chore: merge the last installer items

# Conflicts:
#	install.ps1
#	install.py
#	install.sh
#	install/launchers/index.json
This commit is contained in:
Abdessamad Derraz committed 2026-10-26 23:10:30 +01:00
commit dc225acb57
18 files changed
+770 -106

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 = "c63a84da36e10657838dc359f4258db75e098d4902ee27312296ccd22a6994f4"
$defaultInstallSha256 = "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+313 -64
View File
@@ -15,6 +15,7 @@ Usage:
from __future__ import annotations
import argparse
import bisect
import codecs
import concurrent.futures
import contextlib
@@ -543,6 +544,10 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# kind of its own, since schema 1 never adds a key to the file event.
"declared": ("platform", "files", "present", "missing", "wrong_hash",
"bytes_needed", "dests"),
# Right after declared: the files of the data trees to fetch (check's
# data_missing) and the bytes they write. A kind of its own, since
# schema 1 never adds a key to check.
"data": ("platform", "files", "bytes"),
# A file to install that this machine already holds: misplaced in the
# BIOS folder (folder is its bios_dir, reason says why in words) or in
# a folder named with --from (reason empty). member names the ZIP member
@@ -642,7 +647,7 @@ def _os_name() -> str:
def _emit_check(
plat: str, manifest: dict, bios_path: Path, files: list[dict], up_to_date: list[dict],
mismatched: list[dict], data_missing: int, not_installable: int,
chosen: list[str] | None = None,
chosen: list[str] | None = None, data_bytes: int = 0,
) -> None:
"""One event per file, per system and per platform after a check, then
one for the files of the platform's own list.
@@ -705,6 +710,7 @@ def _emit_check(
bytes_needed=sum(size for _d, size, state in own_states if state != "present"),
dests=[dest for dest, _s, _state in own_states],
)
EVENTS.emit("data", platform=plat, files=data_missing, bytes=data_bytes)
def _declared_state(copy: dict, entry: dict, bios_path: Path) -> str:
@@ -9549,6 +9555,74 @@ def _check_card_root(plat: str, dest: Path) -> None:
f"{display_name(plat)} file list was asked for.")
def card_bound(named: str, plat: str) -> tuple[str, str] | None:
"""(system name, BIOS folder below the card root) when what --platform
named runs from an SD card, else None.
`--platform onion` gets RetroArch's list, and on a computer that list's
default folder is the computer's own RetroArch: the files went there,
which Onion never reads. --platform retroarch names a list, not a card.
"""
if plat in CARD_PLATFORMS:
return display_name(plat), ""
key = _name_key(named)
rule = next((rule for rule in _card_rules()
if key and rule.manifest == plat and key == _name_key(rule.name)), None)
return (rule.name, rule.bios) if rule else None
def mounted_cards_of(system: str, plat: str) -> list[Path]:
"""The mounted cards whose own files say they run system."""
if plat in CARD_PLATFORMS:
return [mount for mount in _mounted_cards() if card_platform(mount) == plat]
return [mount for mount in _mounted_cards()
if any(rule.name == system for rule in _card_matches(mount))]
def card_install_dest(named: str, plat: str, interactive: bool) -> Path:
"""The folder for a card-bound system named without --dest, on a machine
that is not the device.
One mounted card that runs it is taken. At a terminal the card's root is
asked for, never this machine's own folder. Without questions the run
stops on the --dest that names the card.
"""
system, below = card_bound(named, plat) or (display_name(plat), "")
cards = mounted_cards_of(system, plat)
if len(cards) == 1:
dest = cards[0] / below if below else cards[0]
print(f" {system} card found at {cards[0]}: {display_name(plat)} files, in {dest}")
return dest
example = (f"--platform {named.strip() or plat} --dest /media/$USER/<card> "
"(Windows: --dest E:\\)")
if len(cards) > 1:
print(f" {len(cards)} {system} cards are mounted: "
+ ", ".join(str(card) for card in cards) + ".", file=sys.stderr)
if not interactive:
EVENTS.emit("error", stage="detect", reason="not_found", platform=plat,
message=f"{system} keeps its BIOS files on its SD card, and no "
"card was named with --dest")
print(f" {system} keeps its BIOS files on its SD card, not on this machine. "
f"Mount the card and name its root with --dest, for example: {example}",
file=sys.stderr)
sys.exit(1)
print(f" {system} keeps its BIOS files on its SD card, not on this machine. "
"Mount the card, then type its root folder.")
raw = ""
while not raw:
raw = _ask("Card root (Ctrl+C to stop): ")
root = _typed_folder(raw)
if plat in CARD_PLATFORMS:
_check_card_root(plat, root)
return root
folder = card_dest(named, plat, root)[1]
if folder == root and below and not _ends_with(root, below):
# A KNULLI share or a ROCKNIX card read as a list's name: its
# folder below the root, as the device reads it.
return root / below
return folder
def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str:
"""The file list for a --dest given without --platform.
@@ -9670,7 +9744,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
elif args.platform:
print("Detecting platform...")
detected = detect_platforms(os_type)
matched = [(n, p) for n, p in detected if n == args.platform]
bound = card_bound(named, args.platform)
# A system that runs from its card is found only on the device:
# Onion named on a PC matched the PC's own RetroArch.
matched = [(n, p) for n, p in detected if n == args.platform
and (bound is None or detected_os(n, p) == bound[0])]
if matched:
platforms = matched
elif listing:
@@ -9678,20 +9756,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# needed, and asking BizHawk's for one refused a question asked
# before downloading BizHawk.
platforms = [(args.platform, _default_dest(os_type, args.platform) or Path.cwd())]
elif args.platform in CARD_PLATFORMS:
elif bound is not None:
# Its files go on its SD card, which is never this machine's
# own folder: the card's mount point is the user's to name.
EVENTS.emit("error", stage="detect", reason="not_found", platform=args.platform,
message=f"{display_name(args.platform)} keeps its BIOS files on "
"its SD card, and no card was named with --dest")
print(
f" {display_name(args.platform)} keeps its BIOS files on its SD card. "
f"Mount the card and name its root with --dest, for example: "
f"--platform {args.platform} --dest /media/$USER/<card> "
f"(Windows: --dest E:\\)",
file=sys.stderr,
)
sys.exit(1)
# own folder.
platforms = [(args.platform, card_install_dest(named, args.platform,
interactive))]
elif args.platform in PORTABLE_ONLY:
# A portable emulator reads the folder beside its executable:
# any default would be a folder it never opens.
@@ -10163,6 +10232,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
plat_name, manifest, bios_path, files, up_to_date, mismatched,
sum(len(state[1]) for _entry, state in data_states), len(omitted_files),
chosen=selection[0] if selection else None,
data_bytes=sum(meta.get("size", 0) for _entry, state in data_states
for meta in state[1].values()),
)
emit_copies(plat_name, to_download, copies, bios_index, bios_path, files)
emit_unreadable(plat_name, unread)
@@ -10740,6 +10811,11 @@ class TuiState:
# "chosen" (the run's folder, from Change folder) or "closed" (the
# one chosen could not be opened, none is offered).
self.folder_kind: str = "usual"
# (system, BIOS folder below the card root) when the setup asked for
# runs from an SD card: the folder screen offers the mounted cards.
self.card_bound: tuple[str, str] | None = None
# (width and text, lines) of the last pager wrapped.
self.pager_cache: tuple | None = None
self.path_text: str = ""
self.browse_dir: Path | None = None
self.browse_rows: list = []
@@ -11141,13 +11217,20 @@ def tui_rows(state: TuiState) -> list[TuiRow]:
if screen == "platforms":
needle = state.filters.get(screen, "")
systems = _systems_reading()
return [_filter_row(state)] + [
TuiRow("platform", display_name(plat), ", ".join(systems.get(plat, [])), plat)
for plat in AVAILABLE_PLATFORMS
if _matches(" ".join([plat, display_name(plat), *systems.get(plat, [])]), needle)
]
rows = [_filter_row(state)]
for plat in AVAILABLE_PLATFORMS:
named = _setup_matches(plat, systems.get(plat, []), needle)
if named is not None:
# The systems the filter named, those a device typed runs:
# "miyoo" on RetroArch read "muOS, JELOS, KOLOS...".
rows.append(TuiRow("platform", display_name(plat),
", ".join(named or systems.get(plat, [])), plat))
return rows
if screen == "folder":
rows = [TuiRow("default", "Use this folder")] if state.folder_default else []
if _tui_card_bound(state):
rows += [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount)
for mount in _mounted_cards()]
return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")]
if screen == "browse":
return list(state.browse_rows)
@@ -11529,6 +11612,19 @@ def _mounted_cards() -> list[Path]:
return found
def _tui_card_bound(state: TuiState) -> tuple[str, str] | None:
"""The card the chosen setup runs from, when it runs from one."""
if state.card_bound:
return state.card_bound
return card_bound(state.platform, state.platform) if state.platform in CARD_PLATFORMS else None
def _card_what(mount: Path) -> str:
"""What a mounted card is, as its own files say."""
system = card_system(mount)
return f"{system[2]} card" if system else "card or drive"
def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow], str]:
"""A directory's rows for the browser, cards first, and what went wrong.
With zips, the ZIP files too: a backup is often one archive."""
@@ -11539,9 +11635,7 @@ def _browse_rows(folder: Path | None, zips: bool = False) -> tuple[list[TuiRow],
rows = [TuiRow("use", "Use this folder"), TuiRow("up", "..", value=folder.parent)]
for card in _mounted_cards():
if card != folder:
system = card_system(card)
what = f"{system[2]} card" if system else "card or drive"
rows.append(TuiRow("dir", f"{card} ({what})", value=card))
rows.append(TuiRow("dir", f"{card} ({_card_what(card)})", value=card))
try:
entries = list(os.scandir(folder))
except OSError as exc:
@@ -11651,7 +11745,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
if state.dest_given is not None:
return _choose(state, row.value, state.dest_given)
state.platform = row.value
if row.value in CARD_PLATFORMS:
state.card_bound = card_bound(row.value, row.value)
if state.card_bound:
return _open_folder_screen(state, None)
# The folder detected here before the built-in one: the built-in
# default was offered as where RetroArch reads, when the
@@ -11663,6 +11758,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
if screen == "folder":
if action == "default":
return _choose(state, state.platform, state.folder_default)
if action == "card":
below = (_tui_card_bound(state) or ("", ""))[1]
return _choose(state, state.platform, row.value / below if below else row.value)
if action == "browse":
state.browse_for = "dest"
_push(state, "browse")
@@ -11719,6 +11817,9 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
return _open_from_browser(state)
if action in ("misplaced", "unreadable"):
return _push(state, action)
if action == "systems" and len(state.systems) > TUI_LONG_LIST:
# On None: picking one system of 66 began with Up, Up, A.
return _push(state, action, 1)
# On the first system, so A continues and the D-pad ticks.
return _push(state, action, 3 if action == "systems" else 0)
if screen == "systems":
@@ -11726,7 +11827,14 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
state.ticked = {row["system"] for row in state.systems}
elif action == "none":
state.ticked = set()
# On the first system: the next A takes the one in focus alone.
state.focus["systems"] = next(
(i for i, item in enumerate(tui_rows(state)) if item.action == "system"),
state.focus.get("systems", 0))
else:
if action == "system" and not state.ticked:
# Nothing ticked: A takes the system in focus, and only it.
state.ticked = {row.value}
return _begin_install(state)
return []
if screen == "options":
@@ -11770,6 +11878,94 @@ def _systems_reading() -> dict[str, list[str]]:
return names
# The devices people type in the setup filter instead of the setup they run,
# beside the setups a first-party page says each one runs. Keys are platform
# ids or the system names of _EMBEDDED_RULES, and a device that runs several
# setups is under each. The handheld systems' lists are those of
# platforms/_guides.yml (handheld_guide, each with its devices_source), and a
# test holds the two equal.
_SETUP_DEVICES: dict[str, tuple[str, ...]] = {
"ArkOS": ("RG353M", "RG353V", "RG503", "RGB30", "RK2023", "RG351MP", "RG351V", "R35S",
"RGB10"),
"muOS": ("RG35XX H", "RG35XX Plus", "RG35XX SP", "RG35XX 2024", "RG34XX H", "RG40XX H",
"RG40XX V", "RG28XX H", "RGCUBEXX", "TrimUI Brick", "TrimUI Smart Pro"),
"Onion": ("Miyoo Mini", "Miyoo Mini+"),
"spruceOS": ("Miyoo A30", "Miyoo Flip", "Miyoo Mini", "Miyoo Mini Plus",
"Miyoo Mini Flip", "TrimUI Smart Pro", "TrimUI Brick",
"Anbernic RG35XX Plus", "RG40XX H", "Powkiddy RGB30"),
"Allium": ("Miyoo Mini", "Miyoo Mini Plus", "Miyoo Mini Flip"),
"ArchR": ("R36S", "R33S", "R36S Soysauce", "Odroid Go Advance", "Odroid Go Super",
"RG351V", "RG351M", "GameForce Chi", "MagicX XU10", "Powkiddy RGB10",
"Powkiddy RGB10X", "K36"),
"CrossMix-OS": ("TrimUI Smart Pro",),
"dArkOS": ("Odroid Go Advance", "RGB10", "RG351MP", "G350"),
"KOLOS": ("RG353P", "RG353M", "RG353V", "RG353VS", "RG503", "Powkiddy RK2023", "RGB30",
"RGB10 Max 3"),
"Koriki": ("Miyoo Mini",),
"MiyooCFW": ("BittBoy", "Pocket Go", "PowKiddy Q90", "PowKiddy V90", "PowKiddy Q20 Mini",
"Sup M3", "XYC Q8"),
"plumOS": ("MagicX XU20 V32",),
"RetroOZ": ("OGS", "OGA", "RGB10Max", "RGB10Max2"),
"UnofficialOS": ("R35S", "R36S", "RG351M", "RG351P", "RG351V", "RG353M", "RG353P",
"RG353V", "RG503", "RG552", "RGB30", "RGB20 Pro", "Odroid Go Advance",
"Odroid Go Super"),
# The Android tile of the handheld group (_guides.yml): goretroid.com
# "OS: Android 13" for the Retroid Pocket 5, ayntec.com "Android 15" for
# the AYN Odin 3. The guide runs RetroArch on Steam Deck (run: steamdeck)
# and on Windows, which the ROG Ally runs (rog.asus.com spec,
# "Windows 11 Home").
"retroarch": ("Steam Deck", "ROG Ally", "Android", "Retroid Pocket", "Retroid Pocket 5",
"AYN Odin", "AYN Odin 3"),
"emudeck": ("Steam Deck", "ROG Ally"),
"retrodeck": ("Steam Deck",),
"retrobat": ("ROG Ally",),
"bizhawk": ("ROG Ally",),
# _guides.yml runs_on, each cited there.
"batocera": ("Steam Deck",),
"amberelec": ("Anbernic RG351", "RG552"),
"emuelec": ("Odroid Go",),
"minui": ("Anbernic", "Miyoo", "TrimUI"),
"nextui": ("TrimUI Brick", "TrimUI Smart Pro"),
"myminui": ("Anbernic", "Miyoo"),
# rocknix.org/devices, read 2026-10-26.
"rocknix": ("Anbernic RG351", "RG353", "RG503", "RG552", "RG 55G1", "RG ARC",
"RG35XX 2024", "RG35XX Plus", "RG35XX Pro", "RG35XX H", "RG35XX SP", "RG28XX",
"RG40XX V", "RG40XX H", "RG CubeXX", "RG34XX", "AYANEO Pocket", "Ayn Odin 2",
"Ayn Odin 3", "Ayn Thor", "GameForce Ace", "Odroid Go Advance",
"Odroid Go Super", "Odroid Go Ultra", "MagicX XU Mini M", "Mangmi Air X",
"Powkiddy RGB10", "RGB10X", "RK2023", "RGB20SX", "RGB20 Pro", "RGB30", "X35S",
"X35H", "X55", "XU10", "Retroid Pocket 5", "Retroid Pocket 6",
"Retroid Pocket Flip2", "Retroid Pocket Mini", "Retroid Pocket Nova", "R33S",
"R35S", "R36S", "K36"),
# knulli.org, read 2026-10-26.
"knulli": ("Anbernic RG-Arc-S", "RG28XX", "RG34XX", "RG35XX Original", "RG35XX Plus",
"RG35XX H", "RG35XX 2024", "RG35XX SP", "RG40XX H", "RG40XX V", "RGCubeXX",
"Retroid Pocket 5", "Retroid Pocket Flip 2", "Retroid Pocket Mini",
"Miyoo Flip", "Orange Pi Zero 2w", "Powkiddy RGB30", "X55", "TrimUI Brick",
"TrimUI Smart Pro", "BattleXP G350", "GKD Pixel 2"),
}
def _device_needle(needle: str) -> str:
"""The filter text with a model's short form spelled out: owners write
RP5 for the Retroid Pocket 5, as the store names it."""
short = re.fullmatch(r"\s*rp\s*(\d+)\s*", needle, re.IGNORECASE)
return f"Retroid Pocket {short.group(1)}" if short else needle
def _setup_matches(plat: str, systems: list[str], needle: str) -> list[str] | None:
"""What the filter names for plat: its devices and systems that match,
[] when plat's own name does, None when nothing does. A device counts
for each setup it runs."""
if not needle or _matches(f"{plat} {display_name(plat)}", needle):
return []
needle = _device_needle(needle)
devices = [device for device in _SETUP_DEVICES.get(plat, ()) if _matches(device, needle)]
named = [system for system in systems
if _matches(" ".join([system, *_SETUP_DEVICES.get(system, ())]), needle)]
return devices + named or None
def _platform_focus(state: TuiState, plat: str) -> int:
"""The platform list's row for plat, below its filter row."""
return 1 + AVAILABLE_PLATFORMS.index(plat) if plat in AVAILABLE_PLATFORMS else 1
@@ -11982,18 +12178,11 @@ def _pager_key(state: TuiState, key: TuiKey) -> list[tuple]:
def _pager_last(state: TuiState, items: list) -> int:
"""The first item shown when the end of a pager is in view, found from
the end: only the last screenful is wrapped."""
width = min(state.cols, TUI_MAX_WIDTH) - 2
room = max(1, state.rows - 4)
used, index = 0, len(items)
while index > 0:
size = len(_text_lines([items[index - 1]], width))
if used + size > room:
break
used += size
index -= 1
return index
"""The first line shown when the end of a pager is in view: the view's
own height (tui_view), less the line that counts."""
total = _pager_starts(state, items, min(state.cols, TUI_MAX_WIDTH) - 2)[-1]
height = state.rows - 3
return 0 if total <= height else max(0, total - max(1, height - 1))
def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
@@ -12016,6 +12205,12 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return _filter_key(state, key)
if state.pad == "miyoo":
key = _miyoo_key(state, key)
if (state.pad in _NO_PAGE_PADS and key.name in ("left", "right")
and screen not in ("regions", "options")):
# muxterm keeps PgUp and PgDn for its scrollback: the D-pad's Left
# and Right page instead (X or Y still ticks). One system of 66 took
# about 60 Down presses.
key = TuiKey("pgup" if key.name == "left" else "pgdn")
char = key.text if key.name == "char" else ""
if key.name == "f1" or char == "?":
state.help = True
@@ -12099,6 +12294,10 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return []
# The routes inside muxterm, where no button sends PgUp or PgDn.
_NO_PAGE_PADS = ("muxterm", "gptokeyb_muxterm")
# A Miyoo's L1 and R1 are the keys E and T (codes 18 and 20).
_MIYOO_PAGES = {"e": "pgup", "t": "pgdn"}
@@ -12163,6 +12362,10 @@ def _take_event(state: TuiState, event: dict) -> None:
state.done_saves = event
elif kind == "found" and state.run == "install":
state.local_dests[event["dest"]] = (event["folder"], event["reason"])
elif kind == "data" and state.run == "check" and state.check is not None:
state.check["data_bytes"] = event["bytes"]
elif kind == "data" and state.run in ("confirm", "preview") and state.dry is not None:
state.dry["data_bytes"] = event["bytes"]
elif state.run == "check":
if kind == "check":
state.check = event
@@ -12388,6 +12591,9 @@ def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> l
# The rows a screen keeps for its list under pinned lines.
TUI_PINNED_ROWS = 4
# A list longer than this opens its systems on None, and its help names a
# page key.
TUI_LONG_LIST = 9
def _compose(
@@ -12433,24 +12639,46 @@ def _compose(
def _pager(
state: TuiState, width: int, height: int, items: list, lead: int = 0,
) -> list[TuiLine]:
"""The items in view, from the first shown on, each wrapped alone: the
8,000 lines of a full preview were wrapped again at every key."""
first = max(0, min(state.focus.get(state.screen, 0), _pager_last(state, items)))
if len(items) <= height and first == 0:
lines = _text_lines(items, width)
if len(lines) <= height:
return lines
"""The lines in view, scrolled line by line, the last one saying which
lines these are. The lead items (a count above a list) are not counted.
It scrolled by item and counted items: at 40x12 the result read
"1-2 of 5" over eight lines, its last paragraph cut, and a paragraph
longer than the screen could never be read to its end. Only the items
in view are wrapped: the 8,000 lines of a full preview were wrapped
again at every key.
"""
starts = _pager_starts(state, items, width)
total = starts[-1]
if total <= height:
return _text_lines(items, width)
room = max(1, height - 1)
first = max(0, min(state.focus.get(state.screen, 0), total - room))
index = bisect.bisect_right(starts, first) - 1
offset = first - starts[index]
lines: list[TuiLine] = []
index = first
while index < len(items) and len(lines) < room:
while index < len(items) and starts[index] < first + room:
lines += _text_lines([items[index]], width)
index += 1
whole = index if len(lines) <= room else index - 1
lines = lines[:room]
start = max(1, first + 1 - lead)
return lines + [_styled(f"{start}-{max(start, whole - lead)} of {len(items) - lead}",
"dim")]
skip = starts[lead] if lead else 0
start = max(1, first + 1 - skip)
end = max(start, first + room - skip)
return lines[offset:offset + room] + [_styled(f"{start}-{end} of {total - skip}", "dim")]
def _pager_starts(state: TuiState, items: list, width: int) -> list[int]:
"""The line each item starts on once wrapped at width, and the total
last, kept while the items stay the same. An item that fits is one line
without being wrapped."""
key = (width, [(str(item), getattr(item, "style", "")) for item in items])
if state.pager_cache is not None and state.pager_cache[0] == key:
return state.pager_cache[1]
starts = [0]
for item in items:
fits = getattr(item, "style", "") == "path" or cell_width(str(item)) <= width
starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width))))
state.pager_cache = (key, starts)
return starts
def _tui_misplaced(state: TuiState) -> list[dict]:
@@ -12521,11 +12749,15 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
headline = f"{_plural(fetch, 'file')} to download, {format_size(fetch_bytes)}"
elif need:
headline = f"{_plural(len(copied), 'file')} to copy from this machine"
# The data trees' size comes from their own event: "2636 data files"
# beside "6.6 GB" said nothing of what they weigh.
data_size = (f" ({format_size(check['data_bytes'])})" if data and "data_bytes" in check
else "")
if need:
if data:
headline += f", and {_plural(data, 'data file')}"
headline += f", and {_plural(data, 'data file')}{data_size}"
if not need:
headline = (f"{_plural(data, 'data file')} to fetch" if data
headline = (f"{_plural(data, 'data file')} to fetch{data_size}" if data
else "Everything is in place")
wrong = check["wrong_hash"]
offline_lines = (["Copy them from your old card or a folder, or run the installer "
@@ -12570,7 +12802,9 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
f"{'is' if len(misplaced) == 1 else 'are'} in the wrong place, "
f"{'it' if len(misplaced) == 1 else 'they'} will be copied where the "
"emulators read "
f"{'it' if len(misplaced) == 1 else 'them'}.")
f"{'it' if len(misplaced) == 1 else 'them'}, your "
f"{'copy' if len(misplaced) == 1 else 'copies'} left as "
f"{'it is' if len(misplaced) == 1 else 'they are'}.")
if state.facts.get("writable") is False:
if state.os_type == "windows":
lines.append("Or run the installer as an administrator.")
@@ -12654,10 +12888,13 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple:
def _view_folder(state: TuiState, width: int, height: int) -> tuple:
name = display_name(state.platform)
if state.platform in CARD_PLATFORMS:
bound = _tui_card_bound(state)
if bound:
title = "Where is the SD card?"
before = [f"{name} keeps its BIOS files on its SD card. Choose the card's "
"root folder, the one holding .system."]
before = [f"{bound[0]} keeps its BIOS files on its SD card. Choose the card's "
"root folder"
+ (", the one holding .system." if state.platform in CARD_PLATFORMS
else ".")]
elif state.folder_default is not None:
title = "Which folder?"
lead = {"found": f"{name} reads its BIOS files from:",
@@ -13119,6 +13356,10 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
sources = _tui_sources(state, installed, len(failed))
if sources:
lines.append(sources)
ok = {dest for dest, good, _why in state.finished if good}
if any(reason and dest in ok for dest, (_folder, reason) in state.local_dests.items()):
# A fix copies: the misplaced files are the user's, never moved.
lines.append("Your copies at the wrong place were left as they are.")
lines.append(f"BIOS folder: {state.dest}")
if installed + up_to_date:
lines += _saves_lines(state, state.done_saves)
@@ -13241,9 +13482,9 @@ _HELP_KEYS: dict[str, dict[str, str | None]] = {
"back": "B, Select, Esc", "stop": "B, Esc, Ctrl-C", "kill": None,
"enter": "A, Enter", "esc": "B, Esc"},
# muxterm keeps PgUp and PgDn for its own scrollback, and its L1 and R1
# send nothing: no key pages a list there.
"muxterm": {"move": "D-pad, Up/Down", "page": None, "ok": "Start, Enter",
"tick": "Y, Space, Right/Left", "options": "o, Tab",
# send nothing: the D-pad's Left and Right page a list there.
"muxterm": {"move": "D-pad, Up/Down", "page": "D-pad Left/Right", "ok": "Start, Enter",
"tick": "Y, Space", "options": "o, Tab",
"back": "B, Esc, Backspace", "stop": "B, Esc, Ctrl-C", "kill": None,
"enter": "Start, Enter", "esc": "Esc"},
"miyoo": {"move": "D-pad, Up/Down", "page": "L1/R1, PgUp/PgDn", "ok": "A, Start, Enter",
@@ -13252,8 +13493,9 @@ _HELP_KEYS: dict[str, dict[str, str | None]] = {
"enter": "Start, Enter", "esc": "Esc"},
}
# muxterm keeps PgUp and PgDn for its scrollback even when gptokeyb2 owns the
# pad, and the launcher sends nothing on L1 and R1 there.
_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": None}
# pad, and the launcher sends nothing on L1 and R1 there: Left and Right page.
_HELP_KEYS["gptokeyb_muxterm"] = {**_HELP_KEYS["gptokeyb"], "page": "D-pad Left/Right",
"tick": "X, Space"}
def _route_extras(state: TuiState) -> list[tuple[str, str]]:
@@ -13280,7 +13522,7 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]:
("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"),
*_route_extras(state)]
keys = [(names["move"], "Move")]
if len(tui_rows(state)) > 9 or screen in _PAGERS:
if len(tui_rows(state)) > TUI_LONG_LIST or screen in _PAGERS:
keys += [*([(names["page"], "Page")] if names["page"] else []),
("Home/End, g/G", "First, last")]
primary = _primary(state) or _TUI_VIEWS[screen](state, 80, 24)[2][0]
@@ -13319,6 +13561,8 @@ def _primary(state: TuiState) -> str | None:
return None
row = rows[max(0, min(state.focus.get(state.screen, 0), len(rows) - 1))]
verb = _ROW_VERBS.get(row.action)
if state.screen == "systems" and row.action == "system" and not state.ticked:
verb = "Only this system"
return f"A/Enter {verb}" if verb else None
@@ -13465,7 +13709,11 @@ def tui_start(
state.stack = []
return state, _choose(state, plat, Path(args.dest))
if plat:
matched = [setup for setup in state.setups if setup[0] == plat]
bound = card_bound(args.platform, plat)
# A system that runs from its card is its card or the device, never
# this machine's own folder for the same list.
matched = [setup for setup in state.setups if setup[0] == plat
and (bound is None or detected_os(setup[0], setup[1]) == bound[0])]
if len(matched) == 1:
state.stack = []
return state, _choose(state, plat, matched[0][1])
@@ -13473,8 +13721,9 @@ def tui_start(
state.setups = matched
return state, []
state.platform = plat
state.card_bound = bound
state.stack = []
default = None if plat in CARD_PLATFORMS else _default_dest(os_type, plat)
default = None if bound else _default_dest(os_type, plat)
return state, _open_folder_screen(state, default)
if args.dest is not None:
card = card_platform(Path(args.dest))
+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="c63a84da36e10657838dc359f4258db75e098d4902ee27312296ccd22a6994f4"
DEFAULT_INSTALL_SHA256="000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+10 -10
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 622744,
"sha256": "c63a84da36e10657838dc359f4258db75e098d4902ee27312296ccd22a6994f4",
"size": 636292,
"sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06",
"executable": false
},
{
@@ -71,15 +71,15 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 675244,
"sha256": "447a2eb9863dd1dad2108d22132bbb8d553adca5ebf1b2bddfbce698df42cf3f"
"size": 688841,
"sha256": "65c0ff1debcc384c1a6da526e4c92505a1628036cc4c7f51928d5253fbd07484"
},
"members": [
{
"name": "RetroBIOS/README.md",
"repo_path": "ports/muos/README.md",
"size": 1127,
"sha256": "c0cfafad5fae8e6764b263b14a06195b267f66c4fe042333941ae6bcc6dab265",
"size": 1176,
"sha256": "26b5da84f22ce3c652f16ed8335be11d99e3bfb8657eec7caa100cf505f68497",
"executable": false
},
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 622744,
"sha256": "c63a84da36e10657838dc359f4258db75e098d4902ee27312296ccd22a6994f4",
"size": 636292,
"sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 622744,
"sha256": "c63a84da36e10657838dc359f4258db75e098d4902ee27312296ccd22a6994f4",
"size": 636292,
"sha256": "000b7e9e965f6d81c254d7ee7967077f9bbae8aca2286179f31d4f4f7b555d06",
"executable": false
},
{
+2 -1
View File
@@ -11,7 +11,8 @@ It runs in muxterm, muOS's terminal. With PortMaster installed, gptokeyb2
turns every button into a key and muxterm is told to leave the pad alone, so
each button is sent once: D-pad to move, A or Start to choose, B or Select to
go back, X to tick a system, Y for the options. L1 and R1 do nothing in
muxterm, which keeps Page Up and Page Down for its own scrollback. Hold
muxterm, which keeps Page Up and Page Down for its own scrollback: Left and
Right on the D-pad page a list instead. Hold
Select, then press Start to quit. Without PortMaster, muxterm reads the pad
itself (Y then ticks a system) and Menu closes it.
+30
View File
@@ -20,6 +20,7 @@
"checking",
"check",
"declared",
"data",
"found",
"unreadable",
"saves",
@@ -64,6 +65,9 @@
{
"$ref": "#/$defs/declared"
},
{
"$ref": "#/$defs/data"
},
{
"$ref": "#/$defs/found"
},
@@ -454,6 +458,32 @@
}
}
},
"data": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"platform",
"files",
"bytes"
],
"properties": {
"event": {
"const": "data"
},
"platform": {
"type": "string"
},
"files": {
"type": "integer",
"minimum": 0
},
"bytes": {
"type": "integer",
"minimum": 0
}
}
},
"found": {
"type": "object",
"additionalProperties": false,
+1 -1
View File
@@ -8,5 +8,5 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-4 of 8
1-8 of 17
A/Enter Exit Up/Down Scroll
+1 -1
View File
@@ -11,5 +11,5 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-5 of 8
1-11 of 17
A/Enter Exit Up/Down Scroll
+2 -2
View File
@@ -981,7 +981,7 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-4 of 8
1-8 of 17
A Exit Up/Down Scroll
=== result 40x15
RetroBIOS: Finished with errors
@@ -997,7 +997,7 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-5 of 8
1-11 of 17
A Exit Up/Down Scroll
=== result 80x24
RetroBIOS: Finished with errors
+2 -2
View File
@@ -981,7 +981,7 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-4 of 8
1-8 of 17
A Exit Up/Down Scroll
=== result 40x15
RetroBIOS: Finished with errors
@@ -997,7 +997,7 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-5 of 8
1-11 of 17
A Exit Up/Down Scroll
=== result 80x24
RetroBIOS: Finished with errors
+2 -2
View File
@@ -981,7 +981,7 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-4 of 8
1-8 of 17
A Exit Up/Down Scroll
=== result 40x15
RetroBIOS: Finished with errors
@@ -997,7 +997,7 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-5 of 8
1-11 of 17
A Exit Up/Down Scroll
=== result 80x24
RetroBIOS: Finished with errors
+2 -2
View File
@@ -927,7 +927,7 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-4 of 8
1-8 of 17
Enter Exit Up/Down Scroll
=== result 40x15
RetroBIOS: Finished with errors
@@ -943,7 +943,7 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-5 of 8
1-11 of 17
Enter Exit Up/Down Scroll
=== result 80x24
RetroBIOS: Finished with errors
+2 -2
View File
@@ -981,7 +981,7 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-4 of 8
1-8 of 17
A Exit Up/Down Scroll
=== result 40x15
RetroBIOS: Finished with errors
@@ -997,7 +997,7 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-5 of 8
1-11 of 17
A Exit Up/Down Scroll
=== result 80x24
RetroBIOS: Finished with errors
+2 -2
View File
@@ -981,7 +981,7 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-4 of 8
1-8 of 17
Start Exit Up/Down Scroll
=== result 40x15
RetroBIOS: Finished with errors
@@ -997,7 +997,7 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-5 of 8
1-11 of 17
Start Exit Up/Down Scroll
=== result 80x24
RetroBIOS: Finished with errors
+2
View File
@@ -5167,6 +5167,8 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
{"event": "declared", "platform": "retroarch", "files": 4, "present": 1,
"missing": 2, "wrong_hash": 1, "bytes_needed": 245,
"dests": ["a.bin", "b.bin", "c.bin", "d.bin"]},
# No data tree in this list.
{"event": "data", "platform": "retroarch", "files": 0, "bytes": 0},
]
SKIPPED = [{"reason": "not_found", "label": "not found", "files": 1, "required": 1}]
+23
View File
@@ -473,6 +473,29 @@ class FromPackInstallsOffline(Scratch):
for rel, data in tree.items():
self.assertEqual((self.dest / "dolphin-emu" / rel).read_bytes(), data)
def test_the_data_trees_say_what_they_weigh(self):
"""tui_audit_4 P3-10.8: the plan read "and 2636 data files" with no
size. The data event carries the bytes check's data_missing counts."""
tree = {"Sys/GC/font.bin": b"font bytes", "Sys/Wii/setting.txt": b"setting"}
listed = manifest()
listed["data_directories"] = [{
"ref": "dolphin-sys", "dest": "dolphin-emu", "type": "zip",
"url": "https://example.invalid/dolphin.zip", "systems": ["nintendo-gba"],
"files": {rel: {"sha1": _sha1(data), "size": len(data)}
for rel, data in tree.items()},
}]
self.list.write_text(json.dumps(listed))
self.place("dolphin-emu/Sys/GC/font.bin", tree["Sys/GC/font.bin"])
proc = self.run_installer("--check", "--events", "--manifest", str(self.list),
"--from-pack", str(self.pack))
events = [json.loads(line) for line in proc.stdout.splitlines()]
kinds = [event["event"] for event in events]
self.assertEqual(kinds.index("data"), kinds.index("declared") + 1)
data = events[kinds.index("data")]
self.assertEqual(data, {"event": "data", "platform": "retroarch", "files": 1,
"bytes": len(tree["Sys/Wii/setting.txt"])})
self.assertEqual(events[kinds.index("check")]["data_missing"], 1)
def test_a_cut_download_is_named(self):
self.pack.write_bytes(self.pack.read_bytes()[:100])
proc = self.run_installer("--from-pack", str(self.pack))
+366 -9
View File
@@ -444,13 +444,12 @@ class Flow(unittest.TestCase):
self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system")
press(state, "n")
self.assertEqual(state.ticked, set())
self.assertEqual(press(state, "enter"), [])
self.assertIn("No system is ticked", state.status)
focus = next(i for i, row in enumerate(install.tui_rows(state))
if row.value == "nintendo-gba")
state.focus["systems"] = focus
press(state, "right")
# Nothing ticked: Enter takes the system in focus alone.
self.assertEqual(press(state, "enter"), [("run", "confirm")])
self.assertEqual(state.ticked, {"nintendo-gba"})
self.assertEqual(state.screen, "confirm")
self.assertIn("--system=nintendo-gba", install.tui_argv(state, "confirm", "py", "i.py"))
self.assertIn("--dry-run", install.tui_argv(state, "confirm", "py", "i.py"))
@@ -946,6 +945,12 @@ def golden_states() -> dict:
class Views(unittest.TestCase):
"""Every screen as text, at the sizes a handheld and a desktop show."""
def setUp(self):
# The card screen lists the cards mounted here: none, on every machine.
patcher = mock.patch.object(install, "_mounted_cards", return_value=[])
patcher.start()
self.addCleanup(patcher.stop)
def test_goldens(self):
update = os.environ.get("RETROBIOS_UPDATE_GOLDENS") == "1"
GOLDEN.mkdir(parents=True, exist_ok=True)
@@ -1121,19 +1126,20 @@ class PadRoutes(unittest.TestCase):
self.assertIn(words, muxterm)
self.assertNotIn("Start+Select", muxterm)
# gptokeyb2 owns the pad inside muxterm, which keeps PgUp and PgDn
# for its scrollback: the gptokeyb buttons, and no page key.
# for its scrollback: the gptokeyb buttons, Left and Right to page.
in_muxterm = help_text("gptokeyb_muxterm", "preview")
for words in ("A, Start, Enter", "Select, then Start"):
self.assertIn(words, in_muxterm)
self.assertNotIn("L1/R1", in_muxterm)
self.assertIn("L1/R1", help_text("gptokeyb", "preview"))
# muxterm keeps PgUp and PgDn for its scrollback, and L1 and R1
# send nothing: no paging is offered there, on a list or a pager.
# send nothing: the D-pad's Left and Right page there instead.
for screen in ("systems", "result", "plan"):
with self.subTest(screen=screen):
text = help_text("muxterm", screen)
for words in ("L1", "R1", "PgUp", "PgDn", "Page"):
for words in ("L1", "R1", "PgUp", "PgDn"):
self.assertNotIn(words, text)
self.assertIn("D-pad Left/Right Page", help_text("muxterm", "result"))
miyoo = help_text("miyoo", "systems")
for words in ("Start, Enter", "A, Space, Right/Left", "R2, Esc, Backspace",
"L2, Tab, o", "Sends no key here"):
@@ -1998,8 +2004,10 @@ class CopyFromAnOldCard(unittest.TestCase):
self.recheck(state, [found("scph5500.bin", str(SYSTEM_DIR),
"nested folder: bios/scph5500.bin")])
text = " ".join(line.strip() for line in install.tui_view(state, 120, 40))
# tui_audit_4 P3-10.13: a fix copies, and the screen says the user's
# copy stays.
self.assertIn("1 file is in the wrong place, it will be copied where the emulators "
"read it.", text)
"read it, your copy left as it is.", text)
row = next(i for i, r in enumerate(install.tui_rows(state)) if r.action == "misplaced")
state.focus["plan"] = row
press(state, "enter")
@@ -2009,6 +2017,19 @@ class CopyFromAnOldCard(unittest.TestCase):
press(state, "esc")
self.assertEqual(state.screen, "plan")
def test_the_result_says_the_misplaced_copies_stay(self):
state = checked_state()
state.stack.append("result")
state.code = 0
state.summary = {"event": "summary", "mode": "install", "installed": 2,
"up_to_date": 0, "pending": 0, "failed": [], "skipped": [],
"left_out": [], "kept": [], "interrupted": False}
state.local_dests = {"scph5500.bin": (str(SYSTEM_DIR), "nested folder: bios/a")}
state.finished = [("scph5500.bin", True, ""), ("gba_bios.bin", True, "")]
text = " ".join(" ".join(install.tui_outcome(state)[1]).split())
self.assertIn("1 file fixed from copies already in the BIOS folder, 1 downloaded. "
"Your copies at the wrong place were left as they are.", text)
def test_a_local_copy_is_no_download_speed(self):
state = checked_state()
press(state, "enter")
@@ -2764,6 +2785,341 @@ class TheSaveFolderHasARow(unittest.TestCase):
self.assertNotIn("savedir", [row.action for row in install._option_rows(checked_state())])
class ACardSystemNamedOnAComputer(unittest.TestCase):
"""`--platform onion` on a PC wrote RetroArch's files into the PC's own
~/.config/retroarch/system, which Onion never reads: the system's list
is RetroArch's, and so was the folder detection found for it."""
ONION = {".tmp_update/onionVersion/version.txt": "4.3.1\n"}
def card(self, files: dict) -> Path:
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, root, True)
for name, text in files.items():
(root / name).parent.mkdir(parents=True, exist_ok=True)
(root / name).write_text(text, encoding="utf-8")
return root
def test_the_systems_that_run_from_a_card(self):
self.assertEqual(install.card_bound("onion", "retroarch"), ("Onion", "BIOS"))
self.assertEqual(install.card_bound("muOS", "retroarch"), ("muOS", "MUOS/bios"))
self.assertEqual(install.card_bound("spruceos", "retroarch"), ("spruceOS", "BIOS"))
self.assertEqual(install.card_bound("knulli", "knulli"), ("KNULLI", "bios"))
self.assertEqual(install.card_bound("nextui", "nextui"), ("NextUI", ""))
self.assertIsNone(install.card_bound("retroarch", "retroarch"))
self.assertIsNone(install.card_bound("batocera", "batocera"))
def test_without_questions_the_run_names_the_card_and_writes_nothing(self):
home = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, home, True)
system = home / ".config" / "retroarch" / "system"
system.mkdir(parents=True)
(home / ".config" / "retroarch" / "retroarch.cfg").write_text(
f'system_directory = "{system}"\n', encoding="utf-8")
with FakeRepository() as repo:
proc = install_run("--platform", "onion", "--yes",
env={**repo.env(), "HOME": str(home)})
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn("Onion keeps its BIOS files on its SD card, not on this machine.",
proc.stderr)
self.assertIn("--platform onion --dest /media/$USER/<card>", proc.stderr)
self.assertEqual(list(system.iterdir()), [])
def test_one_mounted_card_of_that_system_is_taken(self):
onion, other = self.card(self.ONION), self.card({"roms/a": ""})
with mock.patch.object(install, "_mounted_cards", return_value=[other, onion]), \
contextlib.redirect_stdout(io.StringIO()) as out:
dest = install.card_install_dest("onion", "retroarch", interactive=False)
self.assertEqual(dest, onion / "BIOS")
self.assertIn(f"Onion card found at {onion}", out.getvalue())
def test_at_a_terminal_the_card_root_is_asked(self):
onion = self.card(self.ONION)
with mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_ask", return_value=str(onion)) as ask, \
contextlib.redirect_stdout(io.StringIO()) as out:
dest = install.card_install_dest("onion", "retroarch", interactive=True)
self.assertEqual(dest, onion / "BIOS")
self.assertIn("Mount the card, then type its root folder.", out.getvalue())
ask.assert_called_once()
knulli = self.card({"roms/a": ""})
with mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_ask", return_value=str(knulli)), \
contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(install.card_install_dest("knulli", "knulli", True),
knulli / "bios")
def test_the_interface_opens_the_card_screen_with_the_cards_first(self):
blank = self.card({"roms/a": ""})
(blank / "BIOS").mkdir()
found = [("retroarch", SYSTEM_DIR)]
with mock.patch.object(install, "_mounted_cards", return_value=[blank]), \
mock.patch.object(install, "detect_platforms", return_value=found), \
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(platform="onion"), "linux")
self.assertEqual((state.screen, effects), ("folder", []))
rows = install.tui_rows(state)
text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
effects = install.tui_update(state, Key("enter"))
self.assertEqual([row.action for row in rows], ["card", "browse", "type"])
self.assertIn("Where is the SD card?", text)
self.assertIn("Onion keeps its BIOS files on its SD card", text)
self.assertEqual(effects, [("fetch", "retroarch")])
self.assertEqual(state.dest, blank / "BIOS")
def test_the_interface_takes_the_one_onion_card_mounted(self):
onion = self.card(self.ONION)
(onion / "BIOS").mkdir()
found = [("retroarch", SYSTEM_DIR)]
with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \
mock.patch.object(install, "detect_platforms", return_value=found), \
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(platform="onion"), "linux")
self.assertEqual(effects, [("fetch", "retroarch")])
self.assertEqual(state.dest, onion / "BIOS")
def test_on_the_device_its_own_folder_is_used(self):
device = Path("/mnt/SDCARD/BIOS")
with mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "detect_platforms",
return_value=[("retroarch", device)]), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.dict(install._DETECTED_OS, {("retroarch", device): "Onion"}), \
mock.patch.object(install.Path, "exists", return_value=True):
state, effects = install.tui_start(_args(platform="onion"), "linux")
self.assertEqual(effects, [("fetch", "retroarch")])
self.assertEqual(state.dest, device)
class TheSetupFilterKnowsDevices(unittest.TestCase):
"""tui_audit_4 P3-10.5: the filter found nothing for steam deck, r36s,
trimui or retroid, and miyoo found RetroArch with muOS, JELOS... beside it."""
TYPED = {
"steam deck": {"retroarch", "batocera", "emudeck", "retrodeck"},
"r36s": {"retroarch", "rocknix"},
"rg35xx": {"retroarch", "rocknix", "knulli"},
"trimui": {"retroarch", "knulli", "minui", "nextui"},
"retroid": {"retroarch", "rocknix", "knulli"},
"miyoo": {"retroarch", "knulli", "minui", "myminui"},
"anbernic": {"retroarch", "rocknix", "amberelec", "knulli", "minui", "myminui"},
"odin": {"retroarch", "rocknix"},
"ayn": {"retroarch", "rocknix"},
"rp5": {"retroarch", "rocknix", "knulli"},
"rog ally": {"retroarch", "emudeck", "retrobat", "bizhawk"},
"onion": {"retroarch"},
}
def rows(self, needle: str) -> list:
state = fresh_state()
state.stack = ["platforms"]
state.filters["platforms"] = needle
return [row for row in install.tui_rows(state) if row.action == "platform"]
def test_each_device_finds_every_setup_it_runs(self):
for needle, setups in self.TYPED.items():
with self.subTest(needle=needle):
self.assertEqual({row.value for row in self.rows(needle)}, setups)
def test_the_right_column_names_what_matched(self):
retroarch = {row.value: row for row in self.rows("miyoo")}["retroarch"]
self.assertEqual(retroarch.right.split(", "),
["spruceOS", "Onion", "Allium", "Koriki", "MiyooCFW"])
deck = {row.value: row for row in self.rows("steam deck")}
self.assertEqual(deck["retroarch"].right, "Steam Deck")
self.assertIn("muOS", {row.value: row for row in self.rows("")}["retroarch"].right)
def test_the_handheld_lists_are_the_guides(self):
try:
import yaml
except ImportError:
self.skipTest("pyyaml is not installed")
guides = yaml.safe_load((REPO_ROOT / "platforms" / "_guides.yml").read_text())
for system in guides["handheld_guide"]["systems"]:
if system.get("devices"):
with self.subTest(system=system["name"]):
self.assertEqual(install._SETUP_DEVICES[system["name"]],
tuple(str(name) for name in system["devices"]))
class OneSystemInFewPresses(unittest.TestCase):
"""tui_audit_4 P3-10.11: one system with a controller took 15 presses on
the fixture list, about 60 on muxterm, which has no page key."""
# The buttons each route sends, as the keys the interface reads.
PADS = {
"gptokeyb": {"A": "enter", "X": " ", "Start": "enter", "R1": "pgdn"},
"muxterm": {"A": "enter", "X": " ", "Start": "enter", "R1": "right"},
}
def long_list(self, pad: str) -> install.TuiState:
state = checked_state()
state.pad = pad
state.cols, state.rows = 40, 15
state.systems = [{"event": "system", "platform": "retroarch", "system": f"sys-{n:02d}",
"name": f"System {n:02d}", "files": 1, "present": 0, "missing": 1,
"wrong_hash": 0, "bytes_needed": 10} for n in range(66)]
state.ticked = {row["system"] for row in state.systems}
state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.action == "install")
return state
def press_button(self, state, pad: str, button: str) -> list:
name = self.PADS[pad].get(button, button)
return install.tui_update(state, Key("char", name) if len(name) == 1 else Key(name))
def go_to(self, state, pad: str, target: str, paging: bool) -> int:
"""Presses from the focus to the target system, as a person does."""
count = 0
while True:
rows = install.tui_rows(state)
focus = state.focus.get("systems", 0)
index = next(i for i, row in enumerate(rows) if row.value == target)
if index == focus:
return count
page = max(1, state.rows - 6)
button = "R1" if paging and index - focus >= page else "down"
self.press_button(state, pad, button)
count += 1
def presses(self, pad: str, fast: bool) -> int:
"""From the plan to the run that installs sys-45 alone, with the
setup's A before it."""
state = self.long_list(pad)
count = 1
for button in ("down", "A"):
self.press_button(state, pad, button)
count += 1
self.assertEqual(state.screen, "systems")
paging = pad != "muxterm" or fast
if fast:
self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "none")
self.press_button(state, pad, "A")
count += 1
count += self.go_to(state, pad, "sys-45", paging)
effects = self.press_button(state, pad, "A")
count += 1
else:
state.focus["systems"] = 3
for button in ("up", "up", "A"):
self.press_button(state, pad, button)
count += 1
count += self.go_to(state, pad, "sys-45", paging)
self.press_button(state, pad, "X")
effects = self.press_button(state, pad, "Start")
count += 2
self.assertEqual(state.ticked, {"sys-45"})
self.assertEqual(effects, [("run", "confirm")])
# Install on the confirm screen, then Exit on the result.
return count + 2
def test_presses_before_and_after(self):
# The audit's path, A Down A Up Up A R1 x5 X Start A A. Before the
# fast path it took 17 presses here, 57 on muxterm (None kept the
# focus, and muxterm had no page key).
self.assertEqual(self.presses("gptokeyb", fast=False), 15)
self.assertEqual(self.presses("gptokeyb", fast=True), 12)
self.assertEqual(self.presses("muxterm", fast=False), 55)
self.assertEqual(self.presses("muxterm", fast=True), 12)
def test_a_on_a_system_with_none_ticked_takes_it_alone(self):
state = self.long_list("keys")
state.stack.append("systems")
state.ticked = set()
state.focus["systems"] = 5
text = install.tui_view(state, 80, 24)[-1]
self.assertIn("Enter Only this system", text)
focused = install.tui_rows(state)[5].value
self.assertEqual(press(state, "enter"), [("run", "confirm")])
self.assertEqual(state.ticked, {focused})
def test_enter_still_continues_with_the_ticks_as_they_are(self):
state = self.long_list("keys")
state.stack.append("systems")
state.focus["systems"] = 5
self.assertEqual(press(state, "enter"), [("run", "install")])
self.assertEqual(len(state.ticked), 66)
def test_left_and_right_page_inside_muxterm_only(self):
for pad in ("muxterm", "gptokeyb_muxterm", "gptokeyb"):
with self.subTest(pad=pad):
state = self.long_list(pad)
state.stack.append("systems")
state.focus["systems"] = 3
press(state, "right")
paged = pad != "gptokeyb"
self.assertEqual(state.focus["systems"] > 4, paged)
self.assertEqual(len(state.ticked), 66)
state.help = True
help_text = " ".join(install.tui_view(state, 80, 24))
self.assertEqual("D-pad Left/Right" in help_text, paged)
def test_a_short_list_opens_on_its_first_system(self):
state = checked_state()
state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.action == "systems")
press(state, "enter")
self.assertEqual(install.tui_rows(state)[state.focus["systems"]].action, "system")
class DataFilesHaveASize(unittest.TestCase):
"""tui_audit_4 P3-10.8: "8179 files to download, 6.6 GB, and 2636 data
files", the data files with no size."""
def test_the_plan_gives_the_data_files_size(self):
state = fresh_state()
state.stack = ["setup"]
with mock.patch.object(install.Path, "exists", return_value=True):
install.tui_update(state, Key("enter"))
install.tui_receive(state, ("manifest", "retroarch", MANIFEST))
check = {**CHECK, "data_missing": 2636}
data = {"event": "data", "platform": "retroarch", "files": 2636,
"bytes": 412 * 1024 ** 2}
for event in [*FILES, *SYSTEMS, check, data]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True, "cores": []}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
self.assertIn("and 2636 data files (412.0 MB)", text)
class ThePagerCountsLines(unittest.TestCase):
"""tui_audit_4 P3-9.4: at 40x12 the result read "1-2 of 5" over eight
lines, counting paragraphs, and its last paragraph was cut."""
def result_state(self, cols: int, rows: int) -> install.TuiState:
state = checked_state()
state.stack.append("result")
state.code, state.summary = 0, {"installed": 3}
state.dest = Path("/media/" + "long-folder-name/" * 12)
state.cols, state.rows = cols, rows
return state
def test_the_last_line_is_reachable_at_every_size(self):
for cols, rows in (*SIZES, (50, 13), (60, 14)):
with self.subTest(size=f"{cols}x{rows}"):
state = self.result_state(cols, rows)
width = min(cols, install.TUI_MAX_WIDTH) - 2
wrapped = install._text_lines(install._pager_items(state), width)
press(state, "end")
screen = install.tui_view(state, cols, rows)
body = [line.rstrip() for line in screen[2:-1]]
self.assertIn(" " + str(wrapped[-1]).rstrip(), body)
if len(wrapped) > rows - 3:
self.assertTrue(any(re.fullmatch(rf" \d+-{len(wrapped)} of {len(wrapped)}",
line) for line in body), body)
def test_the_counter_moves_one_line_per_press(self):
state = self.result_state(40, 12)
first = [line for line in install.tui_view(state, 40, 12) if " of " in line][-1]
self.assertRegex(first, r"^ 1-8 of \d+")
press(state, "down")
second = [line for line in install.tui_view(state, 40, 12) if " of " in line][-1]
self.assertRegex(second, r"^ 2-9 of \d+")
class OfflineInTheInterface(unittest.TestCase):
"""--offline in the full screen: a "No connection" error offering a Try
again that can never work, and a plan counting downloads the run
@@ -3743,8 +4099,9 @@ class EndToEnd(unittest.TestCase):
term.wait("> Install")
term.send(DOWN, "\r")
term.wait("Choose systems")
# D-pad and A only: up to None, A, down to the second system, Right.
term.send(UP, UP, "\r", DOWN, DOWN, DOWN, RIGHT)
# D-pad and A only: up to None, A (on the first system), down to the
# second system, Right.
term.send(UP, UP, "\r", DOWN, RIGHT)
screen = term.wait("1 of 2 systems")
self.assertIn("> [x] sony-playstation", screen)
term.send("\r")
+8 -6
View File
@@ -131,7 +131,8 @@ how many files are missing, how much they weigh, how many are in place and how
much space is free, before anything is written.
The other choices sit one press away. **Choose systems** ticks the consoles you
play, with the total to download under the list. **Options** sets the
play, with the total to download under the list. A long list opens on **None**:
A there unticks every system, and A on a system then installs that one alone. **Options** sets the
preferred regions, game data, the installed cores only, the cores, the parallel
downloads, the folder and, for a list with files read from the frontend's save
directory, that directory. Each row names the option it sets, and **Show the
@@ -1073,8 +1074,8 @@ Start pressed first also chooses the line in focus: hold Select first. A
running install is then stopped the way B stops it, and the last screen says
what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y
ticks a system, Select shows its keyboard, and Menu quits. L1 and R1 do not
page there: muxterm keeps PgUp and PgDn for its own scrollback, so a list
moves with the D-pad.
page there: muxterm keeps PgUp and PgDn for its own scrollback, so Left and
Right on the D-pad page a list instead, as they do with gptokeyb in muxterm.
Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed
for 5 minutes since it opened, and no check or install running, the interface
@@ -1094,8 +1095,8 @@ Each launcher tells the interface which buttons reach it, in
| Value | Input | Set by |
|-------|-------|--------|
| `gptokeyb` | gptokeyb or gptokeyb2 with `retrobios.gptk`, the table above | the PortMaster port and the Tools entry |
| `gptokeyb_muxterm` | the same, inside muxterm, which keeps L1 and R1 for its scrollback: no button pages | the muOS app with PortMaster |
| `muxterm` | muxterm's own keys: D-pad, Start as Enter, B as Backspace, Y as Space | the muOS app without PortMaster |
| `gptokeyb_muxterm` | the same, inside muxterm, which keeps L1 and R1 for its scrollback: Left and Right page | the muOS app with PortMaster |
| `muxterm` | muxterm's own keys: D-pad (Left and Right page), Start as Enter, B as Backspace, Y as Space | the muOS app without PortMaster |
| `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port |
| `miyoo` | buttons that are already keys: A as Space, Start as Enter, L1 and R1 as E and T, L2 as Tab, R2 as Backspace | no launcher yet |
@@ -1332,6 +1333,7 @@ other schemas under `api/v1/schemas/`.
| `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it |
| `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply |
| `declared` | `platform`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `dests` | Right after `check`, the same counts over the files the platform's own list names (`cores: null`), and their `dests` in the order of the `file` events. A setup that checks its own list reads its answer here, with or without `--declared-only` |
| `data` | `platform`, `files`, `bytes` | Right after `declared`: the data-tree files to fetch (the `data_missing` of `check`) and the bytes they write once in place |
| `found` | `platform`, `dest`, `folder`, `path`, `member`, `reason` | Each file to install that this machine already holds, after `check` and before anything is written, in `--check` and `--dry-run` too. `folder` is the `--from` folder as given, or the `bios_dir` of the check when the BIOS folder holds the file at another place, and then `reason` says where in words (`nested folder: ...` for a misplaced file, `the same file is at ...` for one the list installs there too). `path` is the file, and `member` the ZIP member holding the bytes when `path` is an archive, an empty string otherwise. Its copy is reported by a `downloaded` event |
| `unreadable` | `platform`, `folder`, `path`, `member`, `reason` | Each file in a `--from` folder that could not be read and may be one the run still needs, right after the `found` events: its size is that of a file no copy was found for, or unknown (a folder that could not be listed, a ZIP that would not open). `member` names the ZIP member, or is empty. `reason` says why in words (`Permission denied`, `it is encrypted, a password is needed`) |
| `saves` | `platform`, `files`, `folder` | After `detect` and before `checking`, when the list holds files read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `<system>` and `<core>` stand for one folder per system or per core. When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) |
@@ -1343,7 +1345,7 @@ other schemas under `api/v1/schemas/`.
| `app` | `system`, `folder`, `ok`, `reason`, `dests`, `written`, `kept` | After `summary`, with `--add-app` or a yes to its question: the RetroBIOS app on the card of `system` whose root is `folder`. `dests` lists the app's files below that root, `written` the ones this run wrote (empty when each was in place), `kept` the backup folders as in `summary`. `ok` is false, with `reason`, when the card takes no app (before anything is written, code 2), when the install did not finish, or when a file could not be fetched, verified or written (code 1) |
| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) |
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`), never a key of an
Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees), never a key of an
existing kind: a reader skips a kind it does not know. An option refused
before the run starts (`install.py: error:`, code 2) writes
no event at all. A program reads a missing `end` as that case, or as a