chore: merge round 2 fixes (installer)

This commit is contained in:
Abdessamad Derraz committed 2026-10-21 10:17:44 +02:00
commit c8c4f3f486
17 files changed
+1154 -286

No files matched your search

+40 -13
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "1cf3bd0d3985e3a728513be75cb80682fe1e7a9224da6437e8f1cd9b2a3eedb1"
$defaultInstallSha256 = "041ac17cefaccb9114393d5aaa811ee4d096bed9279ab279fd7f9561beaabe14"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
@@ -64,24 +64,51 @@
$installer = $temporary
}
$launcher = @()
$python = Get-Command py -ErrorAction SilentlyContinue
if ($python) {
$launcher = @("-3")
} else {
$python = Get-Command python3 -ErrorAction SilentlyContinue
if (-not $python) {
$python = Get-Command python -ErrorAction SilentlyContinue
# The py launcher first, then python3 and python. The Microsoft Store
# places a python.exe that only points at the Store: it fails the
# version check like a missing one.
$findPython = {
$found = Get-Command py -ErrorAction SilentlyContinue
if ($found) { return ,@($found, @("-3")) }
foreach ($name in @("python3", "python")) {
$found = Get-Command $name -ErrorAction SilentlyContinue
if ($found) { return ,@($found, @()) }
}
return $null
}
$usable = {
param($candidate)
if (-not $candidate) { return $false }
$arguments = $candidate[1]
& $candidate[0].Source @arguments -c "import sys; raise SystemExit(sys.version_info < (3, 8))" | Out-Null
return ($LASTEXITCODE -eq 0)
}
$needPython = "Python 3.8 or newer is required. Install it with " +
"'winget install Python.Python.3.12' or from https://www.python.org/downloads/ " +
"(tick 'Add python.exe to PATH'), then open a new PowerShell window and run this command again."
if (-not $python) {
throw $needPython
$candidate = & $findPython
if (-not (& $usable $candidate)) {
# winget ships with Windows 10 and 11: at a console the bootstrap
# offers to run the install the message names, then carries on.
$winget = Get-Command winget -ErrorAction SilentlyContinue
if ($winget -and -not [Console]::IsInputRedirected) {
$answer = Read-Host "Python 3.8 or newer is required. Install Python 3.12 now with winget? [Y/n]"
if ($answer -notmatch '^\s*[nNqQ]') {
& $winget.Source install --id Python.Python.3.12 --exact --source winget `
--accept-package-agreements --accept-source-agreements
# The installer adds Python to the PATH of new sessions:
# this one reads it again from the registry.
if ($env:OS -eq "Windows_NT") {
$env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" +
[Environment]::GetEnvironmentVariable("Path", "User")
}
$candidate = & $findPython
}
}
if (-not (& $usable $candidate)) { throw $needPython }
}
& $python.Source @launcher -c "import sys; raise SystemExit(sys.version_info < (3, 8))"
if ($LASTEXITCODE -ne 0) { throw $needPython }
$python = $candidate[0]
$launcher = $candidate[1]
# The installer rebuilds this command in its hints, and the user has
# no install.py of their own to run.
$previousBootstrap = $env:RETROBIOS_BOOTSTRAP
+316 -44
View File
@@ -1102,6 +1102,7 @@ _PATH_RULES: tuple[tuple[str, str, str, str | None], ...] = (
("windows", "retrobat", "C:\\RetroBat\\bios", None),
("windows", "retrobat", "%USERPROFILE%\\RetroBat\\bios", None),
("linux", "emudeck", "$HOME/Emulation/bios", None),
("darwin", "emudeck", "$HOME/Emulation/bios", None),
("linux", "retrodeck", "$HOME/.var/app/net.retrodeck.retrodeck", "$HOME/retrodeck"),
("linux", "romm", "/romm/library/bios", None),
# A library laid out as {platform}/bios has no library/bios: RomM's
@@ -1238,15 +1239,18 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
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()
emudeck_settings = home / ".config" / "EmuDeck" / "settings.sh"
if os_type in ("linux", "wsl", "darwin"):
# EmuDeck on Linux and macOS: functions/all.sh:3-4 and 24 (EmuDeck
# dddb3022) reads $emudeckFolder/settings.sh on both, and vars.sh:2
# sets emudeckFolder to $HOME/.config/EmuDeck.
emudeck_settings = Path.home() / ".config" / "EmuDeck" / "settings.sh"
if emudeck_settings.exists():
emu_path = _parse_bash_var(emudeck_settings, "emulationPath")
if emu_path:
bios_dir = Path(emu_path) / "bios"
found.append(("emudeck", bios_dir))
found.append(("emudeck", Path(emu_path) / "bios"))
if os_type in ("linux", "wsl"):
home = Path.home()
# RetroDECK: retrodeck.json since the cfg-to-json migration, which
# renames the old retrodeck.cfg to retrodeck.bak (global.sh:149-153)
retrodeck_conf_dir = home / ".var" / "app" / "net.retrodeck.retrodeck" / "config" / "retrodeck"
@@ -1555,17 +1559,23 @@ def _name_key(name: str) -> str:
SHORT_LIST = 12
def did_you_mean(typed: list[str], known: list[str], listing: str) -> str:
def did_you_mean(
typed: list[str], known: list[str], listing: str,
aliases: dict[str, str] | None = None,
) -> str:
"""The closest known names to what was typed, and the option listing them all.
A typo was answered with every name the platform has, up to 400 on one
line, with no hint that playstation is sony-playstation.
line, with no hint that playstation is sony-playstation. An alias close
to what was typed suggests the name it stands for.
"""
import difflib # noqa: PLC0415
by_key: dict[str, str] = {}
for name in known:
by_key.setdefault(_name_key(name), name)
for alias, name in (aliases or {}).items():
by_key.setdefault(_name_key(alias), name)
close: list[str] = []
for word in typed:
key = _name_key(word)
@@ -1979,7 +1989,7 @@ def _validate_manifest(data: object, plat: str) -> dict:
):
raise ValueError("manifest total_omitted does not match omitted_files")
for field in ("core_names", "system_cores"):
for field in ("core_names", "system_cores", "system_names"):
mapping = data.get(field)
if mapping is None:
continue
@@ -2528,21 +2538,31 @@ def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[
}
def resolve_choices(values: list[str], known: list[str], label: str) -> list[str]:
def resolve_choices(
values: list[str], known: list[str], label: str,
aliases: dict[str, str] | None = None,
) -> list[str]:
"""Names given on the command line, matched without regard to case.
Each value is one name or a comma-separated list. A whole value that is
itself a name wins over splitting it: Recalbox names one system
`msx1,msx2,msxturbor`. An unknown name is refused, never ignored, since
carrying on would install something other than what was asked.
aliases maps another name of a known one to it, as Batocera's `psx`
stands for sony-playstation. A known name always wins over an alias.
"""
aliases = {alias: name for alias, name in (aliases or {}).items() if name in known}
by_lower = {name.lower(): name for name in known}
for alias, name in aliases.items():
by_lower.setdefault(alias.lower(), name)
# Spelled as the site writes it (Sony PlayStation), a name is still one
# name when no other one reads the same once case and spacing go.
by_key: dict[str, str | None] = {}
for name in known:
key = _name_key(name)
by_key[key] = None if key in by_key else name
for alias, name in aliases.items():
by_key.setdefault(_name_key(alias), name)
chosen: list[str] = []
unknown: list[str] = []
for value in values:
@@ -2562,7 +2582,8 @@ def resolve_choices(values: list[str], known: list[str], label: str) -> list[str
listing = {"system": "--list-systems", "emulator": "--list-emulators"}.get(
label, "--list-cores")
raise ValueError(
f"unknown {label}: {', '.join(unknown)}. {did_you_mean(unknown, known, listing)}"
f"unknown {label}: {', '.join(unknown)}. "
f"{did_you_mean(unknown, known, listing, aliases)}"
)
if values and not chosen:
raise ValueError(f"--{label} needs at least one name")
@@ -3182,6 +3203,20 @@ class Invocation:
_INVOCATION = Invocation()
def _on_read_only_mount(path: Path) -> bool:
"""Whether the file system holding path, or the folder it would go in,
is mounted read-only (a card Linux remounts read-only after an error)."""
if not hasattr(os, "statvfs"):
return False
probe = path
while not probe.exists() and probe.parent != probe:
probe = probe.parent
try:
return bool(os.statvfs(probe).f_flag & os.ST_RDONLY)
except OSError:
return False
def _write_advice(path: Path, assumed: bool, plat: str = "") -> str:
"""What to do about a folder this run could not write.
@@ -3189,8 +3224,16 @@ def _write_advice(path: Path, assumed: bool, plat: str = "") -> str:
machine it would create that folder on the wrong disk: a platform named
but not found here is a card or share mounted elsewhere, named by --dest.
Windows has no sudo, and Termux reaches shared storage once
termux-setup-storage has run.
termux-setup-storage has run. A read-only mount refuses root as well,
so sudo is never offered for it.
"""
if _on_read_only_mount(path):
again = f"\n {_INVOCATION.command(plat, path)}" if plat else ""
return (f" {path} is on a file system mounted read-only, which no user can "
"write, sudo included. Mount it read-write again (a card or drive "
"the system switched to read-only after an error needs a check "
"first: fsck, or chkdsk on Windows), then run the same command "
f"again.{again}")
if assumed:
return (f" {path} is where the platform keeps its BIOS on its own system, "
"and it was not found here. If this machine prepares its card or "
@@ -3472,11 +3515,13 @@ def _print_plan(
print("\n Nothing was written. Run without --dry-run to install.")
def _check_free_space(bios_path: Path, needed: int, plat: str = "") -> None:
def _check_free_space(bios_path: Path, needed: int, plat: str = "", code: int = 2) -> None:
"""Stop before writing when the volume cannot hold the download.
Reported against the nearest existing ancestor, since the destination
itself may have just been created on a volume that is already full.
may not exist yet. The run ends with code: 2 when nothing was installed,
the status a script reads as "nothing installed", 1 after an earlier
platform of the run installed files.
"""
probe = bios_path
while not probe.exists() and probe != probe.parent:
@@ -3493,13 +3538,13 @@ def _check_free_space(bios_path: Path, needed: int, plat: str = "") -> None:
if not plat:
print(f" Free up {format_size(needed - free)}, or use --dest to install "
"to another drive.", file=sys.stderr)
sys.exit(1)
sys.exit(code)
# --dest alone used to mean RetroArch's list: the platform is named, so
# a Batocera install moved to a USB drive stays Batocera's.
print(f" Free up {format_size(needed - free)}, or install to another drive:\n"
f" {_INVOCATION.command(plat, '<folder on another drive>')}",
file=sys.stderr)
sys.exit(1)
sys.exit(code)
def download_files(
@@ -3710,6 +3755,165 @@ def select_data_directories(manifest: dict, files: list[dict], narrowed: bool) -
return [e for e in entries if kept & set(e.get("systems") or [])]
def _copy_targets(
entry: dict, os_type: str, extra_dirs: dict[str, Path] | None = None,
) -> list[Path]:
"""The emulator folders a standalone copy entry names on this OS.
WSL falls back to the Linux ones, and extra_dirs adds the folder a
frontend such as LaunchBox keeps the emulator in.
"""
targets = entry.get("targets", {}).get(os_type, [])
if not targets and os_type == "wsl":
targets = entry.get("targets", {}).get("linux", [])
target_dirs = [Path(os.path.expandvars(os.path.expanduser(t))) for t in targets]
extra_root = (extra_dirs or {}).get(entry.get("emulator", ""))
if extra_root is not None:
subdir = PurePosixPath(entry.get("file", "")).parent
extra = extra_root if str(subdir) == "." else extra_root / subdir
if extra not in target_dirs:
target_dirs.append(extra)
return target_dirs
def standalone_folders_here(
manifest: dict, os_type: str, extra_dirs: dict[str, Path] | None = None,
) -> list[Path]:
"""The emulator folders of the manifest's standalone copies that exist
on this machine: the ones --standalone-copies would write to."""
found: list[Path] = []
for entry in manifest.get("standalone_copies", []):
if "note" in entry:
continue
for folder in _copy_targets(entry, os_type, extra_dirs):
if folder not in found and folder.is_dir():
found.append(folder)
return found
# The file lists a machine with standalone emulators and no platform is
# served from: EmuDeck's holds the Switch keys, RetroArch's the PPSSPP atlas
# and the Flycast flash, and each copy entry names its folders.
STANDALONE_SOURCES = ("emudeck", "retroarch")
def standalone_plan(
manifests: list[dict], os_type: str, extra_dirs: dict[str, Path] | None = None,
) -> list[tuple[Path, list[dict]]]:
"""What each emulator folder found here receives, by its own file name.
The standalone copies of the manifests, read as do_standalone_copies
reads them, but taken from the file lists rather than from a platform
tree already installed: an ES-DE user with DuckStation and PCSX2 and no
RetroArch had no tree to copy from. The first list that holds a name for
a folder serves it.
"""
from fnmatch import fnmatch # noqa: PLC0415
plan: dict[Path, dict[str, dict]] = {}
for manifest in manifests:
files = manifest.get("files", [])
by_dest = {entry["dest"]: entry for entry in files}
for copy in manifest.get("standalone_copies", []):
if "note" in copy:
continue
if "pattern" in copy:
sources = [entry for entry in files
if fnmatch(PurePosixPath(entry["dest"]).name, copy["pattern"])]
else:
held = by_dest.get(copy.get("source") or copy["file"])
sources = [held] if held else []
for folder in _copy_targets(copy, os_type, extra_dirs):
if not sources or not folder.is_dir():
continue
chosen = plan.setdefault(folder, {})
for entry in sources:
name = PurePosixPath(entry["dest"]).name
chosen.setdefault(name, {**entry, "dest": name})
return [(folder, list(entries.values())) for folder, entries in plan.items() if entries]
def _install_standalone_folders(
args: argparse.Namespace, os_type: str, term: Terminal, interactive: bool,
) -> bool:
"""Fill the standalone emulator folders found here, with no platform.
False when this machine has none, the run then going on to the platform
question or hint.
"""
manifests = []
for plat in STANDALONE_SOURCES:
manifests.append(fetch_manifest(plat))
lb_root = launchbox_root(os_type)
plan = standalone_plan(manifests, os_type,
launchbox_bios_dirs(lb_root) if lb_root else None)
if not plan:
return False
print(" Found standalone emulator folders:")
for folder, entries in plan:
print(f" {folder} {_sized(entries)}")
if interactive and not (args.check or args.dry_run or args.yes) and not _confirm(
"\nInstall the BIOS files these emulators read into their folders? [Y/n] "
):
return False
downloaded = up_to_date_count = errors = 0
pending = 0
failures: list[tuple[str, str]] = []
transferred, seconds = 0, 0.0
interrupted = False
for folder, entries in plan:
print(f"\n{folder}:")
to_download, up_to_date, mismatched = check_local(entries, folder, jobs=args.jobs)
to_download.extend(mismatched)
up_to_date_count += len(up_to_date)
if args.check:
for entry in mismatched:
print(f" wrong hash: {entry['dest']}")
wrong = {id(entry) for entry in mismatched}
missing = [entry for entry in to_download if id(entry) not in wrong]
for entry in missing[:10] if not args.verbose else missing:
print(f" missing: {entry['dest']}")
if not args.verbose and len(missing) > 10:
print(f" ... and {len(missing) - 10} more missing (--verbose lists them all)")
if not to_download:
print(" All files up to date.")
pending += len(to_download)
continue
if args.dry_run:
_print_plan(to_download, folder, term)
continue
if not to_download:
print(" All files up to date.")
continue
size = sum(entry.get("size", 0) for entry in to_download)
_check_free_space(folder, size, code=1 if downloaded else 2)
progress = Progress(len(to_download), size, term)
failed, interrupted = download_files(
to_download, folder, jobs=args.jobs, verbose=args.verbose,
term=term, progress=progress,
)
downloaded += len(to_download) - len(failed)
errors += len(failed)
failures.extend((str(folder / dest), reason) for dest, reason in failed)
transferred += progress.transferred()
seconds += progress.elapsed()
if interrupted:
break
if args.check:
if pending:
print("\n Run the same command without --check to fetch them; "
"a file with a wrong hash is replaced.")
sys.exit(1)
return True
if args.dry_run:
return True
_report_outcome(
[folder for folder, _entries in plan], downloaded, up_to_date_count, errors,
0, interrupted, failures=failures, transferred=transferred, seconds=seconds,
)
return True
def do_standalone_copies(
manifest: dict, bios_path: Path, os_type: str,
extra_dirs: dict[str, Path] | None = None,
@@ -3774,22 +3978,7 @@ def do_standalone_copies(
if not sources:
continue
# Resolve target directories with WSL fallback
targets = entry.get("targets", {}).get(os_type, [])
if not targets and os_type == "wsl":
targets = entry.get("targets", {}).get("linux", [])
target_dirs = [
Path(os.path.expandvars(os.path.expanduser(t))) for t in targets
]
extra_root = (extra_dirs or {}).get(entry.get("emulator", ""))
if extra_root is not None:
subdir = PurePosixPath(entry.get("file", "")).parent
extra = extra_root if str(subdir) == "." else extra_root / subdir
if extra not in target_dirs:
target_dirs.append(extra)
for target_dir in target_dirs:
for target_dir in _copy_targets(entry, os_type, extra_dirs):
if not target_dir.is_dir():
skipped += len(sources)
continue
@@ -3925,7 +4114,7 @@ def _default_dest(os_type: str, platform_name: str) -> Path | None:
return DEFAULT_DESTS.get(platform_name)
def _manual_usage_hint(os_type: str) -> list[str]:
def _manual_usage_hint(os_type: str, listing: str = "") -> list[str]:
"""How to name the platform and directory when nothing is detected.
In the form the run was started with: a one-liner user has no
@@ -3937,6 +4126,15 @@ def _manual_usage_hint(os_type: str) -> list[str]:
}.get(os_type, "~/.config/retroarch/system")
hint = Invocation()
hint.os_type, hint.options = os_type, _INVOCATION.options
if listing:
# A listing reads a platform's list and writes nothing: --dest is
# not needed, and a rebuilt install command dropped the listing
# and started a 6.5 GB download.
return [
" Name the platform whose list to print, for example:",
f" {hint.command('retroarch', None)} {listing}",
f" The platforms: {hint.launcher()} --list-platforms",
]
return [
" Use --platform and --dest to specify manually, for example:",
f" {hint.command('retroarch', example)}",
@@ -4094,8 +4292,24 @@ def _same_choice_hint(plat: str, systems: list[str], cores: list[str], regions:
return " ".join(parts)
def system_aliases(manifest: dict | None) -> dict[str, str]:
"""The platform's own names for its systems, each with the system id."""
return {
alias: system
for system, names in ((manifest or {}).get("system_names") or {}).items()
for alias in names
}
def _system_label(name: str, manifest: dict | None) -> str:
"""A system id with the names the platform gives it: nintendo-gba (gba)."""
names = ((manifest or {}).get("system_names") or {}).get(name)
return f"{name} ({', '.join(names)})" if names else name
def _print_choices(
plat: str, files: list[dict], omitted: list[dict], systems: bool
plat: str, files: list[dict], omitted: list[dict], systems: bool,
manifest: dict | None = None,
) -> None:
"""What --system or --core accepts for a platform, with what each holds."""
choices = available_choices(files, omitted)
@@ -4103,14 +4317,15 @@ def _print_choices(
if not names:
print(f" {plat} records no {'systems' if systems else 'cores'}")
return
width = min(max(len(name) for name in names), 40)
labels = {name: _system_label(name, manifest) if systems else name for name in names}
width = min(max(len(label) for label in labels.values()), 40)
for name in names:
held = (
[f for f in files if _chosen(f, {name})]
if systems
else _filter_by_target([f for f in files if f.get("cores") is not None], [name])
)
print(f" {name:<{width}} {_sized(held)}")
print(f" {labels[name]:<{width}} {_sized(held)}")
def emulator_names(manifest: dict, files: list[dict], omitted: list[dict]) -> dict[str, str]:
@@ -4300,7 +4515,8 @@ def _narrow_to_emulators(
def _requested_narrowing(
args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict]
args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict],
manifest: dict | None = None,
) -> tuple[list[str], list[str], list[str]] | None:
"""The systems, cores and regions named on the command line, checked."""
if not (args.system or args.core or args.region):
@@ -4317,7 +4533,9 @@ def _requested_narrowing(
sys.exit(1)
choices = available_choices(files, omitted)
try:
systems = resolve_choices(args.system or [], choices["systems"], "system")
systems = resolve_choices(
args.system or [], choices["systems"], "system", system_aliases(manifest)
)
cores = resolve_choices(args.core or [], choices["cores"], "core")
regions = resolve_regions(args.region or [])
except ValueError as exc:
@@ -4539,6 +4757,30 @@ def main() -> None:
sys.stdout = real_stdout
def _missing_dest(dest: Path, ask: bool) -> None:
"""Say that a --dest folder does not exist before anything goes there.
A typo (~/.config/retroarh/system) was created with every missing
parent, filled, and reported done in a folder no platform reads, and
--check said only "0/7 present". One missing level is a BIOS folder not
made yet and is created. Several make a path that may be mistyped:
at a terminal the run asks first, and the answer defaults to no.
"""
if dest.exists():
return
nearest = dest.absolute()
while not nearest.exists() and nearest.parent != nearest:
nearest = nearest.parent
missing = len(dest.absolute().relative_to(nearest).parts)
print(f" {dest} does not exist. The nearest folder that does: {nearest}")
if missing > 1 and ask:
answer = _ask(f" Create {_plural(missing, 'folder')} under {nearest}? [y/N] ")
if not answer.strip().lower().startswith("y"):
print(" Nothing installed. Check the folder named with --dest, then run "
"the command again.")
sys.exit(2)
def _no_core_found(plat: str, bios_path: Path, searched: list[Path], dest_given: bool) -> None:
"""Refuse --installed-cores with no core, and say how to point at them.
@@ -4679,8 +4921,22 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
platforms = detect_platforms(os_type)
if not platforms:
print(" No supported platform detected.")
# Standalone emulators read their BIOS from folders of their own,
# which the file lists name: an ES-DE user with DuckStation and
# PCSX2 was sent to RetroArch's folder, which nothing here reads.
narrowing = (args.system or args.core or args.emulator or args.region
or args.target or args.installed_cores)
if not listing and not narrowing and _install_standalone_folders(
args, os_type, term, interactive
):
return
if not interactive or listing:
for line in _manual_usage_hint(os_type):
asked = next((flag for flag, on in (
("--list-systems", args.list_systems), ("--list-cores", args.list_cores),
("--list-emulators", args.list_emulators),
("--list-targets", args.list_targets),
) if on), "")
for line in _manual_usage_hint(os_type, asked):
print(line)
sys.exit(1)
platforms = _prompt_manual_platform(os_type)
@@ -4690,6 +4946,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
if len(platforms) > 1 and not listing and interactive:
platforms = _prompt_platform_choice(platforms)
if args.dest is not None and not listing:
_missing_dest(args.dest, interactive and not (args.check or args.dry_run or args.yes))
total_downloaded = 0
total_up_to_date = 0
total_errors = 0
@@ -4861,7 +5120,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
args.emulator, manifest, plat_name, files, omitted_files
)
selection = _requested_narrowing(args, plat_name, files, omitted_files)
selection = _requested_narrowing(args, plat_name, files, omitted_files, manifest)
if (
selection is None
and interactive
@@ -4898,7 +5157,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
print(f" Narrowed {before} -> {len(files)} files")
if args.list_systems or args.list_cores:
_print_choices(plat_name, files, omitted_files, args.list_systems)
_print_choices(plat_name, files, omitted_files, args.list_systems, manifest)
continue
total_size = sum(f.get("size", 0) for f in files)
@@ -5031,6 +5290,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
print(f"\nDownloading {len(to_download)} files ({format_size(dl_size)})...")
print(" Press Ctrl+C to stop. Running the same command again "
"carries on from here.")
# Checked before the folder is created: a run stopped for space
# left an empty BIOS folder behind.
_check_free_space(bios_path, dl_size, plat_name, 1 if total_downloaded else 2)
created = not bios_path.exists()
try:
bios_path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
@@ -5043,7 +5306,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# as when it exists read-only; 1 once an earlier platform
# of the run installed something.
sys.exit(1 if total_downloaded else 2)
_check_free_space(bios_path, dl_size, plat_name)
if created:
print(f" Created {bios_path}")
progress = Progress(len(to_download), dl_size, term)
failed, interrupted = download_files(
to_download, bios_path, jobs=args.jobs, verbose=args.verbose,
@@ -5102,10 +5366,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
total_errors += copy_failed
elif manifest.get("standalone_copies") and not args.check:
print(
"\nStandalone copies skipped "
"(use --standalone-copies to opt in)."
# Said only where it concerns this machine: "skipped" printed on
# every run read as an incident, and named no emulator.
lb_root = launchbox_root(os_type)
folders = standalone_folders_here(
manifest, os_type, launchbox_bios_dirs(lb_root) if lb_root else None
)
if folders:
print("\nStandalone emulator folders found here:")
for folder in folders:
print(f" {folder}")
print(" --standalone-copies also copies the BIOS files and keys "
"they read into them.")
sys.stdout = real_stdout
# A check scripted as `--check && launch` reads the exit code only.
+65 -10
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="1cf3bd0d3985e3a728513be75cb80682fe1e7a9224da6437e8f1cd9b2a3eedb1"
DEFAULT_INSTALL_SHA256="041ac17cefaccb9114393d5aaa811ee4d096bed9279ab279fd7f9561beaabe14"
MAX_INSTALLER_BYTES=2097152
cleanup() {
@@ -58,8 +58,15 @@ if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then
--connect-timeout 30 --retry 2 "$install_url" --output "$TEMP_INSTALLER"
}
elif command -v wget >/dev/null 2>&1; then
# BusyBox wget (Alpine, the RomM image) has no --https-only and refuses
# the whole command over it (networking/wget.c wget_longopts). The URL
# itself is checked to be https above, and the SHA-256 below decides.
https_only=""
if wget --help 2>&1 | grep -q -- '--https-only'; then
https_only="--https-only"
fi
fetch() {
wget --quiet --https-only --timeout=30 --tries=3 \
wget --quiet ${https_only:+"$https_only"} --timeout=30 --tries=3 \
--output-document="$TEMP_INSTALLER" "$install_url"
}
else
@@ -111,17 +118,65 @@ if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then
INSTALLER="$TEMP_INSTALLER"
fi
PYTHON=""
for command_name in python3 python; do
if command -v "$command_name" >/dev/null 2>&1 \
&& "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' 2>/dev/null; then
PYTHON=$command_name
break
find_python() {
PYTHON=""
for command_name in python3 python; do
if command -v "$command_name" >/dev/null 2>&1 \
&& "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' 2>/dev/null; then
PYTHON=$command_name
return 0
fi
done
return 1
}
# The command that installs Python here, named for this system: Termux has
# no sudo and installs with pkg, macOS has no apt.
python_install_command() {
case "${TERMUX_VERSION:-}:${PREFIX:-}" in
?*:*|*com.termux*) echo "pkg install python"; return ;;
esac
if [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then
echo "xcode-select --install"
return
fi
done
os_ids=""
if [ -r /etc/os-release ]; then
os_ids=$(sed -n 's/^ID\(_LIKE\)\{0,1\}=//p' /etc/os-release | tr -d '"' | tr '\n' ' ')
fi
for os_id in $os_ids; do
case "$os_id" in
debian|ubuntu) echo "sudo apt install python3"; return ;;
fedora|rhel|centos) echo "sudo dnf install python3"; return ;;
arch) echo "sudo pacman -S python"; return ;;
opensuse*|suse) echo "sudo zypper install python3"; return ;;
alpine) echo "apk add python3"; return ;;
esac
done
echo ""
}
if ! find_python; then
install_command=$(python_install_command)
# Termux installs without root: at a terminal, the bootstrap offers to run it.
if [ "$install_command" = "pkg install python" ] && (: </dev/tty) 2>/dev/null; then
printf 'Python 3.8 or newer is required. Install it now with "%s"? [Y/n] ' \
"$install_command" >/dev/tty
read -r answer </dev/tty || answer="n"
case "$answer" in
[Nn]*) ;;
*) pkg install -y python </dev/tty && find_python || true ;;
esac
fi
fi
if [ -z "$PYTHON" ]; then
echo "Error: Python 3.8 or newer is required." >&2
echo " Install it, then run this command again. On Debian or Ubuntu: sudo apt install python3" >&2
if [ -n "$install_command" ]; then
echo " Install it with: $install_command" >&2
echo " Then run this command again." >&2
else
echo " Install it with this system's package manager, then run this command again." >&2
fi
exit 1
fi
+11
View File
@@ -413,6 +413,17 @@ platforms:
- os: linux
method: path_exists
path: $HOME/Emulation/bios
# functions/all.sh:3-4,24 and vars.sh:2 (EmuDeck dddb3022): the macOS
# build reads the same settings.sh, emulationPath=~/Emulation by
# default (settings.sh:79).
- os: darwin
method: config_file
config: $HOME/.config/EmuDeck/settings.sh
parse_key: emulationPath
bios_subdir: bios
- os: darwin
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
+12
View File
@@ -120,6 +120,18 @@
"uniqueItems": true
}
},
"system_names": {
"description": "Each platform system with the names the platform's own file gives it (native_system), which the installer accepts for --system.",
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
}
},
"omitted_files": {
"type": "array",
"items": {
+10 -7
View File
@@ -74,7 +74,7 @@ from common import (
import packresolve
from refresh_data_dirs import cache_lock, hold_data_dirs
import region as region_mod
from manifestselect import SelectionIndex
from manifestselect import SelectionIndex, native_system_names
import slot as slot_mod
import slots
import split_pack
@@ -1229,12 +1229,12 @@ def generate_pack(
undecidable_slots=slot_undecidable,
narrowings=narrowings,
system_filter=system_filter,
bios_paths=[
str(detect.get("bios_path", ""))
for detect in (
_pack_registry.get(platform_name, {}).get("install", {}) or {}
).get("detect", [])
],
detect=(
_pack_registry.get(platform_name, {}).get("install", {}) or {}
).get("detect", []),
standalone_copies=(
_pack_registry.get(platform_name, {}).get("install", {}) or {}
).get("standalone_copies", []),
)
_write_generated_member(zf, "README.txt", readme_text)
@@ -4465,6 +4465,9 @@ def generate_manifest(
),
"core_names": core_names,
"system_cores": system_cores,
# The names the platform itself gives its systems (Batocera's psx),
# which --system accepts beside the ids.
"system_names": native_system_names(config, list(pack_systems)),
"files": manifest_files,
}
if data_directories:
+3 -1
View File
@@ -4735,6 +4735,7 @@ ZIP: extract them all into the same folder.
| [RetroBat](https://www.retrobat.org/) | Windows frontend with EmulationStation, includes RetroArch and standalone emulators | [RetroBat]({rel}) | The `bios` folder inside the RetroBat installation |
| [BizHawk](https://tasvideos.org/BizHawk) | Accuracy-focused multi-system emulator, popular for speedruns and TAS | [BizHawk]({rel}) | The `Firmware` folder inside the BizHawk installation |
| [LaunchBox](https://www.launchbox-app.com/) | Game library manager and launcher, uses RetroArch or standalone emulators behind the scenes | [RetroArch]({rel}) | Open RetroArch (via LaunchBox) > Settings > Directory > System |
| [EmuDeck](https://www.emudeck.com/) | Installs and configures multiple emulators with ES-DE | [EmuDeck]({rel}) | `bios` in the Emulation folder chosen in EmuDeck, `C:\\Emulation\\bios\\` by default |
### Linux
@@ -4750,6 +4751,7 @@ ZIP: extract them all into the same folder.
| Setup | What it is | Pack | Extract to |
|-------|-----------|------|-----------|
| [RetroArch](https://www.retroarch.com/) | Multi-system emulator | [RetroArch]({rel}) | `~/Library/Application Support/RetroArch/system/` |
| [EmuDeck](https://www.emudeck.com/) | Installs and configures multiple emulators with ES-DE | [EmuDeck]({rel}) | `~/Emulation/bios/` |
### Raspberry Pi and single-board computers
@@ -4778,7 +4780,7 @@ ZIP: extract them all into the same folder.
| Setup | What it is | Pack | Extract to |
|-------|-----------|------|-----------|
| [RomM](https://github.com/rommapp/romm) | Web-based ROM manager, plays games in the browser via EmulatorJS | [RomM]({rel}) | The `bios` folder in the RomM library, one subfolder per system |
| [RomM](https://github.com/rommapp/romm) | Web-based ROM manager, plays games in the browser via EmulatorJS | [RomM]({rel}) | `bios/` in the RomM library, one subfolder per platform. With `firmware: "{{platform}}/bios"` in `config.yml`, each subfolder goes to `<platform>/bios/` instead |
---
+36 -1
View File
@@ -17,8 +17,10 @@ from __future__ import annotations
from dataclasses import dataclass
import re
import region as region_mod
from common import _norm_system_id, sanitize_pack_path
from common import _norm_system_id, native_layer, sanitize_pack_path
from packextras import _extra_system_ids, _kept, extra_region_groups, system_spellings
@@ -118,3 +120,36 @@ class SelectionIndex:
if priority is not None:
out["priority"] = priority
return out
def _name_key(name: str) -> str:
"""Letters and digits only, as the installer compares names."""
return re.sub(r"[^a-z0-9]", "", name.lower())
def native_system_names(config: dict, systems: list[str]) -> dict[str, list[str]]:
"""Per platform system, the names the platform's own file gives it.
A Batocera user knows PlayStation as `psx`, the folder under roms/, and
the platform YAML records that name as `native_system` on each file it
transcribes. Only the platform's own entries count (`native_layer`), a
name spelled like the system id adds nothing, and a name the platform
gives to two systems, or that reads as another system's id, names
neither.
"""
native = native_layer(config) or {}
by_name: dict[str, set[str]] = {}
for sys_id, system in native.get("systems", {}).items():
if sys_id not in systems:
continue
for entry in system.get("files", []):
name = entry.get("native_system")
if isinstance(name, str) and name.strip():
by_name.setdefault(name.strip(), set()).add(sys_id)
ids = {_name_key(sys_id) for sys_id in systems}
out: dict[str, list[str]] = {}
for name, owners in by_name.items():
if len(owners) != 1 or _name_key(name) in ids:
continue
out.setdefault(next(iter(owners)), []).append(name)
return {sys_id: sorted(names) for sys_id, names in sorted(out.items())}
+221 -190
View File
@@ -5,6 +5,219 @@ goes, and which ones the user has to rename."""
from __future__ import annotations
INSTALL_SH = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh"
INSTALL_PS1 = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1"
def _detect_systems(detect: list[dict]) -> list[str]:
"""The operating systems the registry's detection rules cover, in order."""
order = ("linux", "darwin", "windows")
seen = {str(rule.get("os", "")) for rule in detect}
return [name for name in order if name in seen]
def _automatic_section(
display: str, detect: list[dict], standalone_copies: list[dict] | None,
) -> str:
"""The one-line install, for every system the registry detects the
platform on. Written per platform by hand, it left ROCKNIX and MiSTer
without it and told EmuDeck's Windows edition to open Konsole."""
systems = _detect_systems(detect)
if not systems:
return ""
# A platform detected on Linux only is a system of its own (Batocera,
# ROCKNIX, MiSTer) or runs on one machine (RetroDECK, a RomM server):
# the command runs there.
on_device = systems == ["linux"]
lines = [
" Option A: Automatic (recommended)",
" ---------------------------------",
]
posix = [name for name in systems if name != "windows"]
if posix:
if on_device:
where = f"On the machine running {display} (its terminal, or SSH), run:"
else:
labels = {"linux": "Linux", "darwin": "macOS"}
where = f"{' and '.join(labels[name] for name in posix)}: run this in a terminal:"
lines += [f" {where}", "", f" curl -fsSL {INSTALL_SH} | sh", ""]
if "windows" in systems:
lines += [" Windows: run this in PowerShell:", "", f" irm {INSTALL_PS1} | iex", ""]
lines += [
f" The installer finds the {display} BIOS folder on this machine and",
" installs there, checking every file. --dest names another folder.",
"",
]
# On a desktop: an emulator folder under the home of a console's own
# system is not one its frontend reads.
if standalone_copies and not on_device:
lines += [
" Standalone emulators read some files from folders of their own",
" (Switch and 3DS keys, PlayStation and GameCube BIOS). To copy them",
" into the emulator folders the installer finds, add --standalone-copies:",
"",
]
if posix:
lines.append(f" curl -fsSL {INSTALL_SH} | sh -s -- --standalone-copies")
if "windows" in systems:
lines.append(f" &([scriptblock]::Create((irm {INSTALL_PS1}))) --standalone-copies")
lines.append("")
return "\n".join(lines) + "\n"
def _generic_manual(bios_paths: list[str]) -> str:
"""Where the files go, from the registry's paths, for a platform with no
manual guide of its own."""
targets = [path for path in bios_paths if path]
where = " or ".join(f"{path}/" for path in dict.fromkeys(targets)) or "your BIOS directory"
return (
" Option B: Manual\n"
" ----------------\n"
f" 1. Extract all files from this archive directly into {where}\n"
" 2. Overwrite if asked\n\n"
)
# What a manual install needs on each platform: its shares, cards and folders.
MANUAL_GUIDES: dict[str, str] = {
"retroarch": (
" Option B: Manual (PC)\n"
" ---------------------\n"
" 1. Find your RetroArch system directory:\n"
" - RetroArch > Settings > Directory > System/BIOS\n"
" - Default: retroarch/system/\n"
" 2. Extract all files from this archive directly into your system directory\n"
" 3. Overwrite if asked\n\n"
" Option C: Manual (handheld / SD card)\n"
" -------------------------------------\n"
" Anbernic, Retroid, Miyoo, Trimui, etc.:\n"
" 1. Connect your SD card to your PC\n"
" 2. Find the BIOS folder (usually BIOS/ or system/)\n"
" 3. Extract all files from this archive directly into that folder\n"
" 4. Eject SD card and reboot your device\n\n"
" Common paths by device:\n"
" Anbernic (ArkOS/JELOS): BIOS/\n"
" Retroid (RetroArch): RetroArch/system/\n"
" Miyoo Mini (Onion OS): BIOS/\n"
" Steam Deck (RetroArch): ~/.config/retroarch/system/\n\n"
),
"batocera": (
" The terminal is F1 from the Batocera menu, or SSH.\n\n"
" Option B: Manual (network share)\n"
" --------------------------------\n"
" 1. On your PC, open the Batocera network share:\n"
" - Windows: \\\\BATOCERA\\share\\bios\\\n"
" - Mac/Linux: smb://batocera/share/bios/\n"
" 2. Extract all files from this archive directly into the share\n"
" 3. Overwrite if asked\n\n"
" Option C: Manual (SD card)\n"
" --------------------------\n"
" 1. Put the SD card in your PC\n"
" 2. Navigate to /userdata/bios/ on the SHARE partition\n"
" 3. Extract all files from this archive directly into that folder\n\n"
" NOTE: Dreamcast flash memory is named dc_nvmem.bin\n"
" (if your setup asks for dc_flash.bin, same file).\n\n"
),
"recalbox": (
" Option B: Manual (network share)\n"
" --------------------------------\n"
" 1. On your PC, open the Recalbox network share:\n"
" - Windows: \\\\RECALBOX\\share\\bios\\\n"
" - Mac/Linux: smb://recalbox/share/bios/\n"
" 2. Extract all files from this archive directly into the share\n\n"
" Option C: Manual (SD card)\n"
" --------------------------\n"
" 1. Put the SD card in your PC\n"
" 2. Navigate to /recalbox/share/bios/\n"
" 3. Extract all files from this archive directly into that folder\n\n"
),
"emudeck": (
" Option B: Manual\n"
" ----------------\n"
" Extract all files from this archive directly into EmuDeck's bios\n"
" folder, inside the Emulation folder chosen in EmuDeck:\n"
" Steam Deck, Linux and macOS: ~/Emulation/bios/ by default\n"
" Windows: C:\\Emulation\\bios\\ by default\n\n"
),
"retrodeck": (
" Option B: Manual\n"
" ----------------\n"
" 1. Open Dolphin file manager\n"
" 2. Show hidden files (Ctrl+H)\n"
" 3. Navigate to ~/retrodeck/\n"
" 4. Extract all files from this archive directly into ~/retrodeck/\n"
" (the archive already holds the bios/ and roms/ folders)\n\n"
" NOTE: RetroDECK uses its own BIOS checker. After\n"
" copying, open RetroDECK > Tools > BIOS Checker to\n"
" verify everything is detected.\n\n"
),
"retrobat": (
" Option B: Manual\n"
" ----------------\n"
" 1. Open your RetroBat installation folder\n"
" 2. Navigate to the bios\\ subfolder\n"
" (default: C:\\RetroBat\\bios\\)\n"
" 3. Extract all files from this archive directly into your bios\\ folder\n"
" 4. Overwrite if asked\n\n"
),
"bizhawk": (
" Option B: Manual\n"
" ----------------\n"
" 1. Open the folder you extracted BizHawk to, the one holding\n"
" EmuHawk.exe (Windows) or EmuHawkMono.sh (Linux)\n"
" 2. Navigate to its Firmware subfolder\n"
" 3. Extract all files from this archive directly into your Firmware folder\n"
" 4. In BizHawk: Config > Paths > Firmware should\n"
" point to this folder\n\n"
),
# RomM 5.4 places firmware by filesystem.structure.firmware in config.yml,
# bios/{platform} by default, and suggests {platform}/bios for a library
# laid out as {platform}/roms (rommapp/romm 68f71259,
# backend/config/config_manager.py:58,101-109,1105-1124).
"romm": (
" Option B: Manual\n"
" ----------------\n"
" The archive holds one folder per RomM platform (psx/, gba/, ...).\n"
" Where they go depends on filesystem.structure.firmware in RomM's\n"
" config.yml, read relative to the library folder:\n\n"
" - Not set, or \"bios/{platform}\" (the default): extract all files\n"
" from this archive directly into library/bios/\n"
" - \"{platform}/bios\" (the layout RomM suggests for a library kept as\n"
" {platform}/roms): each folder goes under its platform, psx/ into\n"
" library/psx/bios/, gba/ into library/gba/bios/, and so on.\n"
" The installer reads config.yml and does this itself. Run from\n"
" the host of a container, --romm-firmware \"{platform}/bios\" says it.\n\n"
" Then restart the RomM service to detect new files.\n\n"
),
"retropie": (
" Option B: Via network share\n"
" --------------------------\n"
" 1. On your PC, open: \\\\RETROPIE\\bios\\\n"
" 2. Extract all files from this archive directly into that folder\n\n"
" Option C: Via SSH\n"
" -----------------\n"
" 1. SSH into your Pi: ssh pi@retropie\n"
" 2. Copy files to ~/RetroPie/BIOS/\n\n"
" NOTE: the BIOS folder is on the card's Linux partition,\n"
" which Windows and macOS cannot open: copying through the\n"
" card works from a Linux computer only\n"
" (<rootfs>/home/pi/RetroPie/BIOS/).\n\n"
),
"lakka": (
" Lakka reads its BIOS files from /storage/system/.\n\n"
" Option B: Via network share\n"
" --------------------------\n"
" 1. On Lakka: Settings > Services > Samba Enable\n"
" 2. On your PC, open the System share: \\\\LAKKA\\System\\\n"
" (macOS and Linux: smb://lakka/System)\n"
" 3. Extract all files from this archive directly into that share\n\n"
" Option C: Via SSH\n"
" -----------------\n"
" 1. On Lakka: Settings > Services > SSH Enable\n"
" 2. Copy the files to /storage/system/ (scp -r * root@lakka:/storage/system/)\n\n"
),
}
def _build_readme(
platform_name: str,
platform_display: str,
@@ -19,7 +232,8 @@ def _build_readme(
undecidable_slots: list[str] | None = None,
narrowings: list[tuple[str, str]] | None = None,
system_filter: list[str] | None = None,
bios_paths: list[str] | None = None,
detect: list[dict] | None = None,
standalone_copies: list[dict] | None = None,
) -> str:
"""Build a personalized step-by-step README for each platform pack."""
narrowings = narrowings or []
@@ -31,195 +245,12 @@ def _build_readme(
f"{sep}\n\n"
)
guides: dict[str, str] = {
"retroarch": (
"INSTALLATION GUIDE\n\n"
" Option A: Automatic (recommended)\n"
" ---------------------------------\n"
" Linux, macOS, Steam Deck: run this in a terminal:\n\n"
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
" Windows: run this in PowerShell:\n\n"
" irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex\n\n"
" The script auto-detects your RetroArch install and copies\n"
" files to the correct location.\n\n"
" Option B: Manual (PC)\n"
" ---------------------\n"
" 1. Find your RetroArch system directory:\n"
" - RetroArch > Settings > Directory > System/BIOS\n"
" - Default: retroarch/system/\n"
" 2. Extract all files from this archive directly into your system directory\n"
" 3. Overwrite if asked\n\n"
" Option C: Manual (handheld / SD card)\n"
" -------------------------------------\n"
" Anbernic, Retroid, Miyoo, Trimui, etc.:\n"
" 1. Connect your SD card to your PC\n"
" 2. Find the BIOS folder (usually BIOS/ or system/)\n"
" 3. Extract all files from this archive directly into that folder\n"
" 4. Eject SD card and reboot your device\n\n"
" Common paths by device:\n"
" Anbernic (ArkOS/JELOS): BIOS/\n"
" Retroid (RetroArch): RetroArch/system/\n"
" Miyoo Mini (Onion OS): BIOS/\n"
" Steam Deck (RetroArch): ~/.config/retroarch/system/\n\n"
),
"batocera": (
"INSTALLATION GUIDE\n\n"
" Option A: Automatic (recommended)\n"
" ---------------------------------\n"
" Open a terminal (F1 from Batocera menu) and run:\n\n"
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
" Option B: Manual (network share)\n"
" --------------------------------\n"
" 1. On your PC, open the Batocera network share:\n"
" - Windows: \\\\BATOCERA\\share\\bios\\\n"
" - Mac/Linux: smb://batocera/share/bios/\n"
" 2. Extract all files from this archive directly into the share\n"
" 3. Overwrite if asked\n\n"
" Option C: Manual (SD card)\n"
" --------------------------\n"
" 1. Put the SD card in your PC\n"
" 2. Navigate to /userdata/bios/ on the SHARE partition\n"
" 3. Extract all files from this archive directly into that folder\n\n"
" NOTE: Dreamcast flash memory is named dc_nvmem.bin\n"
" (if your setup asks for dc_flash.bin, same file).\n\n"
),
"recalbox": (
"INSTALLATION GUIDE\n\n"
" Option A: Automatic\n"
" -------------------\n"
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
" Option B: Manual (network share)\n"
" --------------------------------\n"
" 1. On your PC, open the Recalbox network share:\n"
" - Windows: \\\\RECALBOX\\share\\bios\\\n"
" - Mac/Linux: smb://recalbox/share/bios/\n"
" 2. Extract all files from this archive directly into the share\n\n"
" Option C: Manual (SD card)\n"
" --------------------------\n"
" 1. Put the SD card in your PC\n"
" 2. Navigate to /recalbox/share/bios/\n"
" 3. Extract all files from this archive directly into that folder\n\n"
),
"emudeck": (
"INSTALLATION GUIDE (Steam Deck / Linux)\n\n"
" Option A: Automatic (recommended)\n"
" ---------------------------------\n"
" Open Konsole (or any terminal) and run:\n\n"
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
" The script places the BIOS files. To also copy the keys\n"
" into the standalone emulators it finds, run:\n\n"
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --standalone-copies\n\n"
" Option B: Manual\n"
" ----------------\n"
" 1. Open Dolphin file manager\n"
" 2. Navigate to ~/Emulation/bios/\n"
" 3. Extract all files from this archive directly into ~/Emulation/bios/\n\n"
" STANDALONE EMULATORS (extra step)\n"
" Switch and 3DS emulators need keys in their own folders:\n"
" prod.keys -> ~/.local/share/yuzu/keys/\n"
" prod.keys -> ~/.local/share/eden/keys/\n"
" prod.keys -> ~/.config/Ryujinx/system/\n"
" aes_keys.txt -> ~/.local/share/azahar-emu/sysdata/\n"
" The installer copies them with --standalone-copies.\n\n"
),
"retrodeck": (
"INSTALLATION GUIDE (Steam Deck / Linux)\n\n"
" Option A: Automatic (recommended)\n"
" ---------------------------------\n"
" Open Konsole (or any terminal) and run:\n\n"
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
" Option B: Manual\n"
" ----------------\n"
" 1. Open Dolphin file manager\n"
" 2. Show hidden files (Ctrl+H)\n"
" 3. Navigate to ~/retrodeck/\n"
" 4. Extract all files from this archive directly into ~/retrodeck/\n"
" (the archive already holds the bios/ and roms/ folders)\n\n"
" NOTE: RetroDECK uses its own BIOS checker. After\n"
" copying, open RetroDECK > Tools > BIOS Checker to\n"
" verify everything is detected.\n\n"
),
"retrobat": (
"INSTALLATION GUIDE (Windows)\n\n"
" Option A: Automatic (recommended)\n"
" ---------------------------------\n"
" Open PowerShell and run:\n\n"
" irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex\n\n"
" Option B: Manual\n"
" ----------------\n"
" 1. Open your RetroBat installation folder\n"
" 2. Navigate to the bios\\ subfolder\n"
" (default: C:\\RetroBat\\bios\\)\n"
" 3. Extract all files from this archive directly into your bios\\ folder\n"
" 4. Overwrite if asked\n\n"
),
"bizhawk": (
"INSTALLATION GUIDE\n\n"
" 1. Open the folder you extracted BizHawk to, the one holding\n"
" EmuHawk.exe (Windows) or EmuHawkMono.sh (Linux)\n"
" 2. Navigate to its Firmware subfolder\n"
" 3. Extract all files from this archive directly into your Firmware folder\n"
" 4. In BizHawk: Config > Paths > Firmware should\n"
" point to this folder\n\n"
),
"romm": (
"INSTALLATION GUIDE (RomM server)\n\n"
" 1. Locate your RomM library folder\n"
" 2. Navigate to its bios/ folder (library/bios/)\n"
" 3. Extract all files from this archive directly into that folder\n"
" 4. Restart the RomM service to detect new files\n\n"
),
"retropie": (
"INSTALLATION GUIDE (Raspberry Pi)\n\n"
" Option A: Automatic\n"
" -------------------\n"
" On the Pi (F4 from EmulationStation, or over SSH), run:\n\n"
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
" Option B: Via network share\n"
" --------------------------\n"
" 1. On your PC, open: \\\\RETROPIE\\bios\\\n"
" 2. Extract all files from this archive directly into that folder\n\n"
" Option C: Via SSH\n"
" -----------------\n"
" 1. SSH into your Pi: ssh pi@retropie\n"
" 2. Copy files to ~/RetroPie/BIOS/\n\n"
" NOTE: the BIOS folder is on the card's Linux partition,\n"
" which Windows and macOS cannot open: copying through the\n"
" card works from a Linux computer only\n"
" (<rootfs>/home/pi/RetroPie/BIOS/).\n\n"
),
}
guides["lakka"] = (
"INSTALLATION GUIDE (Lakka)\n\n"
" Lakka reads its BIOS files from /storage/system/.\n\n"
" Option A: Via network share\n"
" --------------------------\n"
" 1. On Lakka: Settings > Services > Samba Enable\n"
" 2. On your PC, open the System share: \\\\LAKKA\\System\\\n"
" (macOS and Linux: smb://lakka/System)\n"
" 3. Extract all files from this archive directly into that share\n\n"
" Option B: Via SSH\n"
" -----------------\n"
" 1. On Lakka: Settings > Services > SSH Enable\n"
" 2. Copy the files to /storage/system/ (scp -r * root@lakka:/storage/system/)\n\n"
)
# A platform without a written guide is told where its files go by the
# registry, which the installer reads too: "your BIOS directory" sent
# MiSTer users looking for a folder MiSTer does not have.
targets = [path for path in bios_paths or [] if path]
where = (
" or ".join(f"{path}/" for path in targets)
if targets else "your BIOS directory"
)
guide = guides.get(
platform_name,
(
f"INSTALLATION\n\n"
f" 1. Extract all files from this archive directly into {where}\n"
f" 2. Overwrite if asked\n\n"
),
detect = list(detect or [])
bios_paths = [str(rule.get("bios_path", "")) for rule in detect]
guide = (
"INSTALLATION GUIDE\n\n"
+ _automatic_section(platform_display, detect, standalone_copies)
+ MANUAL_GUIDES.get(platform_name, _generic_manual(bios_paths))
)
if regions:
+7 -3
View File
@@ -617,7 +617,11 @@ class PackReadmesSayWhatTheInstallerDoes(unittest.TestCase):
def _guide(self, platform: str) -> str:
from packreadme import _build_readme
text = _build_readme(platform, platform, "", 1, 1)
registry = yaml.safe_load((ROOT / "platforms" / "_registry.yml").read_text())
install = registry["platforms"][platform].get("install") or {}
text = _build_readme(platform, platform, "", 1, 1,
detect=install.get("detect", []),
standalone_copies=install.get("standalone_copies", []))
return text[text.index("INSTALLATION"):text.index("TROUBLESHOOTING")]
def test_retroarch_offers_the_windows_installer(self):
@@ -642,7 +646,6 @@ class PackReadmesSayWhatTheInstallerDoes(unittest.TestCase):
named = {
path.rstrip("/") for path in re.findall(r"-> (~/\S+)", guide)
}
self.assertTrue(named)
self.assertEqual(sorted(named - targets), [])
def test_retropie_never_sends_a_pc_to_the_linux_partition(self):
@@ -3847,7 +3850,8 @@ class EveryReadmeNamesItsFolder(unittest.TestCase):
if not folder:
continue
with self.subTest(platform=name):
text = _build_readme(name, name, "", 1, 1, bios_paths=[folder])
text = _build_readme(name, name, "", 1, 1,
detect=[{"os": "linux", "bios_path": folder}])
self.assertIn(folder, text)
lakka = _build_readme("lakka", "Lakka", "", 1, 1)
self.assertNotIn("retroarch/system", lakka)
+20
View File
@@ -73,6 +73,26 @@ def _which_pack() -> str:
return generate_site.generate_which_pack()
class TheDownloadTablesFollowTheRegistry(unittest.TestCase):
"""EmuDeck has a Windows edition the installer detects, and the Windows
table of the download page left it out."""
def test_every_detected_system_lists_the_platform(self):
import yaml # noqa: PLC0415
registry = yaml.safe_load(_read("platforms/_registry.yml"))
names = {"retroarch": "RetroArch", "retrobat": "RetroBat", "emudeck": "EmuDeck",
"bizhawk": "BizHawk"}
page = _which_pack()
for os_name, heading in (("windows", "### Windows"), ("darwin", "### macOS")):
table = _section(page, heading)
for platform, entry in registry["platforms"].items():
systems = {rule["os"] for rule in (entry.get("install") or {}).get("detect", [])}
if os_name in systems:
with self.subTest(os=os_name, platform=platform):
self.assertIn(f"[{names[platform]}](", table)
class DownloadLinksNameTheFile(unittest.TestCase):
def test_a_whole_pack_links_its_asset(self):
self.assertEqual(
+280 -2
View File
@@ -376,8 +376,10 @@ class TestFreeSpaceGuard(unittest.TestCase):
self.assertEqual((out, code), ("", 0))
def test_short_of_space_names_both_numbers_and_stops(self):
"""Nothing was installed: code 2, as the exit table says, not the 1
of a partial install."""
out, code = self._run(free=1_000, needed=5_000)
self.assertEqual(code, 1)
self.assertEqual(code, 2)
self.assertIn("Needed", out)
self.assertIn("Free", out)
self.assertIn("--dest", out)
@@ -385,7 +387,52 @@ class TestFreeSpaceGuard(unittest.TestCase):
def test_probe_climbs_to_an_existing_ancestor(self):
missing = self.root / "not" / "created" / "yet"
out, code = self._run(free=1, needed=2, path=missing)
self.assertEqual(code, 1)
self.assertEqual(code, 2)
def test_the_space_check_comes_before_the_folder(self):
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
check = source.index("_check_free_space(bios_path, dl_size")
self.assertLess(check, source.index("bios_path.mkdir(parents=True, exist_ok=True)"))
class AMissingDestIsSaid(unittest.TestCase):
"""--dest ~/.config/retroarh/system (a typo) was created with every
missing parent and reported done, and --check on it said only
"0/7 present"."""
def _call(self, dest: Path, ask: bool, answer: str = "") -> tuple[str, object]:
out = io.StringIO()
code = None
with contextlib.redirect_stdout(out), \
unittest.mock.patch("builtins.input", return_value=answer):
try:
install._missing_dest(dest, ask)
except SystemExit as exc:
code = exc.code
return out.getvalue(), code
def test_an_existing_folder_says_nothing(self):
root = Path(_tempdir(self))
self.assertEqual(self._call(root, True), ("", None))
def test_a_deep_missing_path_is_asked_and_no_is_the_default(self):
root = Path(_tempdir(self))
dest = root / "retroarh" / "system"
out, code = self._call(dest, True, "")
self.assertEqual(code, 2)
self.assertIn(f"The nearest folder that does: {root}", out)
self.assertFalse((root / "retroarh").exists())
self.assertEqual(self._call(dest, True, "y")[1], None)
def test_one_missing_level_or_no_terminal_is_said_not_asked(self):
root = Path(_tempdir(self))
# A "no" that was asked would stop the run with 2.
out, code = self._call(root / "bios", True, "n")
self.assertIsNone(code)
self.assertIn("does not exist", out)
out, code = self._call(root / "a" / "b", False, "n")
self.assertIsNone(code)
self.assertIn("does not exist", out)
class AFolderThatRefusesWritesIsNamed(unittest.TestCase):
@@ -1110,6 +1157,21 @@ class DetectionFollowsTheRegistry(unittest.TestCase):
install._detect_by_path("linux", found)
self.assertEqual(found, [("emudeck", home / "elsewhere" / "bios")])
def test_emudeck_on_macos_is_found_by_its_settings(self):
"""EmuDeck's macOS build reads ~/.config/EmuDeck/settings.sh as the
Linux one does (functions/all.sh:3-4,24, vars.sh:2): a Mac with
EmuDeck detected nothing and was offered RetroArch's folder."""
home = self._home()
settings = home / ".config" / "EmuDeck" / "settings.sh"
settings.parent.mkdir(parents=True)
settings.write_text(f"emulationPath={home / 'Games' / 'Emulation'}\n")
self.assertIn(("emudeck", home / "Games" / "Emulation" / "bios"),
install.detect_platforms("darwin"))
settings.unlink()
(home / "Emulation" / "bios").mkdir(parents=True)
self.assertIn(("emudeck", home / "Emulation" / "bios"),
install.detect_platforms("darwin"))
class RetroArchIsFoundWhereWindowsInstallsPutIt(unittest.TestCase):
"""Program Files\\RetroArch-Win64, a Steam library on another drive and
@@ -2071,6 +2133,48 @@ class TestAvailablePlatforms(unittest.TestCase):
self.assertTrue(marker.is_file(), "piped bootstrap did not download install.py")
self.assertIn("retroarch", proc.stdout)
def test_busybox_wget_downloads_the_installer(self):
"""BusyBox wget, the only one in the RomM image, refuses
--https-only: with no curl the one-liner always failed and blamed
the network."""
scratch = REPO_ROOT / "tmp" / "tests"
scratch.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=scratch) as directory:
root = Path(directory)
fake_bin = root / "bin"
fake_bin.mkdir()
for tool in ("mktemp", "wc", "tr", "sha256sum", "awk", "rm", "rmdir",
"grep", "tail", "cp", "dirname", "cat", "python3"):
found = shutil.which(tool)
if found:
(fake_bin / tool).symlink_to(found)
(fake_bin / "wget").write_text(
"#!/bin/sh\n"
"output=\n"
"for arg in \"$@\"; do\n"
" case \"$arg\" in\n"
" --help) echo 'Usage: wget [-cqS] [-O FILE] [-T SEC] URL' >&2; exit 1 ;;\n"
" --https-only) echo \"wget: unrecognized option '$arg'\" >&2; exit 1 ;;\n"
" --output-document=*) output=${arg#--output-document=} ;;\n"
" esac\n"
"done\n"
"cp -- \"$FAKE_INSTALLER_SOURCE\" \"$output\"\n",
encoding="utf-8",
)
(fake_bin / "wget").chmod(0o755)
env = {
"PATH": str(fake_bin), "HOME": str(root), "TMPDIR": str(root),
"RETROBIOS_INSTALL_URL": "https://example.invalid/install.py",
"FAKE_INSTALLER_SOURCE": str(REPO_ROOT / "install.py"),
}
proc = subprocess.run(
["/bin/sh", "-s", "--", "--list-platforms"], cwd=root, env=env,
input=(REPO_ROOT / "install.sh").read_text(encoding="utf-8"),
capture_output=True, text=True, timeout=60, check=False,
)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("retroarch", proc.stdout)
def test_a_failed_download_says_so_in_one_message(self):
"""Offline, the one-liner printed curl's progress table and
"curl: (6) ...", then exited 6, a code the table does not list."""
@@ -2274,6 +2378,89 @@ class RetroArchWhereItReallyLives(unittest.TestCase):
self.assertIn(("retroarch", support / "system"), found)
class NoPythonNamesThisSystemsCommand(unittest.TestCase):
"""Without Python the bootstrap said `sudo apt install python3` on every
system: Termux has no sudo and installs with `pkg install python`, and
macOS has no apt."""
def _run(self, extra_env: dict, uname: str = "Linux") -> subprocess.CompletedProcess:
root = Path(_tempdir(self))
fake_bin = root / "bin"
fake_bin.mkdir()
for tool in ("sed", "tr", "dirname", "cat"):
found = shutil.which(tool)
if found:
(fake_bin / tool).symlink_to(found)
(fake_bin / "uname").write_text(f"#!/bin/sh\necho {uname}\n", encoding="utf-8")
(fake_bin / "uname").chmod(0o755)
# No terminal: the Termux offer is a question asked on /dev/tty.
return subprocess.run(
["/bin/sh", str(REPO_ROOT / "install.sh")], cwd=root,
env={"PATH": str(fake_bin), "HOME": str(root), **extra_env},
capture_output=True, text=True, timeout=30, check=False,
start_new_session=True,
)
def test_termux_is_told_pkg(self):
proc = self._run({"TERMUX_VERSION": "0.118",
"PREFIX": "/data/data/com.termux/files/usr"})
self.assertEqual(proc.returncode, 1)
self.assertIn("Install it with: pkg install python", proc.stderr)
self.assertNotIn("sudo", proc.stderr)
def test_macos_is_told_xcode_select(self):
proc = self._run({}, uname="Darwin")
self.assertEqual(proc.returncode, 1)
self.assertIn("Install it with: xcode-select --install", proc.stderr)
self.assertNotIn("apt", proc.stderr)
@unittest.skipUnless(shutil.which("pwsh") and shutil.which("script"), "needs PowerShell and a terminal")
class WindowsWithoutPythonIsOfferedWinget(unittest.TestCase):
"""A Windows machine without Python was told to install it, open a new
window and run the command again, while winget, present on Windows 10
and 11, could do it from the bootstrap."""
def _folder(self) -> tuple[Path, Path]:
folder = Path(_tempdir(self))
shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1")
(folder / "install.py").write_text("print('installer ran')\n")
fake_bin = folder / "bin"
fake_bin.mkdir()
python = shutil.which("python3") or sys.executable
(fake_bin / "winget").write_text(
f"#!/bin/sh\necho \"winget $*\"\n{shutil.which('ln')} -s {shlex.quote(python)} "
f"{shlex.quote(str(fake_bin / 'python3'))}\n"
)
(fake_bin / "winget").chmod(0o755)
return folder, fake_bin
def _run(self, folder: Path, fake_bin: Path, answer: str) -> subprocess.CompletedProcess:
pwsh = shutil.which("pwsh")
command = (f"{shlex.quote(pwsh)} -NoProfile -File "
f"{shlex.quote(str(folder / 'install.ps1'))}")
return subprocess.run(
[shutil.which("script"), "-qec", command, "/dev/null"], input=answer,
env={**os.environ, "PATH": f"{fake_bin}{os.pathsep}/usr/bin/nonexistent"},
capture_output=True, text=True, timeout=120, check=False,
)
def test_yes_installs_and_carries_on(self):
folder, fake_bin = self._folder()
proc = self._run(folder, fake_bin, "y\n")
self.assertIn("Install Python 3.12 now with winget?", proc.stdout)
self.assertIn("winget install --id Python.Python.3.12", proc.stdout)
self.assertIn("installer ran", proc.stdout)
self.assertEqual(proc.returncode, 0, proc.stdout)
def test_no_names_the_command(self):
folder, fake_bin = self._folder()
proc = self._run(folder, fake_bin, "n\n")
self.assertNotIn("installer ran", proc.stdout)
self.assertIn("winget install Python.Python.3.12", proc.stdout)
self.assertFalse((fake_bin / "python3").exists())
class ThePowerShellWrapperPassesTheStatusOn(unittest.TestCase):
"""Every non-zero exit became a red "installer failed" exception and
code 1: a --check that found work read as a crash, 2 and 130 were lost."""
@@ -2368,6 +2555,23 @@ class SudoAdviceNamesTheFolder(unittest.TestCase):
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
self.assertIn("platform_of={path: plat for plat, path in platforms}", source)
@unittest.skipUnless(hasattr(os, "statvfs"), "statvfs reads mount flags")
def test_a_read_only_mount_is_not_sent_to_sudo(self):
"""A card Linux mounted read-only after a dirty unplug was answered
with 'run it again with sudo', which fails the same way."""
real = os.statvfs(".")
flags = SimpleNamespace(f_flag=real.f_flag | os.ST_RDONLY)
for plat in ("retroarch", ""):
with self.subTest(platform=plat), \
unittest.mock.patch.object(install.os, "statvfs", return_value=flags):
advice = install._write_advice(Path("/media/card/bios"), False, plat)
self.assertIn("mounted read-only", advice)
self.assertNotIn("Run it again with sudo", advice)
self.assertNotIn("sudo python3", advice)
writable = SimpleNamespace(f_flag=real.f_flag & ~os.ST_RDONLY)
with unittest.mock.patch.object(install.os, "statvfs", return_value=writable):
self.assertIn("sudo", install._write_advice(Path("/media/card/bios"), False, "retroarch"))
class CaseVariants(unittest.TestCase):
@@ -3372,6 +3576,19 @@ class HintsGiveTheUsersOwnCommandBack(unittest.TestCase):
hint = "\n".join(install._manual_usage_hint("linux"))
self.assertIn("python3 install.py --platform retroarch", hint)
def test_a_listing_hint_keeps_the_listing(self):
"""--list-systems with nothing detected was answered with a full
install command: the listing flag gone, a --dest added."""
home = Path(_tempdir(self))
env = {**os.environ, "HOME": str(home), "RETROBIOS_OS": "linux",
"RETROBIOS_BOOTSTRAP": ""}
for flag in ("--list-systems", "--list-cores", "--list-emulators", "--list-targets"):
with self.subTest(flag=flag):
proc = _install_run(env, flag, "--no-input")
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn(f"python3 install.py --platform retroarch {flag}", proc.stdout)
self.assertNotIn("--dest", proc.stdout)
def test_the_write_advice_keeps_the_narrowing(self):
self._record("--system", "atari-lynx", "--region", "us")
install._INVOCATION.os_type = "linux"
@@ -3729,6 +3946,67 @@ class ACompletePartialIsHandedOffOnWindows(unittest.TestCase):
self.assertTrue(install._write_refused(PermissionError(13, "denied")))
class StandaloneFoldersAreNamedWhenThereAreAny(unittest.TestCase, _LoopbackRun):
"""'Standalone copies skipped' closed every install, on a machine with
no standalone emulator as on one whose DuckStation lacked its BIOS."""
def setUp(self):
self.env = self.serve(self)
def _run(self) -> str:
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
str(self.home / "system"), "--system", "atari-lynx",
"--no-input")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
return proc.stdout
def test_nothing_is_said_without_an_emulator_folder(self):
out = self._run()
self.assertNotIn("skipped", out)
self.assertNotIn("--standalone-copies", out)
def test_a_found_folder_is_named(self):
duckstation = self.home / ".local" / "share" / "duckstation" / "bios"
duckstation.mkdir(parents=True)
out = self._run()
self.assertIn("Standalone emulator folders found here:", out)
self.assertIn(str(duckstation), out)
self.assertIn("--standalone-copies", out)
class StandaloneEmulatorsWithoutAPlatformAreServed(unittest.TestCase, _LoopbackRun):
"""ES-DE with DuckStation and PCSX2 and no RetroArch: the run said 'No
supported platform detected' and offered RetroArch's folder, which no
emulator on the machine reads."""
def setUp(self):
self.env = self.serve(self)
(self.home / "ES-DE").mkdir()
self.duckstation = self.home / ".local" / "share" / "duckstation" / "bios"
self.duckstation.mkdir(parents=True)
def test_the_emulator_folder_is_filled(self):
proc = _install_run(self.env, "--no-input", "--dry-run")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("Found standalone emulator folders:", proc.stdout)
self.assertIn(f"to {self.duckstation}:", proc.stdout)
self.assertNotIn("--platform retroarch --dest", proc.stdout)
proc = _install_run(self.env, "--no-input")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
names = sorted(path.name for path in self.duckstation.iterdir())
self.assertIn("scph5501.bin", names)
self.assertTrue(all(name.startswith("scph") for name in names), names)
proc = _install_run(self.env, "--no-input", "--check")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("All files up to date.", proc.stdout)
def test_no_emulator_folder_keeps_the_hint(self):
self.duckstation.rmdir()
proc = _install_run(self.env, "--no-input")
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn("--platform retroarch --dest", proc.stdout)
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."""
+42
View File
@@ -746,5 +746,47 @@ class AListingNeedsNoInstall(unittest.TestCase):
self.assertIn("nintendo-gba", proc.stdout)
class ThePlatformsOwnSystemNamesAreAccepted(unittest.TestCase):
"""A Batocera user who typed `--system psx`, the folder under roms/,
was told the name is unknown with no suggestion, while the platform
file records psx as the native_system of sony-playstation."""
def test_the_names_come_from_the_platform_file(self):
from common import load_platform_config # noqa: PLC0415
from manifestselect import native_system_names # noqa: PLC0415
config = load_platform_config("batocera", str(REPO_ROOT / "platforms"))
names = native_system_names(config, list(config["systems"]))
self.assertEqual(names["sony-playstation"], ["psx"])
self.assertEqual(names["nintendo-gba"], ["gba"])
# A name spelled like the system id adds nothing.
self.assertTrue(all(
install._name_key(alias) != install._name_key(system)
for system, aliases in names.items() for alias in aliases
))
def test_the_builder_writes_them_into_the_manifest(self):
source = (REPO_ROOT / "scripts" / "generate_pack.py").read_text(encoding="utf-8")
self.assertIn('"system_names": native_system_names(config, list(pack_systems))',
source)
def test_a_native_name_narrows_and_lists(self):
with FakeRepository() as repo:
path = repo.root / "install" / "retroarch.json"
manifest = json.loads(path.read_text())
manifest["system_names"] = {"sony-playstation": ["psx"],
"nintendo-gba": ["gba"]}
path.write_text(json.dumps(manifest))
proc = repo.run("--system", "psx", "--no-input")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual(repo.installed(),
["pcsx/extra.bin", "scph5500.bin", "scph5501.bin"])
listing = repo.run("--list-systems", "--no-input")
self.assertIn("nintendo-gba (gba)", listing.stdout)
typo = repo.run("--system", "psxx", "--no-input")
self.assertEqual(typo.returncode, 1)
self.assertIn("Did you mean: sony-playstation", typo.stderr)
if __name__ == "__main__":
unittest.main()
+54 -6
View File
@@ -34,13 +34,10 @@ class GuidesPointAtTheReadPath(unittest.TestCase):
def test_unguided_platforms_name_the_registry_path(self):
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
for name, data in registry["platforms"].items():
paths = [
str(d.get("bios_path", ""))
for d in (data.get("install", {}) or {}).get("detect", [])
]
if not any(paths):
detect = (data.get("install", {}) or {}).get("detect", [])
if not any(d.get("bios_path") for d in detect):
continue
text = _build_readme(name, name, "", 1, 1, bios_paths=paths)
text = _build_readme(name, name, "", 1, 1, detect=detect)
with self.subTest(platform=name):
self.assertFalse(
any("your BIOS directory" in line for line in _extract_lines(text))
@@ -55,5 +52,56 @@ class GuidesNameShippedInstallers(unittest.TestCase):
with self.subTest(installer=name):
self.assertTrue((REPO_ROOT / name).is_file())
def _registry() -> dict:
return yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
def _guide(name: str) -> str:
install = _registry()["platforms"][name].get("install") or {}
return _build_readme(name, name, "", 1, 1, detect=install.get("detect", []),
standalone_copies=install.get("standalone_copies", []))
class TheOneLinerFollowsTheRegistry(unittest.TestCase):
"""The guides were written per platform by hand: ROCKNIX and MiSTer,
which the installer detects, had no one-line install, EmuDeck's
Windows edition was told to open Konsole, and every RomM user was sent
to library/bios/ whatever its firmware template."""
def test_every_detected_system_gets_its_command(self):
for name, data in _registry()["platforms"].items():
systems = {rule["os"] for rule in (data.get("install") or {}).get("detect", [])}
if not systems:
continue
text = _guide(name)
with self.subTest(platform=name):
self.assertEqual("install.sh | sh" in text, bool(systems - {"windows"}))
self.assertEqual("install.ps1 | iex" in text, "windows" in systems)
def test_rocknix_and_mister_run_it_on_the_device(self):
for name in ("rocknix", "misterfpga"):
with self.subTest(platform=name):
self.assertIn(f"On the machine running {name}", _guide(name))
def test_emudeck_is_not_steam_deck_only(self):
text = _guide("emudeck")
self.assertNotIn("Steam Deck / Linux", text)
self.assertNotIn("Konsole", text)
self.assertIn("install.ps1 | iex", text)
self.assertIn("C:\\Emulation\\bios", text)
self.assertIn("--standalone-copies", text)
def test_romm_names_both_firmware_layouts(self):
text = _guide("romm")
self.assertIn("library/bios/", text)
self.assertIn("library/psx/bios/", text)
self.assertIn("--romm-firmware", text)
def test_the_commands_are_written_once(self):
source = (REPO_ROOT / "scripts" / "packreadme.py").read_text(encoding="utf-8")
self.assertEqual(source.count("main/install.sh"), 1)
self.assertEqual(source.count("main/install.ps1"), 1)
if __name__ == "__main__":
unittest.main()
+6 -2
View File
@@ -154,8 +154,12 @@ its BIOS folder, see [Getting started](getting-started.md).
### Error: Python 3.8 or newer is required.
No Python 3.8 or newer was found: Python is missing, or older. The message
names the command that installs it, `sudo apt install python3` on Debian and
Ubuntu. On a handheld running Batocera or Recalbox it is already there, so
names the command that installs it on the system it runs on: `sudo apt install
python3` on Debian and Ubuntu, `sudo dnf install python3` on Fedora, `sudo
pacman -S python` on Arch, `xcode-select --install` on macOS and `pkg install
python` in Termux, where it offers to run that command itself (Termux installs
without root). On Windows, `install.ps1` offers to run `winget install
Python.Python.3.12`. On a handheld running Batocera or Recalbox it is already there, so
this message usually means the command ran on the computer rather than on the
console.
+27 -7
View File
@@ -62,8 +62,13 @@ the same way: waiting five minutes settles it. Any change to `install.py`
therefore means recomputing that pin in both wrappers.
They locate a Python 3.8 or newer interpreter (`python3` then `python` on
POSIX, `py -3` then `python3` then `python` on Windows) and refuse to continue
without one. `install.sh` needs `curl` or `wget` and `sha256sum` or `shasum`.
POSIX, `py -3` then `python3` then `python` on Windows). Without one,
`install.sh` names the command that installs it on the system it runs on
(`pkg install python` in Termux, which it offers to run since Termux installs
without root, `xcode-select --install` on macOS, apt, dnf, pacman, zypper or apk
from `/etc/os-release`), and `install.ps1` at a console offers to run `winget
install Python.Python.3.12`, then reads the new PATH and carries on.
`install.sh` needs `curl` or `wget` (GNU or BusyBox) and `sha256sum` or `shasum`.
Windows PowerShell 5.1 still negotiates SSL 3.0 and TLS 1.0 by default and
GitHub refuses both, so `install.ps1` raises the floor to TLS 1.2 before
downloading, and `install.sh` pins the same floor with `curl --tlsv1.2`.
@@ -204,7 +209,11 @@ refusal suggests the closest names (`playstation` gives `sony-playstation`) and
the option that lists them all. Names are matched on letters and digits, so
`--platform "MiSTer FPGA"` and `--system "Sony PlayStation"` are read as the
site writes them. A system name that itself contains commas, as Recalbox's
`msx1,msx2,msxturbor` does, is read as one name.
`msx1,msx2,msxturbor` does, is read as one name. The name a platform gives a
system itself is accepted too: on Batocera `--system psx` is
`sony-playstation` and `--system gba` is `nintendo-gba`, the names its
`roms/` folders carry. They come from the platform's own file, and
`--list-systems` prints them beside each system (`nintendo-gba (gba)`).
The region rule is the one `generate_pack.py --region` applies, and a test
holds the two to the same file list. In each group of regional alternatives
@@ -224,7 +233,7 @@ what the options name, everything by default.
| Option | Effect |
|--------|--------|
| `--platform NAME` | Install for this platform instead of the detected one. Unknown names are refused with the available list |
| `--dest PATH` | Destination directory, overriding detection. Without `--platform`, the file list is the platform detected here: at a terminal the installer asks, Enter taking the one found, and otherwise it uses the one found, RetroArch's when nothing is found, and refuses when several are. The run prints whose list it is |
| `--dest PATH` | Destination directory, overriding detection. Without `--platform`, the file list is the platform detected here: at a terminal the installer asks, Enter taking the one found, and otherwise it uses the one found, RetroArch's when nothing is found, and refuses when several are. The run prints whose list it is. A folder that does not exist is named with the nearest one that does, then created. When more than its last level is missing (a mistyped path), a run at a terminal asks first, and the answer defaults to no |
| `--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given |
| `--every-board` | Install the files of every board instead of the one the system names, for a card that moves between devices |
| `--installed-cores` | Keep only what the cores already installed read (RetroArch, Lakka, RetroPie). See [Installed cores](#installed-cores) |
@@ -305,7 +314,7 @@ not already found that way.
|----------|----------|----------------|
| `/media/fat/MiSTer` | MiSTer FPGA | `/media/fat/games` |
| `C:\RetroBat\bios` or `%USERPROFILE%\RetroBat\bios` | RetroBat | the same |
| `~/Emulation/bios` | EmuDeck | the same |
| `~/Emulation/bios` (Linux and macOS) | 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 |
@@ -326,7 +335,7 @@ 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` |
| `~/.config/EmuDeck/settings.sh` (Linux and macOS) | 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` |
@@ -366,6 +375,15 @@ they reference emulators rather than owning a BIOS directory. ES-DE is looked
up at `$ESDE_APPDATA_DIR` or `~/ES-DE`, the two locations its own
`getAppDataDirectory` resolves.
When no platform is found but standalone emulators are (an ES-DE setup with
DuckStation and PCSX2, for one), the installer fills their own BIOS folders:
the folders of the [standalone copies](#standalone-copies) list that exist on
the machine, with the files that list names, taken from the EmuDeck file list
and then RetroArch's. At a terminal it asks first. `--check` and `--dry-run`
work as for a platform. With `--system`, `--core`, `--emulator`, `--region` or
`--target` the run asks for a platform as before, since those name a
platform's lists.
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,
@@ -417,6 +435,8 @@ 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.
A run without the flag lists the emulator folders of this list that exist on
the machine, and says nothing when there are none.
## RomM firmware layout
@@ -534,7 +554,7 @@ fields, the installer refuses to narrow rather than install everything.
|------|---------|
| `0` | everything asked for is installed or already up to date |
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed while others installed, or `--check` found files to fetch |
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one). Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`. The message then starts with `install.py: error:` |
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), the drive has too little free space for the download (checked before any folder is created), or the question about a `--dest` folder that does not exist was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`. The message then starts with `install.py: error:` |
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question. Run the same command again to carry on |
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
+4
View File
@@ -272,6 +272,10 @@ The installer needs write access to the target directory.
- On Linux/macOS: check directory ownership (`ls -la`) and run with appropriate
permissions. Avoid running as root unless the target directory requires it.
- On Windows: run PowerShell as Administrator if installing to a protected directory.
- A file system mounted read-only refuses root too, so the installer does not
suggest sudo for it. Linux remounts a card or drive read-only after an error
(an unclean removal, for one): check it (`fsck`, or `chkdsk` on Windows) and
mount it read-write again.
A file already in the BIOS folder with the read-only attribute is replaced
like any other: on Windows the installer clears the attribute before putting