From fb639647e6f6521c1dc637f97269b620182f2f4f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:05:54 +0200 Subject: [PATCH 01/13] fix: resolve a loose archive entry by its members gngeopi and neo-emu declare neogeo.zip loose and, apart, the ROMs they open inside it. The archive pass took the copy holding them, the loose entry resolved by name to another copy, and both verify --emulator and the emulator pack reported the archive missing while the ZIP held it. with_archive_members() gives the loose entry the members in verify, the pack, slots and the gap report. --- scripts/generate_pack.py | 3 +- scripts/slots.py | 7 +++-- scripts/validation.py | 15 +++++++++ scripts/verify.py | 6 +++- tests/test_archive_choice.py | 60 ++++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 56975f37..fff9b337 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -94,6 +94,7 @@ from validation import ( _build_validation_index, deliverable, filter_files_by_mode, + with_archive_members, ) yaml = require_yaml() @@ -1572,7 +1573,7 @@ def generate_emulator_pack( refused_path: str | None = None for fe in alternatives: local_path, status = resolve_file( - {**fe, "source_profile": emu_name}, + {**with_archive_members(fe, files), "source_profile": emu_name}, db, bios_dir, zip_contents, diff --git a/scripts/slots.py b/scripts/slots.py index 1e91c6b8..68a464fd 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -37,7 +37,7 @@ from common import ( variant_base_name, ) from packresolve import preferred_entries -from validation import outside_gap_scope +from validation import outside_gap_scope, with_archive_members if TYPE_CHECKING: from packextras import ExtraPlacement @@ -241,7 +241,10 @@ def profile_claims( full = placed[1] # The owner's own copy is the one the builder ships. local, status = resolve_local_file( - {**entry, "source_profile": emu_name}, + { + **with_archive_members(entry, profile.get("files") or []), + "source_profile": emu_name, + }, db, zip_contents, dest_hint=dest, diff --git a/scripts/validation.py b/scripts/validation.py index e009c4fc..8aa7e54b 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -13,6 +13,7 @@ from collections.abc import Callable, Iterator, Mapping from common import ( adler32_of, + archive_members, compute_hashes, name_match_size_ok, sanitize_pack_path, @@ -512,6 +513,20 @@ def declaration_settles( return False +def with_archive_members(entry: dict, files: list[dict]) -> dict: + """A loose entry resolved as the archive its profile reads members from. + + gngeopi declares neogeo.zip and, apart, the ROMs it opens inside it. The + members decide which same-named copy is the archive: resolved by name + alone, the loose entry took another copy than the archive pass and the + profile blocked its own archive. + """ + if entry.get("archive"): + return entry + members = archive_members(entry.get("name", ""), files) + return {**entry, "members": members} if members else entry + + def opens_by_name(file_entry: dict, profile: dict, standalone: bool = False) -> bool: """Whether the code opens this entry by its name. diff --git a/scripts/verify.py b/scripts/verify.py index dfae360a..a80b6f70 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -100,6 +100,7 @@ from validation import ( installed_with_emulator, unread_on, outside_gap_scope, + with_archive_members, ) DEFAULT_DB = "database.json" @@ -683,6 +684,9 @@ def find_undeclared_files( } if f.get("contents"): entry["contents"] = f["contents"] + members = with_archive_members(f, profile.get("files", [])).get("members") + if members: + entry["members"] = members if launcher_layer: launcher_candidates.append((emu_name, is_standalone, f, entry)) if duplicate: @@ -1887,7 +1891,7 @@ def verify_emulator( dest_hint = file_entry.get("path", "") local_path, resolve_status = resolve_local_file( - {**file_entry, "source_profile": emu_name}, + {**with_archive_members(file_entry, files), "source_profile": emu_name}, db, zip_contents, dest_hint=dest_hint, diff --git a/tests/test_archive_choice.py b/tests/test_archive_choice.py index d961a48b..046742d3 100644 --- a/tests/test_archive_choice.py +++ b/tests/test_archive_choice.py @@ -349,5 +349,65 @@ class AnArchiveRankedByWhatItHolds(unittest.TestCase): self.assertIn('entry["contents"] = f["contents"]', report) +class ALooseEntryNamingItsArchive(unittest.TestCase): + """gngeopi declares neogeo.zip loose and, apart, the ROMs it opens inside. + The archive pass took the copy holding them, the loose entry resolved by + name alone to another copy, and the profile blocked its own archive: + verify said missing, the pack said missing, and the ZIP held it.""" + + def test_verify_and_the_pack_ship_the_archive_once(self): + import io + from contextlib import redirect_stdout + + import yaml + + from common import _emulator_profiles_cache + from generate_pack import generate_emulator_pack + from verify import verify_emulator + + _emulator_profiles_cache.clear() + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + previous = os.getcwd() + os.chdir(root) + self.addCleanup(os.chdir, previous) + roms = {"bios.rom": b"B" * 32, "lo.lo": b"L" * 32, "rev.rom": b"R" * 32} + files, by_name = {}, {} + # Two revisions, neither holding the other: index order picks A. + for path, members in (("bios/A/set.zip", ["bios.rom", "rev.rom"]), + ("bios/B/set.zip", ["bios.rom", "lo.lo"])): + Path(path).parent.mkdir(parents=True) + with zipfile.ZipFile(path, "w") as zf: + for member in members: + zf.writestr(member, roms[member]) + sha1 = hashlib.sha1(Path(path).read_bytes()).hexdigest() + files[sha1] = {"path": path, "name": "set.zip", "sha1": sha1, "size": 1} + by_name.setdefault("set.zip", []).append(sha1) + db = {"files": files, "indexes": {"by_name": by_name, "by_md5": {}, + "by_path_suffix": {}, "by_crc32": {}}} + Path("emulators").mkdir() + Path("emulators/neo.yml").write_text(yaml.safe_dump({ + "emulator": "Neo", "type": "libretro", "systems": ["snk-neogeo-mvs"], + "files": [{"name": "set.zip", "required": True, "category": "bios_zip"}] + [ + {"name": name, "archive": "set.zip", "required": True, + "crc32": format(zlib.crc32(data), "08x")} + for name, data in roms.items() if name != "rev.rom" + ], + })) + report = verify_emulator(["neo"], "emulators", db, platforms_dir="platforms") + with redirect_stdout(io.StringIO()) as out: + zip_path = generate_emulator_pack( + ["neo"], "emulators", db, "bios", "out", offline=True, + platforms_dir="platforms", + ) + with zipfile.ZipFile(zip_path) as zf, zf.open("set.zip") as inner: + held = zipfile.ZipFile(io.BytesIO(inner.read())).namelist() + self.assertEqual(sorted(held), ["bios.rom", "lo.lo"]) + self.assertNotIn("missing", out.getvalue()) + self.assertEqual({d["status"] for d in report["details"]}, {"ok"}) + self.assertEqual(report["total_files"], 1) + + if __name__ == "__main__": unittest.main() From 38af19091df877880fb94fdbc5bc2d29cfb158d4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:18:02 +0200 Subject: [PATCH 02/13] fix: compare pack paths, not only counts Step 5 compared (ok, total) and the core file count. RetroArch, Lakka and RetroPie read 526/526 on both sides while verify credited four paths below SGB1.sfc and SGB2.sfc that the pack never writes: a declared file blocks them as a directory. verify now places the platform files in the builder's order with the same path rules, and a user_provided or external declaration keeps its slot on both sides, so no core file takes it in the report only. verify --destinations writes the paths each pack holds by the report, and step 5 compares them with the archive members (notes and data tree members set aside). Every emulator profile, in each build it has, is built in memory (PackRecord, nothing read or written) and compared with verify --emulator path by path. Cost measured here: about 1 s per platform archive and 11 s for the 930 emulator packs. --- scripts/generate_pack.py | 59 +++++++++-- scripts/packextras.py | 5 +- scripts/pipeline.py | 176 ++++++++++++++++++++++++++++++- scripts/verify.py | 95 ++++++++++++++++- tests/test_pack_destinations.py | 181 ++++++++++++++++++++++++++++++++ wiki/adding-a-platform.md | 4 +- wiki/testing-guide.md | 2 +- wiki/tools.md | 12 ++- 8 files changed, 509 insertions(+), 25 deletions(-) create mode 100644 tests/test_pack_destinations.py diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index fff9b337..e20cb037 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -106,6 +106,20 @@ DEFAULT_BIOS_DIR = "bios" +class PackRecord: + """A pack build kept in memory: the paths it writes, no archive. + + ``members`` is every path written, ``files`` the ones the build counts as + the profile's files (archives and loose files, not trees or notes). The + pipeline builds every emulator pack this way and compares ``files`` with + what verify --emulator credits, without reading or compressing a byte. + """ + + def __init__(self) -> None: + self.members: list[str] = [] + self.files: list[str] = [] + + def _add_pack_member(zf: zipfile.ZipFile, source_path: str, arcname: str) -> None: """Add a file to the pack with metadata that does not depend on the build. @@ -117,6 +131,9 @@ def _add_pack_member(zf: zipfile.ZipFile, source_path: str, arcname: str) -> Non two consecutive builds. The content is streamed rather than read whole, because some members are firmware images of several hundred megabytes. """ + if isinstance(zf, PackRecord): + zf.members.append(arcname) + return info = zipfile.ZipInfo(filename=arcname, date_time=_FIXED_DATE_TIME) info.compress_type = zipfile.ZIP_DEFLATED # The executable bit is the one permission worth carrying: the RetroDECK @@ -143,6 +160,9 @@ def _write_generated_member(zf: zipfile.ZipFile, arcname: str, text: str) -> Non a different hash on every run. Pinning them to the epoch the archive rebuilder already uses makes a pack a function of its inputs alone. """ + if isinstance(zf, PackRecord): + zf.members.append(arcname) + return info = zipfile.ZipInfo(filename=arcname, date_time=_FIXED_DATE_TIME) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o100644 << 16 @@ -895,6 +915,13 @@ def generate_pack( ) as tmp: tmp_path = tmp.name + # The slot is the platform's file whether the download + # arrives or not: verify cannot fetch, and a core file + # filling it on a failed download made the two differ. + seen_destinations.add(dedup_key) + _register_path(dedup_key, seen_destinations, seen_parents) + if case_insensitive: + seen_lower.add(dedup_key.lower()) try: if download_external(file_entry, tmp_path): extract = file_entry.get("extract", False) @@ -902,10 +929,6 @@ def generate_pack( _extract_zip_to_archive(tmp_path, _flat(full_dest, base_dest, flatten), zf) else: _add_pack_member(zf, tmp_path, _flat(full_dest, base_dest, flatten)) - seen_destinations.add(dedup_key) - _register_path(dedup_key, seen_destinations, seen_parents) - if case_insensitive: - seen_lower.add(dedup_key.lower()) file_status.setdefault(dedup_key, "ok") total_files += 1 else: @@ -1232,6 +1255,9 @@ def _add_zip_to_pack( declare the hash of a ROM inside the ZIP (checkInsideZip), which a content-preserving rebuild does not affect. """ + if isinstance(target_zf, PackRecord): + target_zf.members.append(dest_path) + return if ( file_entry and not file_entry.get("zipped_file") @@ -1379,6 +1405,7 @@ def generate_emulator_pack( pack_name: str | None = None, system_ids: list[str] | None = None, final_dir: str | None = None, + record: PackRecord | None = None, ) -> str | None: """Generate a ZIP pack for specific emulator profiles. @@ -1387,6 +1414,8 @@ def generate_emulator_pack( the 255 bytes a file name may hold. `system_ids` keeps only the entries of those systems, as a platform pack narrows core extras. `final_dir` is where the caller moves the archive, the path the report names. + `record` builds the pack in memory instead (PackRecord): nothing is + written or read, and the paths are kept on it. """ all_profiles = load_emulator_profiles(emulators_dir, skip_aliases=False) if zip_contents is None: @@ -1411,7 +1440,8 @@ def generate_emulator_pack( display_names, [n for n, _p in selected], f"{narrow_tags}_BIOS_Pack.zip" ) zip_path = os.path.join(output_dir, zip_name) - os.makedirs(output_dir, exist_ok=True) + if record is None: + os.makedirs(output_dir, exist_ok=True) region_drops: set[str] = ( emulator_region_drops(selected, standalone, regions, required_only, db) @@ -1449,7 +1479,10 @@ def generate_emulator_pack( platforms_dir or os.path.join(os.path.dirname(__file__), "..", "platforms") ) - with _writing_pack(zip_path) as zf: + with ( + contextlib.nullcontext(record) if record is not None + else _writing_pack(zip_path) + ) as zf: for emu_name, profile in sorted(selected): pack_structure = profile.get("pack_structure") files = expand_agnostic_entries( @@ -1514,6 +1547,8 @@ def generate_emulator_pack( seen_lower.add(archive_dest.lower()) packed_at[archive_dest.lower()] = local_path total_files += 1 + if record is not None: + record.files.append(archive_dest) else: report_missing(archive_name, archive_dest) @@ -1641,12 +1676,16 @@ def generate_emulator_pack( ) as tmp: tmp_path = tmp.name try: - if download_external(fe, tmp_path): + # Built in memory, nothing is fetched: verify --emulator + # cannot fetch either and reports the file missing. + if record is None and download_external(fe, tmp_path): _add_pack_member(zf, tmp_path, dest) seen_destinations.add(dest) _register_path(dest, seen_destinations, seen_parents) seen_lower.add(dest.lower()) total_files += 1 + if record is not None: + record.files.append(dest) else: missing_files.append(fe["name"]) finally: @@ -1675,6 +1714,8 @@ def generate_emulator_pack( seen_lower.add(dest.lower()) packed_at[dest.lower()] = local_path total_files += 1 + if record is not None: + record.files.append(dest) # The trees after the files, as in a platform pack: a dump the # profile declares wins over the copy a tree bundles at the same @@ -1705,7 +1746,9 @@ def generate_emulator_pack( ) # Remove empty ZIP (no files packed and no missing = nothing to ship) - if total_files + data_files == 0 and not (missing_files or rejected_files or instruction_files or unsourceable_files): + if record is None and total_files + data_files == 0 and not ( + missing_files or rejected_files or instruction_files or unsourceable_files + ): os.unlink(zip_path) # Report. The members of data trees are counted apart: verify --emulator diff --git a/scripts/packextras.py b/scripts/packextras.py index e32dab34..544958f4 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -209,7 +209,7 @@ def hidden_extras( emulators_dir: str, db: dict, profiles: dict, - shipped: dict[str, str], + shipped: dict[str, str | None], target_cores: set[str] | None = None, region_drops: set[str] | None = None, placed: set[str] | None = None, @@ -229,7 +229,8 @@ def hidden_extras( while the report counted it in the pack. A twin carrying the same bytes serves the core on the filesystem that folds the names, so it is not a file left out. ``shipped`` maps the full destination of each platform - file the pack carries to its local copy. ``placed``, when given, + file the pack carries to its local copy, or to None for a slot it keeps + without one (user_provided, external). ``placed``, when given, receives the full destination of each held extra the pack carries. ``resolver`` is the builder's lookup. An extra it cannot resolve is skipped and takes no path, as the builder skips it. diff --git a/scripts/pipeline.py b/scripts/pipeline.py index 8fd311f1..c6b235c1 100644 --- a/scripts/pipeline.py +++ b/scripts/pipeline.py @@ -21,7 +21,7 @@ Steps, numbered as the run prints them: 4b generate_pack.py --manifest install manifests 4c --manifest-targets per-target core lists 4d bundled_only.py --strict copies only an emulator install provides - 5 consistency check verify counts == pack counts + 5 consistency check verify == pack: counts, paths, every emulator pack 6 --verify-packs pack integrity, extraction and hashes 7 generate_readme.py README.md and CONTRIBUTING.md 8 generate_site.py MkDocs pages @@ -41,13 +41,16 @@ Usage: from __future__ import annotations import argparse +import atexit import contextlib import importlib.util +import io import os import re import shutil import subprocess import sys +import tempfile import time from pathlib import Path @@ -309,6 +312,159 @@ def check_consistency(verify_output: str, pack_output: str) -> bool: return all_ok +def parse_pack_paths(output: str) -> dict[str, str]: + """The archive each full pack was written to, per pack label.""" + paths: dict[str, str] = {} + current_label = "" + for line in output.splitlines(): + m = re.match(r"Generating (?:shared )?pack for (.+)\.\.\.", line) + if m: + current_label = _full_pack_label(m.group(1).strip()) + continue + written = re.match(r"^\s+(\S.*?\.zip): \d+ files packed", line) + if current_label and written: + paths[current_label] = written.group(1) + return paths + + +# Members the builder writes for the reader rather than for a core. +_PACK_NOTES = re.compile(r"(^README\.txt$|^manifest\.json$|(^|/)(INSTRUCTIONS|RENAMED)_[^/]*\.txt$)") + + +def _tree_members(platform: str, platforms_dir: str) -> dict[str, int]: + """Archive path -> size of every file the platform's data trees can add.""" + import generate_pack # noqa: PLC0415 + from common import load_platform_config # noqa: PLC0415 + from packpaths import _flat # noqa: PLC0415 + + config = load_platform_config(platform, platforms_dir) + base_dest = config.get("base_destination", "") + members: dict[str, int] = {} + with contextlib.redirect_stdout(io.StringIO()): + for src, full in generate_pack._data_directory_members( + config.get("systems", {}), load_data_dir_registry(platforms_dir), + platform, base_dest, False, set(), set(), set(), + ): + members[_flat(full, base_dest, True)] = os.path.getsize(src) + return members + + +def _set_difference_lines(label: str, credited: set[str], written: set[str]) -> list[str]: + """The paths one side has and the other lacks, ten of each at most.""" + lines = [] + for side, paths in (("verify only", credited - written), ("pack only", written - credited)): + if paths: + shown = ", ".join(sorted(paths)[:10]) + more = f" (+{len(paths) - 10})" if len(paths) > 10 else "" + lines.append(f" {label}: {side} {len(paths)}: {shown}{more}") + return lines + + +def check_pack_destinations( + destinations_file: str, pack_output: str, platforms_dir: str = "platforms", +) -> bool: + """Compare the paths verify credits with the members of each archive. + + Equal counts hid a file credited on one side and another written on the + other. The trees and the notes are the archive's own: a tree member is + set aside when its path and size are the cached file's and verify does + not credit the path. + """ + import json # noqa: PLC0415 + import zipfile # noqa: PLC0415 + + print("\n--- 5/8 consistency check: destinations ---") + try: + credited_by = json.loads(Path(destinations_file).read_text()) + except (OSError, ValueError) as exc: + print(f" no destinations from verify ({exc})") + return False + paths = parse_pack_paths(pack_output) + if not paths: + print(" no archive named in the pack output") + return False + all_ok = True + for label, zip_path in sorted(paths.items()): + names = [n for n in credited_by if _match_key(n) & _match_key(label)] + if not names: + print(f" {label}: no verify destinations") + all_ok = False + continue + entry = credited_by[names[0]] + credited = set(entry["pack_destinations"]) + trees = _tree_members(entry["platform"], platforms_dir) + with zipfile.ZipFile(zip_path) as zf: + written = { + info.filename for info in zf.infolist() + if not info.is_dir() + and not _PACK_NOTES.search(info.filename) + and not ( + trees.get(info.filename) == info.file_size + and info.filename not in credited + ) + } + lines = _set_difference_lines(label, credited, written) + if lines: + print("\n".join(lines)) + all_ok = False + else: + print(f" {label}: {len(credited)} paths, the same on both sides") + return all_ok + + +def check_emulator_packs( + emulators_dir: str = "emulators", platforms_dir: str = "platforms", + db_path: str = "database.json", +) -> bool: + """Build every emulator pack in memory and compare it with verify --emulator. + + Each profile in each build it has (libretro, standalone): the paths the + pack fills with the profile's files against the ones verify credits. No + archive is written and no file read, so all of them take seconds. + """ + import generate_pack # noqa: PLC0415 + import verify # noqa: PLC0415 + from common import ( # noqa: PLC0415 + ProfileSelectionError, + build_zip_contents_index, + load_database, + load_emulator_profiles, + select_emulator_profiles, + ) + + print("\n--- 5/8 consistency check: emulator packs ---") + db = load_database(db_path) + zip_contents = build_zip_contents_index(db) + profiles = load_emulator_profiles(emulators_dir, skip_aliases=False) + all_ok = True + compared = 0 + for name in sorted(profiles): + for standalone in (False, True): + try: + select_emulator_profiles([name], profiles, standalone) + except ProfileSelectionError: + continue + record = generate_pack.PackRecord() + with contextlib.redirect_stdout(io.StringIO()): + generate_pack.generate_emulator_pack( + [name], emulators_dir, db, "bios", "", standalone, zip_contents, + offline=True, platforms_dir=platforms_dir, record=record, + ) + report = verify.verify_emulator( + [name], emulators_dir, db, standalone, platforms_dir=platforms_dir, + ) + compared += 1 + label = f"{name}{' --standalone' if standalone else ''}" + lines = _set_difference_lines( + label, set(report["pack_destinations"]), set(record.files) + ) + if lines: + print("\n".join(lines)) + all_ok = False + print(f" {compared} emulator packs compared, {'all equal' if all_ok else 'differences above'}") + return all_ok + + class _Skipped: """A step that did not run. Truthy, so it never fails the pipeline, and distinct, so the summary does not report it as work that was done.""" @@ -597,8 +753,19 @@ def main() -> None: # exclusive. hold_data_dirs(held, load_data_dir_registry("platforms")) - # Step 3: Verify - verify_cmd = [sys.executable, "scripts/verify.py", "--all", "--strict"] + # Step 3: Verify. The paths each pack holds by this report go to a file + # step 5 compares with the archives. + scratch = Path(__file__).resolve().parent.parent / "tmp" + scratch.mkdir(exist_ok=True) + fd, destinations_file = tempfile.mkstemp( + prefix="verify-destinations.", suffix=".json", dir=scratch + ) + os.close(fd) + atexit.register(Path(destinations_file).unlink, missing_ok=True) + verify_cmd = [ + sys.executable, "scripts/verify.py", "--all", "--strict", + "--destinations", destinations_file, + ] if args.include_archived: verify_cmd.append("--include-archived") if args.target: @@ -749,6 +916,9 @@ def main() -> None: results["consistency"] = SKIPPED elif pack_output and verify_output: ok = check_consistency(verify_output, pack_output) + # Counts can agree while the paths differ, both ways at once. + ok = check_pack_destinations(destinations_file, pack_output) and ok + ok = check_emulator_packs() and ok results["consistency"] = ok all_ok = all_ok and ok else: diff --git a/scripts/verify.py b/scripts/verify.py index a80b6f70..a309bbe2 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -30,6 +30,7 @@ import json import os import sys from collections.abc import Mapping +from pathlib import Path import slots @@ -859,7 +860,7 @@ def _mark_hidden( emulators_dir: str, db: dict, emu_profiles: dict | None, - shipped: dict[str, str], + shipped: dict[str, str | None], target_cores: set[str] | None, region_drops: set[str] | None = None, report: list[dict] | None = None, @@ -891,6 +892,39 @@ def _mark_hidden( return placed +def _builder_placement( + packed_order: list[tuple[str, str, str | None]], case_insensitive: bool, +) -> tuple[dict[str, str], dict[str, str | None]]: + """The platform files the builder writes, and every path it keeps. + + The builder walks the systems in name order and skips a path that a + written file blocks as a directory, or the reverse: RetroArch declares + SGB1.sfc and SGB1.sfc/program.rom, and only the first is in the pack. + Returns (path -> local copy written there, path -> copy or None for each + slot kept, a user_provided or external one included). + """ + from packpaths import _has_path_conflict, _register_path + + shipped: dict[str, str] = {} + occupied: dict[str, str | None] = {} + seen: set[str] = set() + folded: set[str] = set() + parents: set[str] = set() + for _sys_id, full, local in sorted(packed_order, key=lambda item: item[0]): + if ( + full in occupied + or (case_insensitive and full.lower() in folded) + or _has_path_conflict(full, seen, parents) + ): + continue + _register_path(full, seen, parents) + folded.add(full.lower()) + occupied[full] = local + if local is not None: + shipped[full] = local + return shipped, occupied + + def _dest_key(file_entry: dict, sys_id: str) -> str: """The path a declaration occupies, as the builder keys its pack.""" dest = sanitize_pack_path(file_entry.get("destination", file_entry.get("name", ""))) @@ -1111,7 +1145,10 @@ def verify_platform( verify_systems, db, base_dest, zip_contents ) - shipped: dict[str, str] = {} + # (system, full destination, local copy) of each declaration the builder + # writes, and None for one whose slot it keeps without a file from the + # collection (user_provided, external). Placed below in the builder's order. + packed_order: list[tuple[str, str, str | None]] = [] shipped_keys: set[str] = set() results_at: dict[str, list[dict]] = {} for sys_id, system in verify_systems.items(): @@ -1135,6 +1172,12 @@ def verify_platform( if stored is not None: result = stored local_path = None + # The builder keeps the slot: instructions for a user_provided + # file, the download for an external one, whether it arrives + # or not. No core file goes there. + packed_order.append(( + sys_id, f"{base_dest}/{destination}" if base_dest else destination, None, + )) elif not reads_file_contents(mode): result = verify_entry_existence( file_entry, @@ -1179,10 +1222,11 @@ def verify_platform( file_entry, local_path, resolve_status, mode, config.get("hash_rule", "") )[1] is None ): - shipped.setdefault( + packed_order.append(( + sys_id, f"{base_dest}/{destination}" if base_dest else destination, local_path, - ) + )) shipped_keys.add(_dest_key(file_entry, sys_id)) if _twin_unmet(result, held_by): continue @@ -1212,6 +1256,10 @@ def verify_platform( ): file_severity[dest] = sev + shipped, occupied = _builder_placement( + packed_order, bool(config.get("case_insensitive_fs", False)) + ) + # Cross-reference undeclared files, called exactly as the builder calls # it: its declarations, not the enriched ones (a core file the platform # also declares under another name is packed at the core's own path, 21 @@ -1243,7 +1291,7 @@ def verify_platform( placed_by: dict[str, tuple[str, str, str]] = {} placed = _mark_hidden( - config, undeclared, emulators_dir, db, emu_profiles, shipped, target_cores, + config, undeclared, emulators_dir, db, emu_profiles, occupied, target_cores, region_drops, report_for_builder, ExtraResolver( db, DEFAULT_BIOS_DIR, zip_contents, data_dir_registry, validation_index, @@ -1323,10 +1371,23 @@ def verify_platform( ) total = len(file_status) + # The paths a pack built from this report holds, as written in the ZIP + # (base_destination stripped, as the builder flattens it). The pipeline + # compares this set with the archive: equal counts can hide one file + # credited here and another written there. + from packpaths import _flat + + pack_destinations = sorted( + {_flat(dest, base_dest, True) for dest in shipped} | { + _flat(dest, base_dest, True) for dest in placed + } + ) + return { "platform": platform, "verification_mode": mode, "total_files": total, + "pack_destinations": pack_destinations, "severity_counts": counts, "status_counts": status_counts, "excluded_files": excluded, @@ -2091,6 +2152,11 @@ def verify_emulator( "emulators": [n for n, _ in selected], "verification_mode": label, "total_files": total, + # The paths the emulator pack fills with the profile's files, for the + # pipeline's comparison with the pack built in memory. + "pack_destinations": sorted( + dest for dest, status in file_status.items() if status == Status.OK + ), "severity_counts": counts, "status_counts": status_counts, "details": details, @@ -2271,6 +2337,7 @@ def _refuse_listing_narrowings( ("--json", args.json), ("--strict", args.strict), ("--verbose", args.verbose), + ("--destinations", args.destinations), ): if on: parser.error(f"{flag} is incompatible with {listing}") @@ -2286,6 +2353,10 @@ def _refuse_mode_flags(args: argparse.Namespace, parser: argparse.ArgumentParser "--target requires --platform or --all"), (args.target and (args.emulator or args.system), "--target is incompatible with --emulator and --system"), + (args.destinations and not (args.platform or args.all), + "--destinations requires --platform or --all"), + (args.destinations and getattr(args, "region", None), + "--destinations describes the full pack and is incompatible with --region"), ): if refused: parser.error(message) @@ -2363,6 +2434,11 @@ def _main(held: contextlib.ExitStack) -> None: help="Show emulator ground truth details", ) parser.add_argument("--json", action="store_true", help="JSON output") + parser.add_argument( + "--destinations", metavar="FILE", + help="With --platform or --all, also write the paths each pack holds " + "according to this report to FILE (JSON), for comparison with the archive", + ) parser.add_argument( "--strict", action="store_true", @@ -2494,6 +2570,15 @@ def _main(held: contextlib.ExitStack) -> None: if len(group_results) > 1: print(_overall_summary(group_results)) + if args.destinations: + held = {} + for (result, names), (group, _rep) in zip(group_results, groups): + for name, key in zip(names, group): + held[name] = { + "platform": key, "pack_destinations": result["pack_destinations"], + } + Path(args.destinations).write_text(json.dumps(held, indent=1, sort_keys=True)) + if args.json: for r in all_results.values(): r["details"] = _json_details(r["details"], args.verbose) diff --git a/tests/test_pack_destinations.py b/tests/test_pack_destinations.py new file mode 100644 index 00000000..98402346 --- /dev/null +++ b/tests/test_pack_destinations.py @@ -0,0 +1,181 @@ +"""verify and the pack name the same paths, not only the same number of them. + +The pipeline compared counts. RetroArch declares SGB1.sfc and, below it, +SGB1.sfc/program.rom: the pack writes the file and skips the path it blocks, +verify credited both, and both sides still read 526/526. A user_provided +declaration keeps its slot in the pack, while verify let a core file take +it and counted one more file from the cores. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import os +import shutil +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + + +class _Fixture(unittest.TestCase): + def setUp(self) -> None: + from common import _emulator_profiles_cache + + _emulator_profiles_cache.clear() + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, self.root, True) + self.bios = self.root / "bios" + self.platforms = self.root / "platforms" + self.emulators = self.root / "emulators" + for directory in (self.bios, self.platforms, self.emulators): + directory.mkdir() + files, by_name, by_md5 = {}, {}, {} + for name, data in (("sgb.sfc", b"SGB" * 8), ("program.rom", b"PRG" * 8), + ("core.bin", b"CORE" * 8)): + path = self.bios / name + path.write_bytes(data) + sha1, md5 = hashlib.sha1(data).hexdigest(), hashlib.md5(data).hexdigest() + files[sha1] = {"path": str(path), "name": name, "md5": md5, "size": len(data)} + by_name[name] = [sha1] + by_md5[md5] = sha1 + self.db = {"files": files, "indexes": {"by_name": by_name, "by_md5": by_md5, + "by_crc32": {}, "by_path_suffix": {}}} + + def _platform(self, files: list[dict], cores: list[str] | None = None) -> dict: + config = {"platform": "Paths", "verification_mode": "existence", + "base_destination": "system", "systems": {"sys": {"files": files}}} + if cores is not None: + config["cores"] = cores + (self.platforms / "paths.yml").write_text(yaml.safe_dump(config)) + return config + + def _both(self) -> tuple[dict, set[str]]: + from common import load_emulator_profiles, load_platform_config + from generate_pack import generate_pack + from verify import verify_platform + + profiles = load_emulator_profiles(str(self.emulators)) + report = verify_platform( + load_platform_config("paths", str(self.platforms)), self.db, + str(self.emulators), profiles, + ) + with contextlib.redirect_stdout(io.StringIO()): + zip_path = generate_pack( + "paths", str(self.platforms), self.db, str(self.bios), + str(self.root / "out"), emulators_dir=str(self.emulators), + emu_profiles=profiles, offline=True, + ) + with zipfile.ZipFile(zip_path) as zf: + members = {n for n in zf.namelist() if n != "README.txt"} + return report, members + + +class APathBlockedByAFile(_Fixture): + def test_verify_credits_only_the_file_the_pack_writes(self): + self._platform([ + {"name": "sgb.sfc", "destination": "SGB1.sfc"}, + {"name": "program.rom", "destination": "SGB1.sfc/program.rom"}, + ]) + report, members = self._both() + self.assertEqual(members, {"SGB1.sfc"}) + self.assertEqual(set(report["pack_destinations"]), members) + + +class AUserProvidedSlot(_Fixture): + def test_no_core_file_takes_it_on_either_side(self): + (self.emulators / "coreemu.yml").write_text(yaml.safe_dump({ + "emulator": "CoreEmu", "type": "libretro", "systems": ["sys"], + "files": [{"name": "core.bin", "required": True}], + })) + self._platform( + [{"name": "manual.bin", "destination": "core.bin", "storage": "user_provided"}], + cores=["coreemu"], + ) + report, members = self._both() + self.assertEqual(members, {"INSTRUCTIONS_manual.bin.txt"}) + self.assertEqual(report["packed_extras"], {"bios": 0, "game_data": 0}) + self.assertEqual(report["pack_destinations"], []) + + +class ThePipelineComparesPaths(unittest.TestCase): + """Equal counts, other paths: the comparison fails and names both sides.""" + + def test_a_swap_at_equal_counts_fails(self): + import pipeline + + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + zip_path = root / "Plat_BIOS_Pack.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for name in ("a.bin", "b.bin", "README.txt", "INSTRUCTIONS_x.bin.txt"): + zf.writestr(name, name) + output = f"Generating pack for plat [source=full]...\n {zip_path}: 2 files packed\n" + for credited, expected in ((["a.bin", "b.bin"], True), (["a.bin", "c.bin"], False)): + with self.subTest(credited=credited): + destinations = root / "d.json" + destinations.write_text(json.dumps( + {"Plat": {"platform": "plat", "pack_destinations": credited}} + )) + previous = os.getcwd() + os.chdir(REPO_ROOT) + try: + with mock_tree_members(), contextlib.redirect_stdout(io.StringIO()) as out: + ok = pipeline.check_pack_destinations(str(destinations), output) + finally: + os.chdir(previous) + self.assertIs(ok, expected, out.getvalue()) + if not expected: + self.assertIn("verify only 1: c.bin", out.getvalue()) + self.assertIn("pack only 1: b.bin", out.getvalue()) + + def test_the_run_compares_every_emulator_pack(self): + source = (REPO_ROOT / "scripts" / "pipeline.py").read_text(encoding="utf-8") + self.assertIn("ok = check_pack_destinations(destinations_file, pack_output) and ok", source) + self.assertIn("ok = check_emulator_packs() and ok", source) + self.assertIn('"--destinations", destinations_file', source) + + +@contextlib.contextmanager +def mock_tree_members(): + import pipeline + + original = pipeline._tree_members + pipeline._tree_members = lambda *_args: {} + try: + yield + finally: + pipeline._tree_members = original + + +class EveryEmulatorPackMatchesItsReport(unittest.TestCase): + """Reads the repository: every profile, both builds, built in memory.""" + + def test_the_paths_agree(self): + if not (REPO_ROOT / "database.json").is_file(): + self.skipTest("database.json is not built") + import pipeline + + previous = os.getcwd() + os.chdir(REPO_ROOT) + try: + with contextlib.redirect_stdout(io.StringIO()) as out: + ok = pipeline.check_emulator_packs() + finally: + os.chdir(previous) + self.assertTrue(ok, out.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index b74b5379..ca45af2c 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -494,7 +494,7 @@ This executes in sequence: 2. `refresh_data_dirs.py` - update data directories (skipped with `--offline`) 3. `verify.py --all` - verify all platforms including the new one 4. `generate_pack.py --all` - build ZIP packs + install and target manifests -5. Consistency check - verify counts match between verify and pack +5. Consistency check: verify and the pack agree on counts and on the paths each archive holds 6. Pack integrity - extract ZIPs and verify hashes per platform mode 7. `generate_readme.py` - regenerate README and CONTRIBUTING 8. `generate_site.py` - regenerate documentation site and `mkdocs.yml` @@ -511,7 +511,7 @@ Check the output for: - Pack generation succeeds and includes the expected files - Consistency check passes (verify file counts match pack file counts) -Verification is not optional. A platform that passes `pipeline.py` today may break tomorrow if upstream changes its data format. Run the full pipeline on every change, even if the modification seems trivial. The consistency check (verify counts must match pack counts) catches subtle issues where files resolve during verification but fail during pack generation, or vice versa. +Verification is not optional. A platform that passes `pipeline.py` today may break tomorrow if upstream changes its data format. Run the full pipeline on every change, even if the modification seems trivial. The consistency check (verify and the pack must agree on counts and paths) catches subtle issues where files resolve during verification but fail during pack generation, or vice versa. ## Checklist diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index bf4c3c1d..92985610 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -40,7 +40,7 @@ Every module in `tests/`, by the area it guards. | Core regression suites | synthetic | `test_e2e`, `test_audit_regressions` | | File resolution and identity | synthetic + collection | `test_adler32_resolution`, `test_agnostic_scan`, `test_agnostic_scan_mode`, `test_agnostic_scope`, `test_agnostic_substitute`, `test_archive_choice`, `test_bios_companions`, `test_bundled_identity`, `test_casefold_cache`, `test_db_aliases`, `test_declared_names`, `test_derivations`, `test_directory_entries`, `test_extra_identity`, `test_foreign_tree`, `test_foreign_tree_names`, `test_frontend_digest`, `test_game_data_homonyms`, `test_hashing`, `test_identity`, `test_md5_pack_context`, `test_name_tiebreak`, `test_nec_filing`, `test_owner_resolution`, `test_path_suffix`, `test_resolution_evidence`, `test_shipped_content`, `test_twin_declarations`, `test_validated_choice`, `test_variant_choice`, `test_variant_identity`, `test_zipped_member` | | Verification, cross-reference and profile checks | synthetic + profiles | `test_crypto_aes`, `test_crypto_unique_data`, `test_crypto_verify`, `test_case_insensitive_platforms`, `test_checks_have_values`, `test_core_file_fills_declaration`, `test_cross_reference_sources`, `test_file_consumers`, `test_fileless_audit`, `test_gap_settlement`, `test_hidden_extras`, `test_launcher_layer`, `test_native_mode`, `test_no_case_collisions`, `test_path_conflict_status`, `test_path_null_copies`, `test_profile_checks`, `test_romm_hash_rule`, `test_shared_groups`, `test_standalone_mode`, `test_undeclared_dedup`, `test_unplaced_extras`, `test_validation_scope`, `test_verify_all_dir`, `test_verify_emulator_evidence`, `test_verify_listing`, `test_verify_pack_choice` | -| Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_pack_conformance_dirs`, `test_pack_counts`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_required_only_archives`, `test_required_only_twins`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` | +| Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_pack_conformance_dirs`, `test_pack_counts`, `test_pack_destinations`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_required_only_archives`, `test_required_only_twins`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` | | Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` | | Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` | | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_selection` | diff --git a/wiki/tools.md b/wiki/tools.md index 8164c5d4..994af51b 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -42,7 +42,7 @@ Pipeline steps: | 4b | Generate install manifests | `--skip-packs` | | 4c | Generate target manifests | `--skip-packs` | | 4d | Collected copies only an emulator install provides (`bundled_only.py --strict`, gates the run) | - | -| 5/8 | Consistency check | if verify or pack skipped | +| 5/8 | Consistency check: counts, paths per archive, every emulator pack | if verify or pack skipped | | 6/8 | Pack integrity (extract + hash) | `--skip-packs` | | 7/8 | Generate README | `--skip-docs` | | 8/8 | Generate site | `--skip-docs` | @@ -78,9 +78,12 @@ alone, run `python scripts/check_buildbot_system.py`. and manifest names. `--manifest --target switch` once overwrote the manifest every installer downloads with a narrowed one. - **A step that did not run says SKIPPED**, never OK. -- **verify.py and generate_pack.py read the same functions.** A count that - differs between the report and the pack is a defect, which is what step 5 - checks. +- **verify.py and generate_pack.py read the same functions.** A count or a + path that differs between the report and the pack is a defect, which is + what step 5 checks: the counts, the paths each archive holds against the + ones verify credits (`--destinations`), and every emulator pack, each + profile in each build, built in memory against `verify --emulator`. + Equal counts once hid four RetroArch paths credited and never written. - **Generators write through `write_if_changed()`.** A new timestamp alone rewrites nothing, so a diff on a generated file always means content. - **One run per output directory.** Pack generation takes a lock on @@ -118,6 +121,7 @@ python scripts/verify.py --emulator handy --strict # exit 1 on a required file python scripts/verify.py --system atari-lynx # single system python scripts/verify.py --platform retroarch --target switch # filter by hardware python scripts/verify.py --platform recalbox --region us # regional priority list +python scripts/verify.py --all --destinations tmp/paths.json # also write the paths each pack holds python scripts/verify.py --list-emulators # list all emulators python scripts/verify.py --list-systems # list all systems python scripts/verify.py --platform retroarch --list-targets # list available targets From 1c2360926b348073b33a1b04b2844c67d268642a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:19:51 +0200 Subject: [PATCH 03/13] fix: a refused regional file wins no slot emulator_region_drops counted as held any file that resolved. A regional dump whose md5 the core's own check refuses won its region, the fallback was withdrawn, and the emulator pack then refused the winner: no BIOS for the system, and verify --emulator, reading the same drops, did not see it. Held now means what the builder ships, as _ships decides on the platform side. --- scripts/packextras.py | 31 ++++++++++++++++++++--- tests/test_region.py | 58 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/scripts/packextras.py b/scripts/packextras.py index 544958f4..739be271 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -1296,11 +1296,12 @@ def emulator_region_drops( """ import region as region_mod from packpaths import _resolve_destination - from validation import filter_files_by_mode + from validation import _build_validation_index, filter_files_by_mode region_index = region_mod.build_region_index(dict(selected)) region_groups: dict[str, list[tuple[str, str]]] = {} held: set[str] | None = set() if db is not None else None + validation_index = _build_validation_index(dict(selected)) if db is not None else {} for emu_name, profile in sorted(selected): # Decided over what the pack keeps, as on the platform side: an # optional winner removed afterwards left its slot empty. @@ -1312,13 +1313,35 @@ def emulator_region_drops( region_groups.setdefault(group_id, []).append( (dest, fe.get("name", "")) ) - if held is not None and resolve_local_file( - dict(fe, source_profile=emu_name), db, dest_hint=dest - )[0]: + if held is not None and _emulator_ships( + fe, emu_name, profile, db, dest, validation_index + ): held.add(dest) return region_mod.resolve_region_drops(region_groups, region_index, regions, held) +def _emulator_ships( + file_entry: dict, emu_name: str, profile: dict, db: dict, dest: str, + validation_index: dict, +) -> bool: + """Whether the emulator pack ships this entry: held, and not refused by + the core's own check, the test _ships applies on the platform side. + """ + from validation import validated_choice, with_archive_members + + local, _status = resolve_local_file( + {**with_archive_members(file_entry, profile.get("files", [])), + "source_profile": emu_name}, + db, dest_hint=dest, + ) + if not local: + return False + _chosen, disagreement = validated_choice( + file_entry, local, db, validation_index, "bios", None, emulators={emu_name}, + ) + return disagreement is None + + def _emulator_region_group( emu_name: str, profile: dict, file_entry: dict, dest: str ) -> str: diff --git a/tests/test_region.py b/tests/test_region.py index cf0771ff..cd3f7941 100644 --- a/tests/test_region.py +++ b/tests/test_region.py @@ -1113,5 +1113,63 @@ class NoMarketStopsAtAustralia(unittest.TestCase): self.assertEqual(tagged, []) +class ARefusedRegionalFileHoldsNoSlot(unittest.TestCase): + """rg_us.bin is held but its md5 contradicts the one the core checks, so + the emulator pack refuses it. Counted held, it won the region and the + Japanese BIOS was withdrawn: --emulator --region us shipped no BIOS for + the system, and verify, reading the same drops, did not see it.""" + + def test_the_fallback_stays_in_pack_and_report(self): + import hashlib + import shutil + import tempfile + import zipfile + + import yaml + + from generate_pack import generate_emulator_pack + from verify import verify_emulator + + common._emulator_profiles_cache.clear() + repo = Path(__file__).resolve().parents[1] + (repo / "tmp").mkdir(exist_ok=True) + root = Path(tempfile.mkdtemp(dir=repo / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + previous = os.getcwd() + os.chdir(root) + self.addCleanup(os.chdir, previous) + files, by_name = {}, {} + for name, data in (("rg_us.bin", b"NOT THE US DUMP"), ("rg_jp.bin", b"JP DUMP")): + path = Path("bios") / name + path.parent.mkdir(exist_ok=True) + path.write_bytes(data) + sha1 = hashlib.sha1(data).hexdigest() + files[sha1] = {"path": str(path), "name": name, "size": len(data), + "md5": hashlib.md5(data).hexdigest()} + by_name[name] = [sha1] + db = {"files": files, "indexes": {"by_name": by_name, "by_md5": {}, + "by_crc32": {}, "by_path_suffix": {}}} + Path("emulators").mkdir() + Path("emulators/rgemu.yml").write_text(yaml.safe_dump({ + "emulator": "RgEmu", "type": "libretro", "systems": ["sys"], + "files": [ + {"name": "rg_us.bin", "region": ["north-america"], "required": True, + "md5": hashlib.md5(b"THE US DUMP").hexdigest(), "validation": ["md5"]}, + {"name": "rg_jp.bin", "region": ["japan"], "required": True}, + ], + })) + with contextlib.redirect_stdout(io.StringIO()): + zip_path = generate_emulator_pack( + ["rgemu"], "emulators", db, "bios", "out", regions=["north-america"], + offline=True, platforms_dir="platforms", + ) + with zipfile.ZipFile(zip_path) as zf: + self.assertIn("rg_jp.bin", zf.namelist()) + report = verify_emulator( + ["rgemu"], "emulators", db, regions=["north-america"], platforms_dir="platforms", + ) + self.assertIn("rg_jp.bin", report["pack_destinations"]) + + if __name__ == "__main__": unittest.main() From 8eda797562fe49c0749b3d991270fa332d34ed32 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:24:19 +0200 Subject: [PATCH 04/13] fix: a hashless entry is a path in any mode Whether the frontend compares content was decided per platform. EmuDeck (md5) declares neocdz.zip with no hash and checkBIOS.sh never names it, yet the proven claims of geolith and FBNeo were dropped and the pack shipped a neocdz.zip without 000-lo.lo. checked_by_path() decides per entry: no hash of the kind the frontend compares (any of md5, sha1, crc32 under RomM's rule) and no zipped_file means a path. Measured: the EmuDeck neocdz.zip slot and three RetroBat DS slots (dsi_bios7.bin, dsi_bios9.bin, firmware.bin) now take the file the cores prove. --- scripts/slots.py | 50 ++++++++++++++++++++++++++++++++++++--------- tests/test_slots.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/scripts/slots.py b/scripts/slots.py index 68a464fd..7b27857f 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -329,6 +329,29 @@ def _archive_claims( return claims +def checked_by_path(mode: str, entry: dict, rule: str = "") -> bool: + """Whether the frontend checks this declaration by its path alone. + + Whether a frontend reads the bytes is a property of the platform, but + what it compares them with is the entry's: Batocera skips an empty md5 + (batocera-systems:1088), and EmuDeck declares neocdz.zip with no hash at + all. Such an entry is a path, in an md5 platform as in an existence one. + A member pinned inside an archive is content. + """ + if not nativemode.reads_file_contents(mode): + return True + if entry.get("zipped_file"): + return False + if rule == nativemode.SIZE_AND_ANY_HASH: + fields: tuple[str, ...] = ("md5", "sha1", "crc32") + else: + fields = (nativemode.digest_algorithm(mode) or "",) + return not any( + parse_md5_list(entry.get(name)) if name == "md5" else entry.get(name) + for name in fields + ) + + def _claim_rank(claim: Claim) -> int: """How strongly a platform claim speaks for what the pack ships.""" if resolution_is_hash_exact(claim.status): @@ -382,10 +405,10 @@ def _slot_claims( # matches, asserts nothing about content. Where the frontend never reads # the bytes, a proven profile claim then decides: RetroArch's neocdz.zip # pins an md5 nobody holds, and the primary shipped without the 000-lo.lo - # geolith and FBNeo read inside it. - path_only = not nativemode.reads_file_contents( - config.get("verification_mode", "existence") - ) + # geolith and FBNeo read inside it. Decided per entry: EmuDeck, an md5 + # platform, declares the same archive with no hash. + mode = config.get("verification_mode", "existence") + rule = config.get("hash_rule", "") by_slot: dict[str, list[Claim]] = {} for claim in profile_claims( profiles, db, base_dest, standalone_cores, zip_contents, data_dir_registry, @@ -401,7 +424,9 @@ def _slot_claims( ): # A size proves a claim only against a file of another size. continue - if not platform.is_proven and not (path_only and platform.local_path): + if not platform.is_proven and not ( + platform.local_path and checked_by_path(mode, platform.entry, rule) + ): continue by_slot.setdefault(key, []).append(claim) @@ -518,8 +543,8 @@ def slot_choices( recorded whole in the install manifest, so a target narrowed from the full manifest gets the bytes the target's own pack carries. """ - if nativemode.reads_file_contents(config.get("verification_mode", "existence")): - return {} + mode = config.get("verification_mode", "existence") + rule = config.get("hash_rule", "") platform_profiles = { name: profiles[name] for name in resolve_platform_cores(config, profiles) } @@ -535,6 +560,9 @@ def slot_choices( choices: dict[str, list[tuple[str, str | None]]] = {} for key, claims in sorted(by_slot.items()): platform = by_dest[key] + # Where the frontend compares a hash, the platform's file stays. + if not checked_by_path(mode, platform.entry, rule): + continue agree = [_satisfies(c, platform, db) for c in claims] agreeing = [c for c, ok in zip(claims, agree) if ok] others = sorted( @@ -573,7 +601,9 @@ def _strongest(claims: list[Claim]) -> Claim: return max(claims, key=lambda claim: (_claim_rank(claim), -claims.index(claim))) -def arbitrate(conflict: Conflict, mode: str, addressee: str = "platform") -> Decision: +def arbitrate( + conflict: Conflict, mode: str, addressee: str = "platform", rule: str = "", +) -> Decision: """Decide a contested destination for the pack being built. A pack answers to whoever asked for it. A platform pack must leave the @@ -590,7 +620,7 @@ def arbitrate(conflict: Conflict, mode: str, addressee: str = "platform") -> Dec profile = _strongest(conflict.profile_claims) if addressee == "emulator": return Decision(conflict, profile, ADDRESSEE) - if not nativemode.reads_file_contents(mode): + if checked_by_path(mode, conflict.platform_claim.entry, rule): return Decision(conflict, profile, SERVES_BOTH) return Decision(conflict, conflict.platform_claim, FRONTEND_CHECKS_CONTENT) @@ -977,7 +1007,7 @@ def main() -> int: mode = config.get("verification_mode", "existence") print(f"{platform}: {len(conflicts)} contradicted [{mode}]") for conflict in conflicts: - decision = arbitrate(conflict, mode) + decision = arbitrate(conflict, mode, rule=config.get("hash_rule", "")) fixable += decision.serves_both print(f" {format_decision(decision)}") total = sum(len(c) for c in found.values()) diff --git a/tests/test_slots.py b/tests/test_slots.py index 9604a254..825ee5aa 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -349,6 +349,7 @@ class TestArbitration(unittest.TestCase): def _conflict(self): platform = slots.Claim( "platform", "GC/JAP/IPL.bin", "IPL.bin", + entry={"md5": "m" * 32, "sha1": "a" * 40}, local_path="bios/Console/GC/USA/IPL.bin", status="md5_exact", ) profile = slots.Claim( @@ -376,6 +377,40 @@ class TestArbitration(unittest.TestCase): self.assertEqual(decision.winner.origin, "profile") self.assertFalse(decision.serves_both) + def test_a_hashless_entry_is_a_path_in_an_md5_platform(self): + """EmuDeck declares neocdz.zip with no md5 and checkBIOS.sh never + names it: the frontend checks nothing there, so the core's file + serves both.""" + conflict = self._conflict() + conflict.platform_claim.entry = {"name": "IPL.bin"} + for mode in ("md5", "sha1"): + with self.subTest(mode=mode): + self.assertTrue(slots.arbitrate(conflict, mode).serves_both) + # RomM compares size then any hash: a hash of another kind is content. + conflict.platform_claim.entry = {"name": "IPL.bin", "sha1": "a" * 40, "size": 3} + self.assertFalse( + slots.arbitrate(conflict, "md5", rule="size_and_any_hash").serves_both + ) + conflict.platform_claim.entry = {"name": "x.zip", "zipped_file": "IPL.bin"} + self.assertFalse(slots.arbitrate(conflict, "md5").serves_both) + + def test_the_pack_serves_the_proven_file_at_a_hashless_md5_entry(self): + config = { + "verification_mode": "md5", + "cores": ["dolphin"], + "systems": {"console": {"files": [ + {"name": "IPL.bin", "destination": "IPL.bin"}, + {"name": "IPL.bin", "destination": "hashed/IPL.bin", "md5": "m" * 32}, + ]}}, + } + profiles = {"dolphin": {"type": "libretro", "systems": ["console"], "files": [ + {"name": "IPL.bin", "md5": "n" * 32}, + {"name": "IPL.bin", "path": "hashed/IPL.bin", "md5": "n" * 32}, + ]}} + overrides = slots.pack_overrides(config, profiles, REGIONS_DB) + # The hashed declaration keeps the file the frontend checks. + self.assertEqual(overrides, {"IPL.bin": "bios/Console/GC/JAP/IPL.bin"}) + def test_the_reported_line_gives_the_ground_for_the_decision(self): kept = slots.format_decision(slots.arbitrate(self._conflict(), "md5")) self.assertIn("verifies content", kept) From 5e955741432dee1f11784900c561ce4ab7feb8b9 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:28:10 +0200 Subject: [PATCH 05/13] fix: the strongest proof takes a shared core path A destination no platform declares went to the first claimant in profile order whatever the proof: fbalpha2012 asks for hiscore.dat by name and took the root of six packs over cores that pin it by sha1. Claims on a contested path are now ranked by what the collection holds for each (a held declared hash, then a path or every required archive member, then a name, then a contradicted hash), and profile order only breaks ties. Measured: 14 slots change file across the platforms, among them hiscore.dat, kick13.rom on RetroPie, cgb_boot.bin and dmg_boot.bin on EmuDeck, and ROCKNIX neogeo.zip and neocdz.zip. --- scripts/packextras.py | 90 +++++++++++++++++++++++++++++++++++++------ tests/test_slots.py | 25 ++++++++++++ 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/scripts/packextras.py b/scripts/packextras.py index 739be271..6a2b2d02 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -9,7 +9,7 @@ from __future__ import annotations from collections.abc import Iterable from common import PROFILE_IDENTITY_FIELDS, _norm_system_id -from common import archive_members +from common import archive_members, holds_required_members from common import declared_root, expand_platform_declared_names, platform_declarations from common import load_emulator_profiles from common import resolution_is_hash_exact @@ -863,11 +863,16 @@ def _collect_emulator_extras( config, emulators_dir, db, emu_profiles, target_cores=target_cores, include_all=include_all, declared_names=yaml_names, ) - # A destination two cores claim goes to the one that names its content: + # A destination two cores claim goes to the strongest proof of what the + # collection holds for it, then to the one that names its content: # hypseus-singe asks for gamecontrollerdb.txt by name alone and took the - # BIOS root before Solarus, which pins its own by sha1. Profile order - # still decides between claims of the same kind. - undeclared = sorted(undeclared, key=lambda u: not _names_content(u)) + # BIOS root before Solarus, which pins its own by sha1, and fbalpha2012's + # hashless hiscore.dat took RetroDECK's over mame2015's sha1. Profile + # order decides only between claims of equal proof. + rank = _ClaimRanks(undeclared, placement, db) + undeclared = sorted( + undeclared, key=lambda u: (-rank.of_entry(u), not _names_content(u)) + ) extras = [] seen_dests: set[str] = set(seen) # Destination -> the extra that took it, to record later claimants on. @@ -906,12 +911,18 @@ def _collect_emulator_extras( claimants[(u.get("emulator", ""), u.get("name", ""), u.get("path") or "")] = ( sanitize_pack_path(dest) ) - extra = _report_extra(u, name, dest) - claims = [_report_extra(c, name, dest) for c in u.get("other_claims", [])] - # Where the builder meets each claim: the report lists the profiles in - # order, archives after the loose files. - for item in (extra, *claims): - item["claim_order"] = (1, 1 if u.get("archive") else 0, item["source_profile"]) + candidates = [ + _report_extra(c, name, dest) for c in (u, *u.get("other_claims", [])) + ] + # Where the builder meets each claim: the strongest proof first, the + # report's profile order between equals, archives after loose files. + for item in candidates: + item["claim_order"] = ( + 1, 1 if u.get("archive") else 0, -rank.of_extra(item, full_dest), + item["source_profile"], + ) + candidates.sort(key=lambda item: item["claim_order"]) + extra, claims = candidates[0], candidates[1:] if not _take(extra, full_dest, seen_dests, taken): for claim in claims: _another_claim(taken.get(full_dest), claim) @@ -1036,6 +1047,63 @@ def _collect_emulator_extras( return extras +class _ClaimRanks: + """How strongly each claim on a contested destination proves its file. + + 2 a declared hash the collection holds, 1 a path or every member the + core requires inside an archive, 0 a name, -1 a copy + contradicting the hash the claim declares, -2 nothing held. Resolved only + where two claims meet: a destination one core asks for has no contest. + """ + + def __init__(self, undeclared: list[dict], placement: ExtraPlacement, db: dict) -> None: + self._db = db + self._placed: dict[int, tuple[str, str] | None] = {} + counts: dict[str, int] = {} + for u in undeclared: + placed = ( + placement.place(u, _extra_dest(u)) + if u.get("in_repo") and not u.get("bundled") else None + ) + self._placed[id(u)] = placed + if placed is not None: + counts[placed[1]] = counts.get(placed[1], 0) + 1 + len( + u.get("other_claims", []) + ) + self._contested = {dest for dest, n in counts.items() if n > 1} + self._cache: dict[tuple, int] = {} + + def of_extra(self, extra: dict, full_dest: str) -> int: + if full_dest not in self._contested: + return 0 + key = (extra.get("source_profile"), extra.get("source_name"), + extra.get("source_path"), full_dest) + if key not in self._cache: + path, status = resolve_local_file( + extra, self._db, dest_hint=extra.get("destination", "") + ) + self._cache[key] = ( + -2 if not path + else -1 if status == "hash_mismatch" + else 2 if resolution_is_hash_exact(status) + else 1 if status == "path_exact" or ( + extra.get("members") and holds_required_members(path, extra["members"]) + ) + else 0 + ) + return self._cache[key] + + def of_entry(self, u: dict) -> int: + placed = self._placed.get(id(u)) + if placed is None or placed[1] not in self._contested: + return 0 + name = u.get("archive") or u["name"] + return max( + self.of_extra(_report_extra(c, name, placed[0]), placed[1]) + for c in (u, *u.get("other_claims", [])) + ) + + def _names_content(entry: dict) -> bool: """Whether a report entry declares the bytes it wants, not only a name.""" return any(entry.get(field) for field in ("sha1", "md5", "sha256", "crc32")) diff --git a/tests/test_slots.py b/tests/test_slots.py index 825ee5aa..d5444995 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -418,6 +418,31 @@ class TestArbitration(unittest.TestCase): self.assertIn("both are satisfied", served) +class TheStrongestProofTakesAnUndeclaredPath(unittest.TestCase): + """No platform declares RetroDECK's hiscore.dat. fbalpha2012 asks for it + by name, mame2015 pins it by sha1: the first in profile order took the + path, whatever the proof, and mame2015 received another revision.""" + + def test_a_held_hash_beats_a_bare_name_whatever_the_order(self): + from packextras import _collect_emulator_extras + + config = { + "platform": "P", "verification_mode": "existence", + "cores": ["aaa", "zzz"], + "systems": {"console": {"files": [{"name": "other.bin", "destination": "other.bin"}]}}, + } + profiles = { + "aaa": {"emulator": "Aaa", "type": "libretro", "systems": ["console"], + "files": [{"name": "IPL.bin", "required": True}]}, + "zzz": {"emulator": "Zzz", "type": "libretro", "systems": ["console"], + "files": [{"name": "IPL.bin", "required": True, "md5": "n" * 32}]}, + } + extras = _collect_emulator_extras(config, "", REGIONS_DB, set(), "", profiles) + winner = next(e for e in extras if e["destination"] == "IPL.bin") + self.assertEqual(winner["source_profile"], "zzz") + self.assertEqual({c["source_profile"] for c in winner["other_claims"]}, {"aaa"}) + + class TestBuilderAndVerifierAgree(unittest.TestCase): """The two tools must settle a contested destination the same way.""" From 0dab8f47068ad4a1e3956f1d26c5f792b3563539 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:29:42 +0200 Subject: [PATCH 06/13] fix: credit file readers through the data trees FileConsumers resolved profile entries without the data directory registry the builder uses. Dolphin's hashless codehandler.bin then took Dolphin-MMJR's copy by name, and the site listed Dolphin and Triforce as its readers while every pack serves data/dolphin-sys. blueMSX's RS232.ROM and MSX2P.rom were credited to bios/ copies the same way. The registry is now read by default, as the builder reads it. With it, bundled_only.py --strict (pipeline step 4d) names bios/Other/dolphin-mmjr/codehandler.bin: only dolphin-mmjr's bundled entry, which already carries its sha1 and size, reaches that copy. --- scripts/cross_reference.py | 18 ++++++++++++++++-- tests/test_file_consumers.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index 5ba4cc83..171ea85d 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -30,6 +30,7 @@ from common import ( get_mame_clone_map, list_emulator_profiles, list_registered_platforms, + load_data_dir_registry, load_database, load_emulator_profiles, load_platform_config, @@ -325,9 +326,21 @@ class FileConsumers: file by the evidence the pack uses, so a homonym is nobody's file. """ - def __init__(self, db: dict, zip_contents: dict | None = None) -> None: + def __init__( + self, db: dict, zip_contents: dict | None = None, + data_dir_registry: dict | None = None, + ) -> None: self._db = db self._zip_contents = zip_contents + # The builder resolves with the data trees: Dolphin's codehandler.bin + # comes from data/dolphin-sys, and without them the name step gave it + # Dolphin-MMJR's copy, which the site then said Dolphin reads. + self._data_dir_registry = ( + data_dir_registry if data_dir_registry is not None + else load_data_dir_registry( + str(Path(__file__).resolve().parent.parent / DEFAULT_PLATFORMS_DIR) + ) + ) self._sha1_by_path = { path: sha1 for sha1, entry in db.get("files", {}).items() @@ -357,7 +370,8 @@ class FileConsumers: if owner: entry = {**entry, "source_profile": owner} path, status = resolve_local_file( - entry, self._db, self._zip_contents, dest_hint=dest + entry, self._db, self._zip_contents, dest_hint=dest, + data_dir_registry=self._data_dir_registry, ) if not path or status in ("not_found", "hash_mismatch"): return None diff --git a/tests/test_file_consumers.py b/tests/test_file_consumers.py index 3ef7a56c..94b23f13 100644 --- a/tests/test_file_consumers.py +++ b/tests/test_file_consumers.py @@ -83,6 +83,40 @@ class ReadersResolve(unittest.TestCase): self.assertNotIn("ioquake3", et) +class ReadersFollowTheDataTrees(unittest.TestCase): + """Dolphin names codehandler.bin without a hash. The pack takes it from + data/dolphin-sys, and the site credited Dolphin and Triforce with the + Dolphin-MMJR copy, another revision, because the consumers resolved + without the data trees. Reads the repository.""" + + def test_dolphin_does_not_read_the_mmjr_copy(self): + import os + + from common import load_database, load_emulator_profiles + + if not (REPO_ROOT / "database.json").is_file() or not ( + REPO_ROOT / "data" / "dolphin-sys" / "codehandler.bin" + ).is_file(): + self.skipTest("database.json or data/dolphin-sys is absent") + previous = os.getcwd() + os.chdir(REPO_ROOT) + try: + db = load_database("database.json") + profiles = load_emulator_profiles("emulators") + consumers = FileConsumers(db) + consumers.add_emulators({"dolphin": profiles["dolphin"]}) + finally: + os.chdir(previous) + mmjr = next( + (sha1 for sha1, entry in db["files"].items() + if entry["path"] == "bios/Other/dolphin-mmjr/codehandler.bin"), + None, + ) + if mmjr is None: + self.skipTest("the Dolphin-MMJR copy is not collected") + self.assertNotIn("dolphin", consumers.emulators.get(mmjr, set())) + + def tearDownModule(): _TMP.cleanup() From c05d29d941e3ff578e980e46c21324d871449cc3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:32:19 +0200 Subject: [PATCH 07/13] fix: a held name must carry the declared hash entry_source took a name or path hit before reading the hashes the entry declares, so the gap report and the site counted as held a file whose only homonym has other bytes: zquestclassic's ending.mid and gameover.mid matched Hydra Castle Labyrinth's. A name or path hit now counts only when its record carries one of the declared hashes, and the declared content anywhere in bios/ comes before a data/ copy. Measured over every profile entry: 254 change, 248 bundled ones (report and site only), three unsourceable SC-55 revisions now read missing, three data hits read bios by hash. --- scripts/cross_reference.py | 42 ++++++++++++++++++++++----- tests/test_cross_reference_sources.py | 16 ++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index 171ea85d..25096341 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -178,21 +178,28 @@ def _resolve_source( or ``None`` (not available anywhere). A name hit whose size contradicts a size the emulator verifies is not the - file, so it does not count as available. + file, so it does not count as available. Nor is one whose bytes carry + none of the hashes the entry declares: a name never wins over a declared + hash, and zquestclassic's ending.mid read as held because Hydra Castle + Labyrinth has another file of that name. """ + declared = _declared_content(file_entry or {}) - def _name_hit(name: str) -> bool: - """Whether the files indexed under *name* can be this entry.""" + def _can_be(sha1s: list | str) -> bool: if not file_entry or db_files is None: return True - sha1s = by_name.get(name) or [] if not isinstance(sha1s, list): sha1s = [sha1s] return any( name_match_size_ok(file_entry, db_files.get(sha1, {}).get("size")) + and _record_carries(sha1, db_files.get(sha1, {}), declared) for sha1 in sha1s ) + def _name_hit(name: str) -> bool: + """Whether the files indexed under *name* can be this entry.""" + return _can_be(by_name.get(name) or []) + # bios/ via database.json by_name if fname in by_name and _name_hit(fname): return "bios" @@ -209,7 +216,7 @@ def _resolve_source( ): return "bios" # bios/ via by_path_suffix (regional variants) - if by_path_suffix and fname in by_path_suffix: + if by_path_suffix and fname in by_path_suffix and _can_be(by_path_suffix[fname]): return "bios" # bios/ under the canonical MAME set name, as resolve_local_file does: # a renamed archive is held once, under the name the dedup kept. @@ -252,6 +259,23 @@ def _data_hit( _HASH_FIELDS = ("sha1", "md5", "sha256", "crc32") +def _record_carries(sha1: str, record: dict, declared: dict[str, set[str]]) -> bool: + """Whether a database record has one of the declared hashes, or none is declared.""" + if not declared: + return True + actual = { + "sha1": sha1.lower(), + **{field: str(record.get(field) or "").lower() for field in ("md5", "sha256", "crc32")}, + } + return any( + actual[field] and ( + actual[field] in values + or (field == "md5" and any(len(v) < 32 and actual[field].startswith(v) for v in values)) + ) + for field, values in declared.items() + ) + + def _declared_content(file_entry: dict) -> dict[str, set[str]]: """The hashes an entry declares, lowercased, by algorithm.""" declared: dict[str, set[str]] = {} @@ -444,6 +468,7 @@ def entry_source(f: dict, index: dict) -> str | None: {"indexes": {"by_path_suffix": by_path_suffix}}, path_field or fname ): return "bios" + found = None for candidate in [fname, *([path_field] if path_field != fname else []), *(f.get("aliases") or [])]: if not candidate: @@ -452,12 +477,15 @@ def entry_source(f: dict, index: dict) -> str | None: candidate, by_name, by_name_lower, data_names, by_path_suffix, f, db_files, index.get("bios_dir"), ) - if source is not None: + if source == "bios": return source + found = found or source def values(field: str) -> list[str]: return parse_hash_list(f.get(field)) + # The declared content anywhere in bios/ comes before a data/ copy, as in + # the resolver: the hash steps run before the data directories. held = ( any(index["by_md5"].get(v) for v in parse_md5_list(f.get("md5"))) or any(v in db_files for v in values("sha1")) @@ -465,7 +493,7 @@ def entry_source(f: dict, index: dict) -> str | None: or any(v in index["by_sha256"] for v in values("sha256")) or any(index["by_crc32"].get(v) for v in values("crc32")) ) - return "bios" if held else None + return "bios" if held else found def _resolve_archive_source( diff --git a/tests/test_cross_reference_sources.py b/tests/test_cross_reference_sources.py index d3596012..0f39afdd 100644 --- a/tests/test_cross_reference_sources.py +++ b/tests/test_cross_reference_sources.py @@ -38,6 +38,22 @@ class GapReportSources(unittest.TestCase): entry = {"name": "wave1.bin", "sha256": "AB" * 32} self.assertEqual(entry_source(entry, _index(by_sha256={"ab" * 32: "s"})), "bios") + def test_a_name_never_wins_over_a_declared_hash(self): + """zquestclassic pins ending.mid by sha1, the collection holds only + Hydra Castle Labyrinth's ending.mid: the report called it held.""" + other = "9" * 40 + index = _index( + by_name={"ending.mid": [other]}, by_name_lower={"ending.mid": "ending.mid"}, + by_path_suffix={"data/midi/ending.mid": [other]}, + db_files={other: {"path": "bios/hcl/ending.mid", "md5": "1" * 32, "size": 9}}, + ) + pinned = {"name": "ending.mid", "sha1": "5" * 40} + self.assertIsNone(entry_source(pinned, index)) + self.assertIsNone(entry_source({**pinned, "path": "data/midi/ending.mid"}, index)) + self.assertEqual(entry_source({"name": "ending.mid"}, index), "bios") + self.assertEqual(entry_source({"name": "ending.mid", "sha1": other}, index), "bios") + self.assertEqual(entry_source({"name": "ending.mid", "md5": "1" * 32}, index), "bios") + def test_a_directory_does_not_stand_for_a_file(self): with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: (Path(tmp) / "bios" / "cpcemu" / "ROM").mkdir(parents=True) From ceb8b752e6547ccecd9c7c797852d5a1c32f538d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:32:50 +0200 Subject: [PATCH 08/13] fix: count bundled gaps apart in each heading cross_reference.py --emulator abuse printed "Gaps: 3 missing" above three [BUNDLED] files, then "0 missing (need to be sourced)" in the total. The heading and the total now read one count: bundled files appear as "N bundled", never among the missing. --- scripts/cross_reference.py | 22 +++++++++++----------- tests/test_cross_reference_sources.py | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index 25096341..d77c650d 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -809,11 +809,16 @@ def print_report( if gaps == 0 and not show_clean: continue - parts = [] - for key in ("bios", "data", "large_file", "missing"): - count = data.get(f"gap_{key}", 0) - if count: - parts.append(f"{count} {key}") + # Counted once, for the heading and the total alike: a file bundled + # with its emulator is not one to source. + emulator_bundled = sum( + 1 for g in data["gap_details"] + if g.get("bundled") and g.get("source") == "missing" + ) + counts = {key: data.get(f"gap_{key}", 0) for key in totals} + counts["missing"] -= emulator_bundled + counts["bundled"] = emulator_bundled + parts = [f"{count} {key}" for key, count in counts.items() if count] status = ", ".join(parts) if parts else "OK" systems = ", ".join(data["systems"]) @@ -842,13 +847,8 @@ def print_report( total_gaps += gaps for key in totals: - totals[key] += data.get(f"gap_{key}", 0) - emulator_bundled = sum( - 1 for g in data["gap_details"] - if g.get("bundled") and g.get("source") == "missing" - ) + totals[key] += counts[key] bundled += emulator_bundled - totals["missing"] -= emulator_bundled print(f"\n{'=' * 60}") print(f"Total: {total_gaps} undeclared files across {scope}") diff --git a/tests/test_cross_reference_sources.py b/tests/test_cross_reference_sources.py index 0f39afdd..40a6fa91 100644 --- a/tests/test_cross_reference_sources.py +++ b/tests/test_cross_reference_sources.py @@ -21,6 +21,7 @@ from cross_reference import ( # noqa: E402 _build_supplemental_index, cross_reference, entry_source, + print_report, ) @@ -54,6 +55,28 @@ class GapReportSources(unittest.TestCase): self.assertEqual(entry_source({"name": "ending.mid", "sha1": other}, index), "bios") self.assertEqual(entry_source({"name": "ending.mid", "md5": "1" * 32}, index), "bios") + def test_the_heading_and_the_total_count_bundled_files_alike(self): + """abuse printed "Gaps: 3 missing" above three [BUNDLED] files and + "0 missing (need to be sourced)" below.""" + import contextlib + import io + + report = {"abuse": { + "emulator": "Abuse", "systems": [], "gaps": 3, "total_files": 3, + "platform_covered": 0, "gap_missing": 3, + "gap_details": [ + {"name": f"f{i}", "required": True, "source": "missing", "bundled": True, + "note": ""} + for i in range(3) + ], + }} + with contextlib.redirect_stdout(io.StringIO()) as out: + print_report(report) + text = out.getvalue() + self.assertIn("Gaps: 3 bundled", text) + self.assertNotIn("3 missing", text) + self.assertIn("0 missing (need to be sourced)", text) + def test_a_directory_does_not_stand_for_a_file(self): with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: (Path(tmp) / "bios" / "cpcemu" / "ROM").mkdir(parents=True) From 184c822e6ef7e2341a9c0d8dbcc1f4bdbcc32ade Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:33:50 +0200 Subject: [PATCH 09/13] fix: name the file each layer reads in slots.py Each content-checked line ended with a verdict on the platform's list, printed at pipeline step 3b. The line now says which emulators read the other file, and the closing count says the rest follow the platform's list. The module source carries no such verdict, a test reads it. --- scripts/slots.py | 21 +++++++++++---------- tests/test_slots.py | 3 +++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/scripts/slots.py b/scripts/slots.py index 7b27857f..6a5668bc 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -1,8 +1,8 @@ """Who decides what goes at a destination. A destination is a slot. Two layers can claim it: the platform YAML, scraped -from what the frontend declares and therefore able to carry an upstream error, -and the emulator profile, read from the emulator's own source. Until this +from what the frontend declares, which can pin another file than the emulator +reads, and the emulator profile, read from the emulator's own source. Until this module existed nothing compared the two: the builder resolves the platform entry first and drops the profile entry on a bare filename match, so a profile that names the right file for a slot could never win and never even be heard. @@ -614,8 +614,8 @@ def arbitrate( In ``existence`` mode the frontend only looks for a path, so the emulator's file satisfies both sides at once and there is nothing to trade away. In a content-checking mode the two answers cannot share one path, and the pack's - addressee decides. The loss is reported rather than absorbed, because the - cause is an upstream declaration that needs fixing at its source. + addressee decides. The loss is reported rather than absorbed: only the + platform's own list can change what its frontend accepts there. """ profile = _strongest(conflict.profile_claims) if addressee == "emulator": @@ -659,10 +659,11 @@ def format_decision(decision: Decision) -> str: f"{conflict.destination}: serve {decision.winner.local_path}, " "the pack answers to the emulator" ) + expected = ", ".join(conflict.emulators) or "the profile" return ( f"{conflict.destination}: keep {decision.winner.local_path}, the frontend " f"verifies content and would reject " - f"{conflict.profile_claims[0].local_path}; upstream declaration is wrong" + f"{conflict.profile_claims[0].local_path}, the file {expected} reads" ) @@ -692,8 +693,8 @@ def find_collisions( One path holds one file, so whichever declaration the builder reaches first decides in silence. Declaring an archive several times to name its inner ROMs is the documented zipped_file pattern and resolves to one - archive. Two declarations landing on two files is a contradiction the - upstream list carries. RetroDECK aims a PC-88 disk subsystem ROM and a + archive. Two declarations landing on two files name two files for one + path. RetroDECK aims a PC-88 disk subsystem ROM and a CoCo disk ROM at one bios/disk.rom. """ fold = bool(config.get("case_insensitive_fs", False)) @@ -1020,13 +1021,13 @@ def main() -> int: doubled = sum(len(c) for c in collided.values()) print( f"\n{total} contested destinations. {fixable} the pack settles on " - f"its own, {total - fixable} rest on an upstream declaration. " + f"its own, {total - fixable} follow the platform's list. " f"{doubled} destinations declared twice." ) if not args.strict and (total or doubled): print( - "Reported, not failed: the remainder needs the upstream list " - "corrected, which no build can do. Use --strict to gate on them." + "Reported, not failed: the remainder changes when the platform's " + "list does, which no build can do. Use --strict to gate on them." ) return 1 if ((found or collided) and args.strict) else 0 diff --git a/tests/test_slots.py b/tests/test_slots.py index d5444995..31f2ee58 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -414,6 +414,9 @@ class TestArbitration(unittest.TestCase): def test_the_reported_line_gives_the_ground_for_the_decision(self): kept = slots.format_decision(slots.arbitrate(self._conflict(), "md5")) self.assertIn("verifies content", kept) + # A fact about each layer, no verdict on the platform's list. + self.assertIn("the file dolphin reads", kept) + self.assertNotIn("wrong", slots.__loader__.get_source("slots").lower()) served = slots.format_decision(slots.arbitrate(self._conflict(), "existence")) self.assertIn("both are satisfied", served) From 82a3e44afd7fc6d6d493239724d4cc253b665596 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:38:11 +0200 Subject: [PATCH 10/13] fix: settle a slot in any case where case folds declaration_settles tested the exact-case name before the slot, so on a filesystem that folds case a declaration spelled otherwise never settled the core's file: RetroBat's 32X_G_BIOS.BIN left BlastEm's 32X_G_BIOS.bin undeclared, and verify counted ten such entries as core files. A slot a case-insensitive platform fills now settles the entry whatever the spelling (case_folded_slots), the slot arbitration judging its bytes as for the exact name. Lakka and RetroPie, which keep both names, are unchanged. Measured: 10 entries settle on RetroBat, 21 on RetroArch. generate_site.py builds its gap report through cross_reference() without the folded slots; it needs folded_declared_slots() passed in the same way as the command line now does. --- scripts/cross_reference.py | 28 ++++++++++++++++++++++++++-- scripts/validation.py | 20 +++++++++++++++++++- scripts/verify.py | 8 ++++++-- tests/test_gap_settlement.py | 29 ++++++++++++++++++++++++++++- tests/test_hidden_extras.py | 32 ++++++++++++++++++++++---------- 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index d77c650d..34855165 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -44,7 +44,7 @@ from common import ( select_emulator_profiles, ) from hashing import parse_hash_list -from validation import declaration_settles, outside_gap_scope +from validation import case_folded_slots, declaration_settles, outside_gap_scope yaml = require_yaml() @@ -105,6 +105,25 @@ def declared_names( ) +def folded_declared_slots( + platforms_dir: str, platforms: list[str] | None, db: dict +) -> frozenset[str]: + """The slots platforms whose filesystem folds case fill, any spelling. + + RetroBat's 32X_G_BIOS.BIN is the file BlastEm opens as 32X_G_BIOS.bin, + and the exact-case name test called it undeclared. + """ + from common import platform_declarations + + slots: set[str] = set() + for platform_name in platforms or list_registered_platforms( + platforms_dir, include_archived=True + ): + config = load_platform_config(platform_name, platforms_dir) + slots |= case_folded_slots(config, platform_declarations(config, db)) + return frozenset(slots) + + def _build_supplemental_index( data_root: str = "data", bios_root: str = "bios" ) -> set[str]: @@ -631,7 +650,9 @@ def _cross_reference_profile( # Check platform declaration: by name, by an alias the core answers # to (RetroArch lists amiberry's cd32.rom as kick40060.CD32), or by # archive. - in_platform = declaration_settles(f, fname, platform_names, effective_path) + in_platform = declaration_settles( + f, fname, platform_names, effective_path, index.get("folded_slots", frozenset()) + ) if in_platform: seen_files.add(seen_key) @@ -738,6 +759,7 @@ def cross_reference( data_names: set[str] | None = None, all_declared: dict[str, list[tuple[int | None, str]]] | None = None, standalone_cores: set[str] | None = None, + folded_slots: frozenset[str] = frozenset(), ) -> dict: """Compare emulator profiles against platform declarations. @@ -774,6 +796,7 @@ def cross_reference( "db_files": db_files, "data_names": data_names, "all_declared": all_declared, + "folded_slots": folded_slots, "bios_dir": db.get("bios_dir"), } for emu_name, profile in profiles.items(): @@ -958,6 +981,7 @@ def main() -> None: profiles, declared, db, plat_data_dirs, data_names, all_declared=declared_names(args.platforms_dir, selected, db), standalone_cores=standalone_cores, + folded_slots=folded_declared_slots(args.platforms_dir, selected, db), ) if args.json: diff --git a/scripts/validation.py b/scripts/validation.py index 8aa7e54b..d4529bf5 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -465,11 +465,25 @@ def outside_gap_scope(file_entry: dict, is_standalone: bool) -> bool: ) +def case_folded_slots( + config: dict, declarations: Mapping[str, list[tuple[int | None, str]]], +) -> frozenset[str]: + """The slots a platform whose filesystem folds case fills, any spelling. + + On RetroBat the 32X_G_BIOS.BIN it declares is the file BlastEm opens as + 32X_G_BIOS.bin. On Lakka bios.col and BIOS.col are two files: nothing. + """ + if not config.get("case_insensitive_fs", False): + return frozenset() + return frozenset(where for places in declarations.values() for _size, where in places) + + def declaration_settles( file_entry: dict, fname: str, declared_names: Mapping[str, list[tuple[int | None, str]]] | set[str], dest: str = "", + folded_slots: frozenset[str] = frozenset(), ) -> bool: """Whether a platform declaration meets a core's requirement. @@ -486,12 +500,16 @@ def declaration_settles( carries no slot or size and answers by name. Read by verify and by the gap report, which settled on the name alone - and left DOSBox's SC-55 ROMs off the gaps page. + and left DOSBox's SC-55 ROMs off the gaps page. ``folded_slots`` + (case_folded_slots) are slots a declaration fills in another spelling on + a filesystem that folds case: the core opens that file there. """ if file_entry.get("archive") in declared_names: return True slot = sanitize_pack_path(dest or fname).lower() directory = slot if dest.endswith("/") else slot.rpartition("/")[0] + if not dest.endswith("/") and slot in folded_slots: + return True for name in (fname, *file_entry.get("aliases", [])): if name not in declared_names: continue diff --git a/scripts/verify.py b/scripts/verify.py index a309bbe2..1721800a 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -86,6 +86,7 @@ from nativemode import ( ) from validation import ( baseline_verdict, + case_folded_slots, declaration_settles, frontend_digest_matches, existence_discrepancy, @@ -420,6 +421,7 @@ def _candidate_verdict( include_all: bool, declared_names: Mapping[str, list[tuple[int | None, str]]] | set[str], dest: str = "", + folded_slots: frozenset[str] = frozenset(), ) -> str: """Whether a profile entry can be a gap, and whether it is settled. @@ -435,7 +437,7 @@ def _candidate_verdict( if outside_gap_scope(file_entry, is_standalone): return "skip" if not include_all and declaration_settles( - file_entry, fname, declared_names, dest + file_entry, fname, declared_names, dest, folded_slots ): return "settled" return "keep" @@ -479,6 +481,7 @@ def find_undeclared_files( """ if declared_names is None: declared_names = platform_declarations(config, db) + folded_slots = case_folded_slots(config, declared_names) # Whether the builder drops a file whose local copy contradicts its # declared hash, which decides if such a copy counts as held here. @@ -568,7 +571,8 @@ def find_undeclared_files( if duplicate and (first is None or first["profile"] == emu_name): continue verdict = _candidate_verdict( - f, fname, is_standalone, include_all, declared_names, dest + f, fname, is_standalone, include_all, declared_names, dest, + folded_slots, ) if verdict == "settled": seen_files.add(seen_key) diff --git a/tests/test_gap_settlement.py b/tests/test_gap_settlement.py index 0646afd9..d86c4c4a 100644 --- a/tests/test_gap_settlement.py +++ b/tests/test_gap_settlement.py @@ -15,7 +15,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "scripts")) from cross_reference import cross_reference # noqa: E402 -from validation import declaration_settles # noqa: E402 +from validation import case_folded_slots, declaration_settles # noqa: E402 SC55 = {"name": "ROM1.BIN", "path": "SC-55/ROM1.BIN", "size": 32768, "validation": ["size"], "required": True} @@ -45,6 +45,33 @@ class OneRule(unittest.TestCase): self.assertIn("declaration_settles(", source, module) +class ACaseFoldingFilesystem(unittest.TestCase): + """RetroBat declares 32X_G_BIOS.BIN, BlastEm opens 32X_G_BIOS.bin at the + same place. The exact-case name test reported it undeclared before the + slot was ever compared.""" + + declared = {"32X_G_BIOS.BIN": [(256, "32x_g_bios.bin")]} + entry = {"name": "32X_G_BIOS.bin"} + + def test_the_slot_settles_it_where_case_folds(self): + folded = case_folded_slots({"case_insensitive_fs": True}, self.declared) + self.assertTrue( + declaration_settles(self.entry, "32X_G_BIOS.bin", self.declared, "", folded) + ) + + def test_two_spellings_are_two_files_elsewhere(self): + folded = case_folded_slots({"case_insensitive_fs": False}, self.declared) + self.assertFalse( + declaration_settles(self.entry, "32X_G_BIOS.bin", self.declared, "", folded) + ) + + def test_another_place_settles_nothing(self): + folded = case_folded_slots({"case_insensitive_fs": True}, self.declared) + self.assertFalse(declaration_settles( + self.entry, "32X_G_BIOS.bin", self.declared, "blastem/32X_G_BIOS.bin", folded + )) + + class TheRunningBuildPlacesTheGap(unittest.TestCase): """Batocera runs Cemu standalone, which reads keys.txt at its standalone_path. The gap report gave the libretro Cemu/keys.txt while diff --git a/tests/test_hidden_extras.py b/tests/test_hidden_extras.py index 66a92d33..222b018f 100644 --- a/tests/test_hidden_extras.py +++ b/tests/test_hidden_extras.py @@ -98,7 +98,7 @@ class TheReportSaysSo(unittest.TestCase): name: verify_platform( load_platform_config(name, "platforms"), db, "emulators", profiles ) - for name in ("retroarch", "retrobat") + for name in ("retroarch", "retrobat", "lakka") } finally: os.chdir(previous) @@ -109,16 +109,28 @@ class TheReportSaysSo(unittest.TestCase): return u self.skipTest(f"{emulator} {path} is not in the cross-reference") - def test_a_case_twin_with_other_bytes_is_left_out(self): - entry = self._entry("NP2kai", "np2kai/bios.rom", "retrobat") - self.assertIs(entry.get("in_pack"), False) - self.assertIn("another case", entry.get("omitted", "")) + def _listed(self, emulator: str, path: str, platform: str) -> bool: + return any( + u.get("emulator") == emulator and (u.get("path") or u.get("name")) == path + for u in self.results[platform]["undeclared_files"] + ) - def test_a_twin_with_the_same_bytes_serves_the_core(self): - # MesenCE opens bios.col, and RetroArch's pack folds it onto BIOS.col, - # the same bytes, which serves the core on a folding filesystem. - entry = self._entry("MesenCE", "bios.col") - self.assertIsNot(entry.get("in_pack"), False) + def test_a_declaration_in_another_case_settles_the_slot(self): + """On a filesystem that folds case the platform's file at the slot is + the one the core opens, whatever its spelling, and the slot + arbitration judges its bytes. Listed as a core file, RetroBat's + 32X_G_BIOS.BIN counted BlastEm's 32X_G_BIOS.bin in the pack twice.""" + for platform, emulator, path in ( + ("retrobat", "NP2kai", "np2kai/bios.rom"), + ("retrobat", "BlastEm", "32X_G_BIOS.bin"), + ("retroarch", "MesenCE", "bios.col"), + ): + with self.subTest(platform=platform, path=path): + self.assertFalse(self._listed(emulator, path, platform)) + + def test_a_filesystem_keeping_both_names_reports_the_other(self): + """Lakka runs on Linux, where bios.col and BIOS.col are two files.""" + self.assertTrue(self._listed("MesenCE", "bios.col", "lakka")) def test_a_directory_under_a_packed_file_is_left_out(self): entry = self._entry("nSide (SFC Balanced)", "SGB1.sfc/lr35902.boot.rom") From aa36ec544dc8de0804da371937391c0b11849365 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:40:11 +0200 Subject: [PATCH 11/13] fix: name every gap verify counts verify --platform printed N optional core files and N game data files missing and named none, even with --verbose. --verbose now lists them as MISSING (optional) and MISSING (game data), and without it the count line says --verbose lists them, as the line about cores with no external file already did. --- scripts/verify.py | 14 +++++++++++-- tests/test_verify_listing.py | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/scripts/verify.py b/scripts/verify.py index 1721800a..68eb6087 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1595,8 +1595,11 @@ def _print_undeclared_section(result: dict, verbose: bool) -> None: core_missing_req = len(req_not_in_repo) + len(req_hle_not_in_repo) core_missing_opt = len(opt_not_in_repo) + # Required gaps are always named below, the optional ones on request. + listed = "" if verbose or not core_missing_opt else " (--verbose lists them)" print( - f" Core files: {core_in_pack} in pack, {core_missing_req} required missing, {core_missing_opt} optional missing" + f" Core files: {core_in_pack} in pack, {core_missing_req} required missing, " + f"{core_missing_opt} optional missing{listed}" ) if bundled: names = ", ".join(sorted({u["name"] for u in bundled})) @@ -1608,6 +1611,9 @@ def _print_undeclared_section(result: dict, verbose: bool) -> None: _print_undeclared_entry(u, "MISSING (required)", verbose) for u in req_hle_not_in_repo: _print_undeclared_entry(u, "MISSING (required, HLE fallback)", verbose) + if verbose: + for u in opt_not_in_repo: + _print_undeclared_entry(u, "MISSING (optional)", verbose) if game_data: gd_missing = [u for u in game_data if not u["in_repo"]] @@ -1617,7 +1623,11 @@ def _print_undeclared_section(result: dict, verbose: bool) -> None: ) gd_unplaced = [u for u in game_data if u["in_repo"] and not u.get("in_pack", True)] if gd_missing or gd_packed or gd_unplaced: - print(f" Game data: {gd_packed} in pack, {len(gd_missing)} missing") + listed = "" if verbose or not gd_missing else " (--verbose lists them)" + print(f" Game data: {gd_packed} in pack, {len(gd_missing)} missing{listed}") + if verbose: + for u in gd_missing: + _print_undeclared_entry(u, "MISSING (game data)", verbose) if gd_unplaced: print( f" Game data held but not packed: {len(gd_unplaced)} " diff --git a/tests/test_verify_listing.py b/tests/test_verify_listing.py index 1e78fec7..893ccec5 100644 --- a/tests/test_verify_listing.py +++ b/tests/test_verify_listing.py @@ -148,5 +148,43 @@ class ExistenceReportsWhatThePackReports(unittest.TestCase): self.assertEqual(result["status"], verify.Status.OK) self.assertIn("declared hash 9d2ff233", result["discrepancy"]) + +class TheDestinationsFlagNeedsAPack(unittest.TestCase): + """--destinations describes a platform's full pack: another mode refuses it.""" + + def test_refused_outside_platform_modes(self): + for mode in (("--emulator", "handy"), ("--platform", "retroarch", "--region", "us")): + with self.subTest(mode=mode): + result = _run("scripts/verify.py", *mode, "--destinations", "tmp/never.json") + self.assertEqual(result.returncode, 2, result.stdout[-300:]) + self.assertIn("--destinations", result.stderr) + + +class EveryGapCountedIsNamed(unittest.TestCase): + """verify --platform batocera --verbose counted 3 optional core files and + 427 game data files missing and named none of them.""" + + def test_verbose_names_them_and_the_summary_says_how(self): + import contextlib + import io + + sys.path.insert(0, str(REPO_ROOT / "scripts")) + import verify # noqa: PLC0415 + + result = {"undeclared_files": [ + {"name": "opt.rom", "emulator": "core", "required": False, "in_repo": False}, + {"name": "level.dat", "emulator": "engine", "required": True, "in_repo": False, + "category": "game_data"}, + ]} + with contextlib.redirect_stdout(io.StringIO()) as quiet: + verify._print_undeclared_section(result, verbose=False) + self.assertIn("1 optional missing (--verbose lists them)", quiet.getvalue()) + self.assertIn("1 missing (--verbose lists them)", quiet.getvalue()) + with contextlib.redirect_stdout(io.StringIO()) as verbose: + verify._print_undeclared_section(result, verbose=True) + self.assertIn("MISSING (optional): core needs opt.rom", verbose.getvalue()) + self.assertIn("MISSING (game data): engine needs level.dat", verbose.getvalue()) + + if __name__ == "__main__": unittest.main() From 4ba935374eaf985ac7e29b12317fdc0240e9e40b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:47:44 +0200 Subject: [PATCH 12/13] fix: keep pack order outside contested paths Ranking contested core claims sorted the whole gap report, which reordered every manifest. Only the claims on one contested path move now, grouped where the first of them stood, and two spellings of a path are one group where the filesystem folds case (RetroBat's 64DD_IPL_DEV.bin). An entry without a destination (EmuDeck's system:md5 whitelist) no longer counts as a pack path in verify, and step 5 prints one status for counts and paths together. A full offline run compared all 12 archives and 930 emulator packs path by path, every one equal. --- scripts/packextras.py | 38 +++++++++++++++++++++++++-------- scripts/pipeline.py | 1 + scripts/verify.py | 9 ++++++-- tests/test_pack_destinations.py | 14 ++++++++++++ 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/scripts/packextras.py b/scripts/packextras.py index 6a2b2d02..f5c8f4cb 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -869,10 +869,11 @@ def _collect_emulator_extras( # BIOS root before Solarus, which pins its own by sha1, and fbalpha2012's # hashless hiscore.dat took RetroDECK's over mame2015's sha1. Profile # order decides only between claims of equal proof. - rank = _ClaimRanks(undeclared, placement, db) - undeclared = sorted( - undeclared, key=lambda u: (-rank.of_entry(u), not _names_content(u)) + undeclared = sorted(undeclared, key=lambda u: not _names_content(u)) + rank = _ClaimRanks( + undeclared, placement, db, bool(config.get("case_insensitive_fs", False)) ) + undeclared = rank.ordered(undeclared) extras = [] seen_dests: set[str] = set(seen) # Destination -> the extra that took it, to record later claimants on. @@ -1056,8 +1057,12 @@ class _ClaimRanks: where two claims meet: a destination one core asks for has no contest. """ - def __init__(self, undeclared: list[dict], placement: ExtraPlacement, db: dict) -> None: + def __init__( + self, undeclared: list[dict], placement: ExtraPlacement, db: dict, fold: bool, + ) -> None: self._db = db + # Two spellings of one path are one file where the filesystem folds case. + self._key = (lambda dest: dest.lower()) if fold else (lambda dest: dest) self._placed: dict[int, tuple[str, str] | None] = {} counts: dict[str, int] = {} for u in undeclared: @@ -1067,14 +1072,13 @@ class _ClaimRanks: ) self._placed[id(u)] = placed if placed is not None: - counts[placed[1]] = counts.get(placed[1], 0) + 1 + len( - u.get("other_claims", []) - ) + key = self._key(placed[1]) + counts[key] = counts.get(key, 0) + 1 + len(u.get("other_claims", [])) self._contested = {dest for dest, n in counts.items() if n > 1} self._cache: dict[tuple, int] = {} def of_extra(self, extra: dict, full_dest: str) -> int: - if full_dest not in self._contested: + if self._key(full_dest) not in self._contested: return 0 key = (extra.get("source_profile"), extra.get("source_name"), extra.get("source_path"), full_dest) @@ -1093,9 +1097,25 @@ class _ClaimRanks: ) return self._cache[key] + def ordered(self, undeclared: list[dict]) -> list[dict]: + """The entries in their order, the claims on one contested path + reordered among themselves by proof. Nothing else moves, so the + pack and the manifest keep their order.""" + first: dict[str, int] = {} + keys: list[tuple[int, int, int]] = [] + for position, u in enumerate(undeclared): + placed = self._placed.get(id(u)) + if placed is None or self._key(placed[1]) not in self._contested: + keys.append((position, 0, position)) + continue + group = first.setdefault(self._key(placed[1]), position) + keys.append((group, -self.of_entry(u), position)) + order = sorted(range(len(undeclared)), key=keys.__getitem__) + return [undeclared[i] for i in order] + def of_entry(self, u: dict) -> int: placed = self._placed.get(id(u)) - if placed is None or placed[1] not in self._contested: + if placed is None or self._key(placed[1]) not in self._contested: return 0 name = u.get("archive") or u["name"] return max( diff --git a/scripts/pipeline.py b/scripts/pipeline.py index c6b235c1..ee3c4e62 100644 --- a/scripts/pipeline.py +++ b/scripts/pipeline.py @@ -919,6 +919,7 @@ def main() -> None: # Counts can agree while the paths differ, both ways at once. ok = check_pack_destinations(destinations_file, pack_output) and ok ok = check_emulator_packs() and ok + print(f"--- 5/8 consistency check, counts and paths: {'OK' if ok else 'FAILED'} ---") results["consistency"] = ok all_ok = all_ok and ok else: diff --git a/scripts/verify.py b/scripts/verify.py index 68eb6087..477af66f 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -904,8 +904,10 @@ def _builder_placement( The builder walks the systems in name order and skips a path that a written file blocks as a directory, or the reverse: RetroArch declares SGB1.sfc and SGB1.sfc/program.rom, and only the first is in the pack. - Returns (path -> local copy written there, path -> copy or None for each - slot kept, a user_provided or external one included). + An entry without a destination (EmuDeck's system:md5 whitelist) is + judged but takes no path. Returns (path -> local copy written there, + path -> copy or None for each slot kept, a user_provided or external one + included). """ from packpaths import _has_path_conflict, _register_path @@ -915,6 +917,9 @@ def _builder_placement( folded: set[str] = set() parents: set[str] = set() for _sys_id, full, local in sorted(packed_order, key=lambda item: item[0]): + # EmuDeck's system:md5 entries name no file: the builder writes none. + if not full or full.endswith("/"): + continue if ( full in occupied or (case_insensitive and full.lower() in folded) diff --git a/tests/test_pack_destinations.py b/tests/test_pack_destinations.py index 98402346..d5bbd80b 100644 --- a/tests/test_pack_destinations.py +++ b/tests/test_pack_destinations.py @@ -108,6 +108,20 @@ class AUserProvidedSlot(_Fixture): self.assertEqual(report["pack_destinations"], []) +class AnEntryWithoutAFileTakesNoPath(unittest.TestCase): + """EmuDeck's system:md5 entries name no file. verify credited the bare + base destination as a pack path, and the comparison failed on ''.""" + + def test_it_is_left_out(self): + from verify import _builder_placement + + shipped, occupied = _builder_placement( + [("s", "bios/", None), ("s", "", "x"), ("s", "bios/a.bin", "bios/a.bin")], False + ) + self.assertEqual(shipped, {"bios/a.bin": "bios/a.bin"}) + self.assertEqual(set(occupied), {"bios/a.bin"}) + + class ThePipelineComparesPaths(unittest.TestCase): """Equal counts, other paths: the comparison fails and names both sides.""" From 88f9c40a78d5ac0b712e0a53916c3839053721bf Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:47:50 +0200 Subject: [PATCH 13/13] chore: regenerate the install manifests The slot and claim decisions above change which copy some paths take (EmuDeck neocdz.zip, RetroBat DS firmware, hiscore.dat on six platforms) and settle case variants on folding filesystems. --- install/batocera.json | 489 +++++------------------------------------ install/emudeck.json | 324 ++++++++++++++------------- install/lakka.json | 26 +-- install/recalbox.json | 60 ++--- install/retroarch.json | 180 +-------------- install/retrobat.json | 394 ++++++++++----------------------- install/retrodeck.json | 62 +++--- install/retropie.json | 48 ++-- install/rocknix.json | 208 ++++++++---------- install/romm.json | 2 +- 10 files changed, 515 insertions(+), 1278 deletions(-) diff --git a/install/batocera.json b/install/batocera.json index a0c11b28..5ad7c85f 100644 --- a/install/batocera.json +++ b/install/batocera.json @@ -5,7 +5,7 @@ "platform": "batocera", "display_name": "Batocera", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "bios", "detect": [ { @@ -273,9 +273,9 @@ } ], "total_files": 14483, - "total_size": 9772313890, + "total_size": 9772027658, "pack_files": 14485, - "pack_size": 9772313890, + "pack_size": 9772027658, "total_omitted": 399, "omitted_files": [ { @@ -168343,8 +168343,8 @@ "size": 3455065, "repo_path": "bios/Arcade/Arcade/stvbios.zip", "cores": [ - "beetle_saturn", "kronos", + "beetle_saturn", "mame", "mame2003_plus" ] @@ -169708,8 +169708,8 @@ "size": 131072, "repo_path": "bios/Sega/Dreamcast/dc_flash.bin", "cores": [ - "demul", - "redream" + "redream", + "demul" ], "systems": [ "sega-dreamcast" @@ -171019,6 +171019,7 @@ "size": 2240738, "repo_path": "bios/Arcade/Arcade/pgm.zip", "cores": [ + "fbneo", "fba2x" ], "alternatives": [ @@ -171037,37 +171038,28 @@ "size": 2240738, "repo_path": "bios/Arcade/Arcade/pgm.zip", "cores": [ - "mame2003_plus", - "fbneo" + "mame2003_plus" ] } ] }, { "dest": "hiscore.dat", - "sha1": "7381472bf046126257e51a0124e4553282f020e5", - "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", - "size": 322458, - "repo_path": "bios/Arcade/Arcade/hiscore.dat", + "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", + "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", + "size": 36182, + "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", "cores": [ - "fbalpha2012" + "mame2000" ], "alternatives": [ - { - "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", - "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", - "size": 36182, - "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", - "cores": [ - "mame2000" - ] - }, { "sha1": "7381472bf046126257e51a0124e4553282f020e5", "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", "size": 322458, "repo_path": "bios/Arcade/Arcade/hiscore.dat", "cores": [ + "fbalpha2012", "visual-pinball" ] } @@ -173870,62 +173862,21 @@ }, { "dest": "isgsm.zip", - "sha1": "6a19d46d3915f2bff823ac34327bb0976fb2fed7", - "sha256": "2a7a1a7ee15b322356bcdfd46dd3592bcf6a9bf6cb04f4cd2cf27ea54dbb6665", - "size": 10185, - "repo_path": "bios/Arcade/MAME/isgsm.zip", + "sha1": "f590ccf688b4c05fa1da5c5dd92c224545170c3b", + "sha256": "c481bfde0ae68e5fbe70a406af1698fcba5b76d7e23c860c2715cacf019efad4", + "size": 10207, + "repo_path": "bios/Arcade/Arcade/isgsm.zip", "cores": [ - "mame" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo" ], "alternatives": [ { - "sha1": "f590ccf688b4c05fa1da5c5dd92c224545170c3b", - "sha256": "c481bfde0ae68e5fbe70a406af1698fcba5b76d7e23c860c2715cacf019efad4", - "size": 10207, - "repo_path": "bios/Arcade/Arcade/isgsm.zip", + "sha1": "6a19d46d3915f2bff823ac34327bb0976fb2fed7", + "sha256": "2a7a1a7ee15b322356bcdfd46dd3592bcf6a9bf6cb04f4cd2cf27ea54dbb6665", + "size": 10185, + "repo_path": "bios/Arcade/MAME/isgsm.zip", "cores": [ - "fbneo" + "mame" ] } ] @@ -173988,10 +173939,10 @@ "size": 22379, "repo_path": "bios/Arcade/Arcade/decocass.zip", "cores": [ + "fbneo", "mame", "mame2000", - "mame2003_plus", - "fbneo" + "mame2003_plus" ] }, { @@ -174290,49 +174241,8 @@ "size": 7950, "repo_path": "bios/Arcade/Arcade/bubsys.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -175020,14 +174930,23 @@ }, { "dest": "skns.zip", - "sha1": "a8238391ddca35f9e59e5ca7f4f4491ae281b77f", - "sha256": "51b5ed3a802b10d599c9e2e00ef38ed1e3caa534067aa810fe0cad094324add8", - "size": 924740, - "repo_path": "bios/Arcade/MAME/skns.zip", + "sha1": "4257bd14b541fafbd555cb98ba079a3416a45934", + "sha256": "6edf22c68e70be06c9b32741e0d1df1aa38cf6fc162c1027c15d6c1d882f621b", + "size": 924762, + "repo_path": "bios/Arcade/Arcade/skns.zip", "cores": [ - "mame" + "fbneo" ], "alternatives": [ + { + "sha1": "a8238391ddca35f9e59e5ca7f4f4491ae281b77f", + "sha256": "51b5ed3a802b10d599c9e2e00ef38ed1e3caa534067aa810fe0cad094324add8", + "size": 924740, + "repo_path": "bios/Arcade/MAME/skns.zip", + "cores": [ + "mame" + ] + }, { "sha1": "aabedfebe2cd8cfede8c2a3cb9ff33166f0ef953", "sha256": "190c5fd9d68619eda986d32d03114a5c9239ab5c29e6e0676afe18612e9cd052", @@ -175036,15 +174955,6 @@ "cores": [ "mame2003_plus" ] - }, - { - "sha1": "4257bd14b541fafbd555cb98ba079a3416a45934", - "sha256": "6edf22c68e70be06c9b32741e0d1df1aa38cf6fc162c1027c15d6c1d882f621b", - "size": 924762, - "repo_path": "bios/Arcade/Arcade/skns.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -175333,49 +175243,8 @@ "size": 57375, "repo_path": "bios/Arcade/MAME/atarisy1.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -195766,49 +195635,8 @@ "size": 2700, "repo_path": "bios/Arcade/Arcade/cchip.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -213566,49 +213394,8 @@ "size": 163, "repo_path": "bios/Arcade/Arcade/midssio.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -218055,49 +217842,8 @@ "size": 7771, "repo_path": "bios/Arcade/Arcade/namcoc69.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -218107,49 +217853,8 @@ "size": 7822, "repo_path": "bios/Arcade/Arcade/namcoc70.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -218261,49 +217966,8 @@ "size": 8709, "repo_path": "bios/Arcade/Arcade/namcoc75.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -219690,49 +219354,8 @@ "size": 3556, "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "apple-macintosh-ii", - "archimedes", - "beena", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "dragon64", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "laser310", - "loopy", - "mc10", - "pc80", - "pcw", - "pv2000", - "rx78", - "scv", - "sega-ai", - "tandy-vis", - "ti99", - "triforce", - "trs80", - "tutor", - "vgmplay", - "vtech-socrates", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { diff --git a/install/emudeck.json b/install/emudeck.json index 84e02fa1..d93fbcec 100644 --- a/install/emudeck.json +++ b/install/emudeck.json @@ -5,7 +5,7 @@ "platform": "emudeck", "display_name": "EmuDeck", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "bios", "detect": [ { @@ -279,9 +279,9 @@ } ], "total_files": 8184, - "total_size": 7838923553, + "total_size": 7838411296, "pack_files": 8186, - "pack_size": 7838923553, + "pack_size": 7838411296, "total_omitted": 11, "omitted_files": [ { @@ -1129,6 +1129,34 @@ "systems": [ "nintendo-nes" ], + "slot_choices": [ + { + "cores": [ + "mesence", + "nestopia" + ] + }, + { + "sha1": "af5af53f66982e749643fdf8b2acbb7d4d3ed229", + "sha256": "fdc1a76e654feea993fcb38366e05ee5f4eb641f86fe6bebaeefd412e112dd72", + "size": 8192, + "repo_path": "bios/Nintendo/Famicom Disk System/.variants/disksys.rom.af5af53f", + "cores": [ + "ares" + ] + } + ], + "declared_choices": [ + { + "sha1": "af5af53f66982e749643fdf8b2acbb7d4d3ed229", + "sha256": "fdc1a76e654feea993fcb38366e05ee5f4eb641f86fe6bebaeefd412e112dd72", + "size": 8192, + "repo_path": "bios/Nintendo/Famicom Disk System/.variants/disksys.rom.af5af53f", + "systems": [ + "nintendo-nes" + ] + } + ], "alternatives": [ { "sha1": "af5af53f66982e749643fdf8b2acbb7d4d3ed229", @@ -1555,15 +1583,48 @@ }, { "dest": "neocdz.zip", - "sha1": "9cab31cfe7eacb6871d36cee105d87b90fd85b64", - "sha256": "d828241905d390c5de4867646296f615c95bca2f5d8f7e81d50e08717373a821", - "size": 1181333, - "repo_path": "bios/Arcade/Arcade/neocdz.zip", + "sha1": "712816a3c41baf4129e23d645305a536e03d69de", + "sha256": "f885f2cc79b5c0120ca316b0db360183c2b9a60bc727cbe211b7518e12bd5548", + "size": 955352, + "repo_path": "bios/SNK/Neo Geo CD/.variants/neocdz.zip.712816a3", "cores": null, "systems": [ "snk-neogeo", "snk-neogeo-cd" ], + "slot_choices": [ + { + "sha1": "712816a3c41baf4129e23d645305a536e03d69de", + "sha256": "f885f2cc79b5c0120ca316b0db360183c2b9a60bc727cbe211b7518e12bd5548", + "size": 955352, + "repo_path": "bios/SNK/Neo Geo CD/.variants/neocdz.zip.712816a3", + "cores": [ + "geolith" + ] + }, + { + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", + "cores": [ + "fbneo", + "fbneo_neogeo" + ] + } + ], + "declared_choices": [ + { + "sha1": "9cab31cfe7eacb6871d36cee105d87b90fd85b64", + "sha256": "d828241905d390c5de4867646296f615c95bca2f5d8f7e81d50e08717373a821", + "size": 1181333, + "repo_path": "bios/Arcade/Arcade/neocdz.zip", + "systems": [ + "snk-neogeo", + "snk-neogeo-cd" + ] + } + ], "alternatives": [ { "sha1": "80c9a25ccf39c6b85d1a7c9c0d6fe527ab45ec1c", @@ -1904,8 +1965,8 @@ "repo_path": "bios/Atari/5200/5200.rom", "cores": [ "ares", - "a5200", - "atari800" + "atari800", + "a5200" ] }, { @@ -1916,10 +1977,10 @@ "repo_path": "bios/Nintendo/Game Boy Advance/GBA_bios.rom", "cores": [ "ares", + "mesence", "beetle_gba", "gpsp", "mednafen_gba", - "mesence", "mgba", "mgba_splitscreen", "noods", @@ -1936,10 +1997,10 @@ "repo_path": "bios/Sega/Game Gear/GG_majesco.gg", "cores": [ "ares", + "mesence", "gearsystem", "genesis_plus_gx", - "genesis_plus_gx_wide", - "mesence" + "genesis_plus_gx_wide" ] }, { @@ -2180,8 +2241,8 @@ "repo_path": "bios/NEC/PC Engine/syscard1.pce", "cores": [ "ares", - "beetle_pce", "geargrafx", + "beetle_pce", "mednafen_pce", "mednafen_pce_fast", "mednafen_supergrafx" @@ -2195,8 +2256,8 @@ "repo_path": "bios/NEC/PC Engine/syscard3.pce", "cores": [ "ares", - "beetle_pce", "geargrafx", + "beetle_pce", "mednafen_pce", "mednafen_pce_fast", "mednafen_supergrafx" @@ -2479,8 +2540,8 @@ "repo_path": "bios/Sony/PlayStation/psxonpsp660.bin", "cores": [ "beetle_psx", - "pcsx_rearmed", - "swanstation" + "swanstation", + "pcsx_rearmed" ], "systems": [ "sony-playstation" @@ -2518,11 +2579,11 @@ "repo_path": "bios/Nintendo/SNES/SNES_cx4.rom", "cores": [ "bsnes", + "higan_sfc_balanced", "bsnes2014", "bsnes_hd_beta", "bsnes_mercury", - "higan_sfc", - "higan_sfc_balanced" + "higan_sfc" ] }, { @@ -2546,10 +2607,10 @@ "repo_path": "bios/Nintendo/Super Game Boy/SGB2.sfc/program.rom", "cores": [ "bsnes_hd_beta", + "mesence", "ares", "bsnes", "mesen-s", - "mesence", "supersnes9x" ] }, @@ -3039,9 +3100,9 @@ "repo_path": "bios/Sony/PlayStation/scph1001.bin", "cores": [ "duckstation", + "rustation", "pcsx1", - "pcsx_rearmed", - "rustation" + "pcsx_rearmed" ], "systems": [ "sony-playstation" @@ -3278,9 +3339,9 @@ "repo_path": "bios/Sony/PlayStation/scph7001.bin", "cores": [ "duckstation", + "rustation", "pcsx1", - "pcsx_rearmed", - "rustation" + "pcsx_rearmed" ], "systems": [ "sony-playstation" @@ -16736,8 +16797,8 @@ "repo_path": "bios/Nintendo/Game Boy Color/GBC_cgb.bin", "cores": [ "fixgb", - "gambatte", "mgba", + "gambatte", "vbam" ] }, @@ -58936,12 +58997,12 @@ "size": 1048576, "repo_path": "bios/Nintendo/SNES/BS-X.bin", "cores": [ + "mesence", "ares", "bsnes", "bsnes_hd_beta", "chimerasnes", "mesen-s", - "mesence", "snes9x", "snes9x2010", "supersnes9x" @@ -59168,8 +59229,8 @@ "size": 3455065, "repo_path": "bios/Arcade/Arcade/stvbios.zip", "cores": [ - "beetle_saturn", "kronos", + "beetle_saturn", "mame", "mame2003", "mame2003_plus", @@ -60814,8 +60875,8 @@ "size": 6478, "repo_path": "bios/Other/dolphin-mmjr/font_western.bin", "cores": [ - "dolphin", - "primehack" + "primehack", + "dolphin" ], "systems": [ "nintendo-gamecube" @@ -60828,8 +60889,8 @@ "size": 259626, "repo_path": "bios/Other/dolphin-mmjr/font_japanese.bin", "cores": [ - "dolphin", - "primehack" + "primehack", + "dolphin" ], "systems": [ "nintendo-gamecube" @@ -61397,32 +61458,24 @@ }, { "dest": "dmg_boot.bin", - "sha1": "1db57a1e8b6e4096f811587f9eab0c6675fd9755", - "sha256": "6f64da4cecd7e54e2f928eb3e3ba7810a7a567d0d247cc71737d1771e073a916", + "sha1": "4ed31ec6b0b175bb109c0eb5fd3d193da823339f", + "sha256": "cf053eccb4ccafff9e67339d4e78e98dce7d1ed59be819d2a1ba2232c6fce1c7", "size": 256, - "repo_path": "bios/Nintendo/Game Boy/dmg_boot.bin", + "repo_path": "bios/Nintendo/Game Boy/GB_dmg.bin", "cores": [ - "emux_gb", - "gearboy", - "irogb", - "mesen-s" + "mesence" ], "alternatives": [ - { - "sha1": "4ed31ec6b0b175bb109c0eb5fd3d193da823339f", - "sha256": "cf053eccb4ccafff9e67339d4e78e98dce7d1ed59be819d2a1ba2232c6fce1c7", - "size": 256, - "repo_path": "bios/Nintendo/Game Boy/GB_dmg.bin", - "cores": [ - "mesence" - ] - }, { "sha1": "1db57a1e8b6e4096f811587f9eab0c6675fd9755", "sha256": "6f64da4cecd7e54e2f928eb3e3ba7810a7a567d0d247cc71737d1771e073a916", "size": 256, "repo_path": "bios/Nintendo/Game Boy/dmg_boot.bin", "cores": [ + "emux_gb", + "gearboy", + "irogb", + "mesen-s", "sameboy" ] } @@ -61435,32 +61488,22 @@ "size": 8192, "repo_path": "bios/Other/zesarux/sms.rom", "cores": [ + "mesence", "emux_sms", "gearsystem", - "mesence", "smsplus" ] }, { "dest": "hiscore.dat", - "sha1": "7381472bf046126257e51a0124e4553282f020e5", - "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", - "size": 322458, - "repo_path": "bios/Arcade/Arcade/hiscore.dat", + "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", + "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", + "size": 36182, + "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", "cores": [ - "fbalpha2012", - "fbalpha2012_cps3" + "mame2000" ], "alternatives": [ - { - "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", - "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", - "size": 36182, - "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", - "cores": [ - "mame2000" - ] - }, { "sha1": "68e94fb579f46f3255ef7c34c0322f3f0f3c8e20", "sha256": "c470a579d8836299e64b835012f7c536115aac695ba9ae10ecd4d168bede54ca", @@ -61469,6 +61512,16 @@ "cores": [ "mame2015" ] + }, + { + "sha1": "7381472bf046126257e51a0124e4553282f020e5", + "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", + "size": 322458, + "repo_path": "bios/Arcade/Arcade/hiscore.dat", + "cores": [ + "fbalpha2012", + "fbalpha2012_cps3" + ] } ] }, @@ -61578,6 +61631,7 @@ "size": 2240738, "repo_path": "bios/Arcade/Arcade/pgm.zip", "cores": [ + "fbneo", "fbalpha2019" ], "alternatives": [ @@ -61603,8 +61657,7 @@ "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] } ] @@ -61616,6 +61669,7 @@ "size": 924762, "repo_path": "bios/Arcade/Arcade/skns.zip", "cores": [ + "fbneo", "fbalpha2019" ], "alternatives": [ @@ -61657,8 +61711,7 @@ "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] } ] @@ -61670,9 +61723,9 @@ "size": 163, "repo_path": "bios/Arcade/Arcade/midssio.zip", "cores": [ + "fbneo", "fbalpha2019", - "mame", - "fbneo" + "mame" ] }, { @@ -61692,8 +61745,8 @@ "size": 28014, "repo_path": "bios/Arcade/Arcade/coleco.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ], "systems": [ "coleco-colecovision" @@ -61706,8 +61759,8 @@ "size": 193854, "repo_path": "bios/Arcade/Arcade/msx.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ] }, { @@ -61717,8 +61770,8 @@ "size": 16506, "repo_path": "bios/Arcade/Arcade/spectrum.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ] }, { @@ -61728,28 +61781,36 @@ "size": 32986, "repo_path": "bios/Arcade/Arcade/spec128.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ] }, { "dest": "nmk004.zip", - "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", - "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", + "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", "size": 3556, - "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": [ - "fbalpha2019" + "fbneo" ], "alternatives": [ + { + "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", + "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "size": 3556, + "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "cores": [ + "fbalpha2019" + ] + }, { "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", "size": 3556, "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": [ - "mame", - "fbneo" + "mame" ] } ] @@ -61761,6 +61822,7 @@ "size": 22379, "repo_path": "bios/Arcade/Arcade/decocass.zip", "cores": [ + "fbneo", "fbalpha2019", "mame", "mame2000", @@ -61771,8 +61833,7 @@ "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] }, { @@ -61782,6 +61843,7 @@ "size": 10207, "repo_path": "bios/Arcade/Arcade/isgsm.zip", "cores": [ + "fbneo", "fbalpha2019" ], "alternatives": [ @@ -61803,8 +61865,7 @@ "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] } ] @@ -61816,9 +61877,9 @@ "size": 2700, "repo_path": "bios/Arcade/Arcade/cchip.zip", "cores": [ + "fbneo", "fbalpha2019", - "mame", - "fbneo" + "mame" ] }, { @@ -61828,6 +61889,7 @@ "size": 7609, "repo_path": "bios/Arcade/Arcade/ym2608.zip", "cores": [ + "fbneo", "fbalpha2019" ], "alternatives": [ @@ -61839,15 +61901,6 @@ "cores": [ "mame" ] - }, - { - "sha1": "06fc753d015b43ca1787f4cfd9331b1674202e64", - "sha256": "187ede0f57d1439092a7ac50ea923533871ab7ee65e29baabc4dcdfa7ad4823f", - "size": 7609, - "repo_path": "bios/Arcade/Arcade/ym2608.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -62348,38 +62401,30 @@ "size": 256, "repo_path": "bios/Nintendo/Game Boy/GB_dmg.bin", "cores": [ - "gambatte", "mgba", + "gambatte", "vbam" ] }, { "dest": "cgb_boot.bin", - "sha1": "80e3ee82416c1a74827a5459fde32f04bf51a1f6", - "sha256": "119470a9cf9f0d85f0c7ba5a6cadffeec07eb9862719086d1a9808189605e388", + "sha1": "1293d68bf9643bc4f36954c1e80e38f39864528d", + "sha256": "b4f2e416a35eef52cba161b159c7c8523a92594facb924b3ede0d722867c50c7", "size": 2304, - "repo_path": "bios/Nintendo/Game Boy Color/cgb_boot.bin", + "repo_path": "bios/Nintendo/Game Boy Color/GBC_cgb.bin", "cores": [ - "gearboy", - "irogb", - "mesen-s" + "mesence" ], "alternatives": [ - { - "sha1": "1293d68bf9643bc4f36954c1e80e38f39864528d", - "sha256": "b4f2e416a35eef52cba161b159c7c8523a92594facb924b3ede0d722867c50c7", - "size": 2304, - "repo_path": "bios/Nintendo/Game Boy Color/GBC_cgb.bin", - "cores": [ - "mesence" - ] - }, { "sha1": "80e3ee82416c1a74827a5459fde32f04bf51a1f6", "sha256": "119470a9cf9f0d85f0c7ba5a6cadffeec07eb9862719086d1a9808189605e388", "size": 2304, "repo_path": "bios/Nintendo/Game Boy Color/cgb_boot.bin", "cores": [ + "gearboy", + "irogb", + "mesen-s", "sameboy" ] } @@ -63579,15 +63624,12 @@ "size": 7950, "repo_path": "bios/Arcade/Arcade/bubsys.zip", "cores": [ + "fbneo", "mame", "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" - ], - "systems": [ - "panasonic-3do" + "ume2015" ] }, { @@ -64051,11 +64093,9 @@ "size": 57375, "repo_path": "bios/Arcade/MAME/atarisy1.zip", "cores": [ + "fbneo", "mame" ], - "systems": [ - "panasonic-3do" - ], "alternatives": [ { "sha1": "4fb1f00681d2540b476add2def2bbf07ca3e3993", @@ -64086,15 +64126,6 @@ "cores": [ "ume2015" ] - }, - { - "sha1": "0c9bc4b948bd1816ebe13e2acb97931cbcc6ea15", - "sha256": "ae8c41b3d0107c6f0634730267af569a51e49f5df1bdb2c1e6c591bb6251b7d3", - "size": 57375, - "repo_path": "bios/Arcade/MAME/atarisy1.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -64481,9 +64512,9 @@ "size": 6800, "repo_path": "bios/Bally/Astrocade/astrocde.zip", "cores": [ + "fbneo", "mame", - "mamemess", - "fbneo" + "mamemess" ] }, { @@ -76223,11 +76254,8 @@ "size": 7771, "repo_path": "bios/Arcade/Arcade/namcoc69.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "panasonic-3do" + "fbneo", + "mame" ] }, { @@ -76237,11 +76265,8 @@ "size": 7822, "repo_path": "bios/Arcade/Arcade/namcoc70.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "panasonic-3do" + "fbneo", + "mame" ] }, { @@ -76277,11 +76302,8 @@ "size": 8709, "repo_path": "bios/Arcade/Arcade/namcoc75.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "panasonic-3do" + "fbneo", + "mame" ] }, { @@ -82421,8 +82443,8 @@ "size": 262144, "repo_path": "bios/Nintendo/NES/StudyBox.bin", "cores": [ - "mesen", - "mesence" + "mesence", + "mesen" ], "systems": [ "nintendo-nes" @@ -84881,8 +84903,8 @@ "size": 524288, "repo_path": "bios/Sony/PlayStation/scph101.bin", "cores": [ - "pcsx_rearmed", - "rustation" + "rustation", + "pcsx_rearmed" ], "systems": [ "sony-playstation" diff --git a/install/lakka.json b/install/lakka.json index eccc6019..5d35cb54 100644 --- a/install/lakka.json +++ b/install/lakka.json @@ -5,7 +5,7 @@ "platform": "lakka", "display_name": "Lakka", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "system", "detect": [ { @@ -78836,14 +78836,9 @@ "size": 57375, "repo_path": "bios/Arcade/MAME/atarisy1.zip", "cores": [ + "fbneo", "mame" ], - "systems": [ - "3do", - "apple-macintosh-ii", - "epoch-scv", - "philips-cdi" - ], "alternatives": [ { "sha1": "4fb1f00681d2540b476add2def2bbf07ca3e3993", @@ -78874,15 +78869,6 @@ "cores": [ "ume2015" ] - }, - { - "sha1": "0c9bc4b948bd1816ebe13e2acb97931cbcc6ea15", - "sha256": "ae8c41b3d0107c6f0634730267af569a51e49f5df1bdb2c1e6c591bb6251b7d3", - "size": 57375, - "repo_path": "bios/Arcade/MAME/atarisy1.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -79326,9 +79312,9 @@ "size": 6800, "repo_path": "bios/Bally/Astrocade/astrocde.zip", "cores": [ + "fbneo", "mame", - "mamemess", - "fbneo" + "mamemess" ] }, { @@ -100968,8 +100954,8 @@ "size": 262144, "repo_path": "bios/Nintendo/NES/StudyBox.bin", "cores": [ - "mesen", - "mesence" + "mesence", + "mesen" ], "systems": [ "nintendo-nes" diff --git a/install/recalbox.json b/install/recalbox.json index e2928e5e..c2fd19b6 100644 --- a/install/recalbox.json +++ b/install/recalbox.json @@ -5,7 +5,7 @@ "platform": "recalbox", "display_name": "Recalbox", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "bios", "detect": [ { @@ -6629,8 +6629,8 @@ "repo_path": "bios/Sony/PlayStation/psxonpsp660.bin", "cores": [ "beetle_psx", - "pcsx_rearmed", - "swanstation" + "swanstation", + "pcsx_rearmed" ], "systems": [ "sony-playstation" @@ -30713,6 +30713,7 @@ "size": 2240738, "repo_path": "bios/Arcade/Arcade/pgm.zip", "cores": [ + "fbneo", "advancemame", "fba2x", "mame0258", @@ -30721,8 +30722,7 @@ "mame2003_plus", "mame2010", "mame2015", - "mame2016", - "fbneo" + "mame2016" ] }, { @@ -30810,6 +30810,7 @@ "size": 22379, "repo_path": "bios/Arcade/Arcade/decocass.zip", "cores": [ + "fbneo", "advancemame", "mame0258", "mame0278", @@ -30818,8 +30819,7 @@ "mame2003_plus", "mame2010", "mame2015", - "mame2016", - "fbneo" + "mame2016" ] }, { @@ -30889,6 +30889,7 @@ "size": 924762, "repo_path": "bios/Arcade/Arcade/skns.zip", "cores": [ + "fbneo", "advancemame", "mame0258", "mame0278", @@ -30912,8 +30913,7 @@ "cores": [ "mame2010", "mame2015", - "mame2016", - "fbneo" + "mame2016" ] } ] @@ -33712,17 +33712,10 @@ "size": 57375, "repo_path": "bios/Arcade/MAME/atarisy1.zip", "cores": [ + "fbneo", "mame0258", "mame0278" ], - "systems": [ - "3do", - "apple2gs", - "cdi", - "naomi2", - "stv", - "ti994a" - ], "alternatives": [ { "sha1": "4fb1f00681d2540b476add2def2bbf07ca3e3993", @@ -33734,15 +33727,6 @@ "mame2015", "mame2016" ] - }, - { - "sha1": "0c9bc4b948bd1816ebe13e2acb97931cbcc6ea15", - "sha256": "ae8c41b3d0107c6f0634730267af569a51e49f5df1bdb2c1e6c591bb6251b7d3", - "size": 57375, - "repo_path": "bios/Arcade/MAME/atarisy1.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -33753,19 +33737,11 @@ "size": 7950, "repo_path": "bios/Arcade/Arcade/bubsys.zip", "cores": [ + "fbneo", "mame0258", "mame0278", "mame2015", - "mame2016", - "fbneo" - ], - "systems": [ - "3do", - "apple2gs", - "cdi", - "naomi2", - "stv", - "ti994a" + "mame2016" ] }, { @@ -34188,19 +34164,11 @@ "size": 10207, "repo_path": "bios/Arcade/Arcade/isgsm.zip", "cores": [ + "fbneo", "mame0258", "mame0278", "mame2015", - "mame2016", - "fbneo" - ], - "systems": [ - "3do", - "apple2gs", - "cdi", - "naomi2", - "stv", - "ti994a" + "mame2016" ] }, { diff --git a/install/retroarch.json b/install/retroarch.json index 8fed4007..42857008 100644 --- a/install/retroarch.json +++ b/install/retroarch.json @@ -5,7 +5,7 @@ "platform": "retroarch", "display_name": "RetroArch", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "system", "detect": [ { @@ -3501,17 +3501,6 @@ "cores": null, "systems": [ "commodore-c128" - ], - "alternatives": [ - { - "sha1": "b1a5b826304d3df2a27d7163c6a81a532e040d32", - "sha256": "2de4d06b4aa2a1b1129cc641a01cbac7e631de362dbd6fe2129eec7336afad53", - "size": 16384, - "repo_path": "bios/Commodore/C128/JiffyDOS_1541-II.bin", - "cores": [ - "vice" - ] - } ] }, { @@ -3523,17 +3512,6 @@ "cores": null, "systems": [ "commodore-c128" - ], - "alternatives": [ - { - "sha1": "d4b79b60cf1eaa399d0932200eb7811e00455249", - "sha256": "4c0277542ec3d4cc93ece8a5c5dcc2a3fedafd3521a3be06f5d2d9ffbde85355", - "size": 32768, - "repo_path": "bios/Commodore/C128/JiffyDOS_1571_repl310654.bin", - "cores": [ - "vice" - ] - } ] }, { @@ -3545,17 +3523,6 @@ "cores": null, "systems": [ "commodore-c128" - ], - "alternatives": [ - { - "sha1": "65bbf2be7bcd5bdcbff609d6c66471ffb9d04bfe", - "sha256": "ed5fd5b75b204c7134b6294584b80cd2b68611393549f68d0b21c5b9d1818525", - "size": 32768, - "repo_path": "bios/Commodore/C128/JiffyDOS_1581.bin", - "cores": [ - "vice" - ] - } ] }, { @@ -3567,17 +3534,6 @@ "cores": null, "systems": [ "commodore-c128" - ], - "alternatives": [ - { - "sha1": "7d1898f32beae4b2ae610d469ce578a588efaa7c", - "sha256": "1c60cff72b21c63509c2de8fb0cbab3568f3b71b70f910e0f31af92ba45696ae", - "size": 16384, - "repo_path": "bios/Commodore/C128/JiffyDOS_C128.bin", - "cores": [ - "vice" - ] - } ] }, { @@ -3589,17 +3545,6 @@ "cores": null, "systems": [ "commodore-c128" - ], - "alternatives": [ - { - "sha1": "31e73e66eccb28732daea8ec3ad1addd9b39a017", - "sha256": "29f21fb4060c7a21ff3da5e6c89d42f9de82baf89d16b6c3ab56cc1bf7365cae", - "size": 8192, - "repo_path": "bios/Commodore/C128/JiffyDOS_C64.bin", - "cores": [ - "vice" - ] - } ] }, { @@ -10615,17 +10560,6 @@ "cores": null, "systems": [ "sinclair-zx-spectrum" - ], - "alternatives": [ - { - "sha1": "d07fcdeca892ee80494d286ea9ea5bf3928a1aca", - "sha256": "633343620691a592c706d18c927fd539b7069a5d0fb7011bcd3bfc94be418681", - "size": 16384, - "repo_path": "bios/Sinclair/ZX Spectrum/128p-0.rom", - "cores": [ - "fuse" - ] - } ] }, { @@ -10637,17 +10571,6 @@ "cores": null, "systems": [ "sinclair-zx-spectrum" - ], - "alternatives": [ - { - "sha1": "80080644289ed93d71a1103992a154cc9802b2fa", - "sha256": "8d93c3342321e9d1e51d60afcd7d15f6a7afd978c231b43435a7c0757c60b9a3", - "size": 16384, - "repo_path": "bios/Arcade/FBNeo/zx128_1.rom", - "cores": [ - "fuse" - ] - } ] }, { @@ -10659,17 +10582,6 @@ "cores": null, "systems": [ "sinclair-zx-spectrum" - ], - "alternatives": [ - { - "sha1": "477114ff0fe1388e0979df1423602b21248164e5", - "sha256": "c7ba1b5a4895b9ffba4499a40f227f955bc147092532f95023afe3a393bba602", - "size": 16384, - "repo_path": "bios/Sinclair/ZX Spectrum/256s-0.rom", - "cores": [ - "fuse" - ] - } ] }, { @@ -10681,17 +10593,6 @@ "cores": null, "systems": [ "sinclair-zx-spectrum" - ], - "alternatives": [ - { - "sha1": "367b5a102fb663beee8e7930b8c4acc219c1f7b3", - "sha256": "e8b8ec052e5e7283a59173326a954773583fc282ccdf1dfff1724c30fdbc86f8", - "size": 16384, - "repo_path": "bios/Sinclair/ZX Spectrum/256s-1.rom", - "cores": [ - "fuse" - ] - } ] }, { @@ -10703,17 +10604,6 @@ "cores": null, "systems": [ "sinclair-zx-spectrum" - ], - "alternatives": [ - { - "sha1": "07783ee295274d8ff15d935bfd787c8ac1d54900", - "sha256": "9f7571982a90268ef567bb6a8523d7c96db1d9644c85fee83c0ef0fa8a8900e1", - "size": 16384, - "repo_path": "bios/Sinclair/ZX Spectrum/256s-2.rom", - "cores": [ - "fuse" - ] - } ] }, { @@ -10725,17 +10615,6 @@ "cores": null, "systems": [ "sinclair-zx-spectrum" - ], - "alternatives": [ - { - "sha1": "33703e97cc93b7edfcc0334b64233cf81b7930db", - "sha256": "31eda65b203179f466e5fcea8741ecb7c14d3c8f022e622585103e903969eadd", - "size": 16384, - "repo_path": "bios/Sinclair/ZX Spectrum/256s-3.rom", - "cores": [ - "fuse" - ] - } ] }, { @@ -10747,17 +10626,6 @@ "cores": null, "systems": [ "sinclair-zx-spectrum" - ], - "alternatives": [ - { - "sha1": "015eb96dafb273d4f4512c467e9b43c305fd1bc4", - "sha256": "7e0d59753fc1bc1f659baf0a1d982fe36d3a56a4de6c5a21f4023acd00371313", - "size": 16384, - "repo_path": "bios/Sinclair/ZX Spectrum/gluck.rom", - "cores": [ - "fuse" - ] - } ] }, { @@ -10769,17 +10637,6 @@ "cores": null, "systems": [ "sinclair-zx-spectrum" - ], - "alternatives": [ - { - "sha1": "21695e3f2a8f796386ce66eea8a246b0ac44810c", - "sha256": "91259fca6a8ded428cc24046f5b48b31d4043f2afbd9087d8946eaf4e10d71a5", - "size": 16384, - "repo_path": "bios/Sinclair/ZX Spectrum/beta128.trdos.v5-03.rom", - "cores": [ - "fuse" - ] - } ] }, { @@ -13479,17 +13336,6 @@ "cores": null, "systems": [ "xrick" - ], - "alternatives": [ - { - "sha1": "ecfc092fe6371dbf38e238a8ba5f90785b5db52d", - "sha256": "2a86458696e83eb924fc6c6fda3ca5d320ca90885bd6c2f32d121757ade389bb", - "size": 1457440, - "repo_path": "bios/Other/xrick/data.zip", - "cores": [ - "xrick" - ] - } ] }, { @@ -79674,14 +79520,9 @@ "size": 57375, "repo_path": "bios/Arcade/MAME/atarisy1.zip", "cores": [ + "fbneo", "mame" ], - "systems": [ - "3do", - "apple-macintosh-ii", - "epoch-scv", - "philips-cdi" - ], "alternatives": [ { "sha1": "4fb1f00681d2540b476add2def2bbf07ca3e3993", @@ -79712,15 +79553,6 @@ "cores": [ "ume2015" ] - }, - { - "sha1": "0c9bc4b948bd1816ebe13e2acb97931cbcc6ea15", - "sha256": "ae8c41b3d0107c6f0634730267af569a51e49f5df1bdb2c1e6c591bb6251b7d3", - "size": 57375, - "repo_path": "bios/Arcade/MAME/atarisy1.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -80164,9 +79996,9 @@ "size": 6800, "repo_path": "bios/Bally/Astrocade/astrocde.zip", "cores": [ + "fbneo", "mame", - "mamemess", - "fbneo" + "mamemess" ] }, { @@ -101832,8 +101664,8 @@ "size": 262144, "repo_path": "bios/Nintendo/NES/StudyBox.bin", "cores": [ - "mesen", - "mesence" + "mesence", + "mesen" ], "systems": [ "nintendo-nes" diff --git a/install/retrobat.json b/install/retrobat.json index 084e1dfc..70ee667e 100644 --- a/install/retrobat.json +++ b/install/retrobat.json @@ -5,7 +5,7 @@ "platform": "retrobat", "display_name": "RetroBat", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "bios", "detect": [ { @@ -271,9 +271,9 @@ } ], "total_files": 5112, - "total_size": 8194952341, + "total_size": 8194666065, "pack_files": 5114, - "pack_size": 8194952341, + "pack_size": 8194666065, "total_omitted": 154, "omitted_files": [ { @@ -7599,14 +7599,36 @@ }, { "dest": "firmware.bin", - "sha1": "1cf9e67c2c703bb9961bbcdd39cd2c7e319a803b", - "sha256": "b5f5913048244b60f67a58eeac13ca48c1878dcb2aed3ab4fc874b098438ab34", + "sha1": "ede9add041614eaa232059c63d8613b83fe4e954", + "sha256": "23250a16807e891e900004956139cebb6cb0bb66bbf5cea1cd598672a47195f6", "size": 262144, - "repo_path": "bios/Nintendo/Nintendo DS/dsfirmware.bin", + "repo_path": "bios/Nintendo/DS/NDS_Firmware_1.bin", "cores": null, "systems": [ "nintendo-ds" ], + "slot_choices": [ + { + "sha1": "ede9add041614eaa232059c63d8613b83fe4e954", + "sha256": "23250a16807e891e900004956139cebb6cb0bb66bbf5cea1cd598672a47195f6", + "size": 262144, + "repo_path": "bios/Nintendo/DS/NDS_Firmware_1.bin", + "cores": [ + "bizhawk" + ] + } + ], + "declared_choices": [ + { + "sha1": "1cf9e67c2c703bb9961bbcdd39cd2c7e319a803b", + "sha256": "b5f5913048244b60f67a58eeac13ca48c1878dcb2aed3ab4fc874b098438ab34", + "size": 262144, + "repo_path": "bios/Nintendo/Nintendo DS/dsfirmware.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "ede9add041614eaa232059c63d8613b83fe4e954", @@ -7693,6 +7715,30 @@ "systems": [ "nintendo-ds" ], + "slot_choices": [ + { + "sha1": "719b9eef33692406d7170ff526069615759c4dfc", + "sha256": "6e9f70b51dc4f26289b597f14e2513620c2af964374a95f4c5a62b5cad199afc", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios9i.bin", + "cores": [ + "bizhawk", + "melonds", + "melonds_ds" + ] + } + ], + "declared_choices": [ + { + "sha1": "719b9eef33692406d7170ff526069615759c4dfc", + "sha256": "6e9f70b51dc4f26289b597f14e2513620c2af964374a95f4c5a62b5cad199afc", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios9i.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "719b9eef33692406d7170ff526069615759c4dfc", @@ -7717,6 +7763,30 @@ "systems": [ "nintendo-ds" ], + "slot_choices": [ + { + "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", + "sha256": "12a03016ba07cfd874fc002df49a2c6eb2414eb7cfa395c2043035ec5e9193e8", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios7i.bin", + "cores": [ + "bizhawk", + "melonds", + "melonds_ds" + ] + } + ], + "declared_choices": [ + { + "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", + "sha256": "12a03016ba07cfd874fc002df49a2c6eb2414eb7cfa395c2043035ec5e9193e8", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios7i.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", @@ -10896,8 +10966,8 @@ "bizhawk", "duckstation", "mednafen", - "pcsx_rearmed", - "swanstation" + "swanstation", + "pcsx_rearmed" ], "systems": [ "sony-playstation" @@ -10915,8 +10985,8 @@ "bizhawk", "duckstation", "mednafen", - "pcsx_rearmed", - "swanstation" + "swanstation", + "pcsx_rearmed" ], "systems": [ "sony-playstation" @@ -10934,8 +11004,8 @@ "bizhawk", "duckstation", "mednafen", - "pcsx_rearmed", - "swanstation" + "swanstation", + "pcsx_rearmed" ], "systems": [ "sony-playstation" @@ -37099,9 +37169,9 @@ "size": 2069, "repo_path": "bios/Commodore/Amiga/rom.key", "cores": [ + "winuae", "amiberry", - "puae", - "winuae" + "puae" ], "systems": [ "commodore-amiga" @@ -38353,8 +38423,8 @@ "size": 1961984, "repo_path": "bios/Other/cxbx-reloaded/EmuDisk/Partition2/xboxdash.xbe", "cores": [ - "chihiro-ds", "cxbx-reloaded", + "chihiro-ds", "cxbx-reloaded-2023" ], "systems": [ @@ -39860,13 +39930,12 @@ }, { "dest": "hiscore.dat", - "sha1": "7381472bf046126257e51a0124e4553282f020e5", - "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", - "size": 322458, - "repo_path": "bios/Arcade/Arcade/hiscore.dat", + "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", + "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", + "size": 36182, + "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", "cores": [ - "fbalpha2012", - "fbalpha2012_cps3" + "mame2000" ], "alternatives": [ { @@ -39878,15 +39947,6 @@ "groovymame" ] }, - { - "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", - "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", - "size": 36182, - "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", - "cores": [ - "mame2000" - ] - }, { "sha1": "68e94fb579f46f3255ef7c34c0322f3f0f3c8e20", "sha256": "c470a579d8836299e64b835012f7c536115aac695ba9ae10ecd4d168bede54ca", @@ -39911,6 +39971,8 @@ "size": 322458, "repo_path": "bios/Arcade/Arcade/hiscore.dat", "cores": [ + "fbalpha2012", + "fbalpha2012_cps3", "visual-pinball" ] } @@ -40417,6 +40479,7 @@ "size": 2240738, "repo_path": "bios/Arcade/Arcade/pgm.zip", "cores": [ + "fbneo", "groovymame" ], "alternatives": [ @@ -40439,8 +40502,7 @@ "mame2003_plus", "mame2010", "mame2014", - "mame2016", - "fbneo" + "mame2016" ] } ] @@ -40649,6 +40711,7 @@ "size": 10207, "repo_path": "bios/Arcade/Arcade/isgsm.zip", "cores": [ + "fbneo", "groovymame" ], "alternatives": [ @@ -40668,8 +40731,7 @@ "repo_path": "bios/Arcade/Arcade/isgsm.zip", "cores": [ "mame2014", - "mame2016", - "fbneo" + "mame2016" ] } ] @@ -40692,6 +40754,7 @@ "size": 22379, "repo_path": "bios/Arcade/Arcade/decocass.zip", "cores": [ + "fbneo", "groovymame", "mame", "mame2000", @@ -40699,8 +40762,7 @@ "mame2003_plus", "mame2010", "mame2014", - "mame2016", - "fbneo" + "mame2016" ] }, { @@ -41027,11 +41089,11 @@ "size": 7950, "repo_path": "bios/Arcade/Arcade/bubsys.zip", "cores": [ + "fbneo", "groovymame", "mame", "mame2014", - "mame2016", - "fbneo" + "mame2016" ] }, { @@ -41159,6 +41221,7 @@ "size": 924762, "repo_path": "bios/Arcade/Arcade/skns.zip", "cores": [ + "fbneo", "groovymame" ], "alternatives": [ @@ -41197,8 +41260,7 @@ "cores": [ "mame2010", "mame2014", - "mame2016", - "fbneo" + "mame2016" ] } ] @@ -41335,6 +41397,7 @@ "size": 57375, "repo_path": "bios/Arcade/MAME/atarisy1.zip", "cores": [ + "fbneo", "groovymame", "mame" ], @@ -41349,15 +41412,6 @@ "mame2014", "mame2016" ] - }, - { - "sha1": "0c9bc4b948bd1816ebe13e2acb97931cbcc6ea15", - "sha256": "ae8c41b3d0107c6f0634730267af569a51e49f5df1bdb2c1e6c591bb6251b7d3", - "size": 57375, - "repo_path": "bios/Arcade/MAME/atarisy1.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -42193,6 +42247,7 @@ "size": 7609, "repo_path": "bios/Arcade/Arcade/ym2608.zip", "cores": [ + "fbneo", "groovymame" ], "alternatives": [ @@ -42204,15 +42259,6 @@ "cores": [ "mame" ] - }, - { - "sha1": "06fc753d015b43ca1787f4cfd9331b1674202e64", - "sha256": "187ede0f57d1439092a7ac50ea923533871ab7ee65e29baabc4dcdfa7ad4823f", - "size": 7609, - "repo_path": "bios/Arcade/Arcade/ym2608.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -60687,42 +60733,8 @@ "size": 2700, "repo_path": "bios/Arcade/Arcade/cchip.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "archimedes", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "loopy", - "namco-system246", - "neogeo64", - "pegasus", - "pv1000", - "scv", - "segastv", - "ti99", - "triforce", - "tutor", - "vc4000", - "vg5k", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -76044,42 +76056,8 @@ "size": 163, "repo_path": "bios/Arcade/Arcade/midssio.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "archimedes", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "loopy", - "namco-system246", - "neogeo64", - "pegasus", - "pv1000", - "scv", - "segastv", - "ti99", - "triforce", - "tutor", - "vc4000", - "vg5k", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -79917,42 +79895,8 @@ "size": 7771, "repo_path": "bios/Arcade/Arcade/namcoc69.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "archimedes", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "loopy", - "namco-system246", - "neogeo64", - "pegasus", - "pv1000", - "scv", - "segastv", - "ti99", - "triforce", - "tutor", - "vc4000", - "vg5k", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -79962,42 +79906,8 @@ "size": 7822, "repo_path": "bios/Arcade/Arcade/namcoc70.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "archimedes", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "loopy", - "namco-system246", - "neogeo64", - "pegasus", - "pv1000", - "scv", - "segastv", - "ti99", - "triforce", - "tutor", - "vc4000", - "vg5k", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -80095,42 +80005,8 @@ "size": 8709, "repo_path": "bios/Arcade/Arcade/namcoc75.zip", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "archimedes", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "loopy", - "namco-system246", - "neogeo64", - "pegasus", - "pv1000", - "scv", - "segastv", - "ti99", - "triforce", - "tutor", - "vc4000", - "vg5k", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -81284,42 +81160,8 @@ "size": 3556, "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": [ - "mame", - "fbneo" - ], - "systems": [ - "3do", - "adam", - "apfm1000", - "archimedes", - "camplynx", - "cdi", - "chihiro", - "coco", - "crvision", - "electron", - "entex-advision", - "fm7", - "gamate", - "gamecom", - "gamepark-gp32", - "gamepock", - "gmaster", - "hikaru", - "loopy", - "namco-system246", - "neogeo64", - "pegasus", - "pv1000", - "scv", - "segastv", - "ti99", - "triforce", - "tutor", - "vc4000", - "vg5k", - "vtech-vsmile", - "xegs" + "fbneo", + "mame" ] }, { @@ -99771,8 +99613,8 @@ "size": 524288, "repo_path": "bios/Commodore/Amiga/kick31.rom", "cores": [ - "puae", - "winuae" + "winuae", + "puae" ], "systems": [ "commodore-amiga" diff --git a/install/retrodeck.json b/install/retrodeck.json index 46d82665..546d20ce 100644 --- a/install/retrodeck.json +++ b/install/retrodeck.json @@ -5,7 +5,7 @@ "platform": "retrodeck", "display_name": "RetroDECK", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "", "detect": [ { @@ -286,9 +286,9 @@ } ], "total_files": 8560, - "total_size": 8649951793, + "total_size": 8649665517, "pack_files": 8562, - "pack_size": 8649951793, + "pack_size": 8649665517, "total_omitted": 32, "omitted_files": [ { @@ -100124,11 +100124,8 @@ "size": 6478, "repo_path": "bios/Other/dolphin-mmjr/font_western.bin", "cores": [ - "dolphin", - "primehack" - ], - "systems": [ - "triforce" + "primehack", + "dolphin" ] }, { @@ -100138,11 +100135,8 @@ "size": 259626, "repo_path": "bios/Other/dolphin-mmjr/font_japanese.bin", "cores": [ - "dolphin", - "primehack" - ], - "systems": [ - "triforce" + "primehack", + "dolphin" ] }, { @@ -100698,27 +100692,17 @@ }, { "dest": "bios/hiscore.dat", - "sha1": "7381472bf046126257e51a0124e4553282f020e5", - "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", - "size": 322458, - "repo_path": "bios/Arcade/Arcade/hiscore.dat", + "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", + "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", + "size": 36182, + "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", "cores": [ - "fbalpha2012", - "fbalpha2012_cps3" + "mame2000" ], "systems": [ "arcade" ], "alternatives": [ - { - "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", - "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", - "size": 36182, - "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", - "cores": [ - "mame2000" - ] - }, { "sha1": "68e94fb579f46f3255ef7c34c0322f3f0f3c8e20", "sha256": "c470a579d8836299e64b835012f7c536115aac695ba9ae10ecd4d168bede54ca", @@ -100727,6 +100711,16 @@ "cores": [ "mame2015" ] + }, + { + "sha1": "7381472bf046126257e51a0124e4553282f020e5", + "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", + "size": 322458, + "repo_path": "bios/Arcade/Arcade/hiscore.dat", + "cores": [ + "fbalpha2012", + "fbalpha2012_cps3" + ] } ] }, @@ -100860,8 +100854,8 @@ "size": 193854, "repo_path": "bios/Arcade/Arcade/msx.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ], "systems": [ "microsoft-msx" @@ -102222,9 +102216,9 @@ "size": 6800, "repo_path": "bios/Bally/Astrocade/astrocde.zip", "cores": [ + "fbneo", "mame", - "mamemess", - "fbneo" + "mamemess" ] }, { @@ -106879,8 +106873,8 @@ "size": 262144, "repo_path": "bios/Nintendo/NES/StudyBox.bin", "cores": [ - "mesen", - "mesence" + "mesence", + "mesen" ], "systems": [ "nintendo-nes" diff --git a/install/retropie.json b/install/retropie.json index bd6dc5f7..fe8d6e5d 100644 --- a/install/retropie.json +++ b/install/retropie.json @@ -16,9 +16,9 @@ ], "standalone_copies": [], "total_files": 8496, - "total_size": 8907267229, + "total_size": 8907005085, "pack_files": 11052, - "pack_size": 8940634299, + "pack_size": 8940372155, "total_omitted": 214, "omitted_files": [ { @@ -79659,24 +79659,24 @@ }, { "dest": "kick13.rom", - "sha1": "90933936cce43ca9bc6bf375662c076b27e3c458", - "sha256": "1d68ba18412501d2a4b307a0a632b94a50b839c2c7c5ff2df6de2c38b99a921f", - "size": 524288, - "repo_path": "bios/Commodore/Amiga/kick13.rom", + "sha1": "891e9a547772fe0c6c19b610baf8bc4ea7fcb785", + "sha256": "ee05862d8102a08436ac4056da7d549db31625c7d47b24dfb7b3c9a5c113ca53", + "size": 262144, + "repo_path": "bios/Commodore/Amiga/Kickstart-v1.3-rev34.5-1987-Commodore-A500-A1000-A2000-CDTV.rom", "cores": [ - "amiberry" + "fs-uae" ], "systems": [ "commodore-amiga" ], "alternatives": [ { - "sha1": "891e9a547772fe0c6c19b610baf8bc4ea7fcb785", - "sha256": "ee05862d8102a08436ac4056da7d549db31625c7d47b24dfb7b3c9a5c113ca53", - "size": 262144, - "repo_path": "bios/Commodore/Amiga/Kickstart-v1.3-rev34.5-1987-Commodore-A500-A1000-A2000-CDTV.rom", + "sha1": "90933936cce43ca9bc6bf375662c076b27e3c458", + "sha256": "1d68ba18412501d2a4b307a0a632b94a50b839c2c7c5ff2df6de2c38b99a921f", + "size": 524288, + "repo_path": "bios/Commodore/Amiga/kick13.rom", "cores": [ - "fs-uae" + "amiberry" ] } ] @@ -83953,14 +83953,9 @@ "size": 57375, "repo_path": "bios/Arcade/MAME/atarisy1.zip", "cores": [ + "fbneo", "mame" ], - "systems": [ - "3do", - "apple-macintosh-ii", - "epoch-scv", - "philips-cdi" - ], "alternatives": [ { "sha1": "4fb1f00681d2540b476add2def2bbf07ca3e3993", @@ -83991,15 +83986,6 @@ "cores": [ "ume2015" ] - }, - { - "sha1": "0c9bc4b948bd1816ebe13e2acb97931cbcc6ea15", - "sha256": "ae8c41b3d0107c6f0634730267af569a51e49f5df1bdb2c1e6c591bb6251b7d3", - "size": 57375, - "repo_path": "bios/Arcade/MAME/atarisy1.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -84389,9 +84375,9 @@ "size": 6800, "repo_path": "bios/Bally/Astrocade/astrocde.zip", "cores": [ + "fbneo", "mame", - "mamemess", - "fbneo" + "mamemess" ] }, { @@ -105896,8 +105882,8 @@ "size": 262144, "repo_path": "bios/Nintendo/NES/StudyBox.bin", "cores": [ - "mesen", - "mesence" + "mesence", + "mesen" ], "systems": [ "nintendo-nes" diff --git a/install/rocknix.json b/install/rocknix.json index cc25f06d..fdf151df 100644 --- a/install/rocknix.json +++ b/install/rocknix.json @@ -5,7 +5,7 @@ "platform": "rocknix", "display_name": "ROCKNIX", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "bios", "detect": [ { @@ -20,9 +20,9 @@ ], "standalone_copies": [], "total_files": 7118, - "total_size": 8395467125, + "total_size": 8395444210, "pack_files": 7120, - "pack_size": 8395467125, + "pack_size": 8395444210, "total_omitted": 41, "omitted_files": [ { @@ -2015,8 +2015,8 @@ "repo_path": "bios/Atari/5200/5200.rom", "cores": [ "ares", - "a5200", - "atari800" + "atari800", + "a5200" ] }, { @@ -2027,8 +2027,8 @@ "repo_path": "bios/Coleco/ColecoVision/BIOS.col", "cores": [ "ares", - "blastem", - "gearcoleco" + "gearcoleco", + "blastem" ] }, { @@ -2039,12 +2039,12 @@ "repo_path": "bios/Nintendo/Famicom Disk System/.variants/disksys.rom.af5af53f", "cores": [ "ares", + "mesence", + "nestopia", "fceumm", "fixnes", "mednafen", "mesen", - "mesence", - "nestopia", "rustynes" ] }, @@ -2295,8 +2295,8 @@ "repo_path": "bios/NEC/PC Engine/syscard1.pce", "cores": [ "ares", - "beetle_pce", "geargrafx", + "beetle_pce", "mednafen_pce", "mednafen_pce_fast", "mednafen_supergrafx" @@ -2310,8 +2310,8 @@ "repo_path": "bios/NEC/PC Engine/syscard3.pce", "cores": [ "ares", - "beetle_pce", "geargrafx", + "beetle_pce", "mednafen", "mednafen_pce", "mednafen_pce_fast", @@ -2631,11 +2631,11 @@ "repo_path": "bios/Nintendo/SNES/SNES_cx4.rom", "cores": [ "bsnes", + "higan_sfc_balanced", "bsnes2014", "bsnes_hd_beta", "bsnes_mercury", - "higan_sfc", - "higan_sfc_balanced" + "higan_sfc" ] }, { @@ -2659,10 +2659,10 @@ "repo_path": "bios/Nintendo/Super Game Boy/SGB2.sfc/program.rom", "cores": [ "bsnes_hd_beta", + "mesence", "ares", "bsnes", - "mesen-s", - "mesence" + "mesen-s" ] }, { @@ -48390,12 +48390,12 @@ "size": 1048576, "repo_path": "bios/Nintendo/SNES/BS-X.bin", "cores": [ + "mesence", "ares", "bsnes", "bsnes_hd_beta", "chimerasnes", "mesen-s", - "mesence", "snes9x", "snes9x2010", "supersnes9x" @@ -48575,8 +48575,8 @@ "size": 3455065, "repo_path": "bios/Arcade/Arcade/stvbios.zip", "cores": [ - "beetle_saturn", "kronos", + "beetle_saturn", "mame", "mame2003", "mame2003_plus", @@ -50741,24 +50741,14 @@ }, { "dest": "hiscore.dat", - "sha1": "7381472bf046126257e51a0124e4553282f020e5", - "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", - "size": 322458, - "repo_path": "bios/Arcade/Arcade/hiscore.dat", + "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", + "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", + "size": 36182, + "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", "cores": [ - "fbalpha2012", - "fbalpha2012_cps3" + "mame2000" ], "alternatives": [ - { - "sha1": "21ddda0c61bd4f1cc04fb9d09e16d403d620f9e8", - "sha256": "045da3c1124ba1726738db704a8132590ea2c42fdb327392cff05fc124f8ad3b", - "size": 36182, - "repo_path": "bios/Arcade/Arcade/.variants/hiscore.dat.21ddda0c", - "cores": [ - "mame2000" - ] - }, { "sha1": "68e94fb579f46f3255ef7c34c0322f3f0f3c8e20", "sha256": "c470a579d8836299e64b835012f7c536115aac695ba9ae10ecd4d168bede54ca", @@ -50767,6 +50757,16 @@ "cores": [ "mame2015" ] + }, + { + "sha1": "7381472bf046126257e51a0124e4553282f020e5", + "sha256": "9d44fc928c0df41debad1b86739bd6ca96a5891b3b499f6fb3d7e9ea95ca8de5", + "size": 322458, + "repo_path": "bios/Arcade/Arcade/hiscore.dat", + "cores": [ + "fbalpha2012", + "fbalpha2012_cps3" + ] } ] }, @@ -50871,29 +50871,21 @@ }, { "dest": "neogeo.zip", - "sha1": "447e23757e5be7ae688885a5ce8ea3da8c8bedc0", - "sha256": "9a8cf2b88488969d7000028b30474175067f753a649a1fe0c008efd8450d60a0", - "size": 1952551, - "repo_path": "bios/Arcade/MAME/neogeo.zip", + "sha1": "deb62b0074b8cae4f162c257662136733cfc76ad", + "sha256": "bf05cc848e403f8ae457bfe26faa8135cf4725cfff956fa030f5c0a00a26adcc", + "size": 1859335, + "repo_path": "bios/Arcade/Arcade/neogeo.zip", "cores": [ - "fbalpha2019" + "geolith" ], "alternatives": [ - { - "sha1": "deb62b0074b8cae4f162c257662136733cfc76ad", - "sha256": "bf05cc848e403f8ae457bfe26faa8135cf4725cfff956fa030f5c0a00a26adcc", - "size": 1859335, - "repo_path": "bios/Arcade/Arcade/neogeo.zip", - "cores": [ - "geolith" - ] - }, { "sha1": "447e23757e5be7ae688885a5ce8ea3da8c8bedc0", "sha256": "9a8cf2b88488969d7000028b30474175067f753a649a1fe0c008efd8450d60a0", "size": 1952551, "repo_path": "bios/Arcade/MAME/neogeo.zip", "cores": [ + "fbalpha2019", "mame" ] }, @@ -50983,21 +50975,21 @@ }, { "dest": "neocdz.zip", - "sha1": "80c9a25ccf39c6b85d1a7c9c0d6fe527ab45ec1c", - "sha256": "ce9df4f0e7706c485713a143802ddcc43e13c533944ef29054bfe1cbab5f0c5b", - "size": 598775, - "repo_path": "bios/SNK/Neo Geo CD/neocdz.zip", + "sha1": "712816a3c41baf4129e23d645305a536e03d69de", + "sha256": "f885f2cc79b5c0120ca316b0db360183c2b9a60bc727cbe211b7518e12bd5548", + "size": 955352, + "repo_path": "bios/SNK/Neo Geo CD/.variants/neocdz.zip.712816a3", "cores": [ - "fbalpha2019" + "geolith" ], "alternatives": [ { - "sha1": "712816a3c41baf4129e23d645305a536e03d69de", - "sha256": "f885f2cc79b5c0120ca316b0db360183c2b9a60bc727cbe211b7518e12bd5548", - "size": 955352, - "repo_path": "bios/SNK/Neo Geo CD/.variants/neocdz.zip.712816a3", + "sha1": "80c9a25ccf39c6b85d1a7c9c0d6fe527ab45ec1c", + "sha256": "ce9df4f0e7706c485713a143802ddcc43e13c533944ef29054bfe1cbab5f0c5b", + "size": 598775, + "repo_path": "bios/SNK/Neo Geo CD/neocdz.zip", "cores": [ - "geolith" + "fbalpha2019" ] }, { @@ -51018,6 +51010,7 @@ "size": 2240738, "repo_path": "bios/Arcade/Arcade/pgm.zip", "cores": [ + "fbneo", "fbalpha2019" ], "alternatives": [ @@ -51043,8 +51036,7 @@ "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] } ] @@ -51056,6 +51048,7 @@ "size": 924762, "repo_path": "bios/Arcade/Arcade/skns.zip", "cores": [ + "fbneo", "fbalpha2019" ], "alternatives": [ @@ -51097,8 +51090,7 @@ "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] } ] @@ -51110,9 +51102,9 @@ "size": 163, "repo_path": "bios/Arcade/Arcade/midssio.zip", "cores": [ + "fbneo", "fbalpha2019", - "mame", - "fbneo" + "mame" ] }, { @@ -51132,8 +51124,8 @@ "size": 28014, "repo_path": "bios/Arcade/Arcade/coleco.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ] }, { @@ -51143,8 +51135,8 @@ "size": 193854, "repo_path": "bios/Arcade/Arcade/msx.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ] }, { @@ -51154,8 +51146,8 @@ "size": 16506, "repo_path": "bios/Arcade/Arcade/spectrum.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ] }, { @@ -51165,28 +51157,36 @@ "size": 32986, "repo_path": "bios/Arcade/Arcade/spec128.zip", "cores": [ - "fbalpha2019", - "fbneo" + "fbneo", + "fbalpha2019" ] }, { "dest": "nmk004.zip", - "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", - "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", + "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", "size": 3556, - "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": [ - "fbalpha2019" + "fbneo" ], "alternatives": [ + { + "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", + "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "size": 3556, + "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "cores": [ + "fbalpha2019" + ] + }, { "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", "size": 3556, "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": [ - "mame", - "fbneo" + "mame" ] } ] @@ -51198,6 +51198,7 @@ "size": 22379, "repo_path": "bios/Arcade/Arcade/decocass.zip", "cores": [ + "fbneo", "fbalpha2019", "mame", "mame2000", @@ -51208,8 +51209,7 @@ "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] }, { @@ -51219,6 +51219,7 @@ "size": 10207, "repo_path": "bios/Arcade/Arcade/isgsm.zip", "cores": [ + "fbneo", "fbalpha2019" ], "alternatives": [ @@ -51240,8 +51241,7 @@ "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] } ] @@ -51253,9 +51253,9 @@ "size": 2700, "repo_path": "bios/Arcade/Arcade/cchip.zip", "cores": [ + "fbneo", "fbalpha2019", - "mame", - "fbneo" + "mame" ] }, { @@ -51265,6 +51265,7 @@ "size": 7609, "repo_path": "bios/Arcade/Arcade/ym2608.zip", "cores": [ + "fbneo", "fbalpha2019" ], "alternatives": [ @@ -51276,15 +51277,6 @@ "cores": [ "mame" ] - }, - { - "sha1": "06fc753d015b43ca1787f4cfd9331b1674202e64", - "sha256": "187ede0f57d1439092a7ac50ea923533871ab7ee65e29baabc4dcdfa7ad4823f", - "size": 7609, - "repo_path": "bios/Arcade/Arcade/ym2608.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -53956,12 +53948,12 @@ "size": 7950, "repo_path": "bios/Arcade/Arcade/bubsys.zip", "cores": [ + "fbneo", "mame", "mame2015", "mame2016", "mamearcade", - "ume2015", - "fbneo" + "ume2015" ] }, { @@ -54368,6 +54360,7 @@ "size": 57375, "repo_path": "bios/Arcade/MAME/atarisy1.zip", "cores": [ + "fbneo", "mame" ], "alternatives": [ @@ -54400,15 +54393,6 @@ "cores": [ "ume2015" ] - }, - { - "sha1": "0c9bc4b948bd1816ebe13e2acb97931cbcc6ea15", - "sha256": "ae8c41b3d0107c6f0634730267af569a51e49f5df1bdb2c1e6c591bb6251b7d3", - "size": 57375, - "repo_path": "bios/Arcade/MAME/atarisy1.zip", - "cores": [ - "fbneo" - ] } ] }, @@ -54738,9 +54722,9 @@ "size": 6800, "repo_path": "bios/Bally/Astrocade/astrocde.zip", "cores": [ + "fbneo", "mame", - "mamemess", - "fbneo" + "mamemess" ] }, { @@ -64026,8 +64010,8 @@ "size": 7771, "repo_path": "bios/Arcade/Arcade/namcoc69.zip", "cores": [ - "mame", - "fbneo" + "fbneo", + "mame" ] }, { @@ -64037,8 +64021,8 @@ "size": 7822, "repo_path": "bios/Arcade/Arcade/namcoc70.zip", "cores": [ - "mame", - "fbneo" + "fbneo", + "mame" ] }, { @@ -64068,8 +64052,8 @@ "size": 8709, "repo_path": "bios/Arcade/Arcade/namcoc75.zip", "cores": [ - "mame", - "fbneo" + "fbneo", + "mame" ] }, { @@ -68969,8 +68953,8 @@ "size": 262144, "repo_path": "bios/Nintendo/NES/StudyBox.bin", "cores": [ - "mesen", - "mesence" + "mesence", + "mesen" ] }, { diff --git a/install/romm.json b/install/romm.json index 2629f425..39d5167f 100644 --- a/install/romm.json +++ b/install/romm.json @@ -5,7 +5,7 @@ "platform": "romm", "display_name": "RomM", "version": "1.0", - "generated": "2026-10-20T19:45:33Z", + "generated": "2026-10-20T20:05:06Z", "base_destination": "bios", "detect": [ {