From 7b93285e1da958bffad1ff131422f32fca072cd2 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:25:24 +0200 Subject: [PATCH] fix: read the build mode per emulator when arbitrating --- scripts/generate_pack.py | 6 +++- scripts/slots.py | 47 +++++++++++++++++++++++---- scripts/verify.py | 8 ++++- tests/test_slots.py | 70 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 8 deletions(-) diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 47ed5d12..31d0d560 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -616,7 +616,11 @@ def generate_pack( # emulator's file satisfies both sides and nothing is traded away. mode = config.get("verification_mode", "existence") for conflict in slots.find_conflicts( - config, platform_profiles, db, base_dest + config, + platform_profiles, + db, + base_dest, + {str(c) for c in config.get("standalone_cores", [])}, ): decision = slots.arbitrate(conflict, mode) if decision.serves_both and decision.winner.local_path: diff --git a/scripts/slots.py b/scripts/slots.py index 5f0c5602..5bc10fc9 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -112,17 +112,42 @@ def platform_claims(config: dict, db: dict, base_dest: str = "") -> list[Claim]: def profile_claims( - profiles: dict, db: dict, base_dest: str = "" + profiles: dict, + db: dict, + base_dest: str = "", + standalone_cores: set[str] | None = None, ) -> list[Claim]: - """What each emulator profile says belongs at each destination it names.""" + """What each emulator profile says belongs at each destination it names. + + A file can exist in one build of an emulator and not the other, and the + two builds read from different directories. The mode is decided per + emulator, not per pack: a platform runs some of its emulators as libretro + cores and others standalone, naming the latter in ``standalone_cores``. + An entry the standalone build alone loads does not address a platform + running that emulator as a core, and where it does, the destination is + ``standalone_path``. This is the gate verify already applies. + """ + standalone_cores = standalone_cores or set() claims: list[Claim] = [] for emu_name, profile in sorted(profiles.items()): if profile.get("type") in ("launcher", "alias"): continue + is_standalone = emu_name in standalone_cores or bool( + standalone_cores & {str(c) for c in profile.get("cores", [])} + ) for entry in profile.get("files") or []: if not isinstance(entry, dict): continue - dest = entry.get("path") or entry.get("name") or "" + entry_mode = entry.get("mode") + if entry_mode == "standalone" and not is_standalone: + continue + if entry_mode == "libretro" and is_standalone: + continue + dest = ( + (entry.get("standalone_path") or entry.get("path")) + if is_standalone + else entry.get("path") + ) or entry.get("name") or "" if not dest: continue full = f"{base_dest}/{dest}" if base_dest else dest @@ -142,7 +167,11 @@ def profile_claims( def find_conflicts( - config: dict, profiles: dict, db: dict, base_dest: str = "" + config: dict, + profiles: dict, + db: dict, + base_dest: str = "", + standalone_cores: set[str] | None = None, ) -> list[Conflict]: """Destinations where a proven profile claim contradicts what ships. @@ -158,7 +187,7 @@ def find_conflicts( # is agreement, not contradiction. Only a destination where no profile # claim at all matches what ships is a disagreement. by_slot: dict[str, list[Claim]] = {} - for claim in profile_claims(profiles, db, base_dest): + for claim in profile_claims(profiles, db, base_dest, standalone_cores): key = _normalize(claim.destination) platform = by_dest.get(key) if platform is None or not platform.is_proven or not claim.is_proven: @@ -334,7 +363,13 @@ def scan_platform( config = load_platform_config(platform, platforms_dir) keys = resolve_platform_cores(config, profiles) relevant = {k: profiles[k] for k in keys if k in profiles} - return find_conflicts(config, relevant, db, config.get("base_destination", "")) + return find_conflicts( + config, + relevant, + db, + config.get("base_destination", ""), + {str(c) for c in config.get("standalone_cores", [])}, + ) def main() -> int: diff --git a/scripts/verify.py b/scripts/verify.py index c9848e69..1b476237 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -804,7 +804,13 @@ def verify_platform( name: emu_profiles[name] for name in resolve_platform_cores(config, emu_profiles) } - for conflict in slots.find_conflicts(config, arbitrated, db, base_dest): + for conflict in slots.find_conflicts( + config, + arbitrated, + db, + base_dest, + {str(c) for c in config.get("standalone_cores", [])}, + ): decision = slots.arbitrate(conflict, mode) if decision.serves_both and decision.winner.local_path: key = conflict.destination diff --git a/tests/test_slots.py b/tests/test_slots.py index 687d92c1..50b26fc2 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -318,6 +318,76 @@ class TestSelfContradictingDestinations(unittest.TestCase): self.assertIn("JAP/IPL.bin", line) +class TestBuildModeDecidesWhoClaims(unittest.TestCase): + """A platform runs some emulators as cores and others standalone.""" + + def _config(self, standalone=None) -> dict: + config = { + "systems": { + "console": { + "files": [ + {"name": "IPL.bin", "destination": "GC/JAP/IPL.bin", + "md5": "m" * 32} + ] + } + } + } + if standalone: + config["standalone_cores"] = standalone + return config + + def _profile(self, mode=None, standalone_path=None) -> dict: + entry = {"name": "IPL.bin", "path": "GC/JAP/IPL.bin"} + if mode: + entry["mode"] = mode + if standalone_path: + entry["standalone_path"] = standalone_path + return {"ares": {"cores": ["ares"], "files": [entry]}} + + def test_a_standalone_only_file_does_not_claim_a_core_slot(self): + conflicts = slots.find_conflicts( + self._config(), self._profile(mode="standalone"), REGIONS_DB + ) + self.assertEqual(conflicts, []) + + def test_it_claims_when_the_platform_runs_that_emulator_standalone(self): + conflicts = slots.find_conflicts( + self._config(standalone=["ares"]), + self._profile(mode="standalone"), + REGIONS_DB, + standalone_cores={"ares"}, + ) + self.assertEqual(len(conflicts), 1) + + def test_a_libretro_only_file_is_silent_in_standalone_mode(self): + conflicts = slots.find_conflicts( + self._config(standalone=["ares"]), + self._profile(mode="libretro"), + REGIONS_DB, + standalone_cores={"ares"}, + ) + self.assertEqual(conflicts, []) + + def test_standalone_mode_reads_the_standalone_destination(self): + claims = slots.profile_claims( + self._profile(standalone_path="elsewhere/IPL.bin"), + REGIONS_DB, + standalone_cores={"ares"}, + ) + self.assertEqual(claims[0].destination, "elsewhere/IPL.bin") + + def test_the_mode_follows_a_core_alias_not_only_the_profile_name(self): + profile = {"ares_core": {"cores": ["ares"], + "files": [{"name": "IPL.bin", + "path": "GC/JAP/IPL.bin", + "mode": "standalone"}]}} + conflicts = slots.find_conflicts( + self._config(standalone=["ares"]), profile, REGIONS_DB, + standalone_cores={"ares"}, + ) + self.assertEqual(len(conflicts), 1) + + class TestProvenEvidence(unittest.TestCase): """What counts as proof that a claim is about content, not about a name."""