diff --git a/install.ps1 b/install.ps1 index 6a62c50f..51431bf0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "e0d8fad4e7e55afa57c412e98726992d82b92286b752c9a8cce0978f0a355127" +$defaultInstallSha256 = "953240dab33c25e52005155ecc8cd694269234697ca2c3f55939994fc6b61951" $maximumInstallerBytes = 2MB $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $temporary = $null diff --git a/install.py b/install.py index f3d7125d..4b482033 100644 --- a/install.py +++ b/install.py @@ -1154,6 +1154,47 @@ def _check_download(entry: dict, where: str, dest: str) -> int: return size +def _check_slot(entry: dict, where: str, dest: str) -> int: + """The copies a platform file takes as cores and systems drop; the largest size.""" + choices = entry.get("slot_choices", []) + declared = entry.get("declared_choices") + if declared is None and not choices: + return 0 + if ( + entry.get("cores") is not None + or not isinstance(choices, list) + or not isinstance(declared, list) + or not declared + or len(choices) > MAX_SELECTION_TAGS + or len(declared) > MAX_SELECTION_TAGS + ): + raise ValueError(f"invalid slot choices for {dest}") + size = 0 + for index, choice in enumerate(declared): + systems = choice.get("systems") if isinstance(choice, dict) else None + if ( + not isinstance(systems, list) + or not systems + or not all(isinstance(s, str) for s in systems) + or not set(choice) <= (set(_COPY_FIELDS) - {"cores"}) | {"systems"} + ): + raise ValueError(f"invalid declared choice for {dest}") + size = max(size, _check_download(choice, f"{where}.declared_choices[{index}]", dest)) + for index, choice in enumerate(choices): + if ( + not isinstance(choice, dict) + or not choice.get("cores") + or not set(choice) <= set(_COPY_FIELDS) + ): + raise ValueError(f"invalid slot choice for {dest}") + # A choice naming only cores leaves the platform's own answer. + if set(choice) != {"cores"}: + size = max(size, _check_download( + choice, f"{where}.slot_choices[{index}]", dest + )) + return size + + def _validate_manifest(data: object, plat: str) -> dict: """Validate the untrusted install-manifest boundary using stdlib only.""" if not isinstance(data, dict): @@ -1192,6 +1233,7 @@ def _validate_manifest(data: object, plat: str) -> dict: raise ValueError(f"invalid alternative for {dest}") # Narrowing can swap in any copy: the cap holds for the largest. size = max(size, _check_download(alternative, where, dest)) + size = max(size, _check_slot(entry, f"files[{index}]", dest)) largest_total += size if largest_total > MAX_TOTAL_DOWNLOAD_SIZE: raise ValueError("manifest total size exceeds safety limit") @@ -1397,13 +1439,44 @@ def _for_cores(entry: dict, chosen: set[str]) -> dict | None: if chosen.intersection(alternative["cores"]): kept = { k: v for k, v in entry.items() - if k not in _COPY_FIELDS and k != "alternatives" + if k not in _COPY_FIELDS + and k not in ("alternatives", "slot_choices", "declared_choices") } kept.update((k, alternative[k]) for k in _COPY_FIELDS if k in alternative) return kept return None +def _settle_slot(entry: dict, chosen: set[str], kept_system=None) -> dict: + """A platform file as a pack built for the chosen cores carries it. + + Where a core's own source contradicts the platform and the frontend only + checks the path, the pack serves the core's copy: the first of + `slot_choices` naming a chosen core decides. Otherwise the platform + answers, through the first of `declared_choices` whose systems remain. + """ + declared = entry.get("declared_choices") + if declared is None: + return entry + copy = next( + (c for c in entry.get("slot_choices", []) if chosen.intersection(c["cores"])), + None, + ) + if copy is None or "sha1" not in copy: + copy = next( + (c for c in declared + if kept_system is None or any(kept_system(s) for s in c["systems"])), + declared[0], + ) + kept = { + k: v for k, v in entry.items() + if k not in _COPY_FIELDS and k not in ("slot_choices", "declared_choices") + } + kept.update((k, copy[k]) for k in _COPY_FIELDS if k in copy and k != "cores") + kept["cores"] = None + return kept + + def _filter_by_target( files: list[dict], target_cores: list[str] ) -> list[dict]: @@ -1412,7 +1485,7 @@ def _filter_by_target( result: list[dict] = [] for f in files: if f.get("cores") is None: - result.append(f) + result.append(_settle_slot(f, chosen)) continue kept = _for_cores(f, chosen) if kept is not None: @@ -1453,6 +1526,9 @@ def narrow_to_cores( cores = designated_cores(manifest, names) reach = manifest.get("system_cores") or {} + def kept_system(system: str) -> bool: + return system not in reach or bool(cores.intersection(reach[system])) + def kept(entries: list[dict]) -> list[dict]: result: list[dict] = [] for entry in entries: @@ -1464,10 +1540,8 @@ def narrow_to_cores( # The systems declaring the file, where they are fewer than the # ones reading it. systems = entry.get("declared_in") or entry_systems(entry) - if not systems or any( - s not in reach or cores.intersection(reach[s]) for s in systems - ): - result.append(entry) + if not systems or any(kept_system(s) for s in systems): + result.append(_settle_slot(entry, cores, kept_system)) continue # The system is dropped, which frees its destination for the # first named core claiming it, as in a pack built for them. @@ -1622,7 +1696,7 @@ def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[ ordered.append(OTHER_SYSTEMS) cores = { c for f in files - for copy in [f, *f.get("alternatives", [])] + for copy in [f, *f.get("alternatives", []), *f.get("slot_choices", [])] for c in (copy.get("cores") or []) } regions = { diff --git a/install.sh b/install.sh index 71727445..4b56be66 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="e0d8fad4e7e55afa57c412e98726992d82b92286b752c9a8cce0978f0a355127" +DEFAULT_INSTALL_SHA256="953240dab33c25e52005155ecc8cd694269234697ca2c3f55939994fc6b61951" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/lakka.json b/install/lakka.json index a13fd496..4ac97a22 100644 --- a/install/lakka.json +++ b/install/lakka.json @@ -1536,6 +1536,28 @@ "systems": [ "arcade" ], + "slot_choices": [ + { + "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", + "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", + "size": 3556, + "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", + "cores": [ + "fbneo" + ] + } + ], + "declared_choices": [ + { + "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", + "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "size": 3556, + "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "systems": [ + "arcade" + ] + } + ], "alternatives": [ { "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", @@ -1850,6 +1872,29 @@ "arcade", "snk-neogeo-cd" ], + "slot_choices": [ + { + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", + "cores": [ + "fbneo", + "fbneo_neogeo" + ] + } + ], + "declared_choices": [ + { + "sha1": "4f28af31ca0defdd73d80edec2fa296908e624dc", + "sha256": "6aa507a5dc33c45d2592bf2bc77eb3e497762221ddd9225f18583a5c55d38667", + "size": 214681, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.4f28af31", + "systems": [ + "arcade" + ] + } + ], "declared_in": [ "arcade" ], @@ -2044,6 +2089,28 @@ "atari-400-800", "atari-5200" ], + "slot_choices": [ + { + "sha1": "db1031585968cfc6ec2ecda5c9a5a52f61444a3b", + "sha256": "63973d5e86b1ae5b6fca5b119a7f7589e817e19acc3ca54e54462c9dfb619e73", + "size": 10240, + "repo_path": "bios/Atari/400-800/ROM_OS_B_NTSC", + "cores": [ + "atari800" + ] + } + ], + "declared_choices": [ + { + "sha1": "f1f0741b1d34fb4350cf7cb8ab3b6ea11cdd8174", + "sha256": "bc6012f8ca2f7dde21ae895899b1d2af572ffaa28f819b84911ddb6a0bd8a5c7", + "size": 10240, + "repo_path": "bios/Atari/400-800/ATARIOSB.ROM", + "systems": [ + "atari-400-800" + ] + } + ], "declared_in": [ "atari-400-800" ], @@ -5224,6 +5291,38 @@ "nintendo-ds", "nintendo-dsi" ], + "slot_choices": [ + { + "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", + "sha256": "12a03016ba07cfd874fc002df49a2c6eb2414eb7cfa395c2043035ec5e9193e8", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios7i.bin", + "cores": [ + "melonds", + "melonds_ds" + ] + } + ], + "declared_choices": [ + { + "sha1": "a3aa751eb6bdaaf8a827ba9e03576a6f1ab0f547", + "sha256": "2946281e730e71f7cafdb125f5cb60fed944ca5d610ee1e082c441b602b5f4e2", + "size": 65536, + "repo_path": "bios/Nintendo/Nintendo DS/biosdsi7.bin", + "systems": [ + "nintendo-dsi" + ] + }, + { + "sha1": "d2b17224e156ee5e55b5d8d9f323b1c9b86dd951", + "sha256": "28a13952901b4065ef3f08c3252b49a00b6525e301b7ddaa3a390b4c7cbef901", + "size": 65536, + "repo_path": "bios/Nintendo/DS/dsi_bios7.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", @@ -5248,6 +5347,38 @@ "nintendo-ds", "nintendo-dsi" ], + "slot_choices": [ + { + "sha1": "719b9eef33692406d7170ff526069615759c4dfc", + "sha256": "6e9f70b51dc4f26289b597f14e2513620c2af964374a95f4c5a62b5cad199afc", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios9i.bin", + "cores": [ + "melonds", + "melonds_ds" + ] + } + ], + "declared_choices": [ + { + "sha1": "7bf549b8be9e48ab0cdc9b0fdadd49a5131f97eb", + "sha256": "47538922a8e8a8e79b922ff1203863ef5c40d9c54656a8d2c89c56ece52029ce", + "size": 65536, + "repo_path": "bios/Nintendo/Nintendo DS/biosdsi9.bin", + "systems": [ + "nintendo-dsi" + ] + }, + { + "sha1": "db61fa39ddbc5f5ed71fb19cda47609ef0201723", + "sha256": "664c608bd6ccfe583e87b3b924afa00f8697dc7ca92eeb6d206d68abb03d2e10", + "size": 65536, + "repo_path": "bios/Nintendo/DS/dsi_bios9.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "719b9eef33692406d7170ff526069615759c4dfc", @@ -9539,6 +9670,38 @@ "systems": [ "snk-neogeo-cd" ], + "slot_choices": [ + { + "sha1": "712816a3c41baf4129e23d645305a536e03d69de", + "sha256": "f885f2cc79b5c0120ca316b0db360183c2b9a60bc727cbe211b7518e12bd5548", + "size": 955352, + "repo_path": "bios/SNK/Neo Geo CD/.variants/neocdz.zip.712816a3", + "cores": [ + "geolith" + ] + }, + { + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", + "cores": [ + "fbneo", + "fbneo_neogeo" + ] + } + ], + "declared_choices": [ + { + "sha1": "9cab31cfe7eacb6871d36cee105d87b90fd85b64", + "sha256": "d828241905d390c5de4867646296f615c95bca2f5d8f7e81d50e08717373a821", + "size": 1181333, + "repo_path": "bios/Arcade/Arcade/neocdz.zip", + "systems": [ + "snk-neogeo-cd" + ] + } + ], "alternatives": [ { "sha1": "712816a3c41baf4129e23d645305a536e03d69de", @@ -9831,6 +9994,29 @@ "region_system_groups": [ "sony-playstation" ], + "slot_choices": [ + { + "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", + "sha256": "0c8359870cbac0ea091f1c87f188cd332dcc709753b91cafd9fd44a4a6188197", + "size": 524288, + "repo_path": "bios/Sony/PlayStation/scph5000.bin", + "cores": [ + "duckstation", + "rustation" + ] + } + ], + "declared_choices": [ + { + "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", + "sha256": "0c8359870cbac0ea091f1c87f188cd332dcc709753b91cafd9fd44a4a6188197", + "size": 524288, + "repo_path": "bios/Sony/PlayStation/scph5000.bin", + "systems": [ + "sony-playstation" + ] + } + ], "alternatives": [ { "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", @@ -11675,6 +11861,46 @@ "systems": [ "sony-playstation-2" ], + "slot_choices": [ + { + "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", + "sha256": "61c77b789ccde856096f024e4a3eef6891e15ca59c127e0dd13e762a7d338a7e", + "size": 2690355, + "repo_path": "bios/Other/armsx2/pcsx2/resources/GameIndex.yaml", + "cores": [ + "armsx2" + ] + }, + { + "sha1": "7efb2ed721cf0c0f0677324e09be311a8337e793", + "sha256": "3bf3c54d6f16d88d3669be698179bb8c2fb981ee5ce71bcb952719429a838cc1", + "size": 2693579, + "repo_path": "bios/Other/yaps2/GameIndex.yaml", + "cores": [ + "yaps2" + ] + }, + { + "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", + "sha256": "61c77b789ccde856096f024e4a3eef6891e15ca59c127e0dd13e762a7d338a7e", + "size": 2690355, + "repo_path": "bios/Other/armsx2/pcsx2/resources/GameIndex.yaml", + "cores": [ + "lrps2" + ] + } + ], + "declared_choices": [ + { + "sha1": "57369df9ca82a460e596cea7835c5a0797dee668", + "sha256": "efa94cb7b9208dc6812bb9407dc986560e523cce57f511eff30942f76b7288bf", + "size": 1737799, + "repo_path": "bios/Sony/PlayStation 2/.variants/GameIndex.yaml.57369df9", + "systems": [ + "sony-playstation-2" + ] + } + ], "alternatives": [ { "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", diff --git a/install/retroarch.json b/install/retroarch.json index ea970443..c751caa3 100644 --- a/install/retroarch.json +++ b/install/retroarch.json @@ -1775,6 +1775,28 @@ "systems": [ "arcade" ], + "slot_choices": [ + { + "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", + "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", + "size": 3556, + "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", + "cores": [ + "fbneo" + ] + } + ], + "declared_choices": [ + { + "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", + "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "size": 3556, + "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "systems": [ + "arcade" + ] + } + ], "alternatives": [ { "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", @@ -2089,6 +2111,29 @@ "arcade", "snk-neogeo-cd" ], + "slot_choices": [ + { + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", + "cores": [ + "fbneo", + "fbneo_neogeo" + ] + } + ], + "declared_choices": [ + { + "sha1": "4f28af31ca0defdd73d80edec2fa296908e624dc", + "sha256": "6aa507a5dc33c45d2592bf2bc77eb3e497762221ddd9225f18583a5c55d38667", + "size": 214681, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.4f28af31", + "systems": [ + "arcade" + ] + } + ], "declared_in": [ "arcade" ], @@ -2283,6 +2328,28 @@ "atari-400-800", "atari-5200" ], + "slot_choices": [ + { + "sha1": "db1031585968cfc6ec2ecda5c9a5a52f61444a3b", + "sha256": "63973d5e86b1ae5b6fca5b119a7f7589e817e19acc3ca54e54462c9dfb619e73", + "size": 10240, + "repo_path": "bios/Atari/400-800/ROM_OS_B_NTSC", + "cores": [ + "atari800" + ] + } + ], + "declared_choices": [ + { + "sha1": "f1f0741b1d34fb4350cf7cb8ab3b6ea11cdd8174", + "sha256": "bc6012f8ca2f7dde21ae895899b1d2af572ffaa28f819b84911ddb6a0bd8a5c7", + "size": 10240, + "repo_path": "bios/Atari/400-800/ATARIOSB.ROM", + "systems": [ + "atari-400-800" + ] + } + ], "declared_in": [ "atari-400-800" ], @@ -5463,6 +5530,38 @@ "nintendo-ds", "nintendo-dsi" ], + "slot_choices": [ + { + "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", + "sha256": "12a03016ba07cfd874fc002df49a2c6eb2414eb7cfa395c2043035ec5e9193e8", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios7i.bin", + "cores": [ + "melonds", + "melonds_ds" + ] + } + ], + "declared_choices": [ + { + "sha1": "a3aa751eb6bdaaf8a827ba9e03576a6f1ab0f547", + "sha256": "2946281e730e71f7cafdb125f5cb60fed944ca5d610ee1e082c441b602b5f4e2", + "size": 65536, + "repo_path": "bios/Nintendo/Nintendo DS/biosdsi7.bin", + "systems": [ + "nintendo-dsi" + ] + }, + { + "sha1": "d2b17224e156ee5e55b5d8d9f323b1c9b86dd951", + "sha256": "28a13952901b4065ef3f08c3252b49a00b6525e301b7ddaa3a390b4c7cbef901", + "size": 65536, + "repo_path": "bios/Nintendo/DS/dsi_bios7.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", @@ -5487,6 +5586,38 @@ "nintendo-ds", "nintendo-dsi" ], + "slot_choices": [ + { + "sha1": "719b9eef33692406d7170ff526069615759c4dfc", + "sha256": "6e9f70b51dc4f26289b597f14e2513620c2af964374a95f4c5a62b5cad199afc", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios9i.bin", + "cores": [ + "melonds", + "melonds_ds" + ] + } + ], + "declared_choices": [ + { + "sha1": "7bf549b8be9e48ab0cdc9b0fdadd49a5131f97eb", + "sha256": "47538922a8e8a8e79b922ff1203863ef5c40d9c54656a8d2c89c56ece52029ce", + "size": 65536, + "repo_path": "bios/Nintendo/Nintendo DS/biosdsi9.bin", + "systems": [ + "nintendo-dsi" + ] + }, + { + "sha1": "db61fa39ddbc5f5ed71fb19cda47609ef0201723", + "sha256": "664c608bd6ccfe583e87b3b924afa00f8697dc7ca92eeb6d206d68abb03d2e10", + "size": 65536, + "repo_path": "bios/Nintendo/DS/dsi_bios9.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "719b9eef33692406d7170ff526069615759c4dfc", @@ -9778,6 +9909,38 @@ "systems": [ "snk-neogeo-cd" ], + "slot_choices": [ + { + "sha1": "712816a3c41baf4129e23d645305a536e03d69de", + "sha256": "f885f2cc79b5c0120ca316b0db360183c2b9a60bc727cbe211b7518e12bd5548", + "size": 955352, + "repo_path": "bios/SNK/Neo Geo CD/.variants/neocdz.zip.712816a3", + "cores": [ + "geolith" + ] + }, + { + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", + "cores": [ + "fbneo", + "fbneo_neogeo" + ] + } + ], + "declared_choices": [ + { + "sha1": "9cab31cfe7eacb6871d36cee105d87b90fd85b64", + "sha256": "d828241905d390c5de4867646296f615c95bca2f5d8f7e81d50e08717373a821", + "size": 1181333, + "repo_path": "bios/Arcade/Arcade/neocdz.zip", + "systems": [ + "snk-neogeo-cd" + ] + } + ], "alternatives": [ { "sha1": "712816a3c41baf4129e23d645305a536e03d69de", @@ -10070,6 +10233,29 @@ "region_system_groups": [ "sony-playstation" ], + "slot_choices": [ + { + "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", + "sha256": "0c8359870cbac0ea091f1c87f188cd332dcc709753b91cafd9fd44a4a6188197", + "size": 524288, + "repo_path": "bios/Sony/PlayStation/scph5000.bin", + "cores": [ + "duckstation", + "rustation" + ] + } + ], + "declared_choices": [ + { + "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", + "sha256": "0c8359870cbac0ea091f1c87f188cd332dcc709753b91cafd9fd44a4a6188197", + "size": 524288, + "repo_path": "bios/Sony/PlayStation/scph5000.bin", + "systems": [ + "sony-playstation" + ] + } + ], "alternatives": [ { "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", @@ -11914,6 +12100,46 @@ "systems": [ "sony-playstation-2" ], + "slot_choices": [ + { + "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", + "sha256": "61c77b789ccde856096f024e4a3eef6891e15ca59c127e0dd13e762a7d338a7e", + "size": 2690355, + "repo_path": "bios/Other/armsx2/pcsx2/resources/GameIndex.yaml", + "cores": [ + "armsx2" + ] + }, + { + "sha1": "7efb2ed721cf0c0f0677324e09be311a8337e793", + "sha256": "3bf3c54d6f16d88d3669be698179bb8c2fb981ee5ce71bcb952719429a838cc1", + "size": 2693579, + "repo_path": "bios/Other/yaps2/GameIndex.yaml", + "cores": [ + "yaps2" + ] + }, + { + "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", + "sha256": "61c77b789ccde856096f024e4a3eef6891e15ca59c127e0dd13e762a7d338a7e", + "size": 2690355, + "repo_path": "bios/Other/armsx2/pcsx2/resources/GameIndex.yaml", + "cores": [ + "lrps2" + ] + } + ], + "declared_choices": [ + { + "sha1": "57369df9ca82a460e596cea7835c5a0797dee668", + "sha256": "efa94cb7b9208dc6812bb9407dc986560e523cce57f511eff30942f76b7288bf", + "size": 1737799, + "repo_path": "bios/Sony/PlayStation 2/.variants/GameIndex.yaml.57369df9", + "systems": [ + "sony-playstation-2" + ] + } + ], "alternatives": [ { "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", diff --git a/install/retrodeck.json b/install/retrodeck.json index 972f6ccb..be9d0c7f 100644 --- a/install/retrodeck.json +++ b/install/retrodeck.json @@ -6570,6 +6570,26 @@ "arcade", "philips-cdi" ], + "declared_choices": [ + { + "sha1": "e7d2a0dad62d6f75bc10f48a376da0a99b764571", + "sha256": "aa8a33813531795de9739b06b65960e0f86cdec504c8452a956d184eac3cdcd4", + "size": 468660, + "repo_path": "bios/Arcade/Arcade/.variants/cdibios.zip.e7d2a0da", + "systems": [ + "arcade" + ] + }, + { + "sha1": "16072afaa65d1b059346616ac5b5a600c63ff1d1", + "sha256": "b53d206ae81607bf6866411b720ad82ce75122423058ce08c6df84cdc1ca3496", + "size": 472798, + "repo_path": "bios/Philips/CD-i/cdibios.zip", + "systems": [ + "philips-cdi" + ] + } + ], "alternatives": [ { "sha1": "16072afaa65d1b059346616ac5b5a600c63ff1d1", @@ -17884,6 +17904,26 @@ "arcade", "sega-saturn" ], + "declared_choices": [ + { + "sha1": "330a2ab5ce2e2c29987b2086e021de7aa04aa102", + "sha256": "bbdf6a9ef4bad6bd9ad16f9f7669071dd4ee3bb2387d49124f024ea8dd750f88", + "size": 2699375, + "repo_path": "bios/Arcade/MAME/.variants/stvbios.zip.330a2ab5", + "systems": [ + "arcade" + ] + }, + { + "sha1": "7783ab663bb1a8d62ff60d583674355c2af556b1", + "sha256": "38ceb2792c72770f56aadb1865cf66dc33a3017db1ee05e11c2b2dff397ad39e", + "size": 2955630, + "repo_path": "bios/Arcade/MAME/stvbios.zip", + "systems": [ + "sega-saturn" + ] + } + ], "alternatives": [ { "sha1": "b14bbed4bf459439cf7e96d2f79d7adea5d31448", @@ -20260,6 +20300,26 @@ "atari-400-800", "atari-5200" ], + "declared_choices": [ + { + "sha1": "f1f0741b1d34fb4350cf7cb8ab3b6ea11cdd8174", + "sha256": "bc6012f8ca2f7dde21ae895899b1d2af572ffaa28f819b84911ddb6a0bd8a5c7", + "size": 10240, + "repo_path": "bios/Atari/400-800/ATARIOSB.ROM", + "systems": [ + "atari-400-800" + ] + }, + { + "sha1": "db1031585968cfc6ec2ecda5c9a5a52f61444a3b", + "sha256": "63973d5e86b1ae5b6fca5b119a7f7589e817e19acc3ca54e54462c9dfb619e73", + "size": 10240, + "repo_path": "bios/Atari/400-800/ROM_OS_B_NTSC", + "systems": [ + "atari-5200" + ] + } + ], "alternatives": [ { "sha1": "db1031585968cfc6ec2ecda5c9a5a52f61444a3b", @@ -23088,6 +23148,26 @@ "systems": [ "nec-pc-88", "tandy-coco" + ], + "declared_choices": [ + { + "sha1": "bb7103a0818850a039c67ff666a31ce49a8d516f", + "sha256": "9dc63118728a171ce2ed437ad38ef280b8b7bcf4a5a95fedf18946501b010456", + "size": 2048, + "repo_path": "bios/NEC/PC-88/N88SUB.ROM", + "systems": [ + "nec-pc-88" + ] + }, + { + "sha1": "892b85cc96f0ff31cae8ad8d409b9f6c04b32e5e", + "sha256": "b9b4bfe42b4957d4c6b532ceb5a1946c9e4d9d4f55a6e3eb600cc7f236e09ca9", + "size": 8194, + "repo_path": "bios/Tandy/CoCo/disk.rom", + "systems": [ + "tandy-coco" + ] + } ] }, { diff --git a/install/retropie.json b/install/retropie.json index 43054933..83137be4 100644 --- a/install/retropie.json +++ b/install/retropie.json @@ -1553,6 +1553,28 @@ "systems": [ "arcade" ], + "slot_choices": [ + { + "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", + "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", + "size": 3556, + "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", + "cores": [ + "fbneo" + ] + } + ], + "declared_choices": [ + { + "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", + "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "size": 3556, + "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "systems": [ + "arcade" + ] + } + ], "alternatives": [ { "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", @@ -1867,6 +1889,29 @@ "arcade", "snk-neogeo-cd" ], + "slot_choices": [ + { + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", + "cores": [ + "fbneo", + "fbneo_neogeo" + ] + } + ], + "declared_choices": [ + { + "sha1": "4f28af31ca0defdd73d80edec2fa296908e624dc", + "sha256": "6aa507a5dc33c45d2592bf2bc77eb3e497762221ddd9225f18583a5c55d38667", + "size": 214681, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.4f28af31", + "systems": [ + "arcade" + ] + } + ], "declared_in": [ "arcade" ], @@ -2061,6 +2106,28 @@ "atari-400-800", "atari-5200" ], + "slot_choices": [ + { + "sha1": "db1031585968cfc6ec2ecda5c9a5a52f61444a3b", + "sha256": "63973d5e86b1ae5b6fca5b119a7f7589e817e19acc3ca54e54462c9dfb619e73", + "size": 10240, + "repo_path": "bios/Atari/400-800/ROM_OS_B_NTSC", + "cores": [ + "atari800" + ] + } + ], + "declared_choices": [ + { + "sha1": "f1f0741b1d34fb4350cf7cb8ab3b6ea11cdd8174", + "sha256": "bc6012f8ca2f7dde21ae895899b1d2af572ffaa28f819b84911ddb6a0bd8a5c7", + "size": 10240, + "repo_path": "bios/Atari/400-800/ATARIOSB.ROM", + "systems": [ + "atari-400-800" + ] + } + ], "declared_in": [ "atari-400-800" ], @@ -2346,6 +2413,34 @@ "systems": [ "coleco-colecovision" ], + "slot_choices": [ + { + "cores": [ + "jollycv", + "openmsx" + ] + }, + { + "sha1": "99ba9be24ada3e86e5c17aeecb7a2d68c5edfe59", + "sha256": "25cdb7ff4ad675ac25e11663e9e9472afc1c7cf59ba40c330081abda9881a968", + "size": 8192, + "repo_path": "bios/Coleco/ColecoVision/colecoa.rom", + "cores": [ + "zesarux" + ] + } + ], + "declared_choices": [ + { + "sha1": "45bedc4cbdeac66c7df59e9e599195c778d86a92", + "sha256": "990bf1956f10207d8781b619eb74f89b00d921c8d45c95c334c16c8cceca09ad", + "size": 8192, + "repo_path": "bios/Coleco/ColecoVision/BIOS.col", + "systems": [ + "coleco-colecovision" + ] + } + ], "alternatives": [ { "sha1": "45bedc4cbdeac66c7df59e9e599195c778d86a92", @@ -4113,6 +4208,33 @@ "systems": [ "microsoft-msx" ], + "slot_choices": [ + { + "cores": [ + "fmsx" + ] + }, + { + "sha1": "fec451b9256570a0e4869355a40329c57f40705f", + "sha256": "a3a6a325935d031cee662b56e2c810c73d35a8375a2c5ffa03f6d27405bc569e", + "size": 65536, + "repo_path": "bios/Microsoft/MSX/openmsx/fmpac.rom", + "cores": [ + "openmsx" + ] + } + ], + "declared_choices": [ + { + "sha1": "9d789166e3caf28e4742fe933d962e99618c633d", + "sha256": "3cfe44646b69a622656b96305f1b975f48ca018839f7946895f7a1352d9720aa", + "size": 65536, + "repo_path": "bios/Microsoft/MSX/FMPAC.ROM", + "systems": [ + "microsoft-msx" + ] + } + ], "alternatives": [ { "sha1": "9d789166e3caf28e4742fe933d962e99618c633d", @@ -4190,6 +4312,33 @@ "systems": [ "microsoft-msx" ], + "slot_choices": [ + { + "cores": [ + "fmsx" + ] + }, + { + "sha1": "e998f0c441f4f1800ef44e42cd1659150206cf79", + "sha256": "1c85dac5536fa3ba6f2cb70deba02ff680b34ac6cc787d2977258bd663a99555", + "size": 32768, + "repo_path": "bios/Microsoft/MSX/MSX_8020-20bios.rom", + "cores": [ + "zesarux" + ] + } + ], + "declared_choices": [ + { + "sha1": "409e82adac40f6bdd18eb6c84e8b2fbdc7fb5498", + "sha256": "999564a371dd2fdf7fbe8d853e82a68d557c27b7d87417639b2fa17704b83f78", + "size": 32768, + "repo_path": "bios/Microsoft/MSX/MSX.ROM", + "systems": [ + "microsoft-msx" + ] + } + ], "alternatives": [ { "sha1": "409e82adac40f6bdd18eb6c84e8b2fbdc7fb5498", @@ -5250,6 +5399,38 @@ "nintendo-ds", "nintendo-dsi" ], + "slot_choices": [ + { + "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", + "sha256": "12a03016ba07cfd874fc002df49a2c6eb2414eb7cfa395c2043035ec5e9193e8", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios7i.bin", + "cores": [ + "melonds", + "melonds_ds" + ] + } + ], + "declared_choices": [ + { + "sha1": "a3aa751eb6bdaaf8a827ba9e03576a6f1ab0f547", + "sha256": "2946281e730e71f7cafdb125f5cb60fed944ca5d610ee1e082c441b602b5f4e2", + "size": 65536, + "repo_path": "bios/Nintendo/Nintendo DS/biosdsi7.bin", + "systems": [ + "nintendo-dsi" + ] + }, + { + "sha1": "d2b17224e156ee5e55b5d8d9f323b1c9b86dd951", + "sha256": "28a13952901b4065ef3f08c3252b49a00b6525e301b7ddaa3a390b4c7cbef901", + "size": 65536, + "repo_path": "bios/Nintendo/DS/dsi_bios7.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "c7c7570bfe51c3c7c5da3b01331b94e7e7cb4f53", @@ -5274,6 +5455,38 @@ "nintendo-ds", "nintendo-dsi" ], + "slot_choices": [ + { + "sha1": "719b9eef33692406d7170ff526069615759c4dfc", + "sha256": "6e9f70b51dc4f26289b597f14e2513620c2af964374a95f4c5a62b5cad199afc", + "size": 65536, + "repo_path": "bios/Nintendo/DS/NDS_Bios9i.bin", + "cores": [ + "melonds", + "melonds_ds" + ] + } + ], + "declared_choices": [ + { + "sha1": "7bf549b8be9e48ab0cdc9b0fdadd49a5131f97eb", + "sha256": "47538922a8e8a8e79b922ff1203863ef5c40d9c54656a8d2c89c56ece52029ce", + "size": 65536, + "repo_path": "bios/Nintendo/Nintendo DS/biosdsi9.bin", + "systems": [ + "nintendo-dsi" + ] + }, + { + "sha1": "db61fa39ddbc5f5ed71fb19cda47609ef0201723", + "sha256": "664c608bd6ccfe583e87b3b924afa00f8697dc7ca92eeb6d206d68abb03d2e10", + "size": 65536, + "repo_path": "bios/Nintendo/DS/dsi_bios9.bin", + "systems": [ + "nintendo-ds" + ] + } + ], "alternatives": [ { "sha1": "719b9eef33692406d7170ff526069615759c4dfc", @@ -9587,6 +9800,38 @@ "systems": [ "snk-neogeo-cd" ], + "slot_choices": [ + { + "sha1": "712816a3c41baf4129e23d645305a536e03d69de", + "sha256": "f885f2cc79b5c0120ca316b0db360183c2b9a60bc727cbe211b7518e12bd5548", + "size": 955352, + "repo_path": "bios/SNK/Neo Geo CD/.variants/neocdz.zip.712816a3", + "cores": [ + "geolith" + ] + }, + { + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", + "cores": [ + "fbneo", + "fbneo_neogeo" + ] + } + ], + "declared_choices": [ + { + "sha1": "9cab31cfe7eacb6871d36cee105d87b90fd85b64", + "sha256": "d828241905d390c5de4867646296f615c95bca2f5d8f7e81d50e08717373a821", + "size": 1181333, + "repo_path": "bios/Arcade/Arcade/neocdz.zip", + "systems": [ + "snk-neogeo-cd" + ] + } + ], "alternatives": [ { "sha1": "712816a3c41baf4129e23d645305a536e03d69de", @@ -9879,6 +10124,29 @@ "region_system_groups": [ "sony-playstation" ], + "slot_choices": [ + { + "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", + "sha256": "0c8359870cbac0ea091f1c87f188cd332dcc709753b91cafd9fd44a4a6188197", + "size": 524288, + "repo_path": "bios/Sony/PlayStation/scph5000.bin", + "cores": [ + "duckstation", + "rustation" + ] + } + ], + "declared_choices": [ + { + "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", + "sha256": "0c8359870cbac0ea091f1c87f188cd332dcc709753b91cafd9fd44a4a6188197", + "size": 524288, + "repo_path": "bios/Sony/PlayStation/scph5000.bin", + "systems": [ + "sony-playstation" + ] + } + ], "alternatives": [ { "sha1": "ffa7f9a7fb19d773a0c3985a541c8e5623d2c30d", @@ -11723,6 +11991,46 @@ "systems": [ "sony-playstation-2" ], + "slot_choices": [ + { + "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", + "sha256": "61c77b789ccde856096f024e4a3eef6891e15ca59c127e0dd13e762a7d338a7e", + "size": 2690355, + "repo_path": "bios/Other/armsx2/pcsx2/resources/GameIndex.yaml", + "cores": [ + "armsx2" + ] + }, + { + "sha1": "7efb2ed721cf0c0f0677324e09be311a8337e793", + "sha256": "3bf3c54d6f16d88d3669be698179bb8c2fb981ee5ce71bcb952719429a838cc1", + "size": 2693579, + "repo_path": "bios/Other/yaps2/GameIndex.yaml", + "cores": [ + "yaps2" + ] + }, + { + "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", + "sha256": "61c77b789ccde856096f024e4a3eef6891e15ca59c127e0dd13e762a7d338a7e", + "size": 2690355, + "repo_path": "bios/Other/armsx2/pcsx2/resources/GameIndex.yaml", + "cores": [ + "lrps2" + ] + } + ], + "declared_choices": [ + { + "sha1": "57369df9ca82a460e596cea7835c5a0797dee668", + "sha256": "efa94cb7b9208dc6812bb9407dc986560e523cce57f511eff30942f76b7288bf", + "size": 1737799, + "repo_path": "bios/Sony/PlayStation 2/.variants/GameIndex.yaml.57369df9", + "systems": [ + "sony-playstation-2" + ] + } + ], "alternatives": [ { "sha1": "72bfdf91fa11a86e2810a7c167cdcf11e20418a8", diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 51d27948..16fc1efb 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -482,6 +482,104 @@ } } }, + "slot_choices": { + "description": "On a platform file a core's own source contradicts while the frontend only checks the path: the copy each core's presence puts there, in the order the pack builder settles the slot. A narrowing serves the first whose cores remain; one naming only cores, or none remaining, leaves `declared_choices`.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "cores" + ], + "properties": { + "sha1": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "repo_path": { + "type": "string" + }, + "storage": { + "const": "release" + }, + "release_asset": { + "type": "string", + "pattern": "^[^/\\\\]+$" + }, + "cores": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "executable": { + "type": "boolean" + } + } + } + }, + "declared_choices": { + "description": "The platform's own answer at the destination by the declaring systems that remain, in the order the pack builder takes the declarations; a narrowing serves the first whose systems remain.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "sha1", + "sha256", + "size", + "repo_path", + "systems" + ], + "properties": { + "sha1": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "size": { + "type": "integer", + "minimum": 0 + }, + "repo_path": { + "type": "string" + }, + "storage": { + "const": "release" + }, + "release_asset": { + "type": "string", + "pattern": "^[^/\\\\]+$" + }, + "executable": { + "type": "boolean" + }, + "systems": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + } + } + } + }, "executable": { "description": "The repository keeps the file executable; the installer gives it the execute bits the umask allows.", "type": "boolean" diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 363bbcbd..9e2a2684 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -3160,6 +3160,66 @@ def _attach_claims( entry.pop("alternatives", None) +def _record_slot_choices( + entry: dict, + choices: list[tuple[str, str | None]], + declarations: list[tuple[list[str], dict, str]], + settle, + dest: str, + db: dict, + repo_root: str, +) -> None: + """Record what a pack built for fewer cores serves at a platform destination. + + `slot_choices` lists, in the order slots decides, the cores whose claim + settles the slot and the copy it serves, a choice without a copy leaving + the platform's answer. `declared_choices` is the platform's answer by the + declaring systems that remain, in the order the builder takes them. + Nothing is recorded where every narrowing gets the entry's own copy. + """ + + def download(file_entry: dict, sys_id: str, override: str | None) -> dict | None: + local_path, _status = settle(file_entry, sys_id, dest, override) + if local_path is None: + return None + copy, _size = _manifest_entry(local_path, dest, None, db, repo_root, {}) + if copy is None: + return None + return {k: v for k, v in copy.items() if k not in ("dest", "cores")} + + declared: list[dict] = [] + for systems, file_entry, sys_id in declarations: + copy = download(file_entry, sys_id, None) + if copy is None: + # An omission is not a copy the installer can switch to. + return + if declared and declared[-1]["sha1"] == copy["sha1"]: + declared[-1]["systems"].extend(systems) + else: + declared.append({**copy, "systems": list(systems)}) + _systems, first, first_sys = declarations[0] + recorded: list[dict] = [] + seen: set[str] = set() + for emulator, path in choices: + if emulator in seen: + continue + seen.add(emulator) + copy = {} if path is None else download(first, first_sys, path) + if copy is None: + return + if recorded and recorded[-1].get("sha1") == copy.get("sha1"): + recorded[-1]["cores"].append(emulator) + else: + recorded.append({**copy, "cores": [emulator]}) + while recorded and "sha1" not in recorded[-1]: + recorded.pop() + if not recorded and len(declared) == 1: + return + if recorded: + entry["slot_choices"] = recorded + entry["declared_choices"] = declared + + def _manifest_core_entries( core_files: list, config: dict, @@ -3441,6 +3501,13 @@ def generate_manifest( config, emu_profiles, db, zip_contents, data_registry, target_cores=target_cores, source=source, ) + # The full manifest records every core's answer at a contested slot, so a + # target narrowed from it gets what that target's own pack serves. + slot_choices = ( + slots.slot_choices(config, emu_profiles, db, zip_contents, data_registry) + if target_cores is None and source != "platform" and emu_profiles + else {} + ) def manifest_destination(full_destination: str) -> str: if base_dest and full_destination.startswith(f"{base_dest}/"): @@ -3489,6 +3556,80 @@ def generate_manifest( pack_systems, db, bios_dir, base_dest, required_only, zip_contents, offline, ) + declarations: dict[str, dict[str, list[dict]]] = {} + for sys_id, system in sorted(pack_systems.items()): + for file_entry in system.get("files", []): + if required_only and file_entry.get("required") is False: + continue + dest = sanitize_pack_path(file_entry.get("destination", file_entry["name"])) + if dest and not (region_drops and dest in region_drops): + full_dest = f"{base_dest}/{dest}" if base_dest else dest + declarations.setdefault(full_dest, {}).setdefault(sys_id, []).append( + file_entry + ) + + def declaration_order(full_dest: str) -> list[tuple[list[str], dict, str]]: + """The declarations answering a destination as its systems drop. + + A pack built for a target loses the systems no available core + reads, and the next declaration takes the destination by the + same rule: RetroArch's bare nintendo-ds dsi_bios9.bin once the + pinned nintendo-dsi one is gone. + """ + remaining = dict(declarations.get(full_dest, {})) + order: list[tuple[list[str], dict, str]] = [] + while remaining: + winner = _preferred_entries( + {sid: {"files": fes} for sid, fes in remaining.items()}, + db, bios_dir, base_dest, required_only, zip_contents, offline, + ).get(full_dest) + first_sys, first = next( + (sid, fe) for sid, fes in sorted(remaining.items()) for fe in fes + if winner is None or id(fe) == winner + ) + systems = sorted( + sid for sid, fes in remaining.items() + if any(fe is first for fe in fes) + ) + order.append((systems, first, first_sys)) + for sid in systems: + del remaining[sid] + return order + + def settle(file_entry: dict, sys_id: str, dest: str, override: str | None): + """The file served once the slot is settled, or None and why not.""" + local_path, status = slots.apply_override(override, *resolve_file( + file_entry, + db, + bios_dir, + zip_contents, + data_dir_registry=data_registry, + offline=offline, + )) + if status == "not_found" and not reads_file_contents(verification_mode): + found = agnostic_substitute(file_entry, sys_id, db, manifest_profiles) + if found: + local_path, status = found[0], "agnostic_fallback" + if os.path.basename(local_path) != file_entry.get("name", ""): + # The pack explains the rename in a note of its own. + rename_notes.add(file_entry.get("name", "")) + # The builder's verdict: a hash platform omits what its frontend + # would reject, a zipped_file archive included when the member is + # missing whatever the outer resolution said. An existence + # platform never reads the bytes. + status, refusal = baseline_verdict( + file_entry, local_path, status, verification_mode + ) + if refusal or status in ("not_found", "external"): + return None, "hash_mismatch" if refusal else status + # The variant the pack ships when an emulator rejects the + # resolved file: the installer must place the same bytes. + local_path, _disagreement = validated_choice( + file_entry, local_path, db, manifest_validation, bios_dir, + digest_algorithm(verification_mode), dest, manifest_owners, + ) + return local_path, status + for sys_id, system in sorted(pack_systems.items()): for file_entry in system.get("files", []): if required_only and file_entry.get("required") is False: @@ -3524,48 +3665,24 @@ def generate_manifest( ) continue - local_path, status = resolve_file( - file_entry, - db, - bios_dir, - zip_contents, - data_dir_registry=data_registry, - offline=offline, + local_path, status = settle( + file_entry, sys_id, dest, slot_overrides.get(full_dest) ) - local_path, status = slots.apply_override( - slot_overrides.get(full_dest), local_path, status - ) - if status == "not_found" and not reads_file_contents(verification_mode): - found = agnostic_substitute(file_entry, sys_id, db, manifest_profiles) - if found: - local_path, status = found[0], "agnostic_fallback" - if os.path.basename(local_path) != file_entry.get("name", ""): - # The pack explains the rename in a note of its own. - rename_notes.add(file_entry.get("name", "")) - # The builder's verdict: a hash platform omits what its - # frontend would reject, a zipped_file archive included when - # the member is missing whatever the outer resolution said. - # An existence platform never reads the bytes. - status, refusal = baseline_verdict( - file_entry, local_path, status, verification_mode - ) - if refusal or status in ("not_found", "external"): - record_omission( - full_dest, file_entry, sys_id, - "hash_mismatch" if refusal else status, None, - ) + if local_path is None: + record_omission(full_dest, file_entry, sys_id, status, None) continue - # The variant the pack ships when an emulator rejects the - # resolved file: the installer must place the same bytes. - local_path, _disagreement = validated_choice( - file_entry, local_path, db, manifest_validation, bios_dir, - digest_algorithm(verification_mode), dest, manifest_owners, - ) entry, file_size = _manifest_entry( local_path, dest, None, db, repo_root, selection.fields(dest, file_entry["name"]), ) + if entry is not None and target_cores is None: + order = declaration_order(full_dest) + if full_dest in slot_choices or len(order) > 1: + _record_slot_choices( + entry, slot_choices.get(full_dest, []), order, + settle, dest, db, repo_root, + ) if entry is None: record_omission(full_dest, file_entry, sys_id, "not_found", None) if file_size and full_dest not in seen_destinations: @@ -3706,7 +3823,7 @@ def _manifest_core_index( } named = { c for entry in [*files, *omitted] - for copy in [entry, *entry.get("alternatives", [])] + for copy in [entry, *entry.get("alternatives", []), *entry.get("slot_choices", [])] for c in copy.get("cores") or [] } named |= {c for cores in system_cores.values() for c in cores} diff --git a/scripts/slots.py b/scripts/slots.py index 4e771559..244bb259 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -28,6 +28,7 @@ from common import ( parse_md5_list, resolution_is_hash_exact, resolve_local_file, + resolve_platform_cores, runs_standalone, ) from packresolve import preferred_entries @@ -302,20 +303,16 @@ def _claim_rank(claim: Claim) -> int: return 1 if claim.is_proven else 0 -def find_conflicts( +def _slot_claims( config: dict, profiles: dict, db: dict, - base_dest: str = "", - standalone_cores: set[str] | None = None, - zip_contents: dict | None = None, - data_dir_registry: dict | None = None, -) -> list[Conflict]: - """Destinations where a proven profile claim contradicts what ships. - - Only proven claims are compared. A claim resolved by filename alone - asserts nothing about content and cannot contradict anything. - """ + base_dest: str, + standalone_cores: set[str] | None, + zip_contents: dict | None, + data_dir_registry: dict | None, +) -> tuple[dict[str, Claim], dict[str, list[Claim]]]: + """The platform claim and the proven profile claims at each destination.""" fold = bool(config.get("case_insensitive_fs", False)) # The builder's own choice among declarations sharing a destination, # read from the same function: the most accepting archive for zipped_file @@ -369,6 +366,27 @@ def find_conflicts( continue by_slot.setdefault(key, []).append(claim) + return by_dest, by_slot + + +def find_conflicts( + config: dict, + profiles: dict, + db: dict, + base_dest: str = "", + standalone_cores: set[str] | None = None, + zip_contents: dict | None = None, + data_dir_registry: dict | None = None, +) -> list[Conflict]: + """Destinations where a proven profile claim contradicts what ships. + + Only proven claims are compared. A claim resolved by filename alone + asserts nothing about content and cannot contradict anything. + """ + by_dest, by_slot = _slot_claims( + config, profiles, db, base_dest, standalone_cores, zip_contents, + data_dir_registry, + ) # A declaration that lists several accepted hashes is satisfied by any of # them: Recalbox names both scph1001.bin revisions, and mesence names # four BS-X dumps among which System.dat pins one. The resolver stops on @@ -428,20 +446,45 @@ def pack_overrides( verify all read this: two of them deciding alone gave the one-line installer different bytes than the ZIP. """ - from common import resolve_platform_cores - # A baseline-only pack serves what the platform declares, and a target's # pack answers to the cores that hardware builds: DuckStation does not # exist on any RetroArch target and still replaced its scph5000.bin. if not profiles or source == "platform": return {} - platform_profiles = { - name: profiles[name] - for name in resolve_platform_cores(config, profiles, target_cores=target_cores) - } - mode = config.get("verification_mode", "existence") + cores = set(resolve_platform_cores(config, profiles, target_cores=target_cores)) overrides: dict[str, str] = {} - for conflict in find_conflicts( + for destination, choices in slot_choices( + config, profiles, db, zip_contents, data_dir_registry + ).items(): + path = next((path for emulator, path in choices if emulator in cores), None) + if path: + overrides[destination] = path + return overrides + + +def slot_choices( + config: dict, + profiles: dict, + db: dict, + zip_contents: dict | None = None, + data_dir_registry: dict | None = None, +) -> dict[str, list[tuple[str, str | None]]]: + """Destination -> (core, the file it has served there) in decision order. + + A pack built for a set of cores serves the first choice whose core is in + it, None standing for the platform's own answer; no core in it leaves + the platform's answer too. The order is arbitrate's: a core agreeing with + the platform settles the slot whatever else is present, then the + strongest proof decides. Read for one core set by pack_overrides and + recorded whole in the install manifest, so a target narrowed from the + full manifest gets the bytes the target's own pack carries. + """ + if nativemode.reads_file_contents(config.get("verification_mode", "existence")): + return {} + platform_profiles = { + name: profiles[name] for name in resolve_platform_cores(config, profiles) + } + by_dest, by_slot = _slot_claims( config, platform_profiles, db, @@ -449,11 +492,23 @@ def pack_overrides( {str(c) for c in config.get("standalone_cores", [])}, zip_contents, data_dir_registry, - ): - decision = arbitrate(conflict, mode) - if decision.serves_both and decision.winner.local_path: - overrides[conflict.destination] = decision.winner.local_path - return overrides + ) + choices: dict[str, list[tuple[str, str | None]]] = {} + for key, claims in sorted(by_slot.items()): + platform = by_dest[key] + agree = [_satisfies(c, platform, db) for c in claims] + agreeing = [c for c, ok in zip(claims, agree) if ok] + others = sorted( + (c for c, ok in zip(claims, agree) if not ok), + key=lambda claim: (-_claim_rank(claim), claims.index(claim)), + ) + if not others: + continue + choices[platform.destination] = [ + *((c.emulator, None) for c in agreeing), + *((c.emulator, c.local_path) for c in others), + ] + return choices def apply_override( diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index 64b47bb5..3c89de0b 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -244,6 +244,74 @@ class APlatformOmissionKeepsItsClaim(unittest.TestCase): ) +class APlatformFileSettledByCoresAndSystems(unittest.TestCase): + """RetroArch's dsi_bios9.bin ships melonds' copy where melonds runs; on + a target without it the pack serves the platform's answer, and the + pinned nintendo-dsi declaration gives way to the bare nintendo-ds one + once its system drops. The installer kept melonds' copy on rpi4.""" + + def copy(self, letter: str) -> dict: + return {"sha1": letter * 40, "size": 1, "repo_path": f"bios/{letter}.bin"} + + def manifest(self) -> dict: + entry = { + "dest": "dsi_bios9.bin", **self.copy("a"), "cores": None, + "systems": ["nintendo-ds", "nintendo-dsi"], + "slot_choices": [{"cores": ["desmume"]}, {**self.copy("a"), "cores": ["melonds"]}], + "declared_choices": [ + {**self.copy("c"), "systems": ["nintendo-dsi"]}, + {**self.copy("b"), "systems": ["nintendo-ds"]}, + ], + } + return { + "manifest_version": 2, "platform": "retroarch", + "files": [entry], "omitted_files": [], + "core_names": {}, + "system_cores": {"nintendo-ds": ["desmume", "melonds", "nds"], + "nintendo-dsi": ["melonds", "dsi"]}, + } + + def served(self, names: list[str]) -> str: + files, _ = install.narrow_to_cores(self.manifest(), names) + self.assertEqual(len(files), 1) + self.assertNotIn("declared_choices", files[0]) + return files[0]["sha1"][0] + + def test_the_first_chosen_core_decides(self): + self.assertEqual(self.served(["melonds"]), "a") + + def test_a_core_agreeing_with_the_platform_leaves_its_answer(self): + self.assertEqual(self.served(["desmume", "melonds"]), "c") + self.assertEqual(self.served(["desmume"]), "b") + + def test_the_remaining_system_answers_without_the_cores(self): + self.assertEqual(self.served(["dsi"]), "c") + self.assertEqual(self.served(["nds"]), "b") + + def test_the_boundary_checks_every_copy(self): + install._validate_manifest(self.manifest(), "retroarch") + bad_choices = ( + [{"cores": []}], + [{"cores": ["x"], "repo_path": "../x", "sha1": "a" * 40, "size": 1}], + [{"cores": ["x"], "dest": "../evil"}], + ) + for choices in bad_choices: + manifest = self.manifest() + manifest["files"][0]["slot_choices"] = choices + with self.subTest(choices=choices), self.assertRaises(ValueError): + install._validate_manifest(manifest, "retroarch") + for declared in ([], [self.copy("c")], [{**self.copy("c"), "systems": []}], + [{**self.copy("c"), "systems": ["s"], "cores": ["x"]}]): + manifest = self.manifest() + manifest["files"][0]["declared_choices"] = declared + with self.subTest(declared=declared), self.assertRaises(ValueError): + install._validate_manifest(manifest, "retroarch") + manifest = self.manifest() + del manifest["files"][0]["declared_choices"] + with self.assertRaises(ValueError): + install._validate_manifest(manifest, "retroarch") + + class RefusedBeforeAnyInstall(unittest.TestCase): def test_a_platform_shipping_its_cores_stops_the_run_first(self): tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) diff --git a/wiki/installer.md b/wiki/installer.md index d961da5e..3b7d5c5c 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -173,7 +173,11 @@ What stays is what a pack built for exactly those cores carries, the rule emulates it or reads its files, or when no core of the platform does; a file a core loads stays when that core is installed. Where two cores read one path and want different bytes, the copy of the first installed core in -the builder's order is the one installed. +the builder's order is the one installed. The same holds for a file of the +platform's own list: where a core's source contradicts the platform and the +frontend only checks the path, the core's copy is installed while that core +is, and otherwise the platform's answer, read from the first declaring +system that stays. Batocera, Recalbox and the other distributions ship their cores with the system, so the option is refused there, as it is when no core is found or