diff --git a/install.ps1 b/install.ps1 index f67d62ec..d9b8fa97 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 diff --git a/install.py b/install.py index 4e3581a9..a5885d06 100644 --- a/install.py +++ b/install.py @@ -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, '')}", 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. diff --git a/install.sh b/install.sh index c5c3ec4a..8da70dda 100755 --- a/install.sh +++ b/install.sh @@ -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/null; then + printf 'Python 3.8 or newer is required. Install it now with "%s"? [Y/n] ' \ + "$install_command" >/dev/tty + read -r answer &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 diff --git a/platforms/_registry.yml b/platforms/_registry.yml index 22c77a2d..790d0f31 100644 --- a/platforms/_registry.yml +++ b/platforms/_registry.yml @@ -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 diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 542555b1..0174cc1b 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -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": { diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index fa7b434a..34f6c3ae 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -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: diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 0bad09ca..58190c10 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -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 `/bios/` instead | --- diff --git a/scripts/manifestselect.py b/scripts/manifestselect.py index fad640c7..1784efe9 100644 --- a/scripts/manifestselect.py +++ b/scripts/manifestselect.py @@ -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())} diff --git a/scripts/packreadme.py b/scripts/packreadme.py index 1afc7c68..d58b74d7 100644 --- a/scripts/packreadme.py +++ b/scripts/packreadme.py @@ -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" + " (/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" - " (/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: diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 1ca902ae..4586855d 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -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) diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py index ced78597..c8930813 100644 --- a/tests/test_doc_surfaces.py +++ b/tests/test_doc_surfaces.py @@ -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( diff --git a/tests/test_install.py b/tests/test_install.py index d358344e..c16ca60e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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.""" diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 22b806a2..2dcfb9d5 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -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() diff --git a/tests/test_pack_readme.py b/tests/test_pack_readme.py index 26f04fe3..4e122e46 100644 --- a/tests/test_pack_readme.py +++ b/tests/test_pack_readme.py @@ -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() diff --git a/wiki/first-install.md b/wiki/first-install.md index 71f39e5a..c5675632 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -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. diff --git a/wiki/installer.md b/wiki/installer.md index 51a87086..57dae073 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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`) | diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index 4b2ced03..ce099d3b 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -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