diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index debe35bc8..0ccfea3a6 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -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" diff --git a/install.ps1 b/install.ps1 index e8b9e6441..c7e2dba19 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 } diff --git a/install.py b/install.py index 974e1aa82..2661858f0 100644 --- a/install.py +++ b/install.py @@ -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") diff --git a/install.sh b/install.sh index 6cef9a276..bc8022a6f 100755 --- a/install.sh +++ b/install.sh @@ -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() { diff --git a/install/launchers/index.json b/install/launchers/index.json index e79aa82c2..60516119e 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -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 }, { diff --git a/install/retroarch.json b/install/retroarch.json index 1b052c818..171cab28a 100644 --- a/install/retroarch.json +++ b/install/retroarch.json @@ -2599,7 +2599,7 @@ ], "total_files": 8338, "total_size": 7080104487, - "pack_files": 14921, + "pack_files": 14922, "pack_size": 7222137851, "total_omitted": 37, "omitted_files": [ diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 25b9f97ca..bf780878d 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -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, diff --git a/scripts/packpaths.py b/scripts/packpaths.py index 3673628e5..c7b953432 100644 --- a/scripts/packpaths.py +++ b/scripts/packpaths.py @@ -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 + "/"): diff --git a/scripts/packverify.py b/scripts/packverify.py index 2f6d825d1..499a1bed8 100644 --- a/scripts/packverify.py +++ b/scripts/packverify.py @@ -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)) diff --git a/tests/test_install.py b/tests/test_install.py index bb1ce4719..c30ffc88b 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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", ) diff --git a/tests/test_install_filesystems.py b/tests/test_install_filesystems.py new file mode 100644 index 000000000..4eb66519b --- /dev/null +++ b/tests/test_install_filesystems.py @@ -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() diff --git a/tests/test_pack_counts.py b/tests/test_pack_counts.py index 55f34e029..05d04534d 100644 --- a/tests/test_pack_counts.py +++ b/tests/test_pack_counts.py @@ -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. diff --git a/wiki/installer.md b/wiki/installer.md index 3cf56dfd0..41aec8acd 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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 diff --git a/wiki/technical-faq.md b/wiki/technical-faq.md index 01159efcf..e01790ce4 100644 --- a/wiki/technical-faq.md +++ b/wiki/technical-faq.md @@ -169,6 +169,22 @@ Running `verify.py --emulator --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 diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index a0beb83a4..f6530b0d9 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -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` |