diff --git a/scripts/check_freshness.py b/scripts/check_freshness.py index 741cd8f3..6985dae9 100644 --- a/scripts/check_freshness.py +++ b/scripts/check_freshness.py @@ -54,6 +54,7 @@ import upstream # noqa: E402 from common import ( # noqa: E402 list_registered_platforms, load_emulator_profiles, + upstream_profile_index, yaml_load, ) from exporter import discover_exporters # noqa: E402 @@ -364,14 +365,9 @@ def check_native(platforms_dir: Path, cache_dir: Path, truth_dir: Path) -> list[ # --- targets ---------------------------------------------------------------- -def profile_name_index(profiles: dict[str, dict]) -> dict[str, str]: - """Upstream core name -> profile key, the index target filtering uses.""" - index: dict[str, str] = {} - for key, profile in profiles.items(): - index.setdefault(key, key) - for alias in profile.get("cores") or []: - index.setdefault(str(alias), key) - return index +def profile_name_index(profiles: dict[str, dict]) -> dict[str, set[str]]: + """Upstream core name -> the profiles that claim it, as target filtering reads it.""" + return upstream_profile_index(profiles, include_aliases=True) def _removed_cores(overrides: dict, platform: str) -> dict[str, set[str]]: @@ -539,13 +535,20 @@ def coreinfo_gaps( Matching folds case: the buildbot serves lower-case names while a few .info files keep the project's own casing. """ - index = {k.casefold(): v for k, v in profile_name_index(profiles).items()} + index: dict[str, set[str]] = {} + for key, claimants in profile_name_index(profiles).items(): + index.setdefault(key.casefold(), set()).update(claimants) unprofiled = [n for n in names if n.casefold() not in index] + # A name only standalone profiles claim: libretro now builds a core + # nothing in the collection describes as one. standalone = [ - (n, index[n.casefold()]) + (n, sorted(index[n.casefold()])[0]) for n in names if n.casefold() in index - and str(profiles[index[n.casefold()]].get("type", "")).strip() == "standalone" + and all( + str(profiles[c].get("type", "")).strip() == "standalone" + for c in index[n.casefold()] + ) ] return unprofiled, standalone diff --git a/scripts/common.py b/scripts/common.py index f8d359ab..17d52709 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -1298,6 +1298,36 @@ def runs_standalone( ) +def upstream_profile_index( + profiles: dict[str, dict], include_aliases: bool = False +) -> dict[str, set[str]]: + """Every name a core is known by upstream -> the profiles that claim it. + + A name can be claimed twice: the buildbot's `pcsx2` is the LRPS2 core, + which lrps2.yml lists in `cores:`, and also the key of the standalone + pcsx2 profile. A last-wins dict kept one of the two depending on file + order, and a RetroArch target filter dropped LRPS2's files. Callers + choose: a platform list prefers the exact key, a target filter keeps + every claimant. + """ + index: dict[str, set[str]] = {} + for name, profile in profiles.items(): + if not include_aliases and profile.get("type") == "alias": + continue + index.setdefault(name, set()).add(name) + for core in profile.get("cores") or []: + index.setdefault(str(core), set()).add(name) + return index + + +def preferred_profile(index: dict[str, set[str]], name: str) -> str | None: + """The profile a name designates: its own key first, else the first claimant.""" + claimants = index.get(name) + if not claimants: + return None + return name if name in claimants else sorted(claimants)[0] + + def resolve_platform_cores( config: dict, profiles: dict[str, dict], @@ -1323,14 +1353,12 @@ def resolve_platform_cores( } elif isinstance(cores_config, list): core_set = {str(c) for c in cores_config} - core_to_profile: dict[str, str] = {} - for name, p in profiles.items(): - if p.get("type") == "alias": - continue - core_to_profile[name] = name - for core_name in p.get("cores", []): - core_to_profile[str(core_name)] = name - result = {core_to_profile[c] for c in core_set if c in core_to_profile} + index = upstream_profile_index(profiles) + result = { + profile + for c in core_set + if (profile := preferred_profile(index, c)) is not None + } # Support "all_libretro" as a list element: combines all libretro # profiles with explicitly listed standalone cores (e.g. RetroDECK # ships RetroArch + standalone emulators) @@ -1355,13 +1383,10 @@ def resolve_platform_cores( # Upstream sources (buildbot, es_systems) may use different names # than our profile keys (e.g., mednafen_psx vs beetle_psx). # The profiles' cores: field lists these alternate names. - upstream_to_profile: dict[str, str] = {} - for name, p in profiles.items(): - upstream_to_profile[name] = name - for alias in p.get("cores", []): - upstream_to_profile[str(alias)] = name - # Expand target_cores to profile keys - expanded = {upstream_to_profile.get(c, c) for c in target_cores} + index = upstream_profile_index(profiles, include_aliases=True) + expanded = { + profile for c in target_cores for profile in index.get(c, {c}) + } result = result & expanded return result @@ -1467,12 +1492,10 @@ def filter_systems_by_target( return systems # Build reverse index for target core name resolution - upstream_to_profile: dict[str, str] = {} - for name, p in profiles.items(): - upstream_to_profile[name] = name - for alias in p.get("cores", []): - upstream_to_profile[str(alias)] = name - expanded_target = {upstream_to_profile.get(c, c) for c in target_cores} + index = upstream_profile_index(profiles, include_aliases=True) + expanded_target = { + profile for c in target_cores for profile in index.get(c, {c}) + } _norm_sid = _norm_system_id diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 436cf701..7283493f 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -38,8 +38,10 @@ from common import ( load_emulator_profiles, load_provenance_snapshots, parse_md5_list, + preferred_profile, require_yaml, unique_emulator_profiles, + upstream_profile_index, write_if_changed as _write_artifact, yaml_load, ) @@ -3116,11 +3118,11 @@ def generate_cross_reference( k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test") } - # Build core -> profile lookup by core name - core_to_profile: dict[str, str] = {} - for pname, p in unique.items(): - for core in p.get("cores", [pname]): - core_to_profile[str(core)] = pname + # Core name -> the profile it designates, the rule target filtering uses. + index = upstream_profile_index(unique) + core_to_profile: dict[str, str] = { + core: preferred_profile(index, core) for core in index + } total_cores = len(unique) total_upstreams = len({ diff --git a/tests/test_check_freshness.py b/tests/test_check_freshness.py index e017cf74..21d38f38 100644 --- a/tests/test_check_freshness.py +++ b/tests/test_check_freshness.py @@ -149,8 +149,8 @@ class CoreResolutionTests(unittest.TestCase): def test_index_maps_key_and_every_core_alias(self): index = cf.profile_name_index(self.PROFILES) - self.assertEqual(index["mednafen_psx"], "beetle_psx") - self.assertEqual(index["beetle_psx"], "beetle_psx") + self.assertEqual(index["mednafen_psx"], {"beetle_psx"}) + self.assertEqual(index["beetle_psx"], {"beetle_psx"}) def test_unresolved_honours_remove_cores_per_target(self): index = cf.profile_name_index(self.PROFILES) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 21e75627..9f7f1e17 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1659,8 +1659,8 @@ class TestE2E(unittest.TestCase): config = { "platform": "TestStandalone", "verification_mode": "existence", - "cores": ["test_emu"], - "standalone_cores": ["test_emu"], + "cores": ["test_sa"], + "standalone_cores": ["test_sa"], "systems": { "console-a": { "files": [ @@ -1680,7 +1680,7 @@ class TestE2E(unittest.TestCase): emu = { "emulator": "TestStandaloneEmu", "type": "standalone + libretro", - "cores": ["test_emu"], + "cores": ["test_sa"], "systems": ["console-a"], "files": [ { diff --git a/tests/test_upstream_index.py b/tests/test_upstream_index.py new file mode 100644 index 00000000..be389491 --- /dev/null +++ b/tests/test_upstream_index.py @@ -0,0 +1,51 @@ +"""One reverse index from upstream core names to profiles. + +Five copies broke ties differently. The target branch of +resolve_platform_cores kept the last profile claiming a name, so the +buildbot's `pcsx2` (the LRPS2 core) mapped to the standalone pcsx2 profile +and a RetroArch target pack lost LRPS2's files. +""" + +from __future__ import annotations + +import re +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from common import ( # noqa: E402 + preferred_profile, + resolve_platform_cores, + upstream_profile_index, +) + +PROFILES = { + "lrps2": {"type": "libretro", "cores": ["lrps2", "pcsx2"]}, + "pcsx2": {"type": "standalone", "cores": ["pcsx2"]}, +} + + +class OneIndex(unittest.TestCase): + def test_target_keeps_every_claimant(self): + config = {"cores": "all_libretro"} + self.assertEqual( + resolve_platform_cores(config, PROFILES, target_cores={"pcsx2"}), {"lrps2"} + ) + + def test_platform_list_prefers_the_key(self): + config = {"cores": ["pcsx2"]} + self.assertEqual(resolve_platform_cores(config, PROFILES), {"pcsx2"}) + self.assertEqual(preferred_profile(upstream_profile_index(PROFILES), "pcsx2"), "pcsx2") + + def test_no_hand_made_index(self): + pattern = re.compile(r"(upstream_to_profile|core_to_profile)\[[^\]]+\]\s*=") + for path in sorted((REPO_ROOT / "scripts").rglob("*.py")): + with self.subTest(module=path.name): + self.assertIsNone(pattern.search(path.read_text(encoding="utf-8"))) + + +if __name__ == "__main__": + unittest.main()