diff --git a/install.ps1 b/install.ps1 index 31dd7476..9f0defe7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "50afca134ab44a140421e3da43ab3a74fbbb35ba7c524adf688b8af5909dcc38" + $defaultInstallSha256 = "b5070145ae7920b90e8d904552a223261fae35a394552c7ce3a2b87cbcf613e8" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index fb0d8f3b..c867a670 100644 --- a/install.py +++ b/install.py @@ -1254,8 +1254,9 @@ _EMBEDDED_RULES: tuple[_OsRule, ...] = ( _OsRule("", "TrimUI", "file_exists", ("/usr/trimui",), None), # MinUI, MyMinUI and NextUI keep their system in .system at the card root # (MinUI dbf89435, workspace/all/common/defines.h:14-16) and give each core - # Bios// (workspace/all/minarch/minarch.c:2942), a layout the - # installer does not write. + # Bios// (workspace/all/minarch/minarch.c:2942). Their file lists + # write that layout below the card root, and the card's version.txt + # says which of the three it is (card_platform). _OsRule("", "MinUI", "file_exists", ("/mnt/SDCARD/.system/res",), None, base="TrimUI"), _OsRule("", "MinUI", "file_exists", ("/mnt/sdcard/.system/res",), None, base="TrimUI"), _OsRule("retroarch", "Anbernic stock firmware", "file_exists", @@ -1265,9 +1266,13 @@ _EMBEDDED_RULES: tuple[_OsRule, ...] = ( # Why a system found here gets no folder: said once per run. _NO_FOLDER = { "TrimUI": "its firmware publishes no source naming the folder its RetroArch reads", - "MinUI": "MinUI, MyMinUI and NextUI give each core its own Bios// folder, " - "a layout the installer does not write yet", + "MinUI": f"its card names no release in {CARD_MARKER}, which tells MinUI, NextUI " + "and MyMinUI apart. Name the card's root and the one it runs: " + "--platform minui (or nextui, myminui) --dest /mnt/SDCARD", } +# A system found by its card, with the end of the path that proves it: the +# card root is what comes before. +_CARD_SYSTEMS = {"MinUI": "/.system/res"} # (platform, BIOS folder) -> the name the system found there gives itself. _DETECTED_OS: dict[tuple[str, Path], str] = {} @@ -1337,15 +1342,23 @@ def _detect_embedded() -> list[tuple[str, Path]]: if name in bases: continue rules = [rule for rule in matched if rule.name == name] - folder = _os_system_dir(rules) if rules[0].manifest else None + plat = rules[0].manifest + folder = _os_system_dir(rules) if plat else None + if name in _CARD_SYSTEMS: + marker = next(value for rule in rules for value in rule.values + if value.endswith(_CARD_SYSTEMS[name])) + root = _on_device(marker[:-len(_CARD_SYSTEMS[name])]) + plat = card_platform(root) or "" + if plat: + folder, name = root, display_name(plat) if folder is None: reason = _NO_FOLDER.get( name, "nothing on it names the folder its RetroArch reads") - print(f"{name} found here, but {reason}. Name the folder with --dest.", - file=sys.stderr) + closing = "" if name in _CARD_SYSTEMS else " Name the folder with --dest." + print(f"{name} found here, but {reason}.{closing}", file=sys.stderr) continue - _DETECTED_OS[(rules[0].manifest, folder)] = name - found.append((rules[0].manifest, folder)) + _DETECTED_OS[(plat, folder)] = name + found.append((plat, folder)) return found @@ -1365,7 +1378,10 @@ def _system_lines() -> list[str]: lines = [] for name in dict.fromkeys(rule.name for rule in _EMBEDDED_RULES): plat = next(rule.manifest for rule in _EMBEDDED_RULES if rule.name == name) - if plat: + if name in _CARD_SYSTEMS: + lines.append(f"{name}: minui, nextui or myminui, as the card's {CARD_MARKER} " + "names it (from a computer: --dest )") + elif plat: lines.append(f"{name}: {plat}") else: lines.append(f"{name}: not installed to, {_NO_FOLDER[name]} (use --dest)") @@ -5611,15 +5627,26 @@ def _sized(files: list[dict]) -> str: return f"{_plural(len(files), 'file')}, {format_size(sum(f.get('size', 0) for f in files))}" -def _pick(title: str, rows: list[tuple[str, list[dict]]], ordered: bool = False) -> list[str]: - """Numbered list of names. An empty answer keeps every one.""" +# A core whose files are all the platform's own. "pcsx_rearmed 0 files, +# 0 B" read as a core that gets nothing. +NO_CORE_FILES = "no extra file: it reads the platform's own, always kept" + + +def _pick( + title: str, rows: list[tuple[str, list[dict]]], ordered: bool = False, empty: str = "", +) -> list[str]: + """Numbered list of names. An empty answer keeps every one. + + empty is said instead of "0 files, 0 B" for a name that holds none. + """ if not rows: return [] print(f"\n{title}") width = len(str(len(rows))) name_width = min(max(len(name) for name, _files in rows), 32) for index, (name, files) in enumerate(rows, 1): - print(f" {index:>{width}}) {name:<{name_width}} {_sized(files)}") + print(f" {index:>{width}}) {name:<{name_width}} " + f"{_sized(files) if files or not empty else empty}") hint = "order of preference, e.g. 2,1" if ordered else "e.g. 1,3-5" while True: text = _ask(f"Numbers ({hint}), Enter for all, q to quit: ") @@ -5689,7 +5716,7 @@ def _prompt_custom_selection( (name, _filter_by_target( [f for f in kept if f.get("cores") is not None], [name])) for name in core_names - ]) + ], empty=NO_CORE_FILES) kept, kept_omitted = narrow(kept, kept_omitted, [], cores, []) regions: list[str] = [] @@ -5750,7 +5777,8 @@ def _print_choices( if systems else _filter_by_target([f for f in files if f.get("cores") is not None], [name]) ) - print(f" {labels[name]:<{width}} {_sized(held)}") + shown = NO_CORE_FILES if not systems and not held else _sized(held) + print(f" {labels[name]:<{width}} {shown}") def emulator_names(manifest: dict, files: list[dict], omitted: list[dict]) -> dict[str, str]: diff --git a/install.sh b/install.sh index bf2e8f60..a1de88a0 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="50afca134ab44a140421e3da43ab3a74fbbb35ba7c524adf688b8af5909dcc38" +DEFAULT_INSTALL_SHA256="b5070145ae7920b90e8d904552a223261fae35a394552c7ce3a2b87cbcf613e8" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index 4b06a9f3..900bc8a2 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4727,8 +4727,28 @@ class HandheldSystemsByTheirOwnFiles(unittest.TestCase): {"/usr/trimui/": "", "/mnt/SDCARD/.system/res/": ""}) self.assertEqual(found, []) self.assertIn("MinUI found here", err) + self.assertIn("--platform minui", err) + self.assertIn("--dest", err) self.assertNotIn("TrimUI", err) + def test_a_minui_card_on_its_device_gets_its_file_list(self): + """--list-platforms said the MinUI layout was not written yet, while + --from-pack and --dest wrote it: on the device the card root is the + folder, and the card's version.txt names which of the three it is.""" + for release, plat, shown in (("MinUI-20251127-0", "minui", "MinUI"), + ("NextUI-20250601-1", "nextui", "NextUI"), + ("MyMinUI-20260809b-0", "myminui", "MyMinUI")): + with self.subTest(release=release): + root, found, name = self._one({ + "/usr/trimui/": "", "/mnt/SDCARD/.system/res/": "", + "/mnt/SDCARD/.system/version.txt": release + "\n"}) + self.assertEqual((found, name), ((plat, root / "mnt/SDCARD"), shown)) + + def test_the_listing_says_what_the_minui_family_gets(self): + line = next(line for line in install._system_lines() if line.startswith("MinUI:")) + self.assertNotIn("does not write", line) + self.assertIn("minui, nextui or myminui", line) + def test_two_unrelated_systems_are_refused(self): _root, found, names, _err = self._found( {"/mnt/SDCARD/Koriki/version.txt": "1\n"}, 'OS_NAME="JELOS"\n') diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 8f81f83b..63a26ddf 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -9,6 +9,7 @@ to one answer. from __future__ import annotations +import contextlib import functools import hashlib import http.server @@ -353,6 +354,42 @@ class CommandLineChoices(unittest.TestCase): install.parse_selection(bad, 5) +class ACoreWithNoFileOfItsOwn(unittest.TestCase): + """pcsx_rearmed, swanstation and pcsx1 showed `0 files, 0 B` in the + cores step and in --list-cores, which reads as "this core gets nothing": + their files are the platform's own, which are always kept.""" + + FILES = [ + {"dest": "scph5501.bin", "size": 5, "cores": None, "systems": ["sony-playstation"], + "declared_choices": [], "slot_choices": [{"cores": ["pcsx_rearmed"]}]}, + {"dest": "ps2.bin", "size": 9, "cores": ["duckstation"], + "systems": ["sony-playstation"]}, + ] + + def _line(self, out: str, core: str) -> str: + return next(line for line in out.splitlines() if line.strip().startswith(core) + or f") {core}" in line) + + def test_the_listing_says_its_files_are_the_platforms(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + install._print_choices("retroarch", self.FILES, [], systems=False) + line = self._line(out.getvalue(), "pcsx_rearmed") + self.assertNotIn("0 files", line) + self.assertIn("always kept", line) + self.assertIn("1 file, 9 B", self._line(out.getvalue(), "duckstation")) + + def test_the_cores_step_says_so_too(self): + answers = iter(["c", "", ""]) + out = io.StringIO() + with mock.patch("builtins.input", lambda _prompt="": next(answers)), \ + contextlib.redirect_stdout(out): + install._prompt_custom_selection("retroarch", self.FILES, []) + line = self._line(out.getvalue(), "pcsx_rearmed") + self.assertNotIn("0 files", line) + self.assertIn("always kept", line) + + class TheBoundaryChecksTheNewFields(unittest.TestCase): def _manifest(self, **extra): entry = {"dest": "a.bin", "sha1": "a" * 40, "size": 1, diff --git a/wiki/installer.md b/wiki/installer.md index b718e7e8..3ad1f64b 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -581,7 +581,7 @@ rewrites both to `/roms2/bios`: a second card is followed through them. | `/mnt/plumos-user` | plumOS | RetroArch | `/mnt/plumos-user/bios`, which its launcher appends to every RetroArch run | | `/etc/os-release` `ID=plumos` without the folder above | plumOS | none | its builds choose a folder per launch: named, not installed to | | `/usr/trimui` | TrimUI | none | no published source names the stock firmware's folder: named, not installed to | -| `/mnt/SDCARD/.system/res` or `/mnt/sdcard/.system/res` | MinUI | none | MinUI, MyMinUI and NextUI give each core its own `Bios//`, a layout not written yet: named, not installed to | +| `/mnt/SDCARD/.system/res` or `/mnt/sdcard/.system/res` | MinUI, NextUI or MyMinUI, as the first line of the card's `.system/version.txt` names it | MinUI, NextUI or MyMinUI | the card root, below which the list writes `Bios//`. Without `version.txt` the three cannot be told apart: named, not installed to, and the message gives `--platform` and `--dest` to use | | `/mnt/vendor/bin/dmenu.bin` | Anbernic stock firmware | RetroArch | `/mnt/mmc/bios` | AmberELEC, EmuELEC and KNULLI install the BIOS list they publish themselves. TheRA is not detected: no @@ -692,9 +692,9 @@ for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms/bios` for ROCKNIX, directory for RetroArch. BizHawk has no such folder: it reads `Firmware` beside its executable wherever the archive was extracted, so a forced `--platform bizhawk` that finds no install stops and asks for `--dest`. MinUI, -NextUI and MyMinUI keep their files on the SD card, which the installer reads -from a computer it is mounted on: a forced `--platform minui` stops and asks -for the card's root as `--dest`, and a `--dest` that is a folder inside such a +NextUI and MyMinUI keep their files on the SD card. Run on the device, the +installer finds the card root. From a computer the card is mounted on, a +forced `--platform minui` stops and asks for the card's root as `--dest`, and a `--dest` that is a folder inside such a card (its `Bios/`) is refused with the root to name instead. On Android a forced `--platform retroarch` falls back to `/RetroArch/system` instead, a home directory there belonging to Termux rather than to the frontend. Pass `--dest` to say where instead of