#!/usr/bin/env python3 """Universal BIOS installer for retrogaming platforms. Self-contained script using only Python stdlib. Downloads missing BIOS files from the retrobios repository and places them in the correct location for the detected emulator platform. Usage: python install.py python install.py --platform retroarch --dest ~/custom/bios python install.py --check python install.py --list-platforms python install.py --platform retroarch --system sony-playstation --region us """ from __future__ import annotations import argparse import concurrent.futures import errno import hashlib import http.client import io import json import os import platform import re import shlex import shutil import socket import stat import subprocess import sys import tarfile import tempfile import threading import time import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET import zipfile from collections.abc import Callable, Iterator from pathlib import Path, PurePosixPath from typing import BinaryIO, TextIO # Manifests are read from the same ref the bootstrap verified this installer # against, so a file list and the code reading it always come from one commit. # RETROBIOS_REF pins an installation to a tag when reproducibility matters. DEFAULT_RELEASE_REF = "main" RELEASE_REF = os.environ.get("RETROBIOS_REF", DEFAULT_RELEASE_REF) DEFAULT_BASE_URL = ( "https://raw.githubusercontent.com/Abdess/retrobios/" + urllib.parse.quote(RELEASE_REF, safe="") ) _LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1") def _checked_base_url(value: str) -> str: """Refuse a base URL that is neither HTTPS nor loopback. This base serves the manifest and the files it declares, so whoever controls it controls the expected hashes too and verification stops proving anything. install.sh and install.ps1 make the same check on the URL they fetch the installer from. Plain HTTP to loopback stays allowed: nothing sits between the two ends to intercept it, and it is how the installer is exercised end to end. """ parsed = urllib.parse.urlparse(value) if parsed.scheme == "https": return value host = (parsed.hostname or "").lower() if parsed.scheme == "http" and host in _LOOPBACK_HOSTS: return value print( f"Error: RETROBIOS_BASE_URL must use HTTPS, got {value!r}", file=sys.stderr, ) sys.exit(1) BASE_URL = _checked_base_url(os.environ.get("RETROBIOS_BASE_URL", DEFAULT_BASE_URL)) MANIFEST_URL = f"{BASE_URL}/install/{{platform}}.json" TARGETS_URL = f"{BASE_URL}/install/targets/{{platform}}.json" RAW_FILE_URL = f"{BASE_URL}/{{path}}" RELEASE_URL = ( "https://github.com/Abdess/retrobios/releases/download/large-files/{asset}" ) MAX_RETRIES = 3 MAX_MANIFEST_BYTES = 16 * 1024 * 1024 MAX_TARGETS_BYTES = 4 * 1024 * 1024 MAX_MANIFEST_FILES = 100_000 MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024 # A data directory's source archive: PCSX2's source tarball is the largest. MAX_DATA_ARCHIVE_SIZE = 4 * 1024 * 1024 * 1024 MAX_TOTAL_DOWNLOAD_SIZE = 64 * 1024 * 1024 * 1024 # Why a manifest leaves a file out, as the installer words it. The manifest # schema and the generator are held to this list by the tests. OMISSION_REASONS = { "hash_mismatch": "hash mismatch", "not_found": "not found", "external": "external", "user_provided": "user provided", "no_platform_slug": "no platform folder", "below_platform_folder": "below the platform folder", } _SHA1_RE = re.compile(r"^[0-9a-fA-F]{40}$") _SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") # The region vocabulary of scripts/region.py, which this file cannot import. # A test holds the two copies equal. WORLD_REGION = "world" REGION_TREE: dict[str, frozenset] = { "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"}), } REGION_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_REGION, "gb": "uk", } AREA_TERRITORIES: dict[str, frozenset] = { "asia-ntsc": frozenset({"taiwan"}), "asia-pal": frozenset({"china"}), } _REGION_PARENT = { member: parent for parent, members in REGION_TREE.items() for member in members } REGIONS = frozenset({WORLD_REGION} | set(REGION_TREE) | set(_REGION_PARENT)) # Marks a region group named after one file that no system names. FILE_GROUP = ":file:" MAX_SELECTION_TAGS = 1000 SELECTION_FIELDS = ("systems", "regions", "region_groups", "region_system_groups") # The files cores load for systems the platform does not list (game engines, # computers): no system of the platform owns them, and choosing systems # leaves them out unless this name is chosen too. OTHER_SYSTEMS = "other" # Platforms with a manifest in install/. Manifest URLs are case sensitive, # so user input is normalized against this list before any fetch. AVAILABLE_PLATFORMS = ( "retroarch", "batocera", "recalbox", "retrobat", "emudeck", "lakka", "retrodeck", "rocknix", "romm", "bizhawk", "misterfpga", "retropie", ) # The names the frontends give themselves, as each manifest's display_name # spells them (a test holds the two equal). Detection and the platform menu # come before any manifest is fetched, and str.capitalize() wrote Retroarch, # Retrodeck and Misterfpga. PLATFORM_NAMES = { "retroarch": "RetroArch", "batocera": "Batocera", "recalbox": "Recalbox", "retrobat": "RetroBat", "emudeck": "EmuDeck", "lakka": "Lakka", "retrodeck": "RetroDECK", "rocknix": "ROCKNIX", "romm": "RomM", "bizhawk": "BizHawk", "misterfpga": "MiSTer FPGA", "retropie": "RetroPie", } SITE_URL = "https://abdess.github.io/retrobios/" INSTALLER_PAGE = f"{SITE_URL}wiki/installer/" TROUBLESHOOTING_PAGE = f"{SITE_URL}wiki/troubleshooting/" BOOTSTRAP_URL = "https://raw.githubusercontent.com/Abdess/retrobios/main/install" # Fallback BIOS destination per platform when --platform is forced # but auto-detection finds nothing on the machine. DEFAULT_DESTS = { "batocera": Path("/userdata/bios"), "recalbox": Path("/recalbox/share/bios"), "lakka": Path("/storage/system"), "retrodeck": Path.home() / "retrodeck", "emudeck": Path.home() / "Emulation" / "bios", "rocknix": Path("/storage/roms/bios"), "misterfpga": Path("/media/fat/games"), "retropie": Path.home() / "RetroPie" / "BIOS", "romm": Path("/romm/library/bios"), } def _os_default_dests(os_type: str) -> dict[str, Path]: """Where a platform keeps its BIOS on this OS, when it is not detected. The same folders the platform's detection looks for: a platform named on the command line but not found must not land in ~/bios, which none of them reads. """ home = Path.home() if os_type == "windows": return { # RetroArch's system directory defaults to \system # (platform_win32.c), and its installer to C:\RetroArch-Win64. "retroarch": _host_path("C:/RetroArch-Win64/system"), # RetroBat's installer defaults to C:\RetroBat (installer.iss). "retrobat": _host_path("C:/RetroBat/bios"), # EmuDeck's Windows edition: C:\Emulation when its settings name # no folder (emudeck-we functions/all.ps1:12-14). "emudeck": _host_path("C:/Emulation/bios"), } if os_type == "wsl": # RetroArch may be the Linux one inside the distribution, while RetroBat # only exists on the Windows side, reached through /mnt/c. return { "retroarch": home / ".config" / "retroarch" / "system", "retrobat": _host_path("C:/RetroBat/bios"), } if os_type == "darwin": return {"retroarch": _macos_retroarch_base() / "system"} return { "retroarch": home / ".config" / "retroarch" / "system", } # Set when the run is interrupted, so queued downloads end instead of draining # the pool: every future is submitted before the first one completes. _stop = threading.Event() class _Interrupted(Exception): """Raised inside a worker once the run has been stopped.""" # A file this size or larger keeps its partial download when a transfer # stops, and the next run asks the server for the rest: the release assets # run to hundreds of megabytes. Smaller files simply start over. RESUME_FROM_SIZE = 16 * 1024 * 1024 # Read once, before any download thread runs: reading the umask sets it. _UMASK = os.umask(0) os.umask(_UMASK) def _enable_ansi() -> bool: """Whether escape sequences reach the terminal as colour and redraws. Windows 10 consoles interpret them once virtual terminal processing is switched on. Older ones print them raw, so the answer is then no. """ if os.name != "nt": return True try: import ctypes # noqa: PLC0415 kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] handle = kernel32.GetStdHandle(-11) mode = ctypes.c_uint32() if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): return False return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004)) except (AttributeError, OSError): return False class Terminal: """What the output stream can show: a live line, colour, or neither. A pipe or a log gets plain lines, and NO_COLOR and TERM=dumb are honoured. """ def __init__(self, stream: TextIO | None = None, quiet: bool = False) -> None: self.stream = stream or sys.stdout self.quiet = quiet isatty = getattr(self.stream, "isatty", None) self.live = bool(isatty and isatty()) and os.environ.get("TERM") != "dumb" self.colour = ( self.live and "NO_COLOR" not in os.environ and _enable_ansi() ) if not self.colour: self.live = self.live and os.name != "nt" def paint(self, text: str, code: str) -> str: return f"\033[{code}m{text}\033[0m" if self.colour else text def ok(self, text: str) -> str: return self.paint(text, "32") def bad(self, text: str) -> str: return self.paint(text, "31") def dim(self, text: str) -> str: return self.paint(text, "2") def bold(self, text: str) -> str: return self.paint(text, "1") def format_duration(seconds: float) -> str: """0:42, 3:05 or 1:02:09.""" seconds = max(0, int(seconds + 0.5)) hours, rest = divmod(seconds, 3600) minutes, secs = divmod(rest, 60) return f"{hours}:{minutes:02}:{secs:02}" if hours else f"{minutes}:{secs:02}" class Progress: """Bytes and files across the parallel downloads, shown as they move. On a terminal one line redraws in place: a bar, the bytes, the speed and the time left. Elsewhere each finished file gets its line, as a log wants it. Failures are always printed, quiet or not. """ BAR = 24 def __init__(self, total_files: int, total_bytes: int, term: Terminal) -> None: self.total_files = total_files self.total_bytes = max(total_bytes, 1) self.term = term self.files = 0 self.bytes = 0 self.resumed = 0 self.start = time.monotonic() self._drawn = 0.0 self._lock = threading.Lock() def add(self, count: int, resumed: bool = False) -> None: with self._lock: self.bytes += count if resumed: self.resumed += count now = time.monotonic() if self.term.live and not self.term.quiet and now - self._drawn >= 0.1: self._drawn = now self._draw() def forget_resumed(self, count: int) -> None: with self._lock: self.resumed -= count def transferred(self) -> int: """Bytes fetched by this run, a resumed partial copy excluded.""" return self.bytes - self.resumed def done(self, dest: str, ok: bool, reason: str = "") -> None: with self._lock: self.files += 1 line = None if not ok and not self.term.quiet: why = f" ({reason})" if reason else "" line = f" {self.term.bad('FAILED')} {dest}{why}" elif not self.term.live and not self.term.quiet: line = f" [{self.files}/{self.total_files}] {dest} {self.term.ok('ok')}" if line is not None: self._clear() print(line, file=self.term.stream, flush=True) if self.term.live and not self.term.quiet: self._draw() def finish(self) -> None: with self._lock: self._clear() def elapsed(self) -> float: return time.monotonic() - self.start def _clear(self) -> None: if self.term.live and not self.term.quiet: print("\r\033[K", end="", file=self.term.stream, flush=True) def _draw(self) -> None: share = min(self.bytes / self.total_bytes, 1.0) elapsed = self.elapsed() # A resumed partial copy was on disk before the run started: counted # in the share, never in the speed. speed = self.transferred() / elapsed if elapsed > 0 else 0 left = (self.total_bytes - self.bytes) / speed if speed > 0 else 0 parts = [ f"{share * 100:5.1f}%", f"{format_size(self.bytes)} / {format_size(self.total_bytes)}", f"{format_size(int(speed))}/s", f"{format_duration(left)} left", f"({self.files}/{self.total_files} files)", ] # One physical line, whatever the width: \r\033[K clears only the # line the cursor is on, so a wrapped line left a ghost per redraw # on an 80-column console. The bar gives way first, then the least # telling figures. width = shutil.get_terminal_size((80, 24)).columns - 1 while True: tail = " ".join(parts) room = width - len(tail) - 6 if room >= 10 or len(parts) == 1: break parts.pop() if room >= 10: bar_width = min(self.BAR, room) filled = int(share * bar_width) text = f" [{'#' * filled}{'-' * (bar_width - filled)}] {tail}" else: text = f" {tail}" print(f"\r\033[K{text[:width]}", end="", file=self.term.stream, flush=True) # The three RetroArch packages published for Android, newest ABI first. ANDROID_RETROARCH_PACKAGES = ( "com.retroarch.aarch64", "com.retroarch", "com.retroarch.ra32", ) def _is_android() -> bool: """Android reports itself as Linux, so the runtime is named instead. Termux inherits the two variables the Android runtime exports, and /system/build.prop covers a shell that was started without them. """ if os.environ.get("ANDROID_ROOT") and os.environ.get("ANDROID_DATA"): return True return Path("/system/build.prop").exists() def detect_os() -> str: """Return normalized OS identifier. RETROBIOS_OS names the platform outright (linux, wsl, windows, darwin, android), for a run that has to behave like another host: the PowerShell wrapper tests drive a Windows layout on a Linux runner. """ forced = os.environ.get("RETROBIOS_OS", "").strip().lower() if forced in ("linux", "wsl", "windows", "darwin", "android"): return forced system = platform.system().lower() if system == "linux": if _is_android(): return "android" proc_version = Path("/proc/version") if proc_version.exists(): try: content = proc_version.read_text(encoding="utf-8", errors="replace") if "microsoft" in content.lower(): return "wsl" except OSError: pass return "linux" if system == "darwin": return "darwin" if system == "windows": return "windows" return system def _parse_os_release() -> dict[str, str]: """Parse /etc/os-release KEY=value format.""" result: dict[str, str] = {} path = Path("/etc/os-release") if not path.exists(): return result try: for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip() if "=" not in line or line.startswith("#"): continue key, _, value = line.partition("=") value = value.strip('"').strip("'") result[key] = value except OSError: pass return result # Set under WSL: a Windows path read from a config or a rule names a drive # that Linux sees under /mnt (drvfs). C:\\RetroBat\\bios was used as a relative # path and nothing Windows-side was ever found from WSL. _WSL = {"active": False, "root": "/mnt"} _WINDOWS_VARIABLES = ("USERPROFILE", "APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)") def _host_path(text: str) -> Path: """A path as this machine opens it: under WSL, C:\\X is /mnt/c/X.""" match = re.match(r"^([A-Za-z]):(?:[\\/]+(.*))?$", text) if _WSL["active"] else None if match: rest = [part for part in re.split(r"[\\/]", match.group(2) or "") if part] return Path(_WSL["root"], match.group(1).lower(), *rest) return Path(text) def _enter_wsl() -> None: """Read Windows paths through drvfs, and the Windows variables detection needs, which WSL does not pass to Linux (only those named in WSLENV).""" _WSL["active"] = True for variable in _WINDOWS_VARIABLES: if os.environ.get(variable): continue try: # No stdin: cmd.exe inherited the terminal and swallowed the # answer typed at the first prompt. result = subprocess.run( ["cmd.exe", "/c", f"echo %{variable}%"], capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL, cwd="/mnt/c" if Path("/mnt/c").is_dir() else None, ) except (OSError, subprocess.SubprocessError): return value = result.stdout.strip() if value and not value.startswith("%"): os.environ[variable] = str(_host_path(value)) def _expand_retroarch_path(value: str, app_dir: Path) -> Path: """Expand the notations RetroArch writes into its config values. fill_pathname_expand_special (libretro-common/file/file_path.c) maps a leading '~' to the home directory and a leading ':' to the application directory, dropping the two leading characters in both cases. """ if value[:1] == "~": return Path(str(Path.home())) / value[2:] if value[:1] == ":": return app_dir / value[2:] return _host_path(os.path.expandvars(os.path.expanduser(value))) def _retroarch_cfg_dir( cfg_path: Path, key: str, app_dir: Path, default: str ) -> Path | None: """A directory setting of retroarch.cfg, as RetroArch expands it. A value left empty or at `default` is the directory RetroArch falls back on, `default` under app_dir. None when the config is unreadable or does not set the key. """ try: lines = cfg_path.read_text(encoding="utf-8", errors="replace").splitlines() except OSError: return None for line in lines: line = line.strip() name, sep, value = line.partition("=") if sep and name.strip() == key: value = value.strip().strip('"').strip("'") if not value or value == "default": return app_dir / default return _expand_retroarch_path(value, app_dir) return None def _steam_libraries() -> list[Path]: """Every Steam library, the default one first. Steam lists the others in steamapps/libraryfolders.vdf, each as a "path" line. A RetroArch installed on D:/SteamLibrary was not found when only Program Files was searched. """ roots: list[Path] = [] for var in ("ProgramFiles(x86)", "ProgramFiles"): base = os.environ.get(var, "") if base and (Path(base) / "Steam").is_dir(): roots.append(Path(base) / "Steam") libraries = list(roots) for root in roots: vdf = root / "steamapps" / "libraryfolders.vdf" try: text = vdf.read_text(encoding="utf-8", errors="replace") except OSError: continue for raw in re.findall(r'"path"\s+"([^"]+)"', text): library = Path(raw.replace("\\\\", "\\")) if library not in libraries: libraries.append(library) return libraries def _windows_retroarch_dirs() -> list[Path]: """Folders holding retroarch.exe where its installers put it. C:/RetroArch-Win64 by default, C:/RetroArch for 32-bit builds, the same names under Program Files, every Steam library, the Emulators folder of an installed ES-DE (es_find_rules.xml lists all of them), and a Scoop install, user or global. """ candidates = [_host_path("C:/RetroArch-Win64"), _host_path("C:/RetroArch")] for var in ("ProgramFiles", "ProgramFiles(x86)"): root = os.environ.get(var, "") if root: candidates += [Path(root) / "RetroArch-Win64", Path(root) / "RetroArch"] candidates += [ Path(root) / "ES-DE" / "Emulators" / name for name in ("RetroArch-Win64", "RetroArch") ] candidates += [ library / "steamapps" / "common" / "RetroArch" for library in _steam_libraries() ] # Scoop's Extras bucket: \apps\retroarch\current, the root being # %SCOOP% or ~\scoop for a user install, %SCOOP_GLOBAL% or # %ProgramData%\scoop for a global one. Its system folder is persisted # through a junction, so the folder beside retroarch.exe is the real one. scoop_roots = [ os.environ.get("SCOOP") or ( str(Path(os.environ["USERPROFILE"]) / "scoop") if os.environ.get("USERPROFILE") else "" ), os.environ.get("SCOOP_GLOBAL") or ( str(Path(os.environ["ProgramData"]) / "scoop") if os.environ.get("ProgramData") else "" ), ] candidates += [ Path(root) / "apps" / "retroarch" / "current" for root in scoop_roots if root ] return [folder for folder in candidates if (folder / "retroarch.exe").is_file()] def _macos_retroarch_base() -> Path: """Where RetroArch on macOS keeps its system folder by default: ~/Documents/RetroArch, or Application Support/RetroArch for a portable install. The Steam build is always portable (platform_darwin.m, `#if HAVE_STEAM portable = YES`), with no portable.txt to say so: its bundle under Steam's library, and no other RetroArch.app, mark it.""" support = Path.home() / "Library" / "Application Support" / "RetroArch" if (support / "portable.txt").is_file(): return support steam = ( Path.home() / "Library" / "Application Support" / "Steam" / "steamapps" / "common" / "RetroArch" / "RetroArch.app" ) if steam.is_dir() and not Path("/Applications/RetroArch.app").is_dir(): return support return Path.home() / "Documents" / "RetroArch" # System directory -> the retroarch.cfg it was read from and the directory # ':' expands to there, for finding the cores of the same installation. _RETROARCH_CONFIGS: dict[Path, tuple[Path, Path]] = {} def _parse_retroarch_system_dir( cfg_path: Path, app_dir: Path | None = None ) -> Path | None: """Parse system_directory from retroarch.cfg. app_dir is RetroArch's application directory, used to expand ':' values. It defaults to the directory holding the config, which is where a portable install keeps both. """ if not cfg_path.exists(): return None app_dir = app_dir or cfg_path.parent system_dir = _retroarch_cfg_dir(cfg_path, "system_directory", app_dir, "system") if system_dir is not None: _RETROARCH_CONFIGS.setdefault(system_dir, (cfg_path, app_dir)) return system_dir def _shell_unquote(value: str) -> str: """Resolve a shell right-hand side into its effective string value. Handles concatenated quoted/unquoted segments the way bash does (e.g. "/run/media/deck/EmuSD"/Emulation), expands variables in double-quoted and unquoted segments, and stops at unquoted whitespace or a comment. """ parts: list[str] = [] i = 0 n = len(value) while i < n: c = value[i] if c == '"': end = value.find('"', i + 1) if end == -1: parts.append(os.path.expandvars(value[i + 1:])) break parts.append(os.path.expandvars(value[i + 1:end])) i = end + 1 elif c == "'": end = value.find("'", i + 1) if end == -1: parts.append(value[i + 1:]) break parts.append(value[i + 1:end]) i = end + 1 elif c.isspace() or c == "#": break else: end = i while end < n and value[end] not in "\"'#" and not value[end].isspace(): end += 1 parts.append(os.path.expandvars(value[i:end])) i = end return os.path.expanduser("".join(parts)) def _parse_bash_var(path: Path, key: str) -> str | None: """Extract the effective value of key= from a bash/shell file.""" if not path.exists(): return None try: for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip() if line.startswith(f"{key}="): _, _, value = line.partition("=") return _shell_unquote(value) except OSError: pass return None # Where an embedded system names the board it was built for, in the words # of our target lists: batocera-system.mk writes BATOCERA_SYSTEM_ARCH, # recalbox-system.mk RECALBOX_SYSTEM_PLATFORM, ROCKNIX's scripts/image puts # HW_DEVICE in os-release (RK3326, our rk3326). Lakka's scripts/image writes # LIBREELEC_DEVICE when the image is built for a device (RPi4) and always # LIBREELEC_PROJECT (Generic): the first one set is the board. _BOARD_SOURCES: dict[str, tuple[str, str]] = { "batocera": ("file", "/usr/share/batocera/batocera.arch"), "recalbox": ("file", "/recalbox/recalbox.arch"), "rocknix": ("os_release", "HW_DEVICE"), "lakka": ("os_release", "LIBREELEC_DEVICE|LIBREELEC_PROJECT"), } # A derivative installed under a platform's name, with its own board file. # Knulli moves /usr/share/batocera to /usr/share/knulli (knulli-linux # 57afa662, board/scripts/post-build-script.sh:264-278) and writes its board # to knulli.arch (package/system/knulli-system/knulli-system.mk:98-99). Its # packages are its own, so Batocera's per-board core lists do not apply. _DERIVATIVE_BOARDS: dict[str, tuple[str, str]] = { "batocera": ("Knulli", "/usr/share/knulli/knulli.arch"), } def derivative_board(platform: str) -> tuple[str, str] | None: """(derivative, board) when a derivative of the platform names its board.""" name, where = _DERIVATIVE_BOARDS.get(platform, ("", "")) if not where: return None try: board = Path(where).read_text(encoding="utf-8", errors="replace").strip().lower() except OSError: return None return (name, board) if board else None def detect_board(platform: str) -> str | None: """The board this system was built for, or None where it names none.""" kind, where = _BOARD_SOURCES.get(platform, ("", "")) if kind == "file": try: value = Path(where).read_text(encoding="utf-8", errors="replace") except OSError: return None elif kind == "os_release": release = _parse_os_release() value = next((release[key] for key in where.split("|") if release.get(key)), "") else: return None return value.strip().lower() or None def _parse_json_path(path: Path, *keys: str) -> str | None: """Extract a nested string value from a JSON file.""" if not path.exists(): return None try: data = json.loads(path.read_text(encoding="utf-8", errors="replace")) for key in keys: data = data[key] return data if isinstance(data, str) and data else None except (OSError, json.JSONDecodeError, KeyError, TypeError): return None def _parse_ps1_var(path: Path, key: str) -> str | None: """Extract value of $key= or key= from a PowerShell file.""" if not path.exists(): return None normalized = key.lstrip("$") try: raw = path.read_bytes() # Windows PowerShell's Out-File writes UTF-16, Set-Content -Encoding # UTF8 a byte order mark: EmuDeck's settings.ps1 passes through both. encoding = "utf-16" if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else "utf-8-sig" for line in raw.decode(encoding, errors="replace").splitlines(): line = line.strip() check = line.lstrip("$") if check.startswith(f"{normalized}="): _, _, value = check.partition("=") return value.strip('"').strip("'") except OSError: pass return None # Embedded systems, first match wins. The registry publishes the same rules # as each manifest's `detect`, and a test holds the two equal. MiSTer is a # path rule below. _EMBEDDED_RULES: tuple[tuple[str, str, tuple[str, ...], str], ...] = ( # ROCKNIX writes OS_NAME and no ID since 2025 (scripts/image). Older # images wrote ID=rocknix. ("rocknix", "os_release", ("OS_NAME=rocknix", "ID=rocknix"), "/storage/roms/bios"), # Knulli (knulli-linux 57afa662): OS_NAME in os-release # (board/scripts/post-build-script.sh:233-236) and knulli.version # (package/system/knulli-system/knulli-system.mk:98-102). ("batocera", "os_release", ("OS_NAME=knulli",), "/userdata/bios"), ("batocera", "file_exists", ("/usr/share/knulli/knulli.version",), "/userdata/bios"), ("lakka", "os_release", ("ID=lakka",), "/storage/system"), # batocera.linux 38845725, batocera-system.mk:21-22 (the same lines in # batocera-30): batocera.arch and batocera.version. ("batocera", "file_exists", ("/usr/share/batocera/batocera.version",), "/userdata/bios"), ("recalbox", "file_exists", ("/recalbox/recalbox.version",), "/recalbox/share/bios"), ("recalbox", "file_exists", ("/usr/bin/recalbox-settings",), "/recalbox/share/bios"), ("retroarch", "file_exists", ("/opt/muos",), "/run/muos/storage/bios"), ("retroarch", "file_exists", ("/mnt/mmc/MUOS",), "/mnt/mmc/MUOS/bios"), ("retroarch", "file_exists", ("/home/ark", "/opt/system"), "/roms/bios"), ("retroarch", "file_exists", ("/mnt/vendor/bin/dmenu.bin",), "/mnt/mmc/bios"), ) def _detect_embedded() -> list[tuple[str, Path]]: """Check for embedded Linux retrogaming OSes.""" release = {k: v.lower() for k, v in _parse_os_release().items()} for platform_name, method, values, bios in _EMBEDDED_RULES: if method == "os_release": # Any of the KEY=value pairs, the value compared without case. matched = any( release.get(key) == value.lower() for key, _, value in (pair.partition("=") for pair in values) ) else: matched = all(Path(value).exists() for value in values) if matched: return [(platform_name, Path(bios))] return [] # muOS runs RetroArch on the config it copies from its default on first launch # (internal f41f4a89, script/control/retroarch.sh:8-20). The default names # /run/muos/storage/bios, a bind mount of MUOS/bios on the second card when # that card holds one, else on the first (script/device/bind.sh:4,116-150), # and loads cores from /opt/muos/share/core (retroarch.default.cfg:2806,3151). MUOS_ROOT = Path("/opt/muos") MUOS_RETROARCH_CONFIGS = ( MUOS_ROOT / "share" / "info" / "config" / "retroarch.cfg", MUOS_ROOT / "share" / "emulator" / "retroarch" / "retroarch.default.cfg", ) def _muos_system_dir() -> Path | None: """The system directory of muOS's RetroArch, read from its config, which also records the config for finding the installed cores.""" if not MUOS_ROOT.is_dir(): return None for cfg in MUOS_RETROARCH_CONFIGS: system_dir = _parse_retroarch_system_dir(cfg) if system_dir is not None: return system_dir return None def _android_storage_root() -> Path: """Shared storage root, the parent RetroArch builds its defaults from.""" value = os.environ.get("EXTERNAL_STORAGE", "").strip() return Path(value) if value else Path("/storage/emulated/0") def _detect_android() -> list[tuple[str, Path]]: """Find the RetroArch system directory on Android. The config is read from the app external files directory first, then from the internal one (platform_unix.c:1327-1395). A system_directory left at default resolves against /RetroArch, which is also where the directory sits when no config has been written yet (platform_unix.c:2836-2851). """ found: list[tuple[str, Path]] = [] storage = _android_storage_root() default_root = storage / "RetroArch" for package in ANDROID_RETROARCH_PACKAGES: for cfg in ( storage / "Android" / "data" / package / "files" / "retroarch.cfg", Path("/data/data") / package / "files" / "retroarch.cfg", ): system_dir = _parse_retroarch_system_dir(cfg, default_root) if system_dir: found.append(("retroarch", system_dir)) return found if default_root.exists(): found.append(("retroarch", default_root / "system")) return found def _lnk_target(lnk_path: Path, exe_name: str) -> Path | None: """Read the target path of a Windows shortcut. Shortcuts embed the target as a plain local path. Scanning for it avoids parsing the whole .lnk structure for the one field that matters here. """ try: if lnk_path.stat().st_size > 64 * 1024: return None blob = lnk_path.read_bytes() except OSError: return None pattern = re.compile( rb"[A-Za-z]:\\[ -~]{0,260}?" + re.escape(exe_name.encode()), re.IGNORECASE ) match = pattern.search(blob) if not match: return None return Path(match.group().decode("ascii", "replace").replace("\\", "/")) def launchbox_root(os_type: str) -> Path | None: """Locate the LaunchBox installation directory. LaunchBox installs wherever the user points its installer and writes no uninstall registry key, so the Start menu shortcut is the only record of that choice. Everything else falls back to the default location. """ if os_type not in ("windows", "wsl"): return None candidates: list[Path] = [] appdata = os.environ.get("APPDATA", "") if appdata: lnk = ( Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "LaunchBox" / "LaunchBox.lnk" ) if lnk.exists(): target = _lnk_target(lnk, "LaunchBox.exe") if target: # The shortcut points at Core/LaunchBox.exe candidates.append(target.parent.parent) candidates.append(target.parent) userprofile = os.environ.get("USERPROFILE", "") if userprofile: candidates.append(Path(userprofile) / "LaunchBox") for root in candidates: if (root / "Data" / "Emulators.xml").exists(): return root return None def launchbox_emulators(emulators_xml: Path) -> dict[str, Path]: """Map each emulator LaunchBox knows about to its installation directory. LaunchBox stores its emulator list in Data/Emulators.xml. ApplicationPath is either absolute or relative to the LaunchBox root directory. Keys are the lowercase executable names. """ found: dict[str, Path] = {} if not emulators_xml.exists(): return found try: if emulators_xml.stat().st_size > 10 * 1024 * 1024: return found raw = emulators_xml.read_text(encoding="utf-8", errors="replace") # ElementTree expands internal entities, and LaunchBox writes no doctype if " Path | None: """Resolve the system dir of a RetroArch referenced by LaunchBox. Mirrors the plugin's UpdateSystemPath: read system_directory from the retroarch.cfg sitting next to the executable, else assume system/. """ emu_dir = launchbox_emulators(emulators_xml).get("retroarch.exe") if emu_dir is None: return None system_dir = _parse_retroarch_system_dir(emu_dir / "retroarch.cfg", emu_dir) return system_dir or emu_dir / "system" def _pcsx2_bios_dir(emu_dir: Path) -> Path: """Resolve the BIOS folder of a PCSX2 installed under LaunchBox. EmuFolders (pcsx2/Pcsx2Config.cpp) enters portable mode when portable.ini or portable.txt sits next to the executable, and then takes the data root from the contents of portable.txt, relative to the executable. Otherwise the data root is Documents/PCSX2. Bios defaults to "bios" below it and the Bios key of PCSX2.ini overrides that. """ documents = Path(os.environ.get("USERPROFILE", str(Path.home()))) / "Documents" portable_txt = emu_dir / "portable.txt" portable = (emu_dir / "portable.ini").exists() or portable_txt.exists() if portable: subpath = "" try: subpath = portable_txt.read_text( encoding="utf-8", errors="replace" ).strip() except OSError: pass data_root = emu_dir / subpath if subpath else emu_dir else: data_root = documents / "PCSX2" ini = data_root / "inis" / "PCSX2.ini" try: for line in ini.read_text(encoding="utf-8", errors="replace").splitlines(): if line.startswith("Bios = "): value = line[len("Bios = "):].strip() candidate = Path(value) return candidate if candidate.is_absolute() else data_root / value except OSError: pass return data_root / "bios" def _xemu_bios_dir(emu_dir: Path) -> Path: """Resolve the BIOS folder of a xemu managed by LaunchBox. Follows the plugin: xemu.toml next to the executable else the roaming copy, with bootrom_path and flashrom_path naming a file whose directory holds the images. Both default to bios/ under the executable. """ appdata = os.environ.get("APPDATA", "") toml = emu_dir / "xemu.toml" if not toml.exists() and appdata: toml = Path(appdata) / "xemu" / "xemu" / "xemu.toml" if toml.exists(): try: for line in toml.read_text(encoding="utf-8", errors="replace").splitlines(): if line.startswith(("bootrom_path", "flashrom_path")): parts = line.split("'") if len(parts) > 1 and Path(parts[1]).exists(): return Path(parts[1]).parent except OSError: pass return emu_dir / "bios" def launchbox_bios_dirs(root: Path) -> dict[str, Path]: """Map emulator ids to the BIOS directory LaunchBox expects for them. Only emulators whose destination LaunchBox itself computes are listed. The rest keep the default locations declared in the manifest. """ emulators = launchbox_emulators(root / "Data" / "Emulators.xml") dirs: dict[str, Path] = {} emu_dir = emulators.get("pcsx2.exe") if emu_dir: dirs["pcsx2"] = _pcsx2_bios_dir(emu_dir) emu_dir = emulators.get("xemu.exe") if emu_dir: dirs["xemu"] = _xemu_bios_dir(emu_dir) emu_dir = emulators.get("dolphin.exe") if emu_dir and (emu_dir / "portable.txt").exists(): # portable.txt beside the executable moves the user directory to # User (SetUserDirectory in Source/Core/UICommon/UICommon.cpp, # PORTABLE_USER_DIR in Source/Core/Common/CommonPaths.h) dirs["dolphin"] = emu_dir / "User" return dirs def detect_frontends(os_type: str, home: Path | None = None) -> list[str]: """Detect frontends that reference emulators without owning a BIOS dir. ES-DE resolves its application data directory to $ESDE_APPDATA_DIR or /ES-DE (es-core FileSystemUtil.cpp getAppDataDirectory). """ home = home or Path.home() frontends: list[str] = [] esde_env = os.environ.get("ESDE_APPDATA_DIR", "") if (esde_env and Path(esde_env).is_dir()) or (home / "ES-DE").is_dir(): frontends.append("esde") if launchbox_root(os_type) is not None: frontends.append("launchbox") return frontends # The registry's path_exists rules (platforms/_registry.yml install.detect), # one row each: (os, platform, path, BIOS directory when it is not the path). # The manifests publish them too, but detection runs before any manifest is # fetched. A test holds this table equal to the registry. Without it a Linux # home holding RetroPie/BIOS or .config/BizHawk/Firmware detected nothing. _PATH_RULES: tuple[tuple[str, str, str, str | None], ...] = ( ("windows", "retrobat", "C:\\RetroBat\\bios", None), ("windows", "retrobat", "%USERPROFILE%\\RetroBat\\bios", None), ("linux", "emudeck", "$HOME/Emulation/bios", None), ("darwin", "emudeck", "$HOME/Emulation/bios", None), ("linux", "retrodeck", "$HOME/.var/app/net.retrodeck.retrodeck", "$HOME/retrodeck"), ("linux", "romm", "/romm/library/bios", None), # A library laid out as {platform}/bios has no library/bios: RomM's # config folder is the evidence (config_manager.py:264-265). ("linux", "romm", "/romm/config/config.yml", "/romm/library/bios"), ("linux", "retropie", "$HOME/RetroPie/BIOS", None), ("windows", "bizhawk", "%USERPROFILE%\\BizHawk\\Firmware", None), ("linux", "misterfpga", "/media/fat/MiSTer", "/media/fat/games"), ) def _rule_path(template: str) -> Path: """A rule's path on this machine: $HOME or %USERPROFILE%, then the rest.""" bases = ( ("$HOME", Path.home()), ("%USERPROFILE%", Path(os.environ.get("USERPROFILE") or Path.home())), ) for variable, base in bases: if template.startswith(variable): rest = [part for part in re.split(r"[\\/]", template[len(variable):]) if part] return base.joinpath(*rest) return _host_path(template) # The registry's executable rules: (os, platform, glob under $HOME or # %USERPROFILE%, folder beside the executable that holds the BIOS). BizHawk is # a portable archive reading /Firmware (PathExtensions.cs # DataDirectoryPath), wherever it was extracted; ~/.config/BizHawk/Firmware, # the old guess, is a folder no build reads. _EXE_RULES: tuple[tuple[str, str, tuple[str, ...], str], ...] = ( ("linux", "bizhawk", ("$HOME/BizHawk*/EmuHawkMono.sh", "$HOME/*/BizHawk*/EmuHawkMono.sh"), "Firmware"), ("windows", "bizhawk", ("%USERPROFILE%\\BizHawk*\\EmuHawk.exe", "%USERPROFILE%\\*\\BizHawk*\\EmuHawk.exe"), "Firmware"), ) def _detect_by_executable(os_type: str, found: list[tuple[str, Path]]) -> None: """Add a portable install found by its executable, the newest first.""" systems = ("linux", "windows") if os_type == "wsl" else (os_type,) for rule_os, platform_name, patterns, subdir in _EXE_RULES: if rule_os not in systems or any(name == platform_name for name, _ in found): continue hits: list[Path] = [] for pattern in patterns: base, _, rest = pattern.partition("/" if "/" in pattern else "\\") glob = "/".join(part for part in re.split(r"[\\/]", rest) if part) try: hits.extend(path for path in _rule_path(base).glob(glob) if path.is_file()) except OSError: continue if hits: newest = max(hits, key=lambda path: path.stat().st_mtime) found.append((platform_name, newest.parent / subdir)) # The registry's registry rules: (os, platform, key, value, BIOS folder under # the folder the value names). RetroBat.exe writes its own folder there on # every start (retrobat-executable 702da66a, RetroBat/RetroBat/Program.cs:57 # and 99, RegistryTools.cs:15-50), wherever it was installed. The path rules # only knew C:\RetroBat and the profile folder. _REGISTRY_RULES: tuple[tuple[str, str, str, str, str], ...] = ( ("windows", "retrobat", "HKCU\\SOFTWARE\\RetroBat", "LatestKnownInstallPath", "bios"), ) def _read_registry(key: str, value: str) -> str | None: """A string value of the Windows registry, read natively or, under WSL, through reg.exe. None when the key, the value or the means is missing.""" if os.name == "nt": import winreg # noqa: PLC0415 hive, _, subkey = key.partition("\\") roots = {"HKCU": winreg.HKEY_CURRENT_USER, "HKLM": winreg.HKEY_LOCAL_MACHINE} try: with winreg.OpenKey(roots[hive], subkey) as handle: data, _kind = winreg.QueryValueEx(handle, value) except (OSError, KeyError): return None return data.strip() if isinstance(data, str) and data.strip() else None if not _WSL["active"]: return None try: result = subprocess.run( ["reg.exe", "query", key, "/v", value], capture_output=True, text=True, errors="replace", timeout=10, stdin=subprocess.DEVNULL, cwd="/mnt/c" if Path("/mnt/c").is_dir() else None, ) except (OSError, subprocess.SubprocessError): return None match = re.search( rf"^\s*{re.escape(value)}\s+REG_(?:EXPAND_)?SZ\s+(.+?)\s*$", result.stdout, re.M ) return match.group(1) if match else None def _detect_by_registry(os_type: str, found: list[tuple[str, Path]]) -> None: """Add a platform whose own program names its folder in the registry. A folder since moved or removed is passed over, so the path rules still get their turn. """ systems = ("windows",) if os_type in ("windows", "wsl") else () for rule_os, platform_name, key, value, subdir in _REGISTRY_RULES: if rule_os not in systems or any(name == platform_name for name, _ in found): continue folder = _read_registry(key, value) if not folder: continue base = _host_path(folder.rstrip("\\/") or folder) if base.is_dir(): found.append((platform_name, base / subdir)) def _detect_by_path(os_type: str, found: list[tuple[str, Path]]) -> None: """Add what a registry path rule finds, for a platform not found yet.""" systems = ("linux", "windows") if os_type == "wsl" else (os_type,) for rule_os, platform_name, path, bios in _PATH_RULES: if rule_os not in systems or any(name == platform_name for name, _ in found): continue if _rule_path(path).exists(): found.append((platform_name, _rule_path(bios or path))) def detect_platforms(os_type: str) -> list[tuple[str, Path]]: """Detect installed emulator platforms and their BIOS directories.""" found: list[tuple[str, Path]] = [] if os_type == "android": found.extend(_detect_android()) if os_type in ("linux", "wsl"): embedded = _detect_embedded() muos = _muos_system_dir() if embedded and embedded[0][0] == "retroarch" else None found.extend([("retroarch", muos)] if muos else embedded) if os_type in ("linux", "wsl", "darwin"): # EmuDeck on Linux and macOS: functions/all.sh:3-4 and 24 (EmuDeck # dddb3022) reads $emudeckFolder/settings.sh on both, and vars.sh:2 # sets emudeckFolder to $HOME/.config/EmuDeck. emudeck_settings = Path.home() / ".config" / "EmuDeck" / "settings.sh" if emudeck_settings.exists(): emu_path = _parse_bash_var(emudeck_settings, "emulationPath") if emu_path: found.append(("emudeck", Path(emu_path) / "bios")) if os_type in ("linux", "wsl"): home = Path.home() # RetroDECK: retrodeck.json since the cfg-to-json migration, which # renames the old retrodeck.cfg to retrodeck.bak (global.sh:149-153) retrodeck_conf_dir = home / ".var" / "app" / "net.retrodeck.retrodeck" / "config" / "retrodeck" retrodeck_json = retrodeck_conf_dir / "retrodeck.json" retrodeck_cfg = retrodeck_conf_dir / "retrodeck.cfg" if retrodeck_json.exists(): rd_home = _parse_json_path(retrodeck_json, "paths", "rd_home_path") if not rd_home: # A home moved to an SD card lives only in this file: the # default is a guess, and the user is told so. print( f"Warning: could not read paths.rd_home_path from " f"{retrodeck_json}; assuming {home / 'retrodeck'}", file=sys.stderr, ) found.append(("retrodeck", Path(rd_home) if rd_home else home / "retrodeck")) elif retrodeck_cfg.exists(): rd_home = _parse_bash_var(retrodeck_cfg, "rdhome") found.append(("retrodeck", Path(rd_home) if rd_home else home / "retrodeck")) # RetroArch Flatpak flatpak_cfg = home / ".var" / "app" / "org.libretro.RetroArch" / "config" / "retroarch" / "retroarch.cfg" ra_dir = _parse_retroarch_system_dir(flatpak_cfg) if ra_dir: found.append(("retroarch", ra_dir)) # RetroArch Snap snap_cfg = home / "snap" / "retroarch" / "current" / ".config" / "retroarch" / "retroarch.cfg" ra_dir = _parse_retroarch_system_dir(snap_cfg) if ra_dir: found.append(("retroarch", ra_dir)) # RetroArch native native_cfg = home / ".config" / "retroarch" / "retroarch.cfg" ra_dir = _parse_retroarch_system_dir(native_cfg) if ra_dir: found.append(("retroarch", ra_dir)) if os_type == "darwin": # open_default_config_file reads /RetroArch/config # (configuration.c). A value left at default is the documents folder, # or Application Support itself for a portable install # (platform_darwin.m). The config beside it is where older builds # kept it. support = Path.home() / "Library" / "Application Support" / "RetroArch" for mac_cfg in (support / "config" / "retroarch.cfg", support / "retroarch.cfg"): ra_dir = _parse_retroarch_system_dir(mac_cfg, _macos_retroarch_base()) if ra_dir: found.append(("retroarch", ra_dir)) break if os_type in ("windows", "wsl"): emudeck = _emudeck_windows_bios() if emudeck is not None: found.append(("emudeck", emudeck)) # RetroArch Windows: open_default_config_file reads retroarch.cfg # beside the exe, then under %APPDATA%\RetroArch, and a value left at # default is \system (configuration.c, platform_win32.c). appdata = os.environ.get("APPDATA", "") appdata_cfg = Path(appdata) / "RetroArch" / "retroarch.cfg" if appdata else None exe_dirs = _windows_retroarch_dirs() if os_type == "windows" else [] for exe_dir in exe_dirs: ra_dir = _parse_retroarch_system_dir(exe_dir / "retroarch.cfg", exe_dir) if ra_dir is None and appdata_cfg is not None: ra_dir = _parse_retroarch_system_dir(appdata_cfg, exe_dir) found.append(("retroarch", ra_dir or exe_dir / "system")) break if not exe_dirs and appdata_cfg is not None: ra_dir = _parse_retroarch_system_dir(appdata_cfg) if ra_dir: found.append(("retroarch", ra_dir)) # RetroArch from Steam: a portable install under the default library for var in ("ProgramFiles(x86)", "ProgramFiles"): program_files = os.environ.get(var, "") if not program_files: continue steam_cfg = ( Path(program_files) / "Steam" / "steamapps" / "common" / "RetroArch" / "retroarch.cfg" ) ra_dir = _parse_retroarch_system_dir(steam_cfg) if ra_dir and not any(name == "retroarch" for name, _ in found): found.append(("retroarch", ra_dir)) break # The UWP/MSIX package (sideloaded, or Xbox in dev mode) if not any(name == "retroarch" for name, _ in found): uwp = _retroarch_uwp_system_dir() if uwp is not None: found.append(("retroarch", uwp)) # Portable RetroArch referenced by LaunchBox lb_root = launchbox_root(os_type) if lb_root and not any(name == "retroarch" for name, _ in found): system_dir = _launchbox_retroarch_system_dir( lb_root / "Data" / "Emulators.xml" ) if system_dir: found.append(("retroarch", system_dir)) # BizHawk is a portable archive reading \Firmware # (PathExtensions.cs DataDirectoryPath, PathEntryCollection "Firmware"). if lb_root and not any(name == "bizhawk" for name, _ in found): emu_dir = launchbox_emulators(lb_root / "Data" / "Emulators.xml").get("emuhawk.exe") if emu_dir is not None: found.append(("bizhawk", emu_dir / "Firmware")) if os_type in ("linux", "wsl", "darwin"): bizhawk = _bizhawk_unix_firmware() if bizhawk is not None and not any(name == "bizhawk" for name, _ in found): found.append(("bizhawk", bizhawk)) _detect_by_executable(os_type, found) _detect_by_registry(os_type, found) _detect_by_path(os_type, found) return found def _emudeck_windows_bios() -> Path | None: """The BIOS folder of EmuDeck's Windows edition, as its scripts find it. functions/all.ps1:4-14 (emudeck-we 037460ef) loads %APPDATA%\\EmuDeck\\settings.ps1, else %USERPROFILE%\\EmuDeck\\settings.ps1, and takes C:\\Emulation when neither sets $emulationPath. A network install sets it through a drive mapped to $VolName in that session (JSONtoPS1.ps1:166-172), which is the share itself here. """ candidates = [Path(os.environ[var]) / "EmuDeck" / "settings.ps1" for var in ("APPDATA", "USERPROFILE") if os.environ.get(var)] settings = next((path for path in candidates if path.is_file()), None) if settings is None: return None emu_path = _parse_ps1_var(settings, "$emulationPath") if emu_path and ("$" in emu_path or "+" in emu_path): share = _parse_ps1_var(settings, "$VolName") emu_path = f"{share.rstrip(chr(92))}\\Emulation" if share else None return _host_path(emu_path or "C:\\Emulation") / "bios" # Identity Name in pkg/msvc-uwp/RetroArch-msvcUWP/Package.appxmanifest. RETROARCH_UWP_ID = "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8" def _retroarch_uwp_system_dir() -> Path | None: """System directory of the RetroArch UWP package, if one is installed. The package writes into its LocalState folder: uwp_main.cpp makes it the home directory, configuration.c reads retroarch.cfg there, and platform_uwp.c defaults the system directory to ~\\system. A `~` in the config is therefore LocalState, not the user's profile. """ local = os.environ.get("LOCALAPPDATA", "").strip() if not local: return None states = sorted(Path(local, "Packages").glob(f"{RETROARCH_UWP_ID}_*/LocalState")) if not states: return None state = states[0] cfg = state / "retroarch.cfg" try: for line in cfg.read_text(encoding="utf-8", errors="replace").splitlines(): name, sep, value = line.strip().partition("=") if sep and name.strip() == "system_directory": value = value.strip().strip('"').strip("'") if value.startswith("~"): return state / value[1:].lstrip("\\/") break except OSError: return state / "system" return _retroarch_cfg_dir(cfg, "system_directory", state, "system") or state / "system" def _bizhawk_unix_firmware() -> Path | None: """BizHawk's Firmware folder on Unix, where its data home says. PathExtensions.cs reads $BIZHAWK_DATA_HOME before the executable's folder, and the Nix package sets it to $XDG_DATA_HOME/emuhawk-monort- (Dist/launch-scripts.nix). """ data_home = os.environ.get("BIZHAWK_DATA_HOME", "").strip() if data_home and Path(data_home).is_dir(): return Path(data_home) / "Firmware" xdg = os.environ.get("XDG_DATA_HOME", "").strip() or str(Path.home() / ".local" / "share") homes = sorted(Path(xdg).glob("emuhawk-monort-*"), key=lambda p: p.stat().st_mtime) return homes[-1] / "Firmware" if homes else None # A libretro core as RetroArch loads it: _libretro.so|dll|dylib, and # _libretro_android.so on Android. _CORE_FILE_RE = re.compile( r"^(?P.+?)_libretro(?:_android)?\.(?:so|dll|dylib)$", re.IGNORECASE ) CORE_PLATFORMS = ("retroarch", "lakka", "retropie") # Where these systems install cores whatever retroarch.cfg says: RetroPie # gives each package its own folder, Lakka keeps the image's cores and the # ones the online updater fetches apart. _FIXED_CORE_DIRS = { "retropie": (Path("/opt/retropie/libretrocores"),), "lakka": (Path("/usr/lib/libretro"), Path("/storage/cores"), Path("/tmp/cores")), } def core_dirs(plat: str, bios_path: Path) -> list[Path]: """The directories the RetroArch writing to bios_path loads cores from. The config detection read for this system directory names them in libretro_directory. Without one, a retroarch.cfg beside the system directory is the portable layout. Unset, the setting is cores/ under RetroArch's own directory. """ found = _RETROARCH_CONFIGS.get(bios_path) if found is None and (bios_path.parent / "retroarch.cfg").is_file(): found = (bios_path.parent / "retroarch.cfg", bios_path.parent) dirs: list[Path] = [] if found is not None: cfg, app_dir = found dirs.append( _retroarch_cfg_dir(cfg, "libretro_directory", app_dir, "cores") or app_dir / "cores" ) dirs.extend(_FIXED_CORE_DIRS.get(plat, ())) return list(dict.fromkeys(dirs)) def keeps_case(directory: Path) -> bool: """Whether names differing only by case are distinct files there. Probed on the nearest existing directory, since the filesystem decides, not the operating system: an ext4 drive mounted on macOS keeps case and a casefolded ext4 directory folds it. Unprobeable: the system's default. """ probe_dir = directory while not probe_dir.is_dir() and probe_dir != probe_dir.parent: probe_dir = probe_dir.parent try: handle, name = tempfile.mkstemp(prefix=".retrobios-case-", dir=str(probe_dir)) except OSError: return sys.platform not in ("win32", "darwin") os.close(handle) try: twin = os.path.join(os.path.dirname(name), os.path.basename(name).upper()) return not os.path.exists(twin) finally: os.unlink(name) def fold_case_twins(files: list[dict]) -> tuple[list[dict], list[str]]: """One file per name where the directory folds case, the others named. The first in manifest order stays, as the pack's own order places it. """ kept: list[dict] = [] seen: set[str] = set() folded: list[str] = [] for entry in files: key = str(entry.get("dest", "")).casefold() if key in seen: folded.append(str(entry.get("dest", ""))) continue seen.add(key) kept.append(entry) return kept, folded def installed_cores(dirs: list[Path]) -> list[str]: """The cores present in these directories, by the name the buildbot uses. A folder inside is looked into once, which is how RetroPie installs each package. An unreadable directory holds nothing that can be seen. """ names: set[str] = set() for directory in dirs: try: children = list(directory.iterdir()) except OSError: continue for child in children: candidates = [child] if child.is_dir(): try: candidates = list(child.iterdir()) except OSError: continue for path in candidates: match = _CORE_FILE_RE.match(path.name) if match and path.is_file(): names.add(match.group("name")) return sorted(names) def display_name(plat: str, manifest: dict | None = None) -> str: """A platform's name as the frontend writes it, the manifest's when it has one.""" named = (manifest or {}).get("display_name") if isinstance(named, str) and named.strip().isprintable() and 0 < len(named.strip()) <= 40: return named.strip() return PLATFORM_NAMES.get(plat, plat) def _name_key(name: str) -> str: """Letters and digits only: MiSTer FPGA, misterfpga and Mister-FPGA are one name.""" return re.sub(r"[^a-z0-9]", "", name.lower()) # A list this short is printed whole rather than pointed at. SHORT_LIST = 12 def did_you_mean( typed: list[str], known: list[str], listing: str, aliases: dict[str, str] | None = None, ) -> str: """The closest known names to what was typed, and the option listing them all. A typo was answered with every name the platform has, up to 400 on one line, with no hint that playstation is sony-playstation. An alias close to what was typed suggests the name it stands for. """ import difflib # noqa: PLC0415 by_key: dict[str, str] = {} for name in known: by_key.setdefault(_name_key(name), name) for alias, name in (aliases or {}).items(): by_key.setdefault(_name_key(alias), name) close: list[str] = [] for word in typed: key = _name_key(word) if not key: continue matches = difflib.get_close_matches(key, list(by_key), n=3, cutoff=0.6) if len(key) >= 4: matches += sorted(k for k in by_key if key in k and k not in matches) close += [by_key[k] for k in matches if by_key[k] not in close] if close: return f"Did you mean: {', '.join(close[:3])}? {listing} lists all {len(known)}." if len(known) <= SHORT_LIST: return f"Known: {', '.join(known)}." return f"{listing} lists all {len(known)}." def normalize_platform(name: str) -> str: """Return the canonical platform id for user-supplied input. The site writes MiSTer FPGA and RetroArch: letters and digits decide, as they do for verify.py and generate_pack.py. """ key = _name_key(name) for plat in AVAILABLE_PLATFORMS: if key and key in (_name_key(plat), _name_key(display_name(plat))): return plat print(f"Unknown platform '{name.strip()}'. " + did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms"), file=sys.stderr) sys.exit(1) def _read_limited_json( response: http.client.HTTPResponse, limit: int, label: str ) -> object: """Read a bounded UTF-8 JSON response. A body shorter than its Content-Length is a dropped connection, raised as one so the caller retries: parsed, the cut read as an invalid file list and the run ended without the network advice. """ raw_length = response.headers.get("Content-Length") if response.headers else None expected: int | None = None if raw_length: try: expected = int(raw_length) except ValueError: expected = None if expected is not None and expected > limit: raise ValueError(f"{label} exceeds {limit} bytes") try: payload = response.read(limit + 1) except http.client.IncompleteRead as exc: raise ConnectionError( f"{label} cut after {len(exc.partial)} bytes" ) from exc if len(payload) > limit: raise ValueError(f"{label} exceeds {limit} bytes") if expected is not None and len(payload) < expected: raise ConnectionError(f"{label} cut after {len(payload)} of {expected} bytes") try: return json.loads(payload.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValueError(f"invalid {label}: {exc}") from exc def _safe_relative_path(value: object, field: str) -> PurePosixPath: """Validate a manifest-controlled relative POSIX path.""" if not isinstance(value, str) or not value or len(value) > 1024: raise ValueError(f"invalid {field}") if ( "\\" in value or "\x00" in value or "//" in value or value.endswith("/") or re.match(r"^[A-Za-z]:", value) ): raise ValueError(f"unsafe {field}: {value!r}") path = PurePosixPath(value) if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts): raise ValueError(f"unsafe {field}: {value!r}") return path def _within(candidate: Path, root: Path) -> bool: """Whether *candidate* stays under *root* once both are resolved.""" try: (root / candidate.name).resolve().relative_to(root) except (OSError, ValueError): return False return True def _safe_target_dir(value: object, field: str) -> str: """Validate a manifest-controlled standalone-copy directory. These name emulator install directories, so unlike a BIOS destination they are legitimately absolute and outside the BIOS tree. What they must never do is climb: a '..' anywhere turns "copy next to the emulator" into "write wherever the manifest likes". """ if not isinstance(value, str) or not value or len(value) > 2048: raise ValueError(f"invalid {field}") if "\x00" in value: raise ValueError(f"unsafe {field}: {value!r}") parts = re.split(r"[\\/]", value) if any(part == ".." for part in parts): raise ValueError(f"unsafe {field}: {value!r}") return value def _destination_path(root: Path, value: object) -> Path: """Place a manifest destination below *root*. The manifest cannot climb: _safe_relative_path refuses absolute paths, drive letters and '..'. Symbolic links met under the root are the user's own layout and are followed. EmuDeck links bios/shadps4/sys_modules into shadPS4's data directory, and the file has to land where the emulator reads it. """ relative = _safe_relative_path(value, "dest") return root.resolve() / Path(*relative.parts) def _check_tags(entry: dict, field: str, where: str) -> None: """A selection field is absent or a short list of short strings.""" value = entry.get(field) if value is None: return if ( not isinstance(value, list) or len(value) > MAX_SELECTION_TAGS or not all(isinstance(tag, str) and 0 < len(tag) <= 256 for tag in value) ): raise ValueError(f"invalid {field} for {where}") # What an alternative replaces on the entry it stands in for. _COPY_FIELDS = ( "sha1", "sha256", "size", "repo_path", "release_asset", "storage", "cores", "executable", ) def _check_omission(entry: dict, dest: str) -> None: """The fields of a file left out: an omitted entry, or the claim of a core with nothing held at a destination another core's copy serves.""" if not isinstance(entry.get("name"), str) or not entry["name"]: raise ValueError(f"invalid omitted file name for {dest}") if not isinstance(entry.get("system", ""), str): raise ValueError(f"invalid omitted system for {dest}") if not isinstance(entry.get("required"), bool): raise ValueError(f"invalid omitted required flag for {dest}") if entry.get("reason") not in OMISSION_REASONS: raise ValueError(f"invalid omission reason for {dest}") cores = entry.get("cores") if cores is not None and ( not isinstance(cores, list) or not all(isinstance(core, str) for core in cores) ): raise ValueError(f"invalid omitted cores list for {dest}") for field in SELECTION_FIELDS: _check_tags(entry, field, dest) def _check_download(entry: dict, where: str, dest: str) -> int: """The size, hashes, source and cores of one downloadable copy, and its size.""" size = entry.get("size") if isinstance(size, bool) or not isinstance(size, int) or not (0 <= size <= MAX_DOWNLOAD_SIZE): raise ValueError(f"invalid size for {dest}") sha1 = entry.get("sha1", "") sha256 = entry.get("sha256", "") if sha1 and (not isinstance(sha1, str) or not _SHA1_RE.fullmatch(sha1)): raise ValueError(f"invalid SHA1 for {dest}") if sha256 and ( not isinstance(sha256, str) or not _SHA256_RE.fullmatch(sha256) ): raise ValueError(f"invalid SHA256 for {dest}") if not sha1 and not sha256: raise ValueError(f"missing content hash for {dest}") release_asset = entry.get("release_asset") repo_path = entry.get("repo_path") if release_asset: asset = _safe_relative_path(release_asset, f"{where}.release_asset") if len(asset.parts) != 1: raise ValueError(f"release asset must be a basename: {release_asset}") elif repo_path: source = _safe_relative_path(repo_path, f"{where}.repo_path") if source.parts[0] != "bios": raise ValueError(f"repo_path outside bios/: {repo_path}") else: raise ValueError(f"no download source for {dest}") cores = entry.get("cores") if cores is not None and ( not isinstance(cores, list) or not all(isinstance(core, str) for core in cores) ): raise ValueError(f"invalid cores list for {dest}") if not isinstance(entry.get("executable", False), bool): raise ValueError(f"invalid executable flag for {dest}") return size def _check_slot(entry: dict, where: str, dest: str) -> int: """The copies a platform file takes as cores and systems drop, and the largest size.""" choices = entry.get("slot_choices", []) declared = entry.get("declared_choices") if declared is None and not choices: return 0 if ( entry.get("cores") is not None or not isinstance(choices, list) or not isinstance(declared, list) or not declared or len(choices) > MAX_SELECTION_TAGS or len(declared) > MAX_SELECTION_TAGS ): raise ValueError(f"invalid slot choices for {dest}") size = 0 for index, choice in enumerate(declared): systems = choice.get("systems") if isinstance(choice, dict) else None if ( not isinstance(systems, list) or not systems or not all(isinstance(s, str) for s in systems) or not set(choice) <= (set(_COPY_FIELDS) - {"cores"}) | {"systems"} ): raise ValueError(f"invalid declared choice for {dest}") size = max(size, _check_download(choice, f"{where}.declared_choices[{index}]", dest)) for index, choice in enumerate(choices): if ( not isinstance(choice, dict) or not choice.get("cores") or not set(choice) <= set(_COPY_FIELDS) ): raise ValueError(f"invalid slot choice for {dest}") # A choice naming only cores leaves the platform's own answer. if set(choice) != {"cores"}: size = max(size, _check_download( choice, f"{where}.slot_choices[{index}]", dest )) return size def _check_entry( entry: object, where: str, seen_destinations: set[str] ) -> tuple[int, int]: """Check one file entry, return its size and the largest copy it may fetch.""" if not isinstance(entry, dict): raise ValueError(f"{where} must be an object") dest = str(_safe_relative_path(entry.get("dest"), f"{where}.dest")) if dest in seen_destinations: raise ValueError(f"duplicate manifest destination: {dest}") seen_destinations.add(dest) size = _check_download(entry, where, dest) largest = size alternatives = entry.get("alternatives", []) if not isinstance(alternatives, list) or len(alternatives) > MAX_SELECTION_TAGS: raise ValueError(f"invalid alternatives for {dest}") for alt_index, alternative in enumerate(alternatives): if ( not isinstance(alternative, dict) or not alternative.get("cores") or not set(alternative) <= set(_COPY_FIELDS) ): raise ValueError(f"invalid alternative for {dest}") # Narrowing can swap in any copy: the cap holds for the largest. largest = max(largest, _check_download( alternative, f"{where}.alternatives[{alt_index}]", dest )) largest = max(largest, _check_slot(entry, where, dest)) absent = entry.get("absent_claims", []) if not isinstance(absent, list) or len(absent) > MAX_SELECTION_TAGS: raise ValueError(f"invalid absent claims for {dest}") for claim in absent: if not isinstance(claim, dict): raise ValueError(f"invalid absent claim for {dest}") _check_omission(claim, dest) for field in (*SELECTION_FIELDS, "declared_in"): _check_tags(entry, field, dest) return size, largest _SHA1_RE = re.compile(r"^[0-9a-f]{40}$") def _check_data_directory(entry: object, where: str) -> None: """A data directory: its source archive, its mapping, its file list.""" if not isinstance(entry, dict): raise ValueError(f"{where} must be an object") if not isinstance(entry.get("ref"), str) or not entry["ref"]: raise ValueError(f"{where}.ref is missing") if entry.get("dest", "") != "": _safe_relative_path(entry.get("dest"), f"{where}.dest") url = entry.get("url") parsed = urllib.parse.urlparse(url if isinstance(url, str) else "") if not (parsed.scheme == "https" or ( parsed.scheme == "http" and (parsed.hostname or "") in _LOOPBACK_HOSTS )): raise ValueError(f"{where}.url must use HTTPS") kind = entry.get("type") if kind not in ("zip", "tarball"): raise ValueError(f"{where}.type must be zip or tarball") strip = entry.get("strip_components", 0) if not isinstance(strip, int) or isinstance(strip, bool) or not 0 <= strip <= 16: raise ValueError(f"{where}.strip_components is invalid") if kind == "tarball" and ( not isinstance(entry.get("source_path"), str) or not entry["source_path"] ): raise ValueError(f"{where}.source_path is missing") for field in ("exclude", "systems"): values = entry.get(field, []) if not isinstance(values, list) or not all(isinstance(v, str) for v in values): raise ValueError(f"{where}.{field} is invalid") files = entry.get("files") if not isinstance(files, dict) or not files or len(files) > MAX_MANIFEST_FILES: raise ValueError(f"{where}.files is invalid") for rel, meta in files.items(): _safe_relative_path(rel, f"{where}.files") if ( not isinstance(meta, dict) or not isinstance(meta.get("sha1"), str) or not _SHA1_RE.match(meta["sha1"]) or not isinstance(meta.get("size"), int) or not 0 <= meta["size"] <= MAX_DOWNLOAD_SIZE ): raise ValueError(f"{where}.files[{rel!r}] is invalid") def _validate_manifest(data: object, plat: str) -> dict: """Validate the untrusted install-manifest boundary using stdlib only.""" if not isinstance(data, dict): raise ValueError("manifest root must be an object") if data.get("manifest_version") not in (1, 2): raise ValueError("unsupported manifest_version") if data.get("platform") != plat: raise ValueError("manifest platform does not match request") files = data.get("files") if not isinstance(files, list) or len(files) > MAX_MANIFEST_FILES: raise ValueError("invalid manifest files list") seen_destinations: set[str] = set() total_size = 0 largest_total = 0 for index, entry in enumerate(files): size, largest = _check_entry(entry, f"files[{index}]", seen_destinations) total_size += size largest_total += largest if largest_total > MAX_TOTAL_DOWNLOAD_SIZE: raise ValueError("manifest total size exceeds safety limit") variants = data.get("case_variants", []) if not isinstance(variants, list) or len(variants) > MAX_MANIFEST_FILES: raise ValueError("invalid case_variants list") for index, entry in enumerate(variants): _size, largest = _check_entry( entry, f"case_variants[{index}]", seen_destinations ) largest_total += largest if largest_total > MAX_TOTAL_DOWNLOAD_SIZE: raise ValueError("manifest total size exceeds safety limit") data_dirs = data.get("data_directories", []) if not isinstance(data_dirs, list) or len(data_dirs) > MAX_SELECTION_TAGS: raise ValueError("invalid data_directories list") for index, entry in enumerate(data_dirs): _check_data_directory(entry, f"data_directories[{index}]") declared_total_files = data.get("total_files") if declared_total_files is not None and declared_total_files != len(files): raise ValueError("manifest total_files does not match files list") declared_total_size = data.get("total_size") if declared_total_size is not None and declared_total_size != total_size: raise ValueError("manifest total_size does not match file sizes") omitted = data.get("omitted_files", []) if not isinstance(omitted, list) or len(omitted) > MAX_MANIFEST_FILES: raise ValueError("invalid omitted_files list") seen_omitted: set[str] = set() for index, entry in enumerate(omitted): if not isinstance(entry, dict): raise ValueError(f"omitted_files[{index}] must be an object") dest = str( _safe_relative_path( entry.get("dest"), f"omitted_files[{index}].dest" ) ) if dest in seen_destinations or dest in seen_omitted: raise ValueError(f"duplicate or conflicting omitted destination: {dest}") seen_omitted.add(dest) _check_omission(entry, dest) claims = entry.get("alternatives", []) if not isinstance(claims, list) or len(claims) > MAX_SELECTION_TAGS: raise ValueError(f"invalid omitted alternatives for {dest}") for claim in claims: claim_cores = claim.get("cores") if isinstance(claim, dict) else None if ( not isinstance(claim, dict) or set(claim) != {"cores"} or not isinstance(claim_cores, list) or not claim_cores or not all(isinstance(core, str) for core in claim_cores) ): raise ValueError(f"invalid omitted alternative for {dest}") for field in (*SELECTION_FIELDS, "declared_in"): _check_tags(entry, field, dest) declared_total_omitted = data.get("total_omitted") if ( declared_total_omitted is not None and declared_total_omitted != len(omitted) ): raise ValueError("manifest total_omitted does not match omitted_files") for field in ("core_names", "system_cores", "system_names"): mapping = data.get(field) if mapping is None: continue if not isinstance(mapping, dict) or len(mapping) > MAX_MANIFEST_FILES: raise ValueError(f"invalid {field}") for key in mapping: if not isinstance(key, str) or not 0 < len(key) <= 256: raise ValueError(f"invalid {field} key") _check_tags(mapping, key, field) if mapping[key] is None: raise ValueError(f"invalid {field} for {key}") copies = data.get("standalone_copies", []) if not isinstance(copies, list) or len(copies) > 10_000: raise ValueError("invalid standalone_copies") for index, entry in enumerate(copies): if not isinstance(entry, dict): raise ValueError(f"standalone_copies[{index}] must be an object") # The shape do_standalone_copies reads, checked here: a note with a # list for detect, a copy naming neither file nor pattern, or a list # for emulator passed and crashed the copy step after the download. where = f"standalone_copies[{index}]" if "note" in entry: detect = entry.get("detect", {}) if not isinstance(entry["note"], str) or not isinstance(detect, dict): raise ValueError(f"invalid note entry at {where}") for values in detect.values(): if not isinstance(values, list) or not all( isinstance(value, str) for value in values ): raise ValueError(f"invalid note entry at {where}") continue if "file" not in entry and "pattern" not in entry: raise ValueError(f"{where} names neither a file nor a pattern") if not isinstance(entry.get("emulator", ""), str): raise ValueError(f"invalid emulator at {where}") if "file" in entry: _safe_relative_path( entry["file"], f"standalone_copies[{index}].file" ) if "source" in entry: _safe_relative_path( entry["source"], f"standalone_copies[{index}].source" ) if "pattern" in entry: pattern = entry["pattern"] if ( not isinstance(pattern, str) or not pattern or len(pattern) > 256 or "/" in pattern or "\\" in pattern or ".." in pattern ): raise ValueError(f"invalid standalone copy pattern: {pattern!r}") targets = entry.get("targets", {}) if targets and not isinstance(targets, dict): raise ValueError(f"invalid standalone copy targets at index {index}") for values in (targets or {}).values(): if not isinstance(values, list) or len(values) > 100: raise ValueError(f"invalid standalone copy targets at index {index}") for value in values: _safe_target_dir(value, f"standalone_copies[{index}].targets") return data def _validate_targets(data: object) -> dict[str, dict]: """Validate and normalize legacy list-valued target manifests. A null core list means the target publishes no core inventory. That is a known target with no filter, not a broken manifest: rejecting it would discard every other target on the platform. """ if not isinstance(data, dict) or len(data) > 10_000: raise ValueError("invalid targets manifest") normalized: dict[str, dict] = {} for target, value in data.items(): if not isinstance(target, str) or not target or len(target) > 128: raise ValueError("invalid target name") if isinstance(value, dict): cores = value.get("cores") else: cores = value if cores is None: normalized[target] = {"cores": None} continue if not isinstance(cores, list) or len(cores) > 10_000 or not all( isinstance(core, str) and 0 < len(core) <= 256 for core in cores ): raise ValueError(f"invalid core list for target {target}") normalized[target] = {"cores": cores} return normalized NETWORK_ADVICE = (" Check the connection, and HTTPS_PROXY if this network needs a " "proxy, then run the same command again.\n" f" More help: {TROUBLESHOOTING_PAGE}") def _address_lines(url: str) -> list[str]: """The address that failed, and the override that chose it if one did. A RETROBIOS_BASE_URL left from a mirror that is down read as a network fault: neither the host nor the variable was named. """ lines = [f" Address: {url}"] if os.environ.get("RETROBIOS_BASE_URL"): lines.append(" RETROBIOS_BASE_URL is set in this environment and chose that " "address: unset it to read from the project itself.") return lines def _fetch_json(url: str, limit: int, label: str) -> object: """A JSON document, tried MAX_RETRIES times like every file download. A file list fetched once ended the run on one timeout over flaky Wi-Fi. A 4xx answer or an invalid document is final: asking again changes nothing. """ for attempt in range(1, MAX_RETRIES + 1): try: with urllib.request.urlopen(url, timeout=30) as resp: return _read_limited_json(resp, limit, label) except urllib.error.HTTPError as exc: if exc.code < 500 and exc.code != 429 or attempt == MAX_RETRIES: raise delay = _retry_delay(attempt, exc.headers.get("Retry-After") if exc.headers else None) problem = f"HTTP {exc.code}" except (urllib.error.URLError, OSError, http.client.HTTPException) as exc: if attempt == MAX_RETRIES: if isinstance(exc, http.client.HTTPException): raise ConnectionError(f"connection cut: {exc!r}") from exc raise delay = _retry_delay(attempt, None) problem = str(getattr(exc, "reason", None) or exc) # Said, or three 30-second attempts read as a frozen program. print(f" The {label} did not arrive ({problem}), trying again " f"({attempt + 1}/{MAX_RETRIES}) in {delay:.0f} s.", file=sys.stderr) time.sleep(delay) raise AssertionError("unreachable") def fetch_manifest(plat: str) -> dict: """Download platform manifest JSON.""" url = MANIFEST_URL.format(platform=plat) try: return _validate_manifest(_fetch_json(url, MAX_MANIFEST_BYTES, "manifest"), plat) except ValueError as exc: print(f" The file list for {plat} is not valid: {exc}", file=sys.stderr) sys.exit(1) except urllib.error.HTTPError as exc: if exc.code == 404 and os.environ.get("RETROBIOS_BASE_URL"): print(f" No file list for {plat} there.", file=sys.stderr) for line in _address_lines(url): print(line, file=sys.stderr) sys.exit(1) if exc.code == 404: # Asked once, a 4xx being final. The name was checked before any # request, so the revision is what has no such list. print(f" No file list for {plat} at {RELEASE_REF}.", file=sys.stderr) if RELEASE_REF != DEFAULT_RELEASE_REF: print(f" RETROBIOS_REF={RELEASE_REF} names a tag that does not exist or " f"predates {plat}: unset RETROBIOS_REF, or pick a tag from " "https://github.com/Abdess/retrobios/releases.", file=sys.stderr) else: print(" The repository may be moving its files: run the same command " "again in a few minutes.", file=sys.stderr) sys.exit(1) print(f" Could not fetch the file list for {plat}: HTTP {exc.code}", file=sys.stderr) for line in _address_lines(url): print(line, file=sys.stderr) print(NETWORK_ADVICE, file=sys.stderr) sys.exit(1) except (urllib.error.URLError, OSError) as exc: print(f" Could not fetch the file list for {plat} after {MAX_RETRIES} " f"attempts: {getattr(exc, 'reason', None) or exc}", file=sys.stderr) for line in _address_lines(url): print(line, file=sys.stderr) print(NETWORK_ADVICE, file=sys.stderr) sys.exit(1) def fetch_targets(plat: str) -> "dict | None": """Download the target core list. Empty on 404, where the platform publishes no targets. None when the list could not be read: a timeout says nothing about the platform, and answering "no targets" for it sent users to the full pack. """ url = TARGETS_URL.format(platform=plat) try: return _validate_targets(_fetch_json(url, MAX_TARGETS_BYTES, "targets manifest")) except urllib.error.HTTPError as exc: if exc.code == 404: return {} print(f" Error: failed to fetch targets for {plat}: {exc}", file=sys.stderr) for line in _address_lines(url): print(line, file=sys.stderr) print(NETWORK_ADVICE, file=sys.stderr) return None except ValueError as exc: print(f" Error: the target list for {plat} is not valid: {exc}", file=sys.stderr) return None except (urllib.error.URLError, OSError) as exc: print(f" Error: failed to fetch targets for {plat}: " f"{getattr(exc, 'reason', None) or exc}", file=sys.stderr) for line in _address_lines(url): print(line, file=sys.stderr) print(NETWORK_ADVICE, file=sys.stderr) return None def _for_cores(entry: dict, chosen: set[str]) -> dict | None: """The copy of a core file the chosen cores read, or None. A destination holds one file. When several cores read it and disagree on its content, the manifest carries the first core's copy and the others as alternatives, in the order the pack builder tries them: a pack built without the first core carries the next one's copy. """ if chosen.intersection(entry.get("cores") or []): return entry for alternative in entry.get("alternatives", []): if chosen.intersection(alternative["cores"]): kept = { k: v for k, v in entry.items() if k not in _COPY_FIELDS and k not in ("alternatives", "slot_choices", "declared_choices") } kept.update((k, alternative[k]) for k in _COPY_FIELDS if k in alternative) return kept return None def _settle_slot( entry: dict, chosen: set[str], kept_system: Callable[[str], bool] | None = None, ) -> dict: """A platform file as a pack built for the chosen cores carries it. Where a core's own source contradicts the platform and the frontend only checks the path, the pack serves the core's copy: the first of `slot_choices` naming a chosen core decides. Otherwise the platform answers, through the first of `declared_choices` whose systems remain. """ declared = entry.get("declared_choices") if declared is None: return entry copy = next( (c for c in entry.get("slot_choices", []) if chosen.intersection(c["cores"])), None, ) if copy is None or "sha1" not in copy: copy = next( (c for c in declared if kept_system is None or any(kept_system(s) for s in c["systems"])), declared[0], ) kept = { k: v for k, v in entry.items() if k not in _COPY_FIELDS and k not in ("slot_choices", "declared_choices") } kept.update((k, copy[k]) for k in _COPY_FIELDS if k in copy and k != "cores") kept["cores"] = None return kept def _filter_by_target( files: list[dict], target_cores: list[str] ) -> list[dict]: """Keep the platform's files, and the core files the given cores read.""" chosen = set(target_cores) result: list[dict] = [] for f in files: if f.get("cores") is None: result.append(_settle_slot(f, chosen)) continue kept = _for_cores(f, chosen) if kept is not None: result.append(kept) return result def designated_cores(manifest: dict, names: list[str]) -> set[str]: """The cores a list of names designates, the way the pack builder reads it. A target list, or a core found installed, names a core the buildbot's way (`mednafen_psx`), and a file names the profile that reads it (`beetle_psx`). `core_names` gives each profile its other names. """ wanted = set(names) chosen = set(wanted) for profile, aliases in (manifest.get("core_names") or {}).items(): if wanted.intersection(aliases): chosen.add(profile) return chosen def narrow_to_cores( manifest: dict, names: list[str], files: list[dict] | None = None, omitted: list[dict] | None = None, ) -> tuple[list[dict], list[dict]]: """Keep what the named cores read, as a pack built for them carries. A file a core loads stays when that core is named. A file of the platform's own list stays when one of its systems does: a system goes when none of the cores `system_cores` gives it is named, and a system it does not list stays whatever is named. A dropped system's file gives way to the first named core claiming its destination. A manifest without the two maps keeps the platform's list whole and matches cores by name. """ cores = designated_cores(manifest, names) reach = manifest.get("system_cores") or {} def kept_system(system: str) -> bool: return system not in reach or bool(cores.intersection(reach[system])) displaced: list[dict] = [] def kept(entries: list[dict]) -> list[dict]: result: list[dict] = [] for entry in entries: if entry.get("cores") is not None: copy = _for_cores(entry, cores) if copy is not None: result.append(copy) continue # None of the cores served here is named: a named core that # wants the slot and has nothing held is a file left out. claim = next( (c for c in entry.get("absent_claims", []) if cores.intersection(c.get("cores") or [])), None, ) if claim is not None: displaced.append({"dest": entry["dest"], **claim}) continue # The systems declaring the file, where they are fewer than the # ones reading it. systems = entry.get("declared_in") or entry_systems(entry) if not systems or any(kept_system(s) for s in systems): result.append(_settle_slot(entry, cores, kept_system)) continue # The system is dropped, which frees its destination for the # first named core claiming it, as in a pack built for them. copy = _for_cores(entry, cores) if copy is not None: result.append(copy) return result files = manifest.get("files", []) if files is None else files omitted = manifest.get("omitted_files", []) if omitted is None else omitted kept_files = kept(files) return kept_files, kept(omitted) + displaced def canonical_region(raw: str) -> str: """One region name or alias, spelled the way manifests spell it.""" key = raw.strip().lower() key = REGION_ALIASES.get(key, key) if key not in REGIONS: import difflib # noqa: PLC0415 close = difflib.get_close_matches(key, [*REGION_ALIASES, *REGIONS], n=3, cutoff=0.5) hint = f" Did you mean: {', '.join(close)}?" if close else "" raise ValueError( f"unknown region: {raw.strip()}.{hint} " f"Short forms: {', '.join(REGION_ALIASES)}; regions: {', '.join(sorted(REGIONS))}" ) return key def _region_comparable(a: str, b: str) -> bool: return ( a == b or _REGION_PARENT.get(a) == b or _REGION_PARENT.get(b) == a or a in AREA_TERRITORIES.get(b, ()) or b in AREA_TERRITORIES.get(a, ()) ) def _region_rank(file_regions: set[str], requested: list[str]) -> int: for index, wanted in enumerate(requested): if any(_region_comparable(wanted, region) for region in file_regions): return index return len(requested) def region_drops( entries: list[dict], requested: list[str], systems: list[str] | None = None, held: set[str] | None = None, ) -> set[str]: """Destinations a region priority list withdraws. The pack builder's rule (region.resolve_region_drops), replayed over the groups the manifest records. Per group, the best-ranked regional file stays. A world file beats unmatched regional ones the code does not search before it (`priority`, lower first). When nothing matches and no world file exists, every regional file stays, so a group is never emptied. A file with no region never competes and always stays. A core extra competes whatever systems are chosen, while a platform's own declaration competes only when its system is kept, as in the pack. ``held`` names the destinations the manifest can install. A match it lacks never displaces a held file: the American BIOS stays under --region japan when the Japanese one is missing. A group named after a single file (FILE_GROUP) competes with nothing and keeps nothing. """ if not requested: return set() kept_systems = set(systems) if systems else None groups: dict[str, list[tuple[set, str, object]]] = {} for entry in entries: regions = entry.get("regions") if not regions: continue joined = list(entry.get("region_groups") or []) joined += [ group for group in entry.get("region_system_groups") or [] if kept_systems is None or group in kept_systems ] priority = entry.get("priority") if not isinstance(priority, int) or isinstance(priority, bool): priority = None for group in joined: groups.setdefault(group, []).append((set(regions), entry["dest"], priority)) keep: set[str] = set() drop: set[str] = set() for group_id, members in groups.items(): if FILE_GROUP in group_id: continue world = {dest for regions, dest, _p in members if WORLD_REGION in regions} ranks = {dest: priority for _regions, dest, priority in members} regional = [ (_region_rank(regions, requested), dest) for regions, dest, _p in members if WORLD_REGION not in regions ] keep |= world if not regional: continue matched = [(rank, dest) for rank, dest in regional if rank < len(requested)] winners, rivals = matched, world if held is not None: winners = [(rank, dest) for rank, dest in matched if dest in held] rivals = world & held if winners: best = min(rank for rank, _dest in winners) keep |= { dest for rank, dest in matched if rank == best or (rank < best and dest not in (held or ())) } drop |= {dest for _rank, dest in regional if dest not in keep} elif rivals: world_ranks = [ranks[dest] for dest in rivals] best_world = min(world_ranks) if None not in world_ranks else None for _rank, dest in regional: ours = ranks[dest] if best_world is not None and ours is not None and ours < best_world: keep.add(dest) else: drop.add(dest) else: keep |= {dest for _rank, dest in regional} return drop - keep def entry_systems(entry: dict) -> list[str]: """The platform systems a file or an omission belongs to. An omission's `system` is spelled the way its profile spells it. Only `systems` names the platform's own, the ones --system accepts. """ return list(entry.get("systems") or []) def narrow( files: list[dict], omitted: list[dict], systems: list[str], cores: list[str], regions: list[str], ) -> tuple[list[dict], list[dict]]: """Keep what the chosen systems, cores and regions need. A core choice keeps the platform's own list and narrows only the files cores load, the rule --target already follows. Regions are then decided the way the pack builder decides them under --system: over the chosen systems' declarations and every core extra. The system filter comes last. """ if cores: files = _filter_by_target(files, cores) omitted = _filter_by_target(omitted, cores) if regions: # The builder's held set: what the collection serves. An omission # answers the request but takes no installable file's place. held = {f["dest"] for f in files} dropped = region_drops(files + omitted, regions, systems, held) files = [f for f in files if f["dest"] not in dropped] omitted = [o for o in omitted if o["dest"] not in dropped] if systems: wanted = set(systems) files = [f for f in files if _chosen(f, wanted)] omitted = [o for o in omitted if _chosen(o, wanted)] return files, omitted def _chosen(entry: dict, wanted: set[str]) -> bool: systems = entry_systems(entry) if not systems: return OTHER_SYSTEMS in wanted return bool(wanted & set(systems)) def records_selection(files: list[dict]) -> bool: """Whether a manifest carries what narrowing reads. Older ones do not.""" return any("systems" in entry for entry in files) def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[str]]: """The systems, cores and regions a manifest lets one narrow to.""" systems = {s for e in files + omitted for s in entry_systems(e)} ordered = sorted(systems) if any(not entry_systems(f) for f in files): ordered.append(OTHER_SYSTEMS) cores = { c for f in files for copy in [f, *f.get("alternatives", []), *f.get("slot_choices", [])] for c in (copy.get("cores") or []) } regions = { r for e in files + omitted for r in (e.get("regions") or []) if r != WORLD_REGION } return { "systems": ordered, "cores": sorted(cores), "regions": sorted(regions), } def resolve_choices( values: list[str], known: list[str], label: str, aliases: dict[str, str] | None = None, ) -> list[str]: """Names given on the command line, matched without regard to case. Each value is one name or a comma-separated list. A whole value that is itself a name wins over splitting it: Recalbox names one system `msx1,msx2,msxturbor`. An unknown name is refused, never ignored, since carrying on would install something other than what was asked. aliases maps another name of a known one to it, as Batocera's `psx` stands for sony-playstation. A known name always wins over an alias. """ aliases = {alias: name for alias, name in (aliases or {}).items() if name in known} by_lower = {name.lower(): name for name in known} for alias, name in aliases.items(): by_lower.setdefault(alias.lower(), name) # Spelled as the site writes it (Sony PlayStation), a name is still one # name when no other one reads the same once case and spacing go. by_key: dict[str, str | None] = {} for name in known: key = _name_key(name) by_key[key] = None if key in by_key else name for alias, name in aliases.items(): by_key.setdefault(_name_key(alias), name) chosen: list[str] = [] unknown: list[str] = [] for value in values: value = value.strip() tokens = [value] if value.lower() in by_lower else value.split(",") for token in (t.strip() for t in tokens): if not token: continue name = by_lower.get(token.lower()) or by_key.get(_name_key(token)) if name is None: unknown.append(token) elif name not in chosen: chosen.append(name) if unknown: if not known: raise ValueError(f"unknown {label}: {', '.join(unknown)}. This list records none.") listing = {"system": "--list-systems", "emulator": "--list-emulators"}.get( label, "--list-cores") raise ValueError( f"unknown {label}: {', '.join(unknown)}. " f"{did_you_mean(unknown, known, listing, aliases)}" ) if values and not chosen: raise ValueError(f"--{label} needs at least one name") return chosen def resolve_regions(values: list[str]) -> list[str]: """A region priority list, best first, from names or aliases.""" chosen: list[str] = [] for value in values: for token in value.split(","): if token.strip(): region = canonical_region(token) if region not in chosen: chosen.append(region) if values and not chosen: raise ValueError("--region needs at least one region") return chosen def parse_selection(text: str, count: int, ordered: bool = False) -> list[int]: """Numbers typed at a prompt: `3`, `1,4`, `2-5`, or `all`, 1-based. Returned sorted, or in the typed order when the order is a priority. """ if text.strip().lower() in ("all", "*"): return list(range(1, count + 1)) chosen: list[int] = [] for token in (t.strip() for t in text.split(",")): if not token: continue low, sep, high = token.partition("-") try: start = int(low) end = int(high) if sep else start except ValueError: raise ValueError(f"not a number or range: {token}") from None if start > end or start < 1 or end > count: raise ValueError(f"out of range: {token} (1-{count})") for number in range(start, end + 1): if number not in chosen: chosen.append(number) return chosen if ordered else sorted(chosen) def _digest_file(path: Path, algorithms: tuple[str, ...]) -> dict[str, str]: """Compute several digests of a file in a single read. Checking a 3 GB collection against both a SHA-1 and a SHA-256 used to walk every file twice, single threaded. Hashing is cheap next to the I/O, so the read is what has to happen once. """ with open(path, "rb") as fh: return _digest_handle(fh, algorithms) def _digest_handle(fh: BinaryIO, algorithms: tuple[str, ...]) -> dict[str, str]: """Digests of an open file, read from its start.""" hashers = {name: hashlib.new(name) for name in algorithms} fh.seek(0) for chunk in iter(lambda: fh.read(1024 * 1024), b""): for hasher in hashers.values(): hasher.update(chunk) return {name: hasher.hexdigest() for name, hasher in hashers.items()} def _sha1_file(path: Path) -> str: """Compute SHA1 of a file.""" return _digest_file(path, ("sha1",))["sha1"] def _sha256_file(path: Path) -> str: """Compute SHA256 of a file.""" return _digest_file(path, ("sha256",))["sha256"] def _classify_local(entry: dict, dest: Path) -> str: """Return 'up_to_date' or 'mismatched' for a file already on disk.""" expected = { name: entry.get(name, "").lower() for name in ("sha256", "sha1") if entry.get(name) } if not expected: return "up_to_date" actual = _digest_file(dest, tuple(expected)) if all(actual[name] == value for name, value in expected.items()): return "up_to_date" return "mismatched" def check_local( files: list[dict], bios_path: Path, jobs: int = 8, term: "Terminal | None" = None, ) -> tuple[list[dict], list[dict], list[dict]]: """Check which files exist locally and have correct hashes. Returns (to_download, up_to_date, mismatched). With a terminal, a count moves while the files are read: 3.9 GB on an SD card went by in minutes of silence, read as a hung run and stopped with Ctrl+C. """ to_download: list[dict] = [] present: list[tuple[dict, Path]] = [] for f in files: dest = _destination_path(bios_path, f["dest"]) if dest.exists(): present.append((f, dest)) else: to_download.append(f) up_to_date: list[dict] = [] mismatched: list[dict] = [] if present: with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool: verdicts = pool.map( lambda item: (item[0], _classify_local(item[0], item[1])), present ) live = term is not None and term.live and not term.quiet last = 0.0 for done, (entry, verdict) in enumerate(verdicts, 1): if verdict == "up_to_date": up_to_date.append(entry) else: mismatched.append(entry) now = time.monotonic() if live and (now - last >= 0.1 or done == len(present)): last = now print(f"\r read {done}/{len(present)}", end="", file=term.stream, flush=True) if live: print("\r\033[K", end="", file=term.stream, flush=True) return to_download, up_to_date, mismatched def _download_one( f: dict, bios_path: Path, verbose: bool = False, progress: Progress | None = None, ) -> tuple[str, bool, str]: """Download a single file. Returns (dest, success, reason it failed).""" if _stop.is_set(): return str(f.get("dest", "?")), False, "stopped" try: dest = _destination_path(bios_path, f["dest"]) except ValueError: return str(f.get("dest", "?")), False, "unsafe destination" try: dest.parent.mkdir(parents=True, exist_ok=True) except OSError as exc: if _write_refused(exc): return f["dest"], False, NOT_WRITABLE return f["dest"], False, exc.strerror or str(exc) if f.get("release_asset"): url = RELEASE_URL.format(asset=urllib.parse.quote(f["release_asset"], safe="")) else: url = RAW_FILE_URL.format(path=urllib.parse.quote(f["repo_path"], safe="/")) expected_size = f["size"] # A large file keeps a fixed partial name so the next attempt, or the # next run, asks only for what is missing. resumable = expected_size >= RESUME_FROM_SIZE reason = "download failed" refused = False full_partial = False for attempt in range(1, MAX_RETRIES + 1): tmp_path: Path | None = None counted = 0 resumed_count = 0 partial = None try: offset = 0 if resumable: tmp_path = dest.parent / f".{dest.name}.part" _partial_size(tmp_path) # Two runs into one BIOS folder used to append to the same # partial copy: the one that finds it locked downloads into # a private file instead. partial = _claim_partial(tmp_path) if partial is None: tmp_path = None else: offset = os.fstat(partial.fileno()).st_size if offset >= expected_size and _NO_RENAME_WHILE_OPEN: # The run that verified this copy releases its lock # to rename it: truncating it there destroyed that # copy and failed the rename. It is left alone and # this run writes a private file. partial.close() partial = None tmp_path = None offset = 0 full_partial = True elif offset >= expected_size: partial.truncate(0) offset = 0 request = urllib.request.Request(url) if offset: request.add_header("Range", f"bytes={offset}-") with urllib.request.urlopen(request, timeout=60) as resp: if offset and getattr(resp, "status", 200) != 206: offset = 0 # the server sent the whole file again partial.truncate(0) raw_length = resp.headers.get("Content-Length") if resp.headers else None if raw_length and int(raw_length) != expected_size - offset: raise ValueError( f"Content-Length {raw_length} != expected {expected_size - offset}" ) if partial is not None: partial.seek(offset) out = _Unclosed(partial) else: out = tempfile.NamedTemporaryFile( mode="wb", dir=dest.parent, prefix=f".{dest.name}.", suffix=".part", delete=False, ) tmp_path = Path(out.name) with out: downloaded = offset if progress is not None and offset: progress.add(offset, resumed=True) counted += offset resumed_count = offset while True: if _stop.is_set(): raise _Interrupted() chunk = resp.read(1024 * 1024) if not chunk: break downloaded += len(chunk) if downloaded > expected_size or downloaded > MAX_DOWNLOAD_SIZE: raise ValueError("download exceeded declared size") out.write(chunk) if progress is not None: progress.add(len(chunk)) counted += len(chunk) if downloaded != expected_size: raise ValueError( f"downloaded {downloaded} bytes, expected {expected_size}" ) expected = { name: f[name].lower() for name in ("sha256", "sha1") if f.get(name) } # A large file is hashed through the handle that holds its lock. # On Windows that lock is mandatory: reopening the path to read # it failed with a sharing violation, which read as an unwritable # folder for every file of 16 MB or more (issue 82). if partial is not None: actual = _digest_handle(partial, tuple(expected)) else: actual = _digest_file(tmp_path, tuple(expected)) if "sha256" in expected and actual["sha256"] != expected["sha256"]: reason = "SHA-256 mismatch" raise _Corrupt() if "sha1" in expected and actual["sha1"] != expected["sha1"]: reason = "SHA-1 mismatch" raise _Corrupt() # The mode a plain open() gives, executable where the repository # file is: the temporary file is private, and a BIOS folder # written with sudo has to stay readable by the emulator's user. os.chmod(tmp_path, (0o777 if f.get("executable") else 0o666) & ~_UMASK) _clear_read_only(dest) if partial is not None and _NO_RENAME_WHILE_OPEN: # Windows renames no open file. Elsewhere the lock is held # until the copy has its final name. partial.close() _replace_released(tmp_path, dest, expected) else: os.replace(tmp_path, dest) if full_partial: _drop_full_partial(dest, expected_size) return f["dest"], True, "" except _Interrupted: # Stopped mid-transfer: a large file keeps its partial copy for # the next run, and a small one is dropped rather than left beside # the destination. if tmp_path is not None and partial is None and tmp_path.exists(): tmp_path.unlink() return f["dest"], False, "stopped" except _Corrupt: if verbose: print(f" {reason} on attempt {attempt}", file=sys.stderr) # Closed before the delete: Windows refuses to remove a file # still open (WinError 32), and the error ended the run. if partial is not None: partial.close() if tmp_path is not None and tmp_path.exists(): tmp_path.unlink() delay = 0.0 except urllib.error.HTTPError as exc: reason = f"HTTP {exc.code}" if exc.code in (403, 429): reason += " (GitHub rate limit or access refused)" if exc.code >= 500 or exc.code == 429: reason = NetworkReason(reason) if verbose: print(f" Attempt {attempt} failed: {exc}", file=sys.stderr) if tmp_path is not None and partial is None and tmp_path.exists(): tmp_path.unlink() if exc.code == 416 and tmp_path is not None and tmp_path.exists(): if partial is not None: partial.close() tmp_path.unlink() # the partial copy no longer fits the file delay = _retry_delay(attempt, exc.headers.get("Retry-After") if exc.headers else None) # http.client raises IncompleteRead (an HTTPException, not an # OSError) when a chunked transfer is cut, as a proxy re-encoding # the stream does: it is a dropped connection like any other. except (urllib.error.URLError, OSError, ValueError, http.client.HTTPException) as exc: if (isinstance(exc, OSError) and not isinstance(exc, urllib.error.URLError) and _write_refused(exc)): # No attempt can change a refused write. A read-only file in # place is not a folder this user cannot write. reason = READ_ONLY if _read_only_file(dest) else NOT_WRITABLE refused = True elif _transfer_failure(exc): reason = NetworkReason(getattr(exc, "reason", "") or exc) else: reason = str(getattr(exc, "reason", "") or exc) if verbose: print(f" Attempt {attempt} failed: {exc}", file=sys.stderr) if not refused and tmp_path is not None and partial is None and tmp_path.exists(): tmp_path.unlink() delay = _retry_delay(attempt, None) finally: if partial is not None: partial.close() if progress is not None and counted: progress.add(-counted) if resumed_count: progress.forget_resumed(resumed_count) if refused: return f["dest"], False, reason if attempt < MAX_RETRIES and delay and _stop.wait(delay): return f["dest"], False, "stopped" return f["dest"], False, reason # Windows renames no file another handle holds open, and Python opens files # without FILE_SHARE_DELETE: a locked partial copy is closed before its rename. _NO_RENAME_WHILE_OPEN = os.name == "nt" # Seconds a closed partial copy waits for a sharing violation to clear: a # second run closing it again, or a scanner reading the new file. RELEASE_WAIT = 30.0 _SHARING_VIOLATIONS = (32, 33) def _replace_released(source: Path, dest: Path, expected: dict[str, str]) -> None: """Rename a verified copy whose lock was just released. Another run may open the copy in that moment, which refuses the rename with a sharing violation until it lets go. When the copy is gone, that run placed the same file and removed it: the destination is checked against the manifest's hashes before it counts as installed. """ deadline = time.monotonic() + RELEASE_WAIT while True: try: os.replace(source, dest) return except FileNotFoundError: if expected and dest.is_file() and _digest_file(dest, tuple(expected)) == expected: return raise except PermissionError as exc: if (getattr(exc, "winerror", None) not in _SHARING_VIOLATIONS or time.monotonic() >= deadline): raise time.sleep(0.2) def _drop_full_partial(dest: Path, expected_size: int) -> None: """Remove a complete partial copy this run left alone. A copy at full size that nothing holds is what a run stopped between its check and its rename leaves behind. The destination now holds the verified file, so the copy is only clutter. One another run holds is left to it. """ path = dest.parent / f".{dest.name}.part" if not path.is_file(): return try: handle = _claim_partial(path) except OSError: return if handle is None: return with handle: complete = os.fstat(handle.fileno()).st_size >= expected_size if complete: try: path.unlink() except OSError: pass # another run opened it meanwhile and finishes with it else: try: if path.stat().st_size == 0: path.unlink() except OSError: pass # created by the claim and taken since by another run def _partial_size(path: Path) -> int: """Bytes already held by a partial copy. Anything but a plain file is dropped.""" try: info = path.lstat() except FileNotFoundError: return 0 if stat.S_ISREG(info.st_mode): return info.st_size path.unlink() return 0 def _claim_partial(path: Path) -> BinaryIO | None: """The partial copy, opened and locked for this run, or None when another run holds it. A link planted at its name is not followed.""" flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0) handle = os.fdopen(os.open(path, flags, 0o644), "r+b") try: if os.name == "nt": import msvcrt msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) else: import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: handle.close() return None # Opened and locked are two steps: in between, the run that held the # partial may have renamed it into place and released it. The lock then # sits on the installed BIOS, which looks like a finished partial and # would be truncated. Only the file still under the partial's name is ours. try: ours = os.path.samestat(os.fstat(handle.fileno()), os.lstat(path)) except OSError: ours = False if not ours: handle.close() return None return handle class _Unclosed: """The locked partial copy, written through without closing it.""" def __init__(self, handle: BinaryIO) -> None: self._handle = handle def __enter__(self) -> BinaryIO: return self._handle def __exit__(self, *_exc: object) -> None: self._handle.flush() def write(self, data: bytes) -> int: return self._handle.write(data) class _Corrupt(Exception): """The bytes arrived but are not the file the manifest names.""" def _retry_delay(attempt: int, retry_after: str | None) -> float: """Seconds before the next attempt: what the server asks, else 1, 2, 4.""" if retry_after: try: return min(max(float(retry_after), 0.0), 60.0) except ValueError: pass return float(min(2 ** (attempt - 1), 8)) def _locations(paths: list[Path]) -> str: """Where the run wrote, naming every destination when it served several.""" return ", ".join(str(p) for p in paths) if paths else "nowhere" # A write refused by the file system: retrying cannot change it, and the # summary names the two ways out. NOT_WRITABLE = "folder not writable" def _write_refused(exc: OSError) -> bool: # A sharing violation is a file another process holds open, which a # later attempt can find released: no refusal of the folder. if getattr(exc, "winerror", None) in _SHARING_VIOLATIONS: return False return isinstance(exc, PermissionError) or exc.errno == errno.EROFS # A file already at the destination, marked read-only, whose content differs, # and whose attribute could not be cleared: replacing it is refused while its # folder stays writable. READ_ONLY = "file in place is read-only" def _clear_read_only(dest: Path) -> None: """Let the verified copy replace a read-only file the user already has. Windows refuses to replace a file carrying the read-only attribute (WinError 5) even in a writable folder. The file is about to be replaced by the copy the manifest names, so its attribute is cleared first. Elsewhere a rename only needs the folder to be writable. """ if os.name != "nt": return try: if dest.is_file() and not os.access(dest, os.W_OK): os.chmod(dest, stat.S_IREAD | stat.S_IWRITE) except OSError: pass # the replace that follows reports the refusal with its reason def _read_only_file(dest: Path) -> bool: return dest.is_file() and not os.access(dest, os.W_OK) and _folder_writable(dest.parent) def _folder_writable(path: Path) -> bool: """Whether a file can be created under path, read from its nearest existing folder: a BIOS folder not created yet is as writable as the folder it would go in.""" probe = path while not probe.exists() and probe.parent != probe: probe = probe.parent return os.access(probe, os.W_OK) # Options that take a value, as a hint gives them back. _VALUED_OPTIONS = ("--system", "--core", "--emulator", "--region", "--target", "--cores-dir") class Invocation: """How this run was started, so that a hint gives the command back whole. A one-liner user has no install.py to run: install.sh and install.ps1 run a temporary copy and say so in RETROBIOS_BOOTSTRAP. And a hint that dropped --system turned a retry of 2 files into one of 8069. """ def __init__(self) -> None: self.os_type = "" self.options: list[tuple[str, str | None]] = [] # Per platform, what was chosen at the questions. self.chosen: dict[str, list[tuple[str, str | None]]] = {} def record(self, args: argparse.Namespace) -> None: """Keep every option that decides what a run installs or reports.""" options: list[tuple[str, str | None]] = [] for flag in _VALUED_OPTIONS: value = getattr(args, flag[2:].replace("-", "_"), None) values = value if isinstance(value, list) else [value] if value else [] options += [(flag, str(v)) for v in values] for flag, on in ( ("--every-board", args.every_board), ("--installed-cores", args.installed_cores and not args.cores_dir), ("--standalone-copies", args.standalone_copies), ("--check", args.check), ("--dry-run", args.dry_run), ): if on: options.append((flag, None)) self.options = options def powershell(self) -> bool: return os.environ.get("RETROBIOS_BOOTSTRAP") == "ps1" or self.os_type == "windows" def launcher(self, elevated: bool = False) -> str: """The command without its options, as the user typed it.""" bootstrap = os.environ.get("RETROBIOS_BOOTSTRAP", "") if bootstrap == "ps1": return f"&([scriptblock]::Create((irm {BOOTSTRAP_URL}.ps1)))" if bootstrap == "sh": return f"curl -fsSL {BOOTSTRAP_URL}.sh | {'sudo ' if elevated else ''}sh -s --" python = "python" if self.os_type == "windows" else "python3" return f"{'sudo ' if elevated else ''}{python} install.py" def word(self, value: str) -> str: if self.powershell(): # A comma makes an array and @ a splat in PowerShell arguments. if re.fullmatch(r"[A-Za-z0-9_./:\\-]+", value): return value return "'" + value.replace("'", "''") + "'" return shlex.quote(value) def command( self, plat: str, dest: Path | str | None, elevated: bool = False, without: tuple[str, ...] = (), ) -> str: """The full command for plat and dest, a str dest being a placeholder.""" words = [self.launcher(elevated)] if plat: words += ["--platform", plat] if isinstance(dest, Path): words += ["--dest", self.word(str(dest.absolute()))] elif dest: words += ["--dest", dest] for flag, value in [*self.options, *self.chosen.get(plat, [])]: if flag in without: continue words += [flag] if value is None else [flag, self.word(value)] return " ".join(words) _INVOCATION = Invocation() def _on_read_only_mount(path: Path) -> bool: """Whether the file system holding path, or the folder it would go in, is mounted read-only (a card Linux remounts read-only after an error).""" if not hasattr(os, "statvfs"): return False probe = path while not probe.exists() and probe.parent != probe: probe = probe.parent try: return bool(os.statvfs(probe).f_flag & os.ST_RDONLY) except OSError: return False def _write_advice(path: Path, assumed: bool, plat: str = "") -> str: """What to do about a folder this run could not write. sudo fixes a root-owned BIOS folder on the console itself. On another machine it would create that folder on the wrong disk: a platform named but not found here is a card or share mounted elsewhere, named by --dest. Windows has no sudo, and Termux reaches shared storage once termux-setup-storage has run. A read-only mount refuses root as well, so sudo is never offered for it. """ if _on_read_only_mount(path): again = f"\n {_INVOCATION.command(plat, path)}" if plat else "" return (f" {path} is on a file system mounted read-only, which no user can " "write, sudo included. Mount it read-write again (a card or drive " "the system switched to read-only after an error needs a check " "first: fsck, or chkdsk on Windows), then run the same command " f"again.{again}") if assumed: return (f" {path} is where the platform keeps its BIOS on its own system, " "and it was not found here. If this machine prepares its card or " "share, run it again with --dest pointing at that folder:\n" f" {_INVOCATION.command(plat, '')}") if plat and _INVOCATION.os_type == "windows": return (" This user cannot write there. Run it again from a PowerShell window " "opened as administrator:\n" f" {_INVOCATION.command(plat, path)}\n" " Or choose another folder with --dest.") if plat and _INVOCATION.os_type == "android": return (" This user cannot write there. Run termux-setup-storage once to give " "Termux the shared storage, then run it again:\n" f" {_INVOCATION.command(plat, path)}\n" " Or choose another folder with --dest.") if plat: # sudo resets HOME to root's on Debian, Ubuntu, Arch and SteamOS: # the same command detected nothing there, fell back to a folder # under /root and reported success. return (" This user cannot write there. Run it again with sudo, naming the " "folder so root does not look in its own home:\n" f" {_INVOCATION.command(plat, path, elevated=True)}\n" " Or choose another folder with --dest.") if _INVOCATION.os_type == "windows": return (" This user cannot write there: run the same command from a PowerShell " "window opened as administrator, or choose another folder with --dest.") return (" This user cannot write there: run the same command with sudo, " "or choose another folder with --dest.") def _plural(count: int, word: str) -> str: """Count and noun, so a run of one file does not read as machine output.""" return f"{count} {word}" if count == 1 else f"{count} {word}s" class NetworkReason(str): """A failure the connection caused: cut, timed out, refused by a server or a proxy. The summary names the network instead of the folder.""" def _transfer_failure(exc: BaseException) -> bool: """Whether a download error came from the connection, not the disk.""" # urlopen raises URLError for the name lookup, the connection and TLS. # A cut read surfaces as a timeout (socket.timeout is no TimeoutError # before 3.10), a reset, an SSL error or a size that does not add up. ssl_error = getattr(sys.modules.get("ssl"), "SSLError", ()) if isinstance(exc, (urllib.error.URLError, http.client.HTTPException, ValueError, ConnectionError, TimeoutError, socket.timeout)): return True if ssl_error and isinstance(exc, ssl_error): return True return isinstance(exc, OSError) and exc.errno in ( errno.ECONNRESET, errno.ECONNREFUSED, errno.ECONNABORTED, errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN, errno.EPIPE, ) NETWORK_FAILURE_ADVICE = ( " The transfers were cut or refused on the way: check the connection, and " "HTTPS_PROXY if this network needs a proxy.\n" f" More help: {TROUBLESHOOTING_PAGE}") def _report_outcome( paths: list[Path], downloaded: int, up_to_date: int, errors: int, omitted: int, interrupted: bool, failures: list[tuple[str, str]] | None = None, transferred: int = 0, seconds: float = 0.0, declined: int = 0, assumed: set[Path] | None = None, omitted_entries: list[dict] | None = None, platform_of: dict[Path, str] | None = None, ) -> None: """Close the run on what happened, and leave with a status that says so. A run that installed nothing used to end on the word Done and a zero exit, which reads as success to a person and to a script alike. """ if transferred and seconds > 0: print(f"\n{format_size(transferred)} downloaded in " f"{format_duration(seconds)} ({format_size(int(transferred / seconds))}/s).") reasons = {reason for _dest, reason in failures or []} if failures and len(failures) > 1 and len(reasons) == 1 and reasons != {""}: # One cause, said once: a read-only folder listed ten of its files # and "... and 266 more" above the advice that fixes them all. print(f" failed: {_plural(len(failures), 'file')} ({reasons.pop()}), " f"first {failures[0][0]}") else: for dest, reason in (failures or [])[:10]: print(f" failed: {dest}" + (f" ({reason})" if reason else "")) if failures and len(failures) > 10: print(f" ... and {len(failures) - 10} more") if declined and not downloaded and not errors and not interrupted: print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, " "as answered.") print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.") return if interrupted: print(f"\nStopped. {_plural(downloaded, 'file')} installed.") print("Run the same command again to carry on from here. Nothing is lost.") sys.exit(130) # A standalone copy fails by absolute path, in an emulator's folder # rather than the BIOS folder: that folder is where the write went. elsewhere = sorted({Path(dest).parent for dest, _reason in failures or [] if Path(dest).is_absolute()}) refused = any(reason == NOT_WRITABLE for dest, reason in failures or [] if not Path(dest).is_absolute()) advice = [ _write_advice(path, path in (assumed or set()), (platform_of or {}).get(path, "")) for path in paths ] if refused else [] advice += [_write_advice(folder, False) for folder in sorted({ Path(dest).parent for dest, reason in failures or [] if Path(dest).is_absolute() and reason == NOT_WRITABLE })] if any(reason == READ_ONLY for _dest, reason in failures or []): advice.append(" Files marked read-only are in the way: clear that attribute " "(or delete them), then run again.") if any(isinstance(reason, NetworkReason) for _dest, reason in failures or []): advice.append(NETWORK_FAILURE_ADVICE) paths = paths + [folder for folder in elsewhere if folder not in paths] if errors and not downloaded: print(f"\nNothing was installed. {_plural(errors, 'file')} failed.") if failures and all(isinstance(r, NetworkReason) for _d, r in failures): # The folder was never the problem: naming it as the place being # written to sent people looking at their disk. print(f"Destination: {_locations(paths)}") else: print(f"Location that was being written to: {_locations(paths)}") for line in advice: print(line) print("Run the same command again once that is resolved. " "Files already installed are kept.") sys.exit(2) if errors: print(f"\nFinished with errors. {_plural(downloaded, 'file')} installed, " f"{_plural(errors, 'file')} failed.") print(f"Location: {_locations(paths)}") for line in advice: print(line) print("Run the same command again to retry only those.") sys.exit(1) print(f"\nDone. {_plural(downloaded, 'file')} installed, " f"{_plural(up_to_date, 'file')} already up to date.") print(f"Location: {_locations(paths)}") if omitted: for line in _omission_lines(omitted, omitted_entries or []): print(line) print("To check this later, run the same command with --check.") def _omission_lines(count: int, entries: list[dict]) -> list[str]: """What was skipped, the files an emulator cannot start without first. "395 file are not in the collection yet" named nothing and gave one reason for all, while the manifest says which file and why. """ one = count == 1 lines = [f"{_plural(count, 'file')} {'was' if one else 'were'} not installed: " f"the collection does not hold {'it' if one else 'them'}, or its copy " f"fails {'its' if one else 'their'} check."] lines += _needed_lines([e for e in entries if e.get("required")], "Needed to start an emulator") return lines def _absent_required(omitted: list[dict], bios_path: Path) -> list[dict]: """Required entries the manifest leaves out that the user has not placed.""" absent = [] for entry in omitted: dest = entry.get("dest") or entry.get("name", "") if not entry.get("required", True) or not dest: continue try: placed = _destination_path(bios_path, dest).is_file() except ValueError: placed = False # a path climbing out of the folder is never one we read if not placed: absent.append(entry) return absent def _needed_lines(entries: list[dict], heading: str, every: bool = False) -> list[str]: """Required files the collection lacks, by system with the emulators reading them. A bare list (DARK.GOB, Data.rsdk, ... and 69 more) left a Batocera user unable to tell whether their PlayStation was concerned. """ by_system: dict[str, tuple[set[str], set[str]]] = {} for entry in entries: system = str(entry.get("system") or ", ".join(entry_systems(entry)) or OTHER_SYSTEMS) names, cores = by_system.setdefault(system, (set(), set())) names.add(str(entry.get("dest") or entry.get("name"))) cores.update(str(core) for core in entry.get("cores") or []) if not by_system: return [] count = sum(len(names) for names, _cores in by_system.values()) lines = [f" {heading} ({_plural(count, 'file')}):"] systems = sorted(by_system) for system in systems if every else systems[:8]: names, cores = by_system[system] readers = f" ({', '.join(sorted(cores))})" if cores else "" lines.append(f" {system}{readers}: {', '.join(sorted(names))}") if not every and len(systems) > 8: lines.append(f" ... and {len(systems) - 8} more systems (--check --verbose lists them all)") lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/") return lines # Asked before, in an interactive run: past this size a download is worth a # second thought, and --system, --core or --region can make it smaller. CONFIRM_FROM_BYTES = 1024 * 1024 * 1024 def installer_id() -> str: """The first twelve hex digits of this file's SHA-256, as the bootstraps pin it.""" try: return hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12] except (OSError, NameError): return "unknown" # Answers that stop: every menu before this one taught q as quit. _REFUSALS = ("n", "q", "quit", "exit", "stop", "abort") def _confirm(prompt: str) -> bool: """Yes unless the answer refuses: n, no, or one of the quit words.""" answer = _ask(prompt).strip().lower() return not (answer.startswith("n") or answer in _REFUSALS) def _print_plan( to_download: list[dict], bios_path: Path, term: Terminal, trees: list[tuple[dict, dict[str, dict]]] = (), ) -> None: """What a real run would fetch, by system, and nothing written. The data trees count in the total and come before the closing line: printed after it, they left 2636 RetroArch files out of the figure a user plans an SD card from. """ total = sum(f.get("size", 0) for f in to_download) trees = [(entry, needed) for entry, needed in trees if needed] tree_files = sum(len(needed) for _entry, needed in trees) tree_size = sum(meta.get("size", 0) for _entry, needed in trees for meta in needed.values()) if not to_download and not trees: print("\n All files up to date. A real run would download nothing.") return parts = [] if to_download: parts.append(f"{_plural(len(to_download), 'file')} ({format_size(total)})") if trees: parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_size)}, " f"extracted from {_plural(len(trees), 'archive')})") print(f"\nWould download {' and '.join(parts)} to {bios_path}:") by_system: dict[str, list[dict]] = {} for entry in to_download: system = ", ".join(entry_systems(entry)) or "other" by_system.setdefault(system, []).append(entry) for system in sorted(by_system): print(f" {term.bold(system)} {_sized(by_system[system])}") for entry in sorted(by_system[system], key=lambda e: e["dest"])[:8]: print(f" {entry['dest']} {term.dim(format_size(entry.get('size', 0)))}") if len(by_system[system]) > 8: print(f" ... and {len(by_system[system]) - 8} more") for entry, needed in trees: print(f" {term.bold(entry['ref'] + ' data')} {_sized(list(needed.values()))}, " f"from {entry['url']}") print("\n Nothing was written. Run without --dry-run to install.") def _check_free_space(bios_path: Path, needed: int, plat: str = "") -> None: """Stop before writing when the volume cannot hold the download. Reported against the nearest existing ancestor, since the destination itself may have just been created on a volume that is already full. """ probe = bios_path while not probe.exists() and probe != probe.parent: probe = probe.parent try: free = shutil.disk_usage(probe).free except OSError: return if free >= needed: return print(f"\nNot enough space on the drive holding {bios_path}.", file=sys.stderr) print(f" Needed: {format_size(needed)}. Free: {format_size(free)}.", file=sys.stderr) if not plat: print(f" Free up {format_size(needed - free)}, or use --dest to install " "to another drive.", file=sys.stderr) sys.exit(1) # --dest alone used to mean RetroArch's list: the platform is named, so # a Batocera install moved to a USB drive stays Batocera's. print(f" Free up {format_size(needed - free)}, or install to another drive:\n" f" {_INVOCATION.command(plat, '')}", file=sys.stderr) sys.exit(1) def download_files( files: list[dict], bios_path: Path, jobs: int = 8, verbose: bool = False, term: Terminal | None = None, progress: Progress | None = None, ) -> tuple[list[tuple[str, str]], bool]: """Download files in parallel. Returns the destinations that failed, each with its reason, and whether the run was interrupted. Every future is submitted up front, so a bare KeyboardInterrupt would let the pool drain the queue in full: the stop flag is what makes Ctrl+C end the run rather than only stop the reporting. """ failed: list[tuple[str, str]] = [] interrupted = False _stop.clear() term = term or Terminal() if progress is None: progress = Progress(len(files), sum(f.get("size", 0) for f in files), term) with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool: future_map = { pool.submit(_download_one, f, bios_path, verbose, progress): f for f in files } refused = False try: for future in concurrent.futures.as_completed(future_map): dest, success, reason = future.result() if refused: # The folder they all go in refused the first: each # would fail alike, counted without a line of its own. if not success: failed.append((dest, NOT_WRITABLE)) continue if _stop.is_set(): continue progress.done(dest, success, reason) if not success: failed.append((dest, reason)) if reason == NOT_WRITABLE and not _folder_writable(bios_path): # A read-only card or share: hundreds of identical # FAILED lines buried the one cause and its advice. refused = True _stop.set() except KeyboardInterrupt: interrupted = True _stop.set() progress.finish() print("\n Stopping. Files already installed are kept.") progress.finish() return failed, interrupted def _fetch_archive(url: str, directory: Path) -> Path: """A data directory's source archive, in *directory*, retried like files.""" reason: Exception | None = None for attempt in range(1, MAX_RETRIES + 1): handle = tempfile.NamedTemporaryFile(dir=directory, suffix=".archive", delete=False) try: with handle, urllib.request.urlopen(url, timeout=60) as resp: received = 0 while True: if _stop.is_set(): raise _Interrupted() chunk = resp.read(1024 * 1024) if not chunk: break received += len(chunk) if received > MAX_DATA_ARCHIVE_SIZE: raise ValueError("archive exceeds the size limit") handle.write(chunk) return Path(handle.name) except urllib.error.HTTPError as exc: Path(handle.name).unlink(missing_ok=True) if exc.code < 500 and exc.code != 429: raise reason = exc except (urllib.error.URLError, OSError, http.client.HTTPException) as exc: Path(handle.name).unlink(missing_ok=True) reason = exc if isinstance(exc, OSError) else ConnectionError(repr(exc)) except BaseException: Path(handle.name).unlink(missing_ok=True) raise if attempt < MAX_RETRIES: time.sleep(_retry_delay(attempt, None)) raise reason if reason else OSError("download failed") def _archive_members( archive: Path, entry: dict ) -> Iterator[tuple[str, int, Callable[[], BinaryIO]]]: """(path in the tree, declared size, opener) for each file the archive maps into it. The rules refresh_data_dirs applies to build the cache: a ZIP drops strip_components leading parts, a tarball keeps what sits under source_path, and a first part named in exclude is left out. """ exclude = set(entry.get("exclude") or []) if entry["type"] == "zip": strip = entry.get("strip_components", 0) with zipfile.ZipFile(archive) as zf: for info in zf.infolist(): if info.is_dir(): continue parts = info.filename.split("/") if ".." in parts or info.filename.startswith("/"): continue if strip: if len(parts) <= strip: continue parts = parts[strip:] if parts[0] in exclude: continue yield "/".join(parts), info.file_size, (lambda info=info: zf.open(info)) return prefix = entry["source_path"].rstrip("/") + "/" with tarfile.open(archive, "r:gz") as tf: for member in tf: if not member.isfile() or not member.name.startswith(prefix): continue rel = member.name[len(prefix):] if not rel or ".." in rel.split("/") or rel.split("/")[0] in exclude: continue yield rel, member.size, (lambda member=member: tf.extractfile(member)) def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str, dict], int]: """(tree root, files missing or wrong, files already right).""" root = _destination_path(bios_path, entry["dest"]) if entry.get("dest") else bios_path.resolve() needed: dict[str, dict] = {} present = 0 for rel, meta in entry["files"].items(): target = _destination_path(root, rel) if ( target.is_file() and target.stat().st_size == meta["size"] and _sha1_file(target) == meta["sha1"] ): present += 1 else: needed[rel] = meta return root, needed, present def install_data_directory(entry: dict, bios_path: Path) -> dict: """Rebuild one data tree from its source archive, file by file verified. Returns counts: present, installed, changed (the source no longer holds the recorded bytes), absent (not in the archive), and error, the reason the archive could not be read. """ root, needed, present = data_directory_state(entry, bios_path) counts = {"present": present, "installed": 0, "changed": 0, "absent": 0, "error": ""} if not needed: return counts try: root.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=root) as scratch: archive = _fetch_archive(entry["url"], Path(scratch)) for rel, declared, opener in _archive_members(archive, entry): meta = needed.pop(rel, None) if meta is None: continue # Read no more than the list records: the archive's own size # field is checked first, and the stream is cut at the # expected size whatever the member claims, so a member that # inflates without end costs one byte past it. if declared != meta["size"]: counts["changed"] += 1 continue target = _destination_path(root, rel) target.parent.mkdir(parents=True, exist_ok=True) digest = hashlib.sha1() size = 0 with opener() as src, tempfile.NamedTemporaryFile( dir=target.parent, prefix=f".{target.name}.", suffix=".part", delete=False, ) as out: while size <= meta["size"]: chunk = src.read(min(1024 * 1024, meta["size"] + 1 - size)) if not chunk: break digest.update(chunk) size += len(chunk) out.write(chunk) if digest.hexdigest() == meta["sha1"] and size == meta["size"]: os.replace(out.name, target) counts["installed"] += 1 else: Path(out.name).unlink(missing_ok=True) counts["changed"] += 1 except (urllib.error.URLError, OSError, ValueError, zipfile.BadZipFile, tarfile.TarError) as exc: counts["error"] = str(getattr(exc, "reason", None) or exc) counts["absent"] = len(needed) return counts def select_data_directories(manifest: dict, files: list[dict], narrowed: bool) -> list[dict]: """The data trees this install keeps: all, or those of a kept system.""" entries = manifest.get("data_directories") or [] if not narrowed: return list(entries) kept = {system for f in files for system in entry_systems(f)} return [e for e in entries if kept & set(e.get("systems") or [])] def do_standalone_copies( manifest: dict, bios_path: Path, os_type: str, extra_dirs: dict[str, Path] | None = None, failures: list[tuple[str, str]] | None = None, ) -> tuple[int, int, int]: """Copy BIOS files to standalone emulator directories. Supports: - file: single file copy - pattern: glob match (e.g. "scph*.bin") - note: informational message when detect path exists - WSL fallback to linux targets extra_dirs maps the emulator id of an entry to a root that replaces the default per-OS locations, for setups such as LaunchBox that keep their emulators outside them. The layout below the root is the same. Returns (copied, skipped, failed): skipped is a target directory that does not exist, failed a copy the system refused (permission, disk full, read-only mount) or a name leading out of the folder, which used to be counted as "dir not found". A link already at a file's name is replaced by the file, never written through. Each failure is also appended to failures, by absolute path, for the summary to name. """ from fnmatch import fnmatch copies = manifest.get("standalone_copies", []) if not copies: return 0, 0, 0 copied = 0 skipped = 0 failed = 0 for entry in copies: # Note entries: print message if emulator detected if "note" in entry: detect_paths = entry.get("detect", {}).get(os_type, []) if not detect_paths and os_type == "wsl": detect_paths = entry.get("detect", {}).get("linux", []) for dp in detect_paths: expanded = Path(os.path.expandvars(os.path.expanduser(dp))) if expanded.is_dir(): print(f" {entry['note']}") break continue # Resolve source files if "pattern" in entry: sources = [ f for f in bios_path.rglob("*") if fnmatch(f.name, entry["pattern"]) and f.is_file() ] else: # `source` is where this platform's manifest put the file, and `file` # names it as the registry does for every platform, and laid out # that way it was missing wherever the pack places it elsewhere. src = _destination_path(bios_path, entry.get("source") or entry["file"]) sources = [src] if src.exists() else [] if not sources: continue # Resolve target directories with WSL fallback targets = entry.get("targets", {}).get(os_type, []) if not targets and os_type == "wsl": targets = entry.get("targets", {}).get("linux", []) target_dirs = [ Path(os.path.expandvars(os.path.expanduser(t))) for t in targets ] extra_root = (extra_dirs or {}).get(entry.get("emulator", "")) if extra_root is not None: subdir = PurePosixPath(entry.get("file", "")).parent extra = extra_root if str(subdir) == "." else extra_root / subdir if extra not in target_dirs: target_dirs.append(extra) for target_dir in target_dirs: if not target_dir.is_dir(): skipped += len(sources) continue resolved_dir = target_dir.resolve() for src in sources: dest = target_dir / src.name linked = dest.is_symlink() # A crafted source name would climb out of the directory. if not linked and not _within(dest, resolved_dir): print(f" ERROR: {dest}: {OUTSIDE_TARGET}") failed += 1 if failures is not None: failures.append((str(dest), OUTSIDE_TARGET)) continue try: if linked: # Writing through a link already at the name would # leave the folder the user opted into. The link # itself gives way to the file. _replace_with_copy(src, dest) print(f" {dest}: link replaced by the file") else: shutil.copy2(src, dest) copied += 1 except OSError as exc: print(f" ERROR: {dest}: {exc.strerror or exc}") failed += 1 if failures is not None: reason = NOT_WRITABLE if _write_refused(exc) else str( exc.strerror or exc) failures.append((str(dest), reason)) return copied, skipped, failed OUTSIDE_TARGET = "name leads outside the emulator folder" def _replace_with_copy(src: Path, dest: Path) -> None: """Put a copy of src at dest's name without following what is there.""" fd, scratch = tempfile.mkstemp(dir=dest.parent, prefix=f".{dest.name}.", suffix=".part") os.close(fd) try: shutil.copy2(src, scratch) os.replace(scratch, dest) except OSError: if os.path.lexists(scratch): os.unlink(scratch) raise def format_size(n: int) -> str: """Human-readable file size.""" if n < 1024: return f"{n} B" if n < 1024 * 1024: return f"{n / 1024:.1f} KB" if n < 1024 * 1024 * 1024: return f"{n / (1024 * 1024):.1f} MB" return f"{n / (1024 * 1024 * 1024):.1f} GB" def _choose_platform(title: str, default: str = "") -> str: """One platform from the numbered list, Enter taking the default.""" print(f"\n{title}") for i, name in enumerate(AVAILABLE_PLATFORMS, 1): print(f" {i}) {display_name(name)}") print(" q) quit") print() prompt = f"> [Enter for {display_name(default)}] " if default else "> " while True: choice = _ask(prompt) if not choice and default: return default if choice.lower() == "q": sys.exit(0) if choice.isdigit() and 1 <= int(choice) <= len(AVAILABLE_PLATFORMS): return AVAILABLE_PLATFORMS[int(choice) - 1] key = _name_key(choice) for plat in AVAILABLE_PLATFORMS: if key and key in (_name_key(plat), _name_key(display_name(plat))): return plat print(f" Type a number from 1 to {len(AVAILABLE_PLATFORMS)}, a platform name, " "or q to quit.") def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]: """Ask for a platform and a BIOS directory when nothing was detected.""" plat = _choose_platform("Choose the platform to install for:") default_dest = _default_dest(os_type, plat) raw = "" while not raw: raw = _ask( f"BIOS directory [{default_dest}]: " if default_dest else "BIOS directory (no default on this system): " ) if default_dest is not None: break dest = Path(raw).expanduser() if raw else default_dest if os_type == "windows" and raw: dest = Path(os.path.expandvars(raw)) return [(plat, dest)] # Platforms with no install location of their own: the BIOS folder sits # beside the executable, wherever the archive was extracted. PORTABLE_ONLY = { "bizhawk": "BizHawk reads the Firmware folder beside EmuHawk, and none was found.", } def _default_dest(os_type: str, platform_name: str) -> Path | None: """Fall back path for a platform named on the command line but not found, or None where no folder on this OS is one the platform reads. A home directory is meaningless for RetroArch on Android, where Termux owns the home and the frontend reads from shared storage. ~/bios is read by no platform, RetroBat lives on Windows only, and a Batocera or Recalbox path on Windows resolved to the root of the current drive: 8.7 GB under C:\\userdata\\bios, reported done. """ if os_type == "android" and platform_name == "retroarch": return _android_storage_root() / "RetroArch" / "system" if platform_name in PORTABLE_ONLY: return None own = _os_default_dests(os_type).get(platform_name) if own is not None: return own if os_type == "windows": return None if platform_name == "retrobat": return None return DEFAULT_DESTS.get(platform_name) def _manual_usage_hint(os_type: str) -> list[str]: """How to name the platform and directory when nothing is detected. In the form the run was started with: a one-liner user has no install.py, and `python3 install.py` failed with "can't open file". """ example = { "windows": "C:\\RetroArch-Win64\\system", "android": "/storage/emulated/0/RetroArch/system", }.get(os_type, "~/.config/retroarch/system") hint = Invocation() hint.os_type, hint.options = os_type, _INVOCATION.options return [ " Use --platform and --dest to specify manually, for example:", f" {hint.command('retroarch', example)}", f" Where each platform keeps its BIOS: {INSTALLER_PAGE}", ] def _prompt_platform_choice( platforms: list[tuple[str, Path]], ) -> list[tuple[str, Path]]: """Prompt user to choose among detected platforms.""" print("\nInstall for:") for i, (name, path) in enumerate(platforms, 1): print(f" {i}) {display_name(name)} ({path})") last = len(platforms) + (1 if len(platforms) > 1 else 0) if len(platforms) > 1: print(f" {last}) All") print(" q) quit") print() while True: choice = _ask("> ").lower() if choice == "q": sys.exit(0) idx = int(choice) if choice.isdigit() else 0 if 1 <= idx <= len(platforms): return [platforms[idx - 1]] if idx == last and len(platforms) > 1: return platforms # A wrong answer used to bring back a bare prompt, and nothing said # how to leave it. print(f" Type a number from 1 to {last}, or q to quit.") def _ask(prompt: str) -> str: """An answer, or a stop: Ctrl+D at a question is Ctrl+C. Both used to leave with 0, so `install.sh && retroarch` went on with no BIOS. They now end where Ctrl+C ends everywhere else, on 130. """ try: return input(prompt).strip() except EOFError: print() raise KeyboardInterrupt from None def _sized(files: list[dict]) -> str: return f"{_plural(len(files), 'file')}, {format_size(sum(f.get('size', 0) for f in files))}" def _pick(title: str, rows: list[tuple[str, list[dict]]], ordered: bool = False) -> list[str]: """Numbered list of names. An empty answer keeps every one.""" if not rows: return [] print(f"\n{title}") width = len(str(len(rows))) name_width = min(max(len(name) for name, _files in rows), 32) for index, (name, files) in enumerate(rows, 1): print(f" {index:>{width}}) {name:<{name_width}} {_sized(files)}") hint = "order of preference, e.g. 2,1" if ordered else "e.g. 1,3-5" while True: text = _ask(f"Numbers ({hint}), Enter for all, q to quit: ") if not text: return [] if text.lower() == "q": sys.exit(0) try: chosen = parse_selection(text, len(rows), ordered=ordered) except ValueError as exc: print(f" {exc}") continue if chosen: return [rows[number - 1][0] for number in chosen] INSTALLED_ONLY = "installed" def _prompt_custom_selection( plat: str, files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None = None, name: str = "", ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. Enter installs everything, the default most runs want. Returns None then, INSTALLED_ONLY for what `installed` (the files the cores found installed read) holds, or the three choices. Each empty list keeps everything on its axis. """ print(f"\n{name or display_name(plat)}: {_sized(files)}.") print(" Enter) install everything") if installed is not None: print(f" i) only what the installed cores read: {_sized(installed[0])}") print(" c) choose systems, cores and regions") print(" q) quit") while True: answer = _ask("> ").lower() if answer == "": return None if answer == "q": sys.exit(0) if answer == "i" and installed is not None: return INSTALLED_ONLY if answer == "c": break keys = "Enter, i, c or q" if installed is not None else "Enter, c or q" print(f" Type {keys}.") choices = available_choices(files, omitted) if OTHER_SYSTEMS in choices["systems"]: print(f"\n'{OTHER_SYSTEMS}' holds what cores load for systems this platform") print("does not list, such as game engines and extra computers.") systems = _pick("Systems:", [ (name, [f for f in files if _chosen(f, {name})]) for name in choices["systems"] ]) kept, kept_omitted = narrow(files, omitted, systems, [], []) cores: list[str] = [] core_names = available_choices(kept, kept_omitted)["cores"] if core_names: print("\nThe platform's own files for these systems are always kept;") print("the extra files each core loads can be narrowed.") cores = _pick("Cores:", [ (name, _filter_by_target( [f for f in kept if f.get("cores") is not None], [name])) for name in core_names ]) kept, kept_omitted = narrow(kept, kept_omitted, [], cores, []) regions: list[str] = [] region_names = available_choices(kept, kept_omitted)["regions"] if region_names: print("\nSome systems have one BIOS per region. Keeping only the") print("preferred region removes the others where a match exists.") regions = _pick("Regions:", [ (name, [f for f in kept if name in (f.get("regions") or [])]) for name in region_names ], ordered=True) return systems, cores, regions def _same_choice_hint(plat: str, systems: list[str], cores: list[str], regions: list[str]) -> str: """The options that repeat a choice made at the prompts.""" def quoted(value: str) -> str: return f'"{value}"' if any(c in value for c in " ;&|") else value parts = [f"--platform {plat}"] parts += [f"--system {quoted(name)}" for name in systems] parts += [f"--core {quoted(name)}" for name in cores] if regions: parts.append(f"--region {','.join(regions)}") return " ".join(parts) def system_aliases(manifest: dict | None) -> dict[str, str]: """The platform's own names for its systems, each with the system id.""" return { alias: system for system, names in ((manifest or {}).get("system_names") or {}).items() for alias in names } def _system_label(name: str, manifest: dict | None) -> str: """A system id with the names the platform gives it: nintendo-gba (gba).""" names = ((manifest or {}).get("system_names") or {}).get(name) return f"{name} ({', '.join(names)})" if names else name def _print_choices( plat: str, files: list[dict], omitted: list[dict], systems: bool, manifest: dict | None = None, ) -> None: """What --system or --core accepts for a platform, with what each holds.""" choices = available_choices(files, omitted) names = choices["systems"] if systems else choices["cores"] if not names: print(f" {plat} records no {'systems' if systems else 'cores'}") return labels = {name: _system_label(name, manifest) if systems else name for name in names} width = min(max(len(label) for label in labels.values()), 40) for name in names: held = ( [f for f in files if _chosen(f, {name})] if systems else _filter_by_target([f for f in files if f.get("cores") is not None], [name]) ) print(f" {labels[name]:<{width}} {_sized(held)}") def emulator_names(manifest: dict, files: list[dict], omitted: list[dict]) -> dict[str, str]: """Every name --emulator accepts, with the profile each one stands for. A profile answers to its own name and to the names its core is built under (core_names: beetle_psx and mednafen_psx). A core named by a file and by no profile stands for itself. """ names = {core: core for core in available_choices(files, omitted)["cores"]} for profile, aliases in (manifest.get("core_names") or {}).items(): names[profile] = profile for alias in aliases: names.setdefault(alias, profile) return names def _print_emulators(plat: str, manifest: dict, files: list[dict], omitted: list[dict]) -> None: """What --emulator accepts for a platform, one name per line.""" names = emulator_names(manifest, files, omitted) if not names: print(f" {plat} records no emulators") return width = min(max(len(name) for name in names), 40) for name in sorted(names): profile = names[name] print(f" {name:<{width}} {'' if profile == name else 'same as ' + profile}".rstrip()) # RomM 5.4 places each platform's firmware by `filesystem.structure.firmware` # in config.yml, "bios/{platform}" by default, relative to the library # (rommapp/romm 5.4.0 68f71259, backend/config/config_manager.py:58,101-109, # 205-225,564-568). The library and config.yml both sit under the RomM base # folder (config/__init__.py:46-48, config_manager.py:264-265). A library # laid out as {platform}/roms makes RomM stop until config.yml declares its # templates, and RomM suggests firmware: "{platform}/bios" there # (config_manager.py:1105-1124). def parse_romm_firmware(template: str) -> tuple[tuple[str, ...], tuple[str, ...]]: """The literal folders before and after {platform}, as RomM parses them.""" sections = [section for section in template.strip().split("/") if section] if not sections: raise ValueError("the RomM firmware template is empty") def macro(section: str) -> str | None: if section.startswith("{") and section.endswith("}"): return section[1:-1].strip() return None if any(section in (".", "..") for section in sections): raise ValueError(f"the RomM firmware template {template!r} climbs out of the library") platform_at = [i for i, section in enumerate(sections) if macro(section) == "platform"] if len(platform_at) != 1: raise ValueError(f"the RomM firmware template {template!r} needs one '{{platform}}'") before, after = sections[:platform_at[0]], sections[platform_at[0] + 1:] if any(macro(section) is not None for section in before + after): raise ValueError(f"the RomM firmware template {template!r} takes literal folder " "names around '{platform}'") return tuple(before), tuple(after) def _romm_configured_firmware(config: Path) -> str | None: """filesystem.structure.firmware from RomM's config.yml, read without a YAML library: only that nested scalar is needed.""" try: lines = config.read_text(encoding="utf-8", errors="replace").splitlines() except OSError: return None path: list[tuple[int, str]] = [] for line in lines: stripped = line.split(" #", 1)[0].rstrip() if not stripped.strip() or stripped.lstrip().startswith(("#", "-")): continue indent = len(stripped) - len(stripped.lstrip()) key, sep, value = stripped.strip().partition(":") if not sep: continue while path and path[-1][0] >= indent: path.pop() keys = [name for _indent, name in path] + [key.strip().strip("'\"")] if keys == ["filesystem", "structure", "firmware"] and value.strip(): return value.strip().strip("'\"") path.append((indent, key.strip().strip("'\""))) return None def romm_firmware_layout( bios_path: Path, template: str | None ) -> tuple[tuple[str, ...], tuple[str, ...]] | None: """Where RomM reads each platform's firmware, relative to its library. bios_path is the folder the default template names, so its parent is the library. The template comes from --romm-firmware, else from config.yml beside the library. None keeps the pack's own bios/ layout. """ library = bios_path.parent if template is None: template = _romm_configured_firmware(library.parent / "config" / "config.yml") if template is not None: return parse_romm_firmware(template) platform_first = not (library / "roms").is_dir() and any( path.is_dir() for path in library.glob("*/roms") ) if platform_first: raise ValueError( f"{library} keeps its roms inside each platform folder, and RomM then " "reads firmware where filesystem.structure.firmware in its config.yml " "says, which was not found here. RomM suggests \"{platform}/bios\" for " "this layout: run again with --romm-firmware \"{platform}/bios\", or " "with the value config.yml holds." ) return None def romm_relayout( files: list[dict], bios_path: Path, before: tuple[str, ...], after: tuple[str, ...], ) -> tuple[list[dict], Path]: """The manifest's bios// entries under RomM's template, with the library as the root they are relative to.""" if (before, after) == (("bios",), ()): return files, bios_path moved = [] for entry in files: slug, _, rest = str(entry["dest"]).partition("/") if not rest: # Firmware of no platform: RomM lists none, the default place stays. moved.append({**entry, "dest": f"bios/{slug}"}) continue moved.append({**entry, "dest": "/".join((*before, slug, *after, rest))}) return moved, bios_path.parent def _older_list_advice() -> str: """How to reach a file list that records what an option needs. A list without the fields comes from an older release. Pinned with RETROBIOS_REF, as the docs suggest for reproducibility, "a newer one" meant unsetting the variable, and nothing said so. """ if RELEASE_REF != DEFAULT_RELEASE_REF: return (f" RETROBIOS_REF={RELEASE_REF} selects file lists published before " "this option existed: unset RETROBIOS_REF to use the current ones, " "or run without the option.") return (" The file list comes from an older release: run the one-line " "installer again to fetch the current one.") def _narrow_to_emulators( names: list[str], manifest: dict, plat: str, files: list[dict], omitted: list[dict], ) -> tuple[list[dict], list[dict]]: """Keep what the named emulators read, as the pack built for them. An emulator page offers this as one command, so a name the platform does not run is refused rather than read as nothing to install. """ if "system_cores" not in manifest: print(f"Error: the {plat} file list does not say which emulators read " "which systems, which --emulator needs.", file=sys.stderr) print(_older_list_advice(), file=sys.stderr) sys.exit(1) known = sorted(emulator_names(manifest, files, omitted)) try: chosen = resolve_choices(names, known, "emulator") except ValueError as exc: print(f"Error: {plat}: {exc}", file=sys.stderr) sys.exit(1) before = len(files) files, omitted = narrow_to_cores(manifest, chosen, files, omitted) # A whole-platform install keeps the platform's files of a system no # core is mapped to. Here only the named emulators' systems are wanted. cores = designated_cores(manifest, chosen) reached = { system for system, readers in (manifest.get("system_cores") or {}).items() if cores.intersection(readers) } def theirs(entry: dict) -> bool: return entry.get("cores") is not None or bool(reached.intersection(entry_systems(entry))) files = [entry for entry in files if theirs(entry)] omitted = [entry for entry in omitted if theirs(entry)] print(f" Narrowed {before} -> {len(files)} files to {', '.join(chosen)}") return files, omitted def _requested_narrowing( args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict], manifest: dict | None = None, ) -> tuple[list[str], list[str], list[str]] | None: """The systems, cores and regions named on the command line, checked.""" if not (args.system or args.core or args.region): return None if not records_selection(files): # Without the fields, a region filter would match nothing and install # everything: refused rather than ignored. print( f"Error: the {plat} file list records no systems or regions, so " "--system, --core and --region cannot narrow it.", file=sys.stderr, ) print(_older_list_advice(), file=sys.stderr) sys.exit(1) choices = available_choices(files, omitted) try: systems = resolve_choices( args.system or [], choices["systems"], "system", system_aliases(manifest) ) cores = resolve_choices(args.core or [], choices["cores"], "core") regions = resolve_regions(args.region or []) except ValueError as exc: print(f"Error: {plat}: {exc}", file=sys.stderr) sys.exit(1) return systems, cores, regions def _help_epilog() -> str: """Examples in the form the run was started with, and where the rest is.""" run = _INVOCATION.launcher() card = "E:\\bios" if _INVOCATION.powershell() else "/media/SHARE/bios" examples = ( ("install for the platforms found here", run), ("prepare a card or a share for another device", f"{run} --platform batocera --dest {card}"), ("only some systems (names: --list-systems)", f"{run} --platform retroarch --system sony-playstation,nintendo-gba"), ("check what is installed, writing nothing", f"{run} --check"), ) lines = ["examples:"] for what, command in examples: lines += [f" {what}", f" {command}"] lines += ["", f"Every option, the environment variables and the exit codes: {INSTALLER_PAGE}"] return "\n".join(lines) def main() -> None: """Entry point.""" _INVOCATION.os_type = detect_os() parser = argparse.ArgumentParser( allow_abbrev=False, description="Download missing BIOS files for retrogaming emulators.", epilog=_help_epilog(), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--platform", help="target platform (retroarch, batocera, emudeck, ...)", ) parser.add_argument( "--dest", type=Path, help="BIOS folder to install into. Without --platform, the file list is " "the platform detected here, asked at a terminal, RetroArch's when " "nothing is detected", ) parser.add_argument( "--target", help="hardware target for core filtering (switch, rpi4, ...); " "on Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system", ) parser.add_argument( "--every-board", action="store_true", help="install the files of every board, not only the detected one", ) parser.add_argument( "--check", action="store_true", help="check existing files without downloading", ) parser.add_argument( "--list-platforms", action="store_true", help="list detected platforms and exit", ) parser.add_argument( "--list-targets", action="store_true", help="list available targets for a platform and exit", ) parser.add_argument( "--jobs", "-j", type=int, default=8, help="parallel download threads (default: 8)", ) parser.add_argument( "--verbose", "-v", action="store_true", help="verbose output", ) parser.add_argument( "--standalone-copies", action="store_true", help="opt in to copies into detected standalone-emulator directories", ) parser.add_argument( "--emulator", action="append", metavar="NAME", help="install what these emulators read on the platform, as a pack " "built for them carries (names: --list-emulators)", ) parser.add_argument( "--installed-cores", action="store_true", help="install only what the cores already installed read " "(RetroArch, Lakka, RetroPie)", ) parser.add_argument( "--cores-dir", type=Path, metavar="PATH", help="the folder RetroArch loads its cores from, when it is not found " "(implies --installed-cores)", ) parser.add_argument( "--system", action="append", metavar="NAME", help="install only these systems (repeat or comma-separate, see " "--list-systems)", ) parser.add_argument( "--core", action="append", metavar="NAME", help="keep only the extra files these cores load (see --list-cores)", ) parser.add_argument( "--region", action="append", metavar="LIST", help="preferred regions, best first, e.g. us,eu,jp: keep one BIOS per " "region where a system has several", ) parser.add_argument( "--list-systems", action="store_true", help="list a platform's systems with their file counts and exit", ) parser.add_argument( "--list-cores", action="store_true", help="list the cores whose extra files a platform installs and exit", ) parser.add_argument( "--romm-firmware", metavar="TEMPLATE", help="RomM: where each platform's firmware goes in the library, as " "filesystem.structure.firmware in its config.yml (default: read " "from config.yml, else bios/{platform})", ) parser.add_argument( "--list-emulators", action="store_true", help="list every name --emulator accepts for a platform and exit", ) parser.add_argument( "--no-input", action="store_true", help="never ask a question and fail where an answer is needed", ) parser.add_argument( "--dry-run", action="store_true", help="show what would be downloaded, and how much, without writing anything", ) parser.add_argument( "--yes", "-y", action="store_true", help="do not ask before a large download", ) parser.add_argument( "--quiet", "-q", action="store_true", help="print only errors and the final summary (implies --no-input)", ) parser.add_argument( "--version", action="version", version=f"retrobios installer {installer_id()} (ref {RELEASE_REF})", help="print the installer version and exit", ) args = parser.parse_args() if args.cores_dir: args.installed_cores = True _INVOCATION.record(args) if not 1 <= args.jobs <= 32: parser.error("--jobs must be between 1 and 32") if args.region: try: resolve_regions(args.region) except ValueError as exc: parser.error(str(exc)) for option, values in (("--system", args.system), ("--core", args.core)): if values is not None and not any( token.strip() for value in values for token in value.split(",") ): parser.error(f"{option} needs at least one name") if args.quiet and ( args.check or args.dry_run or args.list_platforms or args.list_targets or args.list_systems or args.list_cores or args.list_emulators ): parser.error("--quiet applies to an install, not to --check, --dry-run or a listing") if args.every_board and args.target: parser.error("--every-board installs every board; --target names one") if not args.quiet: _install(args, sys.stdout) return # Quiet: what a run prints is kept, and shown on stderr if the run fails, # so an error printed on stdout is never lost. captured = io.StringIO() real_stdout = sys.stdout sys.stdout = captured try: _install(args, real_stdout) except SystemExit as exc: if sys.stdout is captured: sys.stdout = real_stdout if exc.code not in (0, None): sys.stderr.write(captured.getvalue()) raise finally: if sys.stdout is captured: sys.stdout = real_stdout def _no_core_found(plat: str, bios_path: Path, searched: list[Path], dest_given: bool) -> None: """Refuse --installed-cores with no core, and say how to point at them. With --dest, detection does not run, and without a retroarch.cfg beside the folder nothing names the cores folder: the run stopped on "no known directory" with no way forward. """ where = (f"none in {', '.join(map(str, searched))}" if searched else f"no retroarch.cfg was found for {bios_path} to name their folder") print(f"Error: no installed core found for {display_name(plat)}: {where}.", file=sys.stderr) print(" Name the folder RetroArch loads cores from (libretro_directory in " "retroarch.cfg, cores/ beside RetroArch by default):", file=sys.stderr) command = _INVOCATION.command(plat, bios_path, without=("--installed-cores", "--cores-dir")) print(f" {command} --cores-dir ", file=sys.stderr) if dest_given: print(" Or run it without --dest: detection reads retroarch.cfg, which names " "that folder.", file=sys.stderr) print(" Or run it without --installed-cores to install every file.", file=sys.stderr) sys.exit(1) def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: """The file list for a --dest given without --platform. A folder says where, not for whom, and a card is often prepared on a PC for a console: --dest alone installed RetroArch's names and layout into a Batocera share. The platforms detected here are the evidence. At a terminal the user picks, Enter taking the one found. Otherwise the one found is used, RetroArch when none is, and several found are refused. """ print("Detecting platform...") found = list(dict.fromkeys(name for name, _path in detect_platforms(os_type))) default = found[0] if len(found) == 1 else "" if found else "retroarch" if interactive: plat = _choose_platform(f"Which platform's files go to {dest}?", default) print(f" File list: {display_name(plat)}. Same choice without questions: " f"--platform {plat}") return plat if not default: print(f"Error: {' and '.join(display_name(n) for n in found)} found here. " f"Name the platform {dest} is for with --platform.", file=sys.stderr) sys.exit(1) why = "the platform found here" if found else "nothing was detected here" print(f" File list: {display_name(default)} ({why}). For another platform's " "list, add --platform (--list-platforms names them).") return default def _unknown_target(target: str, plat: str, targets: dict) -> None: print(f"Error: unknown target '{target}' for {plat}.", file=sys.stderr) if targets: print(" " + did_you_mean([target], sorted(targets), "--list-targets"), file=sys.stderr) else: print(f" {plat} publishes no target list", file=sys.stderr) def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: """One run of the installer, once the options are read.""" term = Terminal(real_stdout, quiet=args.quiet) interactive = sys.stdin.isatty() and not args.no_input and not args.quiet listing = (args.list_targets or args.list_systems or args.list_cores or args.list_emulators) print(f"{term.bold('RetroBIOS')} {term.dim(installer_id())}\n") os_type = detect_os() if os_type == "wsl": _enter_wsl() # Early exit for listing if args.list_platforms: print("Available platforms:") for p in AVAILABLE_PLATFORMS: print(f" {p}") detected = detect_platforms(os_type) if detected: print("\nDetected on this system:") for name, path in detected: print(f" {name}: {path}") return # Platform detection or override. A destination taken from the default # table, the platform not being found here, is only a guess. assumed_dests: set[Path] = set() if args.platform: args.platform = normalize_platform(args.platform) if args.platform and args.dest: platforms = [(args.platform, args.dest)] elif args.platform: print("Detecting platform...") detected = detect_platforms(os_type) matched = [(n, p) for n, p in detected if n == args.platform] if matched: platforms = matched elif listing: # A listing reads the manifest and writes nothing: no folder is # needed, and asking BizHawk's for one refused a question asked # before downloading BizHawk. platforms = [(args.platform, _default_dest(os_type, args.platform) or Path.cwd())] elif args.platform in PORTABLE_ONLY: # A portable emulator reads the folder beside its executable: # any default would be a folder it never opens. print( f" Platform '{args.platform}' not found. {PORTABLE_ONLY[args.platform]} " f"Name it with --dest, for example: --platform {args.platform} " f"--dest '{os.sep}Firmware'", file=sys.stderr, ) sys.exit(1) else: default_dest = _default_dest(os_type, args.platform) if default_dest is None: print( f" Platform '{args.platform}' not found, and no folder on this " f"system is one it reads. Name it with --dest, for example: " f"--platform {args.platform} --dest ", file=sys.stderr, ) sys.exit(1) print( f" Platform '{args.platform}' not detected, " f"using default path: {default_dest}" ) platforms = [(args.platform, default_dest)] assumed_dests.add(default_dest) elif args.dest: print(f" Using destination: {args.dest}") platforms = [(_platform_for_dest(os_type, args.dest, interactive and not listing), args.dest)] else: print("Detecting platform...") frontends = detect_frontends(os_type) if "esde" in frontends: print(" Found ES-DE (BIOS files go to each emulator, not to ES-DE itself)") if "launchbox" in frontends: print(" Found LaunchBox") platforms = detect_platforms(os_type) if not platforms: print(" No supported platform detected.") if not interactive or listing: for line in _manual_usage_hint(os_type): print(line) sys.exit(1) platforms = _prompt_manual_platform(os_type) for name, path in platforms: print(f" Found {display_name(name)} at {path}") if len(platforms) > 1 and not listing and interactive: platforms = _prompt_platform_choice(platforms) total_downloaded = 0 total_up_to_date = 0 total_errors = 0 total_omitted = 0 omitted_seen: list[dict] = [] total_interrupted = False total_bytes = 0 total_seconds = 0.0 total_declined = 0 check_pending = 0 failures: list[tuple[str, str]] = [] installed_paths: list[Path] = [] # --target is checked for every platform before anything is written, as # --installed-cores is below: EmuDeck installed 3.2 GB and only then was # steamos refused for RetroArch, with no summary of what had landed. target_lists: dict[str, dict] = {} if args.target and not args.list_targets: for plat_name, _bios_path in platforms: targets = fetch_targets(plat_name) if targets is None: sys.exit(1) target_lists[plat_name] = targets if args.target not in targets: _unknown_target(args.target, plat_name, targets) if len(platforms) > 1: print(" Nothing was installed. Name one platform with " "--platform to install it alone.", file=sys.stderr) sys.exit(1) if args.romm_firmware is not None: # Applied or refused, never ignored. if not any(name == "romm" for name, _path in platforms): print("Error: --romm-firmware applies to RomM, which this run does not " "install for.", file=sys.stderr) sys.exit(1) try: parse_romm_firmware(args.romm_firmware) except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) # Every check --installed-cores makes before anything is written: a # refusal for the second platform must not follow an install of the first. cores_found: dict[tuple[str, Path], list[str]] = {} for plat_name, bios_path in platforms: if plat_name in CORE_PLATFORMS: searched = [args.cores_dir] if args.cores_dir else core_dirs(plat_name, bios_path) cores_found[(plat_name, bios_path)] = installed_cores(searched) elif args.installed_cores: print( f"Error: {plat_name} ships its cores with the system; " f"--installed-cores applies to {', '.join(CORE_PLATFORMS)}", file=sys.stderr, ) sys.exit(1) if args.installed_cores and not cores_found[(plat_name, bios_path)]: _no_core_found(plat_name, bios_path, searched, bool(args.dest)) for plat_name, bios_path in platforms: if bios_path not in installed_paths: installed_paths.append(bios_path) print(f"\nFetching file index for {plat_name}...") manifest = fetch_manifest(plat_name) files = manifest.get("files", []) omitted_files = manifest.get("omitted_files", []) variants = manifest.get("case_variants", []) case_kept = keeps_case(bios_path) if variants and case_kept: # The list is folded for Windows and macOS, but here bios.col and # BIOS.col are two files that two cores open. files = files + variants if not case_kept: files, folded = fold_case_twins(files) if folded: # Batocera's share opened over SMB, an exFAT card: the second # spelling overwrote the first, and every --check found one # of them wrong and fetched it again. shown = ", ".join(folded[:3]) + (" ..." if len(folded) > 3 else "") print( f" {_plural(len(folded), 'file')} left out: {bios_path} does not keep " f"case and holds another file under the same name ({shown})" ) if args.list_targets: targets = fetch_targets(plat_name) if targets is None: sys.exit(1) if not targets: print(f" No targets available for {plat_name}") else: for t in sorted(targets.keys()): cores = targets[t].get("cores") label = "no core list" if cores is None else f"{len(cores)} cores" print(f" {t} ({label})") continue # Target filtering. With no --target, a system that names its board # narrows to it: a Batocera RPi4 took 8.6 GiB where its board needs # 5.5. Only on the device itself, where the board file exists. target = args.target if not target and not args.every_board and args.dest is None: board = detect_board(plat_name) if board: known = fetch_targets(plat_name) or {} if board in known: target = board print(f" Board {board} detected (--every-board installs all boards)") else: print(f" Board {board} has no {PLATFORM_NAMES.get(plat_name, plat_name)} " "core list: installing the files of every board") else: derived = derivative_board(plat_name) if derived: print(f" {derived[0]} board {derived[1]} detected. {derived[0]} " "builds its own set of cores and publishes no core list " "per board: installing the files of every board") if target: targets = target_lists.get(plat_name) if targets is None: targets = fetch_targets(plat_name) if targets is None: sys.exit(1) target_info = targets.get(target) if target_info is None: # Carrying on would install every file, which is the opposite # of what a filter was asked for: a 4 GB download instead of # the subset the target needs. Refuse rather than ignore. _unknown_target(target, plat_name, targets) sys.exit(1) elif target_info.get("cores") is None: print( f" Target '{target}' publishes no core list; " "installing every file" ) else: before = len(files) files, omitted_files = narrow_to_cores( manifest, target_info["cores"], files, omitted_files ) print(f" Filtered {before} -> {len(files)} files for target {target}") found_cores = cores_found.get((plat_name, bios_path), []) if "system_cores" not in manifest: found_cores = [] elif found_cores: print(f" {_plural(len(found_cores), 'core')} installed") if args.installed_cores: if "system_cores" not in manifest: print( f"Error: the {plat_name} file list does not say which cores " "read which systems, which --installed-cores needs.", file=sys.stderr, ) print(_older_list_advice(), file=sys.stderr) sys.exit(1) before = len(files) files, omitted_files = narrow_to_cores( manifest, found_cores, files, omitted_files ) print(f" Narrowed {before} -> {len(files)} files to the installed cores") if args.list_emulators: _print_emulators(plat_name, manifest, files, omitted_files) continue if args.emulator: files, omitted_files = _narrow_to_emulators( args.emulator, manifest, plat_name, files, omitted_files ) selection = _requested_narrowing(args, plat_name, files, omitted_files, manifest) if ( selection is None and interactive and not args.check and not listing and records_selection(files) ): installed = ( narrow_to_cores(manifest, found_cores, files, omitted_files) if found_cores and not args.installed_cores else None ) selection = _prompt_custom_selection( plat_name, files, omitted_files, installed, display_name(plat_name, manifest), ) if selection == INSTALLED_ONLY: files, omitted_files = installed print("\n Same choice without questions: " f"--platform {plat_name} --installed-cores") _INVOCATION.chosen[plat_name] = [("--installed-cores", None)] selection = None elif selection is not None: print("\n Same choice without questions: " + _same_choice_hint(plat_name, *selection)) systems, cores, regions = selection _INVOCATION.chosen[plat_name] = [ *(("--system", name) for name in systems), *(("--core", name) for name in cores), *([("--region", ",".join(regions))] if regions else []), ] if selection is not None: before = len(files) files, omitted_files = narrow(files, omitted_files, *selection) print(f" Narrowed {before} -> {len(files)} files") if args.list_systems or args.list_cores: _print_choices(plat_name, files, omitted_files, args.list_systems, manifest) continue total_size = sum(f.get("size", 0) for f in files) print(f" {len(files)} files ({format_size(total_size)})") if omitted_files: required_omitted = sum( 1 for entry in omitted_files if entry.get("required", True) ) reasons: dict[str, int] = {} for entry in omitted_files: reason = entry.get("reason", "unknown") reasons[reason] = reasons.get(reason, 0) + 1 reason_summary = ", ".join( f"{OMISSION_REASONS.get(reason, reason)}: {count}" for reason, count in sorted(reasons.items()) ) print( f" Not installable: {_plural(len(omitted_files), 'file')} " f"({required_omitted} required, {reason_summary})." ) total_omitted += len(omitted_files) omitted_seen.extend(omitted_files) if plat_name == "romm": try: layout = romm_firmware_layout(bios_path, args.romm_firmware) except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) if layout is not None: files, library = romm_relayout(files, bios_path, *layout) if library != bios_path: installed_paths[installed_paths.index(bios_path)] = library print(f" Firmware laid out as {'/'.join(layout[0] + ('{platform}',) + layout[1])} " f"under {library}") bios_path = library print("\nChecking existing files...") to_download, up_to_date, mismatched = check_local( files, bios_path, jobs=args.jobs, term=term ) present = len(up_to_date) + len(mismatched) print( f" {present}/{len(files)} present " f"({len(up_to_date)} verified, {len(mismatched)} wrong hash)" ) # A partial copy left by an interrupted run is useless once the file # is in place: it goes. for entry in up_to_date: try: stale = _destination_path(bios_path, entry["dest"]) except ValueError: continue stale = stale.parent / f".{stale.name}.part" if stale.is_file(): stale.unlink() # Mismatched files need re-download to_download.extend(mismatched) narrowed = bool( target or args.system or args.core or args.region or args.installed_cores or len(files) != len(manifest.get("files", [])) + len( variants if case_kept else [] ) ) data_dirs = select_data_directories(manifest, files, narrowed) if args.check: if to_download: # Named, not only counted: a user looking for the one bad # BIOS learned "1 files need downloading" and nothing else. # A wrong hash comes first and always in full: listed after # 8000 missing files, it hid behind "... and 8059 more". wrong = {id(entry) for entry in mismatched} missing = [entry for entry in to_download if id(entry) not in wrong] shown = missing if args.verbose else missing[:10] print(f"\n {_plural(len(to_download), 'file')} to download:") for entry in mismatched: print(f" wrong hash: {entry['dest']}") for entry in shown: print(f" missing: {entry['dest']}") if len(shown) < len(missing): print(f" ... and {len(missing) - len(shown)} more missing " "(--verbose lists them all)") print(" Run the same command without --check to fetch them; " "a file with a wrong hash is replaced.") check_pending += len(to_download) else: print("\n All files up to date.") absent = _absent_required(omitted_files, bios_path) # The collection cannot supply these, so they leave the exit # code alone, but a user asking why a game will not start needs # their names, and which system and emulator each one is for. for line in _needed_lines( absent, "Not in the collection and absent here, needed to start an emulator", every=args.verbose, ): print(line) for entry in data_dirs: _root, needed, present = data_directory_state(entry, bios_path) if needed: print(f" {entry['ref']}: {present}/{len(entry['files'])} data files " f"present, {len(needed)} to fetch") check_pending += len(needed) continue if args.dry_run: _print_plan(to_download, bios_path, term, [ (entry, data_directory_state(entry, bios_path)[1]) for entry in data_dirs ]) continue if to_download: dl_size = sum(f.get("size", 0) for f in to_download) if ( interactive and not args.yes and dl_size >= CONFIRM_FROM_BYTES and not _confirm( f"\nDownload {format_size(dl_size)} to {bios_path}? [Y/n] " ) ): print(" Nothing downloaded. --system, --core and --region " "install less (see --help).") total_up_to_date += len(up_to_date) total_declined += len(to_download) continue print(f"\nDownloading {len(to_download)} files ({format_size(dl_size)})...") print(" Press Ctrl+C to stop. Running the same command again " "carries on from here.") try: bios_path.mkdir(parents=True, exist_ok=True) except OSError as exc: sys.stdout.flush() print(f"\nCannot create {bios_path}.", file=sys.stderr) print(f" {exc.strerror}.", file=sys.stderr) print(_write_advice(bios_path, bios_path in assumed_dests, plat_name), file=sys.stderr) # The documented code for a folder that cannot be written, # as when it exists read-only; 1 once an earlier platform # of the run installed something. sys.exit(1 if total_downloaded else 2) _check_free_space(bios_path, dl_size, plat_name) progress = Progress(len(to_download), dl_size, term) failed, interrupted = download_files( to_download, bios_path, jobs=args.jobs, verbose=args.verbose, term=term, progress=progress, ) total_downloaded += len(to_download) - len(failed) total_errors += len(failed) failures.extend(failed) total_bytes += progress.transferred() total_seconds += progress.elapsed() if interrupted: total_interrupted = True break else: print("\n All files up to date.") total_up_to_date += len(up_to_date) # Data trees the pack carries beside the files: Dolphin's Sys, # PPSSPP's assets. Fetched from their source archive, each file # checked against the hash the list records. if data_dirs and not total_interrupted: print("\nData directories:") for entry in data_dirs: counts = install_data_directory(entry, bios_path) total_downloaded += counts["installed"] total_up_to_date += counts["present"] line = f" {entry['ref']}: {counts['installed']} installed, " \ f"{counts['present']} already up to date" if counts["changed"] or counts["absent"]: line += (f", {counts['changed'] + counts['absent']} no longer in " "the source (the pack has them)") print(line) if counts["error"]: print(f" {entry['ref']}: could not read {entry['url']}: " f"{counts['error']}", file=sys.stderr) failures.append((entry["ref"], counts["error"])) total_errors += 1 # Standalone copies if ( manifest.get("standalone_copies") and not args.check and args.standalone_copies ): print("\nStandalone emulators:") lb_root = launchbox_root(os_type) extra_dirs = launchbox_bios_dirs(lb_root) if lb_root else None copied, skipped, copy_failed = do_standalone_copies( manifest, bios_path, os_type, extra_dirs, failures ) if copied or skipped or copy_failed: print( f" {copied} copied, {skipped} skipped (dir not found), " f"{copy_failed} failed" ) total_errors += copy_failed elif manifest.get("standalone_copies") and not args.check: print( "\nStandalone copies skipped " "(use --standalone-copies to opt in)." ) sys.stdout = real_stdout # A check scripted as `--check && launch` reads the exit code only. if args.check and check_pending: sys.exit(1) if not args.check and not listing and not args.dry_run: _report_outcome( installed_paths, total_downloaded, total_up_to_date, total_errors, total_omitted, total_interrupted, failures=failures, transferred=total_bytes, seconds=total_seconds, declined=total_declined, assumed=assumed_dests, omitted_entries=omitted_seen, platform_of={path: plat for plat, path in platforms}, ) def _line_buffered_stdout() -> None: """Flush stdout line by line, as stderr is. Piped to a log (`| tee install.log`), stdout was block-buffered while errors went out at once: the log opened on "Could not fetch the file list" before the banner and the step that produced it. """ reconfigure = getattr(sys.stdout, "reconfigure", None) if reconfigure is not None: try: reconfigure(line_buffering=True) except (ValueError, OSError): # A stream that cannot be reconfigured keeps its own buffering: # the order of a log is cosmetic, the run goes on. return if __name__ == "__main__": _line_buffered_stdout() # A reader that stops early (`install.py --list | head`) ends the run # quietly instead of on a BrokenPipeError traceback. try: main() sys.stdout.flush() except BrokenPipeError: os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) sys.exit(141) except KeyboardInterrupt: # Outside the download phase (fetching the file list, checking the # files present, rebuilding data directories): nothing is half # written there, and a socket traceback read as a crash. print("\nStopped. Run the same command again to carry on from here. " "Nothing is lost.") sys.exit(130)