From 9bb3dfbd1bd7410014c562d3a569bc8bcb7c2997 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:51:33 +0200 Subject: [PATCH 01/15] fix: keep release checks importable on python 3.9 check_release_assets.py built a tuple[..., int | None] alias at import, a TypeError on the 3.9 floor tools.md states, and check_release.py imports it. A test reads every script for unions and match statements evaluated at runtime. --- scripts/check_release_assets.py | 4 ++- tests/test_doc_pages.py | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/scripts/check_release_assets.py b/scripts/check_release_assets.py index 84ef4463..070412be 100644 --- a/scripts/check_release_assets.py +++ b/scripts/check_release_assets.py @@ -22,6 +22,7 @@ import re import subprocess import sys from collections.abc import Iterable +from typing import Optional, Tuple sys.path.insert(0, os.path.dirname(__file__)) from common import composition_tier, load_database @@ -32,7 +33,8 @@ from largefiles import ( registered_paths, ) -Finding = tuple[str, str, int, int | None] +# Evaluated at import, where `int | None` needs Python 3.10. +Finding = Tuple[str, str, int, Optional[int]] def expected_assets(db: dict, gitignore_text: str) -> dict[str, int]: diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 153db369..615d2167 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -416,5 +416,55 @@ class PythonFloorOnRetroPie(unittest.TestCase): self.assertIn("3.7.3", changing) +class ToolsRunOnTheStatedFloor(unittest.TestCase): + """tools.md and testing-guide.md state Python 3.9 for every script, and + check_release_assets.py built `int | None` at import: a TypeError on 3.9 + before any argument was read, in check_release.py too. CI runs 3.12 only, + so this test reads the source for what 3.9 cannot evaluate.""" + + TYPES = {"int", "str", "float", "bytes", "bool", "dict", "list", "tuple", "set", "type"} + + def _type_like(self, node: ast.AST) -> bool: + if isinstance(node, ast.Constant) and node.value is None: + return True + if isinstance(node, ast.Subscript): + node = node.value + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return self._type_like(node.left) or self._type_like(node.right) + return isinstance(node, ast.Name) and node.id in self.TYPES + + def _runtime_unions(self, tree: ast.AST) -> list[int]: + annotations = set() + for node in ast.walk(tree): + for field in ("annotation", "returns"): + value = getattr(node, field, None) + if value is not None: + annotations.update(id(n) for n in ast.walk(value)) + return [ + node.lineno for node in ast.walk(tree) + if id(node) not in annotations + and (isinstance(node, ast.Match) + or isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr) + and (self._type_like(node.left) or self._type_like(node.right))) + ] + + def test_pages_state_the_floor(self): + self.assertIn("Python 3.9 or newer", _page("tools.md")) + self.assertIn("Python 3.9 or newer", _page("testing-guide.md")) + + def test_no_script_evaluates_310_syntax(self): + offenders = [ + f"{path.relative_to(ROOT)}:{line}" + for path in sorted((ROOT / "scripts").rglob("*.py")) + for line in self._runtime_unions(ast.parse(path.read_text(encoding="utf-8"))) + ] + self.assertEqual(offenders, [], "\n".join(offenders)) + + def test_the_check_catches_the_alias(self): + self.assertTrue(self._runtime_unions(ast.parse("F = tuple[str, int | None]"))) + self.assertFalse(self._runtime_unions(ast.parse("def f(x: int | None) -> str | None: ..."))) + self.assertFalse(self._runtime_unions(ast.parse("mask = flags | other"))) + + if __name__ == "__main__": unittest.main() From f2628c117091ece1aaf357bfae23c87016db37ec Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:51:51 +0200 Subject: [PATCH 02/15] docs: drop hand-kept --derive figures from tools --- tests/test_doc_pages.py | 12 ++++++++++++ wiki/tools.md | 5 ++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 615d2167..f7cf0260 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -466,5 +466,17 @@ class ToolsRunOnTheStatedFloor(unittest.TestCase): self.assertFalse(self._runtime_unions(ast.parse("mask = flags | other"))) +class DeriveFiguresLiveWithTheTool(unittest.TestCase): + """tools.md gave --derive about 90 s and 67 entries while its --help said + about 35 s: two hand-kept figures for one run, measured at 33 s.""" + + def test_the_page_carries_no_duration_or_count(self): + paragraph = next( + p for p in _page("tools.md").split("\n\n") if "`--derive` reads" in p + ) + self.assertNotRegex(paragraph, r"\d+ ?s\b|\d+ entries") + self.assertIn("derivable.json", paragraph) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/tools.md b/wiki/tools.md index 5a42d3ac..87c8f990 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -864,13 +864,12 @@ python scripts/provenance_report.py --json # full report ``` A catalog lists chips as they come off the board, the collection often holds -the image an emulator reads. `--derive` reads the collection (about 90 s) and +the image an emulator reads. `--derive` reads every file of the collection and marks a missing entry DERIVABLE when a held file produces its sha1 by halving, quartering, splitting into even and odd bytes or 16-bit words (byte-swapped or not), swapping bytes, mirroring, or joining two files of one directory. The Amiga Kickstart chip pairs and TOSEC's split C64 and VES ROMs are found -this way: 67 entries on the current snapshots, recorded in `derivable.json` -by pipeline step 1b. +this way. Pipeline step 1b records what it finds in `derivable.json`. | Catalog | Acquisition | Notes | |---------|------------|-------| From a6d37e40ae6744eab3904bd8c19f520af7fabc92 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:52:57 +0200 Subject: [PATCH 03/15] docs: show the handy output verify prints --- scripts/verify.py | 2 +- tests/test_doc_pages.py | 19 +++++++++++++++++++ wiki/advanced-usage.md | 13 ++++++++----- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/scripts/verify.py b/scripts/verify.py index 09947723..c3981c3f 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1465,7 +1465,7 @@ def _format_ground_truth_aggregate(ground_truth: list[dict]) -> str: def _format_ground_truth_verbose(ground_truth: list[dict]) -> list[str]: """Format ground truth as one line per core with expected values and source ref. - Example: handy validates size=512,crc32=0d973c9d [rom.h:48-49] + Example: handy validates crc32=0d973c9d,size=512 [lynx/rom.h:48-49] """ lines = [] for gt in ground_truth: diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index f7cf0260..30903f5c 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -478,5 +478,24 @@ class DeriveFiguresLiveWithTheTool(unittest.TestCase): self.assertIn("derivable.json", paragraph) +class EmulatorVerboseSample(unittest.TestCase): + """advanced-usage.md showed `handy validates size=512 crc32=0x0d973c9d + [src/handy/system.h:45]`, a format verify.py does not print and a + reference handy.yml does not cite.""" + + def test_every_sample_line_is_printed(self): + import subprocess + + section = _section(_page("advanced-usage.md"), "Per-emulator checks") + sample = [body for lang, body in re.findall(r"(?ms)^```(\w*)\n(.*?)^```", section) if not lang][-1] + printed = subprocess.run( + [sys.executable, "scripts/verify.py", "--emulator", "handy", "--verbose"], + cwd=ROOT, capture_output=True, text=True, check=True, + ).stdout.splitlines() + for line in sample.splitlines(): + with self.subTest(line=line): + self.assertIn(line, printed) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/advanced-usage.md b/wiki/advanced-usage.md index 0379e491..97a88376 100644 --- a/wiki/advanced-usage.md +++ b/wiki/advanced-usage.md @@ -377,13 +377,16 @@ python scripts/verify.py --emulator handy python scripts/verify.py --emulator handy --verbose ``` -Default output shows aggregate results per file: the core name and which checks apply. -With `--verbose`, each file expands to one line per core with the exact validation -parameters and source code reference: +The first line counts the files the profile declares and names the checks they +carry. A file that is missing or fails a check gets a line of its own, followed by +the cores that check it. `--verbose` also lists the files that pass, each followed +by one line per core with the values it expects and the source reference the +profile cites: ``` -lynxboot.img - handy validates size=512 crc32=0x0d973c9d [src/handy/system.h:45] +handy: 2/2 OK [crc32+existence+size] + OK (optional): lynxboot.img + handy validates crc32=0d973c9d,size=512 [lynx/rom.h:48-49 (ROM_SIZE=0x200, ROM_CRC32=0x0D973C9D), lynx/rom.cpp:76-129 (CRom constructor), libretro/libretro.cpp:1231-1258 (bios path + CSystem init)] ``` ### Per-system checks From d8e9ad23d33f53bf8fcb75ab87c88a27e2d77f6c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:54:57 +0200 Subject: [PATCH 04/15] docs: describe short md5 handling as the code does No platform YAML holds an md5 shorter than 32 characters: the Batocera scraper completes one when a single collection md5 starts with it. The pages said some entries carry 29 characters and that scrapers store them as-is. --- scripts/packverify.py | 4 ++-- tests/test_doc_pages.py | 26 ++++++++++++++++++++++++++ tests/test_e2e.py | 4 ++-- tests/test_resolution_evidence.py | 2 +- tests/test_shipped_content.py | 2 +- wiki/adding-a-scraper.md | 6 ++++-- wiki/architecture.md | 2 +- wiki/verification-modes.md | 13 +++++++------ 8 files changed, 44 insertions(+), 15 deletions(-) diff --git a/scripts/packverify.py b/scripts/packverify.py index 94c13046..c3628daf 100644 --- a/scripts/packverify.py +++ b/scripts/packverify.py @@ -314,8 +314,8 @@ def _hash_matches(declared: str, actual: str) -> bool: """Compare a declared hash value against an actual hex digest. Handles comma-separated multi-hash lists and uppercase (Recalbox) - and truncated MD5s (Batocera 29-char): a declared value shorter - than the digest matches by prefix. + and MD5s shorter than 32 characters: a declared value shorter than + the digest matches by prefix. """ actual = actual.lower() for cand in declared.split(","): diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 30903f5c..99669ff0 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -497,5 +497,31 @@ class EmulatorVerboseSample(unittest.TestCase): self.assertIn(line, printed) +class ShortMd5Handling(unittest.TestCase): + """Three pages said some Batocera entries carry 29-character MD5s and show + as untested. No platform YAML holds one, since the Batocera scraper + completes them, and adding-a-scraper.md said to store them as-is.""" + + def test_no_page_claims_short_md5_entries(self): + offenders = [ + page.name for page in PUBLISHED + if re.search(r"(?i)29[- ]char|truncated md5 hashes in some entries|store the hash as-is", + page.read_text(encoding="utf-8")) + ] + self.assertEqual(offenders, []) + + def test_the_page_names_what_the_code_does(self): + import common + from scraper import batocera_scraper + + scraper = inspect.getsource(batocera_scraper) + self.assertIn("md5 = _resolve_truncated_md5(", scraper) + resolver = inspect.getsource(common.resolve_local_file) + self.assertIn("if len(md5_candidate) < 32:", resolver) + self.assertIn("_md5_name_ok(path)", resolver) + section = _section(_page("verification-modes.md"), "MD5 shorter than 32 characters (Batocera)") + self.assertIn("_resolve_truncated_md5", section) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index f0d158bb..900dcabd 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -265,7 +265,7 @@ class TestE2E(unittest.TestCase): ).hexdigest() # sorted: a.rom, b.rom multi_wrong = "0000000000000000000000000000000" multi_right = f["multi.zip"]["inner_md5s"]["rom.bin"] - truncated_md5 = f["truncated.bin"]["md5"][:29] # Batocera 29-char + truncated_md5 = f["truncated.bin"]["md5"][:29] # shorter than 32 characters config = { "platform": "TestMD5", @@ -347,7 +347,7 @@ class TestE2E(unittest.TestCase): "zipped_file": "rom.bin", "required": True, }, - # Truncated MD5 (Batocera 29 chars) + # MD5 shorter than 32 characters { "name": "truncated.bin", "destination": "truncated.bin", diff --git a/tests/test_resolution_evidence.py b/tests/test_resolution_evidence.py index 4677c0bf..588a18be 100644 --- a/tests/test_resolution_evidence.py +++ b/tests/test_resolution_evidence.py @@ -86,7 +86,7 @@ class DeclaredHashVerdict(unittest.TestCase): ) def test_a_truncated_md5_still_decides(self): - """Batocera publishes 29-character prefixes, which are still evidence.""" + """An MD5 shorter than 32 characters is still evidence, by prefix.""" self.assertEqual( self.verdict(self.right, md5_list=[self.d["md5"][:29]]), "data_dir_hash_exact", diff --git a/tests/test_shipped_content.py b/tests/test_shipped_content.py index a1229ca9..75597b30 100644 --- a/tests/test_shipped_content.py +++ b/tests/test_shipped_content.py @@ -118,7 +118,7 @@ class ShippedFilesSatisfyTheirDeclaration(unittest.TestCase): digests["md5"].append(common.md5_composite(str(blob_path))) except (zipfile.BadZipFile, OSError): pass - # Batocera publishes 29-character prefixes, so compare by prefix. + # A declared MD5 can be shorter than 32 characters: compare by prefix. satisfied = any( all( any( diff --git a/wiki/adding-a-scraper.md b/wiki/adding-a-scraper.md index 584b1b8b..3010ef3e 100644 --- a/wiki/adding-a-scraper.md +++ b/wiki/adding-a-scraper.md @@ -206,8 +206,10 @@ System ID consistency matters for cross-platform operations. The same console mu - Handle missing hashes gracefully (set to `None`, not empty string). - Some platforms provide multiple hash types per entry. Populate whichever fields are available. -- Batocera uses 29-character truncated MD5 hashes in some entries. The resolution - layer handles prefix matching, but the scraper should store the hash as-is. +- An MD5 shorter than 32 characters is completed when exactly one MD5 in + `database.json` starts with it, as the Batocera scraper does + (`_resolve_truncated_md5`), and kept as written otherwise. The resolution layer + matches what remains by prefix. Scraped data reflects what the upstream declares, which may not match reality. The scraper's job is faithful transcription of upstream data, not correction. Corrections happen in the emulator profiles (source-verified) and in `_shared.yml` (curated). If a scraper meets a hash or filename that differs from the emulator source, log a warning and still include the upstream value. The divergence will surface during truth diffing. diff --git a/wiki/architecture.md b/wiki/architecture.md index 08ee1071..ffc09f34 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -372,7 +372,7 @@ platform's own verification mode. | Case | Handling | |------|---------| -| Batocera truncated MD5 (29 chars) | prefix match in resolution | +| MD5 shorter than 32 characters | completed by the Batocera scraper when one MD5 in the collection starts with it, matched by prefix in resolution | | `zippedFile` entries | MD5 is of the ROM inside the ZIP, not the ZIP itself | | Regional variants (same filename) | `by_path_suffix` index disambiguates | | MAME BIOS ZIPs | `contents` field documents inner structure | diff --git a/wiki/verification-modes.md b/wiki/verification-modes.md index 5658005e..5b43d469 100644 --- a/wiki/verification-modes.md +++ b/wiki/verification-modes.md @@ -47,13 +47,14 @@ hashed with MD5, and compared case-insensitively against the expected value. If the `resolve_local_file` step already confirmed the MD5 match (status `md5_exact`), `verify.py` skips re-hashing and returns OK directly. -### Truncated MD5 (Batocera) +### MD5 shorter than 32 characters (Batocera) -Some entries in Batocera's system data carry 29-character MD5 strings instead of -the usual 32. Batocera's script compares the full string (`batocera-systems:1088`), so -those entries report the file as untested. `verify.py` compares the prefix instead, to -find the file the entry designates: if the expected hash is shorter than 32 characters, -the actual hash is compared against only its first N characters. +Batocera's script compares the full string (`batocera-systems:1088`), so an MD5 +shorter than 32 characters matches no file there. The Batocera scraper completes such +a value when exactly one MD5 in `database.json` starts with it +(`_resolve_truncated_md5` in `scripts/scraper/batocera_scraper.py`) and keeps it as +written otherwise. `resolve_local_file` matches a value still shorter than 32 +characters by prefix, and only for a file whose name the entry also names. ### md5_composite (Recalbox ZIP verification) From f997bdc81ad202119d779a8db569a64ea264eec5 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:57:06 +0200 Subject: [PATCH 05/15] fix: check a recalbox zip by its composite only Bios::Scan takes Zip::Md5Composite for every .zip (Bios.cpp:64-68). The resolver already honoured zip_digest: composite, but digest_verdict accepted the container's own md5 first, so the pack check passed bytes Recalbox marks red. The page described that order. --- scripts/validation.py | 7 ++++++- tests/test_audit_regressions.py | 24 ++++++++++++++++++++++++ tests/test_doc_pages.py | 12 ++++++++++++ wiki/verification-modes.md | 13 ++++++++++--- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/scripts/validation.py b/scripts/validation.py index d4529bf5..481386f2 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -793,7 +793,12 @@ def digest_verdict( actual = hashes()[algorithm].lower() if algorithm != "md5": return actual in declared - if any(actual == d or (len(d) < 32 and actual.startswith(d)) for d in declared): + # Recalbox hashes a .zip by its members only (Bios.cpp:64-68), so the + # container's own md5 answers nothing there. + composite_only = file_entry.get("zip_digest") == "composite" + if not composite_only and any( + actual == d or (len(d) < 32 and actual.startswith(d)) for d in declared + ): return True member_digest = composite() return bool(member_digest) and member_digest.lower() in declared diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index d12efd68..34011ff7 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -1206,6 +1206,30 @@ class OneBaselineVerdict(unittest.TestCase): ("frontend_digest_exact", None), ) + def test_a_composite_only_archive_ignores_its_raw_md5(self): + """Recalbox hashes a .zip by its members (Bios.cpp:64-68), and the + pack check accepted the container's own md5 before the composite: + neocd.bin shipped as neocdz.zip passed while Recalbox marks it red.""" + import hashlib + + from scripts.common import md5_composite + from scripts.validation import digest_verdict + + with tempfile.TemporaryDirectory(dir=TMP_ROOT) as directory: + archive = self._archive(Path(directory), "rom.bin") + raw = hashlib.md5(Path(archive).read_bytes()).hexdigest() + members = md5_composite(archive) + for declared, accepted in ((raw, False), (members, True)): + entry = {"name": "dragon32.zip", "md5": declared, "zip_digest": "composite"} + with self.subTest(declared=declared): + self.assertIs( + digest_verdict(entry, 0, lambda: {"md5": raw}, "md5", "", + lambda: members), + accepted, + ) + plain = {"name": "dragon32.zip", "md5": raw} + self.assertTrue(digest_verdict(plain, 0, lambda: {"md5": raw}, "md5")) + def test_a_release_asset_keeps_its_answer(self): from scripts.slots import apply_override diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 99669ff0..e2414d26 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -322,6 +322,18 @@ class Md5CompositeDefinition(unittest.TestCase): self.assertIn("contents of every member", section) self.assertNotIn("inner file MD5s", section) + def test_composite_only_is_read_by_both_checks(self): + """The page said the direct MD5 is tried before the composite, which + Recalbox never does for a .zip, and the pack check did the same.""" + import common + import validation + + section = _section(_page("verification-modes.md"), "md5_composite (Recalbox ZIP verification)") + self.assertIn("zip_digest: composite", section) + self.assertNotIn("is tried before reporting a mismatch", section) + for function in (common.resolve_local_file, validation.digest_verdict): + self.assertIn('get("zip_digest") == "composite"', inspect.getsource(function)) + class ValidationIndexScope(unittest.TestCase): """The page said the index reads every profile. verify and the builder diff --git a/wiki/verification-modes.md b/wiki/verification-modes.md index 5b43d469..3990eda5 100644 --- a/wiki/verification-modes.md +++ b/wiki/verification-modes.md @@ -60,9 +60,16 @@ characters by prefix, and only for a file whose name the entry also names. Recalbox hashes a ZIP with `Zip::Md5Composite` (`es-core/src/utils/Zip.cpp:71-96` in recalbox-emulationstation at `f4cd0ae4`): one MD5 over the contents of every member, -read in filename order. The members' own MD5s are not involved. `verify.py` computes -the same value with `md5_composite()` (`scripts/hashing.py`). When a ZIP file's direct -MD5 does not match, the composite is tried before reporting a mismatch. +read in filename order. The members' own MD5s are not involved, and neither is the +MD5 of the ZIP file itself: `Bios::Scan` takes the composite for every `.zip` +(`es-app/src/bios/Bios.cpp:64-68`). `verify.py` computes the same value with +`md5_composite()` (`scripts/hashing.py`). + +The registry gives Recalbox `zip_digest: composite`. For a platform so marked, a +`.zip` entry is checked against its composite alone, by the resolver +(`resolve_local_file`) and by the pack check (`digest_verdict` in +`scripts/validation.py`). On other platforms the ZIP file's direct MD5 is tried first +and the composite after it. ### Multi-hash (Recalbox) From ffd3a6683906416d679f8d2ad7de4154d86a5b17 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:58:20 +0200 Subject: [PATCH 06/15] docs: keep the derivable count in its file alone --- tests/test_audit_regressions.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 34011ff7..bdaa849a 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -4500,11 +4500,9 @@ class ToolReferenceCountsMatchTheirArtifacts(unittest.TestCase): stated = [int(group.replace(",", "")) for group in match.groups()] self.assertEqual(stated, [len(versions), listed, len(store["entries"])]) - def test_derivable_count(self): - derivable = json.loads((ROOT / "derivable.json").read_text(encoding="utf-8")) - match = re.search(r"(\d+) entries on the current snapshots", self.text) - self.assertIsNotNone(match) - self.assertEqual(int(match.group(1)), len(derivable)) + def test_no_derivable_count(self): + """The count now lives in derivable.json alone, which step 1b rewrites.""" + self.assertNotRegex(self.text, r"\d+ entries on the current snapshots") From 6bca75b23ec7bf5df245317762e84e5343312203 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:59:22 +0200 Subject: [PATCH 07/15] docs: name the registry keys the tools read --- tests/test_doc_pages.py | 24 ++++++++++++++++++++++++ wiki/adding-a-platform.md | 29 +++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index e2414d26..57a285f1 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -535,5 +535,29 @@ class ShortMd5Handling(unittest.TestCase): self.assertIn("_resolve_truncated_md5", section) +class RegistryKeysDocumented(unittest.TestCase): + """zip_digest, firmware_folder_flat and extract_to were read from the + registry by the resolver, the pack and the README, and the page that + adds a platform named none of them while saying runtime behaviour lives + in the platform YAML only.""" + + def test_every_registry_key_is_on_the_page(self): + import yaml + + registry = yaml.safe_load((ROOT / "platforms" / "_registry.yml").read_text(encoding="utf-8")) + keys = {key for entry in registry["platforms"].values() for key in entry} + page = _page("adding-a-platform.md") + self.assertEqual(sorted(k for k in keys if not re.search(rf"\b{k}\b", page)), []) + + def test_the_page_says_the_registry_reaches_the_config(self): + import common + + source = inspect.getsource(common.load_platform_config) + self.assertIn("config[key] = val", source) + section = _section(_page("adding-a-platform.md"), "Step 2: Register the platform") + self.assertIn("copies every registry key", section) + self.assertNotIn("belongs to the platform\nYAML", section) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index 2cf0cd9a..166bac8f 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -131,10 +131,13 @@ This fetches from upstream and prints a summary without writing anything. Add an entry to `platforms/_registry.yml` under the `platforms:` key. -The registry describes *where the data comes from*. How the platform behaves -at runtime (`verification_mode`, `base_destination`) belongs to the platform -YAML, because that is what `verify.py` and `generate_pack.py` load. Keeping the -two apart means a re-scrape can rewrite the YAML without touching the registry. +The registry describes *where the data comes from*, and the platform YAML +holds what the upstream file declares, `verification_mode` and +`base_destination` included. A re-scrape rewrites the YAML without touching the +registry. `load_platform_config` copies every registry key the YAML does not +carry into the config that `verify.py` and `generate_pack.py` read, so a runtime +rule that no upstream file states (`zip_digest`, `firmware_folder_flat`) is +written in the registry, where the next scrape leaves it in place. ### Required fields @@ -190,6 +193,11 @@ its libretro layout whatever the platform calls it. logo: https://... # SVG or PNG for UI/docs inherits_from: retroarch # inherit systems/cores from another platform case_insensitive_fs: true # if the platform runs on case-insensitive filesystems + extract_to: /userdata/bios/ # folder the pack extracts into, shown in the README, + # the site and the release notes + zip_digest: composite # the frontend hashes a .zip by its members only + firmware_folder_flat: true # the frontend lists each system folder without + # descending into it source_wiki: https://... # human-readable upstream reference target_scraper: myplatform_targets # hardware target scraper name, null if static target_source: https://... # target data source URL @@ -205,6 +213,19 @@ its libretro layout whatever the platform calls it. parse_key: bios_directory ``` +`zip_digest: composite` is set where the frontend's own check takes +`Zip::Md5Composite` for every `.zip` and never the container's MD5 (Recalbox, +`Bios.cpp:64-68`). Each `.zip` entry is then checked against its composite alone, +by the resolver and by the pack check, as +[verification modes](verification-modes.md#md5_composite-recalbox-zip-verification) +describes. + +`firmware_folder_flat: true` is set where the frontend reads a system's firmware +folder without descending into it (RomM 5.4.0, +`backend/handler/filesystem/base_handler.py:1002`, `recursive=False`). On such a platform, a core file whose destination has a +subfolder is left out of the pack (`nested` in `scripts/packextras.py`), since no +core would receive it. + ### Platform YAML header The runtime behavior lives at the top of `platforms/myplatform.yml`: From 7e926ac90940b22edd5937c1de14e11b889ecc96 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:59:53 +0200 Subject: [PATCH 08/15] docs: say which entries skip the name steps --- tests/test_doc_pages.py | 22 ++++++++++++++++++++++ wiki/verification-modes.md | 14 ++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 57a285f1..27487bbf 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -559,5 +559,27 @@ class RegistryKeysDocumented(unittest.TestCase): self.assertNotIn("belongs to the platform\nYAML", section) +class ResolutionSkipsNamesForUnheldFiles(unittest.TestCase): + """The resolution table promised name_exact to any entry without a hash, + while an unsourceable entry, or one whose indexed path is absent from + disk, skips every step that answers by name and returns not_found.""" + + def test_the_page_names_both_cases_the_code_guards(self): + import common + + source = inspect.getsource(common.resolve_local_file) + self.assertIn('unsourceable = bool(file_entry.get("unsourceable")) or own_file_absent', source) + for guard in ( + "if not has_strong_hash and not unsourceable:", + "elif not unsourceable:", + "if _depth < 3 and not has_strong_hash and not unsourceable:", + "if data_dir_registry and (has_strong_hash or not unsourceable):", + ): + self.assertIn(guard, source) + section = _section(_page("verification-modes.md"), "File resolution chain") + self.assertIn("`unsourceable:`", section) + self.assertIn("restore_large_files.py", section) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/verification-modes.md b/wiki/verification-modes.md index 3990eda5..efaee1fe 100644 --- a/wiki/verification-modes.md +++ b/wiki/verification-modes.md @@ -274,6 +274,20 @@ evidence-compatible match: If no step matches, the result is `(None, "not_found")`. +Two kinds of entry skip every answer given by a name alone: steps 6 and 9, the +`hash_mismatch` of step 7, and the name lookup of step 10 when no hash is declared. + +- An entry whose profile carries `unsourceable:`. The collection cannot hold that + file, so a file answering to its name belongs to something else: `kanji.rom` + names an FPseNG font, an openMSX font and a 3DO ROM. +- An entry whose full destination path is in the `by_path_suffix` index while no + indexed copy is on disk. That file is a release asset the checkout has not + restored (`scripts/restore_large_files.py`), and another file of the same name + is not it. + +The content steps still apply to both, so collecting the real bytes, or restoring +the asset, makes the entry resolve. + The `hash_mismatch` status means a file with the right name or path exists but its hash does not match. This still resolves to a local path (the file is present), but verification will report it as UNTESTED with a reason string showing the expected vs actual hash prefix. From e257504e2061b00aa2390e6f682b8f17f8ab0085 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:00:21 +0200 Subject: [PATCH 09/15] docs: point the CI table to each workflow's paths --- tests/test_doc_pages.py | 19 +++++++++++++++++++ wiki/architecture.md | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 27487bbf..86e44d4d 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -581,5 +581,24 @@ class ResolutionSkipsNamesForUnheldFiles(unittest.TestCase): self.assertIn("restore_large_files.py", section) +class CiTriggersPointToTheirLists(unittest.TestCase): + """The CI table listed six directories for validate.yml, which also runs + on the installer, wiki/ and every other site input: a contributor + touching only wiki/ read that no check would run.""" + + def test_the_table_does_not_copy_a_path_list(self): + import yaml + + section = _section(_page("architecture.md"), "CI workflows") + self.assertNotRegex(section, r"on bios/, platforms/|\(platforms, emulators, wiki, scripts\)") + self.assertIn("`paths:`", section) + self.assertIn("test_workflow_paths.py", section) + workflow = yaml.safe_load((ROOT / ".github/workflows/validate.yml").read_text(encoding="utf-8")) + paths = workflow.get("on", workflow.get(True))["pull_request"]["paths"] + for named in ("wiki/**", "install.sh"): + self.assertIn(named, paths) + self.assertIn(f"`{named.rstrip('*')}`", section) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/architecture.md b/wiki/architecture.md index ffc09f34..dc301edf 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -450,8 +450,14 @@ pattern and how to add a test. | Workflow | File | Trigger | Role | |----------|------|---------|------| -| Deploy Site | `deploy-site.yml` | push to main (platforms, emulators, wiki, scripts) + manual | validate contracts, generate site, build with MkDocs, validate rendered HTML, deploy to Pages | -| Validation | `validate.yml` | pull request and push to main on bios/, platforms/, emulators/, schemas/, scripts/, tests/ | schema check and full test suite on both, BIOS hash validation and auto-label on pull requests | +| Deploy Site | `deploy-site.yml` | push to main on any input of the site build, and manual | validate contracts, generate site, build with MkDocs, validate rendered HTML, deploy to Pages | +| Validation | `validate.yml` | pull request and push to main on the collection, profiles, schemas, scripts, tests, the installer and every input of the site build | schema check and full test suite on both, BIOS hash validation and auto-label on pull requests | + +Each workflow's `paths:` list is the exact set of files that starts it. +`tests/test_workflow_paths.py` holds the Deploy Site list to the scripts the build +imports, and the Validation list to every input of the site build, so a change that +would fail the deploy fails the pull request first. A change to `wiki/`, to +`install.sh` or to any other path in that list runs the full suite. Releases are not built in CI: the packs are generated and checked on the maintainer's machine and uploaded with `gh`, see the From 0b772fe255246951a62550ef69d7dbaed7dba2bc Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:01:07 +0200 Subject: [PATCH 10/15] docs: compare scraper output in the repo tmp/ --- tests/test_doc_pages.py | 27 +++++++++++++++++++++++++++ wiki/adding-a-scraper.md | 9 ++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 86e44d4d..cf689167 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -600,5 +600,32 @@ class CiTriggersPointToTheirLists(unittest.TestCase): self.assertIn(f"`{named.rstrip('*')}`", section) +class ScratchFilesGoToRepoTmp(unittest.TestCase): + """adding-a-scraper.md wrote its comparison to /tmp while two other + pages say /tmp is a RAM disk and send scratch files to the repo's tmp/.""" + + @staticmethod + def _code_lines(text: str) -> list[str]: + lines, inside = [], False + for line in text.splitlines(): + if line.strip().startswith("```"): + inside = not inside + elif inside: + lines.append(line) + return lines + + def test_no_command_writes_to_slash_tmp(self): + offenders = [ + f"{page.name}: {line.strip()}" + for page in PUBLISHED + for line in self._code_lines(page.read_text(encoding="utf-8")) + if re.search(r"(? Date: Wed, 21 Oct 2026 10:01:46 +0200 Subject: [PATCH 11/15] docs: describe the native export without verdicts --- tests/test_doc_pages.py | 1 + wiki/adding-a-scraper.md | 2 +- wiki/advanced-usage.md | 17 +++++++++-------- wiki/tools.md | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index cf689167..4b95dec7 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -273,6 +273,7 @@ class PeerToneTowardPlatforms(unittest.TestCase): WORDS = re.compile( r"(?i)\bgets wrong\b|\bstale (hash|entries|entry)\b||\bpatch their\b" r"|, corrected\b|\bcorrected in place\b|\bdeclare incorrectly\b|\blist error\b" + r"|\btheir syntax\b|\bthe platform lacks\b|\bthe odd one\b|\bmay not match reality\b" ) def test_published_pages(self): diff --git a/wiki/adding-a-scraper.md b/wiki/adding-a-scraper.md index 10d92b05..d67ad4ae 100644 --- a/wiki/adding-a-scraper.md +++ b/wiki/adding-a-scraper.md @@ -211,7 +211,7 @@ System ID consistency matters for cross-platform operations. The same console mu (`_resolve_truncated_md5`), and kept as written otherwise. The resolution layer matches what remains by prefix. -Scraped data reflects what the upstream declares, which may not match reality. The scraper's job is faithful transcription of upstream data, not correction. Corrections happen in the emulator profiles (source-verified) and in `_shared.yml` (curated). If a scraper meets a hash or filename that differs from the emulator source, log a warning and still include the upstream value. The divergence will surface during truth diffing. +Scraped data reflects what the upstream declares, which can differ from what the emulator source reads. The scraper's job is faithful transcription of upstream data, not correction. Corrections happen in the emulator profiles (source-verified) and in `_shared.yml` (curated). If a scraper meets a hash or filename that differs from the emulator source, log a warning and still include the upstream value. The divergence will surface during truth diffing. A few rules keep that transcription honest: diff --git a/wiki/advanced-usage.md b/wiki/advanced-usage.md index 97a88376..467f194f 100644 --- a/wiki/advanced-usage.md +++ b/wiki/advanced-usage.md @@ -331,10 +331,11 @@ The diff reports: ### Export to native formats -Write a platform's own BIOS file, updated from the emulators' code. The output is the file its -maintainers keep, not our data in their syntax: hashes the truth can prove -are applied, entries the truth says nothing about are left alone, and files -the truth knows and the platform lacks are added. +Write a platform's own BIOS file with the updates its emulators' source code +supports. The output is the platform's original file with those updates applied: +a hash the emulator source states is updated, an entry the source says nothing +about is kept as written, and a file the platform's emulators read that the list +does not name yet is added. ```bash python scripts/export_native.py --platform batocera --fetch # batocera-systems @@ -355,10 +356,10 @@ patched from the platform's own file, which `--fetch` downloads once into `.cache/upstream-native/`. Without it the export fails rather than publishing a fragment. -RetroPie is the odd one: it publishes no BIOS list at all, and the only -declaration is the sentence in each package's `rp_module_help`. The export -adds a missing file name to a list a maintainer already wrote, never -removes one, and never drafts a sentence where there was none. +RetroPie keeps its BIOS information in the `rp_module_help` sentence of each +package rather than in a list. The export adds a file name to a sentence a +maintainer already wrote, never removes one, and never drafts a sentence where +there was none. The run reports what it changed, and what it had to leave out: an entry a format cannot express is named, never written half-formed. `es_bios.xsd` diff --git a/wiki/tools.md b/wiki/tools.md index 87c8f990..0330451b 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -922,4 +922,4 @@ Located in `scripts/exporter/`. Each inherits `BaseExporter`, defined in | `romm_exporter` | `known_bios_files.json`, every entry with a size and a hash | | `bizhawk_exporter` | `FirmwareDatabase.cs`, BizHawk's own C# | | `misterfpga_exporter` | hash and size in MiSTer's BiosDB (`bios_db.json`) | -| `retropie_exporter` | the `rp_module_help` prose of each scriptmodule, by adding a name it lacks | +| `retropie_exporter` | the `rp_module_help` prose of each scriptmodule, by adding a file name the package's emulator reads | From a37af8e42e38556e18de19f8d2197c97ab3590c0 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:06:49 +0200 Subject: [PATCH 12/15] docs: readme installer options and plain wording The quick install block gains the PowerShell form that passes options and a link to the installer page. The collection size uses the GB format of the download table and the site, counts get separators, and the sentences about RetroArch's existence check and the method describe the code instead of judging documentation. --- README.md | 11 +++++++---- scripts/generate_readme.py | 24 +++++++++++++---------- scripts/generate_site.py | 2 +- tests/test_doc_pages.py | 40 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3db02361..70f6724f 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,13 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | # SD card mounted on a PC: --dest is the BIOS folder on the card # (RetroArch on Switch: retroarch/cores/system, on Android: RetroArch/system) curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --platform retroarch --dest /path/to/sdcard/retroarch/cores/system + +# Options in PowerShell: anything after | iex goes to iex, so the script runs as a block +&([scriptblock]::Create((irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1))) --check ``` The script auto-detects your platform, downloads only missing files, and verifies checksums. +Every option, and what the installer detects and writes, is on the [installer page](https://abdess.github.io/retrobios/wiki/installer/#passing-options). ## Download BIOS packs @@ -65,7 +69,7 @@ The RetroDECK pack already carries its own `bios/` folder, so it extracts into ` BIOS, firmware, and system files for consoles from Atari to PlayStation 3. These are the files an emulator loads from disk instead of carrying inside itself. Some are required to boot a system, others improve accuracy or unlock a feature, and the packs carry both. -Each file is checked the way your platform checks it. Most compare a checksum, the fingerprint of a file's contents, which catches a corrupt or unexpected copy. RetroArch, Lakka and RetroPie only look for the filename, because that is all their code does: the Coverage table says which applies to you. Independently of that, the collection records five fingerprints per file, and wherever an emulator's code states an expected size or hash, that value is read from its source and rechecked here. +Each file is checked the way your platform checks it. Most compare a checksum, the fingerprint of a file's contents, which catches a corrupt or unexpected copy. RetroArch, Lakka and RetroPie check that the file is present under its name without reading its contents (`path_is_valid` in RetroArch's `core_info.c`): the Coverage table says which applies to you. Independently of that, the collection records five fingerprints per file, and wherever an emulator's code states an expected size or hash, that value is read from its source and rechecked here. - **936 files** the platforms' emulators load are still to be found, and 136 more cannot be sourced at all (per-user keys, user-filled slots, dumps nobody has made). Both are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/) - **12 platforms** supported with platform-specific verification @@ -73,7 +77,7 @@ Each file is checked the way your platform checks it. Most compare a checksum, t - **533 systems** handled by those emulators (NES, SNES, PlayStation, Saturn, Dreamcast, ...) - **19,526 files in the collection**, each with its SHA1, MD5, SHA256, CRC32 and Adler-32 fingerprints: 5,743 system files, 2,851 arcade ROM sets, 10,932 game and engine data files. That is every platform and emulator together, so no pack holds them all: each carries what its own emulators load, counted in the download table - **555 of 5,743 system files** matched to dump-preservation catalogs (No-Intro, Redump, TOSEC). Arcade sets and engine data fall outside what those catalogs index -- **16011 MB** total collection size +- **15.6 GB** for the whole collection ## Supported systems @@ -146,8 +150,7 @@ The [documentation site](https://abdess.github.io/retrobios/) provides: ## How it works -Documentation and metadata can drift from what emulators actually load. -To keep packs accurate, platform lists are checked against emulator source code, file by file where a profile exists. When the two disagree, the code wins. +A platform's BIOS list and an emulator's code are maintained separately. Each list is compared with the emulator source code, file by file where a profile exists, and where the two differ the code decides. Hashes document what emulator code loads and accepts, not dump provenance. That boundary, and how it relates to preservation catalogs such as No-Intro, is drawn in the [FAQ](https://abdess.github.io/retrobios/wiki/faq/#are-these-files-verified-against-original-hardware-dumps). diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index e0650551..4f4dd4e1 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -329,7 +329,6 @@ def _catalog_matched_line(db: dict) -> list[str]: def generate_readme(db: dict, platforms_dir: str) -> str: total_files = db.get("total_files", 0) total_size = db.get("total_size", 0) - size_mb = total_size / (1024 * 1024) ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") platform_names = list_registered_platforms(platforms_dir, include_archived=True) @@ -397,9 +396,13 @@ def generate_readme(db: dict, platforms_dir: str) -> str: "# SD card mounted on a PC: --dest is the BIOS folder on the card", "# (RetroArch on Switch: retroarch/cores/system, on Android: RetroArch/system)", "curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --platform retroarch --dest /path/to/sdcard/retroarch/cores/system", + "", + "# Options in PowerShell: anything after | iex goes to iex, so the script runs as a block", + "&([scriptblock]::Create((irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1))) --check", "```", "", "The script auto-detects your platform, downloads only missing files, and verifies checksums.", + f"Every option, and what the installer detects and writes, is on the [installer page]({SITE_URL}wiki/installer/#passing-options).", "", "## Download BIOS packs", "", @@ -462,16 +465,17 @@ def generate_readme(db: dict, platforms_dir: str) -> str: "", "Each file is checked the way your platform checks it. Most compare" " a checksum, the fingerprint of a file's contents, which catches a" - " corrupt or unexpected copy. RetroArch, Lakka and RetroPie only" - " look for the filename, because that is all their code does: the" + " corrupt or unexpected copy. RetroArch, Lakka and RetroPie check" + " that the file is present under its name without reading its" + " contents (`path_is_valid` in RetroArch's `core_info.c`): the" " Coverage table says which applies to you. Independently of that," " the collection records five fingerprints per file, and wherever" " an emulator's code states an expected size or hash, that value is" " read from its source and rechecked here.", "", ( - f"- **{acquirable_total} files** the platforms' emulators load" - f" are still to be found, and {unsourceable_total} more cannot" + f"- **{acquirable_total:,} files** the platforms' emulators load" + f" are still to be found, and {unsourceable_total:,} more cannot" f" be sourced at all (per-user keys, user-filled slots, dumps" f" nobody has made). Both are named in the" f" [gap analysis]({SITE_URL}gaps/)" @@ -484,7 +488,7 @@ def generate_readme(db: dict, platforms_dir: str) -> str: f"- **{len(system_ids)} systems** handled by those emulators (NES, SNES, PlayStation, Saturn, Dreamcast, ...)", collection_line(total_files, comp), *_catalog_matched_line(db), - f"- **{size_mb:.0f} MB** total collection size", + f"- **{format_size(total_size)}** for the whole collection", "", "## Supported systems", "", @@ -621,10 +625,10 @@ def generate_readme(db: dict, platforms_dir: str) -> str: "", "## How it works", "", - "Documentation and metadata can drift from what emulators actually load.", - "To keep packs accurate, platform lists are checked against emulator" - " source code, file by file where a profile exists. When the two" - " disagree, the code wins.", + "A platform's BIOS list and an emulator's code are maintained" + " separately. Each list is compared with the emulator source code," + " file by file where a profile exists, and where the two differ the" + " code decides.", "", "Hashes document what emulator code loads and accepts, not dump" " provenance. That boundary, and how it relates to preservation" diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 0bad09ca..41de18f5 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -2697,7 +2697,7 @@ def generate_emulators_index(profiles: dict) -> str: lines = [ "# Emulators", "", - f"**{len(unique)}** emulator profiles, **{total_files}** files total, **{len(aliases)}** aliases.", + f"**{len(unique):,}** emulator profiles, **{total_files:,}** files total, **{len(aliases):,}** aliases.", "", "| Classification | Count | Description |", "|---------------|-------|-------------|", diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 4b95dec7..ff40b6cb 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -274,6 +274,7 @@ class PeerToneTowardPlatforms(unittest.TestCase): r"(?i)\bgets wrong\b|\bstale (hash|entries|entry)\b||\bpatch their\b" r"|, corrected\b|\bcorrected in place\b|\bdeclare incorrectly\b|\blist error\b" r"|\btheir syntax\b|\bthe platform lacks\b|\bthe odd one\b|\bmay not match reality\b" + r"|\ball their code does\b|\bcan drift from\b" ) def test_published_pages(self): @@ -628,5 +629,44 @@ class ScratchFilesGoToRepoTmp(unittest.TestCase): self.assertIn("tmp/", (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()) +class ReadmeInstallerOptions(unittest.TestCase): + """The README named --system, --region and --check but showed options + only after `sh -s --`. Appended to `irm ... | iex`, PowerShell rejects + them, and the form that works was on the installer page alone, which the + README never linked.""" + + def test_quick_install_gives_the_powershell_form_and_the_page(self): + import generate_readme + + source = inspect.getsource(generate_readme.generate_readme) + self.assertIn("&([scriptblock]::Create((irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1)))", source) + self.assertIn("wiki/installer/#passing-options", source) + self.assertIn("## Passing options", _page("installer.md")) + readme = (ROOT / "README.md").read_text(encoding="utf-8") + quick = _section(readme, "Quick install") + self.assertIn("[scriptblock]::Create", quick) + self.assertIn("wiki/installer/#passing-options", quick) + self.assertNotRegex(readme, r"(?m)^irm \S+ \| iex \S") + + +class PublishedNumbersShareOneFormat(unittest.TestCase): + """The README gave the collection as `16011 MB` beside `19,526 files`, + the site home as 15.6 GB, and the emulators page `10095 files`.""" + + def test_generators_format_counts_and_sizes_alike(self): + import generate_readme + import generate_site + + readme_source = inspect.getsource(generate_readme.generate_readme) + self.assertNotIn("size_mb", readme_source) + self.assertIn("format_size(total_size)", readme_source) + self.assertIn("{total_files:,}", inspect.getsource(generate_site)) + self.assertEqual(generate_readme.format_size(16011 * 1024 * 1024), "15.6 GB") + + def test_the_readme_has_no_bare_large_count(self): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + self.assertEqual(re.findall(r"\*\*\d{4,}\b[^*]*\*\*", readme), []) + + if __name__ == "__main__": unittest.main() From c9780c882211fa740270f1f3f6210c05f532a8fc Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:06:57 +0200 Subject: [PATCH 13/15] docs: add one BIOS from the browser A browser upload into bios/ lands on a fork and opens the pull request, and CI already hashes the file and names its folder. CONTRIBUTING asked for a fork, a clone, five packages and the whole suite, and promised a PR comment that a fork's read-only token cannot post. The issue chooser links the upload page. --- .github/ISSUE_TEMPLATE/config.yml | 5 +++ CONTRIBUTING.md | 27 ++++++++++++---- scripts/generate_readme.py | 30 +++++++++++++---- tests/test_contributing_text.py | 53 +++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..b5b61442 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Add a BIOS file + url: https://github.com/Abdess/retrobios/upload/main/bios + about: Upload the file into bios/ from the browser. GitHub opens the pull request, and CI checks the file and names the folder of its system. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4860f55e..06870fb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,10 +2,21 @@ ## Add a BIOS file -1. Fork this repository -2. Place the file in `bios/Manufacturer/Console/filename` -3. Variants (alternate hashes for the same file): `bios/Manufacturer/Console/.variants/` -4. Open a pull request. Its hashes are checked automatically and reported in a comment +One file needs no clone and no local setup: + +1. [Upload it into `bios/`](https://github.com/Abdess/retrobios/upload/main/bios) + from the browser, 25 MB at most per file. GitHub writes it to a fork and + opens the pull request. +2. CI hashes it and looks it up in every platform list and emulator profile. + For a file it recognises, it names the folder holding the other files of its + system. The report is in the summary of the pull request's Validation run. +3. The file can be moved to that folder in the same pull request. A file whose + hash differs from the one already held under its name goes to that folder's + `.variants/`. + +From a clone, the file goes to `bios/Manufacturer/Console/filename`, and +`python scripts/validate_pr.py path/to/file` names that folder for a file still +outside `bios/`. A file over the browser's 25 MB limit goes in that way. The [dump provenance](https://abdess.github.io/retrobios/provenance/) page lists catalogued dumps still missing from the collection, with their hashes. A file matching one of those is @@ -43,7 +54,9 @@ Full walkthrough: [profiling guide](https://abdess.github.io/retrobios/wiki/prof ## Before opening a PR -The checks CI runs on a pull request (Python 3.12 or newer): +A pull request that only adds files under `bios/` needs none of this: the +validation above is its check. For code, profiles or platform files, these are +the checks CI runs on a pull request (Python 3.12 or newer): ```bash pip install pyyaml "jsonschema[format-nongpl]" markdown \ @@ -62,7 +75,9 @@ in `dist/`, which is why maintainers run it before a release. CI computes SHA1/MD5/CRC32 for every new file, checks them against the platform configs, validates the YAML against the schemas, confronts each added profile -with its upstream source, runs the test suite, and posts a report on the PR. +with its upstream source and runs the test suite. The BIOS report goes to the +summary of the Validation run, and also to a comment when the branch lives in +this repository: a fork's pull request runs with a read-only token. Contributors who add platform support are credited in the README, on the documentation site, and in the BIOS packs. diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index 4f4dd4e1..f99b9f67 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -250,6 +250,9 @@ def format_size(size: int) -> str: SITE_URL = "https://abdess.github.io/retrobios/" REPO_PATH = "../.." REPO = "Abdess/retrobios" +# GitHub's "Add file > Upload files" page for bios/: a browser upload by +# someone without write access lands on a fork and opens a pull request. +UPLOAD_URL = f"https://github.com/{REPO}/upload/main/bios" def _existing_contributor_block(readme_path: str = "README.md") -> list[str]: @@ -714,10 +717,21 @@ def contributing_text( ## Add a BIOS file -1. Fork this repository -2. Place the file in `bios/Manufacturer/Console/filename` -3. Variants (alternate hashes for the same file): `bios/Manufacturer/Console/.variants/` -4. Open a pull request. Its hashes are checked automatically and reported in a comment +One file needs no clone and no local setup: + +1. [Upload it into `bios/`]({UPLOAD_URL}) + from the browser, 25 MB at most per file. GitHub writes it to a fork and + opens the pull request. +2. CI hashes it and looks it up in every platform list and emulator profile. + For a file it recognises, it names the folder holding the other files of its + system. The report is in the summary of the pull request's Validation run. +3. The file can be moved to that folder in the same pull request. A file whose + hash differs from the one already held under its name goes to that folder's + `.variants/`. + +From a clone, the file goes to `bios/Manufacturer/Console/filename`, and +`python scripts/validate_pr.py path/to/file` names that folder for a file still +outside `bios/`. A file over the browser's 25 MB limit goes in that way. The [dump provenance]({link("provenance")}) page lists catalogued dumps still missing from the collection, with their hashes. A file matching one of those is @@ -755,7 +769,9 @@ Full walkthrough: [profiling guide]({link("wiki/profiling")}). ## Before opening a PR -The checks CI runs on a pull request (Python 3.12 or newer): +A pull request that only adds files under `bios/` needs none of this: the +validation above is its check. For code, profiles or platform files, these are +the checks CI runs on a pull request (Python 3.12 or newer): ```bash pip install pyyaml "jsonschema[format-nongpl]" markdown \\ @@ -774,7 +790,9 @@ in `dist/`, which is why maintainers run it before a release. CI computes SHA1/MD5/CRC32 for every new file, checks them against the platform configs, validates the YAML against the schemas, confronts each added profile -with its upstream source, runs the test suite, and posts a report on the PR. +with its upstream source and runs the test suite. The BIOS report goes to the +summary of the Validation run, and also to a comment when the branch lives in +this repository: a fork's pull request runs with a read-only token. Contributors who add platform support are credited in the README, {site_words}, and in the BIOS packs. diff --git a/tests/test_contributing_text.py b/tests/test_contributing_text.py index 1f72f67d..3803722a 100644 --- a/tests/test_contributing_text.py +++ b/tests/test_contributing_text.py @@ -83,5 +83,58 @@ class BeforeOpeningAPullRequest(unittest.TestCase): self.assertIn(f'"{spec}"', text) +class OneFileThroughTheBrowser(unittest.TestCase): + """Adding one BIOS asked for a fork, a 5.5 GB clone, five pip packages, + the data directories and the whole suite, and promised a PR comment that + a fork's read-only token cannot post. The browser upload into bios/ and + the CI report already did the work.""" + + def test_the_guide_and_the_issue_chooser_offer_the_upload(self): + import yaml + + text = generate_readme.generate_contributing() + section = text.split("## Add a BIOS file", 1)[1].split("\n## ", 1)[0] + self.assertIn(generate_readme.UPLOAD_URL, section) + self.assertNotIn("Fork this repository", section) + self.assertNotIn("reported in a comment", text) + chooser = yaml.safe_load( + (REPO_ROOT / ".github" / "ISSUE_TEMPLATE" / "config.yml").read_text(encoding="utf-8") + ) + self.assertIn(generate_readme.UPLOAD_URL, [link["url"] for link in chooser["contact_links"]]) + + def test_ci_comments_only_on_branches_of_this_repository(self): + workflow = (REPO_ROOT / ".github" / "workflows" / "validate.yml").read_text(encoding="utf-8") + self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", workflow) + self.assertIn('>> "$GITHUB_STEP_SUMMARY"', workflow) + self.assertIn("summary of the Validation run", generate_readme.generate_contributing()) + + def test_an_upload_at_the_bios_root_is_told_its_folder(self): + import os + import tempfile + + import validate_pr + + scratch = REPO_ROOT / "tmp" / "tests" + scratch.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=scratch) as directory: + (Path(directory) / "bios").mkdir() + (Path(directory) / "bios" / "boot.rom").write_bytes(b"boot") + from common import compute_hashes + + sha1 = compute_hashes(str(Path(directory) / "bios" / "boot.rom"))["sha1"] + hashes = {"sha1": {sha1}, "folders": {sha1: {"bios/Maker/Console/"}}} + cwd = os.getcwd() + os.chdir(directory) + try: + result = validate_pr.validate_file("bios/boot.rom", None, hashes) + finally: + os.chdir(cwd) + self.assertTrue(result.passed) + self.assertTrue(any( + "bios/ root" in message and "`bios/Maker/Console/`" in message + for _status, message in result.checks + )) + + if __name__ == "__main__": unittest.main() From 0ffde1bc8f4d189f4629157135cf01b94a17edc4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:09:20 +0200 Subject: [PATCH 14/15] docs: first install shows the run as printed The Batocera example is dated and carries the Not installable line and the closing list of files the collection does not hold, which every Batocera run prints. Disk sizes are no longer hand-kept: the run prints its size. The no-platform case describes the menu install.sh makes possible by handing over the terminal. --- tests/test_doc_pages.py | 58 +++++++++++++++++++++++++++++++++++ wiki/first-install.md | 67 ++++++++++++++++++++++++++++++----------- 2 files changed, 108 insertions(+), 17 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index ff40b6cb..69f51f85 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -668,5 +668,63 @@ class PublishedNumbersShareOneFormat(unittest.TestCase): self.assertEqual(re.findall(r"\*\*\d{4,}\b[^*]*\*\*", readme), []) +class FirstInstallWalkthrough(unittest.TestCase): + """First install showed a Batocera run of 12893 files with no omission + line and said any other ending meant work left undone, while every + Batocera run printed 399 files not installed. Its disk sizes (Batocera + 4.0 GB) contradicted its own example (8.6 GB), and its no-platform case + said a piped command cannot ask, where install.sh hands it the terminal + and the installer shows a menu.""" + + def setUp(self): + self.text = _page("first-install.md") + + def test_no_hand_kept_size_before_the_run(self): + import install + + section = _section(self.text, "Before starting") + # The confirmation threshold is a constant of the installer, not a pack size. + self.assertEqual(install.CONFIRM_FROM_BYTES, 1024 ** 3) + self.assertIn("reaches 1 GB", section) + self.assertNotRegex(section.replace("reaches 1 GB", ""), r"\d+(\.\d+)? ?[KMG]B\b") + + def test_the_run_is_dated_and_consistent_with_the_manifest(self): + import install + + self.assertRegex(self.text, r"published on \d{4}-\d{2}-\d{2}") + step3 = _section(self.text, "Step 3. Let it work") + step4 = _section(self.text, "Step 4. Read the end of the run") + step5 = _section(self.text, "Step 5. Confirm it later") + count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups() + self.assertIn(f"0/{count} present", step3) + self.assertIn(f"Downloading {count} files ({size})", step3) + self.assertIn(f"Done. {count} files installed", step4) + self.assertIn(f"{count}/{count} present ({count} verified", step5) + manifest = json.loads((ROOT / "install" / "batocera.json").read_text(encoding="utf-8")) + omitted = re.search(r"Not installable: (\d+) files \((\d+) required", step3) + if manifest.get("omitted_files"): + self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it") + if omitted: + total, required = map(int, omitted.groups()) + first, needed = install._omission_lines( + total, [{"required": True, "dest": str(i)} for i in range(required)] + )[:2] + self.assertIn(first, step4) + self.assertIn(needed.split(":")[0], step4) + self.assertIn(f"all {required} belong", step4) + self.assertIn(f"other {total - required} are optional", step4) + + def test_no_platform_detected_reads_like_the_installer(self): + source = (ROOT / "install.py").read_text(encoding="utf-8") + branch = source.split('print(" No supported platform detected.")', 1)[1][:400] + self.assertIn("if not interactive or listing:", branch) + self.assertIn("_prompt_manual_platform(os_type)", branch) + self.assertIn('"$INSTALLER" "$@" Date: Wed, 21 Oct 2026 10:10:25 +0200 Subject: [PATCH 15/15] docs: give RetroArch's Windows system folder RetroArch defaults system_directory to :\system, the folder of retroarch.exe (platform_win32.c), for the installer as for the portable archive. %APPDATA%\RetroArch holds only a fallback retroarch.cfg. --- tests/test_doc_pages.py | 17 +++++++++++++++++ wiki/getting-started.md | 13 +++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 69f51f85..56a6efc5 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -726,5 +726,22 @@ class FirstInstallWalkthrough(unittest.TestCase): self.assertNotIn("cannot stop and ask", section) +class RetroArchWindowsSystemFolder(unittest.TestCase): + """Getting started gave %APPDATA%\\RetroArch\\system for the Windows + installer. RetroArch's default is `:\\system`, the folder of + retroarch.exe (platform_win32.c). %APPDATA% is where a retroarch.cfg + can live, and install.py reads it there.""" + + def test_page_and_installer_agree(self): + import install + + source = inspect.getsource(install) + self.assertIn('ra_dir or exe_dir / "system"', source) + self.assertTrue(str(install._os_default_dests("windows")["retroarch"]).replace("\\", "/").endswith("/system")) + section = _section(_page("getting-started.md"), "RetroArch") + self.assertNotIn("%APPDATA%\\RetroArch\\system", section) + self.assertIn("`system\\` next to `retroarch.exe`", section) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/getting-started.md b/wiki/getting-started.md index cd470e3d..cb2ee08a 100644 --- a/wiki/getting-started.md +++ b/wiki/getting-started.md @@ -108,18 +108,19 @@ RetroArch uses the `system_directory` setting in `retroarch.cfg`. Default locati | OS | Default path | |----|-------------| -| Windows (installer) | `%APPDATA%\RetroArch\system\` | -| Windows (portable .7z) | `system\` next to `retroarch.exe` | +| Windows (installer or portable .7z) | `system\` next to `retroarch.exe` | +| Windows (UWP/MSIX package) | `%LOCALAPPDATA%\Packages\1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_*\LocalState\system\` | | Linux | `~/.config/retroarch/system/` | | Linux (Flatpak) | `~/.var/app/org.libretro.RetroArch/config/retroarch/system/` | | macOS | `~/Library/Application Support/RetroArch/system/` | | Steam Deck (Flatpak) | `~/.var/app/org.libretro.RetroArch/config/retroarch/system/` | | Android | `/storage/emulated/0/RetroArch/system/` | -Windows has two layouts because the installer stores its data under `%APPDATA%` -while the portable archive keeps everything inside the folder you extracted it -to. If both exist on the machine, the one RetroArch actually reads is the one -shown in the UI. +On Windows, `retroarch.cfg` is read beside `retroarch.exe` first, then from +`%APPDATA%\RetroArch\`. Either way, a `system_directory` left at its default is +the `system` folder beside `retroarch.exe` (`:\system` in RetroArch's +`frontend/drivers/platform_win32.c`, where `:` stands for the folder of the +executable). To check your actual path: open RetroArch, go to **Settings > Directory > System/BIOS**, or look for `system_directory` in `retroarch.cfg`.