diff --git a/install.ps1 b/install.ps1 index 44f854f60..37ec12874 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511" + $defaultInstallSha256 = "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 06df81429..e81a361ed 100644 --- a/install.py +++ b/install.py @@ -485,6 +485,21 @@ def format_duration(seconds: float) -> str: return f"{hours}:{minutes:02}:{secs:02}" if hours else f"{minutes}:{secs:02}" +# The fewest columns a line question lays itself out in: below it a row is +# all number, and the terminal folds what is printed whatever it is. +LINE_MIN_COLS = 20 + + +def terminal_size() -> tuple[int, int]: + """(columns, lines) of the terminal line mode prints to, the one reading + every layout of it makes. Python 3.8 gives a pty of no size as 0 and 0, + where 3.11 falls back to 80 and 24: a list laid out in 0 columns never + ended, the plan printed an empty heading, and the progress line lost + every figure but its percentage (code_audit_23 P3-5).""" + size = shutil.get_terminal_size((80, 24)) + return max(size.columns or 80, LINE_MIN_COLS), size.lines or 24 + + class Progress: """Bytes and files across the parallel downloads, shown as they move. @@ -598,7 +613,7 @@ class Progress: # line the cursor is on, so a wrapped line left a ghost per redraw # on an 80-column console. The bar gives way first, then the least # telling figures. - width = shutil.get_terminal_size((80, 24)).columns - 1 + width = terminal_size()[0] - 1 while True: tail = " ".join(parts) room = width - len(tail) - 6 @@ -4734,8 +4749,45 @@ def say_saves(plat: str, files: list[dict], omitted: list[dict], bios_path: Path print(f" {line}") -def _classify_local(entry: dict, dest: Path) -> str: - """Return 'up_to_date' or 'mismatched' for a file already on disk.""" +# The digests the line menu's head read of the files in place, by path and +# by what stat says of the file, kept for the check that follows Enter: the +# head named a wrong copy of the right size in place (code_audit_23 P3-2), +# and a card read twice is read at the speed of its reader. Emptied once +# that check has read them (head_reads_done). +_HEAD_READS: dict[tuple, dict[str, str]] = {} + + +def _stat_key(path: Path) -> tuple | None: + """What tells a file apart from the same path rewritten: None for none.""" + try: + info = path.stat() + except OSError: + return None + return (str(path), info.st_size, info.st_mtime_ns, info.st_ctime_ns, info.st_ino) + + +def _read_digests(path: Path, algorithms: tuple[str, ...], remember: bool = False) -> dict: + """_digest_file, answered from what the head read when the file is the + same one, and remembered for the check when remember is set.""" + key = _stat_key(path) + known = _HEAD_READS.get((key, algorithms)) if key is not None else None + if known is not None: + return known + digests = _digest_file(path, algorithms) + if remember and key is not None: + _HEAD_READS[(key, algorithms)] = digests + return digests + + +def head_reads_done() -> None: + """The check after the head has read what it holds: nothing more is + answered from it, a file the run writes being read again.""" + _HEAD_READS.clear() + + +def _classify_local(entry: dict, dest: Path, remember: bool = False) -> str: + """Return 'up_to_date' or 'mismatched' for a file already on disk. + remember keeps what was read for the check after the line menu.""" expected = { name: entry.get(name, "").lower() for name in ("sha256", "sha1") @@ -4754,7 +4806,7 @@ def _classify_local(entry: dict, dest: Path) -> str: if not expected: return "up_to_date" try: - actual = _digest_file(dest, tuple(expected)) + actual = _read_digests(dest, tuple(expected), remember) except OSError: # Unreadable (no permission, a read error on a failing card): its # contents are not known to be the right ones. @@ -5700,6 +5752,22 @@ _VALUED_OPTIONS = ( # The options a choice at the questions sets, which replace the command's. _CHOICE_OPTIONS = ("--system", "--core", "--region") +# The order every command given back writes its options in: the list, what +# narrows it, where files are copied from, then how the run goes. The screen +# wrote --manifest before --from and line mode after it, for one choice +# (tui_audit_16 P3-14). An option not named here comes after them. +_COMMAND_ORDER = ( + "--manifest", "--from-pack", "--system", "--core", "--emulator", "--region", + "--target", "--no-game-data", "--installed-cores", "--cores-dir", "--every-board", + "--declared-only", "--standalone-copies", "--save-dir", "--jobs", "--from", + "--add-app", "--offline", "--check", "--dry-run", +) + + +def _command_rank(option: tuple[str, str | None]) -> int: + flag = option[0] + return _COMMAND_ORDER.index(flag) if flag in _COMMAND_ORDER else len(_COMMAND_ORDER) + class Invocation: """How this run was started, so that a hint gives the command back whole. @@ -5782,7 +5850,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 [*options, *self.chosen.get(plat, [])]: + 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)] @@ -5798,6 +5866,10 @@ EVERYTHING_IN_PLACE = "Everything is in place." # them from here, so they say what the screen shows. TUI_INSTALL_ROW = "Install" TUI_SYSTEMS_ROW = "Choose systems" +# The first screen's title: a setup found here, or none, and the list. +# which-pack names them from here. +TUI_FOUND_TITLE = "Where do you play?" +TUI_CHOOSE_TITLE = "Which setup?" def _on_read_only_mount(path: Path) -> bool: @@ -5892,8 +5964,20 @@ def _write_advice(path: Path, assumed: bool, plat: str = "", def _plural(count: int, word: str) -> str: - """Count and noun, so a run of one file does not read as machine output.""" - return f"{count} {word}" if count == 1 else f"{count} {word}s" + """Count and noun, so a run of one file does not read as machine output: + 1 file, 2 files, 1 wrong copy, 2 wrong copies.""" + if count == 1: + return f"{count} {word}" + if word.endswith("y") and word[-2:-1] not in tuple("aeiou"): + return f"{count} {word[:-1]}ies" + return f"{count} {word}s" + + +def agree(count: int, one: str, many: str) -> str: + """The verb a count takes: "1 needs", "2 need". Each count said with a + verb reads it here: "1 need the internet" stood on the plan + (code_audit_23 P3-4).""" + return f"{count} {one if count == 1 else many}" class OfflineReason(str): @@ -5960,11 +6044,14 @@ def _report_outcome( same_choice: str = "", term: Terminal | None = None, notes: list[str] | None = None, + missing: Tally | None = None, ) -> None: """Close the run on what happened, and leave with a status that says so. kinds is the count of the files installed (tally) and of the data tree files, given for a list marking its game data, said apart. notes are lines every ending repeats (a list built for another version). + missing is the count of the files --offline left out, by kind, given as + kinds is. A run that installed nothing used to end on the word Done and a zero exit, which reads as success to a person and to a script alike. offline counts @@ -6060,8 +6147,8 @@ def _report_outcome( if offline: remedy = _offline_remedy() if not downloaded and not errors: - say(f"\nNothing installed: {_plural(offline, 'file')} " - f"{'needs' if offline == 1 else 'need'} the internet, which " + say(f"\nNothing installed: {agree(offline, 'file needs', 'files need')} " + "the internet, which " f"{offline_named()} does not use.") say(f" {remedy}") for line in kept: @@ -6073,10 +6160,10 @@ def _report_outcome( say(line) sys.exit(2) failed = f", {errors} failed" if errors else "" - if downloaded and sources and not errors: + if downloaded and sources: # One count, the remedy under it, as the screen's result: the # count came twice, "installed" then "copied" (tui_audit_15 - # P3-8). + # P3-8). The failed files are in it. say(f"\nFinished with files missing. {sources}") sources = "" elif downloaded: @@ -6091,6 +6178,11 @@ def _report_outcome( say(f" {sources}") if installed_kinds: say(installed_kinds) + if missing is not None and missing.bios and missing.games: + # The kinds still missing, as the screen's result says them, + # before the remedy: "They are not in OLD" followed the files + # installed (tui_audit_16 P3-3). + say(f" Still missing: {installed_words(missing.bios, missing.games)}.") # Under the files it is about: after the save-directory line, "They" # read as that one file. say(f" {remedy}") @@ -6120,10 +6212,12 @@ def _report_outcome( sys.exit(2) if errors: - say(f"\nFinished with errors. {_plural(downloaded, 'file')} installed, " + # One count: "42 files installed, 2 files failed." over "38 files + # copied from E:\, 4 downloaded, 2 still missing." said it twice, + # the failed files as missing ones (tui_audit_16 P3-11). + say(f"\nFinished with errors. {sources}" if sources else + f"\nFinished with errors. {_plural(downloaded, 'file')} installed, " f"{_plural(errors, 'file')} failed.") - if sources: - say(f" {sources}") if installed_kinds: say(installed_kinds) say(f"BIOS folder: {_locations(paths)}") @@ -6223,7 +6317,7 @@ def _check_fetch_line(fetch: int, beside: str, lacking: int = 0) -> str: if _offline_only(): if not fetch: return " Run the same command without --check to copy them into place." - return (f" {_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the " + return (f" {agree(fetch, 'file needs', 'files need')} the " f"internet, which {offline_named()} does not use. {_offline_remedy()}") return (" Run the same command without --check to fetch them. A wrong copy is " f"replaced, and the one in place kept in {BACKUP_DIR} beside {beside}.") @@ -6264,6 +6358,19 @@ def game_data_missing(names: str, count: int, folders: list[Path]) -> str: f"{'it' if one else 'them'} from your own copy of the game{where}.") +def not_installable_words(count: int, required: int, reasons: dict[str, int]) -> str: + """The line under the index count for the files the list leaves out: + "(1 required, not found: 37)" read as a sum (tui_audit_16 P3-6).""" + need = ("" if not required else ", required" if count == 1 + else f", {required} of them required") + if set(reasons) == {"not_found"}: + return (f"Not installable: {agree(count, 'file is', 'files are')} not in the " + f"collection{need}.") + why = ", ".join(f"{OMISSION_REASONS.get(reason, reason)} ({number})" + for reason, number in sorted(reasons.items())) + return f"Not installable: {_plural(count, 'file')}{need}. Why: {why}." + + def _omission_lines(count: int, entries: list[dict], bios_path: Path | None = None) -> list[str]: """What was skipped, the files an emulator cannot start without first. @@ -6441,7 +6548,7 @@ def _print_plan( for entry in entries[:shown]] if shown is not None and len(entries) > shown: listing.append(f" ... and {len(entries) - shown} more") - screen = shutil.get_terminal_size((80, 24)).lines + screen = terminal_size()[1] if verbose or len(listing) + len(trees) + PLAN_FRAME_LINES <= screen: for line in listing: print(line) @@ -6666,8 +6773,10 @@ def _archive_members( yield rel, member.size, (lambda member=member: tf.extractfile(member)) -def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str, dict], int]: - """(tree root, files missing or wrong, files already right).""" +def data_directory_state(entry: dict, bios_path: Path, + remember: bool = False) -> tuple[Path, dict[str, dict], int]: + """(tree root, files missing or wrong, files already right). remember + keeps what was read for the check after the line menu (_HEAD_READS).""" root = _destination_path(bios_path, entry["dest"]) if entry.get("dest") else bios_path.resolve() needed: dict[str, dict] = {} present = 0 @@ -6675,7 +6784,7 @@ def data_directory_state(entry: dict, bios_path: Path) -> tuple[Path, dict[str, target = _destination_path(root, rel) try: held = (target.is_file() and target.stat().st_size == meta["size"] - and _sha1_file(target) == meta["sha1"]) + and _read_digests(target, ("sha1",), remember)["sha1"] == meta["sha1"]) except OSError: held = False if held: @@ -7162,8 +7271,18 @@ def _choose_platform(title: str, default: str = "", setups: list | None = None, # the screen's focus falls (_platform_focus). default = choices[0][0] if choices else "" print(f"\n{title}") + # Wrapped at the width between words, its devices under its name: left + # to the terminal, "Miyoo Flip" was folded inside "Miy" and "oo" below + # 40 columns (tui_audit_16 P3-15). + cols = terminal_size()[0] - 1 + width = len(str(len(choices))) for i, (_name, label) in enumerate(choices, 1): - print(f" {i}) {label}") + number = f" {i:>{width}}) " + pieces = _no_widow(_wrap(label, max(cols - len(number), 8), ""), + max(cols - len(number), 8)) + print(number + pieces[0]) + for piece in pieces[1:]: + print(" " * len(number) + piece) print(" q) quit") print("Type a number, a setup's name, or your device's name (Miyoo Mini, R36S).") usage = (f" Type a number from 1 to {len(choices)}, a setup name or your " @@ -7278,6 +7397,7 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: P1-2). Another system's card or drive, and one two systems fit, are reached through their own setup (drives_left_out).""" targets = [] + cards = unnamed_cards() for mount in _mounted_cards(): found = card_system(mount) if (found is not None and found[0] != plat) or share_doubt(mount)[0]: @@ -7287,32 +7407,58 @@ def drive_targets(plat: str) -> list[tuple[str, Path]]: what = f"{found[2]} {_card_word(found)}" targets.append((f"{_drive_folder(mount, below)} ({what})", found[1])) else: - targets.append((f"{_drive_folder(mount, 'bios')} (card or drive)", mount / "bios")) + # A drive its files show to be no card is called a drive, under + # the note that says so: "card or drive" sat under "nothing on + # it shows a handheld's card" (tui_audit_16 P3-9). + what = "SD card" if mount in cards else "drive" + targets.append((f"{_drive_folder(mount, 'bios')} ({what})", mount / "bios")) # A handheld's card no file names first: its bios folder is where its # system reads (unnamed_cards). - cards = {mount / "bios" for mount in unnamed_cards()} - return sorted(targets, key=lambda target: target[1] not in cards) + first = {mount / "bios" for mount in cards} + return sorted(targets, key=lambda target: target[1] not in first) + + +def folder_drives(os_type: str, plat: str, side: bool = True) -> list[tuple[str, Path]]: + """The folders the folder question numbers for a setup that runs from no + card, in the one order both modes give them: a handheld's card in the + reader first, then the other side's usual folder (side, under WSL), + then the other drives. Line mode put Windows RetroArch before the card + the screen put first (code_audit_23 P3-6).""" + drives = [*(other_side(os_type, plat) if side else []), *drive_targets(plat)] + first = {mount / "bios" for mount in unnamed_cards()} + return sorted(drives, key=lambda target: target[1] not in first) # What a handheld system writes on the games part a computer mounts, beside # its bios folder: a drive holding these is that card, and a data drive or # a stick with an emulation library holds none of them (code_audit_22 P2-1). -# Each set is (system, folders and files that must all be there). -_GAMES_PART_MARKERS = ( - # ArkOS and dArkOS mount EASYROMS as /roms. dArkOS daecf488 creates every - # folder of game_systems.txt on it (finishing_touches.sh:420-430), - # themes (game_systems.txt:108) and launchimages (game_systems.txt:54), - # and copies loading.jpg there (finishing_touches.sh:471-472). ArkOS - # dedf4278 updates both in place (Update-RG351MP.sh:1003,1443). - ("ArkOS", ("themes", "launchimages")), +# Each rule's values must all be there, with bios/, on a drive the system +# does not call fixed (games_part). One system may have several rules: any +# one of them is its proof. +_GAMES_PART_RULES = ( + # ArkOS and dArkOS mount EASYROMS as /roms. Every default tree ArkOS + # ships for it holds bgmusic/, from the first one (christianhaitian/arkos + # 9664c869, 2020-11-30, defaultromsfolderstructure.tar) through the + # per-device trees of c14afbe5 (rg351p, rgb10, rk2020) to f41b9485, and + # an RG351P card older than them gets it from the 11232020 update + # (Update-RG351P.sh:238-242). launchimages/ is in every tree from + # 495eb210 (2020-12-16) on. dArkOS daecf488 creates both + # (game_systems.txt:23,54, finishing_touches.sh:288-290,420-430). + # themes/ is no proof: the 12262020 update left it on the system part of + # a card with under 1 GB free (Update-RGB10.sh:59-69), and 2020's trees + # hold none (code_audit_23 P3-1). + _OsRule("retroarch", "ArkOS", "games_part", ("bgmusic",), ""), + _OsRule("retroarch", "ArkOS", "games_part", ("launchimages",), ""), # AmberELEC 5a3882cb fills GAMES at every boot: homebrew/ from its ports # with gamelist.xml (packages/amberelec/sources/autostart.sh:93-94, # 110-113) and gamedata/ for RetroArch, PPSSPP, DOSBox and Hatari - # (autostart.sh:123-145). - ("AmberELEC", ("gamedata", "homebrew")), + # (autostart.sh:123-145). Its list is written below that part's root. + _OsRule(_STANDIN_MANIFESTS["AmberELEC"], "AmberELEC", "games_part", + ("gamedata", "homebrew"), ""), # EmuELEC d4c7b2f1 copies its default bezels to EEROMS at every boot # (packages/sx05re/emuelec/bin/emuelec_autostart.sh:78-81). - ("EmuELEC", ("bezels/default.cfg",)), + _OsRule(_STANDIN_MANIFESTS["EmuELEC"], "EmuELEC", "games_part", + ("bezels/default.cfg",), ""), ) # GetDriveTypeW: DRIVE_FIXED, DRIVE_REMOTE, DRIVE_CDROM and DRIVE_RAMDISK, @@ -7367,13 +7513,23 @@ def _fixed_partition(mount: Path, mountinfo: str, sys_root: Path) -> bool: return False -def _games_part_system(mount: Path) -> str: - """The handheld system whose games part mount is, as the files it - writes there say, "" for none (_GAMES_PART_MARKERS).""" - if not _is_dir(mount / "bios"): - return "" - return next((name for name, markers in _GAMES_PART_MARKERS - if all(_exists(mount / marker) for marker in markers)), "") +def _games_part_matches(rule: _OsRule, root: Path) -> bool: + """Whether root is the games part rule's system writes: its bios folder + and every value of the rule on root's own drive, on a drive the system + does not call fixed.""" + return (_is_dir(root / "bios") + and all(_exists(root / value) and _on_the_drive(root, value) + for value in rule.values) + and not _drive_not_removable(root)) + + +def games_part(root: Path) -> _OsRule | None: + """The rule naming the handheld system whose games part root is, by the + files that system writes there, None for none (_GAMES_PART_RULES). The + one reading of a games part: AmberELEC's and EmuELEC's are their own + cards everywhere (card_system, their card routes, line mode), ArkOS's a + card of a system that reads RetroArch's list (unnamed_cards).""" + return next((rule for rule in _GAMES_PART_RULES if _games_part_matches(rule, root)), None) def _unnamed_candidates() -> list[Path]: @@ -7386,18 +7542,19 @@ def _unnamed_candidates() -> list[Path]: def unnamed_cards() -> list[Path]: - """The mounted drives no file names that are a handheld's games part: - ArkOS's EASYROMS, AmberELEC's GAMES, EmuELEC's EEROMS, each told by the - folders its system writes there (_GAMES_PART_MARKERS), on a drive the - system does not call fixed. Which of the systems that read RetroArch's + """The mounted drives no file names that are a handheld's games part + read with RetroArch's list: ArkOS's EASYROMS, told by the folders it + writes there (games_part). Which of the systems that read RetroArch's list runs them cannot be told, but they are a handheld's card: "Nothing was found here" over one, and two presses of Enter filled this computer's folder for a RetroArch that was not there (tui_audit_14 P2-3). A bios folder and a console folder were the rule, and a data drive or WSL's D: disk with bios/, snes/ and Music/ took the list - (code_audit_22 P2-1).""" + (code_audit_22 P2-1). AmberELEC's GAMES and EmuELEC's EEROMS are their + systems' own cards (card_system): RetroArch's list went on them under + Enter (tui_audit_16 P2-1).""" return [mount for mount in _unnamed_candidates() - if _games_part_system(mount) and not _drive_not_removable(mount)] + if getattr(games_part(mount), "manifest", "") == "retroarch"] def unproven_drives() -> list[Path]: @@ -7407,15 +7564,49 @@ def unproven_drives() -> list[Path]: return [mount for mount in _unnamed_candidates() if mount not in cards] +def _names_and(names: list[str]) -> str: + """Names in a sentence: A, A and B, A, B and C.""" + return " and ".join(names) if len(names) < 3 else f"{', '.join(names[:-1])} and {names[-1]}" + + def unproven_words() -> str: - """The plain note on those drives, "" for none.""" + """The plain note on those drives, "" for none: what was looked for on + them, and what the reader does (tui_audit_16 P3-9).""" drives = unproven_drives() if not drives: return "" - names = ", ".join(_mount_name(mount) for mount in drives) - has, it = ("has", "it") if len(drives) == 1 else ("have", "them") - return (f"{names} {has} a bios folder, and nothing on {it} shows a handheld's card: " - "listed, not chosen.") + names = _names_and([_mount_name(mount) for mount in drives]) + has, it = ("has", "it") if len(drives) == 1 else ("have", "one") + return (f"{names} {has} a bios folder but no folder a handheld system writes. " + f"Pick {it} below if your files go there.") + + +def card_folder_words(cards: list[Path], card: Path) -> str: + """Where card, one of the handhelds' cards in the reader, keeps the + files, as both modes say it: the card named where it is one of two + (code_audit_22 P3-2), BIOS files named where "keeps them" followed the + title Which folder? and no noun (tui_audit_16 P3-9).""" + which = "The card in the reader" if len(cards) == 1 else _mount_name(card) + return f"{which} keeps BIOS files in its bios folder" + + +def _said_with(label: str, words: str) -> str: + """label with words in its parenthesis, or in one of their own: two side + by side read as a slip (tui_audit_16 P3-9).""" + return f"{label[:-1]}, {words})" if label.endswith(")") else f"{label} ({words})" + + +def numbered_folder(label: str, folder: Path) -> str: + """A folder numbered at the folder question: its row's label, then the + folder and whether it exists in one parenthesis. "DATA: bios (card or + drive) (/media/me/DATA/bios)" put two side by side (tui_audit_16 + P3-9). A label that spells its folder (the Windows side's) is not + followed by it again. Said of a folder that is missing, as the screen + says it (code_audit_21 P3-7).""" + shown = shown_path(folder) + said = [] if shown in label else [shown] + said += [] if _is_dir(folder) else ["does not exist yet"] + return _said_with(label, ", ".join(said)) if said else label def unnamed_card_words(cards: list[Path], tail: str = ".") -> str: @@ -7498,8 +7689,7 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, default = cards[0] / "bios" # Two cards: the one Enter takes is named, "The card" was one of # two (code_audit_22 P3-2). - which = "The card in the reader" if len(cards) == 1 else _mount_name(cards[0]) - print(f" {which} keeps them in its bios folder: {shown_path(default)}.") + print(f" {card_folder_words(cards, cards[0])}: {shown_path(default)}.") elif found is not None: print(f" {name} reads its BIOS files from: {shown_path(found)}.") elif default is not None: @@ -7508,23 +7698,14 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True, f"{state}.") # A drive is offered by the BIOS folder its system reads, the screen's # rows: never its root, and never for a system that reads another list. - targets = (share_targets(plat) if shared else - [*(other_side(os_type, plat) if found is None else []), *drive_targets(plat)]) + targets = (share_targets(plat) if shared else folder_drives(os_type, plat, found is None)) if cards: # The card first, this computer's usual folder after it. targets = [*targets, (f"{side_name(os_type, plat)}'s usual folder", _default_dest(os_type, plat))] targets = [target for target in targets if target[1] is not None] for index, (label, folder) in enumerate(targets, 1): - # Said of a folder that is missing, as the screen says it: "created" - # was said of folders nobody had created (code_audit_21 P3-7). - missing = "" if _is_dir(folder) else "does not exist yet" - # A label that spells its folder (the Windows side's) is not - # followed by it again. - shown = shown_path(folder) - print(f" {index}) {label}" + ( - f" ({shown}{', ' + missing if missing else ''})" if shown not in label - else f" ({missing})" if missing else "")) + print(f" {index}) {numbered_folder(label, folder)}") left_out = "" if shared else drives_left_out(plat) if left_out: print(f" {left_out}") @@ -7815,8 +7996,24 @@ def _size_lines(size: str, room: int, words: int = 0) -> list[str]: for joint in (", and ", ", +", ", all "): own, found, shared = size.partition(joint) if found: - return [*_wrap(f"{own},", room, ""), *_wrap(found[2:] + shared, room, "")] - return _wrap(size, room, "") + return [*_no_widow(_wrap(f"{own},", room, ""), room), + *_no_widow(_wrap(found[2:] + shared, room, ""), room)] + return _no_widow(_wrap(size, room, ""), room) + + +def _no_widow(lines: list[str], room: int) -> list[str]: + """Wrapped lines whose last one is not a word alone: "shared" stood on a + line of its own under its size at 40 columns (tui_audit_16 P3-5). The + word before it goes down with it where both fit.""" + if len(lines) < 2 or len(_glued_words(lines[-1])) > 1: + return lines + # A size goes down with its unit: "1.4" / "MB shared" split it. + words = _glued_words(lines[-2]) + lead = lines[-2][:len(lines[-2]) - len(lines[-2].lstrip(" "))] + moved = f"{words[-1]} {lines[-1].strip()}" + if len(words) < 2 or cell_width(moved) > room: + return lines + return [*lines[:-2], lead + " ".join(words[:-1]), moved] def _pick( @@ -7835,9 +8032,10 @@ def _pick( print(f"\n{title}") shown = {name: screen_text((labels or {}).get(name, name)) for name, _files in rows} sizes = {name: sizer(files) for name, files in rows} - # Python 3.8 gives a pty of no size as 0 columns, where 3.11 falls back: - # a list laid out in 0 columns never ended. - cols = max(shutil.get_terminal_size((80, 24)).columns or 80, 20) + # One cell short of the terminal: a line as wide as it was folded by + # some terminals, the next row then starting on its last cell + # (tui_audit_16 P3-5). + cols = terminal_size()[0] - 1 # The fewest lines, the whole words where they cost no line: one # wording for the whole list, the id dropped where it takes a tenth # more lines (40 columns). @@ -7854,8 +8052,8 @@ def _pick( if notes: # At the terminal's width too: the note on the other systems passed # 40 columns. - print("\n" + "\n".join(piece for text in notes for line in text.split("\n") - for piece in _wrap(line, cols - 1, ""))) + print("\n" + "\n".join(piece for text in notes + for piece in _no_widow(_wrap(text, cols, ""), cols))) hint = "order of preference, e.g. 2,1" if ordered else "e.g. 1,3-5" while True: text = _ask(f"Numbers ({hint}), Enter for all, q to quit: ") @@ -7898,12 +8096,17 @@ def _pick_lines(rows: list[tuple[str, list[dict]]], shown: dict, sizes: dict, lines.append(f"{number}{_pad(label, name_width)} {parts[0]}") lines += [f"{' ' * column}{part}" for part in parts[1:]] continue - whole = _size_lines(size, cols, words)[0] if words else size + # The size beside the label only where all of it fits there: its + # first part alone left "21 files, 1.4 MB," with its shared files + # unsaid (tui_audit_16 P3-5). + fitted = _size_lines(size, cols, words) + whole = fitted[0] if len(fitted) == 1 else size if cell_width(f"{number}{label} {whole}") <= cols: lines.append(f"{number}{label} {whole}") continue lines += [f"{number}{part}" if not at else f"{' ' * lead}{part}" - for at, part in enumerate(_wrap(label, cols - lead, ""))] + for at, part in enumerate(_no_widow(_wrap(label, cols - lead, ""), + cols - lead))] at = column if name_width and cols - column >= least else lead lines += [f"{' ' * at}{part}" for part in _size_lines(size, cols - at, words)] return lines @@ -7940,6 +8143,25 @@ def emptied_words(names: list[str], game_data: bool) -> str: return f"{subject} {verb} {why}: nothing is left to install." +def game_data_only(named: list[str], files: list[dict]) -> list[str]: + """The systems named with --system whose files are all game data, of + the list with its game data: --no-game-data leaves them empty.""" + return [system for system in named + if any(_chosen(entry, {system}) for entry in files) + and all(entry.get("category") == GAME_DATA for entry in files + if _chosen(entry, {system}))] + + +def game_data_only_words(titles: list[str]) -> str: + """Said on the plan and in line mode's head of those systems, before the + question: "For 1 of 8 systems (--system)" stood for two systems named + and said nothing of the other (tui_audit_16 P3-7). "" for none.""" + if not titles: + return "" + verb = "holds" if len(titles) == 1 else "hold" + return f"{_names_and(titles)} {verb} only game data: left out (--no-game-data)." + + def system_title(system: str, manifest: dict | None) -> str: """A system as the screen's Choose systems names it (_tui_system_label).""" names = ((manifest or {}).get("system_names") or {}).get(system) @@ -8096,6 +8318,7 @@ def _prompt_custom_selection( given: tuple[list[str], list[str], list[str]] | None = None, manifest: dict | None = None, narrowed: bool = False, command: Callable[[object], str] | None = None, cut: list[bool] | None = None, + note: str = "", ) -> tuple[list[str], list[str], list[str]] | str | None: """Offer to narrow the install by system, core and region. @@ -8118,11 +8341,26 @@ def _prompt_custom_selection( Enter installs. c installed as soon as the cores were answered, "Enter for all" read as a choice. narrowed says the list was narrowed before the menu (--target, --no-game-data): the data trees kept are then those - of the systems kept, as the plan and --dry-run count them. + of the systems kept, as the plan and --dry-run count them. note is said + under the head (game_data_only_words). """ trees = data_trees or [] current = given toggled = False + if bios_path is not None and not dry_run and current != INSTALLED_ONLY: + kept = menu_choice(files, omitted, installed, current, False)[0] + kept_trees = select_data_directories({"data_directories": trees}, kept, + narrowed or current is not None) + if kept and all_in_place(kept, kept_trees, bios_path): + # Read before the menu, as the screen reads before it asks: a + # rerun was offered an install that installs nothing, then told + # so twice (tui_audit_16 P3-4). The check after it reads none + # of it again (_HEAD_READS). + print(f"\n{name or display_name(plat)}: " + f"{_plural(len(kept) + sum(len(t.get('files') or {}) for t in kept_trees), 'file')}" + ", every one in place: nothing to install.") + print(f" BIOS folder: {shown_path(shown_bios_folder(bios_path, kept))}") + return current while True: cutting = bool(cut and cut[0]) shown, shown_omitted = ((leave_out_game_data(files, omitted)[:2]) @@ -8143,7 +8381,8 @@ def _prompt_custom_selection( _print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path, current, object() if toggled else given, manifest, narrowed, menu_choice(files, omitted, installed, current, cutting), - games.games if cutting else 0, emptied) + games.games if cutting else 0, emptied, + note if current is given else "") if (current is not given or toggled) and command is not None and not emptied: print(f" Same choice without questions: {screen_text(command(current))}") offline = _offline_only() @@ -8213,7 +8452,7 @@ def _print_menu_head( installed: tuple[list[dict], list[dict]] | None, name: str, trees: list[dict], bios_path: Path | None, current: object, given: object, manifest: dict | None, narrowed: bool, chosen: tuple[list[dict], list[dict]] | None = None, - cut_games: int = 0, emptied: str = "", + cut_games: int = 0, emptied: str = "", note: str = "", ) -> None: """The plan above the line menu: what the choice holds, its folder, what stands in its way and where the save files go, as the screen's plan. @@ -8263,6 +8502,8 @@ def _print_menu_head( print(f"\n{name or display_name(plat)}: " + (found or ( f"{_plural(counts.files + data, 'file')}, " f"{format_size(counts.size + data_size)}")) + named + ".") + if note: + print(f" {note}") categorized = bool((manifest or {}).get("categorized")) kinds = counts if categorized else tally({"size": e.get("size")} for e in shown) parts = parts_words(kinds, data, data_size, cut_games) @@ -8283,15 +8524,22 @@ def _print_menu_head( # screen name it: "Onion's" before the menu, "RetroArch's" after. for line in save_lines(shown, kept_omitted, bios_path, display_name(plat, manifest)): print(f" {line}") + # The required files the collection cannot supply, named as the screen's + # plan names them (tui_audit_16 P3-6). + for line in unavailable_lines([entry for entry in kept_omitted if entry.get("required")], + manifest, bios_path, (manifest or {}).get("files") or shown): + print(f" {line}") def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: """What the --from folders hold of the files not in place, and what is left, in the screen plan's words: "6 found in OLD, 16 need the - internet". "" when they hold none of them. A file in place is one of - the right size: the run hashes what is in place once, after Enter - (check_local, data_directory_state), and a card read twice is read at - the speed of its reader.""" + internet". "" when they hold none of them. A file of the wrong size is + not in place. One of the right size is read, as check_local reads it: + judged by its size, a wrong copy of the right size was in place, and + "nothing to download" stood over a run that left it (code_audit_23 + P3-2). What is read here is kept for the check after Enter + (_HEAD_READS): a card read twice is read at the speed of its reader.""" def held(path: Path, size: object) -> bool: try: @@ -8305,7 +8553,8 @@ def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: path = _entry_path(bios_path, entry) except ValueError: continue - if not held(path, entry.get("size")): + if not held(path, entry.get("size")) or \ + _classify_local(entry, path, remember=True) != "up_to_date": needed.append(entry) copies = plan_local_copies(needed, bios_path, None, LOCAL.folders) if not copies: @@ -8315,12 +8564,9 @@ def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: by_folder[copy.index.label] = by_folder.get(copy.index.label, 0) + 1 data, data_bytes = 0, 0 for tree in trees: - root = (_destination_path(bios_path, tree["dest"]) if tree.get("dest") - else bios_path) - for rel, meta in (tree.get("files") or {}).items(): - if not held(_destination_path(root, rel), meta.get("size")): - data += 1 - data_bytes += meta.get("size", 0) + _root, wanted, _present = data_directory_state(tree, bios_path, remember=True) + data += len(wanted) + data_bytes += sum(meta.get("size", 0) for meta in wanted.values()) left = [entry for entry in needed if id(entry) not in copies] rest = len(left) + data where = ", ".join(f"{count} found in {_last_name(label)}" @@ -8328,10 +8574,39 @@ def found_head(files: list[dict], trees: list[dict], bios_path: Path) -> str: if not rest: return f"{where}, nothing to download" if _offline_only(): - return f"{where}, {rest} {'needs' if rest == 1 else 'need'} the internet" + return f"{where}, {agree(rest, 'needs', 'need')} the internet" return f"{where}, {rest} to download ({format_size(_bytes(left) + data_bytes)})" +def all_in_place(files: list[dict], trees: list[dict], bios_path: Path) -> bool: + """Whether every file and every data tree file is in place, read as the + check reads it. Sizes first: a folder missing a file is told without a + read. What is read is kept for the check (_HEAD_READS).""" + paths = [] + for entry in files: + try: + path = _entry_path(bios_path, entry) + if not _is_file(path) or path.stat().st_size != entry.get("size"): + return False + except (OSError, ValueError): + return False + paths.append((entry, path)) + for tree in trees: + root = (_destination_path(bios_path, tree["dest"]) if tree.get("dest") + else bios_path.resolve()) + for rel, meta in (tree.get("files") or {}).items(): + target = _destination_path(root, rel) + try: + if not _is_file(target) or target.stat().st_size != meta.get("size"): + return False + except OSError: + return False + return (all(_classify_local(entry, path, remember=True) == "up_to_date" + for entry, path in paths) + and all(not data_directory_state(tree, bios_path, remember=True)[1] + for tree in trees)) + + def menu_choice( files: list[dict], omitted: list[dict], installed: tuple[list[dict], list[dict]] | None, current: object, cut: bool, @@ -8370,7 +8645,9 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N if len(listed) > 1 else []) # Said under the list it explains, before the question: above # "Systems:" it read as a heading (tui_audit_13 P3-13). - note = (f"'{OTHER_TITLE}' holds what cores load for systems this platform\n" + # One sentence wrapped at the width: its line break, placed for a + # shorter title, left "platform" alone on a line (tui_audit_16 P3-5). + note = (f"'{OTHER_TITLE}' holds what cores load for systems this platform " "does not list, such as game engines and extra computers." if OTHER_SYSTEMS in systems_listed else "") labels = {} @@ -9857,18 +10134,20 @@ def readers(entry: dict, manifest: dict) -> list[str]: def opened_line(entry: dict, manifest: dict, bios_path: Path) -> str: - """Where the emulators read an entry, and which ones.""" + """Where the emulators read an entry, and which ones: "read by mgba at + ...", "expected at ..." where the list names none. "opened at" under + "missing:" read as a file that was opened (tui_audit_16 P3-12).""" try: where = _entry_path(bios_path, entry) except ValueError: where = bios_path / str(entry.get("dest")) cores = readers(entry, manifest) if not cores: - return f"opened at {where}" + return f"expected at {where}" shown = ", ".join(cores[:SHOWN_CORES]) if len(cores) > SHOWN_CORES: shown += f" and {len(cores) - SHOWN_CORES} more" - return f"opened at {where} by {shown}" + return f"read by {shown} at {where}" def _known_destinations(files: list[dict]) -> set[str]: @@ -10348,22 +10627,27 @@ def emit_unreadable(plat: str, found: list[Unreadable]) -> None: def sources_line( folders: list[tuple[str, int]], fixed: int, downloaded: int, missing: int, - fetched: str = "downloaded", shared: int = 0, + fetched: str = "downloaded", shared: int = 0, wrong: int = 0, failed: int = 0, ) -> str: """Where the files of a run that copied some came from, in words: "38 files copied from E:\\, 4 downloaded, 2 still missing." A file found in the wrong place of the BIOS folder is "copied", as the plan says: - "fixed" read as a wrong copy, of files that were missing.""" + "fixed" read as a wrong copy, of files that were missing. wrong counts + the wrong copies left in place, apart from missing: "1 still missing" + was said of a file --check counts as a wrong copy (code_audit_23 P3-3). + failed counts the files a run tried and could not write or fetch.""" parts = [(count, f"copied from {screen_text(shown_path(label))}") for label, count in folders] parts.append((fixed, "copied from the wrong place in the BIOS folder")) parts.append((shared, "copied from another place of the BIOS folder")) if not any(count for count, _what in parts): return "" - parts += [(downloaded, fetched), (missing, "still missing")] + parts += [(downloaded, fetched), (failed, "failed"), (missing, "still missing")] words = [] for count, what in parts: if count: words.append(f"{_plural(count, 'file') if not words else count} {what}") + if wrong: + words.append(f"{_plural(wrong, 'wrong copy')} left") return ", ".join(words) + "." @@ -11575,10 +11859,15 @@ def _card_rules() -> list[_OsRule]: rules.append(rule._replace( values=tuple(value[len(mount):] for value in rule.values), bios=rule.bios[len(mount):], configs=())) + # The games part of a system whose own list is written below it: its + # files name the card as surely as Onion's .tmp_update (games_part). + rules += [rule for rule in _GAMES_PART_RULES if rule.name in _GAMES_PART_CARDS] return rules def _card_rule_matches(rule: _OsRule, root: Path) -> bool: + if rule.method == "games_part": + return _games_part_matches(rule, root) if rule.method == "file_matches": path, pattern = rule.values try: @@ -11816,10 +12105,12 @@ def card_setups() -> list[CardSetup]: # formats it exFAT as GAMES (packages/sysutils/busybox/scripts/ # fs-resize:64), autostart.sh:93 fills it as "the GAMES volume". EmuELEC # d4c7b2f1 makes EEROMS (fs-resize:127-140), mount_romfs.sh:10-14 mounts - # it there. A computer sees that part's root. No file either writes - # there names it. - setups += [CardSetup(name, plat, "") for name, plat in _STANDIN_MANIFESTS.items() - if name in _GAMES_PART_CARDS] + # it there. A computer sees that part's root, told by what each writes + # there at every boot (_GAMES_PART_RULES): a card not started once + # holds none of it yet, as a KNULLI card before its first start. + setups += [CardSetup(name, plat, "", tuple(rule for rule in rules if rule.name == name), + True) + for name, plat in _STANDIN_MANIFESTS.items() if name in _GAMES_PART_CARDS] for name in _systems_reading().get("retroarch", []): below = next((rule.bios[len(_card_mount(rule.bios)):] for rule in _EMBEDDED_RULES if rule.name == name and rule.bios and _card_mount(rule.bios)), "") @@ -11862,8 +12153,13 @@ def setup_bios_folder(plat: str, path: Path) -> Path: """The BIOS folder a setup found is announced with, before its list is read: a MinUI card's Bios below its root, the folder found else. The first screen and line mode's Found named /media/me/MINUI, the plan - then /media/me/MINUI/Bios.""" - return path / "Bios" if plat in CARD_PLATFORMS else path + then /media/me/MINUI/Bios. An AmberELEC or EmuELEC games part, whose + list writes bios/ below its root (shown_bios_folder), is announced by + that bios folder.""" + if plat in CARD_PLATFORMS: + return path / "Bios" + rule = games_part(path) if plat in _STANDIN_MANIFESTS.values() else None + return path / "bios" if rule is not None and rule.manifest == plat else path def card_setup(named: str, plat: str) -> CardSetup | None: @@ -11908,6 +12204,9 @@ def _not_the_card(setup: CardSetup, folder: Path, card: Path, other: str) -> Car marker = (CARD_MARKER if setup.plat in CARD_PLATFORMS else setup.rules[0].values[0].split("/")[0] if setup.rules else "") detail = f"{name} keeps {marker} at the root of its card" if marker else "" + if setup.name in _GAMES_PART_CARDS and setup.rules: + held = _names_and([value.split("/")[0] for value in setup.rules[0].values]) + detail = f"{name} keeps {held} beside bios on the games part of its card" if " and " in other: return CardChoice(setup.plat, folder, refused=f"{shown} holds the files of {other}, " f"not of {name}", detail=detail, @@ -12657,8 +12956,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(line) sys.exit(1) # A folder typed, or a card's root, is checked there. + # Said once, by the detection line above: "Nothing was found + # here." came twice, and after "A card is in the reader" + # (tui_audit_16 P3-2). platforms = _prompt_manual_platform( - os_type, not (args.check or args.dry_run or args.yes), check_typed=True) + os_type, not (args.check or args.dry_run or args.yes), check_typed=True, + title="Which setup?") offer = False else: for name, path in platforms: @@ -12712,6 +13015,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: total_declined = 0 # Files --offline left out at a terminal: missing, never "as answered". total_offline = 0 + # Of those, the wrong copies left in place: the result names them apart + # from the files still missing, as --check does (code_audit_23 P3-3). + total_wrong_left = 0 + # The BIOS list's files it left out, wrong copies aside: the result + # counts them by kind. + offline_left: list[dict] = [] check_pending = 0 total_verified = 0 files_pending = 0 @@ -12974,6 +13283,9 @@ 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 given else [], files)]) + if args.no_game_data else "", ) if selection is None and LOCAL.pack is not None: smaller = " Nothing copied." @@ -13074,14 +13386,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: for entry in omitted_files: reason = entry.get("reason", "unknown") reasons[reason] = reasons.get(reason, 0) + 1 - reason_summary = ", ".join( - f"{OMISSION_REASONS.get(reason, reason)}: {count}" - for reason, count in sorted(reasons.items()) - ) - print( - f" Not installable: {_plural(len(omitted_files), 'file')} " - f"({required_omitted} required, {reason_summary})." - ) + print(f" {not_installable_words(len(omitted_files), required_omitted, reasons)}") total_omitted += len(omitted_files) # Named for the end of the run: ROCKNIX's own list leaves them out, # and the end said "the frontend's save directory". @@ -13118,11 +13423,12 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # the check, the events, the plan and the install read this # (code_audit_22 P3-3). data_states = [(entry, data_directory_state(entry, bios_path)) for entry in data_dirs] + head_reads_done() trees_missing = sum(len(state[1]) for _entry, state in data_states) trees_held = head_data - trees_missing print(f" {len(up_to_date) + trees_held} in place, " f"{run_counts.files - present + trees_missing} missing, " - f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") + f"{_plural(wrong, 'wrong copy')}") # A partial copy left by an interrupted run is useless once the file # is in place: it goes. @@ -13249,6 +13555,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # Not done: the summary counts them as missing. files_pending += len(fetched) total_offline += len(fetched) + wrong_ids = {id(entry) for entry in mismatched} + total_wrong_left += sum(1 for f in fetched if id(f) in wrong_ids) + offline_left += [f for f in fetched if id(f) not in wrong_ids] to_download = [f for f in to_download if id(f) in copies] fetched = [] # What the pack does not hold is said before a size is promised: @@ -13388,7 +13697,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if interrupted: total_interrupted = True break - else: + elif len(platforms) > 1: + # One setup's run ends on "Done. Everything is in place.": said + # here too, it came twice (tui_audit_16 P3-4). print(f"\n {EVERYTHING_IN_PLACE}") total_up_to_date += len(up_to_date) @@ -13407,7 +13718,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # Left out as the BIOS files above are: counted missing, # not as one failure per tree. print(f" {entry['ref']}: {counts['present']} already in place, " - f"{counts['absent']} need the internet ({offline_named()})") + f"{agree(counts['absent'], 'needs', 'need')} the internet " + f"({offline_named()})") total_offline += counts["absent"] files_pending += counts["absent"] continue @@ -13500,23 +13812,28 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: same_choice=(_INVOCATION.command(*platforms[0]) if interactive and len(platforms) == 1 else ""), sources=_run_sources(bios_indexes, total_downloaded, - total_errors + total_offline), + total_offline - total_wrong_left, total_wrong_left, + total_errors), offline=total_offline, term=term, notes=version_notes, + missing=tally(offline_left) if categorized else None, ) finish_app(args, platforms, app_cards, interactive and not total_declined) -def _run_sources(bios_indexes: list[FolderIndex], installed: int, failed: int) -> str: - """The end line saying which folder gave how many of the files.""" +def _run_sources(bios_indexes: list[FolderIndex], installed: int, missing: int, + wrong: int = 0, failed: int = 0) -> str: + """The end line saying which folder gave how many of the files: missing + the files left out, wrong the wrong copies left in place, failed the + files the run could not write or fetch.""" folders = [(index.label, index.copied) for index in LOCAL.folders] fixed = sum(index.fixed for index in bios_indexes) shared = sum(index.copied for index in bios_indexes) fetched = installed - fixed - shared - sum(count for _label, count in folders) - return sources_line(folders, fixed, max(fetched, 0), failed, + return sources_line(folders, fixed, max(fetched, 0), missing, "copied from the pack" if LOCAL.pack is not None else "downloaded", - shared) + shared, wrong, failed) # Full-screen interface @@ -15098,11 +15415,11 @@ def tui_rows(state: TuiState) -> list[TuiRow]: # Every row whose folder is missing says so, in both modes: the # Windows row alone did (code_audit_21 P3-7). drives = [] if state.folder_kind == "share" else [ - TuiRow("drive", label + ("" if _is_dir(folder) else " (does not exist yet)"), + TuiRow("drive", label if _is_dir(folder) else _said_with(label, + "does not exist yet"), value=folder) - for label, folder in [*(other_side(state.os_type, state.platform) - if state.folder_kind == "usual" else []), - *drive_targets(state.platform)]] + for label, folder in folder_drives(state.os_type, state.platform, + state.folder_kind == "usual")] cards = {mount / "bios" for mount in state.unnamed} if state.folder_kind == "usual" and any(row.value in cards for row in drives): # A handheld's card in the reader, no setup found here: the @@ -17346,9 +17663,11 @@ def _tapped_row(state: TuiState) -> tuple: def _armed(state: TuiState) -> TuiKey | None: """The key a first tap armed on this screen, a second tap pressing it: - a bar entry that writes files, or the row in focus it acts on. A tap + a bar entry that writes files, or the row in focus a tap chose. A tap on the focused Install row was counted and drawn nowhere - (tui_audit_15 P3-1).""" + (tui_audit_15 P3-1), and one on any other focused row too: the row a + beginner taps first gave no sign the tap was seen (tui_audit_16 P3-8). + A tick is no choice: a tap ticks it at once.""" tapped = state.tapped if state.help or not tapped: return None @@ -17357,7 +17676,8 @@ def _armed(state: TuiState) -> TuiKey | None: and _bar_commits(state, tapped[3])) return tapped[3] if armed else None enter = TuiKey("enter") - return enter if tapped == _tapped_row(state) and _bar_commits(state, enter) else None + return (enter if tapped == _tapped_row(state) and tapped[2] not in TUI_TICK_ROWS + and not state.editing else None) def _armed_hint(state: TuiState, parts: tuple, width: int) -> tuple: @@ -17389,12 +17709,15 @@ def _armed_hint(state: TuiState, parts: tuple, width: int) -> tuple: def _armed_label(state: TuiState, row: TuiRow, width: int) -> TuiRow: """The row in focus drawn armed as its bar entry is, in the longest - words that fit the row.""" + words that fit the row. A row that names a choice (a setup, a folder) + keeps its name where the words do not fit beside it, the bar saying + them: "Continue: tap again" in its place hid which setup was tapped.""" verb = _ROW_VERBS.get(row.action, row.label) - for label in (f"{row.label}: {TUI_ARMED}", f"{row.label}: {TUI_ARMED_SHORT}", - f"{verb}: {TUI_ARMED_SHORT}"): + for label in (f"{row.label}: {TUI_ARMED}", f"{row.label}: {TUI_ARMED_SHORT}"): if cell_width(f"> {label}") <= width: return row._replace(label=label) + if row.action not in ("install", "addapp"): + return row return row._replace(label=f"{verb}: {TUI_ARMED_SHORT}") @@ -17656,9 +17979,15 @@ def _back_hint(state: TuiState, parts: tuple) -> tuple: return tuple(word if part in ("B/Esc Back", "B/Esc Quit") else part for part in parts) -def _route_hint(state: TuiState, parts: tuple) -> tuple: - """The bar's entries named as the run's input names them.""" - route = TUI_ROUTES.get(state.pad) +def _route_hint(state: TuiState, parts: tuple, width: int | None = None) -> tuple: + """The bar's entries named as the run's input names them. On Android + with no pad route named (Termux on a phone), a bar too narrow for both + names keeps the key's: it said "A Continue" where a phone has an Enter + key and no A button, and the a key does nothing (tui_audit_16 P3-13). + The help still names both.""" + pad = ("keys" if state.pad == "both" and state.os_type == "android" + and width is not None and width < TUI_LONG_BAR_WIDTH else state.pad) + route = TUI_ROUTES.get(pad) if route is None or state.help: return parts names = route.bar @@ -17889,7 +18218,7 @@ def _compose( room -= len(top_lines) return top_lines + _compose(state, width, room, before, rows, after) + footer_lines focus = max(0, min(state.focus.get(state.screen, 0), len(rows) - 1)) if rows else 0 - armed = rows and _armed(state) is not None and rows[focus].action in ("install", "addapp") + armed = bool(rows) and _armed(state) is not None row_lines = [_row_line(_armed_label(state, row, width) if armed and index == focus else row, index == focus, width) for index, row in enumerate(rows)] for index, (row, line) in enumerate(zip(rows, row_lines)): @@ -18075,13 +18404,12 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: where = ", ".join(f"{len(events)} found in {_last_name(folder)}" for folder, events in elsewhere.items()) rest = fetch + data - headline = (f"{where}, {rest} need the internet ({offline_named()})" + headline = (f"{where}, {agree(rest, 'needs', 'need')} the internet ({offline_named()})" if rest and offline else f"{where}, {rest} to download ({format_size(fetch_bytes + data_bytes)})" if rest else f"{where}, nothing to download") elif need and fetch and offline: - both = fetch + data > 1 - headline = (f"{_plural(fetch + data, 'file')} {'need' if both else 'needs'} the " + headline = (f"{agree(fetch + data, 'file needs', 'files need')} the " f"internet, which {offline_named()} does not use") elif need and fetch and LOCAL.pack is not None and copied: # One count: "21 files to install from the pack" above "23 to @@ -18146,7 +18474,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: # and "10888 need the internet" sat over "8252 missing" (tui_audit_14 # P3-7). counts = (f"{check['present']} in place, {check['missing'] + data} missing, " - f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}") + f"{_plural(wrong, 'wrong copy')}") # A first install's missing files are its normal state, not a warning. lines.append(_styled(counts, "quiet") if not check["present"] and not wrong else counts) # The backup line goes right under the count it explains, before the @@ -18184,7 +18512,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list: if backup: # With its subject: "They are replaced" sat lines under the count. # Not where nothing can be installed: nothing is replaced then. - lines.insert(count_at, f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " + lines.insert(count_at, f"The {_plural(wrong, 'wrong copy')} " f"{'is' if wrong == 1 else 'are'} replaced, the old " f"{'one' if wrong == 1 else 'ones'} kept in a backup folder.") if state.facts.get("writable") is False and dest is not None: @@ -18346,7 +18674,7 @@ def _view_setup(state: TuiState, width: int, height: int) -> tuple: notes = [TUI_ESDE_SHORT if note == TUI_ESDE_NOTE else note for note in state.notes] body = _compose(state, width, height, before, rows, after + status + notes, pinned=state.refused) - return ("Where do you play?", body, ("A/Enter Continue", "B/Esc Quit", "? Help")) + return (TUI_FOUND_TITLE, body, ("A/Enter Continue", "B/Esc Quit", "? Help")) def _view_platforms(state: TuiState, width: int, height: int) -> tuple: @@ -18355,7 +18683,7 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: title = "Whose files go there?" before = [f"Choose the setup that reads {_short_path(state.dest_given, state.home)}."] else: - title = "Which setup?" + title = TUI_CHOOSE_TITLE before = ["Choose the setup that will use the BIOS files."] pinned = list(state.refused) if state.dest_given is None and not state.setups and len(state.stack) == 1: @@ -18387,6 +18715,7 @@ def _view_platforms(state: TuiState, width: int, height: int) -> tuple: def _view_folder(state: TuiState, width: int, height: int) -> tuple: name = display_name(state.platform) bound = _tui_card_bound(state) + first: list = [] if bound and state.folder_default is not None and state.folder_kind == "found": # On the device itself: its own folder, which is no card to look for. title = "Which folder?" @@ -18427,24 +18756,43 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple: and tui_rows(state)[0].value in {mount / "bios" for mount in state.unnamed}): cards = [mount for mount in state.unnamed if mount / "bios" == tui_rows(state)[0].value] - which = ("The card in the reader" if len(state.unnamed) == 1 - else _mount_name(cards[0])) - before = [f"{which} keeps them in its bios folder, first below. " - f"{lead}", str(state.folder_default)] + first = [f"{card_folder_words(state.unnamed, cards[0])}, first below."] elif state.folder_kind == "closed": title = "Which folder?" before = [f"Choose another folder for {name}: browse for it or type it."] else: title = "Which folder?" before = [f"No usual {name} folder here: browse for it or type it."] + notes: list = [] if not bound and state.folder_kind != "share": - left_out = drives_left_out(state.platform) - before += [left_out] if left_out else [] - unproven = unproven_words() - before += [unproven] if unproven else [] - return (title, _compose(state, width, height, before, tui_rows(state), _status_after(state), - pinned=state.refused), - ("A/Enter Choose", "B/Esc Back", "? Help")) + notes = [note for note in (drives_left_out(state.platform), unproven_words()) if note] + body = _fitted_compose(state, width, height, first, before, notes, + tui_rows(state), _status_after(state), state.refused) + return (title, body, ("A/Enter Choose", "B/Esc Back", "? Help")) + + +def _fitted_compose(state: TuiState, width: int, height: int, first: list, core: list, + notes: list, rows: list[TuiRow], after: list, pinned: list) -> list: + """_compose with the text core always shown whole above the rows: the + notes go first, the last one first, then the lines before the core, + then a folder that does not fit its lines is drawn on one, cut in its + middle. At 40x12 a note took the room and the folder Use this folder + takes went with it (tui_audit_16 P3-1).""" + def shown(body: list, before: list) -> bool: + top = _text_lines(list(pinned), width) + wanted = len(_text_lines(before, width)) + (len(top) + 1 if top else 0) + drawn = next((index for index, line in enumerate(body) + if (getattr(line, "mark", None) or ("",))[0] == "row"), len(body)) + return drawn >= wanted + candidates = [first + core + notes[:keep] for keep in range(len(notes), -1, -1)] + candidates.append(list(core)) + if core: + candidates.append([*core[:-1], _styled(str(core[-1]), "path")]) + for before in candidates: + body = _compose(state, width, height, before, rows, after, pinned=pinned) + if shown(body, before): + return body + return body def _status_after(state: TuiState) -> list[str]: @@ -18595,7 +18943,7 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: f"About {_plural(len(held), 'file')}, " f"{format_size(sum(e['size'] for e in held))}, for {len(state.ticked)} of " f"{len(state.systems)} systems" - + (f", {rest} {'needs' if rest == 1 else 'need'} the internet " + + (f", {agree(rest, 'needs', 'need')} the internet " f"({offline_named()})" if rest else ""), "title") if not rest: # The remedy was for files of the whole list: none of the @@ -18619,6 +18967,13 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: if state.cli_systems and state.systems else []), *(["--core"] if state.cores else []), *(["--region"] if state.regions else [])] said = ", ".join(named) + # A system --system named that --no-game-data left empty, in line mode's + # words (tui_audit_16 P3-7). + listed = {row["system"] for row in state.systems} + emptied_named = game_data_only_words([ + system_title(system, state.manifest) for system in game_data_only( + [name for name in state.cli_systems if name not in listed], + (state.manifest or {}).get("files") or [])]) if state.no_game_data else "" # Named on the command line: said in line mode's words, and widened by # Choose systems. --core and --region were said in line mode alone # (tui_audit_11 P3-11). @@ -18652,7 +19007,8 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: f"{parts}") lines[at:at] = [*([state.card_note] if state.card_note and state.card_note != state.status else []), - *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice, + *([f"{said[0].upper()}{said[1:]}."] if said else []), + *([emptied_named] if emptied_named else []), *choice, *([parts] if parts else [])] if state.version: # Before the free space, which gives way first: appended last, the @@ -18687,8 +19043,8 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: # One line: in two, a 40x12 plan dropped it, "1 wrong copy" left # alone to say a file would be replaced. wrong = check.get("wrong_hash", 0) - long_form = f"The {wrong} wrong {'copy' if wrong == 1 else 'copies'} " - lines = [_styled(f"{wrong} wrong {'copy' if wrong == 1 else 'copies'}: backed up, " + long_form = f"The {_plural(wrong, 'wrong copy')} " + lines = [_styled(f"{_plural(wrong, 'wrong copy')}: backed up, " "replaced.") if str(line).startswith(long_form) else line for line in lines] # Above the count it explains, which gives way first: the folder @@ -19060,7 +19416,8 @@ def _space_words(state: TuiState) -> str: return f"The {'card' if card else 'drive'} is full: {match[1]} needed, {match[2]} free." -def _tui_sources(state: TuiState, installed: int, failed: int) -> str: +def _tui_sources(state: TuiState, installed: int, missing: int, wrong: int = 0, + failed: int = 0) -> str: """Where the installed files came from, as the line mode says it: "6 files copied from /media/me/OLD, 42 downloaded, 2 still missing." """ ok = {dest for dest, good, _why in state.finished if good} @@ -19076,8 +19433,9 @@ def _tui_sources(state: TuiState, installed: int, failed: int) -> str: name = _last_name(folder) folders[name] = folders.get(name, 0) + 1 copied = fixed + sum(folders.values()) - return sources_line(list(folders.items()), fixed, max(0, installed - copied), failed, - "from the pack" if LOCAL.pack is not None else "downloaded") + return sources_line(list(folders.items()), fixed, max(0, installed - copied), missing, + "from the pack" if LOCAL.pack is not None else "downloaded", + wrong=wrong, failed=failed) def _unavailable(state: TuiState) -> tuple[list[dict], int]: @@ -19103,21 +19461,29 @@ def _unavailable_lines(state: TuiState) -> list[str]: """The files that cannot be supplied and stop an emulator, by name and system: "1 file ... an emulator needs it" named neither.""" needed, _others = _unavailable(state) + return unavailable_lines(needed, state.manifest, state.dest, + (state.manifest or {}).get("files") or []) + + +def unavailable_lines(needed: list[dict], manifest: dict | None, dest: Path | None, + files: list[dict]) -> list[str]: + """The screen's plan and line mode's head say these sentences of the + required files the collection cannot supply: line mode counted "1 + required, not found: 37" and never named the file or its folder + (tui_audit_16 P3-6).""" if not needed: return [] - titles = _system_titles(state) - rows = {row["system"]: row for row in state.systems} def system(name: str) -> str: - label = _tui_system_label(rows.get(name) or {"system": name}, titles) + label = system_title(name, manifest) # An id no title names (jazz-jackrabbit-2) read as a raw key. return label.replace("-", " ").title() if label == name else label # Named below the BIOS folder the plan names: bios/Machines/... under a # BIOS folder that is bios/ read as a second one. lead = "" - if state.dest is not None and _plan_folder(state, state.dest) != state.dest: - lead = _plan_folder(state, state.dest).name + "/" + if dest is not None and shown_bios_folder(dest, files) != dest: + lead = shown_bios_folder(dest, files).name + "/" def where(dest: str) -> str: return dest[len(lead):] if lead and dest.startswith(lead) else dest @@ -19139,7 +19505,7 @@ def _unavailable_lines(state: TuiState) -> list[str]: f"{named(firmware)}. Your own copy goes in the BIOS folder.") if games: lines.append(game_data_missing(named(games), len(games), [ - (state.dest / entry["dest"]).parent for entry in games] if state.dest else [])) + (dest / entry["dest"]).parent for entry in games] if dest else [])) return lines @@ -19204,8 +19570,16 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: title = "Done" head = EVERYTHING_IN_PLACE lines = [head] - sources = _tui_sources(state, installed, len(failed) + left) - if left and installed and sources: + ok_dests = {dest for dest, good, _why in state.finished if good} + missing = ([failure["dest"] for failure in offline_failed] + or [event["dest"] for event in state.files + if event["dest"] not in ok_dests][:left]) if left else [] + # A wrong copy left in place is no missing file: --check counts it apart, + # and the result said "1 still missing" of it (code_audit_23 P3-3). + wrong_dests = {event["dest"] for event in state.files if event.get("state") == "wrong_hash"} + wrong_left = sum(1 for dest in missing if dest in wrong_dests) + sources = _tui_sources(state, installed, left - wrong_left, wrong_left, len(failed)) + if (left or failed) and installed and sources: # One count, then the remedy, as line mode says it: the count came # twice, the remedy between them (tui_audit_11 P3-18). lines[0] = sources @@ -19221,11 +19595,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # What is still missing by kind, as the copies are counted: 6 of the # 16 were game data, and the old card's result said 16 files # (tui_audit_13 P3-14). - ok_dests = {dest for dest, good, _why in state.finished if good} - missing = ([failure["dest"] for failure in offline_failed] - or [event["dest"] for event in state.files - if event["dest"] not in ok_dests][:left]) - apart = _tui_tally(state, [{"dest": dest, "size": 0} for dest in missing]) + apart = _tui_tally(state, [{"dest": dest, "size": 0} for dest in missing + if dest not in wrong_dests]) if _tui_categories(state) and apart.bios and apart.games: lines.append(f"Still missing: {installed_words(apart.bios, apart.games)}.") # Each folder spelled in full, as the result names its others. @@ -19621,7 +19992,7 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]: # Typing: Space and ? go into the filter, so the bar names the two # keys that end it, not Tick and Help. hint = ("Enter Done", "Esc Clear") - routed = _route_hint(state, _back_hint(state, hint)) + routed = _route_hint(state, _back_hint(state, hint), width) tapped = _tap_hint(state, routed) if (tapped != routed and "? Help" in _hint(width, *routed) and "? Help" not in _hint(width, *tapped)): diff --git a/install.sh b/install.sh index 64784c6ca..26f947c7e 100755 --- a/install.sh +++ b/install.sh @@ -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="665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511" +DEFAULT_INSTALL_SHA256="86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 89fb6a611..bd040ac20 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 961225, - "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", + "size": 981280, + "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1014402, - "sha256": "cdd06d24d30ad6b564d8a1595b906d50ce05d23093121cc7e5d91b5acd8e08ab" + "size": 1034457, + "sha256": "92d78b81459583ea9ac00546e28f3118e815ac302c0f180e882b44a16e5bcb53" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 961225, - "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", + "size": 981280, + "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 961225, - "sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511", + "size": 981280, + "sha256": "86f11e91316119889fda8a2baa228a9b7be1c1d3fd29780473a59e412e362c7d", "executable": false }, { diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py index 803804ea3..611ca54cf 100644 --- a/scripts/generate_installer_doc.py +++ b/scripts/generate_installer_doc.py @@ -161,8 +161,13 @@ def _marker(rule: _install._OsRule) -> str: path, pattern = rule.values line = re.sub(r"\\s\*\$$", "", re.sub(r"^(\^|\\A)", "", pattern)).replace("\\", "") return f"`{path}` holding the line `{line}`" - return ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`" - for value in rule.values) + named = ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`" + for value in rule.values) + if rule.method == "games_part": + # Told on the games part beside its bios folder, on a drive the + # system does not call fixed (install.games_part). + return f"{named} beside `bios/`, on a drive that is not a fixed disk" + return named def _list_words(plat: str) -> str: @@ -173,7 +178,8 @@ def _list_words(plat: str) -> str: def card_marker_rows() -> list[list[str]]: """The files that name a card or a SHARE drive, in the order they are read: install.py's card rules, then its SHARE drive files.""" - rows = [[_marker(rule), rule.name, f"`{rule.bios}`, {_list_words(rule.manifest)}"] + rows = [[_marker(rule), rule.name, + f"`{rule.bios or 'bios'}`, {_list_words(rule.manifest)}"] for rule in _install._card_rules()] hostname = "system.hostname=BATOCERA" for plat, marker in _install._SHARE_DRIVES: diff --git a/scripts/generate_site.py b/scripts/generate_site.py index b94916ace..bbca89d58 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3940,8 +3940,13 @@ def first_screen_words() -> list[str]: showed a line menu (Enter, i, c, q) nobody saw, whose keys had changed since.""" installer = _installer() - return [f"The command opens a screen. **{installer.TUI_INSTALL_ROW}** gets every " - f"file, **{installer.TUI_SYSTEMS_ROW}** fewer."] + # The screen it opens first: the setup found, or the list when none + # is. Install and Choose systems come on the next one (tui_audit_16 + # P3-10). + return [f"The command asks **{installer.TUI_FOUND_TITLE}** with the setup it found, " + f"or **{installer.TUI_CHOOSE_TITLE}** with a list when it finds none. Then " + f"**{installer.TUI_INSTALL_ROW}** gets every file, " + f"**{installer.TUI_SYSTEMS_ROW}** only the consoles you pick."] def check_pass_line() -> str: diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 34303b8cc..bb89cdd0d 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -90,12 +90,15 @@ class InstallerOutputExample(unittest.TestCase): intro, run = re.search(r"(?s)\n([^\n]*)\n\n```\n(Fetching file index.*?)```", text).groups() self.assertRegex(intro, r"\d{4}-\d{2}-\d{2}", "an output example says when it was taken") manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8")) - omitted = re.search(r"Not installable: \d+ files? \(\d+ required, (.*)\)\.", run) + # The line as the installer writes it (not_installable_words), or + # its earlier "(1 required, not found: 37)" form. + omitted = re.search(r"Not installable: \d+ files? (?:\(\d+ required, (.*)\)\.|(?:is|are) " + r"not in the collection|.*Why: (.*)\.)", run) if manifest.get("omitted_files"): self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it") - if omitted: - for part in omitted.group(1).split(", "): - self.assertIn(part.rsplit(":", 1)[0], install.OMISSION_REASONS.values()) + if omitted and (omitted.group(1) or omitted.group(2)): + for part in (omitted.group(1) or omitted.group(2)).split(", "): + self.assertIn(re.split(r":| \(", part)[0], install.OMISSION_REASONS.values()) count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", run).groups() self.assertIn(f"0 in place, {count} missing", run) self.assertSplitAddsUp(run, count, size) @@ -772,11 +775,12 @@ class FirstInstallWalkthrough(unittest.TestCase): self.assertIn(f"Done. {count} files installed", step4) self.assertIn(f"{count} in place, 0 missing", step5) manifest = json.loads((ROOT / "install" / "batocera.json").read_text(encoding="utf-8")) - omitted = re.search(r"Not installable: (\d+) files \((\d+) required", step3) + omitted = re.search(r"Not installable: (\d+) files (?:\((\d+) required|.*?, (\d+) of them " + r"required)", step3) if manifest.get("omitted_files"): self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it") if omitted: - total, required = map(int, omitted.groups()) + total, required = int(omitted.group(1)), int(omitted.group(2) or omitted.group(3)) first, needed = install._omission_lines( total, [{"required": True, "dest": str(i)} for i in range(required)] )[:2] diff --git a/tests/test_install.py b/tests/test_install.py index af3648697..60d2fe8fe 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1806,7 +1806,27 @@ class TestDetectedPlatformPrompt(unittest.TestCase): class TestManualPlatformPrompt(unittest.TestCase): - """With nothing detected, an interactive run asks instead of exiting.""" + """With nothing detected, an interactive run asks instead of exiting. + + Every test runs on no drive and a home of its own: two read the host's + drives and home, and failed with a card in the reader (code_audit_23 + P3-7).""" + + def setUp(self): + home = Path(_tempdir(self)) + for patcher in (unittest.mock.patch.object(install, "_mounted_cards", return_value=[]), + unittest.mock.patch.object(install, "_mount_points", + return_value=frozenset()), + unittest.mock.patch.object(install.Path, "home", return_value=home), + unittest.mock.patch.dict(os.environ, {"HOME": str(home)}), + unittest.mock.patch("sys.stdout", io.StringIO())): + patcher.start() + self.addCleanup(patcher.stop) + + def test_the_host_is_not_read(self): + self.assertEqual(install._mounted_cards(), []) + self.assertEqual(install._default_dest("linux", "retroarch"), + Path.home() / ".config" / "retroarch" / "system") def test_number_picks_the_platform_and_default_directory(self): answers = iter(["1", ""]) diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index 762d0180f..deaf72b81 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -234,15 +234,17 @@ class CheckNamesWhyAFileIsRefused(Scratch): self.complete("scph5501.bin", "pcsx/extra.bin") out = self.check() here = self.dest.resolve() - self.assertIn(f"opened at {here / 'scph5501.bin'} by beetle_psx, pcsx_rearmed", out) - self.assertIn(f"opened at {here / 'pcsx' / 'extra.bin'} by beetle_psx, pcsx_rearmed", + self.assertIn(f"read by beetle_psx, pcsx_rearmed at {here / 'scph5501.bin'}", out) + self.assertIn(f"read by beetle_psx, pcsx_rearmed at {here / 'pcsx' / 'extra.bin'}", out) + # Never "opened" of a file that is not there (tui_audit_16 P3-12). + self.assertNotIn("opened at", out) def test_a_complete_folder_says_nothing_more(self): self.complete() proc = self.run_installer("--check", "--manifest", str(self.list)) self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) - self.assertNotIn("opened at", proc.stdout) + self.assertNotIn("read by", proc.stdout) class ArchivesInTheBiosFolder(Scratch): @@ -845,7 +847,7 @@ class OfflineLeavesFilesMissing(Scratch): proc = self.run_installer("--offline", "--manifest", str(self.list), "--no-input") # Its files are missing, as the BIOS files --offline leaves out are, # with or without questions. - self.assertIn("bluemsx: 0 already in place, 1 need the internet (--offline)", + self.assertIn("bluemsx: 0 already in place, 1 needs the internet (--offline)", proc.stdout) self.assertNotIn("could not read", proc.stderr) self.assertNotIn("no longer in the source", proc.stdout) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 999f5f3c2..96602a56e 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -2537,10 +2537,11 @@ class AuditRoundThree(unittest.TestCase): state.summary = {"installed": 4, "up_to_date": 0, "failed": [ {"dest": "fc/disksys.rom", "reason": "HTTP 404"}]} _title, lines = install.tui_outcome(state) - self.assertEqual(lines[1], "2 files copied from OLD, 1 copied from the wrong place " - "in the BIOS folder, 1 downloaded, 1 still missing.") + # One count, the failed file said as failed (tui_audit_16 P3-11). + self.assertEqual(lines[0], "2 files copied from OLD, 1 copied from the wrong place " + "in the BIOS folder, 1 downloaded, 1 failed.") # The line mode's own sentence, from the same counts. - self.assertEqual(lines[1], install.sources_line([("OLD", 2)], 1, 1, 1)) + self.assertEqual(lines[0], install.sources_line([("OLD", 2)], 1, 1, 0, failed=1)) def test_p2_3_a_run_that_copied_nothing_adds_no_line(self): state = install.TuiState() @@ -5100,9 +5101,13 @@ class EndToEnd(unittest.TestCase): term = self.start() term.wait("Enter for RetroArch") term.send("\r") - term.wait("Enter) install everything") - term.send("\r") - # Installed by the first run, in place for the second. + if value is None: + # In place since the first run: no menu, nothing asked + # (tui_audit_16 P3-4). + term.wait("every one in place: nothing to install") + else: + term.wait("Enter) install everything") + term.send("\r") term.wait("Done.") term.close() self.assertNotIn(b"\x1b", term.raw) @@ -6411,7 +6416,11 @@ class AuditSevenOneCardReading(unittest.TestCase): stick = self.blank(f"{name}STICK") choice = install.card_choice(plat, plat, stick) self.assertEqual(choice.folder, stick) - self.assertIn("cannot tell", choice.note) + # Its card is told by what it writes at every boot: a stick + # holding none of it is one not started yet, never "cannot + # tell" (tui_audit_16 P2-1). + self.assertIn(f"says it is {install._a(name)} card yet", choice.note) + self.assertNotIn("cannot tell", choice.note) def test_install_py_has_two_blank_lines_around_each_definition(self): """code_audit_14 P3-12: E305 after _card_mount, and the same slip @@ -7438,8 +7447,9 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, self.assertRaises(SystemExit): install.ask_platform_folder("linux", "retroarch") text = out.getvalue() - self.assertIn(f"1) EASYROMS: bios (card or drive) ({stick / 'bios'})", text) - self.assertIn(f"2) ONION: BIOS (Onion SD card) ({onion / 'BIOS'})", text) + # One parenthesis a row (tui_audit_16 P3-9). + self.assertIn(f"1) EASYROMS: bios (drive, {stick / 'bios'})", text) + self.assertIn(f"2) ONION: BIOS (Onion SD card, {onion / 'BIOS'})", text) self.assertNotIn("3)", text) self.assertIn("Not offered here, each reads its own system's files: SHARE (Batocera " f"SHARE drive: --platform batocera --dest {share}), KNULLI (KNULLI SD " @@ -8259,7 +8269,9 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings, found here.\"""" text, _asked = self.line_run(_args(declared_only=False), []) self.assertIn("Looking for setups here...", text) - self.assertIn("Nothing was found here. Which setup?", text) + # Said once, the question alone after it (tui_audit_16 P3-2). + self.assertEqual(text.count("Nothing was found here."), 1) + self.assertIn("\nWhich setup?\n", text) self.assertNotIn("Detecting platform", text) self.assertNotIn("No supported platform", text) state = install.TuiState(os_type="linux", home=HOME) @@ -11633,7 +11645,10 @@ class AuditTwentyAndThirteen(unittest.TestCase): with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}): _title, lines = install.tui_outcome(state) - self.assertIn("Still missing: 2 BIOS files and 1 game data file.", lines) + # The wrong copy the check found is left in place: named as one, + # never counted as missing (code_audit_23 P3-3). + self.assertIn("1 file copied from OLD, 2 still missing, 1 wrong copy left.", lines) + self.assertIn("Still missing: 1 BIOS file and 1 game data file.", lines) # Root cause 6: every other word. @@ -12278,15 +12293,16 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): def easyroms(self, name: str = "EASYROMS") -> Path: """A handheld's games part as a computer mounts it, with the folders - its system writes there (install._GAMES_PART_MARKERS): ArkOS's + its system writes there (install._GAMES_PART_RULES): ArkOS's EASYROMS, AmberELEC's GAMES.""" return games_part(self.root / "media" / name) def test_an_arkos_card_is_found_and_comes_first(self): """tui_audit_14 P2-3: an EASYROMS partition mounted, "Nothing was found here", and two presses of Enter filled this computer's - ~/.config/retroarch/system for a RetroArch that was not there.""" - for name in ("EASYROMS", "GAMES"): + ~/.config/retroarch/system for a RetroArch that was not there. An + AmberELEC GAMES is AmberELEC's own card (tui_audit_16 P2-1).""" + for name in ("EASYROMS",): with self.subTest(card=name): card = self.easyroms(name) home = self.root / "home" @@ -12562,15 +12578,14 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): text = out.getvalue() self.assertNotIn(", created)", text) self.assertNotIn("(created)", text) - self.assertIn(f"D: bios (card or drive) ({drives[0] / 'bios'}, does not exist yet)", text) - self.assertIn(f"E: bios (card or drive) ({drives[1] / 'bios'})\n", text) + self.assertIn(f"D: bios (drive, {drives[0] / 'bios'}, does not exist yet)", text) + self.assertIn(f"E: bios (drive, {drives[1] / 'bios'})\n", text) state = fresh_state() state.platform = "retroarch" with mock.patch.object(install, "_mounted_cards", return_value=drives): install._open_folder_screen(state, SYSTEM_DIR) labels = [row.label for row in install.tui_rows(state) if row.action == "drive"] - self.assertEqual(labels, ["D: bios (card or drive) (does not exist yet)", - "E: bios (card or drive)"]) + self.assertEqual(labels, ["D: bios (drive, does not exist yet)", "E: bios (drive)"]) def test_line_mode_puts_the_arkos_card_first(self): card = self.easyroms() @@ -12578,9 +12593,9 @@ class AuditTwentyOneAndFourteen(unittest.TestCase): declared_only=False), ["1", ""], mounts=[card]) self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) self.assertNotIn("Nothing was found here", text) - self.assertIn(f"The card in the reader keeps them in its bios folder: {card / 'bios'}.", - text) - self.assertIn(f"1) EASYROMS: bios (card or drive) ({card / 'bios'})", text) + self.assertIn(f"The card in the reader keeps BIOS files in its bios folder: " + f"{card / 'bios'}.", text) + self.assertIn(f"1) EASYROMS: bios (SD card, {card / 'bios'})", text) self.assertIn("Enter for the card's bios folder", asked[-2]) self.assertIn(f"BIOS folder: {card / 'bios'}", text) @@ -12643,20 +12658,26 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): self.assertIn("Nothing was found here", self.screen(state)) state, text, rows = self.folder_screen(mounts) self.assertNotIn(rows[0].value, (data / "bios", stick / "bios")) - self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a " - "handheld's card: listed, not chosen.", text) + self.assertIn(self.UNPROVEN_TWO, text) self.assertIn(data / "bios", [row.value for row in rows]) text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, declared_only=False), ["1", ""], mounts=mounts) self.assertNotIn("in the reader", text) - self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a " - "handheld's card: listed, not chosen.", text) + self.assertIn(self.UNPROVEN_TWO, text) self.assertIn("Enter for the default above", asked[-2]) self.assertNotIn(f"BIOS folder: {data / 'bios'}", text) + # The note on drives with a bios folder and no handheld's folder. + UNPROVEN_TWO = ("DATA and STICK have a bios folder but no folder a handheld system " + "writes. Pick one below if your files go there.") + UNPROVEN_ONE = ("DATA has a bios folder but no folder a handheld system writes. Pick " + "it below if your files go there.") + def test_the_layouts_handhelds_write_are_cards(self): """ArkOS's EASYROMS, AmberELEC's GAMES and EmuELEC's EEROMS, each - with the folders its own image writes there.""" + with the folders its own image writes there. ArkOS's reads + RetroArch's list, the two others are their systems' own cards + (tui_audit_16 P2-1).""" arkos = games_part(self.root / "media" / "EASYROMS") amber = games_part(self.root / "media" / "GAMES") emuelec = self.drive("EEROMS", ("bios", "nes", "bezels")) @@ -12664,12 +12685,17 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): mounts = [arkos, amber, emuelec] with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ mock.patch.object(install, "_mount_points", return_value=frozenset()): - self.assertEqual(install.unnamed_cards(), mounts) - self.assertEqual([install._games_part_system(mount) for mount in mounts], + self.assertEqual(install.unnamed_cards(), [arkos]) + self.assertEqual([install.games_part(mount).name for mount in mounts], ["ArkOS", "AmberELEC", "EmuELEC"]) + self.assertEqual([install.card_system(mount) for mount in mounts], + [None, ("amberelec", amber, "AmberELEC"), + ("emuelec", emuelec, "EmuELEC")]) # One of these the system calls fixed is a disk, not a card. with mock.patch.object(install, "_drive_not_removable", return_value=True): self.assertEqual(install.unnamed_cards(), []) + self.assertEqual([install.card_system(mount) for mount in mounts], + [None, None, None]) def test_a_card_beside_a_data_drive_comes_first(self): """code_audit_22 P2-1: the ArkOS card and the data drive mounted, @@ -12683,31 +12709,31 @@ class AuditTwentyTwoAndFifteen(unittest.TestCase): self.assertIn("A card is in the reader (EASYROMS): choose its system", self.screen(state)) state, text, rows = self.folder_screen(mounts) self.assertEqual(rows[0].value, card / "bios") - self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text) + self.assertIn(self.UNPROVEN_ONE, text) text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, declared_only=False), ["1", ""], mounts=mounts) self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) - self.assertIn(f"The card in the reader keeps them in its bios folder: {card / 'bios'}.", - text) - self.assertIn(f"1) EASYROMS: bios (card or drive) ({card / 'bios'})", text) - self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text) + self.assertIn(f"The card in the reader keeps BIOS files in its bios folder: " + f"{card / 'bios'}.", text) + self.assertIn(f"1) EASYROMS: bios (SD card, {card / 'bios'})", text) + self.assertIn(self.UNPROVEN_ONE, text) self.assertIn(f"BIOS folder: {card / 'bios'}", text) def test_two_cards_are_spoken_of_in_the_plural(self): """code_audit_22 P3-2: "Cards are in the reader (DATA, EASYROMS): choose its system", then "The card in the reader" of one of two.""" arkos = games_part(self.root / "media" / "EASYROMS") - amber = games_part(self.root / "media" / "GAMES") - mounts = [arkos, amber] + second = games_part(self.root / "media" / "ROMS2") + mounts = [arkos, second] state = self.first_screen(mounts) - self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems", + self.assertIn("Cards are in the reader (EASYROMS, ROMS2): choose their systems", self.screen(state)) state, text, rows = self.folder_screen(mounts) - self.assertIn("EASYROMS keeps them in its bios folder, first below.", text) + self.assertIn("EASYROMS keeps BIOS files in its bios folder, first below.", text) text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, declared_only=False), ["1", ""], mounts=mounts) - self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems.", text) - self.assertIn(f"EASYROMS keeps them in its bios folder: {arkos / 'bios'}.", text) + self.assertIn("Cards are in the reader (EASYROMS, ROMS2): choose their systems.", text) + self.assertIn(f"EASYROMS keeps BIOS files in its bios folder: {arkos / 'bios'}.", text) self.assertNotIn("The card in the reader", text) def test_a_fixed_partition_is_told_from_a_card(self): @@ -13280,8 +13306,16 @@ class TheSystemsQuestionFits(unittest.TestCase): [f for f in files if install._chosen(f, {system})])) # One line a system, a second for its shared files: the # 80-column question took 127 lines for 66 systems. - limit = len(systems) + shared + 10 if cols == 80 else 2 * len(systems) + 12 + # At 40 columns a shared size takes a line more: its first + # part alone was printed beside a long name, the shared + # files unsaid (tui_audit_16 P3-5). + limit = (len(systems) + shared + 10 if cols == 80 + else 2 * len(systems) + shared + 12) self.assertLessEqual(len(lines), limit) + for line, after in zip(lines, lines[1:] + [""]): + if line.rstrip().endswith(","): + self.assertTrue(after.startswith(" ") and not re.match( + r" +\d+\) ", after), (cols, line, after)) def test_a_row_that_fits_keeps_one_line(self): lines, _systems, _files = self.question(80) @@ -13290,5 +13324,555 @@ class TheSystemsQuestionFits(unittest.TestCase): self.assertTrue(re.match(r" +\d+\) ", lines[row + 1])) +class AuditTwentyThreeAndSixteen(unittest.TestCase): + """code_audit_23 and tui_audit_16: a card its markers prove is that + system's card everywhere, counts and states that tell the truth, one + width for every layout, line mode's order and words as the screen's, + and a tap that shows it was seen.""" + + setUp = LineModeAndScreenDecideAlike.setUp + line_run = LineModeAndScreenDecideAlike.line_run + listing = LineModeAndScreenDecideAlike.listing + entry = staticmethod(LineModeAndScreenDecideAlike.entry) + both = LineModeAndScreenDecideAlike.both + drive = AuditTwentyTwoAndFifteen.drive + first_screen = AuditTwentyTwoAndFifteen.first_screen + folder_screen = AuditTwentyTwoAndFifteen.folder_screen + game_listing = AuditTwentyOneAndFourteen.game_listing + game_manifest = AuditTwentyAndThirteen.game_manifest + receive = staticmethod(AuditTwentyAndThirteen.receive) + screen = staticmethod(AuditTwentyOneAndFourteen.screen) + + def amberelec(self, name: str = "GAMES") -> Path: + return games_part(self.root / "media" / name) + + def emuelec(self, name: str = "EEROMS") -> Path: + card = self.drive(name, ("bios", "nes", "bezels")) + (card / "bezels" / "default.cfg").write_text("") + return card + + def line_from(self, args: argparse.Namespace, answers: list, + folders: list[Path] = ()) -> tuple[str, list]: + """(text, prompts) of line mode at a terminal, the result included, + the folders --from names read first, as main() reads them.""" + given, asked = iter(answers), [] + + def answer(prompt: str) -> str: + asked.append(prompt) + try: + return next(given) + except StopIteration: + raise _Decided(prompt) from None + + install.LOCAL.reset() + self.addCleanup(install.LOCAL.reset) + if folders: + setattr(args, "from", [str(folder) for folder in folders]) + invocation = install.Invocation() + invocation.os_type = "linux" + invocation.record(args) + stream = io.StringIO() + with mock.patch.object(install, "_INVOCATION", invocation), \ + mock.patch.object(install, "_mounted_cards", return_value=[]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "detect_platforms", return_value=[]), \ + mock.patch.object(install, "detect_frontends", return_value=[]), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(install.sys.stdin, "isatty", return_value=True), \ + mock.patch.object(install, "_is_terminal", return_value=True), \ + mock.patch.object(install, "_ask", side_effect=answer), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.redirect_stderr(io.StringIO()) as err, \ + contextlib.suppress(_Decided, SystemExit): + install._install(args, stream) + return out.getvalue() + err.getvalue() + stream.getvalue(), asked + + # Root cause 1: a card its markers prove is that system's card. + + def test_a_games_part_is_its_systems_card_in_both_modes(self): + """tui_audit_16 P2-1: the GAMES card AmberELEC's folders prove, and + the EEROMS card EmuELEC's prove, read "A card is in the reader: + choose its system" with RetroArch focused, and two presses of Enter + put RetroArch's list on them.""" + for plat, name, make in (("amberelec", "AmberELEC", self.amberelec), + ("emuelec", "EmuELEC", self.emuelec)): + with self.subTest(system=name): + card = make() + state = self.first_screen([card]) + text = self.screen(state) + self.assertIn(f"{name} SD card ({card.name})", text) + self.assertNotIn("in the reader", text) + self.assertIn(f"BIOSfolder:{card / 'bios'}", "".join(text.split())) + (line, screen, said) = self.both([card], []) + self.assertEqual(line, (plat, card)) + self.assertEqual(screen, (plat, card)) + self.assertIn(f"Found {name}", said) + shutil.rmtree(card) + + def test_their_own_routes_take_the_card_they_prove(self): + """tui_audit_16 P2-1: --platform amberelec said it "cannot tell an + AmberELEC card by its files" of the card holding gamedata/ and + homebrew/, and line mode offered no Enter for it.""" + for plat, name, make in (("amberelec", "AmberELEC", self.amberelec), + ("emuelec", "EmuELEC", self.emuelec)): + with self.subTest(system=name): + card = make() + stick = self.drive(f"{name}STICK", ("bios",)) + with mock.patch.object(install, "_mounted_cards", return_value=[card, stick]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + self.assertEqual(install.mounted_cards_of(name, plat), [card]) + self.assertEqual(install.blank_cards_of(name, plat), [stick]) + choice = install.card_choice(name, plat, card) + self.assertEqual((choice.refused, choice.folder), ("", card)) + self.assertNotIn("cannot tell", choice.note) + (line, screen, said) = self.both([card, stick], [], platform=plat) + self.assertEqual(line, (plat, card)) + self.assertEqual(screen, (plat, card)) + self.assertIn(f"Found {name} at {card / 'bios'}", said) + self.assertNotIn("cannot tell", said) + # Asked for its card, the one its files prove is offered. + with mock.patch.object(install, "_mounted_cards", return_value=[card, stick]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + contextlib.redirect_stdout(io.StringIO()) as out: + dest = install.card_install_dest(name, plat, True, False) + self.assertEqual(dest, card) + self.assertIn(f"{name} card found at {card.name}", out.getvalue()) + with mock.patch.object(install, "_mounted_cards", return_value=[card, stick]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + state = screen_keys(_args(platform=plat, declared_only=False), []) + view = " ".join(install.tui_view(state, 80, 24)) + self.assertNotIn("cannot tell", view) + shutil.rmtree(card) + shutil.rmtree(stick) + + def test_an_arkos_card_from_any_year_is_a_card(self): + """code_audit_23 P3-1: ArkOS's default trees of 2020 hold no themes/ + (9664c869 none of themes and launchimages, 495eb210 and c14afbe5 + launchimages only), and the 12262020 update skipped the themes of a + card with under 1 GB free. Every tree holds bgmusic/. A data drive + holding neither stays no card.""" + layouts = { + "ARK_9664c869": ("bios", "bgmusic", "ports", "snes", "System Volume Information"), + "ARK_495eb210": ("bios", "bgmusic", "launchimages", "ports", "snes"), + "ARK_c14afbe5": ("bios", "bgmusic", "launchimages", "psx"), + "ARK_f41b9485": ("bios", "bgmusic", "launchimages", "themes", "backup", "nes"), + "ARK_FULL1226": ("bios", "launchimages", "nes"), + } + cards = [self.drive(name, folders) for name, folders in layouts.items()] + data = self.drive("DATA", ("bios", "snes", "Music", "themes")) + music = self.drive("MUSIC", ("bgmusic", "launchimages")) + mounts = [*cards, data, music] + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + self.assertEqual(install.unnamed_cards(), cards) + self.assertEqual([install.games_part(card).name for card in cards], + ["ArkOS"] * len(cards)) + self.assertIsNone(install.games_part(data)) + self.assertIsNone(install.games_part(music)) + self.assertEqual(install.unproven_drives(), [data]) + with mock.patch.object(install, "_drive_not_removable", return_value=True): + self.assertEqual(install.unnamed_cards(), []) + + # Root cause 2: counts and states that tell the truth. + + def wrong_copy_run(self, answers: list) -> tuple[str, list, Path]: + """An offline run whose BIOS folder holds a wrong copy of the right + size of a.bin, a folder named with --from holding b.bin.""" + good_a, good_b = b"A" * 64, b"B" * 64 + listing = self.listing("retroarch", [self.entry("a.bin", good_a, ["nintendo-gba"]), + self.entry("b.bin", good_b, ["nintendo-gba"])]) + dest = self.root / "dest" + dest.mkdir() + (dest / "a.bin").write_bytes(b"Z" * 64) + old = self.root / "old" + old.mkdir() + (old / "b.bin").write_bytes(good_b) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text, asked = self.line_from(_args(manifest=listing, dest=dest, platform="retroarch", + declared_only=False), answers, [old]) + return text, asked, dest + + def test_the_head_never_calls_a_wrong_copy_in_place(self): + """code_audit_23 P3-2: a wrong copy of the right size was in place + for the head, which said "1 found in old, nothing to download" over + a run that left it.""" + text, _asked, dest = self.wrong_copy_run([""]) + self.assertIn("RetroArch: 1 found in old, 1 needs the internet.", text) + self.assertNotIn("nothing to download", text) + self.assertEqual((dest / "a.bin").read_bytes(), b"Z" * 64) + + def test_a_wrong_copy_left_is_said_as_one(self): + """code_audit_23 P3-3: the result said "1 still missing" of a file + --check counts as a wrong copy.""" + text, _asked, _dest = self.wrong_copy_run([""]) + self.assertIn("Finished with files missing.", text) + self.assertIn("1 file copied from", text) + self.assertIn(", 1 wrong copy left.", text) + self.assertNotIn("still missing", text.rsplit("Finished", 1)[1]) + + def test_a_count_agrees_with_its_verb(self): + """code_audit_23 P3-4: "1 need the internet" on the plan, and on + line mode's data tree line.""" + self.assertEqual(install.agree(1, "needs", "need"), "1 needs") + self.assertEqual(install.agree(2, "needs", "need"), "2 need") + self.assertEqual(install._plural(2, "wrong copy"), "2 wrong copies") + self.assertEqual(install._plural(1, "wrong copy"), "1 wrong copy") + self.assertEqual(install._plural(2, "day"), "2 days") + state = checked_state() + folder = "/media/OLD" + state.from_folders = [Path(folder)] + state.found = [found(dest, folder) for dest in ("scph5500.bin", "pcsx/extra.bin", + "gba_bios.bin")] + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text = self.screen(state, 100, 30) + self.assertIn("3 found in OLD, 1 needs the internet", text) + source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") + self.assertNotRegex(source, r"\} need the internet") + + def test_line_mode_says_what_is_still_missing_before_the_remedy(self): + """tui_audit_16 P3-3: "They are not in OLD" followed "Installed: 6 + BIOS files", where the screen puts "Still missing:" between them.""" + bios, game = b"B" * 32, b"G" * 32 + listing = self.listing("retroarch", [ + self.entry("a.bin", bios, ["nintendo-gba"]), + self.entry("b.bin", b"C" * 32, ["nintendo-gba"]), + self.entry("g.dat", game, ["sega-dreamcast"], category="game_data")], + categorized=True) + dest = self.root / "dest" + dest.mkdir() + old = self.root / "OLD" + old.mkdir() + (old / "a.bin").write_bytes(bios) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + text, _asked = self.line_from(_args(manifest=listing, dest=dest, + platform="retroarch", declared_only=False), + [""], [old]) + end = text.rsplit("Finished with files missing", 1)[-1] + self.assertIn("Still missing: 1 BIOS file and 1 game data file.", end) + self.assertLess(end.index("Still missing:"), end.index(f"They are not in {old}")) + self.assertLess(end.index("Installed:"), end.index("Still missing:")) + + def test_a_rerun_with_everything_in_place_offers_nothing(self): + """tui_audit_16 P3-4: line mode offered "Enter) install what is + found", then said "Everything is in place." twice.""" + data = b"A" * 64 + listing = self.listing("retroarch", [self.entry("a.bin", data, ["nintendo-gba"])]) + dest = self.root / "dest" + dest.mkdir() + (dest / "a.bin").write_bytes(data) + text, asked = self.line_from(_args(manifest=listing, dest=dest, platform="retroarch", + declared_only=False), []) + self.assertEqual(asked, []) + self.assertNotIn("Enter)", text) + self.assertIn("RetroArch: 1 file, every one in place: nothing to install.", text) + self.assertEqual(text.count(install.EVERYTHING_IN_PLACE), 1) + + def test_line_mode_names_the_files_it_cannot_supply(self): + """tui_audit_16 P3-6: "Not installable: 37 files (1 required, not + found: 37)" read as a sum, and line mode never named the file the + screen names, nor its folder.""" + listing = self.listing("retroarch", [ + self.entry("a.bin", b"A" * 8, ["nintendo-gba"])], categorized=True, + omitted_files=[ + {"dest": "jazz2/Source/Anims.j2a", "name": "Anims.j2a", + "system": "jazz-jackrabbit-2", "required": True, "reason": "not_found", + "category": "game_data"}, + {"dest": "x.bin", "name": "x.bin", "system": "nintendo-gba", + "required": False, "reason": "not_found"}]) + dest = self.root / "dest" + dest.mkdir() + text, _asked = self.line_run(_args(manifest=listing, dest=dest, platform="retroarch", + declared_only=False, dry_run=True), []) + self.assertIn("Not available here: jazz2/Source/Anims.j2a (Jazz Jackrabbit 2), game " + "data its game reads. Copy it from your own copy of the game into " + f"{dest / 'jazz2' / 'Source'}{os.sep}.", text) + self.assertEqual(install.not_installable_words(37, 1, {"not_found": 37}), + "Not installable: 37 files are not in the collection, 1 of them " + "required.") + self.assertEqual(install.not_installable_words(1, 1, {"not_found": 1}), + "Not installable: 1 file is not in the collection, required.") + self.assertIn("Why: no save directory (2), not found (3).", + install.not_installable_words(5, 0, {"not_found": 3, + "no_save_directory": 2})) + + def test_a_system_emptied_by_no_game_data_is_named(self): + """tui_audit_16 P3-7: --system nintendo-ds,sega-dreamcast with + --no-game-data: "For 1 of 8 systems (--system)", Dreamcast unsaid + until after Enter.""" + manifest = self.game_manifest() + words = (f"{install.system_title('sega-dreamcast', manifest)} holds only game data: " + "left out (--no-game-data).") + listing = self.game_listing() + text, _asked = self.line_run(_args(manifest=listing, dest=self.here, + declared_only=False, dry_run=True, + system=["nintendo-ds", "sega-dreamcast"], + no_game_data=True), []) + head = text.split("Enter)", 1)[0] + self.assertIn(words, head) + state = install.TuiState(os_type="linux", home=HOME) + state.manifest = manifest + state.no_game_data = True + state.cli_systems = ["nintendo-ds", "sega-dreamcast"] + state.systems = [{"system": "nintendo-ds", "name": "Nintendo DS", "files": 1, + "present": 0, "missing": 1, "wrong_hash": 0, "bytes_needed": 1}] + state.ticked = {"nintendo-ds"} + state.dest = self.here + state.platform = "retroarch" + state.check = {**CHECK, "files": 1, "present": 0, "missing": 1, "wrong_hash": 0} + state.stack = ["plan"] + self.assertIn(words, self.screen(state, 100, 40)) + + def test_a_run_with_failures_counts_once(self): + """tui_audit_16 P3-11: "Done. 44 files installed." then "38 files + copied from E:\\, 4 downloaded, 2 still missing." gave two counts, + the two that failed as missing. Each mode says one, failures named.""" + out = io.StringIO() + sources = install.sources_line([("/media/OLD", 38)], 0, 4, 0, failed=2) + self.assertEqual(sources, "38 files copied from /media/OLD, 4 downloaded, 2 failed.") + with contextlib.redirect_stdout(out), contextlib.suppress(SystemExit): + install._report_outcome([self.here], 42, 0, 2, 0, False, + failures=[("x.bin", "timed out"), ("y.bin", "timed out")], + sources=sources) + text = out.getvalue() + self.assertIn(f"Finished with errors. {sources}", text) + self.assertNotIn("42 files installed", text) + self.assertNotIn("still missing", text) + state = checked_state() + state.from_folders = [Path("/media/OLD")] + state.local_dests = {"gba_bios.bin": ("/media/OLD", "")} + state.finished = [("gba_bios.bin", True, ""), ("scph5500.bin", False, "timed out")] + state.stack, state.run = ["result"], "install" + state.summary = {"event": "summary", "mode": "install", "installed": 1, + "up_to_date": 0, "pending": 1, + "failed": [{"dest": "scph5500.bin", "reason": "timed out"}], + "skipped": [], "left_out": [], "kept": [], "interrupted": False} + title, lines = install.tui_outcome(state) + self.assertEqual(title, "Finished with errors") + self.assertEqual(lines[0], "1 file copied from OLD, 1 failed.") + self.assertNotIn("1 installed, 1 failed.", lines) + + def test_check_never_says_a_missing_file_was_opened(self): + """tui_audit_16 P3-12: "missing: nintendo-ds_0.bin" then "opened at + ..." read as a file that was opened.""" + entry = {"dest": "a.bin", "systems": ["nintendo-gba"], "cores": None} + self.assertEqual(install.opened_line(entry, {}, self.here), + f"expected at {self.here / 'a.bin'}") + named = {**entry, "cores": ["mgba"]} + self.assertEqual(install.opened_line(named, {}, self.here), + f"read by mgba at {self.here / 'a.bin'}") + + # Root cause 3: one width with a floor. + + def test_a_terminal_of_no_size_has_the_fallback(self): + """code_audit_23 P3-5: Python 3.8 gives a pty of no size as 0 and 0. + The plan printed "0 systems, the largest first:" and the progress + line lost every figure but its percentage.""" + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((0, 0))): + self.assertEqual(install.terminal_size(), (80, 24)) + out = io.StringIO() + term = types.SimpleNamespace(live=True, quiet=False, stream=out, + bold=str, dim=str) + progress = install.Progress(1, 1400, term) + progress.bytes = progress.files = 1400 + progress.files = 1 + progress._draw() + self.assertIn("(1/1 files)", out.getvalue()) + self.assertIn("[####", out.getvalue()) + with contextlib.redirect_stdout(io.StringIO()) as plan: + install._print_plan([], self.here, term, copied=[" 1 file copied"]) + self.assertNotIn("the largest first", plan.getvalue()) + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((8, 3))): + self.assertEqual(install.terminal_size(), (install.LINE_MIN_COLS, 3)) + + def systems_question(self, cols: int) -> list[str]: + manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text()) + out = io.StringIO() + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((cols, 24))), \ + mock.patch.object(install, "_ask", return_value=""), \ + contextlib.redirect_stdout(out): + install._choose_narrowing(manifest["files"], [], manifest, None) + text = out.getvalue() + ends = [at for at in (text.find("\nCores:"), text.find("\nThe platform's own")) + if at >= 0] + return text[text.index("Systems:"):min(ends, default=len(text))].splitlines() + + def test_the_systems_question_stays_inside_the_terminal(self): + """tui_audit_16 P3-5: 6 lines of 81 cells at 80 columns, 16 of 41 at + 40, "shared" and "platform" alone on a line.""" + for cols in (100, 80, 60, 40, 30): + with self.subTest(cols=cols): + lines = self.systems_question(cols) + for line in lines: + self.assertLess(install.cell_width(line), cols, line) + if line.strip() and not line.endswith(":"): + self.assertTrue(len(install._glued_words(line)) > 1 + or re.match(r" *\d+\) ", line), (cols, line)) + note = " ".join(line.strip() for line in lines[lines.index( + next(line for line in lines if line.startswith("'Other"))):]) + self.assertIn("this platform does not list", note) + + def test_the_setup_list_never_folds_inside_a_word(self): + """tui_audit_16 P3-15: at 39 columns "Miy" / "oo Flip".""" + for cols in (80, 39, 30): + with self.subTest(cols=cols): + with mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size((cols, 11))): + text, _asked = self.line_run(_args(declared_only=False), []) + block = text.split("Which setup?", 1)[1].split(" q) quit", 1)[0] + lines = [line for line in block.splitlines() if line.strip()] + self.assertTrue(lines) + for line in lines: + self.assertLess(install.cell_width(line), cols, line) + self.assertIn("Miyoo Flip", " ".join(" ".join(lines).split())) + + # Root cause 4: line mode in the screen's order and words. + + def test_on_wsl_the_card_comes_first_in_both_modes(self): + """code_audit_23 P3-6: line mode numbered Windows RetroArch 1) and + the card 2), where the screen put the card first.""" + card = games_part(self.root / "media" / "EASYROMS") + windows = Path("/mnt/c/RetroArch-Win64/system") + side = [("Windows RetroArch: C:\\RetroArch-Win64\\system", windows)] + with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "other_side", return_value=side), \ + mock.patch.object(install, "_ask", return_value="q"), \ + contextlib.redirect_stdout(io.StringIO()) as out, \ + contextlib.suppress(SystemExit): + install.ask_platform_folder("wsl", "retroarch") + state = self.first_screen([card]) + self.assertRegex(out.getvalue(), r"\n 1\) EASYROMS: bios \(SD card") + self.assertRegex(out.getvalue(), r"\n 2\) Windows RetroArch") + with mock.patch.object(install, "_mounted_cards", return_value=[card]), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()), \ + mock.patch.object(install, "other_side", return_value=side): + state.os_type = "wsl" + install.tui_update(state, Key("enter")) + rows = [row for row in install.tui_rows(state) if row.action == "drive"] + self.assertEqual([row.value for row in rows], [card / "bios", windows]) + + def test_nothing_found_is_said_once(self): + """tui_audit_16 P3-2: "Nothing was found here." twice, and after "A + card is in the reader".""" + text, _asked = self.line_run(_args(declared_only=False), []) + self.assertEqual(text.count("Nothing was found here."), 1) + card = games_part(self.root / "media" / "EASYROMS") + text, _asked = self.line_run(_args(declared_only=False), [], mounts=[card]) + self.assertIn("A card is in the reader (EASYROMS): choose its system.", text) + self.assertNotIn("Nothing was found here", text) + + def test_the_bios_only_drive_note_is_one_plain_sentence(self): + """tui_audit_16 P3-9: "listed, not chosen", "(card or drive)" under + a note saying no card, two parentheses side by side, and "keeps + them" after a title.""" + data = self.drive("DATA", ("bios", "snes", "Music")) + stick = self.drive("STICK", ("bios",)) + card = games_part(self.root / "media" / "EASYROMS") + mounts = [card, data, stick] + state, text, rows = self.folder_screen(mounts) + self.assertIn("DATA and STICK have a bios folder but no folder a handheld system " + "writes. Pick one below if your files go there.", text) + self.assertIn("The card in the reader keeps BIOS files in its bios folder", text) + labels = [row.label for row in rows] + self.assertIn("DATA: bios (drive)", labels) + self.assertIn("EASYROMS: bios (SD card)", labels) + line, _asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True, + declared_only=False), ["1", ""], mounts=mounts) + for said in (text, line): + self.assertNotIn("listed, not chosen", said) + self.assertNotIn("card or drive", said) + self.assertNotIn(") (", said) + self.assertNotIn("keeps them", said) + + def test_the_same_choice_is_one_command_in_both_modes(self): + """tui_audit_16 P3-14: the screen wrote --manifest before --from, + line mode after it.""" + hint = install.Invocation() + hint.os_type = "linux" + hint.options = [("--from", "/media/OLD"), ("--offline", None), + ("--manifest", "/x/list.json"), ("--system", "a")] + self.assertEqual(hint.command("retroarch", "/dest"), + "python3 install.py --platform retroarch --dest /dest --manifest " + "/x/list.json --system a --from /media/OLD --offline") + state = checked_state() + state.manifest_given, state.manifest_path = True, Path("/x/list.json") + state.from_folders = [Path("/media/OLD")] + args = _args(manifest="/x/list.json", platform="retroarch", dest=SYSTEM_DIR, + declared_only=False, offline=False) + setattr(args, "from", ["/media/OLD"]) + line = install.Invocation() + line.os_type = "linux" + line.record(args) + with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}): + line.options.append(("--offline", None)) + self.assertEqual(install.tui_command(state), line.command("retroarch", SYSTEM_DIR)) + + # Root cause 5: interaction. + + def test_a_tap_on_a_focused_row_shows_it_was_seen(self): + """tui_audit_16 P3-8: one tap on the focused Onion row showed + nothing, a second continued.""" + state = fresh_state() + state.stack = ["setup"] + state.mouse = True + lines = drawn(state, 80, 24) + row = next(index for index, line in enumerate(lines) + if str(line).lstrip().startswith("> ")) + self.assertEqual(tap(state, 4, row), []) + lines = drawn(state, 80, 24) + self.assertIn(f"A/Enter Continue: {install.TUI_ARMED}", str(lines[-1])) + self.assertIn(f"RetroArch: {install.TUI_ARMED}", str(lines[row])) + self.assertNotEqual(tap(state, 4, row), []) + + def test_a_phone_bar_names_the_enter_key(self): + """tui_audit_16 P3-13: Termux at 40 columns said "A Continue", and + the a key does nothing.""" + state = install.TuiState(os_type="android", home=HOME) + state.pad = install.tui_pad_route("android", {}) + self.assertEqual(state.pad, "both") + bar = str(install.tui_view(state, 40, 12)[-1]) + self.assertIn("Enter", bar) + self.assertNotRegex(bar, r"(^| )A ") + self.assertIn("A/Enter", str(install.tui_view(state, 80, 24)[-1])) + state.pad = "gptokeyb" + self.assertRegex(str(install.tui_view(state, 40, 12)[-1]), r"(^| )A ") + + def test_which_folder_keeps_its_folder_at_40_by_12(self): + """tui_audit_16 P3-1: with two drives holding bios, Which folder? + at 40x12 dropped the folder "Use this folder" stands for.""" + data = self.drive("DATA", ("bios", "snes", "Music")) + stick = self.drive("STICK", ("bios",)) + for mounts in ([data, stick], [games_part(self.root / "media" / "EASYROMS"), data]): + with self.subTest(mounts=[mount.name for mount in mounts]): + state, _text, _rows = self.folder_screen(mounts) + with mock.patch.object(install, "_mounted_cards", return_value=mounts), \ + mock.patch.object(install, "_mount_points", return_value=frozenset()): + small = " ".join(str(line) for line in install.tui_view(state, 40, 12)) + folder = str(state.folder_default) + self.assertIn("reads its BIOS files", small) + self.assertIn(folder[-12:], small) + + # Root cause 6: the pages say what the installer shows. + + def test_which_pack_names_the_first_screen(self): + """tui_audit_16 P3-10: which-pack said the command opens a screen + where Install and Choose systems are, the second screen.""" + sys.path.insert(0, str(REPO_ROOT / "scripts")) + self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts")) + import generate_site # noqa: PLC0415 + + words = " ".join(generate_site.first_screen_words()) + found, nothing = fresh_state(), install.TuiState(os_type="linux", home=HOME) + found.stack = ["setup"] + self.assertIn(install.tui_view(found, 80, 24)[0].split(": ", 1)[1].strip(), words) + self.assertIn(install.tui_view(nothing, 80, 24)[0].split(": ", 1)[1].strip(), words) + self.assertLess(words.index(install.TUI_FOUND_TITLE), + words.index(install.TUI_INSTALL_ROW)) + self.assertNotIn("opens a screen", words) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_site_r2_entry.py b/tests/test_site_r2_entry.py index 5a59b451f..cda36db3f 100644 --- a/tests/test_site_r2_entry.py +++ b/tests/test_site_r2_entry.py @@ -167,7 +167,7 @@ class GetYourFilesOrder(unittest.TestCase): text = _page("which-pack.md") order = [text.index(mark) for mark in ( "## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "Is it safe?", )] self.assertEqual(order, sorted(order)) diff --git a/wiki/installer.md b/wiki/installer.md index 3304fe810..cd21730d0 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -210,7 +210,9 @@ in the names of what is held. On a computer it names the keys (`Enter Install`, launcher (`A Install`, `B Back`, see `RETROBIOS_PAD` below), and `?` shows both the buttons and the keys. Where neither is known, in SteamOS game mode or on a console no launcher named, it gives both spellings (`A/Enter`, `B/Esc`), -and a narrow screen shortens them to `A`, `B` and `X`. On the +and a narrow screen shortens them to `A`, `B` and `X`. In Termux on Android, +with no launcher named, a narrow screen keeps the keys (`Enter`, `Esc`) instead, +a phone having an Enter key and no A button. On the regions screen, Left and Right move the region under the cursor up and down the order of preference. During the download, a stop waits for the files in progress, and a second one stops at once. Select+Start on a handheld ends the @@ -267,7 +269,8 @@ says why. A mouse or a touch screen works too, beside the keys and buttons, in a terminal that reports clicks: those of Linux, macOS, WSL and Termux. A click or a tap on a box (a system, a core, a region) ticks or unticks it, as Space -does. On any other row it moves the cursor there, and a second one on that +does. On any other row it moves the cursor there and adds `click or tap +again` to the row and the bottom line, and a second one on that row does what Enter does: one row of a terminal is far smaller than a finger, and the row a screen opens on (**Install** on Confirm) is never chosen by one tap. The entries of the bottom line press their key, **Exit** on the result @@ -712,6 +715,7 @@ RetroArch: 10994 files, 6.7 GB. 3269 BIOS files (4.1 GB), 5089 game data files (2.5 GB) and 2636 data files (38.5 MB). Game data: files a game or a game engine reads, of no use to the other cores. Data files: the files an emulator reads from a data folder of its own, such as Dolphin's Sys and PPSSPP's assets. BIOS folder: /home/me/.config/retroarch/system + Not available here: jazz2/Source/Anims.j2a (Jazz Jackrabbit 2), game data its game reads. Copy it from your own copy of the game into /home/me/.config/retroarch/system/jazz2/Source/. Enter) install everything c) choose systems, cores and regions f) copy from a folder or an old card @@ -1028,6 +1032,8 @@ gives for the device, below the card's root instead of its mount point: | `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list | | `.allium/` | Allium | `BIOS`, RetroArch's list | | `Koriki/version.txt` | Koriki | `BIOS`, RetroArch's list | +| `gamedata/`, `homebrew/` beside `bios/`, on a drive that is not a fixed disk | AmberELEC | `bios`, AmberELEC's list | +| `bezels/default.cfg` beside `bios/`, on a drive that is not a fixed disk | EmuELEC | `bios`, EmuELEC's list | | `system/batocera.conf` holding `system.hostname=BATOCERA` or no hostname line, and no KNULLI file above | Batocera SHARE drive | `bios`, Batocera's list | | `system/recalbox.conf` | Recalbox SHARE drive | `bios`, Recalbox's list | | `system/batocera.conf` with any other hostname, and no KNULLI file above, or the files of two SHARE drives | named by neither: the run says which systems it could be | the folder of the system named with `--platform` | @@ -1166,9 +1172,10 @@ folder inside another system's card (ROCKNIX's `roms/bios` named for KNULLI). A card whose own files name it is that card whatever its name: an Onion card mounted as `BIOS` gets `BIOS/BIOS`. One without the system's own files is refused for the MinUI family, Onion, muOS, spruceOS, CrossMix-OS, Allium and -Koriki. KNULLI and ROCKNIX fill their card when they first start, and no file -on the card names ArkOS, dArkOS, JELOS, KOLOS, UnofficialOS, ArchR, RetroOZ, -plumOS, MiyooCFW, AmberELEC, EmuELEC or the Anbernic firmware: such a folder +Koriki. KNULLI, ROCKNIX, AmberELEC and EmuELEC fill their card when they first +start (AmberELEC's `gamedata` and `homebrew`, EmuELEC's `bezels/default.cfg`), +and no file on the card names ArkOS, dArkOS, JELOS, KOLOS, UnofficialOS, ArchR, +RetroOZ, plumOS, MiyooCFW or the Anbernic firmware: such a folder gets the system's folder below it, and a note says so, kept on **Create the folder?** and the plan. No to creating its folders asks for the card again. Under WSL a drive letter (`D:`, `D:\`) is that Windows drive, typed or given @@ -1197,16 +1204,20 @@ that question a drive is numbered by the folder its system reads (`ONION: BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never by its root, which no setup reads. A handheld's card whose own files sit on a part a computer does not open is told by the folders its system writes beside -`bios` on the games part: `themes` and `launchimages` on ArkOS's `EASYROMS`, -`gamedata` and `homebrew` on AmberELEC's `GAMES`, `bezels/default.cfg` on -EmuELEC's `EEROMS`. A disk Windows or Linux calls fixed is never one. With -nothing found here the first screen and line mode say `A card is in the reader -(EASYROMS): choose its system` (two cards: `choose their systems`), and its -`bios` folder comes first at the folder question, under Enter, before this -computer's usual folder. A drive with a `bios` folder and none of those -folders, a data drive or a stick, is numbered with a note (`DATA has a bios -folder, and nothing on it shows a handheld's card: listed, not chosen.`) and -never taken under Enter. Another system's card or SHARE drive, and a +`bios` on the games part: `bgmusic` or `launchimages` on ArkOS's `EASYROMS` +(every ArkOS card has one of them from its first images, `themes` only since +an update a full card skipped), `gamedata` and `homebrew` on AmberELEC's +`GAMES`, `bezels/default.cfg` on EmuELEC's `EEROMS`. A disk Windows or Linux +calls fixed is never one. AmberELEC's and EmuELEC's card is found as theirs +(`AmberELEC SD card (GAMES)` under **Found on this machine**), in both modes +and on their own setup's card question, and gets their list. An ArkOS card +reads RetroArch's list: with nothing found here the first screen and line mode +say `A card is in the reader (EASYROMS): choose its system` (two cards: `choose +their systems`), and its `bios` folder comes first at the folder question, +under Enter, before this computer's usual folder. A drive with a `bios` folder +and none of those folders, a data drive or a stick, is numbered with a note +(`DATA has a bios folder but no folder a handheld system writes. Pick it below +if your files go there.`) and never taken under Enter. Another system's card or SHARE drive, and a drive two systems fit, are left out, and a line names them: each is reached through its own setup. A folder typed is read from the current one when it is not a full path, and named in full after. `q` quits there too