chore: merge audit fixes (installer)

# Conflicts:
#	install.ps1
#	install.sh
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 06:02:02 +02:00
commit 35b4cacb5d
7 files changed
+1204 -87

No files matched your search

+5 -6
View File
@@ -3,16 +3,15 @@
# Everything runs in a script block of its own: piped into iex, the script
# would otherwise leave its variables and $ErrorActionPreference = "Stop"
# in the user's session, where every later error would stop their commands.
# Neither the script nor the block declares a parameter: one [Parameter()]
# attribute makes it an advanced one, whose common parameters took -v
# (-Verbose), -vb, -d (-Debug) and -ea before install.py could see them.
& {
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$InstallerArguments
)
$InstallerArguments = @($args)
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "496e5a3aaf817301c40f45f184b97fc5607147184195d827a46b92c28fd3d2b0"
$defaultInstallSha256 = "1cf3bd0d3985e3a728513be75cb80682fe1e7a9224da6437e8f1cd9b2a3eedb1"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+541 -47
View File
@@ -26,6 +26,7 @@ import platform
import re
import shlex
import shutil
import socket
import stat
import subprocess
import sys
@@ -146,6 +147,8 @@ _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,
@@ -207,6 +210,9 @@ def _os_default_dests(os_type: str) -> dict[str, Path]:
"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
@@ -715,6 +721,28 @@ _BOARD_SOURCES: dict[str, tuple[str, str]] = {
}
# 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, ("", ""))
@@ -750,7 +778,11 @@ def _parse_ps1_var(path: Path, key: str) -> str | None:
return None
normalized = key.lstrip("$")
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
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}="):
@@ -761,19 +793,25 @@ def _parse_ps1_var(path: Path, key: str) -> str | None:
return None
# Embedded systems, first match wins: Knulli also carries the Batocera
# markers. 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 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"),
("batocera", "file_exists", ("/etc/knulli-release",), "/userdata/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", "file_exists", ("/etc/batocera-version",), "/userdata/bios"),
# 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",), "/mnt/mmc/MUOS/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"),
@@ -797,6 +835,30 @@ def _detect_embedded() -> list[tuple[str, Path]]:
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()
@@ -1042,6 +1104,9 @@ _PATH_RULES: tuple[tuple[str, str, str, str | None], ...] = (
("linux", "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"),
@@ -1093,6 +1158,64 @@ def _detect_by_executable(os_type: str, found: list[tuple[str, Path]]) -> None:
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,)
@@ -1111,7 +1234,9 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
found.extend(_detect_android())
if os_type in ("linux", "wsl"):
found.extend(_detect_embedded())
embedded = _detect_embedded()
muos = _muos_system_dir() if embedded and embedded[0][0] == "retroarch" else None
found.extend([("retroarch", muos)] if muos else embedded)
# EmuDeck (Linux/SteamOS)
home = Path.home()
@@ -1174,13 +1299,9 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
break
if os_type in ("windows", "wsl"):
# EmuDeck Windows
home = Path.home()
emudeck_ps1 = Path(os.environ.get("APPDATA", "")) / "EmuDeck" / "settings.ps1"
if emudeck_ps1.exists():
emu_path = _parse_ps1_var(emudeck_ps1, "$emulationPath")
if emu_path:
found.append(("emudeck", Path(emu_path) / "bios"))
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
@@ -1240,10 +1361,32 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
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"
@@ -2230,7 +2373,10 @@ def _region_rank(file_regions: set[str], requested: list[str]) -> int:
def region_drops(
entries: list[dict], requested: list[str], systems: list[str] | None = None
entries: list[dict],
requested: list[str],
systems: list[str] | None = None,
held: set[str] | None = None,
) -> set[str]:
"""Destinations a region priority list withdraws.
@@ -2238,10 +2384,16 @@ def region_drops(
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.
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()
@@ -2263,7 +2415,9 @@ def region_drops(
groups.setdefault(group, []).append((set(regions), entry["dest"], priority))
keep: set[str] = set()
drop: set[str] = set()
for members in groups.values():
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 = [
@@ -2275,12 +2429,19 @@ def region_drops(
if not regional:
continue
matched = [(rank, dest) for rank, dest in regional if rank < len(requested)]
if matched:
best = min(rank for rank, _dest in matched)
keep |= {dest for rank, dest in matched if rank == best}
drop |= {dest for _rank, dest in regional}
elif world:
world_ranks = [ranks[dest] for dest in world]
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]
@@ -2320,7 +2481,10 @@ def narrow(
files = _filter_by_target(files, cores)
omitted = _filter_by_target(omitted, cores)
if regions:
dropped = region_drops(files + omitted, regions, systems)
# 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:
@@ -2395,7 +2559,8 @@ def resolve_choices(values: list[str], known: list[str], label: str) -> list[str
if unknown:
if not known:
raise ValueError(f"unknown {label}: {', '.join(unknown)}. This list records none.")
listing = "--list-systems" if label == "system" else "--list-cores"
listing = {"system": "--list-systems", "emulator": "--list-emulators"}.get(
label, "--list-cores")
raise ValueError(
f"unknown {label}: {', '.join(unknown)}. {did_you_mean(unknown, known, listing)}"
)
@@ -2563,6 +2728,7 @@ def _download_one(
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
@@ -2581,7 +2747,17 @@ def _download_one(
tmp_path = None
else:
offset = os.fstat(partial.fileno()).st_size
if offset >= expected_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)
@@ -2652,11 +2828,15 @@ def _download_one(
# 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 os.name == "nt":
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()
os.replace(tmp_path, dest)
_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:
@@ -2682,6 +2862,8 @@ def _download_one(
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():
@@ -2702,6 +2884,8 @@ def _download_one(
# 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:
@@ -2726,6 +2910,71 @@ def _download_one(
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:
@@ -2810,6 +3059,10 @@ 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
@@ -2973,6 +3226,34 @@ def _plural(count: int, word: str) -> str:
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,
@@ -3034,11 +3315,18 @@ def _report_outcome(
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.")
print(f"Location that was being written to: {_locations(paths)}")
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. "
@@ -3441,7 +3729,9 @@ def do_standalone_copies(
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), which used to be counted as "dir not found".
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.
"""
@@ -3506,14 +3796,23 @@ def do_standalone_copies(
resolved_dir = target_dir.resolve()
for src in sources:
dest = target_dir / src.name
# A symlink already sitting at the destination would redirect
# the write outside the directory the user opted into, and a
# crafted source name would climb out of it.
if dest.is_symlink() or not _within(dest, resolved_dir):
skipped += 1
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:
shutil.copy2(src, dest)
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}")
@@ -3526,6 +3825,22 @@ def do_standalone_copies(
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:
@@ -3604,8 +3919,7 @@ def _default_dest(os_type: str, platform_name: str) -> Path | None:
if own is not None:
return own
if os_type == "windows":
# EmuDeck's Windows edition keeps the same folder under the profile.
return DEFAULT_DESTS["emudeck"] if platform_name == "emudeck" else None
return None
if platform_name == "retrobat":
return None
return DEFAULT_DESTS.get(platform_name)
@@ -3799,6 +4113,138 @@ def _print_choices(
print(f" {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/<platform> 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/<platform>/<file> 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.
@@ -3828,11 +4274,7 @@ def _narrow_to_emulators(
"which systems, which --emulator needs.", file=sys.stderr)
print(_older_list_advice(), file=sys.stderr)
sys.exit(1)
known = sorted({
name
for profile, aliases in (manifest.get("core_names") or {}).items()
for name in [profile, *aliases]
} | set(available_choices(files, omitted)["cores"]))
known = sorted(emulator_names(manifest, files, omitted))
try:
chosen = resolve_choices(names, known, "emulator")
except ValueError as exc:
@@ -3969,7 +4411,7 @@ def main() -> None:
action="append",
metavar="NAME",
help="install what these emulators read on the platform, as a pack "
"built for them carries (names: --list-cores)",
"built for them carries (names: --list-emulators)",
)
parser.add_argument(
"--installed-cores",
@@ -4014,6 +4456,18 @@ def main() -> None:
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",
@@ -4059,7 +4513,7 @@ def main() -> None:
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_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:
@@ -4147,7 +4601,8 @@ 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
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()
@@ -4265,6 +4720,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
"--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]] = {}
@@ -4331,6 +4798,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
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:
@@ -4376,6 +4852,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
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
@@ -4442,6 +4922,20 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
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
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="496e5a3aaf817301c40f45f184b97fc5607147184195d827a46b92c28fd3d2b0"
DEFAULT_INSTALL_SHA256="1cf3bd0d3985e3a728513be75cb80682fe1e7a9224da6437e8f1cd9b2a3eedb1"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+49 -5
View File
@@ -269,10 +269,23 @@ platforms:
parse_key: system_directory
# Handheld firmwares that run RetroArch as their frontend: muOS,
# ArkOS, and the stock Linux of Anbernic devices (dmenu).
# muOS copies its default config on first launch and RetroArch reads
# /run/muos/storage/bios, bound to MUOS/bios on the second card when
# it holds one, else the first (MustardOS/internal f41f4a89,
# script/control/retroarch.sh:8-20, script/device/bind.sh:4,116-150,
# share/emulator/retroarch/retroarch.default.cfg:2806,3151).
- os: linux
method: config_file
config: /opt/muos/share/info/config/retroarch.cfg
parse_key: system_directory
- os: linux
method: config_file
config: /opt/muos/share/emulator/retroarch/retroarch.default.cfg
parse_key: system_directory
- os: linux
method: file_exists
file: /opt/muos
bios_path: /mnt/mmc/MUOS/bios
bios_path: /run/muos/storage/bios
- os: linux
method: file_exists
file: /mnt/mmc/MUOS
@@ -300,14 +313,22 @@ platforms:
target_source: https://github.com/batocera-linux/batocera.linux
install:
detect:
# Knulli is a Batocera derivative and carries its markers too.
# Knulli, a Batocera derivative (knulli-cfw/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).
- os: linux
method: file_exists
file: /etc/knulli-release
method: os_release
match: [OS_NAME=knulli]
bios_path: /userdata/bios
- os: linux
method: file_exists
file: /etc/batocera-version
file: /usr/share/knulli/knulli.version
bios_path: /userdata/bios
# batocera.linux 38845725, package/batocera/core/batocera-system/
# batocera-system.mk:21-22 writes batocera.arch and batocera.version.
- os: linux
method: file_exists
file: /usr/share/batocera/batocera.version
bios_path: /userdata/bios
standalone_copies: *standalone_copies
recalbox:
@@ -351,6 +372,14 @@ platforms:
target_source: null
install:
detect:
# RetroBat.exe writes its own folder there on every start, wherever it
# was installed (retrobat-executable 702da66a,
# RetroBat/RetroBat/Program.cs:57 and 99, RegistryTools.cs:15-50).
- os: windows
method: registry
key: 'HKCU\SOFTWARE\RetroBat'
value: LatestKnownInstallPath
bios_subdir: bios
# installer.iss: DefaultDirName=C:\RetroBat, the first place to look.
- os: windows
method: path_exists
@@ -384,11 +413,18 @@ platforms:
- os: linux
method: path_exists
path: $HOME/Emulation/bios
# functions/all.ps1:4-14 (emudeck-we 037460ef): the APPDATA settings,
# else the profile ones, and C:\Emulation when neither names a folder.
- os: windows
method: config_file
config: '%APPDATA%\EmuDeck\settings.ps1'
parse_key: $emulationPath
bios_subdir: bios
- os: windows
method: config_file
config: '%USERPROFILE%\EmuDeck\settings.ps1'
parse_key: $emulationPath
bios_subdir: bios
standalone_copies: *standalone_copies
lakka:
config: lakka.yml
@@ -475,6 +511,14 @@ platforms:
- os: linux
method: path_exists
path: /romm/library/bios
# RomM 5.4 places firmware by filesystem.structure.firmware in this
# file, relative to /romm/library (rommapp/romm 5.4.0,
# backend/config/config_manager.py:58,101-109,264-265,564-568): a
# {platform}/bios library has no library/bios to find.
- os: linux
method: path_exists
path: /romm/config/config.yml
bios_path: /romm/library/bios
cores:
- atari800
- clk
+507 -7
View File
@@ -19,6 +19,7 @@ import threading
import unittest
from types import SimpleNamespace
import unittest.mock
import urllib.error
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -1027,7 +1028,8 @@ class DetectionFollowsTheRegistry(unittest.TestCase):
with unittest.mock.patch.object(install, "_parse_os_release", return_value={}), \
unittest.mock.patch.object(
Path, "exists",
lambda p: str(p) in ("/etc/knulli-release", "/etc/batocera-version"),
lambda p: str(p) in ("/usr/share/knulli/knulli.version",
"/usr/share/batocera/batocera.version"),
):
self.assertEqual(install._detect_embedded(), [("batocera", Path("/userdata/bios"))])
# What ROCKNIX writes since 2025: OS_NAME and HW_DEVICE, no ID.
@@ -1623,6 +1625,9 @@ class TestStandaloneCopyTargetsAreUntrusted(unittest.TestCase):
self.assertIsInstance(install._validate_manifest(manifest, "retroarch"), dict)
def test_symlinked_destination_is_not_written_through(self):
"""A link at the name was skipped in silence and counted as "dir
not found" while the folder existed: the emulator kept whatever the
link pointed at. The link now gives way to the file."""
bios = Path(_tempdir(self))
(bios / "boot.bin").write_text("rom")
target = Path(_tempdir(self))
@@ -1630,12 +1635,17 @@ class TestStandaloneCopyTargetsAreUntrusted(unittest.TestCase):
outside.write_text("untouched")
(target / "boot.bin").symlink_to(outside)
copied, skipped, _ = install.do_standalone_copies(
self._manifest(str(target)), bios, "linux"
)
self.assertEqual(copied, 0)
self.assertEqual(skipped, 1)
out = io.StringIO()
with contextlib.redirect_stdout(out):
copied, skipped, failed = install.do_standalone_copies(
self._manifest(str(target)), bios, "linux"
)
self.assertEqual((copied, skipped, failed), (1, 0, 0))
self.assertEqual(outside.read_text(), "untouched")
self.assertFalse((target / "boot.bin").is_symlink())
self.assertEqual((target / "boot.bin").read_text(), "rom")
self.assertIn("link replaced", out.getvalue())
self.assertEqual(sorted(p.name for p in target.iterdir()), ["boot.bin"])
@unittest.skipUnless(shutil.which("pwsh"), "pwsh not available")
@@ -2283,6 +2293,29 @@ class ThePowerShellWrapperPassesTheStatusOn(unittest.TestCase):
self.assertNotIn("installer failed", proc.stdout + proc.stderr)
class ThePowerShellWrapperPassesEveryArgument(unittest.TestCase):
"""A [Parameter()] attribute made install.ps1 an advanced script, whose
common parameters took -v (as -Verbose), -vb, -d and -ea: the documented
`-v` never reached install.py."""
@unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell")
def test_short_flags_reach_the_installer(self):
folder = Path(_tempdir(self))
shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1")
(folder / "install.py").write_text(
"import json, sys\nprint(json.dumps(sys.argv[1:]))\n"
)
for args in (["-v", "--check"], ["-vb"], ["-Verbose"], ["-d", "-ea", "x"],
["--platform", "a b"], []):
with self.subTest(args=args):
proc = subprocess.run(
["pwsh", "-NoProfile", "-File", str(folder / "install.ps1"), *args],
capture_output=True, text=True, timeout=120,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(json.loads(proc.stdout), args)
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, "needs a folder this user cannot write")
class AnUnwritableFolderIsCodeTwo(unittest.TestCase):
"""A BIOS folder that could not be created ended with 1, while the same
@@ -3163,8 +3196,9 @@ class NoFallbackOutsideWhatAPlatformReads(unittest.TestCase):
self.assertIsNone(install._default_dest("windows", "batocera"))
self.assertIsNone(install._default_dest("windows", "retropie"))
self.assertEqual(install._default_dest("linux", "batocera"), Path("/userdata/bios"))
# emudeck-we functions/all.ps1:12-14, not the profile folder.
self.assertEqual(
install._default_dest("windows", "emudeck"), install.DEFAULT_DESTS["emudeck"]
install._default_dest("windows", "emudeck"), install._host_path("C:/Emulation/bios")
)
def test_a_forced_platform_without_a_folder_is_refused(self):
@@ -3601,5 +3635,471 @@ class DestAloneFollowsWhatIsFoundHere(unittest.TestCase):
self.assertIn("Which platform's files go to /media/card?", out)
class _FakeResponse:
"""What urlopen returns, serving one payload from its start."""
def __init__(self, payload: bytes) -> None:
self.status = 200
self.headers = {"Content-Length": str(len(payload))}
self._body = io.BytesIO(payload)
def __enter__(self) -> _FakeResponse:
return self
def __exit__(self, *_exc: object) -> None:
return None
def read(self, size: int = -1) -> bytes:
return self._body.read(size)
def _sharing_violation() -> PermissionError:
exc = PermissionError(13, "The process cannot access the file")
exc.winerror = 32
return exc
class ACompletePartialIsHandedOffOnWindows(unittest.TestCase):
"""Windows renames no open file, so the run that verified a large copy
released its lock before the rename. A second run claimed the copy in
that moment, found it complete and truncated it, and the first run's
rename failed with a sharing violation reported as "folder not
writable"."""
PAYLOAD = bytes(range(256)) * 200
ENTRY = {
"dest": "big.bin", "repo_path": "bios/big.bin", "size": len(PAYLOAD),
"sha256": hashlib.sha256(PAYLOAD).hexdigest(),
}
def _run(self, tmp: Path, seen: list[int]):
part = tmp / ".big.bin.part"
def urlopen(request, timeout=None):
seen.append(part.stat().st_size if part.exists() else -1)
return _FakeResponse(self.PAYLOAD)
with unittest.mock.patch.object(install, "RESUME_FROM_SIZE", 1024), \
unittest.mock.patch.object(install, "_NO_RENAME_WHILE_OPEN", True), \
unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen):
return install._download_one(dict(self.ENTRY), tmp)
def test_the_other_runs_copy_is_left_whole(self):
tmp = Path(_tempdir(self))
(tmp / ".big.bin.part").write_bytes(self.PAYLOAD)
seen: list[int] = []
self.assertEqual(self._run(tmp, seen), ("big.bin", True, ""))
# The download ran while the complete copy was still whole.
self.assertEqual(seen, [len(self.PAYLOAD)])
self.assertEqual((tmp / "big.bin").read_bytes(), self.PAYLOAD)
# Nothing held it afterwards: it was a leftover, now removed.
self.assertFalse((tmp / ".big.bin.part").exists())
def test_the_rename_waits_for_the_other_run_to_let_go(self):
tmp = Path(_tempdir(self))
source = tmp / "copy"
source.write_bytes(b"x")
real_replace = os.replace
calls = []
def replace(src, dst):
calls.append(src)
if len(calls) < 3:
raise _sharing_violation()
return real_replace(src, dst)
with unittest.mock.patch.object(install.os, "replace", replace), \
unittest.mock.patch.object(install.time, "sleep"):
install._replace_released(source, tmp / "dest", {})
self.assertEqual(len(calls), 3)
self.assertEqual((tmp / "dest").read_bytes(), b"x")
def test_a_copy_the_other_run_placed_counts_once_verified(self):
tmp = Path(_tempdir(self))
dest = tmp / "dest"
dest.write_bytes(self.PAYLOAD)
expected = {"sha256": self.ENTRY["sha256"]}
install._replace_released(tmp / "gone", dest, expected)
dest.write_bytes(b"other bytes")
with self.assertRaises(FileNotFoundError):
install._replace_released(tmp / "gone", dest, expected)
def test_a_sharing_violation_is_no_refused_folder(self):
self.assertFalse(install._write_refused(_sharing_violation()))
self.assertTrue(install._write_refused(PermissionError(13, "denied")))
class TheEmulatorNamesHaveTheirOwnListing(unittest.TestCase, _LoopbackRun):
"""--emulator nope said "--list-cores lists all 441" and --list-cores
printed 189: the buildbot names --emulator accepts were listed nowhere."""
def setUp(self):
self.env = self.serve(self)
def test_the_hint_counts_what_the_listing_prints(self):
proc = _install_run(self.env, "--platform", "retroarch", "--no-input",
"--emulator", "nope")
self.assertEqual(proc.returncode, 1, proc.stdout)
found = re.search(r"--list-emulators lists all (\d+)\.", proc.stderr)
self.assertIsNotNone(found, proc.stderr)
listing = _install_run(self.env, "--platform", "retroarch", "--no-input",
"--list-emulators")
self.assertEqual(listing.returncode, 0, listing.stderr)
names = [re.split(r"\s{2,}", line.strip())[0]
for line in listing.stdout.splitlines() if line.startswith(" ")]
manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text())
accepted = install.emulator_names(
manifest, manifest["files"], manifest.get("omitted_files", []))
self.assertEqual(int(found.group(1)), len(accepted))
self.assertTrue(set(accepted) <= set(names), set(accepted) - set(names))
self.assertIn("mednafen_psx", accepted)
self.assertRegex(listing.stdout, r"mednafen_psx\s+same as beetle_psx")
def test_every_listed_name_is_accepted(self):
manifest = {"core_names": {"beetle_psx": ["mednafen_psx"]}}
files = [{"dest": "a", "cores": ["pcsx_rearmed"]}]
names = install.emulator_names(manifest, files, [])
self.assertEqual(names, {"beetle_psx": "beetle_psx", "mednafen_psx": "beetle_psx",
"pcsx_rearmed": "pcsx_rearmed"})
self.assertEqual(install.resolve_choices(["MEDNAFEN_PSX"], sorted(names), "emulator"),
["mednafen_psx"])
class ANetworkFailureNamesTheNetwork(unittest.TestCase):
"""Every transfer cut by the connection ended on "Location that was being
written to" and "once that is resolved", naming the folder and nothing
about the connection or the proxy."""
def _fail(self, error: BaseException) -> str:
def urlopen(*_args, **_kwargs):
raise error
entry = {"dest": "a.bin", "repo_path": "bios/a.bin", "size": 10,
"sha256": "0" * 64}
with unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen), \
unittest.mock.patch.object(install, "_retry_delay", return_value=0):
_dest, ok, reason = install._download_one(entry, Path(_tempdir(self)))
self.assertFalse(ok)
return reason
def test_cuts_and_refusals_are_network_failures(self):
import http.client
import socket
for error in (
urllib.error.URLError(socket.gaierror(-2, "Name or service not known")),
http.client.IncompleteRead(b"abc", 10),
ConnectionResetError(104, "Connection reset by peer"),
socket.timeout("timed out"),
urllib.error.HTTPError("u", 503, "Service Unavailable", None, None),
):
with self.subTest(error=error):
self.assertIsInstance(self._fail(error), install.NetworkReason)
def test_a_full_disk_and_a_missing_file_are_not(self):
import errno
for error in (OSError(errno.ENOSPC, "No space left on device"),
urllib.error.HTTPError("u", 404, "Not Found", None, None)):
with self.subTest(error=error):
self.assertNotIsInstance(self._fail(error), install.NetworkReason)
def test_the_summary_points_at_the_connection(self):
failures = [("a.bin", install.NetworkReason("timed out")),
("b.bin", install.NetworkReason("Connection reset by peer"))]
out = io.StringIO()
with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as raised:
install._report_outcome([Path("/bios")], 0, 0, 2, 0, False, failures=failures)
self.assertEqual(raised.exception.code, 2)
text = out.getvalue()
self.assertIn("HTTPS_PROXY", text)
self.assertIn("Destination: /bios", text)
self.assertNotIn("being written to", text)
class RetroBatIsFoundWhereItSaysItIs(unittest.TestCase):
"""RetroBat on D:\\RetroBat was not detected, and --platform retrobat
wrote 7.6 GB into C:\\RetroBat\\bios, a folder it does not read.
RetroBat.exe writes its folder to the registry on every start."""
def test_the_table_is_the_registry(self):
import yaml # noqa: PLC0415
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
rules = {
(rule["os"], name, rule["key"], rule["value"], rule["bios_subdir"])
for name, entry in registry["platforms"].items()
for rule in (entry.get("install") or {}).get("detect", [])
if rule["method"] == "registry"
}
self.assertEqual(set(install._REGISTRY_RULES), rules)
def test_the_registry_folder_wins_over_the_default(self):
folder = Path(_tempdir(self))
with unittest.mock.patch.object(install, "_read_registry",
return_value=str(folder) + "\\"):
found: list = []
install._detect_by_registry("windows", found)
self.assertEqual(found, [("retrobat", folder / "bios")])
def test_a_folder_since_removed_is_passed_over(self):
gone = Path(_tempdir(self)) / "moved"
with unittest.mock.patch.object(install, "_read_registry", return_value=str(gone)):
found: list = []
install._detect_by_registry("windows", found)
self.assertEqual(found, [])
def test_wsl_reads_it_through_reg_exe(self):
output = ("\nHKEY_CURRENT_USER\\SOFTWARE\\RetroBat\n"
" LatestKnownInstallPath REG_SZ D:\\Games\\RetroBat\\\n\n")
with unittest.mock.patch.dict(install._WSL, {"active": True, "root": "/mnt"}), \
unittest.mock.patch.object(install.os, "name", "posix"), \
unittest.mock.patch.object(
install.subprocess, "run",
return_value=SimpleNamespace(stdout=output, returncode=0)) as run:
value = install._read_registry("HKCU\\SOFTWARE\\RetroBat", "LatestKnownInstallPath")
self.assertEqual(value, "D:\\Games\\RetroBat\\")
self.assertEqual(install._host_path(value.rstrip("\\")),
Path("/mnt/d/Games/RetroBat"))
self.assertEqual(run.call_args[0][0][:2], ["reg.exe", "query"])
class EmuDeckWindowsIsFoundAsItsScriptsFindIt(unittest.TestCase):
"""EmuDeck's Windows scripts read %APPDATA%\\EmuDeck\\settings.ps1, else
%USERPROFILE%\\EmuDeck\\settings.ps1, and take C:\\Emulation when neither
names a folder (functions/all.ps1:4-14). Only the first was read, and a
forced --platform emudeck wrote into %USERPROFILE%\\Emulation\\bios."""
def _env(self) -> tuple[Path, Path]:
root = Path(_tempdir(self))
appdata, profile = root / "appdata", root / "profile"
patcher = unittest.mock.patch.dict(
os.environ, {"APPDATA": str(appdata), "USERPROFILE": str(profile)})
patcher.start()
self.addCleanup(patcher.stop)
return appdata, profile
@staticmethod
def _settings(folder: Path, text: str, encoding: str = "utf-8-sig") -> None:
(folder / "EmuDeck").mkdir(parents=True, exist_ok=True)
(folder / "EmuDeck" / "settings.ps1").write_bytes(text.encode(encoding))
def test_the_profile_settings_are_read_when_appdata_has_none(self):
_appdata, profile = self._env()
self._settings(profile, '\n$emulationPath="/games/Emulation"\n')
self.assertEqual(install._emudeck_windows_bios(), Path("/games/Emulation/bios"))
def test_appdata_comes_first(self):
appdata, profile = self._env()
self._settings(profile, '$emulationPath="/b/Emulation"\n')
self._settings(appdata, '$emulationPath="/a/Emulation"\n', "utf-16")
self.assertEqual(install._emudeck_windows_bios(), Path("/a/Emulation/bios"))
def test_settings_naming_no_folder_mean_c_emulation(self):
appdata, _profile = self._env()
self._settings(appdata, '$device="Windows"\n')
self.assertEqual(install._emudeck_windows_bios(),
install._host_path("C:\\Emulation") / "bios")
def test_a_network_install_is_its_share(self):
appdata, _profile = self._env()
self._settings(appdata, '$networkInstallation="true"\n'
'$VolName="/nas/games"\n'
'$emulationPath=$testdrive.ToString() + ":\\Emulation"\n')
self.assertEqual(install._emudeck_windows_bios(),
Path("/nas/games\\Emulation") / "bios")
def test_no_settings_detects_nothing(self):
self._env()
self.assertIsNone(install._emudeck_windows_bios())
def test_a_forced_platform_falls_back_to_c_emulation(self):
self.assertEqual(install._default_dest("windows", "emudeck"),
install._host_path("C:/Emulation/bios"))
class MuOsIsReadFromItsRetroArchConfig(unittest.TestCase):
"""muOS wrote to /mnt/mmc/MUOS/bios while RetroArch reads
/run/muos/storage/bios, bound to the second card when it holds MUOS/bios,
and --installed-cores found no core in /opt/muos/share/core."""
def _muos(self, *, user_config: bool) -> tuple[Path, Path]:
root = Path(_tempdir(self))
muos = root / "opt" / "muos"
system_dir, cores = root / "run" / "bios", root / "core"
cores.mkdir(parents=True)
configs = (muos / "share/info/config/retroarch.cfg",
muos / "share/emulator/retroarch/retroarch.default.cfg")
default = configs[1]
default.parent.mkdir(parents=True)
default.write_text(f'libretro_directory = "{cores}"\n'
f'system_directory = "{root / "default-bios"}"\n')
if user_config:
configs[0].parent.mkdir(parents=True)
configs[0].write_text(f'libretro_directory = "{cores}"\n'
f'system_directory = "{system_dir}"\n')
for patcher in (
unittest.mock.patch.object(install, "MUOS_ROOT", muos),
unittest.mock.patch.object(install, "MUOS_RETROARCH_CONFIGS", configs),
unittest.mock.patch.object(
install, "_detect_embedded",
lambda: [("retroarch", Path("/run/muos/storage/bios"))]),
unittest.mock.patch.dict(os.environ, {"HOME": str(root / "home")}),
unittest.mock.patch.dict(install._RETROARCH_CONFIGS, clear=True),
):
patcher.start()
self.addCleanup(patcher.stop)
return (system_dir if user_config else root / "default-bios"), cores
def test_the_users_config_decides(self):
system_dir, cores = self._muos(user_config=True)
found = install.detect_platforms("linux")
self.assertIn(("retroarch", system_dir), found)
self.assertNotIn(("retroarch", Path("/run/muos/storage/bios")), found)
self.assertIn(cores, install.core_dirs("retroarch", system_dir))
def test_before_the_first_launch_the_default_does(self):
system_dir, cores = self._muos(user_config=False)
self.assertIn(("retroarch", system_dir), install.detect_platforms("linux"))
self.assertIn(cores, install.core_dirs("retroarch", system_dir))
def test_without_a_config_the_bound_folder_is_used(self):
rule = next(r for r in install._EMBEDDED_RULES if r[2] == ("/opt/muos",))
self.assertEqual(rule[3], "/run/muos/storage/bios")
class BatoceraAndKnulliByWhatTheirBuildsWrite(unittest.TestCase):
"""No Batocera build writes /etc/batocera-version (batocera-system.mk
writes /usr/share/batocera/batocera.version), and Knulli writes no
/etc/knulli-release and removes /usr/share/batocera: neither was
detected, and a Knulli board got every board's files without a word."""
def _detect(self, release: dict, files: tuple[str, ...]):
with unittest.mock.patch.object(install, "_parse_os_release", return_value=release), \
unittest.mock.patch.object(Path, "exists", lambda p: str(p) in files):
return install._detect_embedded()
def test_batocera_by_its_version_file(self):
self.assertEqual(self._detect({}, ("/usr/share/batocera/batocera.version",)),
[("batocera", Path("/userdata/bios"))])
self.assertEqual(self._detect({}, ("/etc/batocera-version",)), [])
def test_knulli_by_its_os_release_or_version_file(self):
self.assertEqual(self._detect({"OS_NAME": "knulli"}, ()),
[("batocera", Path("/userdata/bios"))])
self.assertEqual(self._detect({}, ("/usr/share/knulli/knulli.version",)),
[("batocera", Path("/userdata/bios"))])
def test_a_knulli_board_is_named_and_not_narrowed(self):
board = Path(_tempdir(self)) / "knulli.arch"
board.write_text("a133")
with unittest.mock.patch.dict(install._DERIVATIVE_BOARDS,
{"batocera": ("Knulli", str(board))}):
self.assertEqual(install.derivative_board("batocera"), ("Knulli", "a133"))
self.assertIsNone(install.derivative_board("recalbox"))
def test_the_run_says_why_every_board_is_installed(self):
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
block = source[source.index("board = detect_board(plat_name)"):]
block = block[:block.index("if target:")]
self.assertIn("has no", block)
self.assertIn("derivative_board(plat_name)", block)
class RomMFirmwareFollowsItsTemplate(unittest.TestCase):
"""RomM 5.4 reads firmware where filesystem.structure.firmware says, and
a {platform}/roms library is told to declare "{platform}/bios". The
installer only wrote bios/<platform>/, where such a library lists none."""
CONFIG = (
"# RomM\n"
"exclude:\n"
" platforms: []\n"
"filesystem:\n"
" structure:\n"
" default: \"{platform}/roms/{game}\"\n"
" firmware: \"{platform}/bios\" # as RomM suggests\n"
"system:\n"
" firmware: elsewhere\n"
)
def _romm(self, config: str | None) -> Path:
base = Path(_tempdir(self))
library = base / "library"
(library / "psx" / "roms").mkdir(parents=True)
if config is not None:
(base / "config").mkdir()
(base / "config" / "config.yml").write_text(config)
return library / "bios"
def test_templates_parse_as_romm_parses_them(self):
self.assertEqual(install.parse_romm_firmware("bios/{platform}"), (("bios",), ()))
self.assertEqual(install.parse_romm_firmware("{platform}/bios"), ((), ("bios",)))
self.assertEqual(install.parse_romm_firmware("/a/{platform}/b/c/"),
(("a",), ("b", "c")))
for bad in ("", "bios", "{platform}/{game}", "{region}/{platform}",
"../{platform}", "{platform}/{platform}"):
with self.subTest(template=bad), self.assertRaises(ValueError):
install.parse_romm_firmware(bad)
def test_the_template_is_read_from_config_yml(self):
bios = self._romm(self.CONFIG)
layout = install.romm_firmware_layout(bios, None)
self.assertEqual(layout, ((), ("bios",)))
files = [{"dest": "psx/scph5501.bin"}, {"dest": "3do/panafz1.bin"}]
moved, root = install.romm_relayout(files, bios, *layout)
self.assertEqual(root, bios.parent)
self.assertEqual([f["dest"] for f in moved],
["psx/bios/scph5501.bin", "3do/bios/panafz1.bin"])
def test_the_default_keeps_the_pack_layout(self):
bios = self._romm("filesystem:\n structure:\n firmware: bios/{platform}\n")
files = [{"dest": "psx/scph5501.bin"}]
self.assertEqual(install.romm_relayout(files, bios, *install.romm_firmware_layout(
bios, None)), (files, bios))
def test_a_platform_first_library_without_a_template_is_refused(self):
bios = self._romm(None)
with self.assertRaises(ValueError) as raised:
install.romm_firmware_layout(bios, None)
self.assertIn('--romm-firmware "{platform}/bios"', str(raised.exception))
self.assertEqual(install.romm_firmware_layout(bios, "{platform}/bios"),
((), ("bios",)))
def test_the_option_is_refused_where_romm_is_not_installed(self):
err = io.StringIO()
with unittest.mock.patch.object(install, "detect_os", return_value="linux"), \
unittest.mock.patch("sys.argv", [
"install.py", "--platform", "batocera", "--dest", _tempdir(self),
"--romm-firmware", "{platform}/bios", "--no-input"]), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as caught:
install.main()
self.assertEqual(caught.exception.code, 1)
self.assertIn("--romm-firmware applies to RomM", err.getvalue())
class RomMFirmwareInstallsUnderItsTemplate(unittest.TestCase, _LoopbackRun):
"""The whole run, over loopback: config.yml beside the library decides."""
def setUp(self):
self.env = self.serve(self)
def test_lynx_lands_in_its_platform_folder(self):
base = self.home / "romm"
(base / "library" / "lynx" / "roms").mkdir(parents=True)
(base / "config").mkdir()
(base / "config" / "config.yml").write_text(
"filesystem:\n structure:\n default: \"{platform}/roms/{game}\"\n"
" firmware: \"{platform}/bios\"\n")
proc = _install_run(self.env, "--platform", "romm", "--dest",
str(base / "library" / "bios"), "--system", "atari-lynx",
"--no-input")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertTrue((base / "library" / "lynx" / "bios" / "lynxboot.img").is_file())
self.assertFalse((base / "library" / "bios").exists())
self.assertIn("{platform}/bios", proc.stdout)
if __name__ == "__main__":
unittest.main()
+60 -8
View File
@@ -44,6 +44,7 @@ class OneRegionVocabulary(unittest.TestCase):
self.assertEqual(install.REGION_ALIASES, region.ALIASES)
self.assertEqual(install.WORLD_REGION, region.WORLD)
self.assertEqual(install.REGIONS, region.REGIONS)
self.assertEqual(install.FILE_GROUP, region.FILE_GROUP)
class TheRegionRuleIsTheBuilders(unittest.TestCase):
@@ -74,9 +75,11 @@ class TheRegionRuleIsTheBuilders(unittest.TestCase):
index[dest] = {"regions": regions,
"has_untagged": not regions, "emulators": [],
"priorities": {priority}}
groups.setdefault(f"g{g}", []).append((dest, dest))
if f"g{g}" not in entries[dest]["region_groups"]:
entries[dest]["region_groups"].append(f"g{g}")
# A lone core file no system names gets a group of its own.
group = f"g{g}" if rng.random() < 0.85 else f"e{region.FILE_GROUP}{dest}"
groups.setdefault(group, []).append((dest, dest))
if group not in entries[dest]["region_groups"]:
entries[dest]["region_groups"].append(group)
for entry in entries.values():
if not entry["regions"]:
del entry["regions"], entry["region_groups"]
@@ -84,17 +87,57 @@ class TheRegionRuleIsTheBuilders(unittest.TestCase):
requested = rng.sample(["north-america", "europe", "japan", "uk", "canada",
"taiwan", "asia-ntsc", "china", "asia-pal"],
rng.randint(1, 3))
return groups, index, list(entries.values()), requested
held = {dest for dest in entries if rng.random() < 0.7}
return groups, index, list(entries.values()), requested, held
def test_two_thousand_random_platforms(self):
rng = random.Random(20261010)
for trial in range(2000):
groups, index, entries, requested = self._case(rng)
groups, index, entries, requested, held = self._case(rng)
with self.subTest(trial=trial):
self.assertEqual(
install.region_drops(entries, requested),
region.resolve_region_drops(groups, index, requested),
)
self.assertEqual(
install.region_drops(entries, requested, held=held),
region.resolve_region_drops(groups, index, requested, held),
)
def test_narrow_passes_the_installable_files_as_held(self):
"""The builder passes what the collection holds (generate_pack
_select_variants). An omission is the installer's unheld file."""
rng = random.Random(20261021)
for trial in range(500):
groups, index, entries, requested, held = self._case(rng)
files = [dict(e, size=1, cores=None) for e in entries if e["dest"] in held]
omitted = [dict(e, cores=None) for e in entries if e["dest"] not in held]
kept, still_omitted = install.narrow(files, omitted, [], [], requested)
drops = region.resolve_region_drops(groups, index, requested, held)
with self.subTest(trial=trial):
self.assertEqual(
{f["dest"] for f in kept},
{f["dest"] for f in files} - drops,
)
self.assertEqual(
{o["dest"] for o in still_omitted},
{o["dest"] for o in omitted} - drops,
)
def test_a_lone_core_file_leaves_its_systems_drop(self):
"""Dolphin's GC/EUR/IPL.bin is a core extra no system names, plus the
GameCube's own declaration. The builder skips the one-file group, so
--region us drops it."""
entries = [
{"dest": "GC/USA/IPL.bin", "regions": ["north-america"],
"region_system_groups": ["nintendo-gamecube"]},
{"dest": "GC/EUR/IPL.bin", "regions": ["europe"],
"region_groups": [f"dolphin{region.FILE_GROUP}GC/EUR/IPL.bin"],
"region_system_groups": ["nintendo-gamecube"]},
]
self.assertEqual(
install.region_drops(entries, ["north-america"]), {"GC/EUR/IPL.bin"}
)
class Narrowing(unittest.TestCase):
@@ -141,12 +184,21 @@ class Narrowing(unittest.TestCase):
self.assertIn("scph5501.bin", files)
self.assertEqual(omitted, [])
def test_an_unavailable_match_still_wins_as_the_builder_counts_it(self):
def test_an_unavailable_match_never_displaces_a_held_file(self):
"""The builder keeps the held BIOS when the requested one is missing
(region.resolve_region_drops, held). The missing one stays reported."""
files, omitted = self._dests([], [], ["europe"])
self.assertNotIn("scph5500.bin", files)
self.assertNotIn("scph5501.bin", files)
self.assertIn("scph5500.bin", files)
self.assertIn("scph5501.bin", files)
self.assertEqual(omitted, ["scph5502.bin"])
def test_a_missing_japanese_bios_keeps_the_american_one(self):
files = [dict(self.FILES[1])]
omitted = [dict(self.OMITTED[0], dest="scph5500.bin", regions=["japan"])]
kept, still = install.narrow(files, omitted, [], [], ["japan"])
self.assertEqual([f["dest"] for f in kept], ["scph5501.bin"])
self.assertEqual([o["dest"] for o in still], ["scph5500.bin"])
def test_no_match_keeps_every_regional_file(self):
files, _ = self._dests([], [], ["brazil"])
self.assertIn("scph5500.bin", files)
+41 -13
View File
@@ -231,10 +231,12 @@ what the options name, everything by default.
| `--cores-dir PATH` | The folder RetroArch loads its cores from, for `--installed-cores` when it is not found. Implies `--installed-cores` |
| `--system NAMES` | Install only these systems. Comma-separated or repeated. `--list-systems` prints the names, `other` included |
| `--core NAMES` | Keep the platform's own list and only the extra files these cores load. `--list-cores` prints the names |
| `--emulator NAMES` | Install what these emulators read on the detected platform, and nothing else: their own files and the platform's files of the systems they run. Each emulator page on the site gives this command. `--list-cores` prints the names |
| `--emulator NAMES` | Install what these emulators read on the detected platform, and nothing else: their own files and the platform's files of the systems they run. Each emulator page on the site gives this command. `--list-emulators` prints the names, buildbot names such as `mednafen_psx` included |
| `--region LIST` | Preferred regions, best first (`us,eu,jp`): keep one BIOS per region where a system has several. Accepts the names and aliases of `generate_pack.py --region` |
| `--list-systems` | Print a platform's systems with the files and size each holds |
| `--list-cores` | Print the cores whose extra files a platform installs |
| `--list-emulators` | Print every name `--emulator` accepts, each buildbot name beside the emulator it stands for |
| `--romm-firmware TEMPLATE` | RomM: where each platform's firmware goes in the library, as `filesystem.structure.firmware` in RomM's `config.yml` says (`{platform}/bios` for a library laid out as `{platform}/roms`). Read from `config.yml` beside the library when it is there. See [RomM firmware layout](#romm-firmware-layout) |
| `--no-input` | Never ask a question, even at a terminal |
| `--check` | Report and exit without writing |
| `--dry-run` | Show what would be downloaded, by system and with sizes, without writing anything |
@@ -279,17 +281,21 @@ wins, since a machine is only one of them.
| Evidence | Platform | BIOS directory |
|----------|----------|----------------|
| `/etc/os-release` `OS_NAME=ROCKNIX` (or `ID=rocknix` on images before 2025) | ROCKNIX | `/storage/roms/bios` |
| `/etc/knulli-release` | Batocera | `/userdata/bios` |
| `/etc/os-release` `OS_NAME=knulli` or `/usr/share/knulli/knulli.version` | Batocera (Knulli) | `/userdata/bios` |
| `/etc/os-release` `ID=lakka` | Lakka | `/storage/system` |
| `/etc/batocera-version` | Batocera | `/userdata/bios` |
| `/usr/share/batocera/batocera.version` | Batocera | `/userdata/bios` |
| `/recalbox/recalbox.version` or `/usr/bin/recalbox-settings` | Recalbox | `/recalbox/share/bios` |
| `/opt/muos` or `/mnt/mmc/MUOS/` | RetroArch | `/mnt/mmc/MUOS/bios` |
| `/opt/muos` | RetroArch (muOS) | `system_directory` of `/opt/muos/share/info/config/retroarch.cfg`, else of the default it is copied from: `/run/muos/storage/bios`, which muOS binds to `MUOS/bios` on the second card when that card holds one, else the first. Cores are read from that config's `libretro_directory` (`/opt/muos/share/core`) |
| `/mnt/mmc/MUOS/` | RetroArch | `/mnt/mmc/MUOS/bios` |
| `/home/ark` and `/opt/system` | RetroArch | `/roms/bios` |
| `/mnt/vendor/bin/dmenu.bin` | RetroArch | `/mnt/mmc/bios` |
Batocera, Recalbox, ROCKNIX and Lakka also name the board they were built for, and
that board narrows the install unless `--target` or `--every-board` says
otherwise (see the options above).
otherwise (see the options above). A board with no core list of its own is
named and gets the files of every board. Knulli names its board in
`/usr/share/knulli/knulli.arch` but builds its own set of cores, so Batocera's
per-board lists are not applied to it and the run says so.
Some platforms keep their BIOS in a fixed directory, and that directory is the
evidence. They are looked for after the configurations below, for a platform
@@ -302,6 +308,7 @@ not already found that way.
| `~/Emulation/bios` | EmuDeck | the same |
| `~/.var/app/net.retrodeck.retrodeck` | RetroDECK | `~/retrodeck` |
| `/romm/library/bios` | RomM | the same |
| `/romm/config/config.yml` | RomM | `/romm/library/bios`, laid out by its firmware template |
| `~/RetroPie/BIOS` | RetroPie | the same |
| `%USERPROFILE%\BizHawk\Firmware` | BizHawk | the same |
| `EmuHawk.exe` or `EmuHawkMono.sh` in a `BizHawk*` folder of the home folder or one level down (`~/Games/BizHawk-2.11.1`), the newest | BizHawk | `Firmware` beside it |
@@ -318,8 +325,9 @@ WSL does not pass them. A forced `--platform retrobat` falls back to
| Evidence | Platform | BIOS directory |
|----------|----------|----------------|
| `HKCU\SOFTWARE\RetroBat` `LatestKnownInstallPath`, which `RetroBat.exe` writes on every start (read through `reg.exe` under WSL) | RetroBat | that folder + `\bios`, before the fixed folders above |
| `~/.config/EmuDeck/settings.sh` | EmuDeck | `emulationPath` + `/bios` |
| `%APPDATA%\EmuDeck\settings.ps1` | EmuDeck | `$emulationPath` + `\bios` |
| `%APPDATA%\EmuDeck\settings.ps1`, else `%USERPROFILE%\EmuDeck\settings.ps1` | EmuDeck | `$emulationPath` + `\bios`, `C:\Emulation\bios` when it names none (a network install: the `$VolName` share + `\Emulation\bios`) |
| `~/.var/app/net.retrodeck.retrodeck/config/retrodeck/retrodeck.json` | RetroDECK | `paths.rd_home_path`, falling back to the pre-migration `retrodeck.cfg` and its `rdhome` |
| `~/.var/app/org.libretro.RetroArch/config/retroarch/retroarch.cfg` | RetroArch (Flatpak) | `system_directory` |
| `~/snap/retroarch/current/.config/retroarch/retroarch.cfg` | RetroArch (Snap) | `system_directory` |
@@ -362,7 +370,7 @@ When a platform named with `--platform` is not found on the machine, the
destination falls back to the directory its detection looks for:
`/userdata/bios` for Batocera, `/recalbox/share/bios` for Recalbox,
`/storage/system` for Lakka, `~/retrodeck` for RetroDECK, `~/Emulation/bios`
for EmuDeck, `/storage/roms/bios` for ROCKNIX, `/media/fat/games` for MiSTer,
for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms/bios` for ROCKNIX, `/media/fat/games` for MiSTer,
`~/RetroPie/BIOS` for RetroPie, `/romm/library/bios` for RomM,
`C:\RetroBat\bios` for RetroBat, and RetroArch's own default system
directory for RetroArch. BizHawk has no such folder: it reads `Firmware`
@@ -405,9 +413,27 @@ run rather than a file to copy.
Targets are per OS, WSL falling back to the Linux ones. A directory that does
not exist is skipped rather than created, so nothing is copied for an emulator
that is not installed. A destination that is already a symbolic link is skipped
too: the copy leaves the tree the user opted into, and a link there would
redirect the write somewhere else again.
that is not installed. A destination that is already a symbolic link is
replaced by the file rather than written through: a write through the link
would land outside the folder the user opted into. The summary counts each
copy as copied, skipped (folder absent) or failed, and names every failure.
## RomM firmware layout
RomM 5.4 places each platform's firmware where `filesystem.structure.firmware`
in its `config.yml` says, relative to the library: `bios/{platform}` by
default, the layout of the pack. A library laid out as `{platform}/roms` makes
RomM stop until `config.yml` declares its templates, and RomM suggests
`firmware: "{platform}/bios"` for it.
The installer reads that value from `config.yml` in the `config` folder beside
the library, `/romm/config/config.yml` inside the container, and places each
file accordingly: with `{platform}/bios`, `psx/scph5501.bin` goes to
`library/psx/bios/scph5501.bin`. `--dest` still names the library's `bios`
folder, whose parent is the library. Run from the host of a container, where
`config.yml` sits elsewhere, `--romm-firmware` gives the same value. A
library laid out as `{platform}/roms` with no template found is refused
rather than filled in the wrong place, and the message gives the command.
## Android
@@ -479,8 +505,8 @@ user's own layout, and EmuDeck links `bios/shadps4/sys_modules` into shadPS4's
data directory where the emulator actually reads it. The boundary is lexical,
not a resolved-path comparison, which is what keeps that case working.
Standalone copies are stricter, since they write outside the tree the user
selected: a symlink already sitting at the destination is skipped rather than
followed.
selected: a symlink already sitting at the destination is replaced by the
file rather than followed.
## Changing the installer
@@ -514,7 +540,9 @@ fields, the installer refuses to narrow rather than install everything.
`install.ps1` passes the installer's code on unchanged: run as a file it exits
with it, run through `irm \| iex` it sets `$LASTEXITCODE` (an `exit` there
would close the PowerShell window). Its body runs in a script block of its own,
would close the PowerShell window). It declares no parameter of its own, so
every argument reaches `install.py` as typed, short forms such as `-v`
included. Its body runs in a script block of its own,
so `irm \| iex` leaves neither its variables nor its `$ErrorActionPreference`
in the session.