chore: merge main into mouse input

Pins and launcher index recomputed for the merged install.py. Mouse modes are now written before the frame, so a screen read back already takes clicks.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 19:29:42 +01:00
commit 6e30e86d42
7 files changed
+359 -121

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 = "811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486"
$defaultInstallSha256 = "d5e0946681e2056dfc90cd89c755e160ff54e0517f4a48095d8742bc6de387f5"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+199 -93
View File
@@ -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,
@@ -13924,32 +13996,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"))
@@ -16655,7 +16757,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.
@@ -16684,8 +16786,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.
@@ -17111,13 +17215,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
@@ -18329,8 +18434,9 @@ def run_tui(args: argparse.Namespace) -> int:
lines = tui_view(state, cols, rows)
# A click is read against the frame it was made on.
state.hits = tui_hits(state, lines)
terminal.draw(lines, cols)
# Before the frame: a frame on screen takes clicks as it shows.
terminal.set_mouse(tui_wants_mouse(state, lines))
terminal.draw(lines, cols)
for key in terminal.keys(TUI_FRAME):
if key.name == "ctrl-z":
# ISIG is off, so Ctrl-Z comes as a key: it stops what
+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="811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486"
DEFAULT_INSTALL_SHA256="d5e0946681e2056dfc90cd89c755e160ff54e0517f4a48095d8742bc6de387f5"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 843025,
"sha256": "811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486",
"size": 847810,
"sha256": "d5e0946681e2056dfc90cd89c755e160ff54e0517f4a48095d8742bc6de387f5",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 895574,
"sha256": "3fa9ebfa9e3c53a50d9ea9e0de4adc3b856312397802ebfda61c8d7883261c42"
"size": 900359,
"sha256": "ebd82036662a6daa59cfb92d639bb9c274ace947de63d8d56cf4dcc9aac225c4"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 843025,
"sha256": "811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486",
"size": 847810,
"sha256": "d5e0946681e2056dfc90cd89c755e160ff54e0517f4a48095d8742bc6de387f5",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 843025,
"sha256": "811453011fa28ebe70094aefd7affd59ee4b102da89f23313b93d00392bc0486",
"size": 847810,
"sha256": "d5e0946681e2056dfc90cd89c755e160ff54e0517f4a48095d8742bc6de387f5",
"executable": false
},
{
+24 -2
View File
@@ -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):
+106 -4
View File
@@ -5337,7 +5337,9 @@ class TheTerminalComesBack(unittest.TestCase):
self.repo()
term = self.start()
term.wait("Where do you play?")
# Every way out below starts with the clicks asked for.
# Every way out below starts with the clicks asked for, written
# right after the frame.
term.settle()
self.assertIn(install._MOUSE_ON.encode(), term.raw)
how(term)
return term
@@ -7475,8 +7477,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)
@@ -7495,6 +7504,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):
@@ -8462,13 +8563,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)
+20 -12
View File
@@ -323,17 +323,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
@@ -631,25 +636,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,