fix: keep paths whole and spelled one way

A Termux run named /sdcard and /storage/emulated/0 for one storage, read
saves/<core> as a folder, split a backup path and the restore command
inside their words and named no folder for missing game data. Paths are
spelled as the BIOS folder was named, folders per core are said in
words, the result keeps paths and commands whole, and the game data line
names the folder it goes in.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 15:50:51 +01:00
1 parent 2ab01d3eec
commit 992bc8ce70
14 files changed
+324 -89

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4"
$defaultInstallSha256 = "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+163 -35
View File
@@ -940,7 +940,7 @@ def _host_path(text: str) -> Path:
def shown_path(path: Path | str) -> str:
"""A path as the person names it: under WSL, /mnt/d/BIOS is D:\\BIOS, the
drive the card row calls D:."""
text = str(path)
text = str(spelled(path)) if os.path.isabs(str(path)) else str(path)
root = _WSL["root"].rstrip("/") + "/"
match = (re.match(rf"^{re.escape(root)}([a-z])(?:/(.*))?$", text)
if _WSL["active"] else None)
@@ -2820,6 +2820,38 @@ def _record_moved_folders(root: Path, retrodeck_json: Path) -> None:
_MOVED_FOLDERS[root] = moved
# Resolved folder -> the same folder as the run names it: /sdcard is a link
# to /storage/emulated/0 on Android, and one result named the BIOS folder
# /sdcard/RetroArch/system and its backup in /storage/emulated/0/RetroArch.
_SPELLED: dict[Path, Path] = {}
def _note_spelling(root: Path) -> Path:
"""root resolved, its own spelling recorded for every path shown below
the link it goes through (spelled)."""
given, resolved = Path(os.path.abspath(root)), root.resolve()
named, real = given, resolved
while named.name == real.name and named != named.parent and real != real.parent:
named, real = named.parent, real.parent
if named != real:
try:
if Path(os.path.realpath(named)) == real:
_SPELLED[real] = named
except (OSError, ValueError):
pass
return resolved
def spelled(path: Path | str) -> Path:
"""path in the form the run names its folders: below a link the BIOS
folder was named through, the link's name (_note_spelling)."""
path = Path(path)
for real in sorted(_SPELLED, key=lambda each: len(each.parts), reverse=True):
if path == real or real in path.parents:
return _SPELLED[real] / path.relative_to(real)
return path
def _destination_path(root: Path, value: object) -> Path:
"""Place a manifest destination below *root*.
@@ -2833,7 +2865,7 @@ def _destination_path(root: Path, value: object) -> Path:
moved = _MOVED_FOLDERS.get(root, {})
if len(relative.parts) > 1 and relative.parts[0] in moved:
return moved[relative.parts[0]].resolve() / Path(*relative.parts[1:])
return root.resolve() / Path(*relative.parts)
return _note_spelling(root) / Path(*relative.parts)
class SavePlace(NamedTuple):
@@ -4329,7 +4361,9 @@ def place_save_entries(
place = save_directory_for(bios_path, manifest, override)
if isinstance(place, SavePlace):
_SAVE_DIRS[bios_path] = place
where = str(place.directory)
# Named through the link the BIOS folder was named through.
_note_spelling(bios_path)
where = str(spelled(place.directory))
if (manifest.get("save_directory") or {}).get("by_system"):
where += f"{os.sep}<system>"
if place.by_core:
@@ -4736,7 +4770,9 @@ class KeptFiles:
def summary(self) -> list[dict]:
"""[{folder, files, bytes}], one per run folder, for the report and events."""
with self._lock:
return [{"folder": str(run), "files": files, "bytes": size}
# Named as the run names its BIOS folder: /sdcard, not the
# /storage/emulated/0 the link resolves to.
return [{"folder": str(spelled(run)), "files": files, "bytes": size}
for run, (files, size) in sorted(self.counts.items())]
@@ -4781,7 +4817,7 @@ def _kept_slot(root: Path, value: object) -> tuple[Path, PurePosixPath]:
moved = _MOVED_FOLDERS.get(root, {})
if len(relative.parts) > 1 and relative.parts[0] in moved:
return moved[relative.parts[0]].resolve(), PurePosixPath(*relative.parts[1:])
return root.resolve(), relative
return _note_spelling(root), relative
def plan_kept(entries: list[dict], root: Path) -> dict[Path, list[int]]:
@@ -5721,7 +5757,8 @@ def _report_outcome(
if omitted > len(unplaced):
for line in _omission_lines(omitted - len(unplaced),
[entry for entry in omitted_entries or []
if entry.get("reason") != "no_save_directory"]):
if entry.get("reason") != "no_save_directory"],
Path(paths[0]) if len(paths) == 1 else None):
print(line)
for line in hints or []:
print(line)
@@ -5760,18 +5797,47 @@ def _offline_failure(reason: str) -> bool:
return OFFLINE_WORDS in reason or "makes no network request" in reason
def _omission_lines(count: int, entries: list[dict]) -> list[str]:
# What game data is, said wherever it is counted or chosen.
GAME_DATA_WORDS = "game data: files a game or a game engine reads, of no use to the other cores"
def game_data_missing(names: str, count: int, folders: list[Path]) -> str:
"""The game data a run could not supply, and the folder each goes in:
"Copy it from your own copy of the game" named no folder."""
one = count == 1
places = list(dict.fromkeys(f"{shown_path(folder)}{os.sep}" for folder in folders))
where = (f" into {', '.join(places[:3])}" + (f" and {len(places) - 3} more folders"
if len(places) > 3 else "")) if places else ""
return (f"Not available here, {GAME_DATA_WORDS}, read by "
f"{'its game' if one else 'their games'}: {names}. Copy "
f"{'it' if one else 'them'} from your own copy of the game{where}.")
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.
"395 file are not in the collection yet" named nothing and gave one
reason for all, while the manifest says which file and why.
reason for all, while the manifest says which file and why. Game data
is said apart, with the folder it goes in, as the screen says it.
"""
one = count == 1
lines = [f"{_plural(count, 'file')} {'was' if one else 'were'} not installed: "
f"the collection does not hold {'it' if one else 'them'}, or its copy "
f"fails {'its' if one else 'their'} check."]
lines += _needed_lines([e for e in entries if e.get("required")],
needed = [e for e in entries if e.get("required")]
games = [e for e in needed if e.get("category") == GAME_DATA]
lines += _needed_lines([e for e in needed if e.get("category") != GAME_DATA],
"Needed to start an emulator")
if games:
names = ", ".join(str(e.get("dest") or e.get("name")) for e in games[:3]) + (
f" and {len(games) - 3} more" if len(games) > 3 else "")
folders = []
for entry in games:
try:
folders.append(_entry_path(bios_path, entry).parent if bios_path else None)
except ValueError:
continue
lines.append(" " + game_data_missing(names, len(games), [f for f in folders if f]))
return lines
@@ -7165,10 +7231,32 @@ def space_words(needed: int, free: int, card: bool) -> str:
"choose another folder.")
_SAVE_SPLITS = {(True, True): "a folder per system, then one per core, under",
(True, False): "a folder per system under",
(False, True): "a folder per core under"}
def save_where_words(where: str) -> tuple[str, str]:
"""(words, folder) for where the saves event says files go: the folder
without the <system> and <core> it ends with, and those said in words.
"go to: /sdcard/RetroArch/saves/<core>" read as a folder named <core>."""
parts = re.split(r"[\\/]", where)
by_core = bool(parts) and parts[-1] == "<core>"
if by_core:
parts = parts[:-1]
by_system = bool(parts) and parts[-1] == "<system>"
if by_system:
parts = parts[:-1]
folder = where[:len(os.sep.join(parts))] if (by_core or by_system) else where
return _SAVE_SPLITS.get((by_system, by_core), ""), folder
def saves_line(display: str, count: int, where: str) -> str:
"""Where the files read from the frontend's save directory go."""
words, folder = save_where_words(where)
return (f"{_plural(count, 'file')} read from {display}'s save directory "
f"{'goes' if count == 1 else 'go'} to {where}")
f"{'goes' if count == 1 else 'go'} to {words + ' ' if words else ''}"
f"{shown_path(folder)}")
def unplaced_line(display: str, count: int, remedy: str) -> str:
@@ -12477,15 +12565,27 @@ def _glued_words(text: str) -> list[str]:
return words
def _wrap(text: str, width: int, indent: str = " ") -> list[str]:
"""Lines of at most width cells, the ones after the first indented."""
# A shell word: quoted parts kept with what they quote.
_SHELL_WORD = re.compile(r"""(?:'[^']*'|"[^"]*"|[^\s'"]+|['"])+""")
def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[str]:
"""Lines of at most width cells, the ones after the first indented.
whole keeps every word whole, a longer one alone on a line wider than
width, which the terminal folds and a copy keeps in one piece: "text"
for prose and paths, "command" for a command, whose words are shell
words. A restore command was drawn split inside its URL and its folder,
and a backup folder's dated name fell on a line of its own."""
if cell_width(text) <= width:
return [text]
lead = text[:len(text) - len(text.lstrip(" "))]
lines: list[str] = []
line = ""
for word in _glued_words(text):
for index, piece in enumerate(_split_word(word, width - len(lead + indent))):
words = (_SHELL_WORD.findall(text) if whole == "command" else _glued_words(text))
for word in words:
pieces = [word] if whole else _split_word(word, width - len(lead + indent))
for index, piece in enumerate(pieces):
prefix = lead + indent if lines else lead
# The rest of a word broken at a slash joins it without a space:
# a path was drawn "retroarch/ system", and copied wrong.
@@ -13435,7 +13535,7 @@ def _save_folder_words(state: TuiState) -> str:
if given is not None:
return _short_path(given, state.home)
if state.saves and state.saves.get("folder"):
return _short_path(Path(state.saves["folder"]), state.home)
return _short_path(Path(save_where_words(state.saves["folder"])[1]), state.home)
return "not known"
@@ -15277,19 +15377,34 @@ def _row_line(row: TuiRow, focused: bool, width: int) -> TuiLine:
return _styled(text, "focus" if focused else row.style)
def _text_lines(items: list, width: int) -> list[TuiLine]:
def _text_lines(items: list, width: int, whole: bool = False) -> list[TuiLine]:
"""Items as screen lines. whole keeps every word, path and command
whole (_wrap): a line wider than width is drawn as it is, the terminal
folding it, with the rows it takes after it left to it ("cont")."""
lines: list[TuiLine] = []
for item in items:
style = getattr(item, "style", "")
if style == "path":
if style == "path" and not whole:
# One line, cut in its middle: the end of a path tells it apart.
lines.append(_styled(middle(str(item), width)))
continue
for piece in _wrap(str(item), width):
lines.append(_styled(fit(piece, width), style))
kind = ("command" if style == "command" else "text") if whole else ""
for piece in _wrap(str(item), width, whole=kind):
if cell_width(piece) <= width:
lines.append(_styled(fit(piece, width), "" if style in ("path", "command")
else style))
continue
lines.append(_styled(piece, "long"))
lines += [_styled("", "cont")] * (-(-cell_width(piece) // width) - 1)
return lines
def _whole_words(state: TuiState) -> bool:
"""Whether a pager keeps paths and commands whole: the result and the
command line, the screens a person copies from."""
return state.screen in ("result", "command")
def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> list[TuiLine]:
"""height lines of a longer list from first, the last one saying where.
The lead rows (All, None, Filter) are not counted among the entries."""
@@ -15367,15 +15482,16 @@ def _pager(
"""
starts = _pager_starts(state, items, width)
total = starts[-1]
whole = _whole_words(state)
if total <= height:
return _text_lines(items, width)
return _text_lines(items, width, whole)
room = max(1, height - 1)
first = max(0, min(state.focus.get(state.screen, 0), total - room))
index = bisect.bisect_right(starts, first) - 1
offset = first - starts[index]
lines: list[TuiLine] = []
while index < len(items) and starts[index] < first + room:
lines += _text_lines([items[index]], width)
lines += _text_lines([items[index]], width, whole)
index += 1
skip = starts[lead] if lead else 0
start = max(1, first + 1 - skip)
@@ -15387,13 +15503,15 @@ def _pager_starts(state: TuiState, items: list, width: int) -> list[int]:
"""The line each item starts on once wrapped at width, and the total
last, kept while the items stay the same. An item that fits is one line
without being wrapped."""
key = (width, [(str(item), getattr(item, "style", "")) for item in items])
whole = _whole_words(state)
key = (width, whole, [(str(item), getattr(item, "style", "")) for item in items])
if state.pager_cache is not None and state.pager_cache[0] == key:
return state.pager_cache[1]
starts = [0]
for item in items:
fits = getattr(item, "style", "") == "path" or cell_width(str(item)) <= width
starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width))))
fits = ((getattr(item, "style", "") == "path" and not whole)
or cell_width(str(item)) <= width)
starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width, whole))))
state.pager_cache = (key, starts)
return starts
@@ -15596,11 +15714,12 @@ def _saves_lines(state: TuiState, saves: dict | None) -> list[str]:
return []
name = display_name(state.platform) if state.platform else "the frontend"
count = saves["files"]
words, folder = save_where_words(saves["folder"])
# The folder alone is cut in the middle: cut with the sentence, it
# lost "directory goes to" even at 80 columns.
return [f"{_plural(count, 'file')} read from {name}'s save directory "
f"{'goes' if count == 1 else 'go'} to:",
_short_path(Path(saves['folder']), state.home)]
f"{'goes' if count == 1 else 'go'} to{' ' + words if words else ''}:",
_short_path(Path(folder), state.home)]
def _unplaced_lines(summary: dict | None, state: TuiState,
@@ -15984,7 +16103,7 @@ def _pager_items(state: TuiState) -> list:
if state.screen == "result":
return tui_outcome(state)[1]
if state.screen == "command":
return ["The same choice without questions:", "", tui_command(state)]
return ["The same choice without questions:", "", _styled(tui_command(state), "command")]
if state.running:
items = ["Counting the files for these choices..."]
elif state.dry is None:
@@ -16198,10 +16317,8 @@ def _unavailable_lines(state: TuiState) -> list[str]:
f"{'its emulator' if len(firmware) == 1 else 'their emulators'}: "
f"{named(firmware)}. Your own copy goes in the BIOS folder.")
if games:
lines.append(f"Game data not available here, read by "
f"{'its game' if len(games) == 1 else 'their games'}: "
f"{named(games)}. Copy {'it' if len(games) == 1 else 'them'} from "
f"your own copy of the game.")
lines.append(game_data_missing(named(games), len(games), [
(state.dest / entry["dest"]).parent for entry in games] if state.dest else []))
return lines
@@ -16309,8 +16426,9 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
for kept in summary.get("kept") or []:
lines.append(f"The files it replaced are kept in {kept['folder']}")
if typed:
lines.append(f"To put them back: {run.launcher()} --restore "
f"{run.word(str(kept['folder']))}")
# Broken between its words only, never inside one (_wrap).
lines.append(_styled(f"To put them back: {run.launcher()} --restore "
f"{run.word(str(kept['folder']))}", "command"))
if summary and not summary.get("interrupted") and installed + up_to_date:
lines += _unavailable_lines(state)
for group in summary.get("left_out") or []:
@@ -16318,7 +16436,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
lines += _unplaced_lines(summary, state)
lines += _tui_app_lines(state)
if typed:
lines += ["Same choice without questions:", f" {tui_command(state)}"]
lines += ["Same choice without questions:", _styled(f" {tui_command(state)}", "command")]
if tui_keeps_log(state):
lines.append(f"Details: {state.log}")
return title, lines
@@ -16527,7 +16645,10 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]:
hint = ("Enter Done", "Esc Clear")
hint = _route_hint(state, _back_hint(state, hint))
lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")]
lines += [_styled(fit(" " + line, cols), line.style) for line in body[:height]]
# A long line is drawn whole, the terminal folding it into the rows
# after it (_text_lines): cut to the width, a copied path lost its end.
lines += [_styled(" " + line if line.style == "long" else fit(" " + line, cols), line.style)
for line in body[:height]]
lines += [_styled("")] * (rows - 1 - len(lines))
# Never the last cell of the screen: a terminal may scroll when it is written.
lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "dim"))
@@ -16970,7 +17091,14 @@ class _TuiTerminal:
"""Rewrite the lines that changed since the last frame, and only those."""
out = [] if self.frame else ["\x1b[H\x1b[2J"]
frame = [(str(line), getattr(line, "style", "")) for line in lines]
covered = 0
for index, (text, style) in enumerate(frame):
if style == "cont" and covered:
# A row the long line above folds into.
covered -= 1
continue
covered = (max(0, -(-cell_width(text) // max(1, cols)) - 1)
if style == "long" else 0)
if index < len(self.frame) and self.frame[index] == (text, style):
continue
text = screen_text(text)
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4"
DEFAULT_INSTALL_SHA256="c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 795560,
"sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4",
"size": 801988,
"sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 848109,
"sha256": "0a210d563bdb3a6f7ca87eaeb11d48a8ac07f1a1c580011f227fcccc5bba546e"
"size": 854537,
"sha256": "1ed15a4a76d79f5dcd3f9f53173721e3666b159defc49adbcd6622cb252d21a8"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 795560,
"sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4",
"size": 801988,
"sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 795560,
"sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4",
"size": 801988,
"sha256": "c3dcd0682ff917866b79f7718de048e16f13d03651111601e1816ec49f5703cb",
"executable": false
},
{
+2 -2
View File
@@ -5,8 +5,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-8 of 17
A/Enter Exit Up/Down Scroll
+4 -4
View File
@@ -5,11 +5,11 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
1-11 of 17
A/Enter Exit Up/Down Scroll
+4 -4
View File
@@ -978,8 +978,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
A Exit
@@ -991,8 +991,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
+4 -4
View File
@@ -978,8 +978,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
A Exit
@@ -991,8 +991,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
+4 -4
View File
@@ -978,8 +978,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
A Exit
@@ -991,8 +991,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
+6 -6
View File
@@ -924,8 +924,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-8 of 17
Enter Exit Up/Down Scroll
@@ -937,12 +937,12 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
1-11 of 17
Enter Exit Up/Down Scroll
=== result 80x24
+4 -4
View File
@@ -978,8 +978,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
A Exit
@@ -991,8 +991,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
+4 -4
View File
@@ -978,8 +978,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
Start Exit
@@ -991,8 +991,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
Details: /tmp/retrobios-x/install.log
+112 -10
View File
@@ -970,7 +970,9 @@ class Views(unittest.TestCase):
lines = install.tui_view(state, cols, rows)
self.assertEqual(len(lines), rows)
for line in lines:
self.assertLessEqual(install.cell_width(line), cols, line)
# A path or a command kept whole folds in the terminal.
if line.style != "long":
self.assertLessEqual(install.cell_width(line), cols, line)
self.assertTrue(all(ord(c) < 128 or c in "ファミコン" for c in line),
line)
self.assertLess(install.cell_width(lines[-1]), cols)
@@ -993,7 +995,8 @@ class Views(unittest.TestCase):
state.cols, state.rows = cols, rows
lines = install.tui_view(state, cols, rows)
for line in lines:
self.assertLessEqual(install.cell_width(line), cols, line)
if line.style != "long":
self.assertLessEqual(install.cell_width(line), cols, line)
self.assertLess(install.cell_width(lines[-1]), cols)
text = "\n".join(line.rstrip() for line in lines) + "\n"
both = (GOLDEN / f"{name}-{cols}x{rows}.txt").read_text(encoding="utf-8")
@@ -1763,9 +1766,9 @@ class AuditRoundTwo(unittest.TestCase):
real = install._text_lines
calls = []
def counted(items, width):
def counted(items, width, whole=False):
calls.append(len(items))
return real(items, width)
return real(items, width, whole)
with mock.patch.object(install, "_text_lines", counted):
press(state, "end")
@@ -3438,11 +3441,14 @@ class ThePagerCountsLines(unittest.TestCase):
with self.subTest(size=f"{cols}x{rows}"):
state = self.result_state(cols, rows)
width = min(cols, install.TUI_MAX_WIDTH) - 2
wrapped = install._text_lines(install._pager_items(state), width)
# The result keeps a path whole (whole=True): its rows after
# it are left to the terminal's folding ("cont").
wrapped = install._text_lines(install._pager_items(state), width, True)
last = [line for line in wrapped if line.style != "cont"][-1]
press(state, "end")
screen = install.tui_view(state, cols, rows)
body = [line.rstrip() for line in screen[2:-1]]
self.assertIn(" " + str(wrapped[-1]).rstrip(), body)
self.assertIn(" " + str(last).rstrip(), body)
if len(wrapped) > rows - 3:
self.assertTrue(any(re.fullmatch(rf" \d+-{len(wrapped)} of {len(wrapped)}",
line) for line in body), body)
@@ -3581,8 +3587,9 @@ class AuditFive(unittest.TestCase):
state = checked_state()
saves = {"files": 1, "folder": str(HOME / ".config" / "retroarch" / "saves" / "<core>")}
lines = install._saves_lines(state, saves)
self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to:",
"~/.config/retroarch/saves/<core>"])
# In words: "saves/<core>" read as a folder named <core>.
self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to a "
"folder per core under:", "~/.config/retroarch/saves"])
def test_the_shared_note_is_one_line(self):
state = self.long_list()
@@ -3798,7 +3805,8 @@ class TheSaveFolderLineFollowsTheChoice(unittest.TestCase):
SAVES = {"event": "saves", "platform": "retroarch", "files": 1,
"folder": "/home/user/MySaves/<core>"}
LINE = "1 file read from RetroArch's save directory goes to: ~/MySaves/<core>"
LINE = ("1 file read from RetroArch's save directory goes to a folder per core under: "
"~/MySaves")
def said(self, lines) -> str:
return " ".join(" ".join(lines).split())
@@ -5403,7 +5411,11 @@ class GameDataIsNoFirmwareAlarm(unittest.TestCase):
self.assertEqual(len(lines), 2, lines)
self.assertIn("needed to start its emulator: missing.bin", lines[0])
self.assertNotIn("Anims.j2a", lines[0])
self.assertIn("Game data not available here", lines[1])
# The folder it goes in, and what game data is (the Android run of
# 2026-10-27 said neither).
self.assertIn("Not available here, game data: files a game or a game engine reads",
lines[1])
self.assertIn(f"into {SYSTEM_DIR / 'jazz2' / 'Source'}{os.sep}", lines[1])
self.assertIn("Anims.j2a (Jazz Jackrabbit 2)", lines[1])
@@ -8868,6 +8880,96 @@ class AuditEighteenAndEleven(unittest.TestCase):
self.assertIn("Recalbox SHARE drive", block)
self.assertEqual(generate_installer_doc.render(page), page)
class TheAndroidResultReadsWhole(unittest.TestCase):
"""A run on Android 17 under Termux: the result named /sdcard and
/storage/emulated/0 for one storage, saves/<core> read as a folder named
<core>, a backup path and the restore command were split inside a word,
and the game data line named no folder."""
def setUp(self):
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, self.root, True)
for patcher in (mock.patch.dict(install._SPELLED, {}, clear=True),):
patcher.start()
self.addCleanup(patcher.stop)
def test_one_storage_is_named_one_way(self):
real = self.root / "storage" / "emulated" / "0"
(real / "RetroArch" / "system").mkdir(parents=True)
link = self.root / "sdcard"
link.symlink_to(real)
bios = link / "RetroArch" / "system"
self.assertEqual(install._destination_path(bios, "a.bin"),
real.resolve() / "RetroArch" / "system" / "a.bin")
kept = install.KeptFiles()
run = kept.plan(install._kept_slot(bios, "a.bin")[0], 3)
folder = kept.summary()[0]["folder"]
self.assertTrue(folder.startswith(str(link / "RetroArch" / "retrobios-backup")),
folder)
self.assertEqual(install.shown_path(run), folder)
self.assertNotIn(str(real), install.shown_path(real.resolve() / "RetroArch" / "saves"))
def test_a_folder_per_core_is_said_in_words(self):
where = "/sdcard/RetroArch/saves/<core>"
line = install.saves_line("RetroArch", 106, where)
self.assertEqual(line, "106 files read from RetroArch's save directory go to a "
"folder per core under /sdcard/RetroArch/saves")
self.assertEqual(install.save_where_words("/s/saves/<system>"),
("a folder per system under", "/s/saves"))
state = checked_state()
screen = install._saves_lines(state, {"files": 106, "folder": where})
self.assertNotIn("<core>", " ".join(screen))
self.assertIn("a folder per core under:", screen[0])
def result(self, cols: int) -> list:
state = _installed_state(Path("/sdcard/RetroArch/system"))
state.summary["kept"] = [{"folder": "/sdcard/RetroArch/retrobios-backup/"
"20261027-223524", "files": 3, "bytes": 10}]
state.cols, state.rows = cols, 40
return install.tui_view(state, cols, 40)
def test_a_path_or_a_command_is_never_split_inside_a_word(self):
for cols in (40, 50, 80):
with self.subTest(cols=cols):
lines = [str(line).strip() for line in self.result(cols)]
self.assertIn("/sdcard/RetroArch/retrobios-backup/20261027-223524", lines)
words = [word for line in lines for word in line.split()]
self.assertIn("--restore", words)
self.assertTrue(all(not word.endswith("/") for word in words), words)
state = _installed_state(Path("/sdcard/RetroArch/system"))
command = install._styled("curl -fsSL https://raw.githubusercontent.com/Abdess/"
"retrobios/main/install.sh | sh -s -- --restore "
"'/storage/emulated/0/a b/20261027-223524'", "command")
state.stack.append("result")
pieces = install._text_lines([command], 38, whole=True)
joined = [str(piece).strip() for piece in pieces if piece.style != "cont"]
self.assertIn("https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh",
joined)
self.assertIn("'/storage/emulated/0/a b/20261027-223524'", joined)
def test_a_long_line_folds_in_the_terminal(self):
terminal = install._TuiTerminal.__new__(install._TuiTerminal)
terminal.frame, written = [], []
terminal.write = written.append
long = install._styled("x" * 50, "long")
terminal.draw([long, install._styled("", "cont"), install._styled("next")], 40)
out = "".join(written)
self.assertIn("x" * 50, out)
self.assertNotIn("\x1b[2;1H", out)
self.assertIn("\x1b[3;1H", out)
def test_game_data_names_the_folder_it_goes_in(self):
entry = {"dest": "jazz2/Source/Anims.j2a", "required": True, "category": "game_data",
"system": "jazz-jackrabbit-2"}
bios = Path("/sdcard/RetroArch/system")
lines = install._omission_lines(1, [entry], bios)
said = " ".join(lines)
self.assertIn(f"into {bios / 'jazz2' / 'Source'}{os.sep}", said)
self.assertIn("files a game or a game engine reads", said)
self.assertNotIn("Needed to start an emulator", said)
class NothingFromOutsideReachesTheTerminalRaw(unittest.TestCase):
"""code_audit_18 P2-1: a pack's member path, a --manifest dest, the
names a list records, a card's label and a retroarch.cfg folder reached
+7 -2
View File
@@ -197,7 +197,12 @@ progress, and a second one stops at once. Select+Start on a handheld ends the
installer with a signal (SIGTERM), which stops it the same way. Either way the
result says how many files were installed, and it stays printed in the terminal
once the screen closes, with the command that repeats the run. A run that failed
keeps its log and names it, and every other run leaves nothing behind.
keeps its log and names it, and every other run leaves nothing behind. A path
or a command on the result is never split inside a word: one longer than the
window takes a line of its own, which the terminal folds, and a command breaks
only between its arguments. Every path a run names is spelled the way its BIOS
folder was named, the backup folder and the save directory included
(`/sdcard/RetroArch` on Android, not the `/storage/emulated/0` it links to).
It opens only for an install, at a terminal of at least 40 columns and 12 rows
(a smaller window says so, with its size) whose `TERM` is set, is not `dumb`
@@ -1527,7 +1532,7 @@ other schemas under `api/v1/schemas/`.
| `data` | `platform`, `files`, `bytes` | Right after `declared`: the data-tree files to fetch (the `data_missing` of `check`) and the bytes they write once in place |
| `found` | `platform`, `dest`, `folder`, `path`, `member`, `reason` | Each file to install that this machine already holds, after `check` and before anything is written, in `--check` and `--dry-run` too. `folder` is the `--from` folder as given, or the `bios_dir` of the check when the BIOS folder holds the file at another place, and then `reason` says where in words (`nested folder: ...` for a misplaced file, `the same file is at ...` for one the list installs there too). `path` is the file, and `member` the ZIP member holding the bytes when `path` is an archive, an empty string otherwise. Its copy is reported by a `downloaded` event |
| `unreadable` | `platform`, `folder`, `path`, `member`, `reason` | Each file in a `--from` folder that could not be read and may be one the run still needs, right after the `found` events: its size is that of a file no copy was found for, or unknown (a folder that could not be listed, a ZIP that would not open). `member` names the ZIP member, or is empty. `reason` says why in words (`Permission denied`, `it is encrypted, a password is needed`) |
| `saves` | `platform`, `files`, `folder` | After `choices` and before `checking`, when the files the run installs (once `--system` and the other choices narrowed the list) hold some read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `<system>` and `<core>` stand for one folder per system or per core. When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) |
| `saves` | `platform`, `files`, `folder` | After `choices` and before `checking`, when the files the run installs (once `--system` and the other choices narrowed the list) hold some read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `<system>` and `<core>` stand for one folder per system or per core (the screen and `--plain` say it in words: "a folder per core under /sdcard/RetroArch/saves"). When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) |
| `download` | `platform`, `files`, `bytes` | Before the downloads start. Counts the files copied from this machine too |
| `progress` | `files_done`, `files_total`, `bytes_done`, `bytes_total` | At most every half second while bytes arrive |
| `transfer` | `bytes_local`, `bytes_network` | Right after each `progress` event, and once when the files of a `download` event are done. It splits `bytes_done`: `bytes_local` came from this machine (a copy found by `--from` or in the BIOS folder, the pack, a partial copy resumed), `bytes_network` from the network. A download speed is `bytes_network` over the time elapsed |