fix: ask a card setup for its own card

A card system chosen on a computer, from --platform, the setup list, its
filter or the line-mode menu, always asks for its card. The card screen
lists only the cards whose files say they run that system, offers Look
again, and checks a browsed, typed or --dest folder as --dest is checked.
The line-mode Card root prompt lists the mounted drives, asks again on a
missing or foreign root, and asks before creating several folders. On the
device the detected folder is offered, and the bound card is forgotten
when another setup is chosen.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 00:04:56 +01:00
1 parent 44378ae5c9
commit 44f94ab703
15 files changed
+629 -153

No files matched your search

+273 -71
View File
@@ -2617,11 +2617,12 @@ def did_you_mean(
return f"{listing} lists all {len(known)}."
def normalize_platform(name: str) -> str:
def normalize_platform(name: str, say: bool = True) -> str:
"""Return the canonical platform id for user-supplied input.
The site writes MiSTer FPGA and RetroArch: letters and digits decide,
as they do for verify.py and generate_pack.py.
as they do for verify.py and generate_pack.py. say is False where the
name is read again later, which says it once.
"""
key = _name_key(name)
for plat in AVAILABLE_PLATFORMS:
@@ -2631,8 +2632,9 @@ def normalize_platform(name: str) -> str:
# refused: a system named there stands for the list it gets.
for rule in _EMBEDDED_RULES:
if key and rule.manifest and key == _name_key(rule.name):
print(f" {rule.name} gets the {display_name(rule.manifest)} file list: "
f"--platform {rule.manifest}.")
if say:
print(f" {rule.name} gets the {display_name(rule.manifest)} file list: "
f"--platform {rule.manifest}.")
return rule.manifest
print(f"Unknown platform '{name.strip()}'. "
+ did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms"),
@@ -6275,10 +6277,16 @@ def _choose_platform(title: str, default: str = "") -> str:
"or q to quit.")
def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]:
"""Ask for a platform and a BIOS directory when nothing was detected."""
def _prompt_manual_platform(os_type: str, ask: bool = True) -> list[tuple[str, Path]]:
"""Ask for a platform and a BIOS directory when nothing was detected.
A system that runs from its card gets the card's root asked for, as
--platform rocknix does: /storage/roms/bios was offered on a PC.
"""
plat = _choose_platform("No supported platform found here. Choose the one to "
"install for:", "retroarch")
if card_bound(plat, plat) is not None:
return [(plat, card_install_dest(plat, plat, True, ask))]
default_dest = _default_dest(os_type, plat)
raw = ""
while not raw:
@@ -9257,7 +9265,7 @@ def _open_local_sources(args: argparse.Namespace) -> None:
+ (f", {parts} parts" if parts > 1 or _PART_FILE.match(pack.files[0].name) else ""))
spelled = pack.platforms()
if args.platform is not None:
plat = normalize_platform(args.platform)
plat = normalize_platform(args.platform, say=False)
if spelled and plat not in spelled:
print(f"Error: {pack.name} is a pack for "
f"{' and '.join(display_name(n) for n in spelled)}, not "
@@ -9495,18 +9503,31 @@ def _ends_with(path: Path, relative: str) -> bool:
return len(parts) >= len(tail) and parts[len(parts) - len(tail):] == tail
def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]:
"""The file list and folder for a --dest that names a handheld's card.
class CardChoice(NamedTuple):
"""Where the files of a folder named for a card go, and what is said: a
note, or why the folder is not the card of the system named."""
plat: str
folder: Path
note: str = ""
refused: str = ""
# The system's folder below its card, and the file that names the card.
below: str = ""
marker: str = ""
def card_choice(named: str, plat: str, dest: Path) -> CardChoice:
"""The file list and folder for a folder that may be a handheld's card.
The card's root is where the user points, its BIOS folder is below it:
`--platform muos --dest /media/me/SD1` wrote to the root, which muOS
never reads. A system named on the command line whose card this is not
is refused with the folder it reads.
never reads. A system named whose card this is not is refused with the
folder it reads.
"""
found = card_system(dest)
if found and (not plat or found[0] == plat) and found[1] != dest:
print(f" {found[2]} card: {display_name(found[0])} files, in {found[1]}")
return found[0], found[1]
return CardChoice(found[0], found[1],
f"{found[2]} card: {display_name(found[0])} files, in {found[1]}")
# Only a system named by its own name: --platform retroarch or knulli
# says which list, not which card.
key = _name_key(named)
@@ -9516,23 +9537,48 @@ def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]:
if rule is None or found:
if len({(match.manifest, match.bios) for match in matched}) > 1:
names = " and ".join(sorted({match.name for match in matched}))
print(f" {screen_text(str(dest))} holds the files of {names}: installing into "
"it as named. Name the card's BIOS folder with --dest to write there.")
return plat, dest
return CardChoice(plat, dest, f"{screen_text(str(dest))} holds the files of "
f"{names}: installing into it as named. Name the card's BIOS "
"folder with --dest to write there.")
return CardChoice(plat, dest)
# Its folder on the card, or one its device reads (/run/muos/storage/bios).
own = {device.bios for device in _EMBEDDED_RULES if device.name == rule.name}
if dest.as_posix() in own or _ends_with(dest, rule.bios):
return plat, dest
return CardChoice(plat, dest)
if any(match.name == rule.name for match in matched):
# A card two systems claim, the one named among them.
print(f" {rule.name} card: {display_name(plat)} files, in {dest / rule.bios}")
return plat, dest / rule.bios
return CardChoice(plat, dest / rule.bios, f"{rule.name} card: {display_name(plat)} "
f"files, in {dest / rule.bios}")
marker = rule.values[0].split("/")[0]
print(f"Error: {dest} is not a {rule.name} card: it holds no {marker}. "
f"{rule.name} reads its BIOS files from {rule.bios} on its card: name the card's "
f"root (the folder holding {marker}) or that folder: --dest <card>/{rule.bios}",
file=sys.stderr)
sys.exit(1)
return CardChoice(plat, dest, refused=f"{dest} is not a {rule.name} card: it holds no "
f"{marker}. {rule.name} reads its BIOS files from {rule.bios} on its "
"card", below=rule.bios, marker=marker)
def card_dest(named: str, plat: str, dest: Path) -> tuple[str, Path]:
"""The file list and folder for a --dest that names a handheld's card,
the run ended on a folder that is not the card of the system named."""
choice = card_choice(named, plat, dest)
if choice.refused:
print(f"Error: {choice.refused}: name the card's root (the folder holding "
f"{choice.marker}) or that folder: --dest <card>/{choice.below}",
file=sys.stderr)
sys.exit(1)
if choice.note:
print(f" {choice.note}")
return choice.plat, choice.folder
def card_root_above(dest: Path) -> tuple[str, Path] | None:
"""(platform, root) of the MinUI-family card dest is inside, when dest is
a folder below its root and not the root."""
if card_platform(dest) is not None:
return None
for parent in list(dest.absolute().parents)[:2]:
holder = card_platform(parent)
if holder:
return holder, parent
return None
def _check_card_root(plat: str, dest: Path) -> None:
@@ -9541,16 +9587,13 @@ def _check_card_root(plat: str, dest: Path) -> None:
The file list writes Bios/<TAG>/ below --dest: given the card's Bios
folder, every file would land in Bios/Bios/<TAG>/, which no core reads.
"""
above = card_root_above(dest)
if above:
print(f"Error: {dest} is inside a {display_name(above[0])} card. "
f"Name the card's root: --dest {above[1]}", file=sys.stderr)
sys.exit(1)
found = card_platform(dest)
if found is None:
for parent in list(dest.absolute().parents)[:2]:
holder = card_platform(parent)
if holder:
print(f"Error: {dest} is inside a {display_name(holder)} card. "
f"Name the card's root: --dest {parent}", file=sys.stderr)
sys.exit(1)
return
if found != plat:
if found is not None and found != plat:
print(f" Note: this card holds {display_name(found)} ({CARD_MARKER}), and the "
f"{display_name(plat)} file list was asked for.")
@@ -9579,13 +9622,50 @@ def mounted_cards_of(system: str, plat: str) -> list[Path]:
if any(rule.name == system for rule in _card_matches(mount))]
def card_install_dest(named: str, plat: str, interactive: bool) -> Path:
def card_root_folder(named: str, plat: str, root: Path) -> CardChoice:
"""The BIOS folder for a root chosen as the card of a card-bound system,
or why root is not that card.
A typed root was taken as it was: a typo four folders deep was created
and filled, a menu answer (8) became a relative card, and a folder that
was no Onion card ended the run. Each is refused here, with its reason,
for the question to be asked again.
"""
system, below = card_bound(named, plat) or (display_name(plat), "")
# The card is mounted, its BIOS folder may not be made yet.
card = (root.parents[len(PurePosixPath(below).parts) - 1]
if below and _ends_with(root, below) else root)
if not _is_dir(card):
return CardChoice(plat, root, refused=f"{card} does not exist, or is not a folder")
if plat in CARD_PLATFORMS:
above = card_root_above(root)
if above:
return CardChoice(plat, root, refused=f"{root} is inside a "
f"{display_name(above[0])} card, whose root is {above[1]}")
found = card_platform(root)
if found is None:
return CardChoice(plat, root, refused=f"{root} holds no {CARD_MARKER}: it is "
f"not a {system} card")
note = ("" if found == plat else f"Note: this card holds {display_name(found)} "
f"({CARD_MARKER}), and the {display_name(plat)} file list was asked for.")
return CardChoice(plat, root, note)
choice = card_choice(named, plat, root)
if choice.refused or choice.folder != root or not below or _ends_with(root, below):
return choice
# A KNULLI share or a ROCKNIX card read as a list's name: its folder
# below the root, as the device reads it.
return choice._replace(folder=root / below)
def card_install_dest(named: str, plat: str, interactive: bool, ask: bool = True) -> Path:
"""The folder for a card-bound system named without --dest, on a machine
that is not the device.
One mounted card that runs it is taken. At a terminal the card's root is
asked for, never this machine's own folder. Without questions the run
stops on the --dest that names the card.
asked for, never this machine's own folder, and asked again until it is
that card. Without questions the run stops on the --dest that names the
card. ask is whether creating several missing folders is asked first,
as it is for --dest.
"""
system, below = card_bound(named, plat) or (display_name(plat), "")
cards = mounted_cards_of(system, plat)
@@ -9606,21 +9686,27 @@ def card_install_dest(named: str, plat: str, interactive: bool) -> Path:
f"Mount the card and name its root with --dest, for example: {example}",
file=sys.stderr)
sys.exit(1)
# The drives the interface lists, each with what its own files say.
mounts = _mounted_cards()
print(f" {system} keeps its BIOS files on its SD card, not on this machine. "
"Mount the card, then type its root folder.")
raw = ""
while not raw:
"Mount the card, then type its root folder"
+ (" or its number:" if mounts else "."))
for index, mount in enumerate(mounts, 1):
print(f" {index}) {mount} ({_card_what(mount)})")
while True:
raw = _ask("Card root (Ctrl+C to stop): ")
root = _typed_folder(raw)
if plat in CARD_PLATFORMS:
_check_card_root(plat, root)
return root
folder = card_dest(named, plat, root)[1]
if folder == root and below and not _ends_with(root, below):
# A KNULLI share or a ROCKNIX card read as a list's name: its
# folder below the root, as the device reads it.
return root / below
return folder
if not raw:
continue
root = (mounts[int(raw) - 1] if raw.isdigit() and 1 <= int(raw) <= len(mounts)
else _typed_folder(raw))
choice = card_root_folder(named, plat, root)
if choice.refused:
print(f" {choice.refused}. Type the root of the {system} card.")
continue
if choice.note:
print(f" {choice.note}")
_missing_dest(choice.folder, ask, "typed")
return choice.folder
def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str:
@@ -9759,8 +9845,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
elif bound is not None:
# Its files go on its SD card, which is never this machine's
# own folder.
platforms = [(args.platform, card_install_dest(named, args.platform,
interactive))]
platforms = [(args.platform, card_install_dest(
named, args.platform, interactive,
not (args.check or args.dry_run or args.yes)))]
elif args.platform in PORTABLE_ONLY:
# A portable emulator reads the folder beside its executable:
# any default would be a folder it never opens.
@@ -9828,9 +9915,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
for line in _manual_usage_hint(os_type, asked):
print(line)
sys.exit(1)
platforms = _prompt_manual_platform(os_type)
platforms = _prompt_manual_platform(
os_type, not (args.check or args.dry_run or args.yes))
plat, typed = platforms[0]
if typed != _default_dest(os_type, plat):
# A card's root was asked for, and its folders checked, there.
if typed != _default_dest(os_type, plat) and card_bound(plat, plat) is None:
_missing_dest(typed, not (args.check or args.dry_run or args.yes),
"typed")
else:
@@ -10814,6 +10903,8 @@ class TuiState:
# (system, BIOS folder below the card root) when the setup asked for
# runs from an SD card: the folder screen offers the mounted cards.
self.card_bound: tuple[str, str] | None = None
# The mounted cards of that system, as last looked for.
self.cards: list | None = None
# (width and text, lines) of the last pager wrapped.
self.pager_cache: tuple | None = None
self.path_text: str = ""
@@ -11220,18 +11311,41 @@ def tui_rows(state: TuiState) -> list[TuiRow]:
rows = [_filter_row(state)]
for plat in AVAILABLE_PLATFORMS:
named = _setup_matches(plat, systems.get(plat, []), needle)
if named is not None:
if named is None:
continue
# A system that runs from its SD card, named or run by a device
# typed, is a row of its own that asks for the card: "onion" on
# RetroArch's row led to this computer's RetroArch folder.
cards = [name for name in named
if name in systems.get(plat, []) and card_bound(name, plat)]
others = [name for name in named if name not in cards]
if not named or others:
# The systems the filter named, those a device typed runs:
# "miyoo" on RetroArch read "muOS, JELOS, KOLOS...".
rows.append(TuiRow("platform", display_name(plat),
", ".join(named or systems.get(plat, [])), plat))
", ".join(others or systems.get(plat, [])), plat))
for name in cards:
devices = [device for device in _SETUP_DEVICES.get(name, ())
if _matches(device, _device_needle(needle))]
rows.append(TuiRow("cardsystem", f"{name}, SD card", ", ".join(devices)
or f"{display_name(plat)} files", (plat, name)))
return rows
if screen == "folder":
rows = [TuiRow("default", "Use this folder")] if state.folder_default else []
if _tui_card_bound(state):
rows += [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount)
for mount in _mounted_cards()]
return rows + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")]
bound = _tui_card_bound(state)
if not bound:
return rows + [TuiRow("browse", "Browse for a folder"),
TuiRow("type", "Type a path")]
# Only the cards whose own files say they run the system: A on the
# first mount wrote Onion's layout on a muOS card.
mounts = (state.cards if state.cards is not None
else mounted_cards_of(bound[0], state.platform))
cards = [TuiRow("card", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount)
for mount in mounts]
rows += cards + [TuiRow("browse", "Browse for a folder"), TuiRow("type", "Type a path")]
# On the device itself its folder is found, and no card is looked for.
here = state.folder_default is not None and state.folder_kind == "found"
return rows + ([] if cards or here else [TuiRow("rescan", "Look again")])
if screen == "browse":
return list(state.browse_rows)
if screen == "create":
@@ -11566,9 +11680,54 @@ def _choose(state: TuiState, plat: str, dest: Path) -> list[tuple]:
return _start_check(state)
def _card_folder(state: TuiState, folder: Path) -> CardChoice | None:
"""What a folder chosen for a setup that runs from its SD card resolves
to, as a card root typed in line mode does: None when no card is asked
for, or when the folder is inside a MinUI card, whose root _choose
offers."""
bound = _tui_card_bound(state)
if bound is None or (state.platform in CARD_PLATFORMS and card_root_above(folder)):
return None
return card_root_folder(bound[0], state.platform, folder)
def _card_refusal(state: TuiState, folder: Path) -> str:
"""Why folder is not the card the chosen setup runs from, or ""."""
choice = _card_folder(state, folder)
if choice is None or not choice.refused:
return ""
return f"{choice.refused}. Choose the root of the {_tui_card_bound(state)[0]} card."
def _choose_card(state: TuiState, folder: Path) -> list[tuple]:
"""_choose for a folder picked on the folder, browse or path screen, or
given with --dest: for a setup that runs from its SD card, the card's
own BIOS folder, and a folder that is not its card refused in words."""
choice = _card_folder(state, folder)
if choice is None:
return _choose(state, state.platform, folder)
if choice.refused:
state.status = _card_refusal(state, folder)
if state.stack and state.screen in ("folder", "browse", "path", "platforms"):
return []
state.folder_default, state.folder_kind = None, "usual"
return _push(state, "folder")
card = card_system(folder)
if card and card[1] == choice.folder:
# Titled as the same card found by itself: "Onion (RetroArch files)".
_DETECTED_OS.setdefault((choice.plat, choice.folder), card[2])
return _choose(state, choice.plat, choice.folder)
def _open_folder_screen(state: TuiState, default: Path | None,
kind: str = "usual") -> list[tuple]:
state.folder_default, state.folder_kind = default, kind
# The cards are looked for when the screen opens and on Look again: a
# card inserted meanwhile moved the rows under the focus.
state.cards = None
bound = _tui_card_bound(state)
if bound:
state.cards = mounted_cards_of(bound[0], state.platform)
refused = kind == "chosen" and (state.facts.get("writable") is False
or _short_of_space(state, state.check or {}))
# A folder just refused as full or read-only: Browse, not Use this
@@ -11577,7 +11736,11 @@ def _open_folder_screen(state: TuiState, default: Path | None,
def _mount_name(mount: Path) -> str:
"""How a person names a mounted card: E:\\ on Windows, its label elsewhere."""
"""How a person names a mounted card: E:\\ on Windows, its label elsewhere.
Under WSL /mnt/d is the Windows D: drive, which "d" did not say."""
if (_WSL["active"] and mount.parent == Path(_WSL["root"]) and len(mount.name) == 1
and mount.name.isalpha()):
return f"{mount.name.upper()}:"
return mount.name or str(mount)
@@ -11740,14 +11903,22 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
if action == "another":
return _push(state, "platforms", _platform_focus(state, "retroarch"))
plat, path, _label = state.setups[row.value]
# A setup found here is its folder: a card looked at and backed
# out of described the RetroArch chosen next as that card.
state.card_bound = None
return _choose(state, plat, path)
if screen == "platforms":
plat, system = row.value if action == "cardsystem" else (row.value, row.value)
state.platform = plat
state.card_bound = card_bound(system, plat)
if state.dest_given is not None:
return _choose(state, row.value, state.dest_given)
state.platform = row.value
state.card_bound = card_bound(row.value, row.value)
return _choose_card(state, state.dest_given)
if state.card_bound:
return _open_folder_screen(state, None)
# On the device itself, the folder detection found there: a
# KNULLI was asked for its own card.
found = next((path for name, path, _label in state.setups if name == plat
and detected_os(name, path) == state.card_bound[0]), None)
return _open_folder_screen(state, found, "found" if found else "usual")
# The folder detected here before the built-in one: the built-in
# default was offered as where RetroArch reads, when the
# retroarch.cfg found here names another.
@@ -11758,9 +11929,16 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
if screen == "folder":
if action == "default":
return _choose(state, state.platform, state.folder_default)
if action == "card":
below = (_tui_card_bound(state) or ("", ""))[1]
return _choose(state, state.platform, row.value / below if below else row.value)
if action in ("card", "rescan"):
if action == "rescan":
state.cards = mounted_cards_of(_tui_card_bound(state)[0], state.platform)
cards = [index for index, item in enumerate(tui_rows(state))
if item.action == "card"]
state.focus["folder"] = cards[0] if cards else state.focus.get("folder", 0)
state.status = ("" if cards else f"Still no {_tui_card_bound(state)[0]} card "
"is mounted.")
return []
return _choose_card(state, row.value)
if action == "browse":
state.browse_for = "dest"
_push(state, "browse")
@@ -11784,7 +11962,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
# The check runs again with it: the plan then says what it holds.
return _start_check(state)
if action == "use":
return _choose(state, state.platform, state.browse_dir)
return _choose_card(state, state.browse_dir)
if action == "up" and state.browse_dir is not None:
if row.value == state.browse_dir:
_browse_to(state, None if os.name == "nt" else state.browse_dir)
@@ -12147,8 +12325,12 @@ def _path_key(state: TuiState, key: TuiKey) -> list[tuple]:
if closed is not None:
state.status = _closed_status(state, closed)
return []
refused = _card_refusal(state, path)
if refused:
state.status = refused
return []
state.stack.pop()
return _choose(state, state.platform, path)
return _choose_card(state, path)
return []
@@ -12889,12 +13071,22 @@ 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)
if bound:
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?"
before = [f"{bound[0]} reads its BIOS files from:", str(state.folder_default)]
elif bound:
title = "Where is the SD card?"
before = [f"{bound[0]} keeps its BIOS files on its SD card. Choose the card's "
"root folder"
+ (", the one holding .system." if state.platform in CARD_PLATFORMS
else ".")]
if state.folder_default is not None:
# Use this folder takes it: named, not left to guess.
before += [f"The BIOS folder chosen for {bound[0]}:", str(state.folder_default)]
if any(row.action == "rescan" for row in tui_rows(state)):
before.append(f"No {bound[0]} card is mounted. Insert it, then choose "
"Look again.")
elif state.folder_default is not None:
title = "Which folder?"
lead = {"found": f"{name} reads its BIOS files from:",
@@ -13707,7 +13899,17 @@ def tui_start(
if line.strip() and line.strip() not in unsaid]
if plat and args.dest is not None:
state.stack = []
return state, _choose(state, plat, Path(args.dest))
state.platform, state.card_bound = plat, card_bound(args.platform, plat)
dest = Path(args.dest)
if state.card_bound is None or plat in CARD_PLATFORMS:
return state, _choose(state, plat, dest)
# Read as the line mode reads --dest: a muOS card named for Onion
# gets MUOS/bios, a folder that is no Onion card is refused.
choice = card_choice(args.platform, plat, dest)
if choice.refused:
state.status = f"{choice.refused}. Choose the root of the {state.card_bound[0]} card."
return state, _open_folder_screen(state, None)
return state, _choose(state, choice.plat, choice.folder)
if plat:
bound = card_bound(args.platform, plat)
# A system that runs from its card is its card or the device, never