From 47ae5aa601412198172d2072a1f1da830aed80cb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:13:24 +0100 Subject: [PATCH] feat: fill a launcher name from a settled entry The gap report never offers an entry the platform's list settles, so AmberELEC's amiberry configs, which pass kick34005.A500.rom and kick40068.A1200.rom beside a declared kick34005.A500, got nothing. The launcher layer now reads the entries it was not offered, through the setting or the extended name only: by its own name an entry left out of the report was left out for a reason. --- scripts/launchers.py | 47 +++++++++++++++++++++++++----------- tests/test_launcher_layer.py | 18 ++++++++++++++ 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/scripts/launchers.py b/scripts/launchers.py index 8eef3fd28..133269587 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -490,6 +490,8 @@ class _Candidate: by_content: bool # Matched by the size the code checks, the entry naming no hash. by_shape: bool = False + # Matched through the setting the launcher writes or the name it extends. + by_setting_or_stem: bool = False class LauncherLayer: @@ -519,7 +521,7 @@ class LauncherLayer: self._by_name_folded.setdefault(name.lower(), []).extend(sha1s) self._files = db.get("files", {}) self._owners: dict[tuple[str, bool], list[LauncherPlace]] = {} - self._placeholders: dict[tuple[str, bool], list[tuple[dict, dict]]] = {} + self._unoffered: dict[tuple[str, bool], list[tuple[dict, dict]]] = {} relevant = set(relevant) for place in self.places: standalone = place.build == "standalone" @@ -534,14 +536,14 @@ class LauncherLayer: owners = set(setting_readers(place.passes_to, profiles, relevant)) for key in sorted(owners): self._owners.setdefault((key, standalone), []).append(place) - # A placeholder entry stands for every file a scan finds and - # is no gap, so the report never offers it: the setting a - # launcher writes still reads one file of it. - for entry in profiles[key].get("files") or []: - if "<" in str(entry.get("name", "")) and set_by(entry, place.passes_to): - self._placeholders.setdefault((key, standalone), []).append( - (entry, _placeholder_report(key, profiles[key], entry)) - ) + # The report never offers a placeholder, which stands for every + # file a scan finds, nor an entry the platform's list settles + # (AmberELEC declares kick34005.A500): the launcher still reads + # its file under its own name. + self._unoffered.setdefault((key, standalone), [ + (entry, _placeholder_report(key, profiles[key], entry)) + for entry in profiles[key].get("files") or [] + ]) self._db = db @staticmethod @@ -582,14 +584,14 @@ class LauncherLayer: # RetroPie runs sdl2trs with -romfile4p $biosdir/level4p.rom: the # file under that name is the one the romfile4p slot reads. if set_by(entry, place.passes_to): - return _Candidate(entry, basenames, False) + return _Candidate(entry, basenames, False, by_setting_or_stem=True) if place.kind == "fixed_name": stems = { base for base in basenames if (stem := suffixed_stem(base)) is not None and place.fold(stem) in own } if stems: - return _Candidate(entry, stems, False) + return _Candidate(entry, stems, False, by_setting_or_stem=True) content = { place.fold(path.rsplit("/", 1)[-1]) for path in place.paths @@ -654,15 +656,26 @@ class LauncherLayer: by_layout: dict[tuple[str, bool], list[tuple[dict, dict]]] = {} for emulator, standalone, entry, report in candidates: by_layout.setdefault((emulator, standalone), []).append((entry, report)) - for layout, members in self._placeholders.items(): - by_layout.setdefault(layout, []).extend(members) + offered = {id(entry) for _e, _s, entry, _r in candidates} + for layout, members in self._unoffered.items(): + for entry, report in members: + if id(entry) in offered: + continue + if "<" not in str(entry.get("name", "")): + report["in_repo"] = resolve_held(report) is not None + report["unoffered"] = True + by_layout.setdefault(layout, []).append((entry, report)) placed: set[str] = set() for (emulator, standalone), members in sorted(by_layout.items()): for place in self.places_for(emulator, standalone): + # An entry the report left out answers only through the + # setting or the extended name: by its own name it was left + # out for a reason (a region variant, a settled duplicate). matched = [ (candidate, report) for entry, report in members if (candidate := self.match(place, entry)) is not None + and (candidate.by_setting_or_stem or not report.get("unoffered")) ] # A size names one entry only when no other of the core's # entries takes that size too. @@ -798,8 +811,14 @@ class LauncherLayer: def _placeholder_report(key: str, profile: dict, entry: dict) -> dict: - """The report entry of a placeholder, which the gap report never builds.""" + """The report entry of an entry the gap report never builds.""" + identity = { + field: entry[field] + for field in ("aliases", "md5", "sha1", "sha256", "crc32", "size", "min_size", "max_size") + if entry.get(field) is not None + } return { + **identity, "profile": key, "emulator": profile.get("emulator", key), "name": entry.get("name", ""), diff --git a/tests/test_launcher_layer.py b/tests/test_launcher_layer.py index e58f39b23..4257f5863 100644 --- a/tests/test_launcher_layer.py +++ b/tests/test_launcher_layer.py @@ -289,6 +289,24 @@ class LauncherLayerTest(unittest.TestCase): self.assertEqual(added["kick.A500.rom"]["md5"], _md5(CONTENT["core.bin"])) self.assertIsNone(suffixed_stem("scph1001.bin")) + def test_an_entry_the_platform_declares_still_fills_the_launcher_name(self) -> None: + """AmberELEC declares kick34005.A500, so the report never offers + Amiberry's entry, and its config still passes kick34005.A500.rom.""" + profile = { + "emulator": "FixEmu", "type": "standalone", "systems": ["test-sys"], + "files": [{"name": "core.bin", "aliases": ["kick.A500"], "required": True}], + } + with open(os.path.join(self.emulators_dir, "fixemu.yml"), "w") as fh: + yaml.dump(profile, fh) + self.config["launchers"][2]["names"] = ["kick.A500.rom"] + self.config["systems"]["test-sys"]["files"].append( + {"name": "core.bin", "destination": "core.bin", "required": True} + ) + _emulator_profiles_cache.clear() + self.profiles = load_emulator_profiles(self.emulators_dir) + added = {u["path"]: u for u in self._report() if u.get("launcher")} + self.assertEqual(added["kick.A500.rom"]["md5"], _md5(CONTENT["core.bin"])) + def test_each_name_of_a_config_takes_its_own_key(self) -> None: """AmberELEC's atari800.cfg names eight ROMs under eight keys, and one record describes them in order: 5200a.rom is ROM_5200_A's file,