diff --git a/install.ps1 b/install.ps1 index ea43715cf..f23e9d5c3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb" + $defaultInstallSha256 = "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 4178c897b..933ef0df5 100644 --- a/install.py +++ b/install.py @@ -769,6 +769,32 @@ 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)) + + +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 split_words(entries: list[dict]) -> 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: + return "" + return (f"{_plural(bios, 'BIOS file')} ({format_size(bios_bytes)}) and " + f"{_plural(games, 'game data file')} ({format_size(game_bytes)})") + + def keep_declared(files: list[dict], omitted: list[dict]) -> tuple[list[dict], list[dict]]: """The files and omissions the platform's own list declares (cores: null). @@ -5607,8 +5633,10 @@ def _report_outcome( hints: list[str] | None = None, sources: str = "", offline: int = 0, + kinds: list[int] | 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. 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 @@ -5749,6 +5777,8 @@ def _report_outcome( print(f"\nDone. {_plural(downloaded, 'file')} installed, " f"{_plural(up_to_date, 'file')} already up to date.") + if kinds and kinds[1]: + print(f" Installed: {kinds_words(*kinds)}.") if sources: print(f" {sources}") print(f"Location: {_locations(paths)}") @@ -7295,10 +7325,14 @@ def _prompt_custom_selection( bios_path: Path | None = None, choosable: bool = True, given: tuple[list[str], list[str], list[str]] | None = None, manifest: dict | None = None, narrowed: bool = False, - command: Callable[[object], str] | None = None, + command: Callable[[object], str] | None = None, cut: list[bool] | None = None, ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. + cut, given for a list that marks its game data, holds whether g left + it out: one key, as one row of the screen's plan, toggles it, and the + caller applies it as --no-game-data does. + Enter installs everything, the default most runs want. Returns None then, INSTALLED_ONLY for what `installed` (the files the cores found installed read) holds, or the three choices. Each empty list keeps @@ -7318,10 +7352,13 @@ def _prompt_custom_selection( """ trees = data_trees or [] current = given + toggled = False while True: - _print_menu_head(plat, files, omitted, installed, name, trees, bios_path, current, - given, manifest, narrowed) - if current is not given and command is not None: + shown, shown_omitted = ((leave_out_game_data(files, omitted)[:2]) + if cut and cut[0] else (files, omitted)) + _print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path, + current, object() if toggled else given, manifest, narrowed) + 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() if offline: @@ -7355,10 +7392,15 @@ 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] + print(f" g) {'keep' if cut[0] else 'leave out'} the " + f"{_plural(len(games), 'game data file')} ({format_size(_bytes(games))}), " + 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 [])] + *(["f"] if copying else []), *(["g"] if cut is not None else [])] answer = _ask("> ").lower() while answer not in keys: print(f" Type {', '.join(['Enter', *keys[2:]])} or q.") @@ -7367,7 +7409,10 @@ def _prompt_custom_selection( return current if answer == "q": _quit() - if answer == "i": + if answer == "g" and cut is not None: + cut[0] = not cut[0] + toggled = True + elif answer == "i": current = INSTALLED_ONLY elif answer == "c": current = _choose_narrowing(files, omitted, manifest, bios_path) @@ -7413,6 +7458,9 @@ def _print_menu_head( print(f"\n{name or display_name(plat)}: {_sized(shown)}" + (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "") + named + ".") + split = split_words(shown) if (manifest or {}).get("categorized") else "" + if split: + print(f" {split}.") if bios_path is None: return # Named before anything is written: line mode said where only after @@ -11656,6 +11704,8 @@ 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 BIOS folders read for misplaced files, one per platform. bios_indexes: list[FolderIndex] = [] @@ -11870,11 +11920,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: ) listed = len(manifest.get("files", [])) + len(variants if case_kept else []) + # g, the plan's game data row: offered where the list marks its + # game data and --no-game-data did not already leave it out. + cut = ([False] if manifest.get("categorized") and not args.no_game_data + and any(entry.get("category") == GAME_DATA for entry in files) else None) + def command_for(choice: object, plat: str = plat_name, folder: Path = bios_path) -> str: # The choice made at the questions, every other option of the # run kept: --dest, --from-pack and --manifest were dropped. - _INVOCATION.chosen[plat] = chosen_flags(choice) + _INVOCATION.chosen[plat] = ( + (chosen_flags(choice) if choice is not None else []) + + ([("--no-game-data", None)] if cut and cut[0] else [])) return _INVOCATION.command(plat, folder) selection = _prompt_custom_selection( @@ -11884,7 +11941,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: records_selection(files), given, manifest, bool(target or args.installed_cores or args.declared_only or len(files) + game_data_cut + len(unplaced) != listed), - command_for, + command_for, cut, ) if selection is None and LOCAL.pack is not None: smaller = " Nothing copied." @@ -11903,6 +11960,14 @@ 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) @@ -12171,6 +12236,13 @@ 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 files_pending += len(to_download) - progress.installed if interrupted \ else len(failed) total_errors += len(failed) @@ -12284,6 +12356,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, sources=_run_sources(bios_indexes, total_downloaded, total_errors + total_offline), offline=total_offline, @@ -13392,6 +13465,9 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and ( len(state.systems) > 1 or state.cli_systems): rows.append(TuiRow("systems", "Choose systems")) + game_data = _game_data_row(state) + if game_data is not None: + rows.append(game_data) rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")) if _tui_misplaced(state): rows.append(TuiRow("misplaced", "Show the files in the wrong place")) @@ -13400,6 +13476,38 @@ def _plan_rows(state: TuiState) -> list[TuiRow]: return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")] +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.""" + 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", [])} + + +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.""" + categories = _tui_categories(state) + if not categories: + return "" + return split_words([{"size": size, "category": categories.get(dest, "")} + for dest, size in dests]) + + +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: + return None + sized = f"{_plural(len(games), 'file')}, {format_size(_bytes(games))}" + return TuiRow("gamedata", f"Game data ({sized}): " + + ("left out, change" if state.no_game_data else "kept, leave out")) + + def _offline_only() -> bool: """--offline without a pack: what is not on this machine cannot come.""" return offline_requested() and LOCAL.pack is None @@ -14219,6 +14327,10 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]: return _open_from_browser(state) if action in ("misplaced", "unreadable"): return _push(state, action) + if action == "gamedata": + # One press, then counted again, as Back from Options counts. + state.no_game_data = not state.no_game_data + return _start_check(state) if action == "systems" and state.cli_systems: # The check read only what --system named: it is read again for # every system, those named ticked. Its exact plan is kept, to @@ -15956,6 +16068,13 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: f"{check['wrong_hash']} wrong ") lines = [line for line in lines if not str(line).startswith(whole)] lines[2:2] = choice + split = _tui_split(state, [(event["dest"], event["size"]) 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. + lines[1:1] = [f"{split or 'No game data'}. {GAME_DATA_WORDS[0].upper()}" + f"{GAME_DATA_WORDS[1:]}."] if state.cli_systems and state.systems: # Named on the command line: said, and widened by Choose systems. lines[2:2] = [f"For {len(state.systems)} of {len(tui_systems(state))} " @@ -16376,6 +16495,13 @@ 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 left: lines.append(_offline_remedy([_short_path(Path(folder), state.home) for folder in state.from_folders])) diff --git a/install.sh b/install.sh index cdfb8b88d..70d3ffa77 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="c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb" +DEFAULT_INSTALL_SHA256="af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index e0f0eee10..522bc70ab 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": 801988, - "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", + "size": 808521, + "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 854537, - "sha256": "1ed15a4a76d79f5dcd3f9f53173721e3666b159defc49adbcd6622cb252d21a8" + "size": 861070, + "sha256": "b06cd724e1a4d41a6bd5ae7da6049402d374d3265cd816594c89dbb7e1593ee3" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 801988, - "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", + "size": 808521, + "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 801988, - "sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb", + "size": 808521, + "sha256": "af62c61703cf1becc150b0244b6e13c731e062e9471a07c184693bb9cd46ece3", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 7779ce94a..6230eab55 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -6852,6 +6852,73 @@ class LineModeAndScreenDecideAlike(unittest.TestCase): self.assertEqual(ark.screen, "folder") self.assertEqual(install._tui_card_bound(ark)[0], "ArkOS") + # The owner's request of 2026-10-27: BIOS files and game data counted + # apart, the game data choice on the plan itself and in the menu. + + def game_list(self) -> tuple[Path, list]: + files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]), + self.entry("doom/doom1.wad", b"wad" * 10, ["doom"], category="game_data")] + return self.listing("retroarch", files, categorized=True), files + + def test_line_mode_counts_game_data_apart_and_g_leaves_it_out(self): + listing, _files = self.game_list() + args = dict(manifest=listing, dest=self.here, declared_only=False, dry_run=True) + text, asked = self.line_run(_args(**args), ["g", ""]) + self.assertIn("1 BIOS file (3 B) and 1 game data file (30 B).", text) + self.assertIn("g) leave out the 1 game data file (30 B), game data: files a game or " + "a game engine reads, of no use to the other cores", text) + after = text[text.index("Counted again"):] + self.assertIn("RetroArch: 1 file, 3 B.", after) + self.assertIn("g) keep the 1 game data file", after) + self.assertIn("--no-game-data", after) + self.assertIn("Left out as game data", after) + self.assertNotIn("doom1.wad would", after) + + def test_the_plan_counts_game_data_apart_and_one_press_leaves_it_out(self): + state = checked_state() + manifest = json.loads(json.dumps(state.manifest)) + for entry in manifest["files"]: + if entry["dest"] == "pcsx/extra.bin": + entry["category"] = "game_data" + state.manifest = manifest + text = " ".join(" ".join(install.tui_view(state, 80, 24)).split()) + self.assertIn("3 BIOS files (1.0 MB) and 1 game data file (4 B). Game data: files a " + "game or a game engine reads, of no use to the other cores.", text) + rows = install.tui_rows(state) + row = next(row for row in rows if row.action == "gamedata") + self.assertIn("kept, leave out", row.label) + state.focus["plan"] = rows.index(row) + effects = install.tui_update(state, Key("enter")) + self.assertTrue(state.no_game_data) + self.assertEqual(effects, [("run", "check")]) + self.assertIn(("--no-game-data", None), install.tui_flags(state)) + + def test_the_result_counts_game_data_apart_in_both_modes(self): + state = _installed_state(Path("/x/system")) + state.manifest = {**state.manifest, "files": [ + *state.manifest["files"], + {"dest": "doom/doom1.wad", "size": 30, "category": "game_data", "cores": None}]} + state.finished = [("scph5500.bin", True, ""), ("doom/doom1.wad", True, "")] + _title, lines = install.tui_outcome(state) + self.assertIn("Installed: 1 BIOS file and 1 game data file.", lines) + listing, _files = self.game_list() + backup = self.root / "old" + (backup / "doom").mkdir(parents=True) + (backup / "gba_bios.bin").write_bytes(b"gba") + (backup / "doom" / "doom1.wad").write_bytes(b"wad" * 10) + args = _args(manifest=listing, dest=self.here, offline=True, declared_only=False, + no_input=True) + setattr(args, "from", [backup]) + real = io.StringIO() + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + with mock.patch.object(install, "detect_os", return_value="linux"), \ + contextlib.redirect_stdout(io.StringIO()), \ + contextlib.redirect_stderr(io.StringIO()): + install._install(args, real) + self.assertIn("Done. 2 files installed", real.getvalue()) + self.assertIn("Installed: 1 BIOS file and 1 game data file.", real.getvalue()) + def test_line_mode_says_the_card_it_found_and_names_its_folder(self): onion = self.card("ONION", "Onion") with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \ @@ -7739,10 +7806,13 @@ 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: 8069 files, 6.5 GB."):] + menu = page[page.index("RetroArch: 8348 files, 7.1 GB."):] 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) env = page[page.index("## Environment overrides"):] self.assertIn("`TERMINFO`", env) self.assertIn("`TERMINFO_DIRS`", env) diff --git a/wiki/first-install.md b/wiki/first-install.md index 00df0a3af..7e19047fa 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -92,6 +92,9 @@ The next screen counts missing files and free space. Press Enter on **Install**. **Choose systems** keeps the consoles you play. +**Game data** is what a game reads, of no use to other cores. The count +shows it apart, and its row leaves it out. + **Old card?** In a card reader, choose **Copy the BIOS files from your old card or a folder**, then the card. @@ -166,7 +169,8 @@ You cannot write there. Do what the message says. ### The card is full -Nothing was written. Free some space, or choose another folder. +Nothing was written. Free some space on the card. On a computer, you can +also choose another folder. ### The game still does not start diff --git a/wiki/installer.md b/wiki/installer.md index b2f6c1fb3..abbdfdd32 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -607,15 +607,24 @@ the full screen cannot open (a terminal smaller than 40x12, `TERM=dumb`), the same choice comes as questions after detection: ``` -RetroArch: 8069 files, 6.5 GB. +RetroArch: 8348 files, 7.1 GB. + 3259 BIOS files (4.4 GB) and 5089 game data files (2.7 GB). BIOS folder: /home/me/.config/retroarch/system Enter) install everything i) only what the installed cores read: 100 files, 44.2 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 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 +(`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, +and Options keeps its **Game data** row too. + `i` appears for RetroArch, Lakka and RetroPie when cores are found installed, and the line above is a RetroArch with Beetle PSX HW, Snes9x, mGBA, Genesis Plus GX and FCEUmm.