From 5816562bf6f1389c3d4acc243f2e7c051dec7e3e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:51:13 +0200 Subject: [PATCH] fix: one size predicate, size_options retired Co-Authored-By: Claude Opus 5.5 --- emulators/aps3e.yml | 2 +- emulators/beebem.yml | 3 +-- emulators/hakux.yml | 2 +- emulators/melonds-nightly.yml | 2 +- emulators/msx-emu.yml | 2 +- emulators/x1-box.yml | 2 +- emulators/xm6typeg.yml | 10 ++++---- emulators/zesarux.yml | 9 +++---- schemas/emulator.schema.json | 6 ----- scripts/common.py | 16 ++++++++----- scripts/generate_pack.py | 36 ++++++++++++++++------------ scripts/generate_site.py | 5 ---- scripts/packextras.py | 12 ++-------- tests/test_agnostic_scan.py | 19 +++++++++++++++ tests/test_path_suffix.py | 11 +++++++++ tests/test_resolution_evidence.py | 39 +++++++++++++++++++++++++++++++ 16 files changed, 115 insertions(+), 61 deletions(-) diff --git a/emulators/aps3e.yml b/emulators/aps3e.yml index 2f41c825..00d63c8b 100644 --- a/emulators/aps3e.yml +++ b/emulators/aps3e.yml @@ -124,7 +124,7 @@ files: - name: ".dkey" aliases: [".key"] required: false - size_options: [16, 32] + size: [16, 32] validation: [size] unsourceable: "per-disc AES-128 key named after the image it decrypts, one per title, carried by disc dump catalogues rather than by the emulator" description: "Redump disc decryption key read beside an encrypted ISO" diff --git a/emulators/beebem.yml b/emulators/beebem.yml index 8d975478..99fe6923 100644 --- a/emulators/beebem.yml +++ b/emulators/beebem.yml @@ -498,10 +498,9 @@ files: system: bbc-micro-b required: false bundled: true - size: 8192 + size: [2048, 4096, 8192] md5: f786d0647f9732fa065695d569d261ab sha1: e6ee738e5f2f8556002b79d18caa8ef21f14e08d - size_options: [2048, 4096, 8192] note: > Loaded for TubeDevice::TorchZ80. Up to 8192 bytes are read and a short image is mirrored to fill the 8 KB window: a 2 KB image is repeated four times, a diff --git a/emulators/hakux.yml b/emulators/hakux.yml index dc507b55..7b8bc3cf 100644 --- a/emulators/hakux.yml +++ b/emulators/hakux.yml @@ -117,7 +117,7 @@ files: required: true path: "x1box/flash.bin" config_key: "sys.files.flashrom_path" - size_options: [262144, 524288, 1048576] + min_size: 65536 size_note: "Any non-zero multiple of 65536; 256 KB is assumed when the image cannot be read." validation: [size] description: "Xbox flash ROM" diff --git a/emulators/melonds-nightly.yml b/emulators/melonds-nightly.yml index c65f4ce2..57e5e6d3 100644 --- a/emulators/melonds-nightly.yml +++ b/emulators/melonds-nightly.yml @@ -113,7 +113,7 @@ files: description: "Nintendo DS firmware image" required: false hle_fallback: true - size_options: [131072, 262144, 524288] + size: [131072, 262144, 524288] validation: [size] note: >- Three lengths are accepted and everything else is rejected. Loaded only in diff --git a/emulators/msx-emu.yml b/emulators/msx-emu.yml index 9e5d671d..5afa2c2c 100644 --- a/emulators/msx-emu.yml +++ b/emulators/msx-emu.yml @@ -372,7 +372,7 @@ files: path: "Machines/Shared Roms/KANJI.rom" system: msx2plus required: true - size_options: [131072, 262144] + size: [131072, 262144] validation: [size] description: "Kanji character ROM, named by the MSX2+ and turbo R machine definitions" note: >- diff --git a/emulators/x1-box.yml b/emulators/x1-box.yml index 6fb8d67c..456744c6 100644 --- a/emulators/x1-box.yml +++ b/emulators/x1-box.yml @@ -114,7 +114,7 @@ files: required: true path: "x1box/flash.bin" config_key: "sys.files.flashrom_path" - size_options: [262144, 524288, 1048576] + min_size: 65536 size_note: "Any non-zero multiple of 65536; 256 KB is assumed when the image cannot be read." validation: [size] description: "Xbox flash ROM" diff --git a/emulators/xm6typeg.yml b/emulators/xm6typeg.yml index 821c4a6c..00098988 100644 --- a/emulators/xm6typeg.yml +++ b/emulators/xm6typeg.yml @@ -239,7 +239,6 @@ files: system: sharp-x68000 required: false min_size: 8192 - size_options: [8192, 131072] validation: [size] description: "Internal SCSI interface ROM, SUPER and later" source_ref: "xm6g.pdf p.17, xm6g.exe:0x1404fd1ef (reads at 0x1404fd327, 0x1404fd3dd), XM6 2.06 vm/memory.cpp:305-310, 328-345" @@ -254,7 +253,6 @@ files: system: sharp-x68000 required: false min_size: 8160 - size_options: [8160, 8192, 131072] validation: [size] description: "External SCSI interface ROM, CZ-6BS1 board" source_ref: "xm6g.pdf p.17, xm6g.exe:0x1404fd20a (retry at 0x1404fd419), XM6 2.06 vm/memory.cpp:316-318, 341-352" @@ -270,7 +268,7 @@ files: - name: SRAM.DAT system: sharp-x68000 required: false - size_options: [16384, 65536] + size: [16384, 65536] description: "SRAM image for EXPERT and the other base models" source_ref: "xm6g.pdf p.18, xm6g.exe:0x140592350 index 14, XM6 2.06 vm/sram.cpp:70-81, 112" note: >- @@ -282,7 +280,7 @@ files: - name: SRAMXVI.DAT system: sharp-x68000 required: false - size_options: [16384, 65536] + size: [16384, 65536] description: "SRAM image for XVI" source_ref: "xm6g.pdf p.18, xm6g.exe:0x140592350 index 15" note: >- @@ -292,7 +290,7 @@ files: - name: SRAMCMP.DAT system: sharp-x68000 required: false - size_options: [16384, 65536] + size: [16384, 65536] description: "SRAM image for Compact" source_ref: "xm6g.pdf p.18, xm6g.exe:0x140592350 index 16" note: >- @@ -301,7 +299,7 @@ files: - name: SRAM030.DAT system: sharp-x68000 required: false - size_options: [16384, 65536] + size: [16384, 65536] description: "SRAM image for X68030, 040turbo and 060turbo" source_ref: "xm6g.pdf p.18, xm6g.exe:0x140592350 index 17" note: >- diff --git a/emulators/zesarux.yml b/emulators/zesarux.yml index 439d8a3a..a6deef2d 100644 --- a/emulators/zesarux.yml +++ b/emulators/zesarux.yml @@ -344,8 +344,7 @@ files: # ============================================================ - name: "zx80.rom" - size: 4096 - size_options: [4096, 8192] + size: [4096, 8192] validation: [size] required: true bundled: true @@ -630,8 +629,7 @@ files: note: "Beta Disk / TR-DOS firmware." - name: "if1-v1.rom" - size: 8192 - size_options: [8192, 16384] + size: [8192, 16384] validation: [size] required: false bundled: true @@ -640,8 +638,7 @@ files: note: "Interface 1 firmware version 1, selected when if1_rom_version is 1. The load accepts 8192 or 16384 bytes and disables Interface 1 emulation otherwise." - name: "if1-v2.rom" - size: 8192 - size_options: [8192, 16384] + size: [8192, 16384] validation: [size] required: false bundled: true diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index 8e8a9ccd..50c3b722 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -415,12 +415,6 @@ "size_note": { "type": "string" }, - "size_options": { - "type": "array", - "items": { - "type": "integer" - } - }, "standalone_path": { "type": "string" }, diff --git a/scripts/common.py b/scripts/common.py index 80413e4c..55f460eb 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -64,6 +64,15 @@ def name_match_size_ok(file_entry: dict, candidate_size: int | None) -> bool: validation = validation.get("core", []) if "size" not in (validation or []) or candidate_size is None: return True + return size_fits(file_entry, candidate_size) + + +def size_fits(file_entry: dict, candidate_size: int) -> bool: + """Whether a size is one the entry declares. + + ``size`` is an exact size or the list of sizes the code accepts; it + outranks a ``min_size``/``max_size`` range. Nothing declared fits all. + """ declared = file_entry.get("size") if declared is not None: allowed = declared if isinstance(declared, list) else [declared] @@ -450,16 +459,11 @@ def _resolve_agnostic(file_entry: dict, files_db: dict, has_strong_hash: bool): prefix = file_entry.get("agnostic_path_prefix", "") if not prefix: return None - min_size = file_entry.get("min_size", 0) - max_size = file_entry.get("max_size", float("inf")) - exact_size = file_entry.get("size") - if exact_size and not min_size: - min_size = max_size = exact_size for _sha1, entry in files_db.items(): path = entry.get("path", "") if not path.startswith(prefix): continue - if min_size <= entry.get("size", 0) <= max_size and os.path.exists(path): + if size_fits(file_entry, entry.get("size", 0)) and os.path.exists(path): return path, "agnostic_fallback" return None diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index b6cc6175..22e1c2e9 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -57,6 +57,7 @@ from common import ( resolve_local_file, sanitize_pack_path, select_emulator_profiles, + size_fits, yaml_load, ) import packresolve @@ -65,6 +66,7 @@ import slot as slot_mod import slots import split_pack from deterministic_zip import _FIXED_DATE_TIME, rebuild_zip_deterministic +from largefiles import asset_name from nativemode import ( digest_algorithm, hash_mismatch_excludes_file, @@ -847,14 +849,9 @@ def generate_pack( _path = _entry.get("path", "") if _path: _prefix = _path.rsplit("/", 1)[0] + "/" - _min = _ef.get("min_size", 0) - _max = _ef.get("max_size", float("inf")) - if _ef.get("size") and not _min: - _min = _ef["size"] - _max = _ef["size"] for _s, _e in files_db.items(): if _e.get("path", "").startswith(_prefix): - if _min <= _e.get("size", 0) <= _max: + if size_fits(_ef, _e.get("size", 0)): if os.path.exists(_e["path"]): local_path = _e["path"] agnostic_path = _prefix @@ -2765,12 +2762,23 @@ def _is_release_asset(local_path: str, repo_root: str) -> bool: is still served by the repository, and announcing it as a release asset sends the installer to an asset nobody uploaded. """ - gitignore = _load_gitignore_entries(repo_root) + return _repo_relative(local_path, repo_root) in _load_gitignore_entries(repo_root) + + +def _repo_relative(local_path: str, repo_root: str) -> str: try: - rel = os.path.relpath(local_path, repo_root) + return os.path.relpath(local_path, repo_root) except ValueError: - rel = "" - return rel in gitignore + return "" + + +def _release_asset_name(local_path: str, repo_root: str) -> str: + """The large-files asset install.py downloads for a gitignored file.""" + registered = [ + entry for entry in _load_gitignore_entries(repo_root) + if entry.startswith("bios/") + ] + return asset_name(_repo_relative(local_path, repo_root), registered) def _get_repo_path(sha1: str, db: dict) -> str: @@ -2880,9 +2888,7 @@ def _manifest_core_entries( if _is_release_asset(local_path or "", repo_root): entry["storage"] = "release" - entry["release_asset"] = ( - os.path.basename(local_path) if local_path else fe["name"] - ) + entry["release_asset"] = _release_asset_name(local_path, repo_root) manifest_files.append(entry) omitted_by_destination.pop(full_dest, None) @@ -3117,8 +3123,8 @@ def generate_manifest( if is_release_asset: entry["storage"] = "release" - entry["release_asset"] = ( - os.path.basename(local_path) if local_path else file_entry["name"] + entry["release_asset"] = _release_asset_name( + local_path, repo_root ) manifest_files.append(entry) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 39e2b2f4..1f038231 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -1976,7 +1976,6 @@ def _render_emulator_file( fpattern = f.get("pattern", "") region_check = f.get("region_check") size_note = f.get("size_note", "") - size_options = f.get("size_options", []) size_range = f.get("size_range", "") badges = _file_badges(f, in_repo) @@ -2062,10 +2061,6 @@ def _render_emulator_file( details.append(f"Region check: {'yes' if region_check else 'no'}") if size_note: details.append(f"Size note: {size_note}") - if size_options: - details.append( - f"Size options: {', '.join(_fmt_size(s) for s in size_options)}" - ) if size_range: details.append(f"Size range: {size_range}") diff --git a/scripts/packextras.py b/scripts/packextras.py index 0765cda2..d21e2d54 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -12,6 +12,7 @@ from common import resolution_is_hash_exact from common import resolve_local_file from common import runs_standalone from common import sanitize_pack_path +from common import size_fits def _emulator_systems_index(emu_profiles: dict | None) -> dict[str, list[str]]: """Map both the profile key and its display name to the profile's systems. @@ -213,21 +214,12 @@ def _agnostic_scan_extras( if ambiguous and agnostic_votes.get(path_prefix, 0) < 2: continue - # Size criteria from the file entry - min_size = f.get("min_size", 0) - max_size = f.get("max_size", float("inf")) - exact_size = f.get("size") - if exact_size and not min_size: - min_size = exact_size - max_size = exact_size - # Scan DB for all files under this prefix matching size for sha1, entry in files_db.items(): path = entry.get("path", "") if not path.startswith(path_prefix): continue - size = entry.get("size", 0) - if not (min_size <= size <= max_size): + if not size_fits(f, entry.get("size", 0)): continue scan_name = entry.get("name", "") if not scan_name: diff --git a/tests/test_agnostic_scan.py b/tests/test_agnostic_scan.py index 385398e5..94d6d368 100644 --- a/tests/test_agnostic_scan.py +++ b/tests/test_agnostic_scan.py @@ -106,6 +106,25 @@ class AgnosticScanStaysInItsTree(unittest.TestCase): ) return [e for e in extras if e.get("agnostic_scan")] + def test_a_list_of_accepted_sizes_selects_the_scan(self): + """`size` may list every size the code accepts. The scan compared it + as one integer and failed on a list.""" + profiles = self._write_profile( + "emulator: demo\n" + "type: libretro\n" + "display_name: Demo\n" + "bios_mode: agnostic\n" + "systems: [demo-system]\n" + "cores: [demo]\n" + "files:\n" + " - name: boot.bin\n" + " system: demo-system\n" + " size: [512, 1024]\n" + " agnostic: true\n" + ) + names = sorted(e["name"] for e in self._scan(profiles)) + self.assertIn("boot.bin", names) + def test_it_collects_the_interchangeable_dumps(self): profiles = self._write_profile( "emulator: demo\n" diff --git a/tests/test_path_suffix.py b/tests/test_path_suffix.py index 2b5e8fe0..d9174412 100644 --- a/tests/test_path_suffix.py +++ b/tests/test_path_suffix.py @@ -174,6 +174,17 @@ class TestSizeGateOnTheNameStep(unittest.TestCase): self.assertIsNotNone(path) self.assertEqual(status, "hash_mismatch") + def test_a_list_of_accepted_sizes_gates_the_name_step(self): + # SC-55/ROM2.BIN accepts two sizes; a PlayStation 2 ROM2.BIN of a + # third size answered to the name because the list was ignored. + entry = {"name": "ROM", "size": [8, 999], "validation": ["size"]} + path, status = resolve_local_file(entry, self.db) + self.assertEqual(status, "name_exact") + self.assertIn("IIGS", path) + entry = {"name": "ROM", "size": [999, 1000], "validation": ["size"]} + path, status = resolve_local_file(entry, self.db) + self.assertEqual(status, "hash_mismatch") + def test_a_size_without_validation_rejects_nothing(self): # Documented behaviour: a size is informative until the emulator # itself checks it. diff --git a/tests/test_resolution_evidence.py b/tests/test_resolution_evidence.py index 0f0684b1..beb95195 100644 --- a/tests/test_resolution_evidence.py +++ b/tests/test_resolution_evidence.py @@ -198,6 +198,19 @@ class AgnosticFallback(unittest.TestCase): } self.assertIsNotNone(common._resolve_agnostic(entry, self.files_db, False)) + def test_a_list_of_accepted_sizes_is_honoured(self): + entry = { + "agnostic": True, + "agnostic_path_prefix": str(self.root), + "size": [32, 64], + } + self.assertEqual( + common._resolve_agnostic(entry, self.files_db, False), + (str(self.match), "agnostic_fallback"), + ) + entry["size"] = [32, 65] + self.assertIsNone(common._resolve_agnostic(entry, self.files_db, False)) + def test_without_a_prefix_nothing_is_scanned(self): entry = {"agnostic": True, "size": 64} self.assertIsNone(common._resolve_agnostic(entry, self.files_db, False)) @@ -207,5 +220,31 @@ class AgnosticFallback(unittest.TestCase): self.assertIsNone(common._resolve_agnostic(entry, self.files_db, False)) +class OneSpellingForAcceptedSizes(unittest.TestCase): + """A set of sizes the code accepts is ``size: [a, b]`` and nothing else. + + SC-55/ROM2.BIN declared its two sizes under a field the resolver never + read and resolved by name to a PlayStation 2 ROM of a third size. + """ + + def test_no_profile_uses_a_second_field(self): + offenders = [ + path.name + for path in sorted((REPO_ROOT / "emulators").glob("*.yml")) + if "size_options" in path.read_text(encoding="utf-8") + ] + self.assertEqual(offenders, []) + + def test_the_schema_refuses_the_second_field(self): + import json + + schema = json.loads( + (REPO_ROOT / "schemas/emulator.schema.json").read_text(encoding="utf-8") + ) + file_entry = schema["properties"]["files"]["items"]["properties"] + self.assertNotIn("size_options", file_entry) + self.assertEqual(file_entry["size"]["type"], ["integer", "array", "null"]) + + if __name__ == "__main__": unittest.main()