From 57811270e3d0fd675d2a37f86c578a607bed05f5 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:01:02 +0100 Subject: [PATCH 1/4] fix: card setups, pager lines, devices, data size A system that runs from its SD card (Onion, spruceOS, muOS, KNULLI, MinUI...) named with --platform on a computer no longer takes that computer's RetroArch folder: one mounted card of that system is used, at a terminal the card root is asked, without questions the run stops on the --dest example. The interface opens the card screen with the mounted cards first. The result pager scrolls and counts lines, so its last line is reachable at every size. The setup filter knows the devices each setup runs on, from the guides and the firmware pages. A new event kind, data, gives the bytes of the data trees, and the plan shows them. --- install.ps1 | 2 +- install.py | 325 +++++++++++++++--- install.sh | 2 +- install/launchers/index.json | 16 +- schemas/install-events.schema.json | 30 ++ tests/fixtures/tui/result-40x12.txt | 2 +- tests/fixtures/tui/result-40x15.txt | 2 +- tests/fixtures/tui/route-evmapy.txt | 4 +- tests/fixtures/tui/route-gptokeyb.txt | 4 +- tests/fixtures/tui/route-gptokeyb_muxterm.txt | 4 +- tests/fixtures/tui/route-keys.txt | 4 +- tests/fixtures/tui/route-miyoo.txt | 4 +- tests/fixtures/tui/route-muxterm.txt | 4 +- tests/test_install.py | 2 + tests/test_install_offline.py | 23 ++ tests/test_install_tui.py | 218 ++++++++++++ wiki/installer.md | 3 +- 17 files changed, 570 insertions(+), 79 deletions(-) diff --git a/install.ps1 b/install.ps1 index cbe499f8a..54f214a3a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad" + $defaultInstallSha256 = "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 8bb9cbe0a..d596a9aca 100644 --- a/install.py +++ b/install.py @@ -15,6 +15,7 @@ Usage: from __future__ import annotations import argparse +import bisect import codecs import concurrent.futures import contextlib @@ -542,6 +543,10 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # kind of its own, since schema 1 never adds a key to the file event. "declared": ("platform", "files", "present", "missing", "wrong_hash", "bytes_needed", "dests"), + # Right after declared: the files of the data trees to fetch (check's + # data_missing) and the bytes they write. A kind of its own, since + # schema 1 never adds a key to check. + "data": ("platform", "files", "bytes"), # A file to install that this machine already holds: misplaced in the # BIOS folder (folder is its bios_dir, reason says why in words) or in # a folder named with --from (reason empty). member names the ZIP member @@ -641,7 +646,7 @@ def _os_name() -> str: def _emit_check( plat: str, manifest: dict, bios_path: Path, files: list[dict], up_to_date: list[dict], mismatched: list[dict], data_missing: int, not_installable: int, - chosen: list[str] | None = None, + chosen: list[str] | None = None, data_bytes: int = 0, ) -> None: """One event per file, per system and per platform after a check, then one for the files of the platform's own list. @@ -704,6 +709,7 @@ def _emit_check( bytes_needed=sum(size for _d, size, state in own_states if state != "present"), dests=[dest for dest, _s, _state in own_states], ) + EVENTS.emit("data", platform=plat, files=data_missing, bytes=data_bytes) def _declared_state(copy: dict, entry: dict, bios_path: Path) -> str: @@ -9496,6 +9502,71 @@ def _check_card_root(plat: str, dest: Path) -> None: f"{display_name(plat)} file list was asked for.") +def card_bound(named: str, plat: str) -> tuple[str, str] | None: + """(system name, BIOS folder below the card root) when what --platform + named runs from an SD card, else None. + + `--platform onion` gets RetroArch's list, and on a computer that list's + default folder is the computer's own RetroArch: the files went there, + which Onion never reads. --platform retroarch names a list, not a card. + """ + if plat in CARD_PLATFORMS: + return display_name(plat), "" + key = _name_key(named) + rule = next((rule for rule in _card_rules() + if key and rule.manifest == plat and key == _name_key(rule.name)), None) + return (rule.name, rule.bios) if rule else None + + +def mounted_cards_of(system: str, plat: str) -> list[Path]: + """The mounted cards whose own files say they run system.""" + if plat in CARD_PLATFORMS: + return [mount for mount in _mounted_cards() if card_platform(mount) == plat] + return [mount for mount in _mounted_cards() + if any(rule.name == system for rule in _card_matches(mount))] + + +def card_install_dest(named: str, plat: str, interactive: bool) -> Path: + """The folder for a card-bound system named without --dest, on a machine + that is not the device. + + One mounted card that runs it is taken. At a terminal the card's root is + asked for, never this machine's own folder. Without questions the run + stops on the --dest that names the card. + """ + system, below = card_bound(named, plat) or (display_name(plat), "") + cards = mounted_cards_of(system, plat) + if len(cards) == 1: + dest = cards[0] / below if below else cards[0] + print(f" {system} card found at {cards[0]}: {display_name(plat)} files, in {dest}") + return dest + example = (f"--platform {named.strip() or plat} --dest /media/$USER/ " + "(Windows: --dest E:\\)") + if len(cards) > 1: + print(f" {len(cards)} {system} cards are mounted: " + + ", ".join(str(card) for card in cards) + ".", file=sys.stderr) + if not interactive: + print(f" {system} keeps its BIOS files on its SD card, not on this machine. " + f"Mount the card and name its root with --dest, for example: {example}", + file=sys.stderr) + sys.exit(1) + print(f" {system} keeps its BIOS files on its SD card, not on this machine. " + "Mount the card, then type its root folder.") + raw = "" + while not raw: + raw = _ask("Card root (Ctrl+C to stop): ") + root = _typed_folder(raw) + if plat in CARD_PLATFORMS: + _check_card_root(plat, root) + return root + folder = card_dest(named, plat, root)[1] + if folder == root and below and not _ends_with(root, below): + # A KNULLI share or a ROCKNIX card read as a list's name: its + # folder below the root, as the device reads it. + return root / below + return folder + + def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: """The file list for a --dest given without --platform. @@ -9617,7 +9688,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: elif args.platform: print("Detecting platform...") detected = detect_platforms(os_type) - matched = [(n, p) for n, p in detected if n == args.platform] + bound = card_bound(named, args.platform) + # A system that runs from its card is found only on the device: + # Onion named on a PC matched the PC's own RetroArch. + matched = [(n, p) for n, p in detected if n == args.platform + and (bound is None or detected_os(n, p) == bound[0])] if matched: platforms = matched elif listing: @@ -9625,17 +9700,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # needed, and asking BizHawk's for one refused a question asked # before downloading BizHawk. platforms = [(args.platform, _default_dest(os_type, args.platform) or Path.cwd())] - elif args.platform in CARD_PLATFORMS: + elif bound is not None: # Its files go on its SD card, which is never this machine's - # own folder: the card's mount point is the user's to name. - print( - f" {display_name(args.platform)} keeps its BIOS files on its SD card. " - f"Mount the card and name its root with --dest, for example: " - f"--platform {args.platform} --dest /media/$USER/ " - f"(Windows: --dest E:\\)", - file=sys.stderr, - ) - sys.exit(1) + # own folder. + platforms = [(args.platform, card_install_dest(named, args.platform, + interactive))] elif args.platform in PORTABLE_ONLY: # A portable emulator reads the folder beside its executable: # any default would be a folder it never opens. @@ -10101,6 +10170,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: plat_name, manifest, bios_path, files, up_to_date, mismatched, sum(len(state[1]) for _entry, state in data_states), len(omitted_files), chosen=selection[0] if selection else None, + data_bytes=sum(meta.get("size", 0) for _entry, state in data_states + for meta in state[1].values()), ) emit_copies(plat_name, to_download, copies, bios_index, bios_path, files) emit_unreadable(plat_name, unread) @@ -10673,6 +10744,11 @@ class TuiState: # A --dest given without --platform: the list asks whose files go there. self.dest_given: Path | None = None self.folder_default: Path | None = None + # (system, BIOS folder below the card root) when the setup asked for + # runs from an SD card: the folder screen offers the mounted cards. + self.card_bound: tuple[str, str] | None = None + # (width and text, lines) of the last pager wrapped. + self.pager_cache: tuple | None = None self.path_text: str = "" self.browse_dir: Path | None = None self.browse_rows: list = [] @@ -11074,13 +11150,20 @@ def tui_rows(state: TuiState) -> list[TuiRow]: if screen == "platforms": needle = state.filters.get(screen, "") systems = _systems_reading() - return [_filter_row(state)] + [ - TuiRow("platform", display_name(plat), ", ".join(systems.get(plat, [])), plat) - for plat in AVAILABLE_PLATFORMS - if _matches(" ".join([plat, display_name(plat), *systems.get(plat, [])]), needle) - ] + rows = [_filter_row(state)] + for plat in AVAILABLE_PLATFORMS: + named = _setup_matches(plat, systems.get(plat, []), needle) + if named is not None: + # The systems the filter named, those a device typed runs: + # "miyoo" on RetroArch read "muOS, JELOS, KOLOS...". + rows.append(TuiRow("platform", display_name(plat), + ", ".join(named or systems.get(plat, [])), plat)) + return rows if screen == "folder": rows = [TuiRow("default", "Use this folder")] if state.folder_default else [] + if _tui_card_bound(state): + rows += [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount) + for mount in _mounted_cards()] return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")] if screen == "browse": return list(state.browse_rows) @@ -11437,6 +11520,19 @@ def _mounted_cards() -> list[Path]: return found +def _tui_card_bound(state: TuiState) -> tuple[str, str] | None: + """The card the chosen setup runs from, when it runs from one.""" + if state.card_bound: + return state.card_bound + return card_bound(state.platform, state.platform) if state.platform in CARD_PLATFORMS else None + + +def _card_what(mount: Path) -> str: + """What a mounted card is, as its own files say.""" + system = card_system(mount) + return f"{system[2]} card" if system else "card or drive" + + def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], str]: """A directory's rows for the browser, cards first, and what went wrong. With zips, the ZIP files too: a backup is often one archive.""" @@ -11447,9 +11543,7 @@ def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], rows = [TuiRow("use", "Use this folder"), TuiRow("up", "..", value=folder.parent)] for card in _mounted_cards(): if card != folder: - system = card_system(card) - what = f"{system[2]} card" if system else "card or drive" - rows.append(TuiRow("dir", f"{card} ({what})", value=card)) + rows.append(TuiRow("dir", f"{card} ({_card_what(card)})", value=card)) try: entries = list(os.scandir(folder)) except OSError as exc: @@ -11532,11 +11626,15 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: if state.dest_given is not None: return _choose(state, row.value, state.dest_given) state.platform = row.value - default = None if row.value in CARD_PLATFORMS else _default_dest(state.os_type, row.value) + state.card_bound = card_bound(row.value, row.value) + default = None if state.card_bound else _default_dest(state.os_type, row.value) return _open_folder_screen(state, default) if screen == "folder": if action == "default": return _choose(state, state.platform, state.folder_default) + if action == "card": + below = (_tui_card_bound(state) or ("", ""))[1] + return _choose(state, state.platform, row.value / below if below else row.value) if action == "browse": state.browse_for = "dest" _push(state, "browse") @@ -11638,6 +11736,94 @@ def _systems_reading() -> dict[str, list[str]]: return names +# The devices people type in the setup filter instead of the setup they run, +# beside the setups a first-party page says each one runs. Keys are platform +# ids or the system names of _EMBEDDED_RULES, and a device that runs several +# setups is under each. The handheld systems' lists are those of +# platforms/_guides.yml (handheld_guide, each with its devices_source), and a +# test holds the two equal. +_SETUP_DEVICES: dict[str, tuple[str, ...]] = { + "ArkOS": ("RG353M", "RG353V", "RG503", "RGB30", "RK2023", "RG351MP", "RG351V", "R35S", + "RGB10"), + "muOS": ("RG35XX H", "RG35XX Plus", "RG35XX SP", "RG35XX 2024", "RG34XX H", "RG40XX H", + "RG40XX V", "RG28XX H", "RGCUBEXX", "TrimUI Brick", "TrimUI Smart Pro"), + "Onion": ("Miyoo Mini", "Miyoo Mini+"), + "spruceOS": ("Miyoo A30", "Miyoo Flip", "Miyoo Mini", "Miyoo Mini Plus", + "Miyoo Mini Flip", "TrimUI Smart Pro", "TrimUI Brick", + "Anbernic RG35XX Plus", "RG40XX H", "Powkiddy RGB30"), + "Allium": ("Miyoo Mini", "Miyoo Mini Plus", "Miyoo Mini Flip"), + "ArchR": ("R36S", "R33S", "R36S Soysauce", "Odroid Go Advance", "Odroid Go Super", + "RG351V", "RG351M", "GameForce Chi", "MagicX XU10", "Powkiddy RGB10", + "Powkiddy RGB10X", "K36"), + "CrossMix-OS": ("TrimUI Smart Pro",), + "dArkOS": ("Odroid Go Advance", "RGB10", "RG351MP", "G350"), + "KOLOS": ("RG353P", "RG353M", "RG353V", "RG353VS", "RG503", "Powkiddy RK2023", "RGB30", + "RGB10 Max 3"), + "Koriki": ("Miyoo Mini",), + "MiyooCFW": ("BittBoy", "Pocket Go", "PowKiddy Q90", "PowKiddy V90", "PowKiddy Q20 Mini", + "Sup M3", "XYC Q8"), + "plumOS": ("MagicX XU20 V32",), + "RetroOZ": ("OGS", "OGA", "RGB10Max", "RGB10Max2"), + "UnofficialOS": ("R35S", "R36S", "RG351M", "RG351P", "RG351V", "RG353M", "RG353P", + "RG353V", "RG503", "RG552", "RGB30", "RGB20 Pro", "Odroid Go Advance", + "Odroid Go Super"), + # The Android tile of the handheld group (_guides.yml): goretroid.com + # "OS: Android 13" for the Retroid Pocket 5, ayntec.com "Android 15" for + # the AYN Odin 3. The guide runs RetroArch on Steam Deck (run: steamdeck) + # and on Windows, which the ROG Ally runs (rog.asus.com spec, + # "Windows 11 Home"). + "retroarch": ("Steam Deck", "ROG Ally", "Android", "Retroid Pocket", "Retroid Pocket 5", + "AYN Odin", "AYN Odin 3"), + "emudeck": ("Steam Deck", "ROG Ally"), + "retrodeck": ("Steam Deck",), + "retrobat": ("ROG Ally",), + "bizhawk": ("ROG Ally",), + # _guides.yml runs_on, each cited there. + "batocera": ("Steam Deck",), + "amberelec": ("Anbernic RG351", "RG552"), + "emuelec": ("Odroid Go",), + "minui": ("Anbernic", "Miyoo", "TrimUI"), + "nextui": ("TrimUI Brick", "TrimUI Smart Pro"), + "myminui": ("Anbernic", "Miyoo"), + # rocknix.org/devices, read 2026-10-26. + "rocknix": ("Anbernic RG351", "RG353", "RG503", "RG552", "RG 55G1", "RG ARC", + "RG35XX 2024", "RG35XX Plus", "RG35XX Pro", "RG35XX H", "RG35XX SP", "RG28XX", + "RG40XX V", "RG40XX H", "RG CubeXX", "RG34XX", "AYANEO Pocket", "Ayn Odin 2", + "Ayn Odin 3", "Ayn Thor", "GameForce Ace", "Odroid Go Advance", + "Odroid Go Super", "Odroid Go Ultra", "MagicX XU Mini M", "Mangmi Air X", + "Powkiddy RGB10", "RGB10X", "RK2023", "RGB20SX", "RGB20 Pro", "RGB30", "X35S", + "X35H", "X55", "XU10", "Retroid Pocket 5", "Retroid Pocket 6", + "Retroid Pocket Flip2", "Retroid Pocket Mini", "Retroid Pocket Nova", "R33S", + "R35S", "R36S", "K36"), + # knulli.org, read 2026-10-26. + "knulli": ("Anbernic RG-Arc-S", "RG28XX", "RG34XX", "RG35XX Original", "RG35XX Plus", + "RG35XX H", "RG35XX 2024", "RG35XX SP", "RG40XX H", "RG40XX V", "RGCubeXX", + "Retroid Pocket 5", "Retroid Pocket Flip 2", "Retroid Pocket Mini", + "Miyoo Flip", "Orange Pi Zero 2w", "Powkiddy RGB30", "X55", "TrimUI Brick", + "TrimUI Smart Pro", "BattleXP G350", "GKD Pixel 2"), +} + + +def _device_needle(needle: str) -> str: + """The filter text with a model's short form spelled out: owners write + RP5 for the Retroid Pocket 5, as the store names it.""" + short = re.fullmatch(r"\s*rp\s*(\d+)\s*", needle, re.IGNORECASE) + return f"Retroid Pocket {short.group(1)}" if short else needle + + +def _setup_matches(plat: str, systems: list[str], needle: str) -> list[str] | None: + """What the filter names for plat: its devices and systems that match, + [] when plat's own name does, None when nothing does. A device counts + for each setup it runs.""" + if not needle or _matches(f"{plat} {display_name(plat)}", needle): + return [] + needle = _device_needle(needle) + devices = [device for device in _SETUP_DEVICES.get(plat, ()) if _matches(device, needle)] + named = [system for system in systems + if _matches(" ".join([system, *_SETUP_DEVICES.get(system, ())]), needle)] + return devices + named or None + + def _platform_focus(state: TuiState, plat: str) -> int: """The platform list's row for plat, below its filter row.""" return 1 + AVAILABLE_PLATFORMS.index(plat) if plat in AVAILABLE_PLATFORMS else 1 @@ -11833,18 +12019,11 @@ def _pager_key(state: TuiState, key: TuiKey) -> list[tuple]: def _pager_last(state: TuiState, items: list) -> int: - """The first item shown when the end of a pager is in view, found from - the end: only the last screenful is wrapped.""" - width = min(state.cols, TUI_MAX_WIDTH) - 2 - room = max(1, state.rows - 4) - used, index = 0, len(items) - while index > 0: - size = len(_text_lines([items[index - 1]], width)) - if used + size > room: - break - used += size - index -= 1 - return index + """The first line shown when the end of a pager is in view: the view's + own height (tui_view), less the line that counts.""" + total = _pager_starts(state, items, min(state.cols, TUI_MAX_WIDTH) - 2)[-1] + height = state.rows - 3 + return 0 if total <= height else max(0, total - max(1, height - 1)) def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: @@ -12014,6 +12193,10 @@ def _take_event(state: TuiState, event: dict) -> None: state.done_saves = event elif kind == "found" and state.run == "install": state.local_dests[event["dest"]] = (event["folder"], event["reason"]) + elif kind == "data" and state.run == "check" and state.check is not None: + state.check["data_bytes"] = event["bytes"] + elif kind == "data" and state.run in ("confirm", "preview") and state.dry is not None: + state.dry["data_bytes"] = event["bytes"] elif state.run == "check": if kind == "check": state.check = event @@ -12284,24 +12467,46 @@ def _compose( def _pager( state: TuiState, width: int, height: int, items: list, lead: int = 0, ) -> list[TuiLine]: - """The items in view, from the first shown on, each wrapped alone: the - 8,000 lines of a full preview were wrapped again at every key.""" - first = max(0, min(state.focus.get(state.screen, 0), _pager_last(state, items))) - if len(items) <= height and first == 0: - lines = _text_lines(items, width) - if len(lines) <= height: - return lines + """The lines in view, scrolled line by line, the last one saying which + lines these are. The lead items (a count above a list) are not counted. + + It scrolled by item and counted items: at 40x12 the result read + "1-2 of 5" over eight lines, its last paragraph cut, and a paragraph + longer than the screen could never be read to its end. Only the items + in view are wrapped: the 8,000 lines of a full preview were wrapped + again at every key. + """ + starts = _pager_starts(state, items, width) + total = starts[-1] + if total <= height: + return _text_lines(items, width) room = max(1, height - 1) + first = max(0, min(state.focus.get(state.screen, 0), total - room)) + index = bisect.bisect_right(starts, first) - 1 + offset = first - starts[index] lines: list[TuiLine] = [] - index = first - while index < len(items) and len(lines) < room: + while index < len(items) and starts[index] < first + room: lines += _text_lines([items[index]], width) index += 1 - whole = index if len(lines) <= room else index - 1 - lines = lines[:room] - start = max(1, first + 1 - lead) - return lines + [_styled(f"{start}-{max(start, whole - lead)} of {len(items) - lead}", - "dim")] + skip = starts[lead] if lead else 0 + start = max(1, first + 1 - skip) + end = max(start, first + room - skip) + return lines[offset:offset + room] + [_styled(f"{start}-{end} of {total - skip}", "dim")] + + +def _pager_starts(state: TuiState, items: list, width: int) -> list[int]: + """The line each item starts on once wrapped at width, and the total + last, kept while the items stay the same. An item that fits is one line + without being wrapped.""" + key = (width, [(str(item), getattr(item, "style", "")) for item in items]) + if state.pager_cache is not None and state.pager_cache[0] == key: + return state.pager_cache[1] + starts = [0] + for item in items: + fits = getattr(item, "style", "") == "path" or cell_width(str(item)) <= width + starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width)))) + state.pager_cache = (key, starts) + return starts def _tui_misplaced(state: TuiState) -> list[dict]: @@ -12369,11 +12574,15 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}" elif need: headline = f"{_plural(len(copied), 'file')} to copy from this machine" + # The data trees' size comes from their own event: "2636 data files" + # beside "6.6 GB" said nothing of what they weigh. + data_size = (f" ({format_size(check['data_bytes'])})" if data and "data_bytes" in check + else "") if need: if data: - headline += f", and {_plural(data, 'data file')}" + headline += f", and {_plural(data, 'data file')}{data_size}" if not need: - headline = (f"{_plural(data, 'data file')} to fetch" if data + headline = (f"{_plural(data, 'data file')} to fetch{data_size}" if data else "Everything is in place") wrong = check["wrong_hash"] offline_lines = (["Copy them from your old card or a folder, or run the installer " @@ -12484,10 +12693,13 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: def _view_folder(state: TuiState, width: int, height: int) -> tuple: name = display_name(state.platform) - if state.platform in CARD_PLATFORMS: + bound = _tui_card_bound(state) + if bound: title = "Where is the SD card?" - before = [f"{name} keeps its BIOS files on its SD card. Choose the card's " - "root folder, the one holding .system."] + before = [f"{bound[0]} keeps its BIOS files on its SD card. Choose the card's " + "root folder" + + (", the one holding .system." if state.platform in CARD_PLATFORMS + else ".")] elif state.folder_default is not None: title = "Which folder?" before = [f"{name} usually reads its BIOS files from:", str(state.folder_default)] @@ -13246,7 +13458,11 @@ def tui_start( state.stack = [] return state, _choose(state, plat, Path(args.dest)) if plat: - matched = [setup for setup in state.setups if setup[0] == plat] + bound = card_bound(args.platform, plat) + # A system that runs from its card is its card or the device, never + # this machine's own folder for the same list. + matched = [setup for setup in state.setups if setup[0] == plat + and (bound is None or detected_os(setup[0], setup[1]) == bound[0])] if len(matched) == 1: state.stack = [] return state, _choose(state, plat, matched[0][1]) @@ -13254,8 +13470,9 @@ def tui_start( state.setups = matched return state, [] state.platform = plat + state.card_bound = bound state.stack = [] - default = None if plat in CARD_PLATFORMS else _default_dest(os_type, plat) + default = None if bound else _default_dest(os_type, plat) return state, _open_folder_screen(state, default) if args.dest is not None: card = card_platform(Path(args.dest)) diff --git a/install.sh b/install.sh index a3a63ccaa..0e7bfb92f 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="cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad" +DEFAULT_INSTALL_SHA256="a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 968e4f42f..da7543eca 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 611006, - "sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad", + "size": 622687, + "sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 661660, - "sha256": "681ae3d8eb8572640b57dd4ca8699cb2cb8f011d1071ba5ef286c1b2eeb1877d" + "size": 673341, + "sha256": "8ffa4bc6e3e16745012c4cebdfebb7070cdcbd63191e0ef0edb11556338698ad" }, "members": [ { @@ -85,8 +85,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 611006, - "sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad", + "size": 622687, + "sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3", "executable": false }, { @@ -159,8 +159,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 611006, - "sha256": "cec348d9aa6c9f940a2c9dc9bc6414a923acea8137e4a3624d4710c9d88c38ad", + "size": 622687, + "sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3", "executable": false }, { diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index 02bafac05..180fa24b3 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -20,6 +20,7 @@ "checking", "check", "declared", + "data", "found", "unreadable", "saves", @@ -64,6 +65,9 @@ { "$ref": "#/$defs/declared" }, + { + "$ref": "#/$defs/data" + }, { "$ref": "#/$defs/found" }, @@ -454,6 +458,32 @@ } } }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "platform", + "files", + "bytes" + ], + "properties": { + "event": { + "const": "data" + }, + "platform": { + "type": "string" + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "bytes": { + "type": "integer", + "minimum": 0 + } + } + }, "found": { "type": "object", "additionalProperties": false, diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt index e1ef9b134..b7b5d8c6b 100644 --- a/tests/fixtures/tui/result-40x12.txt +++ b/tests/fixtures/tui/result-40x12.txt @@ -8,5 +8,5 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-4 of 8 + 1-8 of 17 A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt index c551897a1..60138766a 100644 --- a/tests/fixtures/tui/result-40x15.txt +++ b/tests/fixtures/tui/result-40x15.txt @@ -11,5 +11,5 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-5 of 8 + 1-11 of 17 A/Enter Exit Up/Down Scroll diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt index 179fa02f6..5bb23c9b9 100644 --- a/tests/fixtures/tui/route-evmapy.txt +++ b/tests/fixtures/tui/route-evmapy.txt @@ -981,7 +981,7 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-4 of 8 + 1-8 of 17 A Exit Up/Down Scroll === result 40x15 RetroBIOS: Finished with errors @@ -997,7 +997,7 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-5 of 8 + 1-11 of 17 A Exit Up/Down Scroll === result 80x24 RetroBIOS: Finished with errors diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt index 946460274..1889a6294 100644 --- a/tests/fixtures/tui/route-gptokeyb.txt +++ b/tests/fixtures/tui/route-gptokeyb.txt @@ -981,7 +981,7 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-4 of 8 + 1-8 of 17 A Exit Up/Down Scroll === result 40x15 RetroBIOS: Finished with errors @@ -997,7 +997,7 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-5 of 8 + 1-11 of 17 A Exit Up/Down Scroll === result 80x24 RetroBIOS: Finished with errors diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt index 946460274..1889a6294 100644 --- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt +++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt @@ -981,7 +981,7 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-4 of 8 + 1-8 of 17 A Exit Up/Down Scroll === result 40x15 RetroBIOS: Finished with errors @@ -997,7 +997,7 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-5 of 8 + 1-11 of 17 A Exit Up/Down Scroll === result 80x24 RetroBIOS: Finished with errors diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt index f2bed0ac5..e506786a3 100644 --- a/tests/fixtures/tui/route-keys.txt +++ b/tests/fixtures/tui/route-keys.txt @@ -927,7 +927,7 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-4 of 8 + 1-8 of 17 Enter Exit Up/Down Scroll === result 40x15 RetroBIOS: Finished with errors @@ -943,7 +943,7 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-5 of 8 + 1-11 of 17 Enter Exit Up/Down Scroll === result 80x24 RetroBIOS: Finished with errors diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt index 99103df10..80f3ae318 100644 --- a/tests/fixtures/tui/route-miyoo.txt +++ b/tests/fixtures/tui/route-miyoo.txt @@ -981,7 +981,7 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-4 of 8 + 1-8 of 17 A Exit Up/Down Scroll === result 40x15 RetroBIOS: Finished with errors @@ -997,7 +997,7 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-5 of 8 + 1-11 of 17 A Exit Up/Down Scroll === result 80x24 RetroBIOS: Finished with errors diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt index 7c360043f..941a538b9 100644 --- a/tests/fixtures/tui/route-muxterm.txt +++ b/tests/fixtures/tui/route-muxterm.txt @@ -981,7 +981,7 @@ /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 To put them back: python3 install.py - 1-4 of 8 + 1-8 of 17 Start Exit Up/Down Scroll === result 40x15 RetroBIOS: Finished with errors @@ -997,7 +997,7 @@ --restore /home/user/.config/retroarch/ retrobios-backup/2026-10-23_1200 - 1-5 of 8 + 1-11 of 17 Start Exit Up/Down Scroll === result 80x24 RetroBIOS: Finished with errors diff --git a/tests/test_install.py b/tests/test_install.py index 62287a03c..6a37ce8c7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -5163,6 +5163,8 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree): {"event": "declared", "platform": "retroarch", "files": 4, "present": 1, "missing": 2, "wrong_hash": 1, "bytes_needed": 245, "dests": ["a.bin", "b.bin", "c.bin", "d.bin"]}, + # No data tree in this list. + {"event": "data", "platform": "retroarch", "files": 0, "bytes": 0}, ] SKIPPED = [{"reason": "not_found", "label": "not found", "files": 1, "required": 1}] diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index efd194318..8fdc48e7e 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -473,6 +473,29 @@ class FromPackInstallsOffline(Scratch): for rel, data in tree.items(): self.assertEqual((self.dest / "dolphin-emu" / rel).read_bytes(), data) + def test_the_data_trees_say_what_they_weigh(self): + """tui_audit_4 P3-10.8: the plan read "and 2636 data files" with no + size. The data event carries the bytes check's data_missing counts.""" + tree = {"Sys/GC/font.bin": b"font bytes", "Sys/Wii/setting.txt": b"setting"} + listed = manifest() + listed["data_directories"] = [{ + "ref": "dolphin-sys", "dest": "dolphin-emu", "type": "zip", + "url": "https://example.invalid/dolphin.zip", "systems": ["nintendo-gba"], + "files": {rel: {"sha1": _sha1(data), "size": len(data)} + for rel, data in tree.items()}, + }] + self.list.write_text(json.dumps(listed)) + self.place("dolphin-emu/Sys/GC/font.bin", tree["Sys/GC/font.bin"]) + proc = self.run_installer("--check", "--events", "--manifest", str(self.list), + "--from-pack", str(self.pack)) + events = [json.loads(line) for line in proc.stdout.splitlines()] + kinds = [event["event"] for event in events] + self.assertEqual(kinds.index("data"), kinds.index("declared") + 1) + data = events[kinds.index("data")] + self.assertEqual(data, {"event": "data", "platform": "retroarch", "files": 1, + "bytes": len(tree["Sys/Wii/setting.txt"])}) + self.assertEqual(events[kinds.index("check")]["data_missing"], 1) + def test_a_cut_download_is_named(self): self.pack.write_bytes(self.pack.read_bytes()[:100]) proc = self.run_installer("--from-pack", str(self.pack)) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index c34c290f0..4759e0e87 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -946,6 +946,12 @@ def golden_states() -> dict: class Views(unittest.TestCase): """Every screen as text, at the sizes a handheld and a desktop show.""" + def setUp(self): + # The card screen lists the cards mounted here: none, on every machine. + patcher = mock.patch.object(install, "_mounted_cards", return_value=[]) + patcher.start() + self.addCleanup(patcher.stop) + def test_goldens(self): update = os.environ.get("RETROBIOS_UPDATE_GOLDENS") == "1" GOLDEN.mkdir(parents=True, exist_ok=True) @@ -2728,6 +2734,218 @@ class AuditRoundFour(unittest.TestCase): self.assertIn(line, install.tui_outcome(state)[1]) +class ACardSystemNamedOnAComputer(unittest.TestCase): + """`--platform onion` on a PC wrote RetroArch's files into the PC's own + ~/.config/retroarch/system, which Onion never reads: the system's list + is RetroArch's, and so was the folder detection found for it.""" + + ONION = {".tmp_update/onionVersion/version.txt": "4.3.1\n"} + + def card(self, files: dict) -> Path: + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + for name, text in files.items(): + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_text(text, encoding="utf-8") + return root + + def test_the_systems_that_run_from_a_card(self): + self.assertEqual(install.card_bound("onion", "retroarch"), ("Onion", "BIOS")) + self.assertEqual(install.card_bound("muOS", "retroarch"), ("muOS", "MUOS/bios")) + self.assertEqual(install.card_bound("spruceos", "retroarch"), ("spruceOS", "BIOS")) + self.assertEqual(install.card_bound("knulli", "knulli"), ("KNULLI", "bios")) + self.assertEqual(install.card_bound("nextui", "nextui"), ("NextUI", "")) + self.assertIsNone(install.card_bound("retroarch", "retroarch")) + self.assertIsNone(install.card_bound("batocera", "batocera")) + + def test_without_questions_the_run_names_the_card_and_writes_nothing(self): + home = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, home, True) + system = home / ".config" / "retroarch" / "system" + system.mkdir(parents=True) + (home / ".config" / "retroarch" / "retroarch.cfg").write_text( + f'system_directory = "{system}"\n', encoding="utf-8") + with FakeRepository() as repo: + proc = install_run("--platform", "onion", "--yes", + env={**repo.env(), "HOME": str(home)}) + self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) + self.assertIn("Onion keeps its BIOS files on its SD card, not on this machine.", + proc.stderr) + self.assertIn("--platform onion --dest /media/$USER/", proc.stderr) + self.assertEqual(list(system.iterdir()), []) + + def test_one_mounted_card_of_that_system_is_taken(self): + onion, other = self.card(self.ONION), self.card({"roms/a": ""}) + with mock.patch.object(install, "_mounted_cards", return_value=[other, onion]), \ + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest("onion", "retroarch", interactive=False) + self.assertEqual(dest, onion / "BIOS") + self.assertIn(f"Onion card found at {onion}", out.getvalue()) + + def test_at_a_terminal_the_card_root_is_asked(self): + onion = self.card(self.ONION) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_ask", return_value=str(onion)) as ask, \ + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest("onion", "retroarch", interactive=True) + self.assertEqual(dest, onion / "BIOS") + self.assertIn("Mount the card, then type its root folder.", out.getvalue()) + ask.assert_called_once() + knulli = self.card({"roms/a": ""}) + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_ask", return_value=str(knulli)), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(install.card_install_dest("knulli", "knulli", True), + knulli / "bios") + + def test_the_interface_opens_the_card_screen_with_the_cards_first(self): + blank = self.card({"roms/a": ""}) + (blank / "BIOS").mkdir() + found = [("retroarch", SYSTEM_DIR)] + with mock.patch.object(install, "_mounted_cards", return_value=[blank]), \ + mock.patch.object(install, "detect_platforms", return_value=found), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, effects = install.tui_start(_args(platform="onion"), "linux") + self.assertEqual((state.screen, effects), ("folder", [])) + rows = install.tui_rows(state) + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + effects = install.tui_update(state, Key("enter")) + self.assertEqual([row.action for row in rows], ["card", "browse", "type"]) + self.assertIn("Where is the SD card?", text) + self.assertIn("Onion keeps its BIOS files on its SD card", text) + self.assertEqual(effects, [("fetch", "retroarch")]) + self.assertEqual(state.dest, blank / "BIOS") + + def test_the_interface_takes_the_one_onion_card_mounted(self): + onion = self.card(self.ONION) + (onion / "BIOS").mkdir() + found = [("retroarch", SYSTEM_DIR)] + with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ + mock.patch.object(install, "detect_platforms", return_value=found), \ + mock.patch.object(install, "detect_frontends", return_value=[]): + state, effects = install.tui_start(_args(platform="onion"), "linux") + self.assertEqual(effects, [("fetch", "retroarch")]) + self.assertEqual(state.dest, onion / "BIOS") + + def test_on_the_device_its_own_folder_is_used(self): + device = Path("/mnt/SDCARD/BIOS") + with mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "detect_platforms", + return_value=[("retroarch", device)]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.dict(install._DETECTED_OS, {("retroarch", device): "Onion"}), \ + mock.patch.object(install.Path, "exists", return_value=True): + state, effects = install.tui_start(_args(platform="onion"), "linux") + self.assertEqual(effects, [("fetch", "retroarch")]) + self.assertEqual(state.dest, device) + + +class TheSetupFilterKnowsDevices(unittest.TestCase): + """tui_audit_4 P3-10.5: the filter found nothing for steam deck, r36s, + trimui or retroid, and miyoo found RetroArch with muOS, JELOS... beside it.""" + + TYPED = { + "steam deck": {"retroarch", "batocera", "emudeck", "retrodeck"}, + "r36s": {"retroarch", "rocknix"}, + "rg35xx": {"retroarch", "rocknix", "knulli"}, + "trimui": {"retroarch", "knulli", "minui", "nextui"}, + "retroid": {"retroarch", "rocknix", "knulli"}, + "miyoo": {"retroarch", "knulli", "minui", "myminui"}, + "anbernic": {"retroarch", "rocknix", "amberelec", "knulli", "minui", "myminui"}, + "odin": {"retroarch", "rocknix"}, + "ayn": {"retroarch", "rocknix"}, + "rp5": {"retroarch", "rocknix", "knulli"}, + "rog ally": {"retroarch", "emudeck", "retrobat", "bizhawk"}, + "onion": {"retroarch"}, + } + + def rows(self, needle: str) -> list: + state = fresh_state() + state.stack = ["platforms"] + state.filters["platforms"] = needle + return [row for row in install.tui_rows(state) if row.action == "platform"] + + def test_each_device_finds_every_setup_it_runs(self): + for needle, setups in self.TYPED.items(): + with self.subTest(needle=needle): + self.assertEqual({row.value for row in self.rows(needle)}, setups) + + def test_the_right_column_names_what_matched(self): + retroarch = {row.value: row for row in self.rows("miyoo")}["retroarch"] + self.assertEqual(retroarch.right.split(", "), + ["spruceOS", "Onion", "Allium", "Koriki", "MiyooCFW"]) + deck = {row.value: row for row in self.rows("steam deck")} + self.assertEqual(deck["retroarch"].right, "Steam Deck") + self.assertIn("muOS", {row.value: row for row in self.rows("")}["retroarch"].right) + + def test_the_handheld_lists_are_the_guides(self): + import yaml + + guides = yaml.safe_load((REPO_ROOT / "platforms" / "_guides.yml").read_text()) + for system in guides["handheld_guide"]["systems"]: + if system.get("devices"): + with self.subTest(system=system["name"]): + self.assertEqual(install._SETUP_DEVICES[system["name"]], + tuple(str(name) for name in system["devices"])) + + +class DataFilesHaveASize(unittest.TestCase): + """tui_audit_4 P3-10.8: "8179 files to download, 6.6 GB, and 2636 data + files", the data files with no size.""" + + def test_the_plan_gives_the_data_files_size(self): + state = fresh_state() + state.stack = ["setup"] + with mock.patch.object(install.Path, "exists", return_value=True): + install.tui_update(state, Key("enter")) + install.tui_receive(state, ("manifest", "retroarch", MANIFEST)) + check = {**CHECK, "data_missing": 2636} + data = {"event": "data", "platform": "retroarch", "files": 2636, + "bytes": 412 * 1024 ** 2} + for event in [*FILES, *SYSTEMS, check, data]: + install.tui_receive(state, ("event", state.run_id, event)) + with mock.patch.object(install, "_tui_folder_facts", return_value={ + "free": 50 * 1024 ** 3, "writable": True, "cores": []}): + install.tui_receive(state, ("ended", state.run_id, 1, [])) + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("and 2636 data files (412.0 MB)", text) + + +class ThePagerCountsLines(unittest.TestCase): + """tui_audit_4 P3-9.4: at 40x12 the result read "1-2 of 5" over eight + lines, counting paragraphs, and its last paragraph was cut.""" + + def result_state(self, cols: int, rows: int) -> install.TuiState: + state = checked_state() + state.stack.append("result") + state.code, state.summary = 0, {"installed": 3} + state.dest = Path("/media/" + "long-folder-name/" * 12) + state.cols, state.rows = cols, rows + return state + + def test_the_last_line_is_reachable_at_every_size(self): + for cols, rows in (*SIZES, (50, 13), (60, 14)): + with self.subTest(size=f"{cols}x{rows}"): + state = self.result_state(cols, rows) + width = min(cols, install.TUI_MAX_WIDTH) - 2 + wrapped = install._text_lines(install._pager_items(state), width) + press(state, "end") + screen = install.tui_view(state, cols, rows) + body = [line.rstrip() for line in screen[2:-1]] + self.assertIn(" " + str(wrapped[-1]).rstrip(), body) + if len(wrapped) > rows - 3: + self.assertTrue(any(re.fullmatch(rf" \d+-{len(wrapped)} of {len(wrapped)}", + line) for line in body), body) + + def test_the_counter_moves_one_line_per_press(self): + state = self.result_state(40, 12) + first = [line for line in install.tui_view(state, 40, 12) if " of " in line][-1] + self.assertRegex(first, r"^ 1-8 of \d+") + press(state, "down") + second = [line for line in install.tui_view(state, 40, 12) if " of " in line][-1] + self.assertRegex(second, r"^ 2-9 of \d+") + + class OfflineInTheInterface(unittest.TestCase): """--offline in the full screen: a "No connection" error offering a Try again that can never work, and a plan counting downloads the run diff --git a/wiki/installer.md b/wiki/installer.md index 872474676..59b5eae89 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1334,6 +1334,7 @@ other schemas under `api/v1/schemas/`. | `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it | | `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply | | `declared` | `platform`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `dests` | Right after `check`, the same counts over the files the platform's own list names (`cores: null`), and their `dests` in the order of the `file` events. A setup that checks its own list reads its answer here, with or without `--declared-only` | +| `data` | `platform`, `files`, `bytes` | Right after `declared`: the data-tree files to fetch (the `data_missing` of `check`) and the bytes they write once in place | | `found` | `platform`, `dest`, `folder`, `path`, `member`, `reason` | Each file to install that this machine already holds, after `check` and before anything is written, in `--check` and `--dry-run` too. `folder` is the `--from` folder as given, or the `bios_dir` of the check when the BIOS folder holds the file at another place, and then `reason` says where in words (`nested folder: ...` for a misplaced file, `the same file is at ...` for one the list installs there too). `path` is the file, and `member` the ZIP member holding the bytes when `path` is an archive, an empty string otherwise. Its copy is reported by a `downloaded` event | | `unreadable` | `platform`, `folder`, `path`, `member`, `reason` | Each file in a `--from` folder that could not be read and may be one the run still needs, right after the `found` events: its size is that of a file no copy was found for, or unknown (a folder that could not be listed, a ZIP that would not open). `member` names the ZIP member, or is empty. `reason` says why in words (`Permission denied`, `it is encrypted, a password is needed`) | | `saves` | `platform`, `files`, `folder` | After `detect` and before `checking`, when the list holds files read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `` and `` stand for one folder per system or per core. When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) | @@ -1345,7 +1346,7 @@ other schemas under `api/v1/schemas/`. | `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) | | `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) | -Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`), never a key of an +Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees), never a key of an existing kind: a reader skips a kind it does not know. An option refused before the run starts (`install.py: error:`, code 2) writes no event at all. A program reads a missing `end` as that case, or as a From e919d8478e97f866ff93f8739a6cbf967082a7fc Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:20:29 +0100 Subject: [PATCH 2/4] feat: pick one system in a few presses A long systems list opens on None. A there unticks every system and moves to the first one, and A on a system with nothing ticked installs that one alone, so Enter still continues. Inside muxterm, which keeps PgUp and PgDn for its scrollback, Left and Right on the D-pad page. One system of 66 at 40x15 took 17 presses with a pad, 57 in muxterm; it now takes 12 on both. The pyyaml check of the device lists skips where pyyaml is not installed. --- install.ps1 | 2 +- install.py | 38 ++++++++-- install.sh | 2 +- install/launchers/index.json | 20 ++--- ports/muos/README.md | 3 +- tests/test_install_tui.py | 142 ++++++++++++++++++++++++++++++++--- wiki/installer.md | 11 +-- 7 files changed, 185 insertions(+), 33 deletions(-) diff --git a/install.ps1 b/install.ps1 index 54f214a3a..4e6878ef7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3" + $defaultInstallSha256 = "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index d596a9aca..49fb58619 100644 --- a/install.py +++ b/install.py @@ -11685,6 +11685,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _open_from_browser(state) if action in ("misplaced", "unreadable"): return _push(state, action) + if action == "systems" and len(state.systems) > TUI_LONG_LIST: + # On None: picking one system of 66 began with Up, Up, A. + return _push(state, action, 1) # On the first system, so A continues and the D-pad ticks. return _push(state, action, 3 if action == "systems" else 0) if screen == "systems": @@ -11692,7 +11695,14 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: state.ticked = {row["system"] for row in state.systems} elif action == "none": state.ticked = set() + # On the first system: the next A takes the one in focus alone. + state.focus["systems"] = next( + (i for i, item in enumerate(tui_rows(state)) if item.action == "system"), + state.focus.get("systems", 0)) else: + if action == "system" and not state.ticked: + # Nothing ticked: A takes the system in focus, and only it. + state.ticked = {row.value} return _begin_install(state) return [] if screen == "options": @@ -12046,6 +12056,12 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: return _filter_key(state, key) if state.pad == "miyoo": key = _miyoo_key(state, key) + if (state.pad in _NO_PAGE_PADS and key.name in ("left", "right") + and screen not in ("regions", "options")): + # muxterm keeps PgUp and PgDn for its scrollback: the D-pad's Left + # and Right page instead (X or Y still ticks). One system of 66 took + # about 60 Down presses. + key = TuiKey("pgup" if key.name == "left" else "pgdn") char = key.text if key.name == "char" else "" if key.name == "f1" or char == "?": state.help = True @@ -12129,6 +12145,10 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]: return [] +# The routes inside muxterm, where no button sends PgUp or PgDn. +_NO_PAGE_PADS = ("muxterm", "gptokeyb_muxterm") + + # A Miyoo's L1 and R1 are the keys E and T (codes 18 and 20). _MIYOO_PAGES = {"e": "pgup", "t": "pgdn"} @@ -12422,6 +12442,9 @@ def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> l # The rows a screen keeps for its list under pinned lines. TUI_PINNED_ROWS = 4 +# A list longer than this opens its systems on None, and its help names a +# page key. +TUI_LONG_LIST = 9 def _compose( @@ -13243,9 +13266,9 @@ _HELP_KEYS: dict[str, dict[str, str | None]] = { "back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None, "enter": "A, Enter", "esc": "B, Esc"}, # muxterm keeps PgUp and PgDn for its own scrollback, and its L1 and R1 - # send nothing: no key pages a list there. - "muxterm": {"move": "D-pad, Up/Down", "page": None, "ok": "Start, Enter", - "tick": "Y, Space, Right/Left", "options": "o, Tab", + # send nothing: the D-pad's Left and Right page a list there. + "muxterm": {"move": "D-pad, Up/Down", "page": "D-pad Left/Right", "ok": "Start, Enter", + "tick": "Y, Space", "options": "o, Tab", "back": "B, Esc, Backspace", "stop": "B, Esc, Ctrl-C", "kill": None, "enter": "Start, Enter", "esc": "Esc"}, "miyoo": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter", @@ -13254,8 +13277,9 @@ _HELP_KEYS: dict[str, dict[str, str | None]] = { "enter": "Start, Enter", "esc": "Esc"}, } # muxterm keeps PgUp and PgDn for its scrollback even when gptokeyb2 owns the -# pad, and the launcher sends nothing on L1 and R1 there. -_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": None} +# pad, and the launcher sends nothing on L1 and R1 there: Left and Right page. +_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": "D-pad Left/Right", + "tick": "X, Space"} def _route_extras(state: TuiState) -> list[tuple[str, str]]: @@ -13282,7 +13306,7 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]: ("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"), *_route_extras(state)] keys = [(names["move"], "Move")] - if len(tui_rows(state)) > 9 or screen in _PAGERS: + if len(tui_rows(state)) > TUI_LONG_LIST or screen in _PAGERS: keys += [*([(names["page"], "Page")] if names["page"] else []), ("Home/End, g/G", "First, last")] primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0] @@ -13315,6 +13339,8 @@ def _primary(state: TuiState) -> str | None: return None row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))] verb = _ROW_VERBS.get(row.action) + if state.screen == "systems" and row.action == "system" and not state.ticked: + verb = "Only this system" return f"A/Enter {verb}" if verb else None diff --git a/install.sh b/install.sh index 0e7bfb92f..724dc1704 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="a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3" +DEFAULT_INSTALL_SHA256="b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index da7543eca..36d6c3ec4 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 622687, - "sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3", + "size": 624131, + "sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697", "executable": false }, { @@ -71,22 +71,22 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 673341, - "sha256": "8ffa4bc6e3e16745012c4cebdfebb7070cdcbd63191e0ef0edb11556338698ad" + "size": 674834, + "sha256": "b7d51126f63aeff0d7ca790d887556619aeb566b7133fe66ef79f08a7f639929" }, "members": [ { "name": "RetroBIOS/README.md", "repo_path": "ports/muos/README.md", - "size": 1127, - "sha256": "c0cfafad5fae8e6764b263b14a06195b267f66c4fe042333941ae6bcc6dab265", + "size": 1176, + "sha256": "26b5da84f22ce3c652f16ed8335be11d99e3bfb8657eec7caa100cf505f68497", "executable": false }, { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 622687, - "sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3", + "size": 624131, + "sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697", "executable": false }, { @@ -159,8 +159,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 622687, - "sha256": "a564b0582012d8851acea1cfc973101785be4a56d79aacb75bcf50691139c5a3", + "size": 624131, + "sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697", "executable": false }, { diff --git a/ports/muos/README.md b/ports/muos/README.md index 8b2facf76..36096ca10 100644 --- a/ports/muos/README.md +++ b/ports/muos/README.md @@ -11,7 +11,8 @@ It runs in muxterm, muOS's terminal. With PortMaster installed, gptokeyb2 turns every button into a key and muxterm is told to leave the pad alone, so each button is sent once: D-pad to move, A or Start to choose, B or Select to go back, X to tick a system, Y for the options. L1 and R1 do nothing in -muxterm, which keeps Page Up and Page Down for its own scrollback. Hold +muxterm, which keeps Page Up and Page Down for its own scrollback: Left and +Right on the D-pad page a list instead. Hold Select, then press Start to quit. Without PortMaster, muxterm reads the pad itself (Y then ticks a system) and Menu closes it. diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 4759e0e87..3cb296e1c 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -444,13 +444,12 @@ class Flow(unittest.TestCase): self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system") press(state, "n") self.assertEqual(state.ticked, set()) - self.assertEqual(press(state, "enter"), []) - self.assertIn("No system is ticked", state.status) focus = next(i for i, row in enumerate(install.tui_rows(state)) if row.value == "nintendo-gba") state.focus["systems"] = focus - press(state, "right") + # Nothing ticked: Enter takes the system in focus alone. self.assertEqual(press(state, "enter"), [("run", "confirm")]) + self.assertEqual(state.ticked, {"nintendo-gba"}) self.assertEqual(state.screen, "confirm") self.assertIn("--system=nintendo-gba", install.tui_argv(state, "confirm", "py", "i.py")) self.assertIn("--dry-run", install.tui_argv(state, "confirm", "py", "i.py")) @@ -1127,19 +1126,20 @@ class PadRoutes(unittest.TestCase): self.assertIn(words, muxterm) self.assertNotIn("Start+Select", muxterm) # gptokeyb2 owns the pad inside muxterm, which keeps PgUp and PgDn - # for its scrollback: the gptokeyb buttons, and no page key. + # for its scrollback: the gptokeyb buttons, Left and Right to page. in_muxterm = help_text("gptokeyb_muxterm", "preview") for words in ("A, Start, Enter", "Select, then Start"): self.assertIn(words, in_muxterm) self.assertNotIn("L1/R1", in_muxterm) self.assertIn("L1/R1", help_text("gptokeyb", "preview")) # muxterm keeps PgUp and PgDn for its scrollback, and L1 and R1 - # send nothing: no paging is offered there, on a list or a pager. + # send nothing: the D-pad's Left and Right page there instead. for screen in ("systems", "result", "plan"): with self.subTest(screen=screen): text = help_text("muxterm", screen) - for words in ("L1", "R1", "PgUp", "PgDn", "Page"): + for words in ("L1", "R1", "PgUp", "PgDn"): self.assertNotIn(words, text) + self.assertIn("D-pad Left/Right Page", help_text("muxterm", "result")) miyoo = help_text("miyoo", "systems") for words in ("Start, Enter", "A, Space, Right/Left", "R2, Esc, Backspace", "L2, Tab, o", "Sends no key here"): @@ -2879,7 +2879,10 @@ class TheSetupFilterKnowsDevices(unittest.TestCase): self.assertIn("muOS", {row.value: row for row in self.rows("")}["retroarch"].right) def test_the_handheld_lists_are_the_guides(self): - import yaml + try: + import yaml + except ImportError: + self.skipTest("pyyaml is not installed") guides = yaml.safe_load((REPO_ROOT / "platforms" / "_guides.yml").read_text()) for system in guides["handheld_guide"]["systems"]: @@ -2889,6 +2892,126 @@ class TheSetupFilterKnowsDevices(unittest.TestCase): tuple(str(name) for name in system["devices"])) +class OneSystemInFewPresses(unittest.TestCase): + """tui_audit_4 P3-10.11: one system with a controller took 15 presses on + the fixture list, about 60 on muxterm, which has no page key.""" + + # The buttons each route sends, as the keys the interface reads. + PADS = { + "gptokeyb": {"A": "enter", "X": " ", "Start": "enter", "R1": "pgdn"}, + "muxterm": {"A": "enter", "X": " ", "Start": "enter", "R1": "right"}, + } + + def long_list(self, pad: str) -> install.TuiState: + state = checked_state() + state.pad = pad + state.cols, state.rows = 40, 15 + state.systems = [{"event": "system", "platform": "retroarch", "system": f"sys-{n:02d}", + "name": f"System {n:02d}", "files": 1, "present": 0, "missing": 1, + "wrong_hash": 0, "bytes_needed": 10} for n in range(66)] + state.ticked = {row["system"] for row in state.systems} + state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.action == "install") + return state + + def press_button(self, state, pad: str, button: str) -> list: + name = self.PADS[pad].get(button, button) + return install.tui_update(state, Key("char", name) if len(name) == 1 else Key(name)) + + def go_to(self, state, pad: str, target: str, paging: bool) -> int: + """Presses from the focus to the target system, as a person does.""" + count = 0 + while True: + rows = install.tui_rows(state) + focus = state.focus.get("systems", 0) + index = next(i for i, row in enumerate(rows) if row.value == target) + if index == focus: + return count + page = max(1, state.rows - 6) + button = "R1" if paging and index - focus >= page else "down" + self.press_button(state, pad, button) + count += 1 + + def presses(self, pad: str, fast: bool) -> int: + """From the plan to the run that installs sys-45 alone, with the + setup's A before it.""" + state = self.long_list(pad) + count = 1 + for button in ("down", "A"): + self.press_button(state, pad, button) + count += 1 + self.assertEqual(state.screen, "systems") + paging = pad != "muxterm" or fast + if fast: + self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "none") + self.press_button(state, pad, "A") + count += 1 + count += self.go_to(state, pad, "sys-45", paging) + effects = self.press_button(state, pad, "A") + count += 1 + else: + state.focus["systems"] = 3 + for button in ("up", "up", "A"): + self.press_button(state, pad, button) + count += 1 + count += self.go_to(state, pad, "sys-45", paging) + self.press_button(state, pad, "X") + effects = self.press_button(state, pad, "Start") + count += 2 + self.assertEqual(state.ticked, {"sys-45"}) + self.assertEqual(effects, [("run", "confirm")]) + # Install on the confirm screen, then Exit on the result. + return count + 2 + + def test_presses_before_and_after(self): + # The audit's path, A Down A Up Up A R1 x5 X Start A A. Before the + # fast path it took 17 presses here, 57 on muxterm (None kept the + # focus, and muxterm had no page key). + self.assertEqual(self.presses("gptokeyb", fast=False), 15) + self.assertEqual(self.presses("gptokeyb", fast=True), 12) + self.assertEqual(self.presses("muxterm", fast=False), 55) + self.assertEqual(self.presses("muxterm", fast=True), 12) + + def test_a_on_a_system_with_none_ticked_takes_it_alone(self): + state = self.long_list("keys") + state.stack.append("systems") + state.ticked = set() + state.focus["systems"] = 5 + text = install.tui_view(state, 80, 24)[-1] + self.assertIn("Enter Only this system", text) + focused = install.tui_rows(state)[5].value + self.assertEqual(press(state, "enter"), [("run", "confirm")]) + self.assertEqual(state.ticked, {focused}) + + def test_enter_still_continues_with_the_ticks_as_they_are(self): + state = self.long_list("keys") + state.stack.append("systems") + state.focus["systems"] = 5 + self.assertEqual(press(state, "enter"), [("run", "install")]) + self.assertEqual(len(state.ticked), 66) + + def test_left_and_right_page_inside_muxterm_only(self): + for pad in ("muxterm", "gptokeyb_muxterm", "gptokeyb"): + with self.subTest(pad=pad): + state = self.long_list(pad) + state.stack.append("systems") + state.focus["systems"] = 3 + press(state, "right") + paged = pad != "gptokeyb" + self.assertEqual(state.focus["systems"] > 4, paged) + self.assertEqual(len(state.ticked), 66) + state.help = True + help_text = " ".join(install.tui_view(state, 80, 24)) + self.assertEqual("D-pad Left/Right" in help_text, paged) + + def test_a_short_list_opens_on_its_first_system(self): + state = checked_state() + state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state)) + if row.action == "systems") + press(state, "enter") + self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system") + + class DataFilesHaveASize(unittest.TestCase): """tui_audit_4 P3-10.8: "8179 files to download, 6.6 GB, and 2636 data files", the data files with no size.""" @@ -3896,8 +4019,9 @@ class EndToEnd(unittest.TestCase): term.wait("> Install") term.send(DOWN, "\r") term.wait("Choose systems") - # D-pad and A only: up to None, A, down to the second system, Right. - term.send(UP, UP, "\r", DOWN, DOWN, DOWN, RIGHT) + # D-pad and A only: up to None, A (on the first system), down to the + # second system, Right. + term.send(UP, UP, "\r", DOWN, RIGHT) screen = term.wait("1 of 2 systems") self.assertIn("> [x] sony-playstation", screen) term.send("\r") diff --git a/wiki/installer.md b/wiki/installer.md index 59b5eae89..7f3e432d6 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -131,7 +131,8 @@ how many files are missing, how much they weigh, how many are in place and how much space is free, before anything is written. The other choices sit one press away. **Choose systems** ticks the consoles you -play, with the total to download under the list. **Options** sets the +play, with the total to download under the list. A long list opens on **None**: +A there unticks every system, and A on a system then installs that one alone. **Options** sets the preferred regions, game data, the installed cores only, the cores, the parallel downloads and the folder. Each row names the option it sets, and **Show the command line** gives the same choice as one command for a script. **Preview the @@ -1072,8 +1073,8 @@ Start pressed first also chooses the line in focus: hold Select first. A running install is then stopped the way B stops it, and the last screen says what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y ticks a system, Select shows its keyboard, and Menu quits. L1 and R1 do not -page there: muxterm keeps PgUp and PgDn for its own scrollback, so a list -moves with the D-pad. +page there: muxterm keeps PgUp and PgDn for its own scrollback, so Left and +Right on the D-pad page a list instead, as they do with gptokeyb in muxterm. Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed for 5 minutes since it opened, and no check or install running, the interface @@ -1087,8 +1088,8 @@ Each launcher tells the interface which buttons reach it, in | Value | Input | Set by | |-------|-------|--------| | `gptokeyb` | gptokeyb or gptokeyb2 with `retrobios.gptk`, the table above | the PortMaster port and the Tools entry | -| `gptokeyb_muxterm` | the same, inside muxterm, which keeps L1 and R1 for its scrollback: no button pages | the muOS app with PortMaster | -| `muxterm` | muxterm's own keys: D-pad, Start as Enter, B as Backspace, Y as Space | the muOS app without PortMaster | +| `gptokeyb_muxterm` | the same, inside muxterm, which keeps L1 and R1 for its scrollback: Left and Right page | the muOS app with PortMaster | +| `muxterm` | muxterm's own keys: D-pad (Left and Right page), Start as Enter, B as Backspace, Y as Space | the muOS app without PortMaster | | `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port | | `miyoo` | buttons that are already keys: A as Space, Start as Enter, L1 and R1 as E and T, L2 as Tab, R2 as Backspace | no launcher yet | From c64f3215eaa21c6d0da71082f5de5b29f422aa9b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:20:42 +0100 Subject: [PATCH 3/4] fix: say the misplaced copies stay in place --- install.ps1 | 2 +- install.py | 8 ++++++-- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install_tui.py | 17 ++++++++++++++++- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index 4e6878ef7..1d9db998d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697" + $defaultInstallSha256 = "6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 49fb58619..deabf7d73 100644 --- a/install.py +++ b/install.py @@ -12641,7 +12641,9 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: f"{'is' if len(misplaced) == 1 else 'are'} in the wrong place, " f"{'it' if len(misplaced) == 1 else 'they'} will be copied where the " "emulators read " - f"{'it' if len(misplaced) == 1 else 'them'}.") + f"{'it' if len(misplaced) == 1 else 'them'}, your " + f"{'copy' if len(misplaced) == 1 else 'copies'} left as " + f"{'it is' if len(misplaced) == 1 else 'they are'}.") if state.facts.get("writable") is False: if state.os_type == "windows": lines.append("Or run the installer as an administrator.") @@ -13063,7 +13065,9 @@ def _tui_sources(state: TuiState, installed: int, failed: int) -> str: name = _last_name(folder) folders[name] = folders.get(name, 0) + 1 copied = fixed + sum(folders.values()) - return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed) + line = sources_line(list(folders.items()), fixed, max(0, installed - copied), failed) + # A fix copies: the misplaced files are the user's, never moved. + return line + (" Your copies at the wrong place were left as they are." if fixed else "") def _unavailable(state: TuiState) -> tuple[list[dict], int]: diff --git a/install.sh b/install.sh index 724dc1704..7185777bf 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="b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697" +DEFAULT_INSTALL_SHA256="6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 36d6c3ec4..9b89a1245 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 624131, - "sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697", + "size": 624453, + "sha256": "6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 674834, - "sha256": "b7d51126f63aeff0d7ca790d887556619aeb566b7133fe66ef79f08a7f639929" + "size": 675156, + "sha256": "f3d238de327230cdde248efb9e4a2a24cf28724c82c2e73e84c3da75907f0651" }, "members": [ { @@ -85,8 +85,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 624131, - "sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697", + "size": 624453, + "sha256": "6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64", "executable": false }, { @@ -159,8 +159,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 624131, - "sha256": "b13b2ef42634a371e0fa8c55be5182e8fb04c09d7c456816e300150f507d8697", + "size": 624453, + "sha256": "6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 3cb296e1c..d9124d9c8 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2004,8 +2004,10 @@ class CopyFromAnOldCard(unittest.TestCase): self.recheck(state, [found("scph5500.bin", str(SYSTEM_DIR), "nested folder: bios/scph5500.bin")]) text = " ".join(line.strip() for line in install.tui_view(state, 120, 40)) + # tui_audit_4 P3-10.13: a fix copies, and the screen says the user's + # copy stays. self.assertIn("1 file is in the wrong place, it will be copied where the emulators " - "read it.", text) + "read it, your copy left as it is.", text) row = next(i for i, r in enumerate(install.tui_rows(state)) if r.action == "misplaced") state.focus["plan"] = row press(state, "enter") @@ -2015,6 +2017,19 @@ class CopyFromAnOldCard(unittest.TestCase): press(state, "esc") self.assertEqual(state.screen, "plan") + def test_the_result_says_the_misplaced_copies_stay(self): + state = checked_state() + state.stack.append("result") + state.code = 0 + state.summary = {"event": "summary", "mode": "install", "installed": 2, + "up_to_date": 0, "pending": 0, "failed": [], "skipped": [], + "left_out": [], "kept": [], "interrupted": False} + state.local_dests = {"scph5500.bin": (str(SYSTEM_DIR), "nested folder: bios/a")} + state.finished = [("scph5500.bin", True, ""), ("gba_bios.bin", True, "")] + text = " ".join(" ".join(install.tui_outcome(state)[1]).split()) + self.assertIn("1 file fixed from copies already in the BIOS folder, 1 downloaded. " + "Your copies at the wrong place were left as they are.", text) + def test_a_local_copy_is_no_download_speed(self): state = checked_state() press(state, "enter") From b4a17fefe1ca8c18f8b4dd30fb8489aa81eb4a63 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:51:50 +0100 Subject: [PATCH 4/4] fix: give the kept copies a line of their own --- install.ps1 | 2 +- install.py | 8 +++++--- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index 1d9db998d..ee641b523 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64" + $defaultInstallSha256 = "ebd49c716385fbbb440e10a324776a4a0b3803e2b1e8a6a32325e766891a719e" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index deabf7d73..46d0ae470 100644 --- a/install.py +++ b/install.py @@ -13065,9 +13065,7 @@ def _tui_sources(state: TuiState, installed: int, failed: int) -> str: name = _last_name(folder) folders[name] = folders.get(name, 0) + 1 copied = fixed + sum(folders.values()) - line = sources_line(list(folders.items()), fixed, max(0, installed - copied), failed) - # A fix copies: the misplaced files are the user's, never moved. - return line + (" Your copies at the wrong place were left as they are." if fixed else "") + return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed) def _unavailable(state: TuiState) -> tuple[list[dict], int]: @@ -13148,6 +13146,10 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: sources = _tui_sources(state, installed, len(failed)) if sources: lines.append(sources) + ok = {dest for dest, good, _why in state.finished if good} + if any(reason and dest in ok for dest, (_folder, reason) in state.local_dests.items()): + # A fix copies: the misplaced files are the user's, never moved. + lines.append("Your copies at the wrong place were left as they are.") lines.append(f"BIOS folder: {state.dest}") if installed + up_to_date: lines += _saves_lines(state, state.done_saves) diff --git a/install.sh b/install.sh index 7185777bf..07aff15cb 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="6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64" +DEFAULT_INSTALL_SHA256="ebd49c716385fbbb440e10a324776a4a0b3803e2b1e8a6a32325e766891a719e" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 9b89a1245..c231b5c89 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 624453, - "sha256": "6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64", + "size": 624596, + "sha256": "ebd49c716385fbbb440e10a324776a4a0b3803e2b1e8a6a32325e766891a719e", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 675156, - "sha256": "f3d238de327230cdde248efb9e4a2a24cf28724c82c2e73e84c3da75907f0651" + "size": 675299, + "sha256": "95b39b09ea326bdcdd8f3a9cde459c70dce230a3edccd2c73acf53949cd27c04" }, "members": [ { @@ -85,8 +85,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 624453, - "sha256": "6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64", + "size": 624596, + "sha256": "ebd49c716385fbbb440e10a324776a4a0b3803e2b1e8a6a32325e766891a719e", "executable": false }, { @@ -159,8 +159,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 624453, - "sha256": "6cf53f4fb49a96cbb6a13a0517a51440ea86f873fcc711158638db9b41217f64", + "size": 624596, + "sha256": "ebd49c716385fbbb440e10a324776a4a0b3803e2b1e8a6a32325e766891a719e", "executable": false }, {