mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
chore: merge audit fixes (installer)
# Conflicts: # install.ps1 # install.sh
This commit is contained in:
commit
35b4cacb5d
7 files changed
+1204
-87
No files matched your search
+5
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user