diff --git a/install.ps1 b/install.ps1 index 93b8437c6..e9438dcbf 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a" + $defaultInstallSha256 = "aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index df9bd3ba6..b70503eed 100644 --- a/install.py +++ b/install.py @@ -51,7 +51,7 @@ import weakref import xml.etree.ElementTree as ET import zipfile import zlib -from collections.abc import Callable, Collection, Iterator, Mapping +from collections.abc import Callable, Collection, Iterable, Iterator, Mapping from pathlib import Path, PurePosixPath from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO @@ -508,6 +508,8 @@ class Progress: # Files written to their place, which a stopped run counts: the # queue's length said "118 files installed" over 8 on disk. self.installed = 0 + # The entries of those files, which the end of the run counts apart. + self.landed: list[dict] = [] self.start = time.monotonic() self._drawn = 0.0 self._evented = self.start @@ -862,30 +864,60 @@ def leave_out_game_data( ) -def category_split(entries: list[dict]) -> tuple[int, int, int, int]: - """(BIOS files, their bytes, game data files, their bytes) of entries, - each with its size and the category the list gives it.""" - games = [entry for entry in entries if entry.get("category") == GAME_DATA] - bios = [entry for entry in entries if entry.get("category") != GAME_DATA] - return (len(bios), sum(int(entry.get("size") or 0) for entry in bios), - len(games), sum(int(entry.get("size") or 0) for entry in games)) +class Tally(NamedTuple): + """Files and bytes of one list, BIOS files and game data apart. bios + and games add up to files, their bytes to size: every count a run + prints of a list comes from here.""" + + files: int + size: int + bios: int + bios_size: int + games: int + game_size: int -def kinds_words(bios: int, games: int) -> str: - """BIOS files and game data files, counted apart.""" - return f"{_plural(bios, 'BIOS file')} and {_plural(games, 'game data file')}" +def tally(entries: Iterable[dict]) -> Tally: + """The counts of entries, each with its size and the category the list + gives it, anything not game data being a BIOS file. + + The run said 8069 files (6.5 GB) while its menu said 8348 (7.1 GB) and + 3259 BIOS files and 5089 game data files: three counts of three lists. + """ + bios = bios_size = games = game_size = 0 + for entry in entries: + size = int(entry.get("size") or 0) + if entry.get("category") == GAME_DATA: + games += 1 + game_size += size + else: + bios += 1 + bios_size += size + return Tally(bios + games, bios_size + game_size, bios, bios_size, games, game_size) -def split_words(entries: list[dict]) -> str: +def sized_words(counts: Tally) -> str: + """The count and the size, as in 8358 files, 6.6 GB.""" + return f"{_plural(counts.files, 'file')}, {format_size(counts.size)}" + + +def kinds_words(bios: int, games: int, data: int = 0) -> str: + """BIOS files and game data files, counted apart, and the files of the + data trees when some were installed.""" + parts = [_plural(bios, "BIOS file"), _plural(games, "game data file")] + if data: + parts.append(_plural(data, "data file")) + return f"{', '.join(parts[:-1])} and {parts[-1]}" + + +def split_words(counts: Tally) -> str: """BIOS files and game data apart, in words, "" when there is no game data: the installer tells them apart (--no-game-data), and no count - showed it. RetroArch's list is 3259 BIOS files (4.4 GB) and 5089 game - data files (2.7 GB).""" - bios, bios_bytes, games, game_bytes = category_split(entries) - if not games: + showed it. Both parts come from the count they divide.""" + if not counts.games: return "" - return (f"{_plural(bios, 'BIOS file')} ({format_size(bios_bytes)}) and " - f"{_plural(games, 'game data file')} ({format_size(game_bytes)})") + return (f"{_plural(counts.bios, 'BIOS file')} ({format_size(counts.bios_size)}) and " + f"{_plural(counts.games, 'game data file')} ({format_size(counts.game_size)})") def keep_declared(files: list[dict], omitted: list[dict]) -> tuple[list[dict], list[dict]]: @@ -932,13 +964,19 @@ def declared_copy(entry: dict) -> dict: def _left_out_lines(left_out: list[dict], every: bool) -> list[str]: """What --no-game-data left out, named per category, ten each unless every.""" lines = [] + # The omitted ones apart: "leave out the 5089 game data files" was + # followed by "Left out as game data: 5099 files", the other ten being + # files the list cannot install anyway. for group in _left_out_events(left_out): dests = group["dests"] shown = dests if every else dests[:10] more = (f" ... and {len(dests) - len(shown)} more (--verbose lists them all)" if len(shown) < len(dests) else "") + omitted = sum(1 for entry in left_out if entry.get("reason") + and entry.get("category", "") == group["category"]) + aside = f", {omitted} of them not installable" if omitted else "" lines.append(f" Left out as {group['label']} (--no-game-data): " - f"{_plural(len(dests), 'file')}: {', '.join(shown)}{more}") + f"{_plural(len(dests), 'file')}{aside}: {', '.join(shown)}{more}") return lines @@ -5737,12 +5775,13 @@ def _report_outcome( hints: list[str] | None = None, sources: str = "", offline: int = 0, - kinds: list[int] | None = None, + kinds: tuple[Tally, int] | None = None, same_choice: str = "", term: Terminal | None = None, ) -> None: """Close the run on what happened, and leave with a status that says so. - kinds is [BIOS files, game data files] installed, said apart. + kinds is the count of the files installed (tally) and of the data + tree files, given for a list marking its game data, said apart. A run that installed nothing used to end on the word Done and a zero exit, which reads as success to a person and to a script alike. offline counts @@ -5884,8 +5923,8 @@ def _report_outcome( say(f"\nDone. {_plural(downloaded, 'file')} installed, " f"{_plural(up_to_date, 'file')} already up to date.") - if kinds and kinds[1]: - say(f" Installed: {kinds_words(*kinds)}.") + if kinds and kinds[0].games: + say(f" Installed: {kinds_words(kinds[0].bios, kinds[0].games, kinds[1])}.") if sources: say(f" {sources}") say(f"Location: {_locations(paths)}") @@ -6074,15 +6113,15 @@ def _print_plan( total out of sight (599 lines for RetroArch): it gives way to the largest systems, and --verbose lists every file. """ - total = sum(f.get("size", 0) for f in to_download) + counts = tally(to_download) trees = [(entry, needed) for entry, needed in trees if needed] - tree_files = sum(len(needed) for _entry, needed in trees) - tree_size = sum(meta.get("size", 0) for _entry, needed in trees for meta in needed.values()) + tree_counts = tally(meta for _entry, needed in trees for meta in needed.values()) + tree_files = tree_counts.files parts = [] if to_download: - parts.append(f"{_plural(len(to_download), 'file')} ({format_size(total)})") + parts.append(f"{_plural(counts.files, 'file')} ({format_size(counts.size)})") if trees: - parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_size)}, " + parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_counts.size)}, " f"extracted from {_plural(len(trees), 'archive')})") # A pack is copied from, not downloaded: "Would download 23 files" was # said of a run that makes no network request. @@ -6255,10 +6294,11 @@ def download_files( print("\n Stopping. Files already installed are kept.") # The pool has run every future by now: a file that landed after Ctrl+C # is on disk and counts. - progress.installed = sum( - 1 for future in future_map + progress.landed = [ + entry for future, entry in future_map.items() if not future.cancelled() and future.exception() is None and future.result()[1] - ) + ] + progress.installed = len(progress.landed) progress.finish() progress.emit_transfer() return failed, interrupted @@ -7226,7 +7266,7 @@ def _quit() -> None: def _sized(files: list[dict]) -> str: - return f"{_plural(len(files), 'file')}, {format_size(sum(f.get('size', 0) for f in files))}" + return sized_words(tally(files)) # A core whose files are all the platform's own. "pcsx_rearmed 0 files, @@ -7490,10 +7530,12 @@ def _prompt_custom_selection( current = given toggled = False while True: + cutting = bool(cut and cut[0]) shown, shown_omitted = ((leave_out_game_data(files, omitted)[:2]) - if cut and cut[0] else (files, omitted)) + if cutting else (files, omitted)) _print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path, - current, object() if toggled else given, manifest, narrowed) + current, object() if toggled else given, manifest, narrowed, + menu_choice(files, omitted, installed, current, cutting)) if (current is not given or toggled) and command is not None: print(f" Same choice without questions: {screen_text(command(current))}") offline = _offline_only() @@ -7528,15 +7570,20 @@ def _prompt_custom_selection( copying = not dry_run if copying: print(" f) copy from a folder or an old card") - if cut is not None: - games = [entry for entry in files if entry.get("category") == GAME_DATA] + # The game data of the choice the head counts, which g takes out of + # it or puts back: counted over the whole list, it said 5089 files + # under a head counting 12 for one system. Not offered for a + # choice that holds none. + games = tally(menu_choice(files, omitted, installed, current, False)[0] + if cut is not None else []) + if cut is not None and games.games: print(f" g) {'keep' if cut[0] else 'leave out'} the " - f"{_plural(len(games), 'game data file')} ({format_size(_bytes(games))}), " + f"{_plural(games.games, 'game data file')} ({format_size(games.game_size)}), " f"{GAME_DATA_WORDS}") print(" q) quit") keys = ["", "q", *(["c"] if choosable else []), *(["i"] if installed is not None and current != INSTALLED_ONLY else []), - *(["f"] if copying else []), *(["g"] if cut is not None else [])] + *(["f"] if copying else []), *(["g"] if games.games else [])] answer = _ask("> ").lower() while answer not in keys: print(f" Type {', '.join(['Enter', *keys[2:]])} or q.") @@ -7545,7 +7592,7 @@ def _prompt_custom_selection( return current if answer == "q": _quit() - if answer == "g" and cut is not None: + if answer == "g" and cut is not None and games.games: cut[0] = not cut[0] toggled = True elif answer == "i": @@ -7560,15 +7607,13 @@ def _print_menu_head( plat: str, files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None, name: str, trees: list[dict], bios_path: Path | None, current: object, given: object, manifest: dict | None, - narrowed: bool, + narrowed: bool, chosen: tuple[list[dict], list[dict]] | None = None, ) -> None: """The plan above the line menu: what the choice holds, its folder, what - stands in its way and where the save files go, as the screen's plan.""" - shown, kept_omitted = files, omitted - if current == INSTALLED_ONLY and installed is not None: - shown, kept_omitted = installed - elif isinstance(current, tuple): - shown, kept_omitted = narrow(files, omitted, *current) + stands in its way and where the save files go, as the screen's plan. + chosen is what the choice keeps (menu_choice), the list every count of + the head is taken from.""" + shown, kept_omitted = chosen or menu_choice(files, omitted, installed, current, False) # The trees of the systems kept: 2636 data files of blueMSX, Dolphin # and PPSSPP were counted for every --system tried. kept_trees = select_data_directories({"data_directories": trees}, shown, @@ -7591,10 +7636,11 @@ def _print_menu_head( named = "" if named == ", " else named if current is not given: print("\n Counted again for your choices. Nothing is written before Enter.") - print(f"\n{name or display_name(plat)}: {_sized(shown)}" + counts = tally(shown) + print(f"\n{name or display_name(plat)}: {sized_words(counts)}" + (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "") + named + ".") - split = split_words(shown) if (manifest or {}).get("categorized") else "" + split = split_words(counts) if (manifest or {}).get("categorized") else "" if split: print(f" {split}.") if bios_path is None: @@ -7611,6 +7657,21 @@ def _print_menu_head( print(f" {line}") +def menu_choice( + files: list[dict], omitted: list[dict], + installed: tuple[list[dict], list[dict]] | None, current: object, cut: bool, +) -> tuple[list[dict], list[dict]]: + """The files and omissions a choice at the line menu keeps, in the order + the run applies it: the systems, cores and regions chosen, or what the + installed cores read (i), then the game data left out (g).""" + kept, kept_omitted = files, omitted + if current == INSTALLED_ONLY and installed is not None: + kept, kept_omitted = installed + elif isinstance(current, tuple): + kept, kept_omitted = narrow(files, omitted, *current) + return leave_out_game_data(kept, kept_omitted)[:2] if cut else (kept, kept_omitted) + + def narrowing_words(files: list[dict], omitted: list[dict]) -> str: """What c chooses among for these files, in words, "" for nothing: a question with one answer is not asked (tui_audit_11 P3-10).""" @@ -11881,8 +11942,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: failures: list[tuple[str, str]] = [] installed_paths: list[Path] = [] bulk_lines: list[str] = [] - # BIOS files and game data files installed, of a list marking them. - kinds_done = [0, 0] + # The entries installed and the data tree files: the end line counts + # BIOS files and game data among them, the data files apart. + landed: list[dict] = [] + data_landed = 0 + categorized = False # The BIOS folders read for misplaced files, one per platform. bios_indexes: list[FolderIndex] = [] @@ -11942,6 +12006,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: else: print(f"\nFetching file index for {plat_name}...") manifest = fetch_manifest(plat_name) + categorized = categorized or bool(manifest.get("categorized")) files = manifest.get("files", []) omitted_files = manifest.get("omitted_files", []) variants = manifest.get("case_variants", []) @@ -12089,6 +12154,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # What declining the size question points back at, when it was asked. smaller = "" given = selection + # g at the menu: whether it left the game data out. + cut: list[bool] | None = None menu = interactive and not args.check and not listing if menu: installed = ( @@ -12137,19 +12204,21 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: _INVOCATION.chosen[plat_name] = chosen_flags(selection) else: _INVOCATION.chosen.pop(plat_name, None) - if cut and cut[0]: - # Left out at g, as --no-game-data leaves it out. - files, omitted_files, left_out = leave_out_game_data(files, omitted_files) - left_out_seen.extend(left_out) - _INVOCATION.chosen[plat_name] = (_INVOCATION.chosen.get(plat_name, []) - + [("--no-game-data", None)]) - for line in _left_out_lines(left_out, args.verbose): - print(line) if selection is not None: before = len(files) files, omitted_files = narrow(files, omitted_files, *selection) unplaced = narrow([], unplaced, *selection)[1] print(f" Narrowed {before} to {len(files)} files") + if cut and cut[0]: + # Left out at g, as --no-game-data leaves it out, from what the + # choice kept: g offered the game data of the systems chosen, + # and the line below counted the whole list's. + files, omitted_files, left_out = leave_out_game_data(files, omitted_files) + left_out_seen.extend(left_out) + _INVOCATION.chosen[plat_name] = (_INVOCATION.chosen.get(plat_name, []) + + [("--no-game-data", None)]) + for line in _left_out_lines(left_out, args.verbose): + print(line) if unplaced: total_omitted += len(unplaced) omitted_seen.extend(unplaced) @@ -12159,8 +12228,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: _print_choices(plat_name, files, omitted_files, args.list_systems, manifest) continue - total_size = sum(f.get("size", 0) for f in files) - print(f" {len(files)} files ({format_size(total_size)})") + # The count every later line of the platform divides: the files + # checked, planned, offered and installed are these. + run_counts = tally(files) + print(f" {_plural(run_counts.files, 'file')} ({format_size(run_counts.size)})") + split = split_words(run_counts) if manifest.get("categorized") else "" + if split: + print(f" {split}.") if omitted_files: required_omitted = sum( 1 for entry in omitted_files if entry.get("required", True) @@ -12205,7 +12279,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) present = len(up_to_date) + len(mismatched) print( - f" {present}/{len(files)} present " + f" {present}/{run_counts.files} present " f"({len(up_to_date)} verified, {len(mismatched)} wrong hash)" ) @@ -12421,13 +12495,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) bulk_lines += hint total_downloaded += progress.installed - if not interrupted and manifest.get("categorized"): - # The end line tells BIOS files from game data, as the - # menu counted them. - gone = [str(dest) for dest, _reason in failed] - for entry in to_download: - if not any(dest.endswith(str(entry.get("dest", ""))) for dest in gone): - kinds_done[entry.get("category") == GAME_DATA] += 1 + # The end line tells BIOS files from game data, as the menu + # counted them, over the files that landed: matched by the end + # of a failed path, a.bin was dropped when x/a.bin failed. + landed.extend(progress.landed) files_pending += len(to_download) - progress.installed if interrupted \ else len(failed) total_errors += len(failed) @@ -12450,6 +12521,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for entry in data_dirs: counts = install_data_directory(entry, bios_path) total_downloaded += counts["installed"] + data_landed += counts["installed"] total_up_to_date += counts["present"] if isinstance(counts["error"], OfflineReason): # Left out as the BIOS files above are: counted missing, @@ -12541,7 +12613,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: omitted_entries=omitted_seen, platform_of={path: plat for plat, path in platforms}, hints=bulk_lines, - kinds=kinds_done, + kinds=((tally(landed), data_landed) if categorized else None), same_choice=(_INVOCATION.command(*platforms[0]) if interactive and len(platforms) == 1 else ""), sources=_run_sources(bios_indexes, total_downloaded, @@ -13821,32 +13893,62 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: def _tui_categories(state: TuiState) -> dict[str, str]: """Each destination's category, from the list the screen holds, for a - list that marks its game data.""" + list that marks its game data. The second spellings a folder that keeps + case installs are in it too: counted as nothing, the result's BIOS and + game data fell short of its total.""" manifest = state.manifest or {} if not manifest.get("categorized"): return {} return {str(entry.get("dest")): str(entry.get("category") or "") - for entry in manifest.get("files", [])} + for entry in [*manifest.get("files", []), *manifest.get("case_variants", [])]} -def _tui_split(state: TuiState, dests: list[tuple[str, int]]) -> str: - """BIOS files and game data apart, for (dest, size) pairs, "" when the - list marks none or holds no game data among them.""" +def _tui_tally(state: TuiState, events: Iterable[dict]) -> Tally: + """The count of file events (dest and size), each with the category the + list gives its destination: the one count the plan, its game data row + and the result divide.""" categories = _tui_categories(state) - if not categories: + return tally({"size": event["size"], "category": categories.get(event["dest"], "")} + for event in events) + + +def _tui_split(state: TuiState, events: Iterable[dict]) -> str: + """BIOS files and game data apart, for file events, "" when the list + marks none or holds no game data among them.""" + if not _tui_categories(state): return "" - return split_words([{"size": size, "category": categories.get(dest, "")} - for dest, size in dests]) + return split_words(_tui_tally(state, events)) + + +def _tui_estimating(state: TuiState) -> bool: + """Whether the plan counts the systems ticked since the check, not the + check itself.""" + return bool(_choice_line(state) and state.systems and state.check is not None + and not _offline_only()) + + +def _plan_files(state: TuiState) -> list[dict]: + """The file events the plan's headline counts: the systems ticked when + it estimates them, the check's own otherwise.""" + return _ticked_files(state) if _tui_estimating(state) else state.files def _game_data_row(state: TuiState) -> TuiRow | None: """The plan's game data row, one press to leave it out or keep it, for - a list that marks game data: inside Options alone, it went unseen.""" - games = [entry for entry in (state.manifest or {}).get("files", []) - if entry.get("category") == GAME_DATA] - if not _tui_categories(state) or not games: + a list that marks game data: inside Options alone, it went unseen. + + Counted over the files the plan counts, as g at the line menu: over + the whole list, it said 5089 files beside a plan of 12. Once left out + the check holds none, and the list's own game data is counted.""" + if not _tui_categories(state): return None - sized = f"{_plural(len(games), 'file')}, {format_size(_bytes(games))}" + games = _tui_tally(state, _plan_files(state)) + if not games.games: + games = tally(entry for entry in (state.manifest or {}).get("files", []) + if entry.get("category") == GAME_DATA) + if not games.games: + return None + sized = f"{_plural(games.games, 'file')}, {format_size(games.game_size)}" return TuiRow("gamedata", f"Game data ({sized}): " + ("left out, change" if state.no_game_data else "kept, leave out")) @@ -16434,7 +16536,7 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: check = state.check or {} lines = _plan_lines(check, state.dest, state) choice = _choice_line(state) - if choice and state.systems and check is state.check and not _offline_only(): + if _tui_estimating(state): # The headline counts the systems ticked, as the list just did: at # 40x12 the line below it was dropped, and the plan showed the # total of all of them. @@ -16463,8 +16565,10 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: lines[at:at] = [*([state.card_note] if state.card_note and state.card_note != state.status else []), *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice] - split = _tui_split(state, [(event["dest"], event["size"]) for event in state.files - if event["state"] != "present"]) + # Of the files the headline counts: those ticked when it estimates + # them, those to install otherwise. + split = _tui_split(state, _ticked_files(state) if _tui_estimating(state) else [ + event for event in state.files if event["state"] != "present"]) if split or (state.no_game_data and _game_data_row(state) is not None): # BIOS files and game data apart, and what game data is, beside the # row that leaves it out. @@ -16890,13 +16994,14 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: title = "Done" head = "Everything is in place." lines = [head] - categories = _tui_categories(state) - done = [categories.get(dest, "") for dest, good, _why in state.finished - if good and dest in categories] - if GAME_DATA in done: - # As the plan counted them, and as line mode ends. - games = done.count(GAME_DATA) - lines.append(f"Installed: {kinds_words(len(done) - games, games)}.") + if _tui_categories(state) and title != "Stopped": + # As the plan counted them, and as line mode ends: the data tree + # files are the installed ones no file event names. + done = _tui_tally(state, [{"dest": dest, "size": 0} + for dest, good, _why in state.finished if good]) + if done.games: + lines.append("Installed: " + kinds_words( + done.bios, done.games, max(0, installed - done.files)) + ".") sources = _tui_sources(state, installed, len(failed) + left) if left and installed and sources: # One count, then the remedy, as line mode says it: the count came diff --git a/install.sh b/install.sh index c38d90bc4..5cc625c38 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="7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a" +DEFAULT_INSTALL_SHA256="aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 6ad02257f..aa909ab97 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": 829229, - "sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a", + "size": 833938, + "sha256": "aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 881778, - "sha256": "7e5cc8505ce9c810888baf62260b7ac2ff1cfcbce2e13c44b99d15458969f38a" + "size": 886487, + "sha256": "2c0bfadad268416608e9ada4e5b77934ac997208bca9f6546a4827c165a131b8" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 829229, - "sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a", + "size": 833938, + "sha256": "aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 829229, - "sha256": "7998082004788f8d97177492ec371d8bdc507c5f132983ae05dc43be82d7e62a", + "size": 833938, + "sha256": "aba3edfbad94300545c951a5b7fd78ce7ce56c1640930c9291be69cbc5e34e02", "executable": false }, { diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index da7dfc7d6..e6bf0a222 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -98,8 +98,30 @@ class InstallerOutputExample(unittest.TestCase): self.assertIn(part.rsplit(":", 1)[0], install.OMISSION_REASONS.values()) count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", run).groups() self.assertIn(f"0/{count} present", run) - menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)\.", text) - self.assertEqual(menu.groups(), (count, size)) + self.assertSplitAddsUp(run, count, size) + menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)[.,]([^`]*)", text) + self.assertEqual(menu.groups()[:2], (count, size)) + self.assertSplitAddsUp(menu.group(3), count, size) + games = re.search(r"g\) leave out the (\d+) game data files \(([\d.]+ [KMG]B)\)", + menu.group(3)) + self.assertIsNotNone(games, "the menu offers to leave the game data out") + split = re.search(r"(\d+) BIOS files \(([\d.]+ [KMG]B)\) and (\d+) game data files " + r"\(([\d.]+ [KMG]B)\)", menu.group(3)) + self.assertEqual(games.groups(), split.groups()[2:]) + + def assertSplitAddsUp(self, text: str, count: str, size: str) -> None: + """The run counted 8069 files where its menu counted 3259 BIOS files + and 5089 game data files: the parts add up to the total they divide, + the sizes within the rounding of their three figures.""" + split = re.search(r"(\d+) BIOS files \(([\d.]+) ([KMG]B)\) and (\d+) game data " + r"files \(([\d.]+) ([KMG]B)\)", text) + self.assertIsNotNone(split, "a categorized list says its BIOS files and game data") + bios, bios_size, bios_unit, games, game_size, game_unit = split.groups() + self.assertEqual(int(bios) + int(games), int(count)) + units = {"KB": 1, "MB": 1024, "GB": 1024 ** 2} + total, unit = size.split() + added = (float(bios_size) * units[bios_unit] + float(game_size) * units[game_unit]) + self.assertLessEqual(abs(added - float(total) * units[unit]), 0.15 * units[unit]) class OneResolutionChain(unittest.TestCase): diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index caf6f68fb..158805065 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -7456,8 +7456,15 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, *state.manifest["files"], {"dest": "doom/doom1.wad", "size": 30, "category": "game_data", "cores": None}]} state.finished = [("scph5500.bin", True, ""), ("doom/doom1.wad", True, "")] + state.summary = {**state.summary, "installed": 2} _title, lines = install.tui_outcome(state) self.assertIn("Installed: 1 BIOS file and 1 game data file.", lines) + # The data tree files are installed without a file event: the parts + # still add up to the head's count. + state.summary = {**state.summary, "installed": 5} + _title, lines = install.tui_outcome(state) + self.assertIn("5 files installed.", lines[0]) + self.assertIn("Installed: 1 BIOS file, 1 game data file and 3 data files.", lines) listing, _files = self.game_list() backup = self.root / "old" (backup / "doom").mkdir(parents=True) @@ -7476,6 +7483,98 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertIn("Done. 2 files installed", real.getvalue()) self.assertIn("Installed: 1 BIOS file and 1 game data file.", real.getvalue()) + # 2026-10-27, CI run 40541886959: the run said 8069 files, the menu + # 8348 and 3259 BIOS files and 5089 game data files, the real dry run + # 8358. Every count of a run is one tally of one list. + + def counted_list(self) -> Path: + files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]), + self.entry("doom/doom1.wad", b"wad" * 10, ["doom"], category="game_data"), + self.entry("doom/doom2.wad", b"two" * 20, ["doom"], category="game_data")] + variant = {**self.entry("DOOM/DOOM1.WAD", b"WAD" * 10, ["doom"]), + "category": "game_data"} + omitted = [{"dest": "doom/doom3.wad", "name": "doom3.wad", "system": "doom", + "systems": ["doom"], "required": False, "reason": "not_found", + "category": "game_data"}] + return self.listing("retroarch", files, categorized=True, case_variants=[variant], + omitted_files=omitted) + + def test_every_count_of_a_line_run_is_one_list(self): + args = dict(manifest=self.counted_list(), dest=self.here, declared_only=False, + dry_run=True) + text, _asked = self.line_run(_args(**args), [""]) + # The second spelling a folder keeping case installs is counted + # everywhere or nowhere: here, everywhere. + self.assertIn("RetroArch: 4 files, 123 B.\n 1 BIOS file (3 B) and 3 game data files " + "(120 B).", text) + self.assertIn("g) leave out the 3 game data files (120 B)", text) + self.assertIn(" 4 files (123 B)\n 1 BIOS file (3 B) and 3 game data files (120 B).", + text) + self.assertIn("0/4 present", text) + self.assertIn("4 files (123 B)", text[text.index("Checking existing files"):]) + + def test_g_counts_the_systems_chosen_and_leaves_out_what_it_said(self): + args = dict(manifest=self.counted_list(), dest=self.here, declared_only=False, + dry_run=True) + text, _asked = self.line_run(_args(**args), ["c", "2", ""]) + after = text[text.index("Counted again"):] + self.assertIn("RetroArch: 1 file, 3 B, for 1 of 2 systems.", after) + # No game data among the systems chosen: nothing for g to leave out. + self.assertNotIn("g) leave out", after) + text, _asked = self.line_run(_args(**args), ["c", "1", "g", ""]) + after = text[text.index("Counted again"):] + self.assertIn("g) leave out the 3 game data files (120 B)", after) + last = after[after.rindex("Counted again"):] + self.assertIn("g) keep the 3 game data files (120 B)", last) + self.assertIn("Left out as game data (--no-game-data): 4 files, 1 of them not " + "installable", text) + + def test_the_parts_add_up_on_every_committed_list(self): + for path in sorted((REPO_ROOT / "install").glob("*.json")): + manifest = json.loads(path.read_text(encoding="utf-8")) + if not manifest.get("categorized"): + continue + with self.subTest(path.name): + for files in (manifest.get("files", []), + manifest.get("files", []) + manifest.get("case_variants", [])): + counts = install.tally(files) + self.assertEqual(counts.bios + counts.games, counts.files) + self.assertEqual(counts.files, len(files)) + self.assertEqual(counts.bios_size + counts.game_size, counts.size) + self.assertEqual(counts.size, sum(e.get("size") or 0 for e in files)) + out = io.StringIO() + with contextlib.redirect_stdout(out): + install._print_menu_head( + manifest["platform"], manifest["files"], [], None, "", [], None, + None, None, manifest, False) + head = re.search(r"(\d+) files?, ", out.getvalue()) + split = re.search(r"(\d+) BIOS files? \(.*?\) and (\d+) game data files?", + out.getvalue()) + if split: + self.assertEqual(int(split[1]) + int(split[2]), int(head[1])) + + def test_the_screen_divides_the_count_it_shows(self): + state = checked_state() + manifest = json.loads(json.dumps(state.manifest)) + manifest["case_variants"] = [{"dest": "PCSX/EXTRA.BIN", "size": 4, + "category": "game_data", "cores": ["pcsx_rearmed"], + "systems": ["sony-playstation"]}] + for entry in manifest["files"]: + if entry["dest"] == "pcsx/extra.bin": + entry["category"] = "game_data" + state.manifest = manifest + state.files = [*state.files, { + "event": "file", "platform": "retroarch", "dest": "PCSX/EXTRA.BIN", + "systems": ["sony-playstation"], "size": 4, "state": "missing"}] + state.check = {**state.check, "missing": state.check["missing"] + 1, + "files": state.check["files"] + 1, + "bytes_needed": state.check["bytes_needed"] + 4} + text = " ".join(" ".join(install.tui_view(state, 120, 40)).split()) + self.assertIn("5 files to download", text) + self.assertIn("3 BIOS files (1.0 MB) and 2 game data files (8 B).", text) + row = next(row for row in install.tui_rows(state) if row.action == "gamedata") + self.assertIn("Game data (2 files, 8 B)", row.label) + # code_audit_18 P3-5, tui_audit_11 P3-10 and P3-21: line mode wording. def test_one_row_says_type_1(self): @@ -8443,13 +8542,14 @@ class AuditSeventeenAndTen(unittest.TestCase): def test_the_docs_say_what_the_installer_does(self): """code_audit_17 P3-7.""" page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") - menu = page[page.index("RetroArch: 8348 files, 7.1 GB."):] + menu = page[re.search(r"RetroArch: \d+ files, [\d.]+ [KMG]B[.,]", page).start():] menu = menu[:menu.index("```")] self.assertIn("BIOS folder:", menu) self.assertIn("f) copy from a folder or an old card", menu) # The owner's request of 2026-10-27: game data counted apart, g. - self.assertIn("3259 BIOS files (4.4 GB) and 5089 game data files (2.7 GB).", menu) - self.assertIn("g) leave out the 5089 game data files", menu) + # test_doc_pages checks that the figures add up. + self.assertRegex(menu, r"\d+ BIOS files \([\d.]+ [KMG]B\) and \d+ game data files") + self.assertRegex(menu, r"g\) leave out the \d+ game data files") env = page[page.index("## Environment overrides"):] self.assertIn("`TERMINFO`", env) self.assertIn("`TERMINFO_DIRS`", env) diff --git a/wiki/installer.md b/wiki/installer.md index 59f70c1d4..9ac47b60b 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -307,17 +307,22 @@ names `~/Emulation/saves/retroarch/saves`. RetroPie, ROCKNIX, AmberELEC and EmuELEC keep saves beside each game, and RomM keeps them in the browser: their lists record these files as omitted. -A RetroArch run into an empty folder printed this on 2026-10-21: +A RetroArch dry run into an empty folder printed this on 2026-10-27: ``` Fetching file index for retroarch... - 8069 files (6.5 GB) - Not installable: 11 files (1 required, not found: 11). + 106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves + 8358 files (6.6 GB) + 3269 BIOS files (4.1 GB) and 5089 game data files (2.5 GB). + Not installable: 37 files (1 required, not found: 37). Checking existing files... - 0/8069 present (0 verified, 0 wrong hash) + 0/8358 present (0 verified, 0 wrong hash) ``` +The second line divides the first, and every later count of the run (the +menu, the plan, `--dry-run`, the result) is taken from the same list. + When the manifest leaves entries out (a file the collection cannot supply, or one that failed its hash check), a `Not installable:` line under the index count says how many and why. `--check` and the closing report also name the required @@ -615,25 +620,28 @@ By default the installer fetches everything the platform runs. At a terminal, the plan screen of the [full-screen interface](#the-full-screen-interface) offers **Install**, **Choose systems** and **Options**. With `--plain`, or where the full screen cannot open (a terminal smaller than 40x12, `TERM=dumb`), the -same choice comes as questions after detection: +same choice comes as questions after detection. A RetroArch run with `--plain` +printed this on 2026-10-27: ``` -RetroArch: 8348 files, 7.1 GB. - 3259 BIOS files (4.4 GB) and 5089 game data files (2.7 GB). +RetroArch: 8358 files, 6.6 GB, and 2636 data files (38.5 MB). + 3269 BIOS files (4.1 GB) and 5089 game data files (2.5 GB). BIOS folder: /home/me/.config/retroarch/system + 106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves Enter) install everything - i) only what the installed cores read: 100 files, 44.2 MB + i) only what the installed cores read: 87 files, 43.8 MB c) choose systems, cores and regions f) copy from a folder or an old card - g) leave out the 5089 game data files (2.7 GB), game data: files a game or a game engine reads, of no use to the other cores + g) leave out the 5089 game data files (2.5 GB), game data: files a game or a game engine reads, of no use to the other cores q) quit ``` A list that marks its game data counts it apart from the BIOS files, on the -plan, in this menu and on the result (`Installed: 3259 BIOS files and 5089 game -data files.`). `g` leaves it out, as `--no-game-data` does, and counts again +plan, in this menu and on the result (`Installed: 3269 BIOS files, 5089 game +data files and 2636 data files.`), the parts adding up to the total. `g` leaves +the game data of the choice out, as `--no-game-data` does, and counts again (`g` then keeps it). The plan screen has the same choice as a row of its own, -**Game data (5089 files, 2.7 GB): kept, leave out**, one press toggling it, +**Game data (5089 files, 2.5 GB): kept, leave out**, one press toggling it, and Options keeps its **Game data** row too. `i` appears for RetroArch, Lakka and RetroPie when cores are found installed,