diff --git a/scripts/exporter/retropie_exporter.py b/scripts/exporter/retropie_exporter.py index 4d6f7b7a..6ee5a487 100644 --- a/scripts/exporter/retropie_exporter.py +++ b/scripts/exporter/retropie_exporter.py @@ -184,6 +184,34 @@ class Exporter(BaseExporter): names.append(fe.name) return names + @staticmethod + def _slot_of(fe: NativeFile) -> tuple[str, tuple[str, ...]] | None: + """The search chain a file belongs to, or None when it is in none. + + PicoDrive walks one ranked list per region and loads the first file + it finds: the files of one chain are alternatives, and naming two of + them in an enumeration that reads "requires a, b and c" says both + are needed. A file joins a chain only when its source ranks it and + names its territory. + """ + regions = (fe.truth or {}).get("region") or [] + if fe.priority is None or not regions: + return None + return fe.native_system, tuple(sorted(str(r) for r in regions)) + + @classmethod + def _one_per_slot(cls, candidates: list[NativeFile], filled: set) -> list[NativeFile]: + """The candidates whose chain the help leaves empty, best first.""" + kept: list[NativeFile] = [] + for fe in search_order(candidates): + slot = cls._slot_of(fe) + if slot is not None: + if slot in filled: + continue + filled.add(slot) + kept.append(fe) + return kept + @staticmethod def _required_for(fe: NativeFile, core: str | None) -> bool: """Whether the package's own core needs the file. @@ -290,7 +318,14 @@ class Exporter(BaseExporter): ) & listed ] - missing = self._in_search_order(candidates) + filled = { + slot + for fe in files + if ({fe.name.lower()} + | {str(a).lower() for a in (fe.native("aliases", []) or [])}) & listed + and (slot := self._slot_of(fe)) is not None + } + missing = self._in_search_order(self._one_per_slot(candidates, filled)) if not missing: continue diff --git a/tests/test_exporters.py b/tests/test_exporters.py index 897eedff..80bd22ce 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -356,6 +356,35 @@ class SearchOrder(unittest.TestCase): exporter._in_search_order(candidates), ["ranked.bin", "unranked.bin"] ) + def test_a_region_the_help_already_fills_gets_no_alternative(self): + """PicoDrive loads the first file of each region's list. RetroPie + names one per region; the export appended three more, which the + sentence reads as three more required files.""" + from exporter.baseline import NativeSystem # noqa: PLC0415 + + def cd(name, region, rank): + return NativeFile(name=name, destination=name, native_system="segacd", truth={ + "name": name, "required": True, "md5": "0" * 32, "_cores": ["picodrive"], + "region": [region], "priority": rank}) + + files = [cd("us_scd2_9306.bin", "north-america", 1), + cd("us_scd1_9210.bin", "north-america", 3), + cd("eu_mcd2_9306.bin", "europe", 1), + cd("eu_mcd1_9210.bin", "europe", 3), + cd("jp_mcd2_921222.bin", "japan", 1), + cd("jp_mcd1_9112.bin", "japan", 2)] + script = ('rp_module_id="lr-picodrive"\nrp_module_help="The Sega CD requires the ' + 'BIOS files us_scd1_9210.bin, eu_mcd1_9210.bin copied to $biosdir"\n') + exporter = discover_exporters()["retropie"]() + produced = exporter.render( + {"segacd": NativeSystem("segacd", files=files)}, None, + {"scriptmodules/libretrocores/lr-picodrive.sh": script}, + )["scriptmodules/libretrocores/lr-picodrive.sh"] + self.assertIn("us_scd1_9210.bin, eu_mcd1_9210.bin and jp_mcd2_921222.bin copied", + produced) + self.assertNotIn("us_scd2_9306", produced) + self.assertNotIn("jp_mcd1_9112", produced) + def test_cores_that_disagree_keep_the_best_rank(self): """Nothing is dropped on the rank here, so the best one wins.