diff --git a/install.ps1 b/install.ps1 index bcd51894..e79f313e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "bbc1b4665a37255deecf941765a1c13bb271da54ecf626bd218747165a126820" +$defaultInstallSha256 = "f9e46dd53fc7501ddf18ece3fc7f08dbf738c617fec93707c6a7f40e8db39673" $maximumInstallerBytes = 2MB $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $temporary = $null diff --git a/install.py b/install.py index b73fa40d..47bbe1d4 100644 --- a/install.py +++ b/install.py @@ -1234,7 +1234,20 @@ def _validate_manifest(data: object, plat: str) -> dict: or not all(isinstance(core, str) for core in cores) ): raise ValueError(f"invalid omitted cores list for {dest}") - for field in SELECTION_FIELDS: + claims = entry.get("alternatives", []) + if not isinstance(claims, list) or len(claims) > MAX_SELECTION_TAGS: + raise ValueError(f"invalid omitted alternatives for {dest}") + for claim in claims: + claim_cores = claim.get("cores") if isinstance(claim, dict) else None + if ( + not isinstance(claim, dict) + or set(claim) != {"cores"} + or not isinstance(claim_cores, list) + or not claim_cores + or not all(isinstance(core, str) for core in claim_cores) + ): + raise ValueError(f"invalid omitted alternative for {dest}") + for field in (*SELECTION_FIELDS, "declared_in"): _check_tags(entry, field, dest) declared_total_omitted = data.get("total_omitted") if ( diff --git a/install.sh b/install.sh index a7a879ef..91c71c1c 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="bbc1b4665a37255deecf941765a1c13bb271da54ecf626bd218747165a126820" +DEFAULT_INSTALL_SHA256="f9e46dd53fc7501ddf18ece3fc7f08dbf738c617fec93707c6a7f40e8db39673" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 4494ab5d..51d27948 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -257,6 +257,36 @@ "priority": { "description": "The rank the code searches the file at, lower first: a world file withdraws an unmatched regional one only when it ranks no worse.", "type": "integer" + }, + "declared_in": { + "description": "The platform systems declaring the file, when fewer than `systems`, which also names the systems whose cores read it there.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "alternatives": { + "description": "Cores claiming this destination behind the platform's own declaration, in the order the pack builder tries them; a narrowing that drops every declaring system takes the first whose cores remain.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "cores" + ], + "properties": { + "cores": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + } + } } } } diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index d14983b3..82a396eb 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -3231,14 +3231,17 @@ def _manifest_core_entries( ) if status in ("not_found", "external", "user_provided") or not local_path: source_emu = fe.get("source_profile") or fe.get("source_emulator", "") - systems = _extra_system_ids(fe) - record_omission( - full_dest, - fe, - systems[0] if systems else "", - status, - [source_emu] if source_emu else [], - ) + if not _claim_platform_omission( + omitted_by_destination.get(full_dest), source_emu + ): + systems = _extra_system_ids(fe) + record_omission( + full_dest, + fe, + systems[0] if systems else "", + status, + [source_emu] if source_emu else [], + ) continue # The copy the emulator accepts, as the pack ships it. local_path, _disagreement = validated_choice( @@ -3259,11 +3262,14 @@ def _manifest_core_entries( selection.fields(dest, fe.get("name", "")), ) if entry is None: - systems = _extra_system_ids(fe) - record_omission( - full_dest, fe, systems[0] if systems else "", "not_found", - [source_emu] if source_emu else [], - ) + if not _claim_platform_omission( + omitted_by_destination.get(full_dest), source_emu + ): + systems = _extra_system_ids(fe) + record_omission( + full_dest, fe, systems[0] if systems else "", "not_found", + [source_emu] if source_emu else [], + ) if file_size: pack_only_sizes.append(file_size) continue @@ -3285,6 +3291,23 @@ def _manifest_core_entries( return total_size +def _claim_platform_omission(held: dict | None, emulator: str) -> bool: + """Record a core's claim on a destination the platform already omits. + + The platform's own omission stands while one of its declaring systems + does; a pack built without them falls to the first core claiming the + path, as for a file present. Overwriting the record lost the platform's + claim: a target without that core dropped an omission its pack reports. + """ + if held is None or held.get("cores") is not None: + return False + if emulator: + claims = held.setdefault("alternatives", []) + if {"cores": [emulator]} not in claims: + claims.append({"cores": [emulator]}) + return True + + def _manifest_selection( config: dict, pack_systems: dict, @@ -3599,6 +3622,16 @@ def generate_manifest( entry, claims, db, bios_dir, zip_contents, offline, required_only, manifest_validation, manifest_owners, dest, dest, repo_root, ) + # A platform omission narrows like a platform file: by the systems + # declaring it, which can be fewer than the ones whose cores read it. + for full_dest, omission in omitted_by_destination.items(): + declared = declared_by.get(full_dest, set()) + if ( + omission.get("cores") is None + and declared + and declared != set(omission.get("systems") or []) + ): + omission["declared_in"] = sorted(declared) # Phase 3: data directories. The installer does not fetch them, so they # stay out of the file list; the pack carries them, so they count toward diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index 235dbfe2..64b47bb5 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -192,6 +192,58 @@ class TheBoundaryBoundsAlternatives(unittest.TestCase): install._validate_manifest(manifest, "retroarch") +class APlatformOmissionKeepsItsClaim(unittest.TestCase): + """A file the platform declares and a core reads, absent from the + collection, was recorded under the core alone: RetroPie's dsi_nand.bin + went to melonds, and a target without melonds lost the omission its + pack reports for nintendo-ds.""" + + OMISSION = { + "dest": "dsi_nand.bin", "name": "dsi_nand.bin", "system": "nintendo-ds", + "required": True, "reason": "not_found", "cores": None, + "systems": ["nintendo-ds", "nintendo-dsi"], "declared_in": ["nintendo-ds"], + "alternatives": [{"cores": ["melonds"]}], + } + MANIFEST = { + "files": [], "omitted_files": [OMISSION], + "core_names": {"melonds": ["melonds"], "desmume": ["desmume"]}, + "system_cores": {"nintendo-ds": ["desmume"], "nintendo-dsi": ["melonds"]}, + } + + def test_the_declaring_system_keeps_it(self): + _, omitted = install.narrow_to_cores(self.MANIFEST, ["desmume"]) + self.assertEqual(omitted, [self.OMISSION]) + + def test_a_dropped_system_falls_to_the_claiming_core(self): + _, omitted = install.narrow_to_cores(self.MANIFEST, ["melonds"]) + self.assertEqual([o["cores"] for o in omitted], [["melonds"]]) + _, omitted = install.narrow_to_cores(self.MANIFEST, ["mgba"]) + self.assertEqual(omitted, []) + + def test_the_generator_files_the_core_behind_the_platform(self): + sys.path.insert(0, str(REPO_ROOT / "scripts")) + import generate_pack as gp + + held = {"cores": None} + self.assertTrue(gp._claim_platform_omission(held, "melonds")) + self.assertTrue(gp._claim_platform_omission(held, "melonds")) + self.assertEqual(held["alternatives"], [{"cores": ["melonds"]}]) + self.assertFalse(gp._claim_platform_omission({"cores": ["x"]}, "melonds")) + self.assertFalse(gp._claim_platform_omission(None, "melonds")) + + def test_the_boundary_takes_only_core_names(self): + manifest = {"manifest_version": 2, "platform": "retroarch", "files": [], + "omitted_files": [self.OMISSION]} + install._validate_manifest(manifest, "retroarch") + for claims in ([{"cores": []}], [{"cores": ["x"], "sha1": "a" * 40}], + ["melonds"], [5], {"cores": ["x"]}): + bad = {**self.OMISSION, "alternatives": claims} + with self.subTest(claims=claims), self.assertRaises(ValueError): + install._validate_manifest( + {**manifest, "omitted_files": [bad]}, "retroarch" + ) + + class RefusedBeforeAnyInstall(unittest.TestCase): def test_a_platform_shipping_its_cores_stops_the_run_first(self): tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))