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] 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