mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-12 04:53:52 -06:00
chore: merge the platform quirks branch
# Conflicts: # install.ps1 # install.sh # install/launchers/index.json # wiki/testing-guide.md
This commit is contained in:
commit
20291e3573
15 files changed
+360
-19
No files matched your search
@@ -65,6 +65,7 @@ on:
|
||||
- "scripts/sitesources.py"
|
||||
- "scripts/sitesteps.py"
|
||||
- "scripts/sitestarlight.py"
|
||||
- "scripts/sitesteps.py"
|
||||
- "scripts/sitewrite.py"
|
||||
- "scripts/slot.py"
|
||||
- "scripts/slots.py"
|
||||
|
||||
+1
-1
@@ -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 = "6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852"
|
||||
$defaultInstallSha256 = "8e76cb7414e0a427b22c617a918db13c6481082ae4bf55e4399e25532b6a81ab"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+79
@@ -3132,6 +3132,72 @@ def fold_case_twins(files: list[dict]) -> tuple[list[dict], list[str]]:
|
||||
return kept, folded
|
||||
|
||||
|
||||
# The characters FAT and exFAT refuse in a name, as Windows does on any drive
|
||||
# (Linux 7475ea8b, fs/fat/namei_vfat.c:201-207 and fs/exfat/nls.c:396-403;
|
||||
# "Naming Files, Paths, and Namespaces" in the Win32 documentation). The
|
||||
# slash, the backslash and the controls never reach a destination
|
||||
# (_safe_relative_path). RetroDECK names three files zxs:48.rom,
|
||||
# zxs:if1-2.rom and snes:st018.data.rom (its retroarch component manifest):
|
||||
# on an exFAT card each write failed, on every run.
|
||||
FAT_REFUSED = re.compile(r'[<>:"|?*]')
|
||||
FAT_REFUSED_SHOWN = '< > : " | ? *'
|
||||
|
||||
|
||||
def holds_fat_refused_names(directory: Path) -> bool:
|
||||
"""Whether a name holding a colon can be created there.
|
||||
|
||||
Probed on the nearest existing directory, as keeps_case is: an ext4
|
||||
card on a Steam Deck holds one, an exFAT card refuses it with EINVAL.
|
||||
Windows refuses it on every drive. A probe refused for another cause
|
||||
(a folder this user cannot write) decides nothing.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return False
|
||||
probe_dir = directory
|
||||
while not probe_dir.is_dir() and probe_dir != probe_dir.parent:
|
||||
probe_dir = probe_dir.parent
|
||||
try:
|
||||
handle, name = tempfile.mkstemp(prefix=".retrobios-name-", suffix=":",
|
||||
dir=str(probe_dir))
|
||||
except OSError as exc:
|
||||
return exc.errno != errno.EINVAL
|
||||
os.close(handle)
|
||||
os.unlink(name)
|
||||
return True
|
||||
|
||||
|
||||
def split_fat_refused(files: list[dict]) -> tuple[list[dict], list[str]]:
|
||||
"""The entries whose destination a FAT, exFAT or Windows folder can
|
||||
hold, and the destinations it cannot."""
|
||||
kept = [entry for entry in files if not FAT_REFUSED.search(str(entry.get("dest", "")))]
|
||||
refused = [str(entry.get("dest", "")) for entry in files
|
||||
if FAT_REFUSED.search(str(entry.get("dest", "")))]
|
||||
return kept, refused
|
||||
|
||||
|
||||
# Android's media scanner marks a folder holding a .nomedia file hidden, and
|
||||
# every folder below it (MediaProvider a183e2b9, FileUtils.java:1587-1629
|
||||
# isDirectoryHidden, ModernMediaScanner.java:845-846 and 1017-1020). Without
|
||||
# one, the samples, overlays and videos of a BIOS folder in shared storage
|
||||
# showed in the phone's gallery and music apps. RetroArch writes none
|
||||
# (libretro-common/file/file_path_io.c:194 only skips one).
|
||||
NOMEDIA = ".nomedia"
|
||||
|
||||
|
||||
def hide_from_media_scanner(folder: Path) -> bool:
|
||||
"""Leave an empty .nomedia in folder, True when this call wrote it."""
|
||||
marker = folder / NOMEDIA
|
||||
if not _is_dir(folder) or _exists(marker):
|
||||
return False
|
||||
try:
|
||||
marker.touch()
|
||||
except OSError as exc:
|
||||
print(f" {NOMEDIA} not written in {shown_path(folder)}: {exc.strerror}",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def installed_cores(dirs: list[Path]) -> list[str]:
|
||||
"""The cores present in these directories, by the name the buildbot uses.
|
||||
|
||||
@@ -15609,6 +15675,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
f" {_plural(len(folded), 'file')} left out: {bios_path} does not keep "
|
||||
f"case and holds another file under the same name ({shown})"
|
||||
)
|
||||
if any(FAT_REFUSED.search(str(entry.get("dest", ""))) for entry in files) \
|
||||
and not holds_fat_refused_names(bios_path):
|
||||
files, refused = split_fat_refused(files)
|
||||
shown = ", ".join(refused[:3]) + (" ..." if len(refused) > 3 else "")
|
||||
print(
|
||||
f" {_plural(len(refused), 'file')} left out: the file system of "
|
||||
f"{shown_path(bios_path)} cannot hold a name with any of "
|
||||
f"{FAT_REFUSED_SHOWN} ({screen_text(shown)})"
|
||||
)
|
||||
|
||||
game_data_cut = 0
|
||||
if args.no_game_data and not manifest.get("categorized"):
|
||||
@@ -16359,6 +16434,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
|
||||
failures.append((where, f"{NOT_KEPT} ({reason})"))
|
||||
total_errors += 1
|
||||
|
||||
if os_type == "android" and not args.check and hide_from_media_scanner(bios_path):
|
||||
print(f" Added {NOMEDIA} to {shown_path(bios_path)}: the gallery and music "
|
||||
"apps leave its images, sounds and videos out")
|
||||
|
||||
# Standalone copies
|
||||
if (
|
||||
manifest.get("standalone_copies")
|
||||
|
||||
+1
-1
@@ -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="6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852"
|
||||
DEFAULT_INSTALL_SHA256="8e76cb7414e0a427b22c617a918db13c6481082ae4bf55e4399e25532b6a81ab"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
{
|
||||
"dest": "roms/ports/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1126086,
|
||||
"sha256": "6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852",
|
||||
"size": 1129605,
|
||||
"sha256": "8e76cb7414e0a427b22c617a918db13c6481082ae4bf55e4399e25532b6a81ab",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -71,8 +71,8 @@
|
||||
"muos": {
|
||||
"archive": {
|
||||
"dest": "ARCHIVE/RetroBIOS.muxapp",
|
||||
"size": 1179711,
|
||||
"sha256": "184146fc9785a1aee6b69f0fae3fac5100dc8cb5558a6f369a5cc1ad3301377d"
|
||||
"size": 1183230,
|
||||
"sha256": "2ea59a9d6b82a06d58154b2e0efdd7d40372e373340fa4f16f33262e2fe0695e"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
@@ -92,8 +92,8 @@
|
||||
{
|
||||
"name": "RetroBIOS/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1126086,
|
||||
"sha256": "6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852",
|
||||
"size": 1129605,
|
||||
"sha256": "8e76cb7414e0a427b22c617a918db13c6481082ae4bf55e4399e25532b6a81ab",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@
|
||||
{
|
||||
"dest": ".config/modules/retrobios/install.py",
|
||||
"repo_path": "install.py",
|
||||
"size": 1126086,
|
||||
"sha256": "6bd77b283f20cad55d53df8a2e7fe8ef177e821f4dfade37e2d12af2fa3f3852",
|
||||
"size": 1129605,
|
||||
"sha256": "8e76cb7414e0a427b22c617a918db13c6481082ae4bf55e4399e25532b6a81ab",
|
||||
"executable": false
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2599,7 +2599,7 @@
|
||||
],
|
||||
"total_files": 8338,
|
||||
"total_size": 7080104487,
|
||||
"pack_files": 14921,
|
||||
"pack_files": 14922,
|
||||
"pack_size": 7222137851,
|
||||
"total_omitted": 37,
|
||||
"omitted_files": [
|
||||
|
||||
@@ -416,6 +416,17 @@ def platform_list_version(config: dict, platform: str) -> str:
|
||||
PACK_DOCUMENTS = ("README.txt", "manifest.json")
|
||||
|
||||
|
||||
def pack_markers(
|
||||
platform_name: str, target_name: str | None, platforms_dir: str
|
||||
) -> tuple[str, ...]:
|
||||
"""The empty files a pack carries for the systems it is unzipped on,
|
||||
as named before flattening: the .nomedia of a pack for Android."""
|
||||
from common import list_available_targets
|
||||
|
||||
names = [t["name"] for t in list_available_targets(platform_name, platforms_dir)]
|
||||
return (MEDIA_MARKER,) if lands_on_android(names, target_name) else ()
|
||||
|
||||
|
||||
def core_data_trees(
|
||||
config: dict,
|
||||
profiles: dict[str, dict],
|
||||
@@ -1477,6 +1488,10 @@ def generate_pack(
|
||||
),
|
||||
)
|
||||
_write_generated_member(zf, "README.txt", readme_text)
|
||||
for marker in pack_markers(platform_name, target_name, platforms_dir):
|
||||
_write_generated_member(
|
||||
zf, _flat(f"{base_dest}/{marker}" if base_dest else marker, base_dest, flatten), ""
|
||||
)
|
||||
|
||||
_report_pack_counts(
|
||||
zip_path, file_status, total_files, core_count, source,
|
||||
@@ -5147,7 +5162,7 @@ def generate_manifest(
|
||||
"total_size": total_size,
|
||||
"pack_files": (
|
||||
len(manifest_files) + len(data_sizes) + len(PACK_DOCUMENTS)
|
||||
+ len(rename_notes)
|
||||
+ len(rename_notes) + len(pack_markers(platform_name, target_name, platforms_dir))
|
||||
),
|
||||
"pack_size": total_size + sum(data_sizes),
|
||||
"total_omitted": len(omitted_by_destination),
|
||||
@@ -5719,6 +5734,8 @@ def verify_and_finalize_packs(
|
||||
return all_ok
|
||||
|
||||
from packpaths import ( # noqa: E402,F401
|
||||
MEDIA_MARKER,
|
||||
lands_on_android,
|
||||
_archive_destination,
|
||||
_path_parents,
|
||||
_has_path_conflict,
|
||||
|
||||
@@ -192,6 +192,23 @@ def _register_path(dest: str, seen_files: set[str], seen_parents: set[str]) -> N
|
||||
for parent in _path_parents(dest):
|
||||
seen_parents.add(parent)
|
||||
|
||||
# Android's media scanner hides a folder holding a .nomedia file, and every
|
||||
# folder below it (MediaProvider a183e2b9, FileUtils.java:1587-1629
|
||||
# isDirectoryHidden, ModernMediaScanner.java:845-846). RetroArch writes none
|
||||
# (libretro-common/file/file_path_io.c:194 only skips one), so a pack
|
||||
# unzipped into RetroArch Android's system folder put its samples, overlays
|
||||
# and videos in the gallery and music apps.
|
||||
MEDIA_MARKER = ".nomedia"
|
||||
|
||||
|
||||
def lands_on_android(target_names: Iterable[str], target: str | None) -> bool:
|
||||
"""Whether a pack can be unzipped on Android: its target is an Android
|
||||
one, or, unfiltered, the platform files one."""
|
||||
if target:
|
||||
return target.startswith("android")
|
||||
return any(name.startswith("android") for name in target_names)
|
||||
|
||||
|
||||
def _flat(arcname: str, prefix: str, flatten: bool) -> str:
|
||||
"""Strip base_destination prefix from ZIP arcname when flattening."""
|
||||
if flatten and prefix and arcname.startswith(prefix + "/"):
|
||||
|
||||
@@ -10,7 +10,7 @@ from collections import Counter
|
||||
|
||||
from auto_fetch import DEFAULT_BIOS_DIR
|
||||
from artifacts import _build_timestamp
|
||||
from packpaths import _has_path_conflict
|
||||
from packpaths import MEDIA_MARKER, _has_path_conflict
|
||||
from ziptools import build_zip_contents_index
|
||||
from ziptools import check_inside_zip
|
||||
from nativemode import digest_algorithm
|
||||
@@ -170,7 +170,7 @@ def verify_pack(
|
||||
# (bios/INSTRUCTIONS_x.txt, games/keen6/INSTRUCTIONS_y.txt), so
|
||||
# they are told by their own name, not by a root prefix.
|
||||
base = name.rsplit("/", 1)[-1]
|
||||
if base.startswith(("INSTRUCTIONS_", "RENAMED_")) or name in (
|
||||
if base.startswith(("INSTRUCTIONS_", "RENAMED_")) or base == MEDIA_MARKER or name in (
|
||||
"manifest.json",
|
||||
"README.txt",
|
||||
):
|
||||
@@ -539,7 +539,7 @@ def _structural_errors(zf: zipfile.ZipFile, zip_set: set) -> list[str]:
|
||||
if "GraphicMods" not in info.filename and info.filename not in (
|
||||
"manifest.json",
|
||||
"README.txt",
|
||||
):
|
||||
) and info.filename.rsplit("/", 1)[-1] != MEDIA_MARKER:
|
||||
errors.append(f"zero-byte: {info.filename}")
|
||||
|
||||
return errors
|
||||
@@ -746,7 +746,7 @@ def verify_pack_against_platform(
|
||||
is_flat = bool(base_dest) and not any(
|
||||
n.startswith(base_dest + "/")
|
||||
for n in zip_set
|
||||
if n not in ("README.txt", "manifest.json") and not n.endswith("/")
|
||||
if n not in ("README.txt", "manifest.json", MEDIA_MARKER) and not n.endswith("/")
|
||||
)
|
||||
|
||||
errors.extend(_structural_errors(zf, zip_set))
|
||||
|
||||
@@ -4313,9 +4313,16 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
|
||||
manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text())
|
||||
dirs = manifest.get("data_directories") or []
|
||||
files = sum(len(d["files"]) for d in dirs)
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
import generate_pack
|
||||
|
||||
written = generate_pack.PACK_DOCUMENTS + generate_pack.pack_markers(
|
||||
"retroarch", None, str(REPO_ROOT / "platforms"))
|
||||
self.assertEqual(written, ("README.txt", "manifest.json", ".nomedia"))
|
||||
self.assertEqual(
|
||||
manifest["pack_files"] - manifest["total_files"] - files,
|
||||
2, "the pack's two documents are the only files the installer lacks",
|
||||
manifest["pack_files"] - manifest["total_files"] - files, len(written),
|
||||
"the pack's two documents and its .nomedia are the only files the "
|
||||
"installer lacks",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""What an install does for the device and file system it lands on."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
|
||||
|
||||
import contextlib
|
||||
import errno
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
sys.path.insert(0, str(REPO_ROOT / "tests"))
|
||||
|
||||
import install # noqa: E402
|
||||
from test_install_selection import FakeRepository # noqa: E402
|
||||
|
||||
|
||||
class AndroidMediaScanner(unittest.TestCase):
|
||||
"""Without a .nomedia the BIOS folder's samples, overlays and videos
|
||||
showed in the phone's gallery and music apps."""
|
||||
|
||||
def setUp(self):
|
||||
self.repo = FakeRepository().__enter__()
|
||||
self.addCleanup(self.repo.__exit__)
|
||||
|
||||
def _run(self, os_name: str, *options: str):
|
||||
proc = subprocess.run(self.repo.command("--yes", "--no-input", *options),
|
||||
env={**self.repo.env(), "RETROBIOS_OS": os_name},
|
||||
capture_output=True, text=True, timeout=60,
|
||||
stdin=subprocess.DEVNULL)
|
||||
return self.repo, proc
|
||||
|
||||
def test_an_android_install_writes_one(self):
|
||||
repo, proc = self._run("android")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
marker = repo.dest / ".nomedia"
|
||||
self.assertTrue(marker.is_file())
|
||||
self.assertEqual(marker.stat().st_size, 0)
|
||||
self.assertIn("Added .nomedia", proc.stdout)
|
||||
|
||||
def test_a_second_run_leaves_it_and_says_nothing(self):
|
||||
self._run("android")
|
||||
_, proc = self._run("android")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertNotIn("Added .nomedia", proc.stdout)
|
||||
# The marker is not a file of the list: the check finds all in place.
|
||||
_, check = self._run("android", "--check")
|
||||
self.assertEqual(check.returncode, 0, check.stdout + check.stderr)
|
||||
|
||||
def test_a_check_and_a_dry_run_write_nothing(self):
|
||||
for options in (("--check",), ("--dry-run",)):
|
||||
with self.subTest(options=options):
|
||||
self._run("android", *options)
|
||||
self.assertFalse((self.repo.dest / ".nomedia").exists())
|
||||
|
||||
def test_other_systems_get_none(self):
|
||||
repo, proc = self._run("linux")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertFalse((repo.dest / ".nomedia").exists())
|
||||
|
||||
|
||||
class NamesAFatCardCannotHold(unittest.TestCase):
|
||||
"""RetroDECK names three files with a colon (zxs:48.rom): on an exFAT
|
||||
card each write failed with EINVAL, on every run."""
|
||||
|
||||
def test_the_probe_reads_einval_as_a_refusal(self):
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
refused = OSError(errno.EINVAL, "Invalid argument")
|
||||
closed = PermissionError(errno.EACCES, "Permission denied")
|
||||
with mock.patch.object(install.os, "name", "posix"):
|
||||
self.assertTrue(install.holds_fat_refused_names(Path(tmp) / "a" / "b"))
|
||||
with mock.patch.object(install.tempfile, "mkstemp", side_effect=refused):
|
||||
self.assertFalse(install.holds_fat_refused_names(Path(tmp)))
|
||||
with mock.patch.object(install.tempfile, "mkstemp", side_effect=closed):
|
||||
self.assertTrue(install.holds_fat_refused_names(Path(tmp)))
|
||||
self.assertEqual(os.listdir(tmp), [])
|
||||
|
||||
def test_windows_refuses_them_on_any_drive(self):
|
||||
with mock.patch.object(install.os, "name", "nt"):
|
||||
self.assertFalse(install.holds_fat_refused_names(Path(".")))
|
||||
|
||||
def _install(self, holds: bool) -> tuple[str, list[str]]:
|
||||
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
|
||||
self.addCleanup(shutil.rmtree, root, True)
|
||||
old, dest = root / "old", root / "dest"
|
||||
old.mkdir()
|
||||
files = []
|
||||
for name, payload in (("zxs:48.rom", b"zx 48"), ("gba_bios.bin", b"gba")):
|
||||
(old / name.replace(":", "_")).write_bytes(payload)
|
||||
files.append({"dest": name, "sha1": hashlib.sha1(payload).hexdigest(),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"size": len(payload), "repo_path": f"bios/{name}"})
|
||||
listed = root / "list.json"
|
||||
listed.write_text(json.dumps({"manifest_version": 2, "platform": "retrodeck",
|
||||
"files": files}))
|
||||
out = io.StringIO()
|
||||
argv = ["install.py", "--platform", "retrodeck", "--dest", str(dest),
|
||||
"--manifest", str(listed), "--from", str(old), "--no-input"]
|
||||
with mock.patch.object(install, "holds_fat_refused_names", return_value=holds), \
|
||||
mock.patch.object(install, "detect_os", return_value="linux"), \
|
||||
mock.patch("sys.argv", argv), contextlib.redirect_stdout(out), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
try:
|
||||
install.main()
|
||||
except SystemExit as exc:
|
||||
self.assertIn(exc.code, (0, None), out.getvalue())
|
||||
self.addCleanup(install.LOCAL.reset)
|
||||
return (" ".join(out.getvalue().split()),
|
||||
sorted(p.name for p in dest.iterdir()) if dest.is_dir() else [])
|
||||
|
||||
def test_the_names_are_left_out_and_said(self):
|
||||
said, installed = self._install(holds=False)
|
||||
self.assertEqual(installed, ["gba_bios.bin"])
|
||||
self.assertIn("1 file left out: the file system of", said)
|
||||
self.assertIn('cannot hold a name with any of < > : " | ? * (zxs:48.rom)', said)
|
||||
|
||||
def test_a_folder_that_holds_them_gets_them(self):
|
||||
said, installed = self._install(holds=True)
|
||||
self.assertEqual(installed, ["gba_bios.bin", "zxs:48.rom"])
|
||||
self.assertNotIn("left out", said)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -301,6 +301,60 @@ RECORD = {
|
||||
}
|
||||
|
||||
|
||||
class APackForAndroidHidesItsMedia(PackCountFixture):
|
||||
"""RetroArch Android writes no .nomedia, and its system folder held the
|
||||
samples, overlays and videos the gallery and music apps then showed."""
|
||||
|
||||
def _pack(self, target: str | None) -> dict[str, int]:
|
||||
targets = self.platforms / "targets"
|
||||
targets.mkdir(exist_ok=True)
|
||||
(targets / "demo.yml").write_text(yaml.dump({"platform": "demo", "targets": {
|
||||
"android-arm64-v8a": {"architecture": "aarch64", "cores": ["demo"]},
|
||||
"linux-x86_64": {"architecture": "x86_64", "cores": ["demo"]},
|
||||
}}))
|
||||
out = self.root / f"dist-{target}"
|
||||
out.mkdir()
|
||||
zip_path = builder.generate_pack(
|
||||
"demo", str(self.platforms), self.db, str(self.bios), str(out),
|
||||
emulators_dir=str(self.emulators), emu_profiles=self.profiles,
|
||||
data_registry=self.registry, offline=True, target_name=target,
|
||||
)
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
return {m.filename: m.file_size for m in archive.infolist()}
|
||||
|
||||
def test_the_whole_pack_and_an_android_one_carry_it(self):
|
||||
for target in (None, "android-arm64-v8a"):
|
||||
with self.subTest(target=target):
|
||||
self.assertEqual(self._pack(target).get(".nomedia"), 0)
|
||||
|
||||
def test_a_pack_for_another_target_does_not(self):
|
||||
self.assertNotIn(".nomedia", self._pack("linux-x86_64"))
|
||||
|
||||
def test_the_manifest_counts_it(self):
|
||||
members = self._pack(None)
|
||||
manifest = builder.generate_manifest(
|
||||
"demo", str(self.platforms), self.db, str(self.bios),
|
||||
str(self.platforms / "_registry.yml"),
|
||||
emulators_dir=str(self.emulators), emu_profiles=self.profiles,
|
||||
offline=True, data_registry=self.registry,
|
||||
)
|
||||
self.assertEqual(manifest["pack_files"], len(set(members) | {"manifest.json"}))
|
||||
|
||||
def test_the_integrity_check_takes_it_for_a_marker(self):
|
||||
import packverify
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr(".nomedia", b"")
|
||||
archive.writestr("empty.bin", b"")
|
||||
with zipfile.ZipFile(buffer) as archive:
|
||||
errors = packverify._structural_errors(archive, set(archive.namelist()))
|
||||
self.assertEqual(errors, ["zero-byte: empty.bin"])
|
||||
|
||||
def test_a_platform_without_android_gets_none(self):
|
||||
self.assertEqual(builder.pack_markers("demo", None, str(self.platforms)), ())
|
||||
|
||||
|
||||
class TheTableDescribesTheRelease(unittest.TestCase):
|
||||
"""The Download link gives the last release, not what main would build.
|
||||
|
||||
|
||||
@@ -1473,6 +1473,15 @@ elsewhere. A custom BIOS directory is still named directly:
|
||||
python install.py --platform retroarch --dest /storage/emulated/0/RetroArch/system
|
||||
```
|
||||
|
||||
An install on Android leaves an empty `.nomedia` in the BIOS folder. Android's
|
||||
media scanner hides a folder holding one, and every folder below it
|
||||
(MediaProvider, `FileUtils.java` `isDirectoryHidden`), so the samples,
|
||||
overlays and videos of the BIOS folder stay out of the gallery and music
|
||||
apps. RetroArch writes none. A run that finds one already there leaves it,
|
||||
and `--check` and `--dry-run` write nothing. The RetroArch pack, unzipped on
|
||||
Android as on other systems, carries the same empty `.nomedia` beside
|
||||
`README.txt`. A pack built for a target other than Android does not.
|
||||
|
||||
## On a handheld { #on-a-handheld-the-retrobios-port }
|
||||
|
||||
RetroBIOS also runs on the handheld itself, in the
|
||||
@@ -2010,6 +2019,12 @@ opened from Windows, an exFAT card), one overwrites the other. The installer
|
||||
checks the folder first, keeps one file per name there and lists the ones it
|
||||
left out.
|
||||
|
||||
FAT, exFAT and every Windows drive also refuse `< > : " | ? *` in a name
|
||||
(Linux `fs/fat/namei_vfat.c:201-207`, `fs/exfat/nls.c:396-403`). RetroDECK's
|
||||
list names three files with a colon, like `zxs:48.rom`. Before writing such a
|
||||
name, the installer creates one in the BIOS folder: on an ext4 card it goes
|
||||
in, on an exFAT card the installer leaves those files out and names them.
|
||||
|
||||
### Packs and their parts
|
||||
|
||||
`scripts/download.py --list` and `scripts/download.sh --list` print the packs of
|
||||
|
||||
@@ -169,6 +169,22 @@ Running `verify.py --emulator <core> --verbose` shows the emulator-level ground
|
||||
|
||||
It reaches only as far as the profiles do. Files no profile states a value for are platform-only, and nothing can be said about their content. The footer of the platform report gives that ratio as `Ground truth: N/M files have emulator validation`.
|
||||
|
||||
## Why do BIOS pictures and sounds show in a phone's gallery?
|
||||
|
||||
Android's media scanner indexes every image, sound and video in shared
|
||||
storage, and the RetroArch BIOS folder holds 3,842 of them (FBNeo samples,
|
||||
overlays, PPSSPP assets, sdlpal's videos). An empty file named `.nomedia` in
|
||||
the BIOS folder hides it and every folder below it. The installer writes one
|
||||
on Android and the RetroArch pack carries one: see [Android](installer.md#android).
|
||||
Copied by hand without either, the folder needs that empty file created by hand.
|
||||
|
||||
## Why were files left out on an exFAT card?
|
||||
|
||||
FAT, exFAT and Windows refuse `< > : " | ? *` in a name, and RetroDECK's list
|
||||
names three files with a colon, like `zxs:48.rom`. The installer leaves those
|
||||
out on such a card and names them. An ext4 card holds them. See
|
||||
[Upper and lower case](installer.md#upper-and-lower-case).
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Reference](index.md), every reference page in one list
|
||||
|
||||
@@ -59,7 +59,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_core_data_trees`, `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_release_packs`, `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_amberelec_targets`, `test_emuelec_targets`, `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_app`, `test_install_from`, `test_install_games`, `test_install_offline`, `test_install_selection`, `test_install_tui`, `test_pypi_package` |
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_filesystems`, `test_install_app`, `test_install_from`, `test_install_games`, `test_install_offline`, `test_install_selection`, `test_install_tui`, `test_pypi_package` |
|
||||
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
|
||||
| Running cores | parsers on fixed inputs, a libretro core built from C in the test when a compiler exists | `test_core_probe` |
|
||||
| Measurement records | synthetic record files, the committed `measurements/` against HEAD, the **Proof** line naming the BIOS a core ran, and that no page shows a Tested block, a region outcome or a time per frame | `test_measurements` |
|
||||
|
||||
Reference in new issue
Block a user