mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
feat: explain check refusals, install offline
--check names why each flagged file is refused and where each core opens it, and names a pack part missing from the folder from the markers split_pack writes. --from-pack installs from a downloaded pack or its parts with no network request, --manifest reads a saved file list, both checking every hash. The end of a run names the system or core that pulls most of the download when narrowing saves over half.
This commit is contained in:
1 parent
c2de7c3a8e
commit
aaabea8bd5
6 files changed
+1432
-26
No files matched your search
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "ad3b338a8f709be45352a12bd9c6a6250819a8fb9afd19498e24f41e3f062d20"
|
||||
$defaultInstallSha256 = "996cf4a8e21b8f05a524778046d0d32c807fbe3564f6edefdf5bee1add9c76cd"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+848
-18
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -18,7 +18,7 @@ esac
|
||||
TEMP_INSTALLER=""
|
||||
TEMP_DIRECTORY=""
|
||||
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
DEFAULT_INSTALL_SHA256="ad3b338a8f709be45352a12bd9c6a6250819a8fb9afd19498e24f41e3f062d20"
|
||||
DEFAULT_INSTALL_SHA256="996cf4a8e21b8f05a524778046d0d32c807fbe3564f6edefdf5bee1add9c76cd"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
"""What --check says about a refused file, and an install with no network.
|
||||
|
||||
A user with the BIOS one folder too deep, still in its ZIP or under another
|
||||
name read "missing: scph5501.bin" and nothing else. --check now names the
|
||||
reason and where each core opens the file. A pack extracted from some of
|
||||
its parts is named by the part it lacks. And a pack downloaded once
|
||||
installs again from disk, --from-pack, every file checked by its hash.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "tests"))
|
||||
|
||||
import install # noqa: E402
|
||||
import split_pack # noqa: E402
|
||||
from test_install_selection import FakeRepository # noqa: E402
|
||||
|
||||
US = b"us bios " * 64
|
||||
JP = b"jp bios " * 64
|
||||
EU = b"eu bios " * 64
|
||||
GBA = b"gba bios" * 32
|
||||
EXTRA = b"pcsx extra"
|
||||
|
||||
|
||||
def _sha1(data: bytes) -> str:
|
||||
return hashlib.sha1(data).hexdigest()
|
||||
|
||||
|
||||
def _entry(dest: str, data: bytes, systems: list[str], **fields) -> dict:
|
||||
return {
|
||||
"dest": dest, "sha1": _sha1(data), "size": len(data),
|
||||
"repo_path": f"bios/{dest.replace('/', '_')}", "cores": None,
|
||||
"systems": systems, **fields,
|
||||
}
|
||||
|
||||
|
||||
def manifest() -> dict:
|
||||
"""A small RetroArch list: two regions of one BIOS, a core file with a
|
||||
second revision, a file in a subfolder."""
|
||||
return {
|
||||
"manifest_version": 2,
|
||||
"platform": "retroarch",
|
||||
"files": [
|
||||
_entry("scph5501.bin", US, ["sony-playstation"], regions=["north-america"]),
|
||||
_entry("scph5500.bin", JP, ["sony-playstation"], regions=["japan"]),
|
||||
_entry("gba_bios.bin", GBA, ["nintendo-gba"]),
|
||||
_entry("pcsx/extra.bin", EXTRA, ["sony-playstation"], cores=["pcsx_rearmed"],
|
||||
alternatives=[{"sha1": _sha1(EU), "size": len(EU),
|
||||
"repo_path": "bios/eu", "cores": ["beetle_psx"]},
|
||||
{"sha1": _sha1(EXTRA), "size": len(EXTRA),
|
||||
"repo_path": "bios/x", "cores": ["pcsx_rearmed"]}]),
|
||||
],
|
||||
"system_cores": {"sony-playstation": ["beetle_psx", "pcsx_rearmed"],
|
||||
"nintendo-gba": ["mgba"]},
|
||||
}
|
||||
|
||||
|
||||
PAYLOAD = {"scph5501.bin": US, "scph5500.bin": JP, "gba_bios.bin": GBA,
|
||||
"pcsx/extra.bin": EXTRA}
|
||||
|
||||
|
||||
def _closed_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def _offline_env() -> dict:
|
||||
"""An environment where any request the installer makes is refused."""
|
||||
env = dict(os.environ)
|
||||
env["RETROBIOS_BASE_URL"] = f"http://127.0.0.1:{_closed_port()}"
|
||||
env["HTTPS_PROXY"] = env["HTTP_PROXY"] = f"http://127.0.0.1:{_closed_port()}"
|
||||
return env
|
||||
|
||||
|
||||
class Scratch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scratch = REPO_ROOT / "tmp"
|
||||
scratch.mkdir(exist_ok=True)
|
||||
self.root = Path(tempfile.mkdtemp(dir=scratch))
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
self.dest = self.root / "system"
|
||||
self.list = self.root / "retroarch.json"
|
||||
self.list.write_text(json.dumps(manifest()))
|
||||
|
||||
def run_installer(self, *options: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retroarch",
|
||||
"--dest", str(self.dest), *options],
|
||||
env=_offline_env(), capture_output=True, text=True, timeout=120,
|
||||
check=False, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
def place(self, relative: str, data: bytes) -> Path:
|
||||
path = self.dest / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
def check(self) -> str:
|
||||
proc = self.run_installer("--check", "--manifest", str(self.list), "--verbose")
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
||||
return proc.stdout
|
||||
|
||||
def complete(self, *skip: str) -> None:
|
||||
for name, data in PAYLOAD.items():
|
||||
if name not in skip:
|
||||
self.place(name, data)
|
||||
|
||||
|
||||
class CheckNamesWhyAFileIsRefused(Scratch):
|
||||
def test_another_region_is_named(self):
|
||||
self.complete("scph5501.bin")
|
||||
self.place("scph5501.bin", JP)
|
||||
out = self.check()
|
||||
self.assertIn("wrong hash: scph5501.bin", out)
|
||||
self.assertIn("another region: the contents of scph5500.bin (japan)", out)
|
||||
|
||||
def test_another_revision_names_the_core_that_reads_it(self):
|
||||
self.complete("pcsx/extra.bin")
|
||||
self.place("pcsx/extra.bin", EU)
|
||||
out = self.check()
|
||||
self.assertIn("another revision: the copy beetle_psx reads", out)
|
||||
|
||||
def test_a_wrong_size_is_measured(self):
|
||||
self.complete("gba_bios.bin")
|
||||
self.place("gba_bios.bin", b"short")
|
||||
self.assertIn(f"wrong size: 5 bytes, the list expects {len(GBA):,}", self.check())
|
||||
|
||||
def test_a_zip_under_the_file_name_is_still_zipped(self):
|
||||
self.complete("gba_bios.bin")
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("gba_bios.bin", GBA)
|
||||
self.place("gba_bios.bin", buffer.getvalue())
|
||||
self.assertIn("still zipped: this file is a ZIP archive", self.check())
|
||||
|
||||
def test_a_file_inside_an_archive_is_still_zipped(self):
|
||||
self.complete("gba_bios.bin")
|
||||
self.dest.mkdir(exist_ok=True)
|
||||
with zipfile.ZipFile(self.dest / "GBA BIOS.zip", "w") as archive:
|
||||
archive.writestr("bios/gba_bios.bin", GBA)
|
||||
self.assertIn("still zipped: it is inside GBA BIOS.zip", self.check())
|
||||
|
||||
def test_a_case_difference_is_named(self):
|
||||
probe = self.root / "CaseProbe"
|
||||
probe.write_text("x")
|
||||
if (self.root / "caseprobe").exists():
|
||||
self.skipTest("this filesystem does not keep case")
|
||||
self.complete("gba_bios.bin")
|
||||
self.place("GBA_BIOS.BIN", GBA)
|
||||
self.assertIn("case of the name: the folder holds GBA_BIOS.BIN", self.check())
|
||||
|
||||
def test_a_nested_bios_folder_is_named(self):
|
||||
for name, data in PAYLOAD.items():
|
||||
self.place(f"system/{name}", data)
|
||||
out = self.check()
|
||||
self.assertIn("nested folder: it is at system/system/scph5501.bin", out)
|
||||
self.assertIn(f"4 files sit too deep, in {self.dest.resolve() / 'system'}", out)
|
||||
|
||||
def test_a_wrong_folder_is_named(self):
|
||||
self.complete("pcsx/extra.bin")
|
||||
self.place("beetle/extra.bin", EXTRA)
|
||||
self.assertIn("wrong folder: it is at beetle/extra.bin", self.check())
|
||||
|
||||
def test_a_folder_too_deep_is_named(self):
|
||||
self.complete("gba_bios.bin")
|
||||
self.place("gba/roms/gba_bios.bin", GBA)
|
||||
self.assertIn("2 folders too deep: it is at gba/roms/gba_bios.bin", self.check())
|
||||
|
||||
def test_a_folder_too_high_is_named(self):
|
||||
self.complete("gba_bios.bin")
|
||||
(self.root / "gba_bios.bin").write_bytes(GBA)
|
||||
self.assertIn(f"one folder too high: it is at {self.root.resolve() / 'gba_bios.bin'}",
|
||||
self.check())
|
||||
|
||||
def test_a_name_no_core_opens_is_named(self):
|
||||
self.complete("scph5501.bin")
|
||||
self.place("PSX BIOS (USA).bin", US)
|
||||
self.assertIn("a name no core opens: its contents are at PSX BIOS (USA).bin, "
|
||||
"rename it scph5501.bin", self.check())
|
||||
|
||||
def test_each_flagged_file_says_where_its_cores_open_it(self):
|
||||
self.complete("scph5501.bin", "pcsx/extra.bin")
|
||||
out = self.check()
|
||||
here = self.dest.resolve()
|
||||
self.assertIn(f"opened at {here / 'scph5501.bin'} by beetle_psx, pcsx_rearmed", out)
|
||||
self.assertIn(f"opened at {here / 'pcsx' / 'extra.bin'} by beetle_psx, pcsx_rearmed",
|
||||
out)
|
||||
|
||||
def test_a_complete_folder_says_nothing_more(self):
|
||||
self.complete()
|
||||
proc = self.run_installer("--check", "--manifest", str(self.list))
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertNotIn("opened at", proc.stdout)
|
||||
|
||||
|
||||
def _pack(directory: Path, name: str, members: dict[str, bytes]) -> Path:
|
||||
"""A pack as generate_pack writes one: the files and a manifest.json."""
|
||||
listed = [{"path": path, "sha1": _sha1(data), "md5": hashlib.md5(data).hexdigest(),
|
||||
"size": len(data), "status": "verified", "name": path.rsplit("/", 1)[-1]}
|
||||
for path, data in members.items()]
|
||||
path = directory / name
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for member, data in members.items():
|
||||
archive.writestr(member, data)
|
||||
archive.writestr("README.txt", b"read me\n")
|
||||
archive.writestr("manifest.json", json.dumps({"files": listed}))
|
||||
return path
|
||||
|
||||
|
||||
def _noise(seed: str, size: int) -> bytes:
|
||||
"""Bytes deflate cannot shrink."""
|
||||
return b"".join(hashlib.sha256(f"{seed}{n}".encode()).digest()
|
||||
for n in range(size // 32))
|
||||
|
||||
|
||||
BIG = {f"dir{n}/file{n}.bin": _noise(str(n), 2048) for n in range(4)}
|
||||
|
||||
|
||||
class SplitParts(Scratch):
|
||||
def split(self) -> list[Path]:
|
||||
downloads = self.root / "downloads"
|
||||
downloads.mkdir()
|
||||
pack = _pack(downloads, "RetroArch_v1_BIOS_Pack.zip", BIG)
|
||||
parts = split_pack.split_pack(pack, limit=pack.stat().st_size // 3)
|
||||
self.assertGreater(len(parts), 2)
|
||||
return parts
|
||||
|
||||
def extract(self, parts: list[Path], into: Path | None = None) -> None:
|
||||
for part in parts:
|
||||
with zipfile.ZipFile(part) as archive:
|
||||
archive.extractall(into or self.dest)
|
||||
|
||||
def list_of_pack(self) -> None:
|
||||
self.list.write_text(json.dumps({
|
||||
"manifest_version": 2, "platform": "retroarch",
|
||||
"files": [_entry(path, data, ["arcade"]) for path, data in BIG.items()],
|
||||
}))
|
||||
|
||||
def test_check_names_the_part_missing_from_the_folder(self):
|
||||
parts = self.split()
|
||||
self.list_of_pack()
|
||||
self.extract(parts[:1] + parts[2:])
|
||||
out = self.check()
|
||||
count = len(parts)
|
||||
self.assertIn(f"RetroArch_v1_BIOS_Pack.zip comes in {count} parts, all needed.", out)
|
||||
self.assertIn(f"missing: part 2, RetroArch_v1_BIOS_Pack.part2of{count}.zip", out)
|
||||
self.assertNotIn("missing: part 1,", out)
|
||||
|
||||
def test_check_names_a_part_extracted_into_a_folder_of_its_own(self):
|
||||
parts = self.split()
|
||||
self.list_of_pack()
|
||||
self.extract(parts[1:])
|
||||
own = self.dest / parts[0].stem
|
||||
self.extract(parts[:1], own)
|
||||
out = self.check()
|
||||
self.assertIn(f"Part 1 went into {own}, a folder of its own", out)
|
||||
self.assertNotIn("missing: part 1,", out)
|
||||
|
||||
def test_a_complete_set_of_parts_says_nothing(self):
|
||||
parts = self.split()
|
||||
self.list_of_pack()
|
||||
self.extract(parts)
|
||||
proc = self.run_installer("--check", "--manifest", str(self.list))
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertNotIn("comes in", proc.stdout)
|
||||
|
||||
def test_from_pack_finds_the_other_parts_beside_one(self):
|
||||
parts = self.split()
|
||||
proc = self.run_installer("--from-pack", str(parts[1]))
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
for path, data in BIG.items():
|
||||
self.assertEqual((self.dest / path).read_bytes(), data)
|
||||
|
||||
def test_from_pack_names_a_part_that_is_not_there(self):
|
||||
parts = self.split()
|
||||
parts[1].unlink()
|
||||
proc = self.run_installer("--from-pack", str(parts[0]))
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn(f"not in {parts[0].parent}: {parts[1].name}", proc.stderr)
|
||||
self.assertFalse(self.dest.exists())
|
||||
|
||||
|
||||
class FromPackInstallsOffline(Scratch):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.downloads = self.root / "downloads"
|
||||
self.downloads.mkdir()
|
||||
self.pack = _pack(self.downloads, "RetroArch_v1_BIOS_Pack.zip", PAYLOAD)
|
||||
|
||||
def installed(self) -> dict[str, bytes]:
|
||||
return {p.relative_to(self.dest).as_posix(): p.read_bytes()
|
||||
for p in self.dest.rglob("*") if p.is_file()}
|
||||
|
||||
def test_the_pack_installs_with_no_network(self):
|
||||
proc = self.run_installer("--from-pack", str(self.pack))
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual(self.installed(), PAYLOAD)
|
||||
self.assertIn("Copying 4 files", proc.stdout)
|
||||
self.assertNotIn("Fetching", proc.stdout)
|
||||
|
||||
def test_the_folder_holding_the_pack_is_enough(self):
|
||||
proc = self.run_installer("--from-pack", str(self.downloads))
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual(self.installed(), PAYLOAD)
|
||||
|
||||
def test_the_platform_is_read_from_the_pack_name(self):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--dest", str(self.dest),
|
||||
"--from-pack", str(self.pack), "--no-input"],
|
||||
env=_offline_env(), capture_output=True, text=True, timeout=120,
|
||||
check=False, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual(self.installed(), PAYLOAD)
|
||||
|
||||
def test_a_pack_for_another_platform_is_refused(self):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "install.py"), "--platform", "batocera",
|
||||
"--dest", str(self.dest), "--from-pack", str(self.pack)],
|
||||
env=_offline_env(), capture_output=True, text=True, timeout=120,
|
||||
check=False, stdin=subprocess.DEVNULL,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("is a pack for RetroArch, not Batocera", proc.stderr)
|
||||
|
||||
def test_a_file_whose_bytes_differ_from_the_list_is_refused(self):
|
||||
"""The hash is checked against the list the run reads, not trusted
|
||||
from the archive that carries both."""
|
||||
listed = manifest()
|
||||
listed["files"][0]["sha1"] = _sha1(b"something else")
|
||||
self.list.write_text(json.dumps(listed))
|
||||
proc = self.run_installer("--from-pack", str(self.pack), "--manifest", str(self.list))
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
||||
self.assertIn("scph5501.bin", proc.stdout)
|
||||
self.assertFalse((self.dest / "scph5501.bin").exists())
|
||||
self.assertEqual((self.dest / "gba_bios.bin").read_bytes(), GBA)
|
||||
|
||||
def test_with_a_saved_list_the_choice_options_apply(self):
|
||||
proc = self.run_installer("--from-pack", str(self.pack), "--manifest",
|
||||
str(self.list), "--system", "nintendo-gba")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual(self.installed(), {"gba_bios.bin": GBA})
|
||||
|
||||
def test_a_file_the_pack_does_not_hold_fails_by_name(self):
|
||||
listed = manifest()
|
||||
listed["files"].append(_entry("absent.bin", b"absent", ["nintendo-gba"]))
|
||||
self.list.write_text(json.dumps(listed))
|
||||
proc = self.run_installer("--from-pack", str(self.pack), "--manifest", str(self.list))
|
||||
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
|
||||
self.assertIn("absent.bin (not in the pack)", proc.stdout)
|
||||
|
||||
def test_a_data_tree_is_rebuilt_from_the_pack(self):
|
||||
"""The tree's source archive is never fetched: the pack holds it
|
||||
where it extracts."""
|
||||
tree = {"Sys/GC/font.bin": b"font bytes", "Sys/Wii/setting.txt": b"setting"}
|
||||
self.pack = _pack(self.downloads, "RetroArch_v1_BIOS_Pack.zip",
|
||||
{**PAYLOAD, **{f"dolphin-emu/{k}": v for k, v in tree.items()}})
|
||||
listed = manifest()
|
||||
listed["data_directories"] = [{
|
||||
"ref": "dolphin-sys", "dest": "dolphin-emu", "type": "zip",
|
||||
"url": "https://example.invalid/dolphin.zip", "systems": ["nintendo-gba"],
|
||||
"files": {rel: {"sha1": _sha1(data), "size": len(data)}
|
||||
for rel, data in tree.items()},
|
||||
}]
|
||||
self.list.write_text(json.dumps(listed))
|
||||
proc = self.run_installer("--from-pack", str(self.pack), "--manifest", str(self.list))
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("dolphin-sys: 2 installed", proc.stdout)
|
||||
for rel, data in tree.items():
|
||||
self.assertEqual((self.dest / "dolphin-emu" / rel).read_bytes(), data)
|
||||
|
||||
def test_a_cut_download_is_named(self):
|
||||
self.pack.write_bytes(self.pack.read_bytes()[:100])
|
||||
proc = self.run_installer("--from-pack", str(self.pack))
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("cannot be read as a ZIP", proc.stderr)
|
||||
|
||||
def test_target_is_refused_with_from_pack(self):
|
||||
proc = self.run_installer("--from-pack", str(self.pack), "--target", "switch")
|
||||
self.assertEqual(proc.returncode, 2)
|
||||
self.assertIn("--target", proc.stderr)
|
||||
|
||||
def test_no_request_leaves_the_process(self):
|
||||
"""The guard holds in the code, not only in the test's dead proxy."""
|
||||
install.LOCAL.reset()
|
||||
install.LOCAL.pack = install.PackSource([self.pack])
|
||||
self.addCleanup(install.LOCAL.reset)
|
||||
with self.assertRaises(OSError):
|
||||
install._fetch_json("http://example.invalid/x.json", 10, "manifest")
|
||||
|
||||
|
||||
class ManifestFromDisk(unittest.TestCase):
|
||||
def test_a_saved_list_installs_without_fetching_the_list(self):
|
||||
with FakeRepository() as repo:
|
||||
saved = repo.root / "saved.json"
|
||||
served = repo.root / "install" / "retroarch.json"
|
||||
saved.write_bytes(served.read_bytes())
|
||||
served.unlink()
|
||||
proc = repo.run("--manifest", str(saved))
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertEqual(len(repo.installed()), 5)
|
||||
|
||||
def test_a_list_for_another_platform_is_refused(self):
|
||||
with FakeRepository() as repo:
|
||||
saved = repo.root / "saved.json"
|
||||
data = json.loads((repo.root / "install" / "retroarch.json").read_text())
|
||||
data["platform"] = "batocera"
|
||||
saved.write_text(json.dumps(data))
|
||||
proc = repo.run("--manifest", str(saved))
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("not valid for retroarch", proc.stderr)
|
||||
|
||||
|
||||
def _sized(dest: str, size: int, systems: list[str], cores: list[str] | None = None) -> dict:
|
||||
return {"dest": dest, "size": size, "sha1": "0" * 40, "systems": systems,
|
||||
"cores": cores, "repo_path": f"bios/{dest}"}
|
||||
|
||||
|
||||
MB = 1024 * 1024
|
||||
|
||||
|
||||
class TheSummaryNamesTheBulk(unittest.TestCase):
|
||||
def test_a_core_pulling_most_of_a_system_is_named_with_the_saving(self):
|
||||
files = [
|
||||
_sized("opera.bin", 12 * MB, ["3do"]),
|
||||
_sized("mame/3do.zip", 300 * MB, ["3do"], ["mame"]),
|
||||
_sized("mame/3do_m2.zip", 37 * MB, ["3do"], ["mame"]),
|
||||
]
|
||||
lines = install.bulk_hint(files, {"system_cores": {"3do": ["mame", "opera"]}},
|
||||
systems_chosen=True, cores_chosen=False)
|
||||
self.assertEqual(lines, [
|
||||
" mame pulls 337.0 MB of this 349.0 MB download for 3do. --core opera "
|
||||
"keeps 12.0 MB of it and saves 337.0 MB (96%)."
|
||||
])
|
||||
|
||||
def test_a_system_pulling_most_of_the_download_is_named(self):
|
||||
files = [_sized("a.zip", 400 * MB, ["arcade"]), _sized("b.bin", 50 * MB, ["nes"]),
|
||||
_sized("c.bin", 10 * MB, ["gba"])]
|
||||
lines = install.bulk_hint(files, {}, systems_chosen=False, cores_chosen=False)
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertIn("arcade pulls 400.0 MB of this 460.0 MB download", lines[0])
|
||||
self.assertIn("saves 400.0 MB (86%)", lines[0])
|
||||
|
||||
def test_nothing_is_said_under_half(self):
|
||||
files = [_sized("a.zip", 100 * MB, ["arcade"]), _sized("b.bin", 100 * MB, ["nes"])]
|
||||
self.assertEqual(install.bulk_hint(files, {}, False, False), [])
|
||||
|
||||
def test_nothing_is_said_for_a_small_download(self):
|
||||
files = [_sized("a.zip", 40 * MB, ["arcade"]), _sized("b.bin", 1 * MB, ["nes"])]
|
||||
self.assertEqual(install.bulk_hint(files, {}, False, False), [])
|
||||
|
||||
def test_a_chosen_core_is_not_second_guessed(self):
|
||||
files = [_sized("opera.bin", 12 * MB, ["3do"]),
|
||||
_sized("mame/3do.zip", 300 * MB, ["3do"], ["mame"])]
|
||||
self.assertEqual(install.bulk_hint(
|
||||
files, {"system_cores": {"3do": ["mame", "opera"]}}, True, True), [])
|
||||
|
||||
def test_the_end_summary_prints_the_line(self):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
install._report_outcome([Path("/x")], 3, 0, 0, 0, False,
|
||||
hints=[" arcade pulls most of it."])
|
||||
text = out.getvalue()
|
||||
self.assertIn(" arcade pulls most of it.", text)
|
||||
self.assertLess(text.index("Done."), text.index("arcade pulls"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+93
-5
@@ -223,6 +223,16 @@ Location: /userdata/bios
|
||||
To check this later, run the same command with --check.
|
||||
```
|
||||
|
||||
When most of a download of 100 MB or more comes from one system, or from
|
||||
one core of a single system, the end of the run says so and what narrowing
|
||||
would save, once that saving is over half:
|
||||
|
||||
```
|
||||
mame pulls 336.5 MB of this 349.0 MB download for 3do. --core opera keeps 12.5 MB of it and saves 336.5 MB (96%).
|
||||
```
|
||||
|
||||
`--dry-run` prints the same line under its plan.
|
||||
|
||||
The word `Done` and a location are the confirmation. Any other ending
|
||||
(`Stopped`, `Finished with errors`, `Nothing was installed`) means something
|
||||
was left undone, and the run says which case it was.
|
||||
@@ -252,6 +262,52 @@ present 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).
|
||||
|
||||
### Why a file is refused { #why-a-file-is-refused }
|
||||
|
||||
Each file `--check` flags comes with the reason it found and the place each
|
||||
core opens it:
|
||||
|
||||
```
|
||||
3 files to download:
|
||||
wrong hash: scph5501.bin
|
||||
another region: the contents of scph5500.bin (japan)
|
||||
opened at /home/me/.config/retroarch/system/scph5501.bin by beetle_psx, pcsx_rearmed
|
||||
missing: gba_bios.bin
|
||||
nested folder: it is at system/system/gba_bios.bin, a system folder inside system
|
||||
opened at /home/me/.config/retroarch/system/gba_bios.bin by mgba
|
||||
missing: pcsx/extra.bin
|
||||
still zipped: it is inside PSX BIOS.zip
|
||||
opened at /home/me/.config/retroarch/system/pcsx/extra.bin by pcsx_rearmed
|
||||
```
|
||||
|
||||
| Reason | What the folder holds |
|
||||
|--------|-----------------------|
|
||||
| `nested folder` | The file inside a folder named like the BIOS folder itself (`bios/bios/`, `system/system/`) |
|
||||
| `one folder too deep`, `2 folders too deep` | The file below its place, as a ZIP extracted into a folder of its own leaves it |
|
||||
| `one folder too high` | The file in the folder above the BIOS folder |
|
||||
| `wrong folder` | The file under its name in another folder |
|
||||
| `case of the name` | A name that differs only by capital letters, on a system where that makes two names |
|
||||
| `still zipped` | The file inside a ZIP in the folder, a `.7z` or `.rar` of the same name, or a ZIP saved under the file's own name |
|
||||
| `wrong size` | A file of another size than any copy the list accepts |
|
||||
| `another region`, `another revision`, `another file` | The contents of another file of the list: another region's BIOS, the copy another core reads, or the one the platform's own list declares |
|
||||
| `a name no core opens` | The right contents under a name no core of the platform reads |
|
||||
| `same size, other contents` | Another dump, or a damaged copy |
|
||||
|
||||
The reasons are read from the folder and the file list, nothing else. When
|
||||
three files or more sit too deep in the same folder, a line before the list
|
||||
names that folder once. Files with a reason are always listed. The plain
|
||||
missing ones stop at ten unless `--verbose` is given.
|
||||
|
||||
A pack published in parts leaves a marker beside the files of each part
|
||||
(`RetroArch_v1.22.2_BIOS_Pack.part1of3.txt`). `--check` reads them and names
|
||||
a part that was not extracted, or one extracted into a folder of its own:
|
||||
|
||||
```
|
||||
RetroArch_v1.22.2_BIOS_Pack.zip comes in 3 parts, all needed.
|
||||
missing: part 2, RetroArch_v1.22.2_BIOS_Pack.part2of3.zip (1.9 GB)
|
||||
Download it and extract into /home/me/.config/retroarch/system, beside the other parts.
|
||||
```
|
||||
|
||||
## Files you already had
|
||||
|
||||
A file in the BIOS folder whose contents differ from the expected one (another
|
||||
@@ -448,6 +504,8 @@ what the options name, everything by default.
|
||||
| `--yes`, `-y` | Do not ask before a download of 1 GB or more |
|
||||
| `--quiet`, `-q` | Print only failures and the final summary. Implies `--no-input` |
|
||||
| `--events` | Write the run as JSON events on standard output, one object per line, and the usual text on standard error. Implies `--no-input`. See [Machine-readable events](#machine-readable-events) |
|
||||
| `--from-pack PATH` | Install from a pack already downloaded: its ZIP, one of its parts (the others are found in the same folder), or the folder holding them. Makes no network request and checks every file's hash. See [Without a network](#without-a-network) |
|
||||
| `--manifest FILE` | Read the file list from FILE, a copy of `install/<platform>.json`, instead of downloading it. See [Without a network](#without-a-network) |
|
||||
| `--restore FOLDER` | Put back the files a run replaced and kept in FOLDER, a dated folder inside `retrobios-backup`. Takes no other option but `--dry-run` and `--no-input`. See [Files you already had](#files-you-already-had) |
|
||||
| `--version` | Print the installer's identity, the first 12 hex digits of the SHA-256 the bootstraps pin |
|
||||
| `--list-platforms` | Print the supported platforms and what was detected here |
|
||||
@@ -459,8 +517,9 @@ what the options name, everything by default.
|
||||
|
||||
`--check` reads the same file list, hashes the destination and exits, with
|
||||
code 1 when a file is missing or does not match and 0 when every one does. Files
|
||||
with a wrong hash are listed first and in full, then the first ten missing ones
|
||||
(`--verbose` lists them all). `--dry-run` counts the data trees in its total. Without
|
||||
with a wrong hash are listed first and in full, then the missing ones whose
|
||||
reason it found, then the first ten other missing ones (`--verbose` lists them
|
||||
all). See [Why a file is refused](#why-a-file-is-refused). `--dry-run` counts the data trees in its total. Without
|
||||
it, a file already present with the expected hash is left untouched, and one
|
||||
whose contents do not match is re-downloaded and replaced by the verified copy,
|
||||
the old one kept beside the BIOS folder.
|
||||
@@ -806,6 +865,34 @@ The launcher (`ports/RetroBIOS/RetroBIOS.sh`) and its screens
|
||||
(`retrobios/screens.txt`) read the installer through
|
||||
[Machine-readable events](#machine-readable-events) only.
|
||||
|
||||
## Without a network { #without-a-network }
|
||||
|
||||
A pack downloaded once installs again with no network, on this machine or on
|
||||
one that has none:
|
||||
|
||||
```bash
|
||||
python install.py --from-pack ~/Downloads/RetroArch_v1.22.2_BIOS_Pack.zip
|
||||
python install.py --from-pack ~/Downloads/RetroArch_v1.22.2_BIOS_Pack.part1of3.zip
|
||||
python install.py --from-pack ~/Downloads --dest /media/card/retroarch/system
|
||||
```
|
||||
|
||||
One part is enough to name a split pack: the others are taken from the same
|
||||
folder, and a part that is not there is named before anything is written.
|
||||
The platform is read from the pack's name when `--platform` is not given. The
|
||||
file list is the one the pack carries (`manifest.json`), with each file's
|
||||
SHA-1 and size, and every file is checked against it before it takes its
|
||||
place. A data tree (Dolphin's `Sys/`, PPSSPP's assets) comes from the pack
|
||||
too, not from its source archive. `--target` reads a core list from the
|
||||
network and is refused with `--from-pack`. A board found on the device is not
|
||||
applied either: every board's files are installed.
|
||||
|
||||
`--manifest FILE` reads the file list from a copy of `install/<platform>.json`
|
||||
instead of downloading it. Alone, the files still come from the repository.
|
||||
With `--from-pack`, the run reads that list and takes every file from the
|
||||
pack: `--system`, `--core`, `--region` and `--no-game-data` then apply as
|
||||
they do online, and a file of the list the pack does not hold fails as
|
||||
`not in the pack`. The list's platform has to be the one installed for.
|
||||
|
||||
## Where the files come from
|
||||
|
||||
Each manifest entry names either `repo_path`, served from the repository at the
|
||||
@@ -911,8 +998,8 @@ fields, the installer refuses to narrow rather than install everything.
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `0` | everything asked for is installed or already up to date |
|
||||
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--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 while others installed, `--check` found files to fetch, or `--restore` could not put a file back |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), the drive has too little free space for the download (checked before any folder is created), or the question about a `--dest` folder that does not exist was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board`. The message then starts with `install.py: error:`. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` |
|
||||
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--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 while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, or `--restore` could not put a file back |
|
||||
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), the drive has too little free space for the download (checked before any folder is created), or the question about a `--dest` folder that does not exist was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`. The message then starts with `install.py: error:`. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` |
|
||||
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question. Run the same command again to carry on |
|
||||
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
|
||||
|
||||
@@ -1054,7 +1141,8 @@ right pack either way.
|
||||
|
||||
Parts named `Pack.part1of2.zip` are ordinary ZIPs: unzip each one into the same
|
||||
folder, and check that all of them were downloaded. The name says how many
|
||||
there are. `scripts/download.sh` and `scripts/download.py` download every part
|
||||
there are, and each part leaves a marker file naming every part, which
|
||||
`--check` reads to name a part that is missing. `scripts/download.sh` and `scripts/download.py` download every part
|
||||
and check it against the release's checksums before unzipping. Both stage
|
||||
inside the destination folder, never in `/tmp`, which is a RAM disk on most of
|
||||
these systems.
|
||||
@@ -51,7 +51,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_minui_platform`, `test_no_game_data`, `test_pack_conformance_dirs`, `test_pack_core_extras`, `test_pack_counts`, `test_pack_destinations`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_required_only_archives`, `test_required_only_twins`, `test_save_root`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` |
|
||||
| Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` |
|
||||
| Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` |
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_selection` |
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` |
|
||||
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
|
||||
|
||||
Reference in new issue
Block a user