From ec407dfbddae09a857216e64fbad4f0e0b1ddd0b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:41:49 +0100 Subject: [PATCH 1/8] feat: hide an Android BIOS folder from the gallery An install on Android leaves an empty .nomedia in the BIOS folder. MediaProvider hides a folder holding one and every folder below it (FileUtils.java:1587-1629, ModernMediaScanner.java:845-846, a183e2b9), and RetroArch writes none: the samples, overlays and videos of system/ showed in the gallery and music apps. --check and --dry-run write nothing. --- install.ps1 | 2 +- install.py | 27 +++++++++++++ install.sh | 2 +- install/launchers/index.json | 16 ++++---- tests/test_install_filesystems.py | 64 +++++++++++++++++++++++++++++++ wiki/faq.md | 5 +++ wiki/installer.md | 7 ++++ 7 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 tests/test_install_filesystems.py diff --git a/install.ps1 b/install.ps1 index 0b44aa3b4..f20e65a2c 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 = "bba0f7cd7677fb7f4d767504412843cf3809c03b91808c7c7772f17758893d50" + $defaultInstallSha256 = "192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a7a272964..4e24fd57b 100644 --- a/install.py +++ b/install.py @@ -3132,6 +3132,29 @@ def fold_case_twins(files: list[dict]) -> tuple[list[dict], list[str]]: return kept, folded +# 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. @@ -16343,6 +16366,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 1e47b7f91..3eb6c581b 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="bba0f7cd7677fb7f4d767504412843cf3809c03b91808c7c7772f17758893d50" +DEFAULT_INSTALL_SHA256="192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 8db6e297a..1aaa624f4 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": 1125268, - "sha256": "bba0f7cd7677fb7f4d767504412843cf3809c03b91808c7c7772f17758893d50", + "size": 1126407, + "sha256": "192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1178895, - "sha256": "814d80f123e59f5192631842d53a4808bf373599ced857f4622612ffbc25322b" + "size": 1180034, + "sha256": "14c4e4b762ec84fe30d2cf0baed58d1022c3423e05118b43dbccfbaff5e4d84a" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1125268, - "sha256": "bba0f7cd7677fb7f4d767504412843cf3809c03b91808c7c7772f17758893d50", + "size": 1126407, + "sha256": "192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1125268, - "sha256": "bba0f7cd7677fb7f4d767504412843cf3809c03b91808c7c7772f17758893d50", + "size": 1126407, + "sha256": "192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b", "executable": false }, { diff --git a/tests/test_install_filesystems.py b/tests/test_install_filesystems.py new file mode 100644 index 000000000..010011134 --- /dev/null +++ b/tests/test_install_filesystems.py @@ -0,0 +1,64 @@ +"""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 subprocess +import sys +import unittest +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 / "tests")) + +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()) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/faq.md b/wiki/faq.md index f3429d353..4afe294ef 100644 --- a/wiki/faq.md +++ b/wiki/faq.md @@ -101,6 +101,11 @@ Fill it from a computer or point `--from-pack` at your ZIP: [Without a network]( `--from` copies BIOS files from an old card or backup: [Copy from an old card](installer.md#copy-from-an-old-card). +## BIOS pictures or sounds in my phone's gallery? + +An empty file named `.nomedia` in the BIOS folder hides them. The installer +adds it on Android: [Android](installer.md#android). + ## My setup is not in the list - Name your emulator's BIOS folder with `--dest`. diff --git a/wiki/installer.md b/wiki/installer.md index 894674a5d..c9bc26a51 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1461,6 +1461,13 @@ 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. + ## On a handheld { #on-a-handheld-the-retrobios-port } RetroBIOS also runs on the handheld itself, in the From c33114714e840509f08f638db80a6f0baa84e1de Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:47:43 +0100 Subject: [PATCH 2/8] feat: leave out names a FAT card refuses FAT, exFAT and Windows refuse < > : " | ? * in a name (fs/fat/namei_vfat.c:201-207, fs/exfat/nls.c:396-403). RetroDECK's component manifest names zxs:48.rom, zxs:if1-2.rom and snes:st018.data.rom: on an exFAT card each write failed with EINVAL on every run. The installer now probes the BIOS folder as keeps_case does, leaves those entries out and names them. --- install.ps1 | 2 +- install.py | 52 ++++++++++++++++++++++ install.sh | 2 +- install/launchers/index.json | 16 +++---- tests/test_install_filesystems.py | 71 +++++++++++++++++++++++++++++++ wiki/installer.md | 6 +++ wiki/troubleshooting.md | 2 + 7 files changed, 141 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index f20e65a2c..47488df21 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 = "192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b" + $defaultInstallSha256 = "1282e441fb6562b4413f60504976763b5fc337170334dc3836ef70914b67c4f4" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 4e24fd57b..4ffe2f285 100644 --- a/install.py +++ b/install.py @@ -3132,6 +3132,49 @@ 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 @@ -15616,6 +15659,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"): diff --git a/install.sh b/install.sh index 3eb6c581b..c626a4b20 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="192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b" +DEFAULT_INSTALL_SHA256="1282e441fb6562b4413f60504976763b5fc337170334dc3836ef70914b67c4f4" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 1aaa624f4..8f666970b 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": 1126407, - "sha256": "192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b", + "size": 1128787, + "sha256": "1282e441fb6562b4413f60504976763b5fc337170334dc3836ef70914b67c4f4", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1180034, - "sha256": "14c4e4b762ec84fe30d2cf0baed58d1022c3423e05118b43dbccfbaff5e4d84a" + "size": 1182414, + "sha256": "2c2d797483fa6c64031d55fd4b5dde327cb64b163c506eace2593e90f88b58e3" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1126407, - "sha256": "192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b", + "size": 1128787, + "sha256": "1282e441fb6562b4413f60504976763b5fc337170334dc3836ef70914b67c4f4", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1126407, - "sha256": "192ecdd781447b69967d5d335911fb34887757ee1bf9b6c000ad68a62b17de4b", + "size": 1128787, + "sha256": "1282e441fb6562b4413f60504976763b5fc337170334dc3836ef70914b67c4f4", "executable": false }, { diff --git a/tests/test_install_filesystems.py b/tests/test_install_filesystems.py index 010011134..4eb66519b 100644 --- a/tests/test_install_filesystems.py +++ b/tests/test_install_filesystems.py @@ -4,15 +4,25 @@ 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 @@ -60,5 +70,66 @@ class AndroidMediaScanner(unittest.TestCase): 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/wiki/installer.md b/wiki/installer.md index c9bc26a51..886ec39c1 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -2005,6 +2005,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/troubleshooting.md b/wiki/troubleshooting.md index 4452074a6..8056d77b1 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -50,6 +50,8 @@ Without `--check`, it adds missing files and replaces wrong ones. folder. Emulators add their own, like `dc/` for Flycast. - **A ZIP in parts, like `Pack.part1of2.zip`?** Unzip all parts into one folder. +- **"left out" on an exFAT card?** The card refuses a `:` in a name. An + ext4 card holds these files. From 39bc5f020bc299c214a0036569605c0541797c76 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:52:52 +0100 Subject: [PATCH 3/8] feat: put a .nomedia in packs unzipped on Android The RetroArch pack lands in RetroArch Android's system folder, where MediaProvider indexed 3 842 images, sounds and videos (552 MB) into the gallery and music apps: a folder holding .nomedia is hidden with every folder below it (FileUtils.java:1587-1629, ModernMediaScanner.java:845-846, a183e2b9). A pack whose platform files an Android target, unfiltered or built for one, carries an empty .nomedia. pack_files counts it and the integrity check takes it for a marker. --- install/retroarch.json | 2 +- scripts/generate_pack.py | 19 +++++++++++++- scripts/packpaths.py | 17 ++++++++++++ scripts/packverify.py | 8 +++--- tests/test_pack_counts.py | 54 +++++++++++++++++++++++++++++++++++++++ wiki/installer.md | 4 ++- 6 files changed, 97 insertions(+), 7 deletions(-) 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_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 886ec39c1..05c4a860a 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1466,7 +1466,9 @@ 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. +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 } From 6e825e813720248aa35ea8026960f7e4e2d2e407 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:51:12 +0100 Subject: [PATCH 4/8] fix: count the pack's .nomedia in its list test --- tests/test_install.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_install.py b/tests/test_install.py index 7fac30ff3..aa4be1fb1 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", ) From 9c95b66979ba5f0fc15e1455f2fa428f64598b60 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:51:12 +0100 Subject: [PATCH 5/8] docs: answer gallery media and exFAT names The start pages are at their word ceilings: the two answers move to the technical FAQ, and the testing guide names test_install_filesystems. --- wiki/faq.md | 5 ----- wiki/technical-faq.md | 16 ++++++++++++++++ wiki/testing-guide.md | 2 +- wiki/troubleshooting.md | 2 -- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/wiki/faq.md b/wiki/faq.md index 4afe294ef..f3429d353 100644 --- a/wiki/faq.md +++ b/wiki/faq.md @@ -101,11 +101,6 @@ Fill it from a computer or point `--from-pack` at your ZIP: [Without a network]( `--from` copies BIOS files from an old card or backup: [Copy from an old card](installer.md#copy-from-an-old-card). -## BIOS pictures or sounds in my phone's gallery? - -An empty file named `.nomedia` in the BIOS folder hides them. The installer -adds it on Android: [Android](installer.md#android). - ## My setup is not in the list - Name your emulator's BIOS folder with `--dest`. diff --git a/wiki/technical-faq.md b/wiki/technical-faq.md index ee8723759..c6b488d1a 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 my 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, create the empty file yourself. + +## Why were files left out on my 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 35978ff5d..f62444419 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` | +| 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` | | 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` | diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index 8056d77b1..4452074a6 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -50,8 +50,6 @@ Without `--check`, it adds missing files and replaces wrong ones. folder. Emulators add their own, like `dc/` for Flycast. - **A ZIP in parts, like `Pack.part1of2.zip`?** Unzip all parts into one folder. -- **"left out" on an exFAT card?** The card refuses a `:` in a name. An - ext4 card holds these files. From 3349ba943805feea6dd53ba098c0dcfb1a68d30e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:42:18 +0100 Subject: [PATCH 6/8] fix: send site_markdown through run_cli --- scripts/site_markdown.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/site_markdown.py b/scripts/site_markdown.py index c72b972e3..9183ec570 100644 --- a/scripts/site_markdown.py +++ b/scripts/site_markdown.py @@ -342,4 +342,6 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + from common import run_cli + + run_cli(main) From f38c8ec34611eac3cebd7b70eb1d747b0130f75b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:25:21 +0100 Subject: [PATCH 7/8] fix: rebuild the site when sitesteps.py changes --- .github/workflows/deploy-site.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 0f4721b23..92e56d01b 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -61,6 +61,7 @@ on: - "scripts/retired_files.py" - "scripts/sitesources.py" - "scripts/sitestarlight.py" + - "scripts/sitesteps.py" - "scripts/sitewrite.py" - "scripts/slot.py" - "scripts/slots.py" From 5c6f63dc573945bd58c8c1bbe0468d319b43cee0 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:28:55 +0100 Subject: [PATCH 8/8] docs: word the two answers without pronouns --- wiki/technical-faq.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wiki/technical-faq.md b/wiki/technical-faq.md index 9f6af7a8c..e01790ce4 100644 --- a/wiki/technical-faq.md +++ b/wiki/technical-faq.md @@ -169,16 +169,16 @@ 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 my phone's gallery? +## 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, create the empty file yourself. +Copied by hand without either, the folder needs that empty file created by hand. -## Why were files left out on my exFAT card? +## 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