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