mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 01:53:56 -06:00
chore: merge installer audit batch
This commit is contained in:
commit
a4e09b90df
13 files changed
+751
-101
No files matched your search
+6
-2
@@ -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 = "f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63"
|
||||
$defaultInstallSha256 = "d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
@@ -42,7 +42,11 @@
|
||||
}
|
||||
$temporary = Join-Path ([IO.Path]::GetTempPath()) ("retrobios-install-{0}.py" -f [Guid]::NewGuid())
|
||||
try {
|
||||
Invoke-WebRequest -Uri $uri -OutFile $temporary -UseBasicParsing
|
||||
# A server that takes the connection and never answers (a
|
||||
# captive portal, a proxy) held the window forever with no
|
||||
# line: the wait for an answer stops after 30 s, as curl's
|
||||
# --connect-timeout in install.sh.
|
||||
Invoke-WebRequest -Uri $uri -OutFile $temporary -UseBasicParsing -TimeoutSec 30
|
||||
} catch {
|
||||
# The raw WebException read as a crash and named no next step.
|
||||
throw ("The installer could not be downloaded from {0}: {1} " +
|
||||
|
||||
+224
-54
@@ -104,6 +104,14 @@ def _checked_base_url(value: str) -> str:
|
||||
BASE_URL = _checked_base_url(os.environ.get("RETROBIOS_BASE_URL", DEFAULT_BASE_URL))
|
||||
MANIFEST_URL = f"{BASE_URL}/install/{{platform}}.json"
|
||||
TARGETS_URL = f"{BASE_URL}/install/targets/{{platform}}.json"
|
||||
# The platforms that publish a core list per board, install/targets/ in the
|
||||
# repository: a test holds the two equal. Offline, a platform outside it
|
||||
# has no hardware targets, where the run sent the user after a list that
|
||||
# exists nowhere.
|
||||
TARGET_PLATFORMS = frozenset({
|
||||
"batocera", "emudeck", "knulli", "lakka", "recalbox", "retroarch", "retrobat",
|
||||
"retrodeck", "retropie", "rocknix", "romm",
|
||||
})
|
||||
RAW_FILE_URL = f"{BASE_URL}/{{path}}"
|
||||
RELEASE_URL = (
|
||||
"https://github.com/Abdess/retrobios/releases/download/large-files/{asset}"
|
||||
@@ -138,8 +146,10 @@ OMISSION_REASONS = {
|
||||
# says, and as the installer words it. Firmware carries no category.
|
||||
GAME_DATA = "game_data"
|
||||
CATEGORY_LABELS = {GAME_DATA: "game data"}
|
||||
_SHA1_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
# The manifest writes its digests in lowercase, as hashlib gives them, and
|
||||
# every comparison reads them as written.
|
||||
_SHA1_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
# The region vocabulary of scripts/region.py, which this file cannot import.
|
||||
# A test holds the two copies equal.
|
||||
@@ -1437,8 +1447,9 @@ def _parse_bash_var(path: Path, key: str) -> str | None:
|
||||
if line.startswith(f"{key}="):
|
||||
_, _, value = line.partition("=")
|
||||
return _shell_unquote(value)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
# A settings.sh a sudo EmuDeck run left to root hid the setup.
|
||||
_note_refused(path, exc)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1447,36 +1458,31 @@ def _parse_bash_var(path: Path, key: str) -> str | None:
|
||||
# recalbox-system.mk RECALBOX_SYSTEM_PLATFORM, ROCKNIX's scripts/image puts
|
||||
# HW_DEVICE in os-release (RK3326, our rk3326). Lakka's scripts/image writes
|
||||
# LIBREELEC_DEVICE when the image is built for a device (RPi4) and always
|
||||
# LIBREELEC_PROJECT (Generic): the first one set is the board.
|
||||
# LIBREELEC_PROJECT (Generic): the first one set is the board. KNULLI moves
|
||||
# /usr/share/batocera to /usr/share/knulli (knulli-linux 57afa662,
|
||||
# board/scripts/post-build-script.sh:264-278) and writes KNULLI_SYSTEM_ARCH
|
||||
# to knulli.arch (package/system/knulli-system/knulli-system.mk:98-99).
|
||||
_BOARD_SOURCES: dict[str, tuple[str, str]] = {
|
||||
"batocera": ("file", "/usr/share/batocera/batocera.arch"),
|
||||
"recalbox": ("file", "/recalbox/recalbox.arch"),
|
||||
"rocknix": ("os_release", "HW_DEVICE"),
|
||||
"lakka": ("os_release", "LIBREELEC_DEVICE|LIBREELEC_PROJECT"),
|
||||
"knulli": ("file", "/usr/share/knulli/knulli.arch"),
|
||||
"retropie": ("retropie", ""),
|
||||
}
|
||||
|
||||
|
||||
# A platform that names its board without publishing a core list per board.
|
||||
# Knulli moves /usr/share/batocera to /usr/share/knulli (knulli-linux
|
||||
# 57afa662, board/scripts/post-build-script.sh:264-278) and writes its board
|
||||
# to knulli.arch (package/system/knulli-system/knulli-system.mk:98-99).
|
||||
_DERIVATIVE_BOARDS: dict[str, tuple[str, str]] = {
|
||||
"knulli": ("Knulli", "/usr/share/knulli/knulli.arch"),
|
||||
# The board a system's own name stands for, where the two differ: the
|
||||
# target lists are named for the board files (configs/knulli-rk3566.board),
|
||||
# and the arch file for the TARGET flag the board sets. knulli-rk3566.board
|
||||
# sets BR2_PACKAGE_BATOCERA_TARGET_RK3568, which knulli-system.mk:22-23
|
||||
# (20260511) writes as rk3568. batocera-zen3.board sets TARGET_ZEN3, which
|
||||
# batocera-system.mk:56-57 (batocera-43.1) writes as x86-64-v3.
|
||||
_BOARD_NAMES: dict[str, dict[str, str]] = {
|
||||
"batocera": {"x86-64-v3": "zen3"},
|
||||
"knulli": {"rk3568": "rk3566"},
|
||||
}
|
||||
|
||||
|
||||
def derivative_board(platform: str) -> tuple[str, str] | None:
|
||||
"""(derivative, board) when a derivative of the platform names its board."""
|
||||
name, where = _DERIVATIVE_BOARDS.get(platform, ("", ""))
|
||||
if not where:
|
||||
return None
|
||||
try:
|
||||
board = Path(where).read_text(encoding="utf-8", errors="replace").strip().lower()
|
||||
except OSError:
|
||||
return None
|
||||
return (name, board) if board else None
|
||||
|
||||
|
||||
# The board a card names on a partition a computer reads. Batocera writes
|
||||
# its image's board to boot/batocera.board on the BATOCERA boot partition,
|
||||
# the FAT one beside SHARE (batocera-43.1, board/batocera/scripts/
|
||||
@@ -1515,9 +1521,55 @@ def detect_board(platform: str) -> str | None:
|
||||
elif kind == "os_release":
|
||||
release = _parse_os_release()
|
||||
value = next((release[key] for key in where.split("|") if release.get(key)), "")
|
||||
elif kind == "retropie":
|
||||
value = _retropie_board() or ""
|
||||
else:
|
||||
return None
|
||||
return value.strip().lower() or None
|
||||
board = value.strip().lower()
|
||||
# A board no TARGET line names is written "unknown" (KNULLI's r16).
|
||||
if board == "unknown":
|
||||
return None
|
||||
return _BOARD_NAMES.get(platform, {}).get(board, board) or None
|
||||
|
||||
|
||||
# RetroPie names its board as get_platform does (RetroPie-Setup f5ebfba6,
|
||||
# scriptmodules/system.sh:396-424 and 425-513): a Pi by the BCM Hardware
|
||||
# line of /proc/cpuinfo or raspberrypi in its device tree, its model by the
|
||||
# CPU field (bits 12-15) of a revision code with bit 23 set, rpi1 without
|
||||
# it. With neither a Hardware line nor a device tree, the machine: x86_64.
|
||||
# Read only where RetroPie is installed (rootdir /opt/retropie,
|
||||
# retropie_packages.sh:17): a computer preparing a Pi's card is no Pi.
|
||||
_RPI_MODELS = {0: "rpi1", 1: "rpi2", 2: "rpi3", 3: "rpi4", 4: "rpi5"}
|
||||
|
||||
|
||||
def _retropie_board() -> str | None:
|
||||
"""The target RetroPie's own reading of this machine names, or None."""
|
||||
if not _is_dir(_on_device("/opt/retropie")):
|
||||
return None
|
||||
try:
|
||||
cpuinfo = _on_device("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
cpuinfo = ""
|
||||
fields = {key.strip(): value.strip() for key, sep, value in
|
||||
(line.partition(":") for line in cpuinfo.splitlines()) if sep}
|
||||
tree = _on_device("/proc/device-tree/compatible")
|
||||
try:
|
||||
compatible = tree.read_bytes().decode("ascii", errors="replace")
|
||||
except OSError:
|
||||
compatible = None
|
||||
hardware = fields.get("Hardware", "")
|
||||
if hardware.startswith("BCM") or (not hardware and compatible is not None
|
||||
and "raspberrypi" in compatible):
|
||||
try:
|
||||
revision = int(fields.get("Revision", ""), 16)
|
||||
except ValueError:
|
||||
return None
|
||||
if not (revision >> 23) & 1:
|
||||
return "rpi1"
|
||||
return _RPI_MODELS.get((revision >> 12) & 15)
|
||||
if hardware or compatible is not None:
|
||||
return None
|
||||
return "x86_64" if platform.machine() == "x86_64" else None
|
||||
|
||||
|
||||
def _parse_json_path(path: Path, *keys: str) -> str | None:
|
||||
@@ -1549,8 +1601,8 @@ def _parse_ps1_var(path: Path, key: str) -> str | None:
|
||||
if check.startswith(f"{normalized}="):
|
||||
_, _, value = check.partition("=")
|
||||
return value.strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
_note_refused(path, exc)
|
||||
return None
|
||||
|
||||
|
||||
@@ -2426,6 +2478,12 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]:
|
||||
|
||||
# (folder, why in words) for each folder detection could not look in.
|
||||
_DETECTION_REFUSED: list[tuple[Path, str]] = []
|
||||
# Those of them that failed for another cause than a permission: a read
|
||||
# error from a failing card, a link that loops.
|
||||
_DETECTION_UNREADABLE: set[Path] = set()
|
||||
# The errors that say a place is not there, as against one that is there
|
||||
# and could not be read.
|
||||
_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EINVAL, errno.ENAMETOOLONG)
|
||||
# The platforms whose configuration names a folder with a control character.
|
||||
_UNNAMEABLE: list[str] = []
|
||||
|
||||
@@ -2434,13 +2492,18 @@ def refused_words(folder: Path, why: str, shown: str = "") -> str:
|
||||
"""The line said for a folder detection could not look in. shown is
|
||||
the folder as the screen writes it (~/.config/EmuDeck), the full path
|
||||
by default."""
|
||||
if folder in _DETECTION_UNREADABLE:
|
||||
return (f"Could not look for setups: {shown or folder} could not be read ({why}). "
|
||||
"A failing card or a link that loops gives this: check it, or name the "
|
||||
"BIOS folder with --dest.")
|
||||
return (f"Could not look for setups: this user cannot open {shown or folder} ({why}). "
|
||||
"Run as its owner (su - or sudo -H), or name the BIOS folder "
|
||||
"with --dest.")
|
||||
|
||||
|
||||
def _note_refused(path: Path, exc: OSError) -> None:
|
||||
"""Say once that a folder detection looks in is closed to this user.
|
||||
"""Say once that a folder detection looks in is closed to this user,
|
||||
or could not be read.
|
||||
|
||||
A probe refused there counts as absent and the others go on: a config
|
||||
folder a sudo run left at 0700 (~/.config/EmuDeck) hid a RetroArch whose
|
||||
@@ -2448,12 +2511,16 @@ def _note_refused(path: Path, exc: OSError) -> None:
|
||||
not read.
|
||||
"""
|
||||
if (not getattr(_SETUP_SEARCH, "active", False) or getattr(_SETUP_SEARCH, "quiet", False)
|
||||
or not isinstance(exc, PermissionError)):
|
||||
or exc.errno in _ABSENT_ERRNOS):
|
||||
return
|
||||
refused = closed_folder(path) or (path, exc.strerror or str(exc))
|
||||
if refused in _DETECTION_REFUSED:
|
||||
return
|
||||
_DETECTION_REFUSED.append(refused)
|
||||
if not isinstance(exc, PermissionError):
|
||||
# An EIO on retroarch.cfg counted as absent: the card about to be
|
||||
# written to failed, and the run said nothing was detected.
|
||||
_DETECTION_UNREADABLE.add(refused[0])
|
||||
words = refused_words(*refused)
|
||||
EVENTS.emit("unsearched", folder=str(refused[0]), reason=refused[1], message=words)
|
||||
print(f" {screen_text(words)}", file=sys.stderr)
|
||||
@@ -3592,9 +3659,6 @@ def _check_entry(
|
||||
return size, largest
|
||||
|
||||
|
||||
_SHA1_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
def _check_data_directory(entry: object, where: str) -> None:
|
||||
"""A data directory: its source archive, its mapping, its file list."""
|
||||
if not isinstance(entry, dict):
|
||||
@@ -3631,7 +3695,7 @@ def _check_data_directory(entry: object, where: str) -> None:
|
||||
if (
|
||||
not isinstance(meta, dict)
|
||||
or not isinstance(meta.get("sha1"), str)
|
||||
or not _SHA1_RE.match(meta["sha1"])
|
||||
or not _SHA1_RE.fullmatch(meta["sha1"])
|
||||
or not isinstance(meta.get("size"), int)
|
||||
or not 0 <= meta["size"] <= MAX_DOWNLOAD_SIZE
|
||||
):
|
||||
@@ -4196,6 +4260,8 @@ def fetch_targets(plat: str) -> "dict | None":
|
||||
held = local_targets(plat)
|
||||
if held is not None:
|
||||
return held
|
||||
if LOCAL.offline and plat not in TARGET_PLATFORMS:
|
||||
return {}
|
||||
if LOCAL.offline:
|
||||
source = (f"{LOCAL.pack.name} carries none" if LOCAL.pack is not None
|
||||
else f"{offline_named()} makes no request")
|
||||
@@ -5038,10 +5104,8 @@ def _probed(probe: Callable[[], bool], path: Path) -> bool:
|
||||
# raised: a refusal is told from absence by the stat it hides.
|
||||
try:
|
||||
os.stat(path)
|
||||
except PermissionError as exc:
|
||||
except OSError as exc:
|
||||
_note_refused(path, exc)
|
||||
except OSError:
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
@@ -5695,8 +5759,9 @@ def _download_one(
|
||||
reason = f"HTTP {exc.code}"
|
||||
if exc.code in (403, 429):
|
||||
reason += " (GitHub rate limit or access refused)"
|
||||
if exc.code >= 500 or exc.code == 429:
|
||||
reason = NetworkReason(reason)
|
||||
reason = (NetworkReason if exc.code >= 500 or exc.code == 429
|
||||
else HttpReason)(reason)
|
||||
reason.url = url
|
||||
if verbose:
|
||||
print(f" Attempt {attempt} failed: {exc}", file=sys.stderr)
|
||||
if tmp_path is not None and partial is None and tmp_path.exists():
|
||||
@@ -6200,6 +6265,32 @@ class NetworkReason(str):
|
||||
"""A failure the connection caused: cut, timed out, refused by a server
|
||||
or a proxy. The summary names the network instead of the folder."""
|
||||
|
||||
url = ""
|
||||
|
||||
|
||||
class HttpReason(str):
|
||||
"""A file the server answered for with an error of its own (HTTP 404):
|
||||
the summary names the address, where "HTTP 404" alone told the user
|
||||
neither what failed nor that running again changes nothing."""
|
||||
|
||||
url = ""
|
||||
|
||||
|
||||
def _http_failure_lines(failures: list[tuple[str, str]]) -> list[str]:
|
||||
"""The address the first refused file was asked at, and what chose it:
|
||||
a mirror named with RETROBIOS_BASE_URL that holds the lists and not yet
|
||||
the files, or a list out of step with the files it names."""
|
||||
refused = [reason for _dest, reason in failures if isinstance(reason, HttpReason)]
|
||||
if not refused:
|
||||
return []
|
||||
more = f" (and {_plural(len(refused) - 1, 'other file')})" if len(refused) > 1 else ""
|
||||
lines = [f" The server answered {refused[0]} for {refused[0].url}{more}."]
|
||||
if os.environ.get("RETROBIOS_BASE_URL"):
|
||||
return lines + _address_lines(refused[0].url)[1:]
|
||||
return lines + [" The file list names files the server does not hold, and running "
|
||||
"again changes nothing until the two match.",
|
||||
f" More help: {TROUBLESHOOTING_PAGE}"]
|
||||
|
||||
|
||||
def _transfer_failure(exc: BaseException) -> bool:
|
||||
"""Whether a download error came from the connection, not the disk."""
|
||||
@@ -6345,8 +6436,12 @@ def _report_outcome(
|
||||
if any(reason == READ_ONLY for _dest, reason in failures or []):
|
||||
advice.append(" Files marked read-only are in the way: clear that attribute "
|
||||
"(or delete them), then run again.")
|
||||
advice += _http_failure_lines(failures or [])
|
||||
if any(isinstance(reason, NetworkReason) for _dest, reason in failures or []):
|
||||
advice.append(NETWORK_FAILURE_ADVICE)
|
||||
if os.environ.get("RETROBIOS_BASE_URL"):
|
||||
# A mirror that is down read as this machine's connection.
|
||||
advice += _address_lines(BASE_URL)[1:]
|
||||
if any(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []):
|
||||
# The same run fails the same way: "retry only those" sent a phone
|
||||
# user round the loop with nothing changed.
|
||||
@@ -6408,7 +6503,7 @@ def _report_outcome(
|
||||
|
||||
if errors and not downloaded:
|
||||
say(f"\nNothing was installed. {_plural(errors, 'file')} failed.")
|
||||
if failures and all(isinstance(r, (NetworkReason, OfflineReason))
|
||||
if failures and all(isinstance(r, (NetworkReason, HttpReason, OfflineReason))
|
||||
for _d, r in failures):
|
||||
# The folder was never the problem: naming it as the place being
|
||||
# written to sent people looking at their disk.
|
||||
@@ -12073,16 +12168,35 @@ def _copy_reason(entry: dict, copy: LocalCopy, bios_path: Path, known: set[str])
|
||||
return _place_reason(entry, copy.path, copy.index, expected, known)[0]
|
||||
|
||||
|
||||
def _twin_in_place(entry: dict, files: list[dict], bios_path: Path) -> bool:
|
||||
"""Whether the name a case variant shares with another entry, but for
|
||||
the case (bios.col and BIOS.col), is in place."""
|
||||
folded = str(entry.get("dest", "")).casefold()
|
||||
for twin in files:
|
||||
if twin is entry or twin.get("case_variant") or \
|
||||
str(twin.get("dest", "")).casefold() != folded:
|
||||
continue
|
||||
try:
|
||||
if _is_file(_entry_path(bios_path, twin)):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _found_elsewhere(
|
||||
entry: dict, index: FolderIndex, bios_path: Path, files: list[dict],
|
||||
known: set[str], zipped: _ZippedLookup | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""(reason, folder too deep) for an entry missing at its place."""
|
||||
dest = str(entry.get("dest", ""))
|
||||
if entry.get("case_variant"):
|
||||
if entry.get("case_variant") and _twin_in_place(entry, files, bios_path):
|
||||
# A pack is read on Windows and macOS too, where two names that
|
||||
# differ only by case are one file: it carries one of the pair. Moving
|
||||
# the other copy here would take it from the core that opens it.
|
||||
# Only its twin in place says a pack was used: on an empty folder the
|
||||
# note named a pack never downloaded and pushed the BIOS files the
|
||||
# user looked for into "... and N more".
|
||||
return ("a second name the pack leaves out: Windows and macOS see it "
|
||||
"as the same file. Run the command without --check to add it"), ""
|
||||
name = dest.rsplit("/", 1)[-1]
|
||||
@@ -12976,7 +13090,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--target",
|
||||
metavar="NAME",
|
||||
help="hardware target for core filtering (switch, rpi4, ...). "
|
||||
"On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system",
|
||||
"On Batocera, Recalbox, ROCKNIX, Lakka, KNULLI and RetroPie the board is read "
|
||||
"from the system",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--every-board",
|
||||
@@ -13316,8 +13431,14 @@ def main() -> None:
|
||||
# The runs the full-screen interface starts read it from here, and
|
||||
# the words name the option given.
|
||||
os.environ[OFFLINE_VARIABLE] = OFFLINE_FLAG_VALUE
|
||||
named = next((plat for plat in AVAILABLE_PLATFORMS if args.platform and _name_key(
|
||||
args.platform) in (_name_key(plat), _name_key(display_name(plat)))), "")
|
||||
if offline_requested() and args.target and named and named not in TARGET_PLATFORMS:
|
||||
refuse_name(named, f"Error: {display_name(named)} has no hardware targets: run "
|
||||
"again without --target.")
|
||||
if (offline_requested() and (args.target or args.list_targets)
|
||||
and not (args.manifest or args.from_pack)):
|
||||
and not (args.manifest or args.from_pack)
|
||||
and not (named and named not in TARGET_PLATFORMS)):
|
||||
# No list can be at hand: a pack carries one, and so can the folder
|
||||
# of a --manifest (targets/<platform>.json).
|
||||
parser.error("--target and --list-targets read a core list from the network, "
|
||||
@@ -13625,6 +13746,30 @@ def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest",
|
||||
return True
|
||||
|
||||
|
||||
def _check_cores_dir(folder: Path) -> None:
|
||||
"""Refuse a --cores-dir that cannot be listed, saying why: a typo
|
||||
(core/ for cores/) or a closed folder read as an empty one, and the
|
||||
run said "none in" and asked for the --cores-dir it was given."""
|
||||
try:
|
||||
with os.scandir(folder):
|
||||
return
|
||||
except FileNotFoundError:
|
||||
why = "does not exist"
|
||||
except NotADirectoryError:
|
||||
why = "is not a folder"
|
||||
except PermissionError as exc:
|
||||
closed = closed_folder(folder)
|
||||
why = (f"cannot be opened by this user ({exc.strerror or exc})"
|
||||
if closed is None or closed[0] == folder
|
||||
else f"cannot be opened: {closed_folder_words(folder, closed)}")
|
||||
except OSError as exc:
|
||||
why = f"could not be read ({exc.strerror or exc})"
|
||||
print(f"Error: --cores-dir {screen_text(str(folder))} {why}.", file=sys.stderr)
|
||||
print(" Name the folder RetroArch loads cores from (libretro_directory in "
|
||||
"retroarch.cfg, cores/ beside RetroArch by default).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _no_core_found(plat: str, bios_path: Path, searched: list[Path]) -> None:
|
||||
"""Refuse --installed-cores with no core, and say how to point at them.
|
||||
|
||||
@@ -13702,6 +13847,12 @@ _SHARE_RULES: tuple[_OsRule, ...] = (
|
||||
_OsRule("rocknix", "ROCKNIX", "file_matches",
|
||||
(".config/system/configs/system.cfg", r"^root\.password=rocknix\s*$"),
|
||||
"roms/bios"),
|
||||
# MiSTer's is its exFAT partition, /media/fat (Main_MiSTer e0a234f9,
|
||||
# file_io.cpp:1177), whose root holds the MiSTer binary and menu.rbf
|
||||
# (Distribution_MiSTer ce602319) and which reads each core's files
|
||||
# below games/ (GAMES_DIR, file_io.h:171). A card prepared on a
|
||||
# computer got RetroArch's list, or MiSTer's at its root.
|
||||
_OsRule("misterfpga", "MiSTer FPGA", "file_exists", ("MiSTer", "menu.rbf"), "games"),
|
||||
)
|
||||
|
||||
|
||||
@@ -14157,6 +14308,12 @@ def _card_and_below(setup: CardSetup, path: Path) -> tuple[Path, bool]:
|
||||
name: an Onion card mounted as BIOS was read as a BIOS folder, its
|
||||
parent checked as the card and refused."""
|
||||
below = setup.below
|
||||
if (not below and setup.name in _GAMES_PART_CARDS and path.name.casefold() == "bios"
|
||||
and not _is_dir(path / "bios") and _card_is(setup, path)[0] == "unknown"
|
||||
and not _card_boundary(path)):
|
||||
# Its list writes bios/ below the games part's root: its BIOS
|
||||
# folder named got bios/bios, or was refused as inside the card.
|
||||
return path.parent, True
|
||||
depth = len(PurePosixPath(below).parts)
|
||||
if (below and _ends_with(path, below) and _card_is(setup, path)[0] == "unknown"
|
||||
and not any(_card_boundary(folder)
|
||||
@@ -14313,7 +14470,8 @@ def card_choice(named: str, plat: str, root: Path) -> CardChoice:
|
||||
f"{setup.name} card, whose root is {shown_path(holder[2])}",
|
||||
short=f"That is inside {_a(setup.name)} card.",
|
||||
below=setup.below, root=holder[2])
|
||||
folder = root if inside or not setup.below else card / setup.below
|
||||
folder = (card if inside and not setup.below
|
||||
else root if inside or not setup.below else card / setup.below)
|
||||
if kind == "other":
|
||||
return _not_the_card(setup, root, card, what)
|
||||
if kind == "own":
|
||||
@@ -14949,6 +15107,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
cores_found: dict[tuple[str, Path], list[str]] = {}
|
||||
for plat_name, bios_path in platforms:
|
||||
if plat_name in CORE_PLATFORMS:
|
||||
if args.cores_dir:
|
||||
_check_cores_dir(args.cores_dir)
|
||||
searched = [args.cores_dir] if args.cores_dir else core_dirs(plat_name, bios_path)
|
||||
cores_found[(plat_name, bios_path)] = installed_cores(searched)
|
||||
elif args.installed_cores:
|
||||
@@ -15044,12 +15204,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
print(f" {words}")
|
||||
EVENTS.emit("board", platform=plat_name, board=board,
|
||||
applied=target == board, message=words)
|
||||
else:
|
||||
derived = derivative_board(plat_name)
|
||||
if derived:
|
||||
print(f" {derived[0]} board {derived[1]} detected. {derived[0]} "
|
||||
"builds its own set of cores and publishes no core list "
|
||||
"per board: installing the files of every board")
|
||||
if target:
|
||||
targets = target_lists.get(plat_name)
|
||||
if targets is None:
|
||||
@@ -15419,7 +15573,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
lacking = pack_lacks([f for f in to_download if id(f) not in copies])
|
||||
if lacking:
|
||||
print(f"\n {pack_lacks_words(len(lacking), len(to_download) - len(copies))}")
|
||||
print(f"\n {_plural(len(to_download), 'file')} "
|
||||
# One total with the count above it, the data files among
|
||||
# them as the screen counts them: "0 in place, 10805 missing"
|
||||
# stood over "8164 files to download".
|
||||
print(f"\n {_plural(len(to_download) + trees_missing, 'file')} "
|
||||
+ ("not in place:" if offline or LOCAL.pack is not None
|
||||
else "to download:"))
|
||||
for line in check_lines(missing, mismatched, files, manifest, bios_path,
|
||||
@@ -15427,9 +15584,16 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
print(line)
|
||||
for line in unreadable_lines(unread, args.verbose):
|
||||
print(line)
|
||||
print(_check_fetch_line(len(to_download) - len(copies), "the BIOS folder",
|
||||
len(lacking)))
|
||||
# The data files need the internet too: offline, "8164 files
|
||||
# need the internet" stood under "10805 files not in place".
|
||||
trees_fetched = trees_missing if LOCAL.pack is None else 0
|
||||
print(_check_fetch_line(len(to_download) - len(copies) + trees_fetched,
|
||||
"the BIOS folder", len(lacking)))
|
||||
check_pending += len(to_download)
|
||||
elif trees_missing:
|
||||
# The screen's headline: "Everything is in place." stood
|
||||
# above "0/169 data files present" and exit code 1.
|
||||
print(f"\n {_plural(trees_missing, 'data file')} to fetch")
|
||||
else:
|
||||
print(f"\n {EVERYTHING_IN_PLACE}")
|
||||
absent = _absent_required(omitted_files, bios_path)
|
||||
@@ -19220,13 +19384,14 @@ def setup_list(setups: list, mounted: set, needle: str = "",
|
||||
continue
|
||||
setup = card_setup(plat, plat)
|
||||
label = display_name(plat)
|
||||
if setup and _found_device(setups, mounted, plat, setup.name) is None:
|
||||
if (setup and plat not in _DESK_CARDS
|
||||
and _found_device(setups, mounted, plat, setup.name) is None):
|
||||
label += ", SD card"
|
||||
# The systems and devices the filter named, else a card's devices.
|
||||
shown = named or (list(_setup_devices(plat, os_type)) if setup else [])
|
||||
row = TuiRow("platform", label, ", ".join(shown), plat, narrow=_device_forms(shown),
|
||||
cut=shown[0] if shown else "")
|
||||
if setup:
|
||||
if setup and plat not in _DESK_CARDS:
|
||||
handheld.append((label, row))
|
||||
else:
|
||||
desktop.append(row)
|
||||
@@ -19258,6 +19423,11 @@ def setup_list(setups: list, mounted: set, needle: str = "",
|
||||
return [*desktop, *cards_rows]
|
||||
|
||||
|
||||
# The systems that run from an SD card on a board kept on a desk, not in
|
||||
# the hand: their row stays among the platforms, and the handhelds' block,
|
||||
# which leads when nothing is found here, keeps Onion four presses away.
|
||||
_DESK_CARDS = frozenset({"misterfpga"})
|
||||
|
||||
# The setups a computer runs, by its system, first in the setup list when
|
||||
# nothing was found here. RetroBat runs on Windows alone (retrobat.org), and
|
||||
# under WSL its folder is the Windows side's (_os_default_dests). EmuDeck has
|
||||
|
||||
+23
-3
@@ -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="f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63"
|
||||
DEFAULT_INSTALL_SHA256="d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
@@ -34,7 +34,13 @@ cleanup() {
|
||||
rmdir -- "$TEMP_DIRECTORY" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
# A signal ends the run with the code a shell gives it, 130 for Ctrl+C:
|
||||
# trapped by cleanup alone, the run went on, and Ctrl+C during the download
|
||||
# was told as a network failure, with code 1.
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then
|
||||
install_url=${RETROBIOS_INSTALL_URL:-$DEFAULT_INSTALL_URL}
|
||||
@@ -124,11 +130,25 @@ fi
|
||||
# A Python can be built without ssl, unicodedata or zlib, and the installer
|
||||
# needs all three: a python3 lacking ssl was chosen over a full python and
|
||||
# every download then failed as if the network were down.
|
||||
# A python3.X on PATH counts too, newest first: Buster and Ubuntu 18.04
|
||||
# keep python3 at 3.7 or 3.6 beside the python3.8 the advice below has the
|
||||
# user install, and the same advice looped.
|
||||
versioned_pythons() {
|
||||
(
|
||||
IFS=:
|
||||
for dir in $PATH; do
|
||||
for path in "$dir"/python3.[0-9]*; do
|
||||
[ -x "$path" ] && basename -- "$path"
|
||||
done
|
||||
done
|
||||
) | grep -E '^python3\.[0-9]+$' | sort -t . -k 2,2nr -u
|
||||
}
|
||||
|
||||
find_python() {
|
||||
PYTHON=""
|
||||
PYTHON_LACKING=""
|
||||
PYTHON_OLD=""
|
||||
for command_name in python3 python; do
|
||||
for command_name in python3 python $(versioned_pythons); do
|
||||
command -v "$command_name" >/dev/null 2>&1 || continue
|
||||
if ! "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' \
|
||||
2>/dev/null; then
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"files":[["128-0.rom",16384,"bios"],["128-1.rom",16384,"bios"],["128-spanish-0.rom",16384,"bios"],["128-spanish-1.rom",16384,"bios"],["22vp931.zip",1405,"bios"],["32X_G_BIOS.bin",256,"bios"],["32X_M_BIOS.bin",2048,"bios"],["32X_S_BIOS.bin",1024,"bios"],["3c505.zip",10896,"bios"],["3do_arcade_saot.bin",524288,"bios"],["3xtwin.zip",1417,"bios"],["48.rom",16384,"bios"],["4dparprn.zip",409,"bios"],["5200.rom",2048,"bios"],["64DD_IPL.bin",4194304,"bios"],["6570-036.rom",2048,"bios"],["68HC05.rom",8192,"bios"],["7800 BIOS (E).rom",16384,"bios"],["7800 BIOS (U).rom",4096,"bios"],["ATARIBAS.ROM",8192,"bios"],["ATARIOSA.ROM",10240,"bios"],["ATARIOSB.ROM",10240,"bios"],["ATARIXL.ROM",16384,"bios"],["Azahar/Azahar/nand/private/movable.sed",320,"bios"],["Azahar/Azahar/nand/rw/sys/LocalFriendCodeSeed_B",272,"bios"],["Azahar/Azahar/nand/rw/sys/SecureInfo_A",273,"bios"],["Azahar/Azahar/sysdata/keys.txt",7424,"bios"],["Azahar/Azahar/sysdata/otp.bin",256,"bios"],["Azahar/Azahar/sysdata/seeddb.bin",53264,"bios"],["Azahar/Azahar/sysdata/shared_font.bin",3145728,"bios"],["BB01R4_OS.ROM",16384,"bios"],["BBKEmu/A4980/8.BIN",2097152,"bios"],["BBKEmu/A4980/E.BIN",2097152,"bios"],["BIOS.col",8192,"bios"],["BS-X (En) (DRM-Free).bin",1048576,"bios"],["BS-X (En).bin",1048576,"bios"],["BS-X.bin",1048576,"bios"],["CARTS.SHA",33152,"bios"],["CM32LN_CONTROL.ROM",65536,"bios"],["CM32L_CONTROL.ROM",65536,"bios"],["CM32L_PCM.ROM",1048576,"bios"],["CYRILLIC.FNT",2048,"bios"],["Cemu/cafeLibs/erreula.rpl",790080,"bios"],["Cemu/cafeLibs/snd_user.rpl",84032,"bios"],["Cemu/cafeLibs/snduser2.rpl",111360,"bios"],["Cemu/cafeLibs/swkbd.rpl",1116288,"bios"],["Cemu/keys.txt",449507,"bios"],["Cemu/mlc01/sys/title/0005001b/10042400/content/CafeCn.ttf",13110604,"bios"],["Cemu/mlc01/sys/title/0005001b/10042400/content/CafeKr.ttf",2260660,"bios"],["Cemu/mlc01/sys/title/0005001b/10042400/content/CafeStd.ttf",2442464,"bios"],["Cemu/mlc01/sys/title/0005001b/10042400/content/CafeTw.ttf",8229724,"bios"],["Cemu/mlc01/sys/title/0005001b/1004f000/content/00/swkbd/swkbd.pack",1907900,"bios"],["Cemu/mlc01/sys/title/0005001b/10051000/content/00/erreula/erreula.pack",1252759,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_ACCOUNT_1_CERT.der",1226,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_ACCOUNT_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_COMMON_1_CERT.der",1224,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_COMMON_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_OLIVE_1_CERT.der",1224,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_OLIVE_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_VINO_1_CERT.der",1223,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_VINO_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_WOOD_1_CERT.der",1223,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_WOOD_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ADDTRUST_EXT_CA_ROOT.der",1082,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/BALTIMORE_CYBERTRUST_ROOT_CA.der",891,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CA.der",897,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CA_G2.der",1173,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CA_G3.der",1060,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CLASS2_CA.der",924,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CLASS2_CA_G2.der",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CLASS2_CA_G3.der",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/COMODO_CA.der",980,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CYBERTRUST_GLOBAL_ROOT_CA.der",933,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_ASSURED_ID_ROOT_CA.der",955,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_ASSURED_ID_ROOT_CA_G2.der",922,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_GLOBAL_ROOT_CA.der",947,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_GLOBAL_ROOT_CA_G2.der",914,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_HIGH_ASSURANCE_EV_ROOT_CA.der",969,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ENTRUST_CA_2048.der",1070,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ENTRUST_ROOT_CA.der",1173,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ENTRUST_ROOT_CA_G2.der",1090,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ENTRUST_SECURE_SERVER_CA.der",1244,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerLine truncated
|
||||
{"files":[["128-0.rom",16384,"bios"],["128-1.rom",16384,"bios"],["128-spanish-0.rom",16384,"bios"],["128-spanish-1.rom",16384,"bios"],["22vp931.zip",1405,"bios"],["32X_G_BIOS.bin",256,"bios"],["32X_M_BIOS.bin",2048,"bios"],["32X_S_BIOS.bin",1024,"bios"],["3c505.zip",10896,"bios"],["3do_arcade_saot.bin",524288,"bios"],["3xtwin.zip",1417,"bios"],["48.rom",16384,"bios"],["4dparprn.zip",409,"bios"],["5200.rom",2048,"bios"],["64DD_IPL.bin",4194304,"bios"],["6570-036.rom",2048,"bios"],["68HC05.rom",8192,"bios"],["7800 BIOS (E).rom",16384,"bios"],["7800 BIOS (U).rom",4096,"bios"],["ATARIBAS.ROM",8192,"bios"],["ATARIOSA.ROM",10240,"bios"],["ATARIOSB.ROM",10240,"bios"],["ATARIXL.ROM",16384,"bios"],["Azahar/Azahar/nand/private/movable.sed",320,"bios"],["Azahar/Azahar/nand/rw/sys/LocalFriendCodeSeed_B",272,"bios"],["Azahar/Azahar/nand/rw/sys/SecureInfo_A",273,"bios"],["Azahar/Azahar/sysdata/keys.txt",7424,"bios"],["Azahar/Azahar/sysdata/otp.bin",256,"bios"],["Azahar/Azahar/sysdata/seeddb.bin",53264,"bios"],["Azahar/Azahar/sysdata/shared_font.bin",3145728,"bios"],["BB01R4_OS.ROM",16384,"bios"],["BBKEmu/A4980/8.BIN",2097152,"bios"],["BBKEmu/A4980/E.BIN",2097152,"bios"],["BIOS.col",8192,"bios"],["BS-X (En) (DRM-Free).bin",1048576,"bios"],["BS-X (En).bin",1048576,"bios"],["BS-X.bin",1048576,"bios"],["CARTS.SHA",33152,"bios"],["CM32LN_CONTROL.ROM",65536,"bios"],["CM32L_CONTROL.ROM",65536,"bios"],["CM32L_PCM.ROM",1048576,"bios"],["CYRILLIC.FNT",2048,"bios"],["Cemu/cafeLibs/erreula.rpl",790080,"bios"],["Cemu/cafeLibs/snd_user.rpl",84032,"bios"],["Cemu/cafeLibs/snduser2.rpl",111360,"bios"],["Cemu/cafeLibs/swkbd.rpl",1116288,"bios"],["Cemu/keys.txt",449507,"bios"],["Cemu/mlc01/sys/title/0005001b/10042400/content/CafeCn.ttf",13110604,"bios"],["Cemu/mlc01/sys/title/0005001b/10042400/content/CafeKr.ttf",2260660,"bios"],["Cemu/mlc01/sys/title/0005001b/10042400/content/CafeStd.ttf",2442464,"bios"],["Cemu/mlc01/sys/title/0005001b/10042400/content/CafeTw.ttf",8229724,"bios"],["Cemu/mlc01/sys/title/0005001b/1004f000/content/00/swkbd/swkbd.pack",1907900,"bios"],["Cemu/mlc01/sys/title/0005001b/10051000/content/00/erreula/erreula.pack",1252759,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_ACCOUNT_1_CERT.der",1226,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_ACCOUNT_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_COMMON_1_CERT.der",1224,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_COMMON_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_OLIVE_1_CERT.der",1224,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_OLIVE_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_VINO_1_CERT.der",1223,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_VINO_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_WOOD_1_CERT.der",1223,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/ccerts/WIIU_WOOD_1_RSA_KEY.aes",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ADDTRUST_EXT_CA_ROOT.der",1082,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/BALTIMORE_CYBERTRUST_ROOT_CA.der",891,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CA.der",897,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CA_G2.der",1173,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CA_G3.der",1060,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CLASS2_CA.der",924,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CLASS2_CA_G2.der",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CACERT_NINTENDO_CLASS2_CA_G3.der",1200,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/COMODO_CA.der",980,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/CYBERTRUST_GLOBAL_ROOT_CA.der",933,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_ASSURED_ID_ROOT_CA.der",955,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_ASSURED_ID_ROOT_CA_G2.der",922,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_GLOBAL_ROOT_CA.der",947,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_GLOBAL_ROOT_CA_G2.der",914,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/DIGICERT_HIGH_ASSURANCE_EV_ROOT_CA.der",969,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ENTRUST_CA_2048.der",1070,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ENTRUST_ROOT_CA.der",1173,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ENTRUST_ROOT_CA_G2.der",1090,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerts/ENTRUST_SECURE_SERVER_CA.der",1244,"bios"],["Cemu/mlc01/sys/title/0005001b/10054000/content/scerLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"arcade":{"fbneo":{"file":"arcade/fbneo.json","gzip_size":115955,"sha256":"88961c9a6be804772baa767f6ee3a9162478c30c60fe55c8363d8fe4fbc909da","size":423362},"flycast":{"file":"arcade/flycast.json","gzip_size":1841,"sha256":"957189a6e993fda08e21c90ffc6ad94aed9ca9e6dd9ed8c7724e29db036791cb","size":5441},"mame":{"file":"arcade/mame.json","gzip_size":154527,"sha256":"b12010301293a218ecfac45706d81a26c3ac0c27eebeca7409bc8f6ce0163e9e","size":604648},"mame0258":{"file":"arcade/mame0258.json","gzip_size":141151,"sha256":"7ecff1979ed2ac123ea3ba4825dde6e2c31906415165fabefc9af93fa48a6de4","size":556059},"mame0278":{"file":"arcade/mame0278.json","gzip_size":148595,"sha256":"7881e3516f451ec83e886aaf41ebac9d60f497e1f954f49749d52890d7955cbf","size":582797},"mame2000":{"file":"arcade/mame2000.json","gzip_size":8624,"sha256":"f0d69b0872ecfc2845eeb7304543e5b17c11db82060b5514c206b6962ed08263","size":23316},"mame2003":{"file":"arcade/mame2003.json","gzip_size":17456,"sha256":"3b44e2a92c80a8f104b7781fcc1c776d025ad2cf4986638efa3ea041baa9a56d","size":48929},"mame2003_plus":{"file":"arcade/mame2003_plus.json","gzip_size":21077,"sha256":"2857283556fa49c20d8160ed113465bf0c2e643ed0b8acd2d38266a78be8e25e","size":62373},"mame2010":{"file":"arcade/mame2010.json","gzip_size":30869,"sha256":"50b1b381b4b07463f69f1f4c512dd1c98a78d4d0e9c3aeaf49b7b1b007b42152","size":96528},"mame2015":{"file":"arcade/mame2015.json","gzip_size":86990,"sha256":"afeb8c32f651421f74c6de057b0c0b837acd14c533b5160bb31f4411e0afffec","size":360117},"mame2016":{"file":"arcade/mame2016.json","gzip_size":101249,"sha256":"1f75147977d9d082a38b8f8e47a02b301f3d6e6db97c9b9282f3e7f27f26ee47","size":406190}},"containers":{"7z":{"description":"Per-file CRCs and sizes sit in the header at the end of the archive, LZMA-compressed by default (kEncodedHeader). One LZMA decode of the header, independent of the archive size, gives them. Python's lzma is an optional module.","re_read":true,"signature":{"bytes_hex":"377abcaf271c","offset":0},"source_ref":"ip7z/7zip@b88c6bc0 DOC/7zFormat.txt:171-189, DOC/7zFormat.txt:318-334, CPP/7zip/Archive/7z/7zHandlerOut.cpp:859"},"cd-sector":{"description":"User data of a CD sector. In a 2048-byte image (.iso) sector N starts at N * 2048. In a raw 2352-byte image it starts at N * 2352 plus 16 for Mode 1 and plus 24 for Mode 2 Form 1 (12 sync bytes, a 4-byte header, and for Mode 2 an 8-byte subheader).","re_read":true,"sector_size":{"iso":2048,"raw":2352},"source_ref":"stenzek/duckstation@526b4a12 src/util/iso_reader.cpp:157-171, src/util/cd_image.h:28-34","user_data_offset":{"mode1":16,"mode2":24}},"chd-v5":{"description":"MAME compressed hunks, big-endian. The header holds sizes, codecs and hashes only, never the system or the region. The overall SHA-1 is what MAME software lists record. Neither SHA-1 equals a Redump track hash: chdman hashes 2448-byte frames (subcode included, zero-filled), pads each track to 4 frames and byte-swaps audio, all tracks in one stream.","fields":{"compressors":{"encoding":"fourcc4","length":16,"offset":16},"header_length":{"encoding":"u32be","length":4,"offset":8,"value":124},"hunk_bytes":{"encoding":"u32be","length":4,"offset":56},"logical_bytes":{"encoding":"u64be","length":8,"offset":32},"map_offset":{"encoding":"u64be","length":8,"offset":40},"metadata_offset":{"encoding":"u64be","length":8,"offset":48},"overall_sha1":{"encoding":"hex","length":20,"offset":84},"parent_sha1":{"encoding":"hex","length":20,"offset":104},"raw_sha1":{"encoding":"hex","length":20,"offset":64},"unit_bytes":{"encoding":"u32be","length":4,"offset":60},"version":{"encoding":"u32be","length":4,"offset":12,"value":5}},"magic":{"bytes":"MComprHD","offset":0},"media_tags":{"CHCD":"cd","CHGD":"gd-rom","CHGT":"gd-rom","CHT2":"cd","CHTR":"cd","DVD ":"dvd","GDDD":"hard-disk"},"metadata_entry":{"description":"16-byte entry header, payload follows. Linked list from metadata_offset.","flags":{"encoding":"u8","length":1,"offset":4},"length":{"encoding":"u24be","length":3,"offset":5},"next":{"encoding":"u64be","length":8,"offset":8},"tag":{"encoding":"ascii","length":4,"offset":0}},"re_read":true,"source_ref":"mamedev/mame@f34f0250 src/lib/util/chd.h:148-162, src/lib/util/chd.cpp:2021-2061, src/lib/util/chd.cpp:2822-2831, src/lib/util/chd.h:212-235, src/lib/util/chd.cpp:1709-1749, src/tools/chdman.cpp:443-445, src/tools/chdman.cpp:2195-2203, src/lib/util/cdrom.cpp:2669-2672, src/emu/romload.cpp:1211"},"gamecube-disc":{"description":"Where disc offset 0 sits inside each container Dolphin opens.","formats":{"ciso":{"disc_header_at":32768,"magic":"CISO"},"gcz":{"disc_header_at":null,"magic_u32le":2970337281,"note":"block 0 is zlib unless bit 63 of its pointer is set"},"iso":{"disc_header_at":0,"magic":null},"rvz":{"disc_header_at":88,"magic":"RVZ\u0001","note":"first 0x80 disc bytes copied into header 2"},"wbfs":{"disc_header_at":null,"magic":"WBFS","note":"disc offset 0 at wlba[0] << wbfs_sector_shift"},"wia":{"disc_header_at":88,"magic":"WIA\u0001","note":Line truncated
|
||||
{"arcade":{"fbneo":{"file":"arcade/fbneo.json","gzip_size":115955,"sha256":"88961c9a6be804772baa767f6ee3a9162478c30c60fe55c8363d8fe4fbc909da","size":423362},"flycast":{"file":"arcade/flycast.json","gzip_size":1841,"sha256":"957189a6e993fda08e21c90ffc6ad94aed9ca9e6dd9ed8c7724e29db036791cb","size":5441},"mame":{"file":"arcade/mame.json","gzip_size":154527,"sha256":"b12010301293a218ecfac45706d81a26c3ac0c27eebeca7409bc8f6ce0163e9e","size":604648},"mame0258":{"file":"arcade/mame0258.json","gzip_size":141151,"sha256":"7ecff1979ed2ac123ea3ba4825dde6e2c31906415165fabefc9af93fa48a6de4","size":556059},"mame0278":{"file":"arcade/mame0278.json","gzip_size":148595,"sha256":"7881e3516f451ec83e886aaf41ebac9d60f497e1f954f49749d52890d7955cbf","size":582797},"mame2000":{"file":"arcade/mame2000.json","gzip_size":8624,"sha256":"f0d69b0872ecfc2845eeb7304543e5b17c11db82060b5514c206b6962ed08263","size":23316},"mame2003":{"file":"arcade/mame2003.json","gzip_size":17456,"sha256":"3b44e2a92c80a8f104b7781fcc1c776d025ad2cf4986638efa3ea041baa9a56d","size":48929},"mame2003_plus":{"file":"arcade/mame2003_plus.json","gzip_size":21077,"sha256":"2857283556fa49c20d8160ed113465bf0c2e643ed0b8acd2d38266a78be8e25e","size":62373},"mame2010":{"file":"arcade/mame2010.json","gzip_size":30869,"sha256":"50b1b381b4b07463f69f1f4c512dd1c98a78d4d0e9c3aeaf49b7b1b007b42152","size":96528},"mame2015":{"file":"arcade/mame2015.json","gzip_size":86990,"sha256":"afeb8c32f651421f74c6de057b0c0b837acd14c533b5160bb31f4411e0afffec","size":360117},"mame2016":{"file":"arcade/mame2016.json","gzip_size":101249,"sha256":"1f75147977d9d082a38b8f8e47a02b301f3d6e6db97c9b9282f3e7f27f26ee47","size":406190}},"containers":{"7z":{"description":"Per-file CRCs and sizes sit in the header at the end of the archive, LZMA-compressed by default (kEncodedHeader). One LZMA decode of the header, independent of the archive size, gives them. Python's lzma is an optional module.","re_read":true,"signature":{"bytes_hex":"377abcaf271c","offset":0},"source_ref":"ip7z/7zip@b88c6bc0 DOC/7zFormat.txt:171-189, DOC/7zFormat.txt:318-334, CPP/7zip/Archive/7z/7zHandlerOut.cpp:859"},"cd-sector":{"description":"User data of a CD sector. In a 2048-byte image (.iso) sector N starts at N * 2048. In a raw 2352-byte image it starts at N * 2352 plus 16 for Mode 1 and plus 24 for Mode 2 Form 1 (12 sync bytes, a 4-byte header, and for Mode 2 an 8-byte subheader).","re_read":true,"sector_size":{"iso":2048,"raw":2352},"source_ref":"stenzek/duckstation@526b4a12 src/util/iso_reader.cpp:157-171, src/util/cd_image.h:28-34","user_data_offset":{"mode1":16,"mode2":24}},"chd-v5":{"description":"MAME compressed hunks, big-endian. The header holds sizes, codecs and hashes only, never the system or the region. The overall SHA-1 is what MAME software lists record. Neither SHA-1 equals a Redump track hash: chdman hashes 2448-byte frames (subcode included, zero-filled), pads each track to 4 frames and byte-swaps audio, all tracks in one stream.","fields":{"compressors":{"encoding":"fourcc4","length":16,"offset":16},"header_length":{"encoding":"u32be","length":4,"offset":8,"value":124},"hunk_bytes":{"encoding":"u32be","length":4,"offset":56},"logical_bytes":{"encoding":"u64be","length":8,"offset":32},"map_offset":{"encoding":"u64be","length":8,"offset":40},"metadata_offset":{"encoding":"u64be","length":8,"offset":48},"overall_sha1":{"encoding":"hex","length":20,"offset":84},"parent_sha1":{"encoding":"hex","length":20,"offset":104},"raw_sha1":{"encoding":"hex","length":20,"offset":64},"unit_bytes":{"encoding":"u32be","length":4,"offset":60},"version":{"encoding":"u32be","length":4,"offset":12,"value":5}},"magic":{"bytes":"MComprHD","offset":0},"media_tags":{"CHCD":"cd","CHGD":"gd-rom","CHGT":"gd-rom","CHT2":"cd","CHTR":"cd","DVD ":"dvd","GDDD":"hard-disk"},"metadata_entry":{"description":"16-byte entry header, payload follows. Linked list from metadata_offset.","flags":{"encoding":"u8","length":1,"offset":4},"length":{"encoding":"u24be","length":3,"offset":5},"next":{"encoding":"u64be","length":8,"offset":8},"tag":{"encoding":"ascii","length":4,"offset":0}},"re_read":true,"source_ref":"mamedev/mame@f34f0250 src/lib/util/chd.h:148-162, src/lib/util/chd.cpp:2021-2061, src/lib/util/chd.cpp:2822-2831, src/lib/util/chd.h:212-235, src/lib/util/chd.cpp:1709-1749, src/tools/chdman.cpp:443-445, src/tools/chdman.cpp:2195-2203, src/lib/util/cdrom.cpp:2669-2672, src/emu/romload.cpp:1211"},"gamecube-disc":{"description":"Where disc offset 0 sits inside each container Dolphin opens.","formats":{"ciso":{"disc_header_at":32768,"magic":"CISO"},"gcz":{"disc_header_at":null,"magic_u32le":2970337281,"note":"block 0 is zlib unless bit 63 of its pointer is set"},"iso":{"disc_header_at":0,"magic":null},"rvz":{"disc_header_at":88,"magic":"RVZ\u0001","note":"first 0x80 disc bytes copied into header 2"},"wbfs":{"disc_header_at":null,"magic":"WBFS","note":"disc offset 0 at wlba[0] << wbfs_sector_shift"},"wia":{"disc_header_at":88,"magic":"WIA\u0001","note":Line truncated
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1089187,
|
||||
"sha256": "f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63",
|
||||
"size": 1098257,
|
||||
"sha256": "d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 1142814,
|
||||
"sha256": "090c40f23a9c087641c9835a79a0d4181fc702865ce20e5115fe5a7ca72a31fc"
|
||||
"size": 1151884,
|
||||
"sha256": "54ff79a8c51879532db206bb24dd7b3a884a2c8de1d3bcc07f1a2d4f66084c71"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1089187,
|
||||
"sha256": "f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63",
|
||||
"size": 1098257,
|
||||
"sha256": "d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1089187,
|
||||
"sha256": "f0410caadbcc354efc86038decb95da63478ddf018cad03ec09e41ce2b5e4d63",
|
||||
"size": 1098257,
|
||||
"sha256": "d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -175,6 +175,10 @@ def route_keys() -> str:
|
||||
return _table(heading, rows)
|
||||
|
||||
|
||||
# Card markers that are files with no extension: the MiSTer binary.
|
||||
_BARE_FILES = frozenset({"MiSTer"})
|
||||
|
||||
|
||||
def _marker(rule: _install._OsRule) -> str:
|
||||
"""A card rule's evidence in words: the file or folder, and the line it
|
||||
holds for a rule that reads one."""
|
||||
@@ -182,8 +186,8 @@ 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}`"
|
||||
named = ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`"
|
||||
for value in rule.values)
|
||||
named = ", ".join(f"`{value}`" if PurePosixPath(value).suffix or value in _BARE_FILES
|
||||
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).
|
||||
|
||||
+295
-8
@@ -14,6 +14,7 @@ import select
|
||||
import shlex
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -2288,6 +2289,80 @@ class TestAvailablePlatforms(unittest.TestCase):
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("retroarch", proc.stdout)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "the sh bootstrap is POSIX")
|
||||
def test_ctrl_c_during_the_download_ends_with_130(self):
|
||||
"""Ctrl+C while install.py downloaded: the trap cleaned up and the
|
||||
script went on to blame the network, with code 1."""
|
||||
scratch = REPO_ROOT / "tmp" / "tests"
|
||||
scratch.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=scratch) as directory:
|
||||
root = Path(directory)
|
||||
fake_bin = root / "bin"
|
||||
fake_bin.mkdir()
|
||||
started = root / "started"
|
||||
(fake_bin / "curl").write_text(
|
||||
f"#!/bin/sh\n: > '{started}'\nexec sleep 60\n", encoding="utf-8")
|
||||
(fake_bin / "curl").chmod(0o755)
|
||||
env = {**os.environ, "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}",
|
||||
"TMPDIR": str(root),
|
||||
"RETROBIOS_INSTALL_URL": "https://example.invalid/install.py"}
|
||||
for shell in ("sh", "bash"):
|
||||
if not shutil.which(shell):
|
||||
continue
|
||||
with self.subTest(shell=shell):
|
||||
started.unlink(missing_ok=True)
|
||||
proc = subprocess.Popen(
|
||||
[shell, "-s", "--", "--check"], cwd=root, env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, start_new_session=True)
|
||||
proc.stdin.write((REPO_ROOT / "install.sh").read_text(encoding="utf-8"))
|
||||
proc.stdin.close()
|
||||
proc.stdin = None
|
||||
try:
|
||||
deadline = time.monotonic() + 30
|
||||
while not started.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(started.exists(), "the download never started")
|
||||
os.killpg(proc.pid, signal.SIGINT)
|
||||
_out, err = proc.communicate(timeout=30)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.communicate()
|
||||
self.assertEqual(proc.returncode, 130, err)
|
||||
self.assertNotIn("could not be downloaded", err)
|
||||
self.assertEqual(sorted(p.name for p in root.iterdir()),
|
||||
["bin", "started"])
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "the sh bootstrap is POSIX")
|
||||
def test_a_versioned_python_is_found_beside_an_old_python3(self):
|
||||
"""Buster keeps python3 at 3.7: the user installs python3.8 as the
|
||||
advice says, and the bootstrap, trying python3 and python alone,
|
||||
gave the same advice again."""
|
||||
root = Path(_tempdir(self))
|
||||
shutil.copy(REPO_ROOT / "install.sh", root / "install.sh")
|
||||
(root / "install.py").write_text("import sys\nprint('ran', sys.version_info >= (3, 8))\n")
|
||||
fake_bin = root / "bin"
|
||||
fake_bin.mkdir()
|
||||
old = ("#!/bin/sh\ncase \"$*\" in *SystemExit*) exit 1 ;; esac\n"
|
||||
"echo 3.7.3\n")
|
||||
for name in ("python3", "python"):
|
||||
(fake_bin / name).write_text(old)
|
||||
(fake_bin / name).chmod(0o755)
|
||||
tools = root / "tools"
|
||||
tools.mkdir()
|
||||
for tool in ("sh", "grep", "sort", "basename", "dirname", "uname", "sed", "tr"):
|
||||
found = shutil.which(tool)
|
||||
if found:
|
||||
(tools / tool).symlink_to(found)
|
||||
(fake_bin / "python3.11").symlink_to(sys.executable)
|
||||
proc = subprocess.run(
|
||||
[shutil.which("sh"), str(root / "install.sh")],
|
||||
env={"PATH": f"{fake_bin}{os.pathsep}{tools}", "HOME": str(root)},
|
||||
capture_output=True, text=True, timeout=60, stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("ran True", proc.stdout)
|
||||
|
||||
def test_a_failed_download_says_so_in_one_message(self):
|
||||
"""Offline, the one-liner printed curl's progress table and
|
||||
"curl: (6) ...", then exited 6, a code the table does not list."""
|
||||
@@ -2761,6 +2836,29 @@ class APyLauncherWithoutARuntimeIsPassed(unittest.TestCase):
|
||||
self.assertIn("installer ran", proc.stdout)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell")
|
||||
class APowerShellDownloadThatNeverAnswersEnds(unittest.TestCase):
|
||||
"""Invoke-WebRequest had no timeout: behind a server that takes the
|
||||
connection and never answers, the window waited forever with no line."""
|
||||
|
||||
def test_a_silent_server_ends_on_the_network_advice(self):
|
||||
folder = Path(_tempdir(self))
|
||||
shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1")
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(4)
|
||||
port = listener.getsockname()[1]
|
||||
started = time.monotonic()
|
||||
proc = subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(folder / "install.ps1"), "--check"],
|
||||
env={**os.environ,
|
||||
"RETROBIOS_INSTALL_URL": f"https://127.0.0.1:{port}/install.py"},
|
||||
capture_output=True, text=True, timeout=150, stdin=subprocess.DEVNULL)
|
||||
self.assertLess(time.monotonic() - started, 120)
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertIn("could not be downloaded", " ".join((proc.stdout + proc.stderr).split()))
|
||||
|
||||
|
||||
class ThePowerShellWrapperPassesTheStatusOn(unittest.TestCase):
|
||||
"""Every non-zero exit became a red "installer failed" exception and
|
||||
code 1: a --check that found work read as a crash, 2 and 130 were lost."""
|
||||
@@ -3264,6 +3362,28 @@ class ACardPreparedOnAComputerGetsItsBoard(unittest.TestCase):
|
||||
self.assertEqual(state.board, "Board bcm2711 found on the card (x)")
|
||||
|
||||
|
||||
class APlatformWithoutTargetsSaysSoOffline(unittest.TestCase):
|
||||
"""Offline, --target on AmberELEC asked for install/targets/amberelec.json,
|
||||
which exists nowhere: online the same run says it has no targets."""
|
||||
|
||||
def test_the_list_matches_the_published_targets(self):
|
||||
published = {path.stem for path in (REPO_ROOT / "install" / "targets").glob("*.json")}
|
||||
self.assertEqual(install.TARGET_PLATFORMS, published)
|
||||
|
||||
def test_offline_says_there_are_no_targets(self):
|
||||
dest = Path(_tempdir(self))
|
||||
for extra in ([], ["--manifest", str(REPO_ROOT / "install" / "amberelec.json")]):
|
||||
with self.subTest(manifest=bool(extra)):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "amberelec",
|
||||
"--dest", str(dest), "--target", "rk3326", "--offline", "--no-input",
|
||||
"--dry-run", *extra],
|
||||
capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
self.assertEqual(proc.returncode, 2, proc.stderr)
|
||||
self.assertIn("AmberELEC has no hardware targets", proc.stderr)
|
||||
self.assertNotIn("targets/amberelec.json", proc.stderr)
|
||||
|
||||
|
||||
class TheBoardTheSystemNamesIsTheTarget(unittest.TestCase):
|
||||
"""Batocera, Recalbox and ROCKNIX each write the board they were built
|
||||
for, but the installer read none of them, so a Batocera RPi4 installed
|
||||
@@ -3280,6 +3400,42 @@ class TheBoardTheSystemNamesIsTheTarget(unittest.TestCase):
|
||||
self.assertEqual(install.detect_board("rocknix"), "rk3326")
|
||||
self.assertIsNone(install.detect_board("retroarch"))
|
||||
|
||||
def test_retropie_names_its_pi_as_get_platform_does(self):
|
||||
"""A RetroPie Pi 4 installed every target's files, 8.4 GiB where
|
||||
rpi4 needs 4.0: nothing read the Pi model."""
|
||||
root = Path(_tempdir(self))
|
||||
(root / "proc" / "device-tree").mkdir(parents=True)
|
||||
cpuinfo = root / "proc" / "cpuinfo"
|
||||
tree = root / "proc" / "device-tree" / "compatible"
|
||||
cases = (
|
||||
("Hardware\t: BCM2835\nRevision\t: c03114\n", None, "rpi4"),
|
||||
("Revision\t: d04170\n", b"raspberrypi,5-model-b\0brcm,bcm2712\0", "rpi5"),
|
||||
("Hardware\t: BCM2835\nRevision\t: a02082\n", None, "rpi3"),
|
||||
("Hardware\t: BCM2835\nRevision\t: 000e\n", None, "rpi1"),
|
||||
("Hardware\t: ODROIDC\n", None, None),
|
||||
)
|
||||
with unittest.mock.patch.object(install, "_SYSTEM_ROOT", root):
|
||||
# Not installed here: a computer preparing a Pi's card.
|
||||
cpuinfo.write_text(cases[0][0])
|
||||
self.assertIsNone(install.detect_board("retropie"))
|
||||
(root / "opt" / "retropie").mkdir(parents=True)
|
||||
for text, compatible, board in cases:
|
||||
with self.subTest(board=board, text=text):
|
||||
cpuinfo.write_text(text)
|
||||
if compatible is None:
|
||||
tree.unlink(missing_ok=True)
|
||||
else:
|
||||
tree.write_bytes(compatible)
|
||||
self.assertEqual(install.detect_board("retropie"), board)
|
||||
tree.unlink(missing_ok=True)
|
||||
cpuinfo.write_text("processor\t: 0\n")
|
||||
with unittest.mock.patch.object(install.platform, "machine",
|
||||
return_value="x86_64"):
|
||||
self.assertEqual(install.detect_board("retropie"), "x86_64")
|
||||
targets = json.loads((REPO_ROOT / "install" / "targets" / "retropie.json")
|
||||
.read_text(encoding="utf-8"))
|
||||
self.assertLessEqual({*install._RPI_MODELS.values(), "x86_64"}, set(targets))
|
||||
|
||||
def test_lakka_names_its_device_or_its_project(self):
|
||||
# An RPi4 Lakka installed 5.7 GB of every board's files: its board
|
||||
# was in os-release and never read.
|
||||
@@ -4658,6 +4814,35 @@ class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun):
|
||||
self.assertIn("File list: RetroArch", proc.stdout)
|
||||
self.assertIn("--platform", proc.stdout)
|
||||
|
||||
def test_check_says_in_place_only_when_the_data_files_are_too(self):
|
||||
"""The PSP BIOS in place and PPSSPP's assets absent: --check said
|
||||
"Everything is in place." then "169 to fetch", and exited 1."""
|
||||
dest = self.home / "psp"
|
||||
(dest / "PPSSPP").mkdir(parents=True)
|
||||
for repo_path in ("bios/Sony/PlayStation Portable/ppge_atlas.zim",
|
||||
"bios/Other/ppsspp/PPSSPP/ppge_atlas.meta"):
|
||||
shutil.copy(REPO_ROOT / repo_path, dest / "PPSSPP")
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest", str(dest),
|
||||
"--system", "sony-psp", "--check", "--no-input")
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
||||
self.assertNotIn("Everything is in place", proc.stdout)
|
||||
found = re.search(r"(\d+) in place, (\d+) missing", proc.stdout)
|
||||
self.assertIsNotNone(found, proc.stdout)
|
||||
self.assertIn(f"\n {found.group(2)} data files to fetch\n", proc.stdout)
|
||||
|
||||
def test_check_gives_one_total_offline(self):
|
||||
"""Offline on an empty folder: "10805 missing", then "8164 files not in
|
||||
place" and "8164 files need the internet", the data files left out."""
|
||||
dest = self.home / "empty"
|
||||
dest.mkdir()
|
||||
proc = _install_run({**self.env, "RETROBIOS_INSTALL_OFFLINE": "1"}, "--platform",
|
||||
"retroarch", "--dest", str(dest), "--check", "--no-input",
|
||||
"--manifest", str(REPO_ROOT / "install" / "retroarch.json"))
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
||||
missing = re.search(r"0 in place, (\d+) missing", proc.stdout).group(1)
|
||||
self.assertIn(f"\n {missing} files not in place:\n", proc.stdout)
|
||||
self.assertIn(f" {missing} files need the internet", proc.stdout)
|
||||
|
||||
def test_a_cores_folder_can_be_named(self):
|
||||
cores = self.home / "cores"
|
||||
cores.mkdir()
|
||||
@@ -4668,6 +4853,37 @@ class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun):
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("to the installed cores", proc.stdout)
|
||||
|
||||
def test_a_cores_folder_that_cannot_be_listed_says_why(self):
|
||||
"""A typo (core/ for cores/) or a closed folder read as an empty
|
||||
one: the run said "none in" and asked for the --cores-dir given."""
|
||||
missing = self.home / "core"
|
||||
a_file = self.home / "cores.txt"
|
||||
a_file.write_text("")
|
||||
for folder, why in ((missing, "does not exist"), (a_file, "is not a folder")):
|
||||
with self.subTest(why=why):
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
||||
str(self.home / "s"), "--cores-dir", str(folder),
|
||||
"--dry-run", "--no-input")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn(f"--cores-dir {folder} {why}", proc.stderr)
|
||||
self.assertNotIn("none in", proc.stderr)
|
||||
|
||||
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
|
||||
"a folder without read permission is POSIX")
|
||||
def test_a_closed_cores_folder_says_so(self):
|
||||
cores = self.home / "closed"
|
||||
cores.mkdir()
|
||||
(cores / "handy_libretro.so").write_bytes(b"")
|
||||
os.chmod(cores, 0o000)
|
||||
try:
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
||||
str(self.home / "s"), "--cores-dir", str(cores),
|
||||
"--dry-run", "--no-input")
|
||||
finally:
|
||||
os.chmod(cores, 0o755)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn(f"--cores-dir {cores} cannot be opened by this user", proc.stderr)
|
||||
|
||||
def test_no_core_found_says_how_to_go_on(self):
|
||||
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
|
||||
str(self.home / "s"), "--installed-cores", "--dry-run",
|
||||
@@ -4973,6 +5189,54 @@ class ANetworkFailureNamesTheNetwork(unittest.TestCase):
|
||||
self.assertNotIn("being written to", text)
|
||||
|
||||
|
||||
class AnHttpErrorNamesItsAddress(unittest.TestCase):
|
||||
"""A file the server answered 404 for ended on "HTTP 404" alone: no
|
||||
address, no cause, and a mirror chosen with RETROBIOS_BASE_URL went
|
||||
unnamed, as it did behind a NETWORK failure of that mirror."""
|
||||
|
||||
def _said(self, failures: list, base_url: str = "") -> str:
|
||||
out = io.StringIO()
|
||||
env = {"RETROBIOS_BASE_URL": base_url} if base_url else {}
|
||||
with unittest.mock.patch.dict(os.environ, env), contextlib.redirect_stdout(out), \
|
||||
self.assertRaises(SystemExit) as raised:
|
||||
install._report_outcome([Path("/bios")], 0, 0, len(failures), 0, False,
|
||||
failures=failures)
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
return " ".join(out.getvalue().split())
|
||||
|
||||
def _reason(self, kind: type, words: str, url: str) -> str:
|
||||
reason = kind(words)
|
||||
reason.url = url
|
||||
return reason
|
||||
|
||||
def test_the_address_and_the_cause_are_said(self):
|
||||
url = "https://raw.githubusercontent.com/Abdess/retrobios/main/bios/a.bin"
|
||||
failures = [("a.bin", self._reason(install.HttpReason, "HTTP 404", url)),
|
||||
("b.bin", self._reason(install.HttpReason, "HTTP 404", url + "x"))]
|
||||
with unittest.mock.patch.dict(os.environ):
|
||||
os.environ.pop("RETROBIOS_BASE_URL", None)
|
||||
text = self._said(failures)
|
||||
self.assertIn(f"The server answered HTTP 404 for {url} (and 1 other file).", text)
|
||||
self.assertIn("running again changes nothing", text)
|
||||
self.assertIn("BIOS folder: /bios", text)
|
||||
self.assertNotIn("RETROBIOS_BASE_URL", text)
|
||||
|
||||
def test_a_mirror_named_in_the_environment_is_said(self):
|
||||
mirror = "http://127.0.0.1:18766"
|
||||
for kind, words in ((install.HttpReason, "HTTP 404"),
|
||||
(install.NetworkReason, "the connection timed out")):
|
||||
with self.subTest(kind=kind.__name__):
|
||||
text = self._said([("a.bin", self._reason(kind, words, f"{mirror}/a.bin"))],
|
||||
base_url=mirror)
|
||||
self.assertIn("RETROBIOS_BASE_URL is set in this environment", text)
|
||||
|
||||
def test_the_download_records_the_address(self):
|
||||
error = urllib.error.HTTPError("https://x/bios/a.bin", 404, "Not Found", None, None)
|
||||
reason = ANetworkFailureNamesTheNetwork._fail(self, error)
|
||||
self.assertIsInstance(reason, install.HttpReason)
|
||||
self.assertTrue(reason.url.endswith("/bios/a.bin"), reason.url)
|
||||
|
||||
|
||||
class RetroBatIsFoundWhereItSaysItIs(unittest.TestCase):
|
||||
"""RetroBat on D:\\RetroBat was not detected, and --platform retrobat
|
||||
wrote 7.6 GB into C:\\RetroBat\\bios, a folder it does not read.
|
||||
@@ -5400,20 +5664,43 @@ class BatoceraAndKnulliByWhatTheirBuildsWrite(unittest.TestCase):
|
||||
[("emuelec", Path("/storage/roms"))])
|
||||
self.assertEqual(self._detect({"NAME": "CoreELEC", "ID": "coreelec"}, ()), [])
|
||||
|
||||
def test_a_knulli_board_is_named_and_not_narrowed(self):
|
||||
board = Path(_tempdir(self)) / "knulli.arch"
|
||||
board.write_text("a133")
|
||||
with unittest.mock.patch.dict(install._DERIVATIVE_BOARDS,
|
||||
{"knulli": ("Knulli", str(board))}):
|
||||
self.assertEqual(install.derivative_board("knulli"), ("Knulli", "a133"))
|
||||
self.assertIsNone(install.derivative_board("recalbox"))
|
||||
def test_a_knulli_board_narrows_to_its_target(self):
|
||||
"""KNULLI publishes a core list per board since 2026-11-05, and an
|
||||
H700 handheld still took every board's files (9.3 GB for 4.5)."""
|
||||
arch = Path(_tempdir(self)) / "knulli.arch"
|
||||
targets = json.loads((REPO_ROOT / "install" / "targets" / "knulli.json")
|
||||
.read_text(encoding="utf-8"))
|
||||
for written, board in (("h700", "h700"), ("rk3568", "rk3566"), ("unknown", None)):
|
||||
arch.write_text(written)
|
||||
with self.subTest(written=written), unittest.mock.patch.dict(
|
||||
install._BOARD_SOURCES, {"knulli": ("file", str(arch))}):
|
||||
self.assertEqual(install.detect_board("knulli"), board)
|
||||
if board:
|
||||
self.assertIn(board, targets)
|
||||
|
||||
def test_a_batocera_zen3_board_narrows_to_its_target(self):
|
||||
"""batocera.arch says x86-64-v3 on a zen3 image, which no target
|
||||
list names: the run installed the files of every board."""
|
||||
arch = Path(_tempdir(self)) / "batocera.arch"
|
||||
arch.write_text("x86-64-v3")
|
||||
with unittest.mock.patch.dict(install._BOARD_SOURCES,
|
||||
{"batocera": ("file", str(arch))}):
|
||||
self.assertEqual(install.detect_board("batocera"), "zen3")
|
||||
|
||||
def test_each_board_name_is_a_published_target(self):
|
||||
for plat, names in install._BOARD_NAMES.items():
|
||||
targets = json.loads((REPO_ROOT / "install" / "targets" / f"{plat}.json")
|
||||
.read_text(encoding="utf-8"))
|
||||
for written, board in names.items():
|
||||
with self.subTest(platform=plat, board=board):
|
||||
self.assertIn(board, targets)
|
||||
self.assertNotIn(written, targets)
|
||||
|
||||
def test_the_run_says_why_every_board_is_installed(self):
|
||||
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
block = source[source.index("board = detect_board(plat_name)"):]
|
||||
block = block[:block.index("if target:")]
|
||||
self.assertIn("has no", block)
|
||||
self.assertIn("derivative_board(plat_name)", block)
|
||||
|
||||
|
||||
class RomMFirmwareFollowsItsTemplate(unittest.TestCase):
|
||||
|
||||
@@ -192,6 +192,29 @@ class TheBoundaryBoundsAlternatives(unittest.TestCase):
|
||||
install._validate_manifest(manifest, "retroarch")
|
||||
|
||||
|
||||
class OneDigestRuleAtTheBoundary(unittest.TestCase):
|
||||
"""install.py defined _SHA1_RE twice: the second, lowercase only,
|
||||
replaced the first everywhere, so an uppercase sha1 was refused where
|
||||
an uppercase sha256 passed. Every comparison reads the digest as
|
||||
hashlib writes it, so both refuse uppercase, data trees included."""
|
||||
|
||||
def _manifest(self, **digests: str) -> dict:
|
||||
entry = {"dest": "a.bin", "size": 1, "repo_path": "bios/a.bin", **digests}
|
||||
return {"manifest_version": 2, "platform": "retroarch", "files": [entry]}
|
||||
|
||||
def test_sha1_and_sha256_follow_the_same_rule(self):
|
||||
install._validate_manifest(self._manifest(sha1="a" * 40), "retroarch")
|
||||
install._validate_manifest(self._manifest(sha256="a" * 64), "retroarch")
|
||||
for digests in ({"sha1": "A" * 40}, {"sha256": "A" * 64}):
|
||||
with self.subTest(digests=digests), self.assertRaises(ValueError):
|
||||
install._validate_manifest(self._manifest(**digests), "retroarch")
|
||||
|
||||
def test_one_definition_each(self):
|
||||
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
|
||||
for name in ("_SHA1_RE", "_SHA256_RE"):
|
||||
self.assertEqual(source.count(f"\n{name} = "), 1, name)
|
||||
|
||||
|
||||
class APlatformOmissionKeepsItsClaim(unittest.TestCase):
|
||||
"""A file the platform declares and a core reads, absent from the
|
||||
collection, was recorded under the core alone: RetroPie's dsi_nand.bin
|
||||
|
||||
@@ -708,6 +708,72 @@ class AHomeThisUserCannotOpen(unittest.TestCase):
|
||||
self.assertNotIn("using default path", proc.stdout + proc.stderr)
|
||||
|
||||
|
||||
class ASettingsFileThisUserCannotRead(unittest.TestCase):
|
||||
"""A sudo EmuDeck run left settings.sh to root: its parser swallowed the
|
||||
refusal and the run said "No supported platform detected" without a
|
||||
word on the file, where the same refusal on retroarch.cfg is said."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
install._DETECTION_REFUSED.clear()
|
||||
self.addCleanup(install._DETECTION_REFUSED.clear)
|
||||
install._SETUP_SEARCH.active = True
|
||||
self.addCleanup(setattr, install._SETUP_SEARCH, "active", False)
|
||||
|
||||
def test_each_settings_parser_says_the_refusal(self):
|
||||
for parse, name in ((install._parse_bash_var, "settings.sh"),
|
||||
(install._parse_ps1_var, "settings.ps1")):
|
||||
with self.subTest(file=name):
|
||||
install._DETECTION_REFUSED.clear()
|
||||
path = self.root / name
|
||||
path.write_text('emulationPath="/x"\n', encoding="utf-8")
|
||||
refusal = PermissionError(errno.EACCES, "Permission denied", str(path))
|
||||
with mock.patch.object(Path, "read_text", side_effect=refusal), \
|
||||
mock.patch.object(Path, "read_bytes", side_effect=refusal), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as err:
|
||||
self.assertIsNone(parse(path, "emulationPath"))
|
||||
self.assertEqual(install._DETECTION_REFUSED, [(path, "Permission denied")])
|
||||
self.assertIn(f"cannot open {path}", err.getvalue())
|
||||
|
||||
|
||||
class AConfigThatFailsToRead(unittest.TestCase):
|
||||
"""retroarch.cfg on a failing card raised EIO, and a link that loops
|
||||
ELOOP: both counted as absent without a word, as only a permission
|
||||
refusal was said. Absence itself stays silent."""
|
||||
|
||||
def setUp(self):
|
||||
self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
install._DETECTION_REFUSED.clear()
|
||||
self.addCleanup(install._DETECTION_REFUSED.clear)
|
||||
install._SETUP_SEARCH.active = True
|
||||
self.addCleanup(setattr, install._SETUP_SEARCH, "active", False)
|
||||
|
||||
def test_a_read_error_is_said_with_its_own_words(self):
|
||||
cfg = self.root / "retroarch.cfg"
|
||||
cfg.write_text('system_directory = "/x"\n', encoding="utf-8")
|
||||
failure = OSError(errno.EIO, "Input/output error", str(cfg))
|
||||
with mock.patch.object(Path, "read_text", side_effect=failure), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as err:
|
||||
self.assertIsNone(install._retroarch_cfg_dir(cfg, "system_directory",
|
||||
self.root, "system"))
|
||||
self.assertEqual(install._DETECTION_REFUSED, [(cfg, "Input/output error")])
|
||||
said = " ".join(err.getvalue().split())
|
||||
self.assertIn(f"{cfg} could not be read (Input/output error)", said)
|
||||
self.assertNotIn("Run as its owner", said)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "a symbolic link needs privileges on Windows")
|
||||
def test_a_link_that_loops_is_said_and_absence_is_not(self):
|
||||
loop = self.root / "retroarch.cfg"
|
||||
loop.symlink_to(loop.name)
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertFalse(install._exists(loop))
|
||||
self.assertFalse(install._exists(self.root / "absent.cfg"))
|
||||
self.assertFalse(install._exists(self.root / "absent" / "x.cfg"))
|
||||
self.assertEqual([folder for folder, _why in install._DETECTION_REFUSED], [loop])
|
||||
|
||||
|
||||
class TheFrontendsOwnFolderClosed(unittest.TestCase):
|
||||
"""The Flatpak RetroArch's folder at 000: its retroarch.cfg names
|
||||
~/Games/bios, and the run took ~/.config/retroarch/system, a folder it
|
||||
|
||||
@@ -179,14 +179,30 @@ class CheckNamesWhyAFileIsRefused(Scratch):
|
||||
self.skipTest("this filesystem does not keep case")
|
||||
data = json.loads(self.list.read_text())
|
||||
twin = next(f for f in data["files"] if f["dest"] == "gba_bios.bin")
|
||||
data["case_variants"] = [{**twin, "dest": "twin/gba_bios.bin"}]
|
||||
data["case_variants"] = [{**twin, "dest": "GBA_BIOS.BIN"}]
|
||||
self.list.write_text(json.dumps(data))
|
||||
self.complete()
|
||||
out = self.check()
|
||||
self.assertIn("missing: twin/gba_bios.bin", out)
|
||||
self.assertIn("missing: GBA_BIOS.BIN", out)
|
||||
self.assertIn("a second name the pack leaves out", out)
|
||||
self.assertNotIn("wrong folder: it is at gba_bios.bin", out)
|
||||
|
||||
def test_a_case_twin_is_plain_missing_when_no_pack_was_used(self):
|
||||
"""On an empty folder every case variant came first, uncapped, with
|
||||
a note on a pack never downloaded: the BIOS files the user looked
|
||||
for sat in "... and N more missing"."""
|
||||
probe = self.root / "CaseProbe"
|
||||
probe.write_text("x")
|
||||
if (self.root / "caseprobe").exists():
|
||||
self.skipTest("this filesystem does not keep case")
|
||||
data = json.loads(self.list.read_text())
|
||||
twin = next(f for f in data["files"] if f["dest"] == "gba_bios.bin")
|
||||
data["case_variants"] = [{**twin, "dest": "GBA_BIOS.BIN"}]
|
||||
self.list.write_text(json.dumps(data))
|
||||
out = self.check()
|
||||
self.assertIn("missing: GBA_BIOS.BIN", out)
|
||||
self.assertNotIn("a second name the pack leaves out", out)
|
||||
|
||||
def test_a_nested_bios_folder_is_named(self):
|
||||
for name, data in PAYLOAD.items():
|
||||
self.place(f"system/{name}", data)
|
||||
|
||||
@@ -5901,6 +5901,12 @@ def _card_from_rules(root: Path, setup: install.CardSetup) -> Path:
|
||||
return root
|
||||
|
||||
|
||||
def _card_row(setup: install.CardSetup) -> str:
|
||||
"""The setup list's row of a card system: MiSTer's board sits on a desk
|
||||
and keeps its plain row among the platforms."""
|
||||
return setup.name if setup.plat in install._DESK_CARDS else f"{setup.name}, SD card"
|
||||
|
||||
|
||||
def _setup_folder(setup: install.CardSetup, card: Path) -> Path:
|
||||
return card / setup.below if setup.below else card
|
||||
|
||||
@@ -5968,7 +5974,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase):
|
||||
state = self.platforms()
|
||||
rows = install.tui_rows(state)
|
||||
index = next(i for i, row in enumerate(rows)
|
||||
if row.label == f"{setup.name}, SD card")
|
||||
if row.label == _card_row(setup))
|
||||
state.focus["platforms"] = index
|
||||
with mock.patch.object(install, "_mounted_cards", return_value=[]):
|
||||
effects = press(state, "enter")
|
||||
@@ -5983,7 +5989,7 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase):
|
||||
for setup in self.setups:
|
||||
with self.subTest(system=setup.name):
|
||||
rows = install.tui_rows(self.platforms(setup.name))
|
||||
self.assertIn(f"{setup.name}, SD card", [row.label for row in rows])
|
||||
self.assertIn(_card_row(setup), [row.label for row in rows])
|
||||
|
||||
def test_platform_named_on_a_computer_asks_for_the_card(self):
|
||||
for setup in self.setups:
|
||||
@@ -6077,6 +6083,46 @@ class EveryCardSetupGoesToItsCard(unittest.TestCase):
|
||||
self.assertNotIn(card, install.mounted_cards_of(setup.name,
|
||||
setup.plat))
|
||||
|
||||
def test_a_games_part_named_by_its_bios_folder_is_its_card(self):
|
||||
"""AmberELEC and EmuELEC write bios/ below their games part, as
|
||||
RetroDECK below its folder. Their BIOS folder named got bios/bios
|
||||
on a card not started once, and was refused on one started."""
|
||||
for setup in self.setups:
|
||||
if setup.name not in install._GAMES_PART_CARDS:
|
||||
continue
|
||||
started = _card_from_rules(self.root / setup.name / "started", setup)
|
||||
blank = self.root / setup.name / "blank"
|
||||
(blank / "bios").mkdir(parents=True)
|
||||
for card in (started, blank):
|
||||
with self.subTest(system=setup.name, card=card.name):
|
||||
choice = install.card_choice(setup.name, setup.plat, card / "bios")
|
||||
self.assertEqual((choice.refused, choice.folder), ("", card))
|
||||
# A folder named bios holding bios/ is a card's root, as named.
|
||||
nested = self.root / setup.name / "bios"
|
||||
(nested / "bios").mkdir(parents=True)
|
||||
self.assertEqual(install.card_choice(setup.name, setup.plat, nested).folder,
|
||||
nested)
|
||||
|
||||
def test_a_mister_card_gets_its_files_below_games(self):
|
||||
"""A MiSTer card mounted on a computer got RetroArch's list, and
|
||||
with --platform misterfpga its files at the root, where MiSTer
|
||||
reads none: it reads games/<core>/ below /media/fat."""
|
||||
card = self.root / "MiSTer"
|
||||
(card / "games").mkdir(parents=True)
|
||||
(card / "_Computer").mkdir()
|
||||
for name in ("MiSTer", "menu.rbf", "MiSTer.ini"):
|
||||
(card / name).write_text("", encoding="utf-8")
|
||||
self.assertEqual(install.card_system(card),
|
||||
("misterfpga", card / "games", "MiSTer FPGA"))
|
||||
for named in (card, card / "games"):
|
||||
with self.subTest(named=named.name):
|
||||
choice = install.card_choice("misterfpga", "misterfpga", named)
|
||||
self.assertEqual((choice.refused, choice.folder), ("", card / "games"))
|
||||
# A folder holding a games folder and nothing of MiSTer's is no card.
|
||||
other = self.root / "stick"
|
||||
(other / "games").mkdir(parents=True)
|
||||
self.assertIsNone(install.card_system(other))
|
||||
|
||||
def test_a_blank_drive_is_refused_where_the_card_always_names_its_system(self):
|
||||
blank = self.root / "BLANK"
|
||||
blank.mkdir()
|
||||
@@ -7054,8 +7100,13 @@ class _CardFixtures:
|
||||
def narrowed(self, plat: str, **given) -> tuple[int, str, list]:
|
||||
"""(exit code, what was said, the files kept) of a --dry-run narrowed
|
||||
as given."""
|
||||
dest = self.root / "dest"
|
||||
if not dest.exists():
|
||||
dest = self.root / f"dest-{plat}"
|
||||
if plat == "misterfpga" and not dest.exists():
|
||||
# A MinUI card is another system's card for MiSTer's list.
|
||||
dest.mkdir()
|
||||
for marker in ("MiSTer", "menu.rbf"):
|
||||
(dest / marker).write_text("", encoding="utf-8")
|
||||
elif not dest.exists():
|
||||
make_card(dest, "MinUI")
|
||||
stream = io.StringIO()
|
||||
code = 0
|
||||
|
||||
+25
-16
@@ -565,8 +565,11 @@ Checking existing files...
|
||||
```
|
||||
<!-- /generated: run-check -->
|
||||
|
||||
The files the collection does not hold are listed after it again, as long as
|
||||
they are absent from the folder. A number before `wrong copies` means a file is
|
||||
`Everything is in place.` is said only when the data folders are complete
|
||||
too. With the BIOS files in place and data files missing (PPSSPP's assets,
|
||||
Dolphin's `Sys`), the line counts those instead (`169 data files to fetch`),
|
||||
and `--check` exits with code 1. The files the collection does not hold are
|
||||
listed after it again, as long as they are absent from the folder. A number before `wrong copies` means a file is
|
||||
in place with contents the platform does not accept. Running the install again
|
||||
replaces it and keeps the old one, see
|
||||
[Files you already had](#files-you-already-had).
|
||||
@@ -958,10 +961,10 @@ what the options name, everything by default.
|
||||
|--------|--------|
|
||||
| <span id="option-platform"></span>`--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. ES-DE, LaunchBox and Android, which keep no BIOS folder, are answered with the run that fills each emulator's folder. Unknown names are refused with the closest ones |
|
||||
| <span id="option-dest"></span>`--dest PATH` | Destination directory, overriding detection. Without `--platform`, the file list is the platform detected here: at a terminal the installer asks, Enter taking the one found, and otherwise it uses the one found, RetroArch's when nothing is found, and refuses when several are. The run prints whose list it is. A folder that does not exist is named with the nearest one that does, then created, except for a system that runs from its SD card, which refuses it. When more than its last level is missing (a mistyped path), a run at a terminal asks first, and the answer defaults to no |
|
||||
| <span id="option-target"></span>`--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given, from a pack alone too when it carries the core list per board. On a computer, the board a Batocera card names on its BATOCERA partition (`boot/batocera.board`) is applied the same way, in line mode and on the full screen |
|
||||
| <span id="option-target"></span>`--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX, Lakka, KNULLI and RetroPie the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`, `knulli.arch`, the Pi model in `/proc/cpuinfo` where `/opt/retropie` exists) and applied when no `--target` is given, from a pack alone too when it carries the core list per board. On a computer, the board a Batocera card names on its BATOCERA partition (`boot/batocera.board`) is applied the same way, in line mode and on the full screen |
|
||||
| <span id="option-every-board"></span>`--every-board` | Install the files of every board instead of the one the system names, for a card that moves between devices |
|
||||
| <span id="option-installed-cores"></span>`--installed-cores` | Keep only what the cores already installed read (RetroArch, Lakka, RetroPie). See [Installed cores](#installed-cores) |
|
||||
| <span id="option-cores-dir"></span>`--cores-dir PATH` | The folder RetroArch loads its cores from, for `--installed-cores` when it is not found. Implies `--installed-cores` |
|
||||
| <span id="option-cores-dir"></span>`--cores-dir PATH` | The folder RetroArch loads its cores from, for `--installed-cores` when it is not found. Implies `--installed-cores`. A folder that does not exist or cannot be opened is refused with the reason (code 1) |
|
||||
| <span id="option-save-dir"></span>`--save-dir PATH` | The frontend's save directory (RetroArch's `savefile_directory`), for the files some cores read there, when the run cannot find it. The core's own folder is still added below it when the frontend sorts saves by core |
|
||||
| <span id="option-system"></span>`--system NAMES` | Install only these systems. Comma-separated or repeated. `--list-systems` prints the names, `other` included, and the title a system is printed with is taken too. A short name the platform does not use (`gba`, `psx`) is refused with the ids it may stand for, never taken in their place. So is an id the list files under another one: RetroArch keeps `neogeo.zip` under `arcade`, which `--system snk-neogeo` suggests |
|
||||
| <span id="option-core"></span>`--core NAMES` | Keep the platform's own list and only the extra files these cores load. `--list-cores` prints the names |
|
||||
@@ -1058,12 +1061,13 @@ AmberELEC, EmuELEC and KNULLI install the BIOS list they publish themselves. The
|
||||
image or source of its Odroid Go builds is published, so neither its marker
|
||||
files nor its folder could be read.
|
||||
|
||||
Batocera, Recalbox, ROCKNIX and Lakka also name the board they were built for, and
|
||||
that board narrows the install unless `--target` or `--every-board` says
|
||||
Batocera, Recalbox, ROCKNIX, Lakka and KNULLI also name the board they were built
|
||||
for, RetroPie reads the Pi model as its setup script does (`x86_64` on a PC),
|
||||
and that board narrows the install unless `--target` or `--every-board` says
|
||||
otherwise (see the options above). A board with no core list of its own is
|
||||
named and gets the files of every board. KNULLI names its board in
|
||||
`/usr/share/knulli/knulli.arch` but publishes no core list per board, so the
|
||||
run says so and installs the files of every board.
|
||||
named and gets the files of every board. KNULLI writes its board to
|
||||
`/usr/share/knulli/knulli.arch` under the name of the chip its board file
|
||||
selects: `rk3568` there is the `rk3566` list.
|
||||
|
||||
Some platforms keep their BIOS in a fixed directory, and that directory is the
|
||||
evidence. They are looked for after the configurations below, for a platform
|
||||
@@ -1097,6 +1101,7 @@ gives for the device, below the card's root instead of its mount point:
|
||||
| `system/configs/emulationstation/about.info` holding the line `KNULLI` | KNULLI | `bios`, KNULLI's list |
|
||||
| `system/batocera.conf` holding the line `system.hostname=KNULLI` | KNULLI | `bios`, KNULLI's list |
|
||||
| `.config/system/configs/system.cfg` holding the line `root.password=rocknix` | ROCKNIX | `roms/bios`, ROCKNIX's list |
|
||||
| `MiSTer`, `menu.rbf` | MiSTer FPGA | `games`, MiSTer FPGA's list |
|
||||
| `MUOS/` | muOS | `MUOS/bios`, RetroArch's list |
|
||||
| `spruce/` | spruceOS | `BIOS`, RetroArch's list |
|
||||
| `System/usr/trimui/crossmix-version.txt` | CrossMix-OS | `BIOS`, RetroArch's list |
|
||||
@@ -1220,7 +1225,7 @@ beside its executable wherever the archive was extracted, so a forced
|
||||
`--platform bizhawk` that finds no install stops and asks for `--dest`.
|
||||
|
||||
The systems that run from an SD card keep their files there: MinUI, NextUI,
|
||||
MyMinUI, KNULLI, ROCKNIX, AmberELEC and EmuELEC (the card's games part, which
|
||||
MyMinUI, KNULLI, ROCKNIX, MiSTer FPGA, AmberELEC and EmuELEC (the card's games part, which
|
||||
they mount as `/storage/roms`), and each system of the
|
||||
[Linux handhelds](../platforms/linux-handhelds.md) that gets RetroArch's list
|
||||
(Onion, muOS, spruceOS, ArkOS, UnofficialOS and the others). Run on the
|
||||
@@ -1256,7 +1261,9 @@ Under WSL a drive letter (`D:`, `D:\`) is that Windows drive, typed or given
|
||||
to `--dest`, `--from`, `--from-pack`, `--save-dir` and `--cores-dir`, and the
|
||||
screens write the folder `D:\BIOS`. Without questions (`--no-input`) the
|
||||
run stops with a `--dest` example. A `--dest` that is a folder inside a MinUI
|
||||
card (its `Bios/`) is refused with the root to name instead. A refused
|
||||
card (its `Bios/`) is refused with the root to name instead. AmberELEC
|
||||
and EmuELEC write `bios/` below their games part: their `bios` folder named
|
||||
with `--dest` stands for the games part, as RetroDECK's for its folder. A refused
|
||||
`--dest` ends with code 1 and an `error` event (`stage` `detect`). On Android a forced `--platform retroarch` falls back to
|
||||
`<shared storage>/RetroArch/system` instead, a home directory there belonging
|
||||
to Termux rather than to the frontend. Pass `--dest` to say where instead of
|
||||
@@ -1554,7 +1561,9 @@ names each route it tried, and `log.prev.txt` keeps the run before.
|
||||
**From a computer.** Onion, MinUI, NextUI, Allium, GarlicOS and the Switch
|
||||
have no Python 3 to run the installer. Put the card in a computer and run
|
||||
the [one-line command](#one-line) there: it finds a mounted Onion,
|
||||
spruceOS, MinUI, muOS, Knulli or ROCKNIX card by itself. For a card it does
|
||||
spruceOS, MinUI, muOS, Knulli, ROCKNIX or MiSTer card by itself (MiSTer's
|
||||
files go to `games/` below the card's root, which MiSTer reads as
|
||||
`/media/fat/games`). For a card it does
|
||||
not recognise, add `--platform <platform> --dest <BIOS folder on the card>`.
|
||||
This works for every system above too.
|
||||
|
||||
@@ -1629,8 +1638,8 @@ site build refuses to publish a copy whose pin is not the SHA-256 of the
|
||||
deploys, so a failed check keeps the last good pair served. Any change to
|
||||
`install.py` therefore means recomputing that pin in both wrappers.
|
||||
|
||||
They locate a Python 3.8 or newer interpreter (`python3` then `python` on
|
||||
POSIX, `py -3` then `python3` then `python` on Windows). Without one,
|
||||
They locate a Python 3.8 or newer interpreter (`python3`, `python`, then each
|
||||
`python3.X` on PATH, newest first, on POSIX, `py -3` then `python3` then `python` on Windows). Without one,
|
||||
`install.sh` names the command that installs it on the system it runs on
|
||||
(`pkg install python` in Termux, which it offers to run since Termux installs
|
||||
without root, `xcode-select --install` on macOS, apt, dnf, pacman, zypper or apk
|
||||
@@ -1727,7 +1736,7 @@ fields, the installer refuses to narrow rather than install everything.
|
||||
|------|---------|
|
||||
| `0` | everything asked for is installed or already in place |
|
||||
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 with its ssl, unicodedata and zlib modules (nothing was run), an unusable base URL, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`, `--target` with `--from-pack` or `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set) when no core list per board is at hand (the pack carries none and none is beside `--manifest` as `targets/<platform>.json`), `--list-targets` with `--offline` and neither, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`, `--target` with `--from-pack` or `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set) when no core list per board is at hand (the pack carries none and none is beside `--manifest` as `targets/<platform>.json`), or for a platform that publishes none, which is said to have no hardware targets, `--list-targets` with `--offline` and neither, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit |
|
||||
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question, the terminal keeping `RetroBIOS: stopped. Nothing was installed.` before an install. Run the same command again to carry on |
|
||||
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
|
||||
|
||||
@@ -1771,7 +1780,7 @@ other schemas under `api/v1/schemas/`.
|
||||
| Kind | Keys | When |
|
||||
|------|------|------|
|
||||
| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` |
|
||||
| `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence |
|
||||
| `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it or a read failing there (a failing card, a link that loops). What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence |
|
||||
| `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found |
|
||||
| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download with code 2. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart |
|
||||
| `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered |
|
||||
|
||||
Reference in new issue
Block a user