feat: remember the install and open on the plan

An install writes retrobios-backup/<folder>.install.json beside the BIOS
folder: each file it placed with its sha1, size and source, and the choice
it made. The next run for the same setup and folder takes that choice when
the command line chose nothing, in line mode and on the full screen, and
says so. A record that cannot be read is said once and ignored. A record
event reports it.

With one setup found, the full screen opens on its plan, Change where one
row away. The plan's Install row says the size its headline says.
This commit is contained in:
Abdessamad Derraz committed 2026-11-10 11:46:57 +01:00
1 parent 76ccc3e72a
commit 8f2018e7c2
25 files changed
+1621 -143

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
# the site's build rewrites this line there (scripts/sitebootstrap.py).
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
$defaultInstallSha256 = "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712"
$defaultInstallSha256 = "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca"
# The pin above refuses any changed byte: this cap only stops a body that
# cannot be the installer (a sign-in portal's page) before it is hashed.
$maximumInstallerBytes = 8MB
+596 -44
View File
@@ -814,6 +814,15 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# when all were in place), kept the backup folders as in summary.
# ok false with reason when it was refused or failed.
"app": ("system", "folder", "ok", "reason", "dests", "written", "kept"),
# The install record of the BIOS folder (path, beside it in
# retrobios-backup). status: applied (before choices, its choice taken
# as the run's because nothing on the command line chose), written
# (before the summary, once the install placed files), unreadable (said
# once and ignored) or not_written (the install itself unharmed). choice is
# the recorded options as words, date when it was written, files the
# files it holds for the platform. A kind of its own, since schema 1
# never adds a key to an existing kind.
"record": ("platform", "folder", "path", "status", "choice", "date", "files", "message"),
"end": ("code",),
}
# Seconds between two progress events while bytes arrive.
@@ -6099,6 +6108,270 @@ def _kept_lines() -> list[str]:
return lines
# The install record: what the runs placed in a BIOS folder, each file with
# its hash, and the choice they made, which the next run's plan takes. It
# lives in the backup holder beside the folder, never loose in it, for the
# reason the backups do. On a card it travels with the card.
RECORD_SCHEMA = 1
RECORD_SUFFIX = ".install.json"
# The options a record keeps as the choice: what the list was narrowed to.
RECORD_CHOICE = ("--system", "--core", "--emulator", "--region", "--for-games",
"--required-only", "--all-files", "--target", "--every-board",
"--no-game-data", "--installed-cores", "--declared-only")
# The record's choice options that narrow the list, --all-files aside.
RECORD_NARROWING = tuple(flag for flag in RECORD_CHOICE if flag != "--all-files")
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec")
# The records already said unreadable in this run, said once each.
_RECORD_SAID: set[str] = set()
# Per platform, the record whose choice this run took (apply_record).
_RECORD_TAKEN: dict[str, dict] = {}
def record_paths(folder: Path) -> list[Path]:
"""Where the record of folder is looked for, in order: the holder beside
it, then the one inside it that a folder with no writable parent gets."""
folder = Path(os.path.abspath(folder))
name = f"{folder.name or 'folder'}{RECORD_SUFFIX}"
return [holder / name for holder, _prefix in KeptFiles._holders(folder)]
def _valid_setup(setup: object) -> bool:
if not isinstance(setup, dict):
return False
choice, files = setup.get("choice"), setup.get("files")
return (isinstance(setup.get("written"), str) and isinstance(choice, list)
and all(isinstance(word, str) for word in choice) and isinstance(files, list)
and all(isinstance(item, dict) and isinstance(item.get("dest"), str)
and isinstance(item.get("sha1"), str) and isinstance(item.get("size"), int)
for item in files))
def read_record(folder: Path) -> tuple[dict | None, Path | None, str]:
"""(the record of folder, where it is, why it cannot be used). Neither a
record nor a reason where there is none."""
for path in record_paths(folder):
try:
text = path.read_text(encoding="utf-8")
except (FileNotFoundError, NotADirectoryError):
continue
except (OSError, UnicodeDecodeError) as exc:
reason = getattr(exc, "strerror", None) or str(exc)
return None, path, f"The install record {path} cannot be read ({reason})"
try:
data = json.loads(text)
except ValueError:
return None, path, f"The install record {path} is damaged"
if not isinstance(data, dict) or not isinstance(data.get("setups"), dict):
return None, path, f"The install record {path} is damaged"
if data.get("schema") != RECORD_SCHEMA:
return None, path, (f"The install record {path} was written by another "
"version of the installer")
if not all(_valid_setup(setup) for setup in data["setups"].values()):
return None, path, f"The install record {path} is damaged"
return data, path, ""
return None, None, ""
def setup_record(plat: str, folder: Path) -> tuple[dict | None, Path | None, str]:
"""The record of plat in folder, where it is, and why it cannot be used.
A record written for a folder of another name (copied, or renamed with
it) is not this folder's."""
data, path, problem = read_record(folder)
if data is None:
return None, path, problem
written_for = str(data.get("folder") or "")
if Path(written_for).name != Path(os.path.abspath(folder)).name:
return None, path, ""
return data["setups"].get(plat), path, ""
def record_day(setup: dict) -> str:
"""The day a record was written, as the plan says it: 3 Nov."""
try:
when = datetime.datetime.fromisoformat(setup["written"].replace("Z", "+00:00"))
except (KeyError, ValueError):
return ""
local = when.astimezone()
day = f"{local.day} {_MONTHS[local.month - 1]}"
return day if local.year == datetime.datetime.now().year else f"{day} {local.year}"
def record_options(argv: list[str]) -> list[tuple[str, str | None]]:
"""A record's choice as (option, value) pairs."""
options: list[tuple[str, str | None]] = []
words = list(argv)
while words:
flag = words.pop(0)
valued = flag in _VALUED_OPTIONS or (
flag == "--for-games" and words and not words[0].startswith("--"))
options.append((flag, words.pop(0) if valued and words else None))
return options
def record_argv(options: Iterable[tuple[str, str | None]]) -> list[str]:
"""The choice options a record keeps, as words, in the command's order."""
kept = sorted((option for option in options if option[0] in RECORD_CHOICE),
key=_command_rank)
if not any(flag in RECORD_NARROWING for flag, _value in kept):
# The whole list, chosen as such: a card that holds games later
# still gets what was installed before.
kept = [("--all-files", None)]
return [word for flag, value in kept for word in ([flag] if value is None else [flag, value])]
def record_says(setup: dict, manifest: dict | None = None) -> str:
"""The recorded choice in words: Game Boy Advance, the games, the
options chosen, every file."""
options = record_options(setup.get("choice") or [])
systems = [system_title(value, manifest) for flag, value in options
if flag == "--system" and value]
if systems:
shown = ", ".join(systems[:3])
return shown + (f" and {len(systems) - 3} more" if len(systems) > 3 else "")
if any(flag == "--for-games" for flag, _value in options):
return "the games"
if any(flag in RECORD_NARROWING for flag, _value in options):
return "the options chosen"
return "every file"
def record_changes(setup: dict, entries: Iterable[dict]) -> tuple[int, int]:
"""Of the files a choice keeps now, those the list changed since the
record and those it did not hold then: (updated, new)."""
recorded = {(str(item.get("root") or ""), item["dest"]): item["sha1"]
for item in setup.get("files") or []}
updated = new = 0
for entry in entries:
sha1 = recorded.get((str(entry.get("root") or ""), str(entry.get("dest"))))
if sha1 is None:
new += 1
elif entry.get("sha1") and sha1 != entry.get("sha1"):
updated += 1
return updated, new
def record_file(entry: dict, origin: str, root: str = "") -> dict:
"""One file a run wrote, as the record keeps it."""
return {"dest": str(entry["dest"]), "root": root or str(entry.get("root") or ""),
"sha1": str(entry.get("sha1") or ""), "size": int(entry.get("size") or 0),
"from": origin}
def write_record(
folder: Path, plat: str, manifest: dict, choice: list[str], files: list[dict],
) -> tuple[Path | None, dict, str]:
"""Write what this run placed in folder for plat, merged with what
earlier runs placed: (where, the record of plat, why it was not
written). A record that cannot be read is replaced."""
folder = Path(os.path.abspath(folder))
data, found, _problem = read_record(folder)
if data is None:
data = {"setups": {}}
previous = data["setups"].get(plat) or {}
merged = {(str(item.get("root") or ""), item["dest"]): item
for item in previous.get("files") or []}
for item in files:
merged[(item["root"], item["dest"])] = item
setup = {
"written": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"list": {"version": manifest.get("version"), "generated": manifest.get("generated")},
"choice": choice,
"files": [merged[key] for key in sorted(merged)],
}
data = {"schema": RECORD_SCHEMA, "installer": installer_id(), "folder": str(folder),
"setups": {**data["setups"], plat: setup}}
text = json.dumps(data, separators=(",", ":"), sort_keys=True) + "\n"
places = record_paths(folder)
if found is not None and found in places:
# Rewritten where it was read: never a second record of one folder.
places = [found] + [place for place in places if place != found]
failure = "nothing could be written beside the folder"
for path in places:
scratch: Path | None = None
try:
path.parent.mkdir(exist_ok=True)
_check_holder(path.parent)
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.",
suffix=".part", delete=False) as handle:
scratch = Path(handle.name)
handle.write(text)
os.replace(scratch, path)
except OSError as exc:
failure = exc.strerror or str(exc)
if scratch is not None and os.path.lexists(scratch):
try:
scratch.unlink()
except OSError as left:
failure += f", and {scratch} stays ({left.strerror or left})"
continue
return path, setup, ""
return None, setup, f"The install record could not be written ({failure})"
def record_menu_words(setup: dict | None) -> str:
"""What line mode's menu says of a recorded choice it opens on."""
if setup is None:
return ""
day = record_day(setup)
return f"Same choice as on {day}." if day else "Same choice as last time."
def choice_given(args: argparse.Namespace) -> bool:
"""Whether the command line chose what to install, which a record then
never replaces: an option of the choice, or a file list of its own."""
# A namespace built by a caller that predates an option lacks it.
return (getattr(args, "for_games", None) is not None or LOCAL.manifest is not None
or any(getattr(args, flag[2:].replace("-", "_"), None)
for flag in RECORD_CHOICE if flag != "--for-games"))
def say_record_problem(plat: str, folder: Path, path: Path | None, problem: str) -> None:
"""A record that cannot be used, said once and ignored."""
key = str(path)
if not problem or key in _RECORD_SAID:
return
_RECORD_SAID.add(key)
message = f"{problem}, so it is ignored."
print(f" {message}")
EVENTS.emit("record", platform=plat, folder=str(folder), path=key, status="unreadable",
choice=[], date="", files=0, message=message)
def apply_record(args: argparse.Namespace, plat: str, folder: Path) -> dict | None:
"""Take the choice recorded for plat in folder as the run's, as if its
options had been typed: the plan's first choice. Returns the record,
None where none is taken."""
setup, path, problem = setup_record(plat, folder)
say_record_problem(plat, folder, path, problem)
if setup is None:
return None
try:
parsed = build_parser().parse_args(setup["choice"])
except SystemExit:
say_record_problem(plat, folder, path,
f"The install record {path} names options this installer lacks")
return None
for flag in RECORD_CHOICE:
name = flag[2:].replace("-", "_")
setattr(args, name, getattr(parsed, name))
if args.for_games is not None:
args.all_files = False
args.from_record = True
options = record_options(setup["choice"])
if any(flag in RECORD_NARROWING for flag, _value in options):
# Said where it narrows the list: the whole list again is the run
# a person expects, and said nothing new.
_RECORD_TAKEN[plat] = setup
_INVOCATION.chosen[plat] = [option for option in options if option[0] != "--all-files"]
message = record_menu_words(setup)
EVENTS.emit("record", platform=plat, folder=str(folder), path=str(path), status="applied",
choice=list(setup["choice"]), date=setup["written"],
files=len(setup["files"]), message=message)
return setup
def restore_kept(run: Path, dry_run: bool = False) -> int:
"""Put every file of a backup run folder back where it was.
@@ -6769,6 +7042,14 @@ class Invocation:
words += ["--dest", self.word(str(dest.absolute()))]
elif dest:
words += ["--dest", dest]
for flag, value in self.flags(plat):
if flag in without:
continue
words += [flag] if value is None else [flag, self.word(value)]
return " ".join(words)
def flags(self, plat: str) -> list[tuple[str, str | None]]:
"""The options of the run for plat, in the command's order."""
options = self.options
if plat in self.chosen:
# A choice made at the questions replaces the narrowing the
@@ -6776,11 +7057,7 @@ class Invocation:
# the systems of the first run and of the second.
replaced = {*_CHOICE_OPTIONS, *(flag for flag, _value in self.chosen[plat])}
options = [option for option in options if option[0] not in replaced]
for flag, value in sorted([*options, *self.chosen.get(plat, [])], key=_command_rank):
if flag in without:
continue
words += [flag] if value is None else [flag, self.word(value)]
return " ".join(words)
return sorted([*options, *self.chosen.get(plat, [])], key=_command_rank)
_INVOCATION = Invocation()
@@ -7956,13 +8233,15 @@ def install_data_directory(entry: dict, bios_path: Path,
Returns counts: present, installed, changed (the source no longer holds
the recorded bytes), absent (not in the archive), error, the reason
the archive could not be read, and not_kept, the (path, reason) of each
file in place that differs and could not be moved aside, left as it is.
the archive could not be read, not_kept, the (path, reason) of each
file in place that differs and could not be moved aside, left as it is,
and written, each file written (dest below the BIOS folder, sha1, size).
"""
root, needed, present = state or data_directory_state(entry, bios_path)
needed = dict(needed)
written: list[dict] = []
counts = {"present": present, "installed": 0, "changed": 0, "absent": 0, "error": "",
"not_kept": []}
"not_kept": [], "written": written}
if not needed:
return counts
_writing.set()
@@ -7999,12 +8278,13 @@ def install_data_directory(entry: dict, bios_path: Path,
continue
target = _destination_path(root, rel)
target.parent.mkdir(parents=True, exist_ok=True)
slot = f"{entry['dest']}/{rel}" if entry.get("dest") else rel
outcome = _install_data_member(
opener, meta, target, bios_path,
f"{entry['dest']}/{rel}" if entry.get("dest") else rel,
rel, counts["not_kept"])
opener, meta, target, bios_path, slot, rel, counts["not_kept"])
if outcome:
counts[outcome] += 1
if outcome == "installed":
written.append({"dest": slot, "sha1": meta["sha1"], "size": meta["size"]})
except _Offline as exc:
# Kept apart, so the advice is the offline one, not the network's.
counts["error"] = OfflineReason(str(exc))
@@ -9595,6 +9875,11 @@ def _prompt_custom_selection(
elif playing and played is not None:
kept_games = menu_choice(files, omitted, installed, GamesChoice(played), cutting)[0]
print(f" h) only for the games {played.where()}: {_sized(kept_games)}")
# The screen's "Same choice as last time: no": the whole list.
widen = (plat in _RECORD_TAKEN and current is not None
and not (playing and isinstance(current, GamesChoice)))
if widen:
print(f" e) everything: {_sized(shown)}")
if installed is not None and current != INSTALLED_ONLY:
print(f" i) only what the installed cores read: {_sized(installed[0])}")
# What c asks, named: "systems, cores and regions" was offered with
@@ -9614,6 +9899,7 @@ def _prompt_custom_selection(
print(" q) quit")
keys = [*([] if emptied else [""]), "q", *(["c"] if choosable else []),
*((["r", "e"] if isinstance(current, GamesChoice) else ["h"]) if playing else []),
*(["e"] if widen else []),
*(["i"] if installed is not None and current != INSTALLED_ONLY else []),
*(["f"] if copying else []), *(["g"] if games.games else [])]
others = [key for key in keys if key not in ("", "q")]
@@ -9668,10 +9954,12 @@ def _print_menu_head(
if current == INSTALLED_ONLY:
named = ", only what the installed cores read"
elif isinstance(current, GamesChoice):
named = f", {current.words()}" + (" (--for-games)" if current is given else "")
named = f", {current.words()}" + (" (--for-games)" if current is given
and plat not in _RECORD_TAKEN else "")
elif isinstance(current, tuple):
systems, cores, regions = current
flag = current is given
# A recorded choice was typed by nobody: no option is named.
flag = current is given and plat not in _RECORD_TAKEN
listed = choosable_systems(files, omitted, cores, regions)
total = len(listed)
# The systems chosen that still hold a file: Sega Dreamcast holding
@@ -10206,6 +10494,20 @@ def _narrow_to_emulators(
return files, omitted
def _known_names(values: list[str] | None, known: list[str], label: str,
aliases: dict[str, str] | None = None) -> list[str]:
"""The names of values that resolve against known, the others left out."""
kept = []
for value in values or []:
try:
resolve_choices([value], known, label, aliases)
except ValueError:
detail(f" {value} is no longer on the list: left out of the recorded choice")
continue
kept.append(value)
return kept
def _requested_narrowing(
args: argparse.Namespace, plat: str, files: list[dict], omitted: list[dict],
manifest: dict | None = None,
@@ -10234,6 +10536,14 @@ def _requested_narrowing(
print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr)
sys.exit(1)
choices = available_choices(files, omitted)
if getattr(args, "from_record", False):
# A name the list no longer holds is dropped from a recorded choice,
# never refused: nobody typed it.
args.system = _known_names(args.system, choices["systems"], "system",
system_aliases(manifest)) or None
args.core = _known_names(args.core, choices["cores"], "core") or None
if not (args.system or args.core or args.region):
return None
try:
systems = resolve_choices(
args.system or [], choices["systems"], "system", system_aliases(manifest),
@@ -16101,6 +16411,42 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# The BIOS folders read for misplaced files, one per platform.
bios_indexes: list[FolderIndex] = []
# The choice the folder's record keeps is the plan's first choice, taken
# as its options would be: before the checks of --target and
# --installed-cores, which it may hold. Never where the command line
# chose, nor for a run over several setups.
_RECORD_TAKEN.clear()
def remember(plat: str, folder: Path, manifest: dict, written: list[dict],
games: bool) -> None:
"""Record what this install placed in folder and the choice it made,
games being the card's games applied with no --for-games."""
if not written and setup_record(plat, folder)[0] is None:
# Nothing of ours is there to record, and no choice to keep.
return
options = _INVOCATION.flags(plat)
if games:
options += [("--for-games", None),
*([("--required-only", None)] if args.required_only else [])]
choice = record_argv(options)
path, setup, problem = write_record(folder, plat, manifest, choice, written)
if problem:
detail(f" {problem}.")
count = len(setup["files"])
EVENTS.emit("record", platform=plat, folder=str(folder),
path=str(path or record_paths(folder)[0]),
status="not_written" if problem else "written", choice=choice,
date=setup["written"], files=count,
message=f"{problem}." if problem else
f"Recorded {_plural(count, 'file')} and this choice in {path}.")
if len(platforms) == 1 and not listing and not choice_given(args):
if apply_record(args, *platforms[0]) is not None and args.target:
known = fetch_targets(platforms[0][0])
if known is not None and args.target not in known:
# A board the list no longer names: every board's files.
args.target = None
# --target is checked for every platform before anything is written, as
# --installed-cores is below: EmuDeck installed 3.2 GB and only then was
# steamos refused for RetroArch, with no summary of what had landed.
@@ -16409,6 +16755,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
+ ([("--no-game-data", None)] if cut and cut[0] else []))
return _INVOCATION.command(plat, folder)
# The record's options, which the menu's commands overwrite.
recorded = _INVOCATION.chosen.get(plat_name)
selection = _prompt_custom_selection(
plat_name, bare, bare_omitted + unplaced, installed,
detected_os(plat_name, bios_path) or display,
@@ -16417,14 +16765,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
bool(target or args.installed_cores or args.declared_only
or len(files) + game_data_cut + len(unplaced) != listed),
command_for, cut,
game_data_only_words([system_title(system, manifest) for system in
game_data_only(given[0] if isinstance(given, tuple)
else [], files)])
if args.no_game_data else "",
" ".join(filter(None, [
record_menu_words(_RECORD_TAKEN.get(plat_name)),
game_data_only_words([system_title(system, manifest) for system in
game_data_only(given[0] if isinstance(given, tuple)
else [], files)])
if args.no_game_data else ""])),
None if games_applied else games_plan,
)
# Its head named the BIOS folder: the result does not again.
OUTPUT.where_said = True
kept_record = plat_name in _RECORD_TAKEN and selection is not None and (
selection is given or selection == given)
if selection is None and LOCAL.pack is not None:
smaller = " Nothing copied."
elif selection is None:
@@ -16456,6 +16808,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
_INVOCATION.chosen[plat_name] = []
else:
_INVOCATION.chosen.pop(plat_name, None)
if kept_record and recorded is not None:
# Enter on the recorded choice: still the record's options.
_INVOCATION.chosen[plat_name] = recorded
elif selection is None and plat_name in _RECORD_TAKEN and not games_applied:
# e: every file, the recorded narrowing given up.
args.system = args.core = args.region = None
if selection is not None:
before = len(files)
files, omitted_files = narrow(files, omitted_files, *selection)
@@ -16567,6 +16925,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
bios_path = library
detail("\nChecking existing files...")
record_files: list[dict] = []
to_download, up_to_date, mismatched = check_local(
files, bios_path, jobs=args.jobs, term=term
)
@@ -16672,6 +17031,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
else:
print(f"\n {EVERYTHING_IN_PLACE}")
print(f" BIOS folder: {shown_path(bios_path)}")
if plat_name in _RECORD_TAKEN:
# The files counted are the recorded choice's, not the list's.
print(f" {record_menu_words(_RECORD_TAKEN[plat_name])} "
"--all-files checks every file.")
absent = _absent_required(omitted_files, bios_path)
# The collection cannot supply these, so they leave the exit
# code alone, but a user asking why a game will not start needs
@@ -16710,6 +17073,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
trees, kept, copy_plan_lines(to_download, copies, bios_index,
bios_path, files, args.verbose, True),
verbose=args.verbose, manifest=manifest)
if plat_name in _RECORD_TAKEN:
print(f" {record_menu_words(_RECORD_TAKEN[plat_name])} "
"--all-files lists every file.")
for line in unreadable_lines(unread, args.verbose):
print(line)
for line in hint:
@@ -16784,9 +17150,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
coming = len(to_download) - len(lacking) + trees_missing
weight = dl_size + sum(data_fetch_bytes(entry, state[1])
for entry, state in data_states)
taken = _RECORD_TAKEN.get(plat_name)
print(f"\nInstalling {_plural(coming, 'file')} ({format_size(weight)}) for "
f"{detected_os(plat_name, bios_path) or display}"
+ (", what the games need" if games_applied else "") + ".")
+ (", what the games need" if games_applied else "")
+ (f", same choice as on {record_day(taken)}" if taken else "") + ".")
print(f"BIOS folder: {shown_path(bios_path)}")
OUTPUT.where_said = True
if unread:
@@ -16878,6 +17246,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# 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)
record_files += [record_file(entry, "copy" if id(entry) in copies
else "pack" if LOCAL.pack is not None else "download")
for entry in progress.landed]
files_pending += len(to_download) - progress.installed if interrupted \
else len(failed)
total_errors += len(failed)
@@ -16886,6 +17257,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
total_seconds += progress.elapsed()
if interrupted:
total_interrupted = True
remember(plat_name, bios_path, manifest, record_files,
games_applied and args.for_games is None)
break
elif len(platforms) > 1:
# One setup's run ends on "Done. Everything is in place.": said
@@ -16931,6 +17304,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
total_seconds += progress.elapsed()
total_downloaded += counts["installed"]
data_landed += counts["installed"]
record_files += [record_file(item, "pack" if LOCAL.pack is not None
else "download", "data")
for item in counts.get("written", [])]
total_up_to_date += counts["present"]
if isinstance(counts["error"], OfflineReason):
# Left out as the BIOS files above are: counted missing,
@@ -16971,6 +17347,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
failures.append((where, f"{NOT_KEPT} ({reason})"))
total_errors += 1
remember(plat_name, bios_path, manifest, record_files,
games_applied and args.for_games is None)
if os_type == "android" and not args.check and hide_from_media_scanner(bios_path):
detail(f" Added {NOMEDIA} to {shown_path(bios_path)}: the gallery and music "
"apps leave its images, sounds and videos out")
@@ -18019,6 +18398,16 @@ class TuiState:
self.games: dict | None = None
self.games_reading: tuple | None = None
self.games_unavailable: dict | None = None
# The install record of the setup and folder chosen (read for
# record_for), whose choice the plan opens on when the command line
# chose nothing (cli_choice), and the options of it no row shows.
self.record: dict | None = None
self.record_for: tuple | None = None
self.record_flags: list = []
self.cli_choice: bool = False
# Opened on the plan of the one setup found: its Change where row
# goes back to the setups.
self.opened_on_plan: bool = False
@property
def screen(self) -> str:
@@ -18475,6 +18864,12 @@ def tui_flags(state: TuiState) -> list[tuple[str, str | None]]:
flags.append(("--installed-cores", None))
if state.jobs != 8:
flags.append(("--jobs", str(state.jobs)))
flags += state.record_flags
if (state.record is not None and not state.for_games
and not any(flag in RECORD_NARROWING for flag, _value in flags)):
# The whole list, chosen over a recorded narrower one: said so to
# the runs, whose own reading of the record would narrow it again.
flags.append(("--all-files", None))
flags += [("--from", str(folder)) for folder in state.from_folders]
if (os.environ.get(OFFLINE_VARIABLE) == OFFLINE_FLAG_VALUE
and not any(flag == "--offline" for flag, _v in flags)):
@@ -18960,14 +19355,12 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
return rows + ([game_data] if game_data is not None else []) + [
*_details_row(state), TuiRow("options", "Options"), TuiRow("quit", "Quit")]
if not (_needed(check) if check else 0) and not check.get("data_missing"):
return [TuiRow("close", "Close"), *_games_rows(state), *_details_row(state),
TuiRow("options", "Options")]
# The size the pack can give: "Install 6.6 GB" from a pack holding
# none of the files.
lacking = set(_tui_pack_lacks(state, [event["dest"] for event in state.files]))
size = (_ticked_estimate(state)[1] if _tui_estimating(state)
else check.get("bytes_needed", 0) - sum(event["size"] for event in state.files
if event["dest"] in lacking))
return [TuiRow("close", "Close"), *_games_rows(state), *_record_rows(state),
*_where_rows(state), *_details_row(state), TuiRow("options", "Options")]
# The size the headline says: "Install 6.6 GB" stood under "14930 files
# to download, 6.8 GB", the copies counted in one and the data files in
# the other.
size = _ticked_estimate(state)[1] if _tui_estimating(state) else _plan_fetch(state, check)[1]
label = (f"{TUI_INSTALL_ROW} {format_size(size)}" if size >= CONFIRM_FROM_BYTES
else TUI_INSTALL_ROW)
rows = [TuiRow("install", label)]
@@ -19006,6 +19399,7 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
# Right under Install: the choice the plan opened on, the whole list one
# press away.
rows += _games_rows(state)
rows += _record_rows(state)
if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and (
len(state.systems) > 1 or state.cli_systems):
rows.append(TuiRow("systems", TUI_SYSTEMS_ROW))
@@ -19018,7 +19412,27 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
rows.append(TuiRow("misplaced", "Show the files in the wrong place"))
if state.unreadable:
rows.append(TuiRow("unreadable", "Show the files that could not be read"))
return rows + [*_details_row(state), TuiRow("options", "Options"), TuiRow("quit", "Quit")]
return rows + [*_where_rows(state), *_details_row(state), TuiRow("options", "Options"),
TuiRow("quit", "Quit")]
# The plan's row for the record's choice, and the one back to the setups.
TUI_RECORD_ROW = "Same choice as last time"
TUI_WHERE_ROW = TuiRow("where", "Change where")
def _record_rows(state: TuiState) -> list[TuiRow]:
"""Same choice as last time: yes or no, where the folder has a record."""
if state.record is None or state.cli_choice:
return []
day = record_day(state.record)
label = f"Same choice as on {day}" if day else TUI_RECORD_ROW
return [TuiRow("record", f"{label}: {_yes(tui_record_kept(state))}")]
def _where_rows(state: TuiState) -> list[TuiRow]:
"""Change where, on a plan opened straight on the one setup found."""
return [TUI_WHERE_ROW] if state.opened_on_plan and state.stack[:1] == ["setup"] else []
# The plan's explanations, counts and reasons, one press away: a pad has
@@ -19503,9 +19917,88 @@ def _start_run(state: TuiState, run: str) -> list[tuple]:
return [("run", run)]
def _record_key(options: Iterable[tuple[str, str | None]]) -> list:
"""A choice in a form two spellings of it share: one name per --system
and --core, in any order."""
pairs = record_options(record_argv(options))
return sorted((flag, name.strip()) for flag, value in pairs
for name in ((value or "").split(",") if flag in ("--system", "--core")
else [value or ""]) if name.strip() or value is None)
def tui_record_kept(state: TuiState) -> bool:
"""Whether the plan's choice is the one the record keeps."""
if state.record is None:
return False
return _record_key(tui_flags(state)) == _record_key(
record_options(state.record["choice"]))
def _tui_record_take(state: TuiState) -> None:
"""The recorded choice made the screen's, as its options would make it."""
setup = state.record or {}
try:
parsed = build_parser().parse_args(setup.get("choice") or [])
except SystemExit:
state.status = "The install record names options this installer lacks, so it is ignored."
state.record = None
return
state.cli_systems = list(parsed.system or [])
state.cores = [token.strip() for value in parsed.core or [] for token in value.split(",")
if token.strip()]
try:
state.regions = resolve_regions(parsed.region) if parsed.region else []
except ValueError:
state.regions = []
state.no_game_data = bool(parsed.no_game_data)
state.installed_only = bool(parsed.installed_cores)
state.for_games = parsed.for_games is not None
state.games_folder = parsed.for_games if isinstance(parsed.for_games, Path) else None
state.required_only = bool(parsed.required_only) and state.for_games
state.games_choice = "record"
state.record_flags = [(flag, value) for flag, value in record_options(setup["choice"])
if flag in ("--target", "--emulator", "--every-board",
"--declared-only")]
def _tui_record_drop(state: TuiState) -> None:
"""The recorded choice given up: the choice nothing narrows."""
state.cli_systems, state.cores, state.regions, state.record_flags = [], [], [], []
state.no_game_data = state.installed_only = state.required_only = False
state.for_games, state.games_folder, state.games_choice = None, None, ""
# The systems of the recorded check go, and with them its ticks.
state.systems, state.ticked, state.keep_ticked, state.widened_from = [], set(), None, None
def _tui_record_load(state: TuiState) -> None:
"""Read the record of the setup and folder chosen, once for each, and
take its choice where nothing chose: the command line, or the screen
for an earlier folder."""
key = (state.platform, state.dest)
if state.dest is None or state.record_for == key:
return
taken = tui_record_kept(state)
state.record_for = key
setup, path, problem = setup_record(state.platform, state.dest)
if problem and str(path) not in _RECORD_SAID:
_RECORD_SAID.add(str(path))
state.status = f"{problem}, so it is ignored."
if taken:
_tui_record_drop(state)
# Games read on a card are what the plan found, not a choice.
found = ("--for-games", "--required-only") if state.games_choice in ("", "probe") else ()
state.record = None
untouched = not any(flag in RECORD_NARROWING and flag not in found
for flag, _value in tui_flags(state))
state.record = setup
if setup is not None and not state.cli_choice and untouched:
_tui_record_take(state)
def _start_check(state: TuiState) -> list[tuple]:
"""Check the chosen folder, after fetching the file list once, the
systems ticked kept (_keep_choice)."""
_tui_record_load(state)
_keep_choice(state)
_games_look(state)
state.refocus = ""
@@ -19528,7 +20021,7 @@ def _games_look(state: TuiState) -> None:
looked at too, from what this run already read (no network): its games
make "For the games on this card" the choice, unless the user or the
command line decided."""
if (state.games_choice in ("user", "cli") or state.games_probed is None
if (state.games_choice in ("user", "cli", "record") or state.games_probed is None
or state.dest is None or state.games_probed == (state.platform, state.dest)):
return
count = games_probe(state.platform, state.dest, network=False, os_type=state.os_type)
@@ -20130,6 +20623,28 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
return _push(state, action)
if action == "recheck":
return _start_check(state)
if action == "where":
effects = [("kill",)] if state.running else []
state.running, state.run_id = False, state.run_id + 1
del state.stack[1:]
return effects
if action == "record":
# No: the choice nothing narrows, the card's games when it holds
# some. Yes: the recorded choice again. Counted again either way.
if tui_record_kept(state):
_tui_record_drop(state)
if state.dest is not None:
count = games_probe(state.platform, state.dest, network=False,
os_type=state.os_type)
state.games_probed = (state.platform, state.dest)
state.games_here = count or 0
state.for_games, state.games_choice = bool(count), "probe"
else:
_tui_record_drop(state)
_tui_record_take(state)
effects = _start_check(state)
state.refocus = "record"
return effects
if action in ("games", "startonly"):
# One press, then counted again: the games decide the systems,
# so the ticks start again from every system they need.
@@ -21485,7 +22000,7 @@ def tui_receive(state: TuiState, message: tuple) -> list[tuple]:
_kind, plat, dest, count = message
if plat == state.platform and dest == state.dest:
state.games_probed = (plat, dest)
if state.games_choice not in ("user", "cli"):
if state.games_choice not in ("user", "cli", "record"):
state.games_here = count or 0
state.for_games, state.games_choice = bool(count), "probe"
return []
@@ -21985,25 +22500,36 @@ def _last_name(folder: str) -> str:
return Path(folder).name or folder
def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
need = _needed(check)
data = check.get("data_missing", 0)
# A file found on this machine is copied, not downloaded: the count and
# the size to download leave it out, from the check's own events. On
# Confirm the dry run's files are matched the same way: it called the
# files a --from folder holds downloads, and sent an offline user for
# a pack.
def _plan_fetch(state: TuiState, check: dict) -> tuple[int, int, list, list, dict]:
"""(files to fetch, bytes the install fetches with the data files',
dests copied from this machine, dests the pack lacks, the size of each
file): the one size the plan's headline and its Install row say.
A file found on this machine is copied, not downloaded: the count and
the size to download leave it out, from the check's own events. On
Confirm the dry run's files are matched the same way: it called the
files a --from folder holds downloads, and sent an offline user for a
pack. The files the pack does not hold are left out too: the plan
offered "Install 6.6 GB" from a pack that held none of them.
"""
dry = check is state.dry and check is not state.check
sizes = {event["dest"]: event["size"]
for event in (state.dry_files if dry else state.files)}
copied = _plan_copies(state, check) if dry or check is state.check else []
# The files the pack does not hold are said before a size is promised,
# and left out of it: the plan offered "Install 6.6 GB" from a pack that
# held none of them.
lacking = _tui_pack_lacks(state, [dest_ for dest_ in sizes if dest_ not in copied])
fetch = max(0, need - len(copied) - len(lacking))
fetch_bytes = max(0, check["bytes_needed"] - sum(sizes[dest_] for dest_ in copied)
fetch = max(0, _needed(check) - len(copied) - len(lacking))
fetch_bytes = max(0, check.get("bytes_needed", 0) - sum(sizes[dest_] for dest_ in copied)
- sum(sizes[dest_] for dest_ in lacking))
data_bytes = check.get("data_bytes", 0) if check.get("data_missing", 0) else 0
return fetch, fetch_bytes + data_bytes, copied, lacking, sizes
def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
need = _needed(check)
data = check.get("data_missing", 0)
dry = check is state.dry and check is not state.check
fetch, fetched, copied, lacking, sizes = _plan_fetch(state, check)
fetch_bytes = fetched - (check.get("data_bytes", 0) if data else 0)
elsewhere = (_tui_found_elsewhere(state, set(copied) if dry else None)
if check is state.check or dry else {})
offline = _offline_only()
@@ -22603,6 +23129,23 @@ def _plan_text(state: TuiState) -> list:
said = ", ".join(named)
if said and not said.startswith("for "):
said = f"for this choice: {said}"
since: list = []
if tui_record_kept(state) and not state.cli_choice and state.record is not None:
# The record's systems by name, its row saying whose choice it is:
# nobody typed --system. The games and the other options are said
# as the plan says them.
what = record_says(state.record, state.manifest)
if what == "every file":
said = ""
elif what not in ("the games", "the options chosen"):
said = f"for {what}"
updated, new = record_changes(state.record, [
entry for entry in (state.manifest or {}).get("files") or []
if entry.get("dest") in state.checked_dests])
changes = [*([f"{_plural(updated, 'file')} updated in the list"] if updated else []),
*([f"{new} new"] if new else [])]
if changes:
since = [_detail(f"Since then: {', '.join(changes)}.")]
# A system --system named that --no-game-data left empty, in line mode's
# words.
listed = {row["system"] for row in state.systems}
@@ -22641,7 +23184,7 @@ def _plan_text(state: TuiState) -> list:
f"{parts}")
lines[at:at] = [*([_detail(state.card_note)] if state.card_note
and state.card_note != state.status else []),
*([f"{said[0].upper()}{said[1:]}."] if said else []),
*([f"{said[0].upper()}{said[1:]}."] if said else []), *since,
*([emptied_named] if emptied_named else []), *choice,
*([_detail(parts)] if parts else []), *games_plan_words(state)]
if state.version:
@@ -24065,6 +24608,7 @@ def tui_start(
if args.manifest is not None:
state.manifest_path = Path(args.manifest).absolute()
state.manifest_given = True
state.cli_choice = choice_given(args) or state.manifest_given
plat = normalize_platform(args.platform, say=False) if args.platform else ""
if detect:
captured = io.StringIO()
@@ -24137,6 +24681,14 @@ def tui_start(
if state.dest_given is None and not args.platform:
state.doubts = [(plat, mount) for mount in _mounted_cards()
for plat in share_doubt(mount)[0]]
if (state.dest_given is None and not state.doubts and not state.refused
and len(state.setups) == 1):
# One setup found and no card in doubt: its plan, Change where (or
# Back) one press away. A folder detection could not look in may
# hold another setup: the setups are listed then, with it.
found, folder, _label = state.setups[0]
state.opened_on_plan = True
return state, _choose(state, found, folder, proposed=True)
if state.dest_given is not None or not (state.setups or state.doubts):
state.stack = ["platforms"]
state.focus["platforms"] = _platform_focus(state, dest_default(state.setups))
@@ -24732,7 +25284,7 @@ def run_tui(args: argparse.Namespace) -> int:
if kind == "fetch":
threading.Thread(target=_tui_fetch_games,
args=(effect[1], inbox, buffer, state.dest,
state.games_choice not in ("user", "cli")),
state.games_choice not in ("user", "cli", "record")),
daemon=True).start()
elif kind == "run":
for run in current:
+1 -1
View File
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
# curl or wget stop on a cut transfer before the hash is read: the site's
# build rewrites this line there (scripts/sitebootstrap.py).
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
DEFAULT_INSTALL_SHA256="456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712"
DEFAULT_INSTALL_SHA256="70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca"
# The pin above refuses any changed byte: this cap only stops a body that
# cannot be the installer (a sign-in portal's page) before it is hashed.
MAX_INSTALLER_BYTES=8388608
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 1162452,
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
"size": 1189759,
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 1216077,
"sha256": "b9e623839a8cf71d929a860b53a6c4c82cbfde551151912e1aba0347532be965"
"size": 1243384,
"sha256": "ae0d36d5f2dda70c39bfe8be474108b92f0a5a80457bc8c07d36736b09b0310d"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 1162452,
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
"size": 1189759,
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 1162452,
"sha256": "456a32e79668e125f2ebe573b549247210c59fed6389e78dc1482b57d3a36712",
"size": 1189759,
"sha256": "70e71268db3c09fd3e3c9bd823bab905de73b01e95affcdae9db344a9f4087ca",
"executable": false
},
{
+57
View File
@@ -38,6 +38,7 @@
"downloaded",
"summary",
"app",
"record",
"end"
]
}
@@ -127,6 +128,9 @@
{
"$ref": "#/$defs/app"
},
{
"$ref": "#/$defs/record"
},
{
"$ref": "#/$defs/end"
}
@@ -1091,6 +1095,59 @@
}
}
},
"record": {
"type": "object",
"additionalProperties": false,
"required": [
"event",
"platform",
"folder",
"path",
"status",
"choice",
"date",
"files",
"message"
],
"properties": {
"event": {
"const": "record"
},
"platform": {
"type": "string"
},
"folder": {
"type": "string"
},
"path": {
"type": "string"
},
"status": {
"enum": [
"applied",
"written",
"unreadable",
"not_written"
]
},
"choice": {
"type": "array",
"items": {
"type": "string"
}
},
"date": {
"type": "string"
},
"files": {
"type": "integer",
"minimum": 0
},
"message": {
"type": "string"
}
}
},
"end": {
"type": "object",
"additionalProperties": false,
+3 -2
View File
@@ -37,10 +37,11 @@ KEY_TYPES: dict[str, dict] = {
**dict.fromkeys((
"installer", "os", "os_name", "stage", "reason", "platform", "message",
"folder", "dest", "system", "name", "bios_dir", "path", "member",
"list_version", "installed_version", "board", "core",
"list_version", "installed_version", "board", "core", "date",
), _TEXT),
"mode": {"enum": ["install", "check", "dry-run", "list"]},
"state": {"enum": ["present", "wrong_hash", "missing"]},
"status": {"enum": ["applied", "written", "unreadable", "not_written"]},
**dict.fromkeys((
"files", "files_done", "files_total", "bytes", "bytes_done", "bytes_total",
"bytes_local", "bytes_network", "size", "present", "missing", "wrong_hash",
@@ -48,7 +49,7 @@ KEY_TYPES: dict[str, dict] = {
"pending", "code", "games", "recognised", "not_games",
), _COUNT),
**dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied", "info"), _FLAG),
**dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES),
**dict.fromkeys(("systems", "other_drives", "dests", "written", "choice"), _NAMES),
**dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept", "found",
"fallback"), _RECORDS),
}
+41
View File
@@ -1064,3 +1064,44 @@
^[[0;1;35mA/Enter^[[0m Back
=== plan-record
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
For nintendo-gba.
^[[0;7;35m > Install^[[0m
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help
+13
View File
@@ -336,3 +336,16 @@
^[[0;2m Shortened to fit this window.^[[0m
^[[0;1;35mA^[[0m Back
=== plan-record
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
BIOS folder:
^[[0;1m~/.config/retroarch/system^[[0m
^[[0;7;35m > Install^[[0m
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
^[[0;2m 1-4 of 8^[[0m
^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help
+16
View File
@@ -414,3 +414,19 @@
^[[0;1;35mA^[[0m Back
=== plan-record
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
BIOS folder:
^[[0;1m~/.config/retroarch/system^[[0m
For nintendo-gba.
^[[0;7;35m > Install^[[0m
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
^[[0;1;35mA^[[0m Install ^[[0;2mB^[[0m Back ^[[0;2mY^[[0m Options ^[[0;2m?^[[0m Help
+25
View File
@@ -648,3 +648,28 @@
^[[0;1;35mA/Enter^[[0m Back
=== plan-record
^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
For nintendo-gba.
^[[0;7;35m > Install^[[0m
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
^[[0;1;35mA/Enter^[[0m Install ^[[0;2mB/Esc^[[0m Back ^[[0;2mY/o^[[0m Options ^[[0;2m?^[[0m Help
+40
View File
@@ -0,0 +1,40 @@
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A/Enter Install B/Esc Back Y/o Options ? Help
+12
View File
@@ -0,0 +1,12 @@
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install B Back Y Options ? Help
+15
View File
@@ -0,0 +1,15 @@
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options ? Help
+24
View File
@@ -0,0 +1,24 @@
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A/Enter Install B/Esc Back Y/o Options ? Help
+54
View File
@@ -1183,3 +1183,57 @@
A Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install B Back Y Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
+54
View File
@@ -1183,3 +1183,57 @@
A Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install B Back Y Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
+54
View File
@@ -1183,3 +1183,57 @@
A Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install B Back Y Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A Install B Back Y Options
+54
View File
@@ -1348,3 +1348,57 @@
Enter Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
Enter Install Esc Back o Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
Enter Install Esc Back o Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
Enter Install Esc Back o Options ? Help
+54
View File
@@ -1286,3 +1286,57 @@
A Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
A Install R2 Back L2 Options
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
A Install R2 Back L2 Options
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
A Install R2 Back L2 Options
+54
View File
@@ -1402,3 +1402,57 @@
Start Back
=== plan-record 40x12
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
1-4 of 8
Start Install B Back
=== plan-record 40x15
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder:
~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy from an old card or a folder
Change where
Details
Options
Quit
Start Install B Back
=== plan-record 80x24
RetroBIOS: RetroArch
1 file to download, 16.0 KB
BIOS folder: ~/.config/retroarch/system
For nintendo-gba.
> Install
Same choice as on 3 Nov: yes
Choose systems
Copy the BIOS files from an old card or a folder
Change where
Details
Options
Quit
Start Install B Back
+16 -2
View File
@@ -6954,10 +6954,19 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
# The last transfer: every byte came over the network.
self.assertEqual([e for e in events if e["event"] == "transfer"][-1],
{"event": "transfer", "bytes_local": 0, "bytes_network": 245})
record = next(e for e in events if e["event"] == "record")
# What the run placed, and its choice, the whole list: written
# right before the summary.
self.assertEqual({key: record[key] for key in ("folder", "status", "choice", "files")},
{"folder": str(self.bios), "status": "written",
"choice": ["--all-files"], "files": 3})
self.assertEqual(record["path"], str(self.home / install.BACKUP_DIR
/ f"{self.bios.name}.install.json"))
self.assertEqual([e for e in events if e["event"] not in ("progress", "transfer")],
self._head("install", installer) + [
{"event": "download", "platform": "retroarch", "files": 3, "bytes": 245},
*done,
record,
{"event": "summary", "mode": "install", "installed": 3, "up_to_date": 1,
"pending": 0, "failed": [], "skipped": self.SKIPPED, "left_out": [],
"kept": [{"folder": kept[0]["folder"], "files": 1, "bytes": 11}],
@@ -7565,7 +7574,11 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
"--no-input", "--jobs", "1", "--verbose")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertEqual((self.bios / "b.bin").read_bytes(), self.FILES["bios/A/b.bin"])
runs = list((self.home / install.BACKUP_DIR).iterdir())
# The install record sits beside the run folders, a file of its own.
held = list((self.home / install.BACKUP_DIR).iterdir())
runs = [path for path in held if path.is_dir()]
self.assertEqual(sorted(path.name for path in held if not path.is_dir()),
[f"{self.bios.name}.install.json"])
self.assertEqual(len(runs), 1)
self.assertIn("Kept 1 file already there (11 B), which the collection's copies "
f"replaced, in {runs[0]}", proc.stdout)
@@ -7594,7 +7607,8 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
again = _install_run(self.env, "--platform", "retroarch", "--dest", str(self.bios),
"--no-input", "--jobs", "1")
self.assertEqual(again.returncode, 0, again.stderr)
self.assertEqual(len(list((self.home / install.BACKUP_DIR).iterdir())), 2)
self.assertEqual(len([path for path in (self.home / install.BACKUP_DIR).iterdir()
if path.is_dir()]), 2)
+175 -34
View File
@@ -41,15 +41,17 @@ ESC = "\x1b"
# (keys to start the install or reach the answer, keys in all). One system
# is reached through None and the filter, the other device's folder typed
# as ~/bios.
# as ~/bios. With one setup found the screen opens on its plan: one key
# fewer, and one more for another device, whose Esc goes back to the
# setups. The second run of one system takes its install record's choice.
JOURNEY_KEYS = {
"first_install": (2, 3),
"card_with_games": (2, 3),
"update": (2, 3),
"one_system": (14, 15),
"one_system_again": (2, 3),
"what_is_missing": (5, 6),
"another_device": (19, 20),
"first_install": (1, 2),
"card_with_games": (1, 2),
"update": (1, 2),
"one_system": (13, 14),
"one_system_again": (1, 2),
"what_is_missing": (4, 5),
"another_device": (20, 21),
}
# Words each journey's screens show by default, summed over the screens it
@@ -58,13 +60,15 @@ JOURNEY_KEYS = {
# word: its length is the machine's. A change that adds words raises a
# number here and says why.
JOURNEY_WORDS = {
"first_install": 63,
"card_with_games": 106,
"update": 79,
"one_system": 193,
"one_system_again": 68,
"what_is_missing": 223,
"another_device": 230,
"first_install": 47,
"card_with_games": 87,
"update": 70,
"one_system": 179,
"one_system_again": 67,
"what_is_missing": 207,
# The plan of the setup found comes first now (33 words), the setups
# one Esc away, and the last plan names Change where (2 words).
"another_device": 265,
}
# Which setup? names every setup, Preview every file: lists, not prose.
SCREEN_CEILING = 90
@@ -75,7 +79,9 @@ LINE_TEXT = {
"card_with_games": (4, 29),
"update": (5, 36),
"one_system": (4, 18),
"what_is_missing": (3, 15),
# After one system installed, the check counts the install record's
# choice: one line says so, and how to count every file.
"what_is_missing": (4, 25),
"another_device": (4, 18),
}
@@ -221,10 +227,9 @@ class Journeys(unittest.TestCase):
repo = Repository(self)
dest = repo.retroarch_here()
term = self.start(repo)
screen = term.wait("Where to install?", timeout=40)
self.assertIn("> RetroArch", screen)
self.press(term, "\r")
term.wait("files to download", timeout=40)
# The one setup found: its plan first.
screen = term.wait("files to download", timeout=40)
self.assertIn("Change where", screen)
self.press(term, "\r")
started = self.keys
self.assertEqual(self.finish(term), 0)
@@ -241,8 +246,6 @@ class Journeys(unittest.TestCase):
(card / "ROMS" / "GBA").mkdir(parents=True)
(card / "ROMS" / "GBA" / "unknown.gba").write_bytes(b"not in any catalog")
term = self.start(repo, env={"JOURNEY_CARD": str(card)})
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
screen = term.wait("For the games on this card: yes", timeout=60)
self.assertIn("For the 1 game on this card.", screen)
self.press(term, "\r")
@@ -265,8 +268,6 @@ class Journeys(unittest.TestCase):
changed["size"] += 1
repo.write_list()
term = self.start(repo)
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
term.wait("1 file to download", timeout=40)
self.press(term, "\r")
started = self.keys
@@ -278,8 +279,6 @@ class Journeys(unittest.TestCase):
repo = Repository(self)
dest = repo.retroarch_here()
term = self.start(repo)
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
term.wait("files to download", timeout=40)
for _step in range(12):
if "> Choose systems" in term.settle():
@@ -298,24 +297,25 @@ class Journeys(unittest.TestCase):
self.assertEqual(self.finish(term), 0)
self.counted("one_system", started, self.keys)
self.assertEqual(written(dest), ["gba_bios.bin"])
# The next release: the earlier choice is not kept, and two keys
# install the whole list.
# The next release changes its file: the plan opens on the same
# choice, read from the install record, and one key installs it.
changed = next(e for e in repo.listed["files"] if e["dest"] == "gba_bios.bin")
changed["size"] += 1
repo.write_list()
term = self.start(repo)
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
term.wait("files to download", timeout=40)
screen = term.wait("1 file to download", timeout=40)
self.assertRegex(screen, r"Same choice as on \d+ [A-Z][a-z]{2}: yes")
self.press(term, "\r")
started = self.keys
self.assertEqual(self.finish(term), 0)
self.counted("one_system_again", started, self.keys)
self.assertEqual(written(dest), sorted(e["dest"] for e in repo.listed["files"]))
self.assertEqual(written(dest), ["gba_bios.bin"])
self.assertEqual((dest / "gba_bios.bin").stat().st_size, changed["size"])
def test_what_is_missing(self):
repo = Repository(self)
repo.retroarch_here()
term = self.start(repo)
term.wait("Where to install?", timeout=40)
self.press(term, "\r")
term.wait("files to download", timeout=40)
self.press(term, "o")
term.wait("Each row is an option")
@@ -332,6 +332,9 @@ class Journeys(unittest.TestCase):
repo = Repository(self)
repo.retroarch_here()
term = self.start(repo)
# The plan of the one setup found first: Esc goes back to the setups.
term.wait("files to download", timeout=40)
self.press(term, ESC)
term.wait("Where to install?", timeout=40)
for _step in range(12):
if "> Another setup or folder" in term.settle():
@@ -478,5 +481,143 @@ class LineModeJourneys(unittest.TestCase):
self.run_line(repo, *base, "--check", "--verbose").stdout)
def events(proc: subprocess.CompletedProcess, kind: str | None = None) -> list[dict]:
said = [json.loads(line) for line in proc.stdout.splitlines() if line.startswith("{")]
return [event for event in said if kind is None or event["event"] == kind]
class InstallRecord(unittest.TestCase):
"""What an install placed, recorded beside the BIOS folder, and its
choice taken by the next run when nothing on the command line chose."""
def setUp(self):
self.repo = Repository(self)
self.dest = self.repo.root / "retroarch" / "system"
self.record = self.dest.parent / install.BACKUP_DIR / "system.install.json"
def run_line(self, *args: str, dest: Path | None = None) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
"--dest", str(dest or self.dest), *args],
env=self.repo.env, capture_output=True, text=True, timeout=120, check=False,
stdin=subprocess.DEVNULL)
def dests(self, proc: subprocess.CompletedProcess) -> list[str]:
return sorted(event["dest"] for event in events(proc, "file"))
def everything(self) -> list[str]:
return sorted(entry["dest"] for entry in self.repo.listed["files"])
def test_the_record_holds_each_file_written_and_its_hash(self):
proc = self.run_line("--system", "nintendo-gba", "--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
said = events(proc, "record")
self.assertEqual([event["status"] for event in said], ["written"])
self.assertEqual(said[0]["choice"], ["--system", "nintendo-gba"])
self.assertEqual((said[0]["path"], said[0]["files"]), (str(self.record), 1))
# Before the summary, its keys those of EVENT_KEYS.
kinds = [event["event"] for event in events(proc)]
self.assertEqual(kinds.index("record") + 1, kinds.index("summary"))
self.assertEqual(list(said[0])[1:], list(install.EVENT_KEYS["record"]))
kept = json.loads(self.record.read_text(encoding="utf-8"))
files = kept["setups"]["retroarch"]["files"]
self.assertEqual([item["dest"] for item in files], ["gba_bios.bin"])
self.assertEqual(files[0]["sha1"], hashlib.sha1(
(self.dest / "gba_bios.bin").read_bytes()).hexdigest())
self.assertEqual((files[0]["from"], files[0]["root"]), ("download", ""))
self.assertEqual(kept["folder"], str(self.dest))
# Nothing of it in the BIOS folder, which some cores read whole.
self.assertEqual(written(self.dest), ["gba_bios.bin"])
def test_the_next_run_takes_the_recorded_choice(self):
self.run_line("--system", "nintendo-gba", "--yes")
(self.dest / "gba_bios.bin").unlink()
proc = self.run_line("--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stderr)
kinds = [event["event"] for event in events(proc)]
self.assertLess(kinds.index("record"), kinds.index("choices"))
applied = events(proc, "record")[0]
self.assertEqual((applied["status"], applied["choice"]),
("applied", ["--system", "nintendo-gba"]))
self.assertEqual(self.dests(proc), ["gba_bios.bin"])
self.assertEqual(written(self.dest), ["gba_bios.bin"])
(self.dest / "gba_bios.bin").unlink()
proc = self.run_line("--yes")
self.assertRegex(proc.stdout, r"Installing 1 file \(.*\) for RetroArch, same choice "
r"as on \d+ [A-Z][a-z]{2}\.")
def test_the_command_line_replaces_the_record(self):
self.run_line("--system", "nintendo-gba", "--yes")
self.assertEqual(self.dests(self.run_line("--system", "sega-megadrive", "--check",
"--events")),
sorted(e["dest"] for e in self.repo.listed["files"]
if "sega-megadrive" in e.get("systems", [])))
whole = self.run_line("--all-files", "--check", "--events")
self.assertEqual(self.dests(whole), self.everything())
self.assertEqual(events(whole, "record"), [])
def test_a_record_that_cannot_be_read_is_said_once_and_ignored(self):
self.dest.mkdir(parents=True)
self.record.parent.mkdir()
self.record.write_text("{not json", encoding="utf-8")
proc = self.run_line("--yes", "--events")
self.assertEqual(proc.returncode, 0, proc.stderr)
said = events(proc, "record")
self.assertEqual([event["status"] for event in said], ["unreadable", "written"])
self.assertIn("is damaged, so it is ignored.", said[0]["message"])
# Said once to a person (standard error under --events), once as an event.
self.assertEqual(proc.stderr.count("so it is ignored"), 1)
self.assertEqual(self.dests(proc), self.everything())
# Replaced by a record that reads.
self.assertEqual(json.loads(self.record.read_text())["schema"], install.RECORD_SCHEMA)
for text in ('{"schema": 99, "setups": {}}', '{"schema": 1, "setups": {"retroarch": 3}}',
"\xff\xfe"):
with self.subTest(record=text):
self.record.write_bytes(text.encode("latin-1"))
proc = self.run_line("--check", "--events")
self.assertEqual(events(proc, "record")[0]["status"], "unreadable")
self.assertEqual(self.dests(proc), self.everything())
def test_a_record_for_another_folder_is_not_applied(self):
self.run_line("--system", "nintendo-gba", "--yes")
# A sibling folder shares the holder, never the record.
other = self.dest.parent / "bios"
proc = self.run_line("--check", "--events", dest=other)
self.assertEqual(events(proc, "record"), [])
self.assertEqual(self.dests(proc), self.everything())
# The record copied under the sibling's name still names the first.
shutil.copy(self.record, self.record.with_name("bios.install.json"))
proc = self.run_line("--check", "--events", dest=other)
self.assertEqual(events(proc, "record"), [])
self.assertEqual(self.dests(proc), self.everything())
# The record of another setup in the same folder is not this one's.
proc = subprocess.run(
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "batocera",
"--dest", str(self.dest), "--check", "--events"],
env=self.repo.env, capture_output=True, text=True, timeout=120, check=False,
stdin=subprocess.DEVNULL)
self.assertEqual(events(proc, "record"), [])
self.assertEqual(self.dests(proc), self.everything())
def test_line_mode_chooses_what_the_screen_chooses(self):
"""The plan's choice from the record is the same set of files on the
screen and in line mode: the screen's options, given to a run, and a
run given none."""
self.run_line("--system", "nintendo-gba", "--yes")
(self.dest / "gba_bios.bin").unlink()
state = install.TuiState()
state.platform, state.dest = "retroarch", self.dest
install._tui_record_load(state)
self.assertTrue(install.tui_record_kept(state))
flags = [flag if value is None else f"{flag}={value}"
for flag, value in install.tui_flags(state)]
screen = self.run_line(*flags, "--dry-run", "--events")
line = self.run_line("--dry-run", "--events")
self.assertEqual(self.dests(screen), ["gba_bios.bin"])
self.assertEqual(self.dests(line), self.dests(screen))
self.assertEqual(events(line, "check")[0]["bytes_needed"],
events(screen, "check")[0]["bytes_needed"])
if __name__ == "__main__":
unittest.main()
+194 -44
View File
@@ -378,6 +378,14 @@ def screen_text(lines) -> str:
return re.sub(r"(?<=[/\\]) ", "", " ".join(" ".join(lines).split()))
def listed(state: install.TuiState) -> install.TuiState:
"""The setups found, as Back shows them from the plan the screen opens
on when it found one setup."""
if state.opened_on_plan and state.screen != "setup":
install.tui_update(state, Key("esc"))
return state
def fresh_state() -> install.TuiState:
state = install.TuiState(os_type="linux", home=HOME)
state.setups = [("retroarch", SYSTEM_DIR, "RetroArch"),
@@ -761,7 +769,9 @@ class Flow(unittest.TestCase):
mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(), "linux")
self.assertEqual((state.screen, effects), ("setup", []))
# The one setup found: its plan first, the list one Back away.
self.assertEqual((state.screen, effects), ("checking", [("fetch", "minui")]))
self.assertEqual(listed(state).screen, "setup")
label = f"MinUI SD card ({card.name})"
self.assertEqual(state.setups, [("minui", card, label)])
self.assertEqual([row.label for row in install.tui_rows(state)],
@@ -1032,9 +1042,55 @@ def golden_states() -> dict:
told = golden_states_result()
told.stack.append("details")
states["result-details"] = told
states["plan-record"] = record_state()
return states
def record_setup(choice: list[str] | None = None) -> dict:
"""A record kept on 3 November of this year: the plan says 3 Nov in
any year it runs."""
year = time.localtime().tm_year
return {"written": f"{year}-11-03T12:00:00Z", "list": {"version": "v1", "generated": ""},
"choice": ["--system", "nintendo-gba"] if choice is None else choice,
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "0" * 40, "size": 16384,
"from": "download"}]}
def record_state(setup: dict | None = None) -> install.TuiState:
"""The plan of a RetroArch folder whose record kept Game Boy Advance,
its file changed by a release since, opened on the one setup found."""
setup = record_setup() if setup is None else setup
state = fresh_state()
state.setups = state.setups[:1]
manifest = json.loads(json.dumps(MANIFEST))
manifest["files"][2]["sha1"] = "1" * 40
with mock.patch.object(install, "setup_record",
return_value=(setup, SYSTEM_DIR.parent / "x", "")), \
mock.patch.object(install.Path, "exists", return_value=True), \
mock.patch.object(install, "found_setups",
return_value=(state.setups, set())), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_DETECTION_REFUSED", []), \
mock.patch.object(install.Path, "home", return_value=HOME):
state, effects = install.tui_start(_args(), "linux")
assert effects == [("fetch", "retroarch")], effects
# Drawn as the other screens are, whatever the shell running the tests.
state.home, state.pad = HOME, "both"
effects = install.tui_receive(state, ("manifest", "retroarch", manifest))
assert effects == [("run", "check")], effects
gba = [event for event in FILES if event["dest"] == "gba_bios.bin"]
systems = [event for event in SYSTEMS if event["system"] == "nintendo-gba"]
check = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0,
"bytes_needed": 16384, "not_installable": 0}
for event in [*gba, *systems, check]:
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": ["pcsx_rearmed"]}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
return state
def golden_states_result() -> install.TuiState:
"""The result golden_states draws, a file failed and one kept."""
result = checked_state()
@@ -1075,6 +1131,102 @@ def result_states() -> dict:
}
class RecordedPlan(unittest.TestCase):
"""The plan of a folder with an install record: opened on the one setup
found, on the recorded choice, one press from installing it again."""
def row(self, state: install.TuiState, action: str) -> install.TuiRow:
return next(row for row in install.tui_rows(state) if row.action == action)
def test_opens_on_the_plan_of_the_one_setup_found(self):
state = record_state()
self.assertTrue(state.opened_on_plan)
self.assertEqual(state.stack, ["setup", "plan"])
self.assertEqual(install.tui_rows(state)[0].action, "install")
press(state, "end", "up", "up", "up")
self.assertEqual(install.tui_rows(state)[state.focus["plan"]].label, "Change where")
press(state, "enter")
self.assertEqual(state.screen, "setup")
def test_several_setups_open_the_list(self):
with mock.patch.object(install, "found_setups", return_value=(
fresh_state().setups, set())), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_DETECTION_REFUSED", []):
state, effects = install.tui_start(_args(), "linux")
self.assertEqual((state.screen, effects, state.opened_on_plan), ("setup", [], False))
def test_one_press_installs_the_recorded_choice(self):
state = record_state()
self.assertEqual(self.row(state, "record").label, "Same choice as on 3 Nov: yes")
self.assertIn("For nintendo-gba.", screen_text(
install.tui_view(state, 80, 24)))
self.assertEqual(press(state, "enter"), [("run", "install")])
argv = install.tui_argv(state, "install", "py", "install.py")
self.assertIn("--system=nintendo-gba", argv)
self.assertNotIn("--all-files", argv)
def test_no_takes_the_whole_list_and_yes_brings_it_back(self):
state = record_state()
state.focus["plan"] = install.tui_rows(state).index(self.row(state, "record"))
with mock.patch.object(install, "games_probe", return_value=None):
effects = press(state, "enter")
self.assertEqual(effects, [("run", "check")])
flags = install.tui_flags(state)
self.assertIn(("--all-files", None), flags)
self.assertFalse([flag for flag in flags if flag[0] == "--system"])
self.assertFalse(install.tui_record_kept(state))
for event in [*FILES, *SYSTEMS, CHECK]:
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}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
self.assertEqual(self.row(state, "record").label, "Same choice as on 3 Nov: no")
self.assertEqual(install.tui_rows(state)[state.focus["plan"]].action, "record")
self.assertEqual(press(state, "enter"), [("run", "check")])
self.assertIn(("--system", "nintendo-gba"), install.tui_flags(state))
self.assertTrue(install.tui_record_kept(state))
def test_the_command_line_choice_wins(self):
with mock.patch.object(install, "setup_record",
return_value=(record_setup(), SYSTEM_DIR, "")), \
mock.patch.object(install.Path, "exists", return_value=True):
state, _effects = install.tui_start(
_args(platform="retroarch", dest=str(SYSTEM_DIR), system=["sony-playstation"]),
"linux", detect=False)
self.assertEqual(install._tui_system_flags(state), [("--system", "sony-playstation")])
self.assertFalse(install.tui_record_kept(state))
self.assertEqual(install._record_rows(state), [])
def test_a_record_that_cannot_be_read_is_said_once(self):
problem = "The install record /x/system.install.json is damaged"
install._RECORD_SAID.discard("/x/system.install.json")
with mock.patch.object(install, "setup_record",
return_value=(None, Path("/x/system.install.json"), problem)), \
mock.patch.object(install.Path, "exists", return_value=True):
state, _effects = install.tui_start(
_args(platform="retroarch", dest=str(SYSTEM_DIR)), "linux", detect=False)
self.assertEqual(str(state.status), f"{problem}, so it is ignored.")
state.status, state.record_for = "", None
install._tui_record_load(state)
self.assertEqual(state.status, "")
self.assertIsNone(state.record)
self.assertNotIn("--all-files", str(install.tui_flags(state)))
def test_the_install_row_says_the_size_the_headline_says(self):
"""14930 files to download, 6.8 GB stood above Install 6.6 GB: the
data files counted in one, the copies in the other."""
big = 2 * 1024 ** 3
state = checked_state()
state.files[0] = {**state.files[0], "size": big}
state.check = {**state.check, "bytes_needed": state.check["bytes_needed"] + big,
"data_missing": 3, "data_bytes": 300 * 1024 ** 2}
headline = str(install._plan_lines(state.check, state.dest, state)[0])
size = headline.rsplit(", ", 1)[1]
self.assertEqual(install.tui_rows(state)[0].label, f"Install {size}")
class Views(unittest.TestCase):
"""Every screen as text, at the sizes a handheld and a desktop show."""
@@ -2387,6 +2539,9 @@ class CardsSpaceAndConfirm(unittest.TestCase):
mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(), "linux")
self.assertEqual(effects, [("fetch", "retroarch")])
self.assertEqual(state.dest, root / "MUOS" / "bios")
listed(state)
label = f"muOS SD card ({root.name})"
self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)])
text = screen_text(install.tui_view(state, 120, 40))
@@ -4527,6 +4682,14 @@ class WindowsHasNoPosixModules(unittest.TestCase):
# --- End to end, on a pseudo-terminal -------------------------------------
def on_setups(term: Pty) -> str:
"""The setups found, one Back from the plan the screen opens on when it
found one setup: the first screen as it was before."""
term.wait("> Install")
term.send("\x1b")
return term.wait("Where to install?")
class Pty:
"""install.py on a pseudo-terminal, its screen read back through pyte."""
@@ -4925,14 +5088,13 @@ class EndToEnd(unittest.TestCase):
self.assertAppOnCard(root, "KNULLI")
self.assertIn("--add-app", term.after())
def test_three_presses_install_everything(self):
def test_two_presses_install_everything(self):
repo = self.repo()
term = self.start()
screen = term.wait("Where to install?")
self.assertIn("> RetroArch", screen)
term.send("\r")
# The one setup found here: its plan first.
screen = term.wait("files to download")
self.assertIn("5 files to download, 30 B", screen)
self.assertIn("Change where", screen)
self.assertIn("> Install", screen)
term.send("\r")
term.wait("5 files installed.")
@@ -4979,8 +5141,6 @@ class EndToEnd(unittest.TestCase):
self.assertIn("6 files installed", proc.stdout)
else:
term = self.start()
term.wait("Where to install?")
term.send("\r")
screen = term.wait("files to download")
self.assertIn("6 files to download", screen)
# The second folder the run writes to, named on the
@@ -5159,7 +5319,7 @@ class EndToEnd(unittest.TestCase):
def test_ctrl_c_on_the_first_screen(self):
self.repo()
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
term.send("\x03")
self.assertEqual(term.close(), 130)
self.assertIn(b"\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l", term.raw)
@@ -5169,7 +5329,7 @@ class EndToEnd(unittest.TestCase):
self.repo()
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
mark = len(term.raw)
os.kill(term.pid, signal.SIGTSTP)
end = time.monotonic() + 10
@@ -5182,7 +5342,7 @@ class EndToEnd(unittest.TestCase):
self.assertIn(b"\x1b[?25h\x1b[?1049l", term.raw[mark:])
mark = len(term.raw)
os.kill(term.pid, signal.SIGCONT)
term.wait("A/Enter Continue")
term.wait("A/Enter Install")
end = time.monotonic() + 5
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
@@ -5194,11 +5354,11 @@ class EndToEnd(unittest.TestCase):
"""The terminal gone, the farewell has nowhere to go: the code is
129 (SIGHUP), not 120 from the flush failing at exit."""
self.repo()
for screen_name, keys_first in (("Where to install?", ()),
("files to download", ("\r",))):
for screen_name, keys_first in (("files to download", ()),
("Where to install?", ("\x1b",))):
with self.subTest(screen=screen_name):
term = self.start()
term.wait("Where to install?")
term.wait("files to download")
term.send(*keys_first)
term.wait(screen_name)
os.close(term.fd)
@@ -5247,12 +5407,12 @@ class EndToEnd(unittest.TestCase):
self.repo()
self.env[install.TUI_IDLE_ENV] = "1"
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
self.assertEqual(term.close(), install.QUIT_CODE)
self.assertIn("No button pressed, closing.", term.after())
# A key pressed, the screen stays: the person is there.
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
term.send(DOWN)
end = time.monotonic() + 2.5
while time.monotonic() < end:
@@ -5282,7 +5442,7 @@ class EndToEnd(unittest.TestCase):
with self.subTest(env=env):
self.env.update(env)
term = self.start(cols=40, rows=12)
term.wait("Where to install?")
on_setups(term)
screen = term.wait(bar)
self.assertEqual(screen.splitlines()[-1].strip(), bar)
term.send("q")
@@ -5293,7 +5453,7 @@ class EndToEnd(unittest.TestCase):
def test_resize_redraws(self):
self.repo()
term = self.start(cols=40, rows=15)
term.wait("Where to install?")
on_setups(term)
term.resize(100, 30)
term.send(DOWN)
screen = term.wait("> Another setup or folder")
@@ -5379,7 +5539,7 @@ class EndToEnd(unittest.TestCase):
self.env.pop("NO_COLOR", None)
self.env.update({"TERM": "xterm", **env})
term = self.start()
screen = term.wait("Where to install?")
screen = on_setups(term)
self.assertIn("> RetroArch", screen)
screens[name] = screen
# pyte reads the colours drawn: RetroBIOS in the accent.
@@ -5612,7 +5772,7 @@ class TheTerminalComesBack(unittest.TestCase):
def ended(self, how) -> Pty:
self.repo()
term = self.start()
term.wait("Where to install?")
term.wait("> Install")
# Every way out below starts with the clicks asked for, written
# right after the frame.
term.settle()
@@ -5672,7 +5832,7 @@ class TheTerminalComesBack(unittest.TestCase):
job control it stops, and fg brings the screen back."""
term = self.shell()
self.typed(term, f"{shlex.quote(sys.executable)} {REPO_ROOT / 'install.py'} --tui")
term.wait("Where to install?")
term.wait("> Install")
mark = len(term.raw)
term.send("\x1a")
end = time.monotonic() + 10
@@ -5687,7 +5847,7 @@ class TheTerminalComesBack(unittest.TestCase):
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
self.assertIn(b"\x1b[?1049h", term.raw[mark:])
term.wait("Where to install?")
term.wait("> Install")
self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO)
# The clicks come back with the screen.
term.settle()
@@ -5709,7 +5869,7 @@ class TheTerminalComesBack(unittest.TestCase):
term = Pty(["--tui"], self.env, program=program)
self.addCleanup(lambda term=term: term.status is None
and os.killpg(term.pid, 9))
term.wait("Where to install?")
term.wait("> Install")
term.send("\x1a")
screen = term.wait("Ctrl-Z is ignored")
self.assertIn("no shell started this installer", " ".join(screen.split()))
@@ -5765,8 +5925,6 @@ class TheTerminalComesBack(unittest.TestCase):
self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9))
term.wait("PROMPT$")
self.typed(term, f"sh {REPO_ROOT / 'install.sh'} --jobs 1")
term.wait("Where to install?")
term.send("\r")
term.wait("to download")
term.send("\r")
term.wait("Installing")
@@ -10803,7 +10961,7 @@ class MouseOnAPty(unittest.TestCase):
def test_taps_choose_a_row_and_press_the_bar(self):
self.repo()
term = self.start()
term.wait("Where to install?")
on_setups(term)
term.settle()
self.assertIn(install._MOUSE_ON.encode(), term.raw)
row = self.line_of(term, "Another setup")
@@ -11455,8 +11613,6 @@ class TapsOnAPty(unittest.TestCase):
def test_taps_on_systems_tick_and_write_nothing(self):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
row = self.line_of(term, "Choose systems")
self.click(term, 20, row)
@@ -11482,8 +11638,6 @@ class TapsOnAPty(unittest.TestCase):
def test_the_result_is_left_by_a_tap_on_exit(self):
self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("files to download")
row = self.line_of(term, "> Install")
self.click(term, 20, row)
@@ -12365,8 +12519,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
def test_one_tap_on_the_bar_install_installs_nothing(self):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
term.settle()
self.click(term, *self.bar_entry(term, "Install"))
@@ -12380,7 +12532,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
def test_a_double_tap_on_continue_neither_quits_nor_installs(self):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
on_setups(term)
term.settle()
col, line = self.bar_entry(term, "Continue")
report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True)
@@ -12402,7 +12554,7 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
with self.subTest(gap=gap):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
on_setups(term)
term.settle()
time.sleep(install.TUI_TAP_SETTLE)
col, line = self.bar_entry(term, "Continue")
@@ -12422,8 +12574,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
def test_continue_then_install_in_one_cell_stops_on_confirm(self):
repo = self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
row = self.line_of(term, "Choose systems")
self.click(term, 20, row)
@@ -12465,8 +12615,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
repo = self.repo(GameDataRepository)
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
for _step in range(8):
if "> Choose systems" in term.settle():
@@ -12508,8 +12656,6 @@ class TapsOnAPtySeeFirst(unittest.TestCase):
def test_two_taps_on_a_region_untick_it_where_it_was(self):
self.repo()
term = self.start()
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
term.send("o")
term.wait("Preferred regions")
@@ -12662,8 +12808,10 @@ class ChoiceStateInBothModes(unittest.TestCase):
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install.Path, "home", return_value=home):
state, _effects = install.tui_start(_args(), "linux")
text = self.screen(state)
# Named on the first screen, its plan one Enter away.
# Its plan first, the setups one Back away.
self.assertEqual(state.dest, card / "bios")
text = self.screen(listed(state))
# Named on the setups, its plan one Enter away.
self.assertIn(f"> ArkOS SD card ({name}", text)
self.assertNotIn("Nothing was found here", text)
install.tui_update(state, Key("enter"))
@@ -12977,7 +13125,7 @@ class DriveIsACardByItsFiles(unittest.TestCase):
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install.Path, "home", return_value=self.root / "home"):
state, _effects = install.tui_start(_args(), "linux")
return state
return listed(state)
def folder_screen(self, mounts: list) -> tuple[install.TuiState, str, list]:
"""RetroArch chosen on the first screen: (state, Which folder? at
@@ -13176,8 +13324,6 @@ class TheSettleRunsFromTheFrameShown(unittest.TestCase):
env = {**self.env, "SLOW": "0.5"}
term = Pty([], env, 80, 24, program=[sys.executable, str(wrapper)])
self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9))
term.wait("Where to install?")
term.send("\r")
term.wait("> Install")
term.settle()
term.send("o")
@@ -14949,6 +15095,10 @@ SCREEN_WORDS = {
"quit": (14, 14, 12, 12), "help": (24, 26, 26, 26), "plan-games": (36, 46, 54, 54),
"games": (36, 45, 44, 44), "plan-details": (30, 30, 29, 29),
"result-details": (30, 30, 19, 19),
# The plan's words, plus the recorded choice by name, its row (the one
# press back to the whole list) and Change where (back to the setups,
# the plan having opened on the one found).
"plan-record": (38, 42, 42, 42),
}
+5 -4
View File
@@ -68,7 +68,8 @@ On Batocera, Recalbox and similar setups, see
## 2. Check the setup it found
It opens a menu:
With one setup found, it opens on step 3, its folder on the second line.
With none or several, it asks:
```
RetroBIOS: Where to install?
@@ -80,8 +81,8 @@ It opens a menu:
- **Right folder?** Press Enter.
- **Wrong folder, or a handheld's SD card?** Choose
**Another setup or folder**. Press / and type the device (Miyoo Mini),
or browse.
**Another setup or folder** (**Change where** from step 3).
Press / and type the device (Miyoo Mini), or browse.
Arrows move, Enter chooses, Esc goes back (A and B on a handheld). On a
touch screen, tap a row twice to open it, once to tick a box.
@@ -89,7 +90,7 @@ touch screen, tap a row twice to open it, once to tick a box.
## 3. Install
Press Enter on **Install**. **Choose systems** picks the consoles to
keep.
keep. Run it again later: it keeps that choice, one press.
**Game data** (only a game reads it) has its own row.
+55 -3
View File
@@ -169,8 +169,13 @@ Enter checks the BIOS folder when nothing can be copied.
## The full-screen interface { #the-full-screen-interface }
Run at a terminal, the installer opens a full screen that a keyboard or a
controller drives. Three presses of A or Enter install everything the setup
reads: the setup found here, the plan, then the result. The plan screen says
controller drives. With one setup found here, it opens on that setup's plan,
and two presses of A or Enter install everything it reads: the plan, then the
result. **Change where** (or B) goes back to the setups, which open first when
none or several are found. After an install, the plan of the same folder opens
on the same choice (**Same choice as on 10 Nov: yes**, the
[install record](#the-install-record)), and **no** takes the whole list, or the
games of the card when it holds some. The plan screen says
what it installs, its size and where, before anything is written. **Details**
(`d`) adds the rest: how many files are in place, the free space, the files
left out and why.
@@ -371,6 +376,8 @@ file list is downloaded once and handed to each run.
that archive in use and downloads its own copy. A file already there with other contents
is moved aside first, see [Files already in the folder](#files-you-already-had).
8. Copies into standalone emulator directories, only with `--standalone-copies`.
9. Writes the [install record](#the-install-record): each file it placed, with
its SHA-1, and the choice it made.
The file list names the version of the platform it was built for (Batocera
43.1, RetroBat 8.3.1). Where the system writes its own version in a file, the
@@ -444,6 +451,50 @@ ones, grouped by system with the emulators that read them, and point at the
emulator pages. They do not change the exit code of `--check`, since no run of
the installer can fetch them.
## The install record { #the-install-record }
An install that places files writes what it placed in a record beside the BIOS
folder: `retrobios-backup/<folder name>.install.json`, in the same holder as
the [backups](#files-you-already-had). A folder with nothing writable beside it
(a drive's root) keeps it in `retrobios-backup` inside the folder. It is never
loose in the BIOS folder, which DuckStation, SwanStation and Rustation read
whole. On a card it travels with the card.
The record is JSON, one entry per setup:
```json
{"schema": 1, "installer": "48e57b2f26e2",
"folder": "/home/me/.config/retroarch/system",
"setups": {"retroarch": {
"written": "2026-11-10T09:48:36Z",
"list": {"version": "v1.22.2", "generated": "2026-11-10T01:52:59Z"},
"choice": ["--system", "nintendo-gba"],
"files": [{"dest": "gba_bios.bin", "root": "", "sha1": "4176d5fc...",
"size": 16384, "from": "download"}]}}}
```
`files` holds each file this run and the earlier ones wrote, with the SHA-1
checked before it was moved into place: `root` is empty for the BIOS folder,
`save` for the save directory and `data` for a data tree's member, and `from`
says `download`, `copy` (this machine, `--from`) or `pack`. A file that was
already in place is not added: the record names only what the installer wrote.
`choice` is the options of the run that decided what to install, `--all-files`
for the whole list.
The next run for the same setup and folder takes that choice when nothing on
the command line chose: line mode installs the same systems
(`Installing 1 file (16.0 KB) for RetroArch, same choice as on 10 Nov.`), and
the full screen opens on it (**Same choice as on 10 Nov: yes**). Any option of
the choice (`--system`, `--core`, `--region`, `--for-games`, `--no-game-data`,
`--target`, `--every-board`, `--emulator`, `--installed-cores`, `--declared-only`)
replaces it, `--all-files` installs the whole list, and a run over several
setups or from a file list (`--manifest`) never reads it. `--check` and
`--dry-run` read it too, and say so. To forget it, delete the file. A record
that cannot be read, or that another version wrote, is said once and ignored,
and the next install writes a new one. A record kept for a folder of another
name (copied or renamed with it) is not applied. With `--events` the record
gives a `record` event, see [Machine-readable events](#machine-readable-events).
## Reading a run { #reading-a-run }
<!-- generated: run-date -->
@@ -1885,11 +1936,12 @@ other schemas under `api/v1/schemas/`.
| `downloaded` | `dest`, `ok`, `reason`, `size`, `files_done`, `files_total`, `bytes_done`, `bytes_total` | Each finished file, downloaded or copied from where a `found` event named it. `reason` says why one failed, and is empty otherwise |
| `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `left_out`, `kept`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply, `left_out` lists `{category, label, files, dests}` for what `--no-game-data` left out, and is empty without it. `kept` lists `{folder, files, bytes}`, one per backup folder, for the files in place the run moved aside before replacing them (with `--dry-run`, would move), and is empty when none. Not written for a listing |
| `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) |
| `record` | `platform`, `folder`, `path`, `status`, `choice`, `date`, `files`, `message` | The [install record](#the-install-record) of `folder`, kept at `path`. `status` is `applied` before `choices` when the run took its choice, nothing on the command line having chosen, `written` before `summary` once an install placed files, `unreadable` when it cannot be read (said once, ignored), or `not_written` when it could not be saved, the install itself unharmed. `choice` is the recorded options as words, `date` when it was written (UTC), `files` how many files it holds for the platform |
| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) |
<!-- /generated: events -->
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, `version` with the list's version), 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, `version` with the list's version, `record` with the install record), 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