From 7cebbfe2ec889e3d335f7fee2836370ab19afe88 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:03:58 +0200 Subject: [PATCH] feat: fill standalone emulator folders With no platform detected, a machine running standalone emulators (ES-DE with DuckStation and PCSX2) was offered RetroArch's folder. The installer now fills the emulator folders of the standalone copies list that exist here, from the EmuDeck then RetroArch file lists, asking first at a terminal. --- install.ps1 | 2 +- install.py | 132 ++++++++++++++++++++++++++++++++++++++++++ install.sh | 2 +- tests/test_install.py | 33 +++++++++++ wiki/installer.md | 9 +++ 5 files changed, 176 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index 23c8b082..c41c4003 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "864b631d417299feb305fdab7db62e4395608cce809c71bbb15b9f942be2b867" + $defaultInstallSha256 = "041ac17cefaccb9114393d5aaa811ee4d096bed9279ab279fd7f9561beaabe14" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a22277c8..a5885d06 100644 --- a/install.py +++ b/install.py @@ -3791,6 +3791,129 @@ def standalone_folders_here( 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, @@ -4798,6 +4921,15 @@ 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: asked = next((flag for flag, on in ( ("--list-systems", args.list_systems), ("--list-cores", args.list_cores), diff --git a/install.sh b/install.sh index e3643d4b..82aea29f 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="864b631d417299feb305fdab7db62e4395608cce809c71bbb15b9f942be2b867" +DEFAULT_INSTALL_SHA256="041ac17cefaccb9114393d5aaa811ee4d096bed9279ab279fd7f9561beaabe14" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index 79426846..68802f45 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3891,6 +3891,39 @@ class StandaloneFoldersAreNamedWhenThereAreAny(unittest.TestCase, _LoopbackRun): 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/wiki/installer.md b/wiki/installer.md index e795a51d..05fe93c6 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -370,6 +370,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,