diff --git a/install.ps1 b/install.ps1 index 2c16dbe0..fa2ce047 100644 --- a/install.ps1 +++ b/install.ps1 @@ -12,7 +12,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "376edf794195d74f21e598854b5f3e90fb1955513835264bc9326ad4d2acf9c9" + $defaultInstallSha256 = "973d64627af05b239555ab5a83a8902747b7a742bc1176f1b994cf7ac74774f9" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 32e6a681..4e18bb17 100644 --- a/install.py +++ b/install.py @@ -142,6 +142,8 @@ _REGION_PARENT = { member: parent for parent, members in REGION_TREE.items() for member in members } REGIONS = frozenset({WORLD_REGION} | set(REGION_TREE) | set(_REGION_PARENT)) +# Marks a region group named after one file that no system names. +FILE_GROUP = ":file:" MAX_SELECTION_TAGS = 1000 SELECTION_FIELDS = ("systems", "regions", "region_groups", "region_system_groups") # The files cores load for systems the platform does not list (game engines, @@ -2220,7 +2222,10 @@ def _region_rank(file_regions: set[str], requested: list[str]) -> int: def region_drops( - entries: list[dict], requested: list[str], systems: list[str] | None = None + entries: list[dict], + requested: list[str], + systems: list[str] | None = None, + held: set[str] | None = None, ) -> set[str]: """Destinations a region priority list withdraws. @@ -2228,10 +2233,16 @@ def region_drops( groups the manifest records. Per group, the best-ranked regional file stays. A world file beats unmatched regional ones the code does not search before it (`priority`, lower first). When nothing matches and no world - file exists, every regional file stays, so a group is never emptied. A file with no region never competes and always stays. + file exists, every regional file stays, so a group is never emptied. A + file with no region never competes and always stays. A core extra competes whatever systems are chosen, while a platform's own declaration competes only when its system is kept, as in the pack. + + ``held`` names the destinations the manifest can install. A match it + lacks never displaces a held file: the American BIOS stays under + --region japan when the Japanese one is missing. A group named after a + single file (FILE_GROUP) competes with nothing and keeps nothing. """ if not requested: return set() @@ -2253,7 +2264,9 @@ def region_drops( groups.setdefault(group, []).append((set(regions), entry["dest"], priority)) keep: set[str] = set() drop: set[str] = set() - for members in groups.values(): + for group_id, members in groups.items(): + if FILE_GROUP in group_id: + continue world = {dest for regions, dest, _p in members if WORLD_REGION in regions} ranks = {dest: priority for _regions, dest, priority in members} regional = [ @@ -2265,12 +2278,19 @@ def region_drops( if not regional: continue matched = [(rank, dest) for rank, dest in regional if rank < len(requested)] - if matched: - best = min(rank for rank, _dest in matched) - keep |= {dest for rank, dest in matched if rank == best} - drop |= {dest for _rank, dest in regional} - elif world: - world_ranks = [ranks[dest] for dest in world] + winners, rivals = matched, world + if held is not None: + winners = [(rank, dest) for rank, dest in matched if dest in held] + rivals = world & held + if winners: + best = min(rank for rank, _dest in winners) + keep |= { + dest for rank, dest in matched + if rank == best or (rank < best and dest not in (held or ())) + } + drop |= {dest for _rank, dest in regional if dest not in keep} + elif rivals: + world_ranks = [ranks[dest] for dest in rivals] best_world = min(world_ranks) if None not in world_ranks else None for _rank, dest in regional: ours = ranks[dest] @@ -2310,7 +2330,10 @@ def narrow( files = _filter_by_target(files, cores) omitted = _filter_by_target(omitted, cores) if regions: - dropped = region_drops(files + omitted, regions, systems) + # The builder's held set: what the collection serves. An omission + # answers the request but takes no installable file's place. + held = {f["dest"] for f in files} + dropped = region_drops(files + omitted, regions, systems, held) files = [f for f in files if f["dest"] not in dropped] omitted = [o for o in omitted if o["dest"] not in dropped] if systems: diff --git a/install.sh b/install.sh index a7948a1c..fa86e185 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="376edf794195d74f21e598854b5f3e90fb1955513835264bc9326ad4d2acf9c9" +DEFAULT_INSTALL_SHA256="973d64627af05b239555ab5a83a8902747b7a742bc1176f1b994cf7ac74774f9" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index 66c8d9dc..6c2eb183 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -43,6 +43,7 @@ class OneRegionVocabulary(unittest.TestCase): self.assertEqual(install.REGION_ALIASES, region.ALIASES) self.assertEqual(install.WORLD_REGION, region.WORLD) self.assertEqual(install.REGIONS, region.REGIONS) + self.assertEqual(install.FILE_GROUP, region.FILE_GROUP) class TheRegionRuleIsTheBuilders(unittest.TestCase): @@ -72,26 +73,68 @@ class TheRegionRuleIsTheBuilders(unittest.TestCase): index[dest] = {"regions": regions, "has_untagged": not regions, "emulators": [], "priorities": {priority}} - groups.setdefault(f"g{g}", []).append((dest, dest)) - if f"g{g}" not in entries[dest]["region_groups"]: - entries[dest]["region_groups"].append(f"g{g}") + # A lone core file no system names gets a group of its own. + group = f"g{g}" if rng.random() < 0.85 else f"e{region.FILE_GROUP}{dest}" + groups.setdefault(group, []).append((dest, dest)) + if group not in entries[dest]["region_groups"]: + entries[dest]["region_groups"].append(group) for entry in entries.values(): if not entry["regions"]: del entry["regions"], entry["region_groups"] entry.pop("priority", None) requested = rng.sample(["north-america", "europe", "japan", "uk", "canada"], rng.randint(1, 3)) - return groups, index, list(entries.values()), requested + held = {dest for dest in entries if rng.random() < 0.7} + return groups, index, list(entries.values()), requested, held def test_two_thousand_random_platforms(self): rng = random.Random(20261010) for trial in range(2000): - groups, index, entries, requested = self._case(rng) + groups, index, entries, requested, held = self._case(rng) with self.subTest(trial=trial): self.assertEqual( install.region_drops(entries, requested), region.resolve_region_drops(groups, index, requested), ) + self.assertEqual( + install.region_drops(entries, requested, held=held), + region.resolve_region_drops(groups, index, requested, held), + ) + + def test_narrow_passes_the_installable_files_as_held(self): + """The builder passes what the collection holds (generate_pack + _select_variants). An omission is the installer's unheld file.""" + rng = random.Random(20261021) + for trial in range(500): + groups, index, entries, requested, held = self._case(rng) + files = [dict(e, size=1, cores=None) for e in entries if e["dest"] in held] + omitted = [dict(e, cores=None) for e in entries if e["dest"] not in held] + kept, still_omitted = install.narrow(files, omitted, [], [], requested) + drops = region.resolve_region_drops(groups, index, requested, held) + with self.subTest(trial=trial): + self.assertEqual( + {f["dest"] for f in kept}, + {f["dest"] for f in files} - drops, + ) + self.assertEqual( + {o["dest"] for o in still_omitted}, + {o["dest"] for o in omitted} - drops, + ) + + def test_a_lone_core_file_leaves_its_systems_drop(self): + """Dolphin's GC/EUR/IPL.bin is a core extra no system names, plus the + GameCube's own declaration. The builder skips the one-file group, so + --region us drops it.""" + entries = [ + {"dest": "GC/USA/IPL.bin", "regions": ["north-america"], + "region_system_groups": ["nintendo-gamecube"]}, + {"dest": "GC/EUR/IPL.bin", "regions": ["europe"], + "region_groups": [f"dolphin{region.FILE_GROUP}GC/EUR/IPL.bin"], + "region_system_groups": ["nintendo-gamecube"]}, + ] + self.assertEqual( + install.region_drops(entries, ["north-america"]), {"GC/EUR/IPL.bin"} + ) class Narrowing(unittest.TestCase): @@ -138,12 +181,21 @@ class Narrowing(unittest.TestCase): self.assertIn("scph5501.bin", files) self.assertEqual(omitted, []) - def test_an_unavailable_match_still_wins_as_the_builder_counts_it(self): + def test_an_unavailable_match_never_displaces_a_held_file(self): + """The builder keeps the held BIOS when the requested one is missing + (region.resolve_region_drops, held). The missing one stays reported.""" files, omitted = self._dests([], [], ["europe"]) - self.assertNotIn("scph5500.bin", files) - self.assertNotIn("scph5501.bin", files) + self.assertIn("scph5500.bin", files) + self.assertIn("scph5501.bin", files) self.assertEqual(omitted, ["scph5502.bin"]) + def test_a_missing_japanese_bios_keeps_the_american_one(self): + files = [dict(self.FILES[1])] + omitted = [dict(self.OMITTED[0], dest="scph5500.bin", regions=["japan"])] + kept, still = install.narrow(files, omitted, [], [], ["japan"]) + self.assertEqual([f["dest"] for f in kept], ["scph5501.bin"]) + self.assertEqual([o["dest"] for o in still], ["scph5500.bin"]) + def test_no_match_keeps_every_regional_file(self): files, _ = self._dests([], [], ["brazil"]) self.assertIn("scph5500.bin", files)