feat: count game data apart from BIOS files

The installer knew BIOS files from game data and no count showed it.
The plan, the line menu and the result count them apart for a list that
marks game data, the plan gets a game data row and the menu a g key,
each toggling it in one press, and one sentence says what game data is.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 16:03:25 +01:00
1 parent 992bc8ce70
commit 0fb1047231
7 files changed
+230 -21

No files matched your search

+134 -8
View File
@@ -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]))