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

+1 -1
View File
@@ -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 }
+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]))
+1 -1
View File
@@ -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() {
+8 -8
View File
@@ -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
},
{
+71 -1
View File
@@ -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)
+5 -1
View File
@@ -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
+10 -1
View File
@@ -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.