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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 10:03:58 +02:00
1 parent 0b0480a121
commit 7cebbfe2ec
5 files changed
+176 -2

No files matched your search

+1 -1
View File
@@ -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 }
+132
View File
@@ -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),
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="864b631d417299feb305fdab7db62e4395608cce809c71bbb15b9f942be2b867"
DEFAULT_INSTALL_SHA256="041ac17cefaccb9114393d5aaa811ee4d096bed9279ab279fd7f9561beaabe14"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+33
View File
@@ -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."""
+9
View File
@@ -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,