diff --git a/install.ps1 b/install.ps1 index f4d16af0a..7cdc38751 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa" + $defaultInstallSha256 = "8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0" # The pin above refuses any changed byte: this cap only stops a body that # cannot be the installer (a sign-in portal's page) before it is hashed. $maximumInstallerBytes = 8MB diff --git a/install.py b/install.py index 529bf6664..42aca769c 100644 --- a/install.py +++ b/install.py @@ -4277,6 +4277,21 @@ def _validate_manifest(data: object, plat: str) -> dict: _check_tags(mapping, key, field) if mapping[key] is None: raise ValueError(f"invalid {field} for {key}") + offered = data.get("offered_boards") + if offered is not None: + if not isinstance(offered, dict) or len(offered) > MAX_MANIFEST_FILES: + raise ValueError("invalid offered_boards") + for key, places in offered.items(): + if not isinstance(key, str) or not 0 < len(key) <= 256: + raise ValueError("invalid offered_boards key") + if not isinstance(places, list) or not all( + isinstance(p, dict) and isinstance(p.get("folder"), str) + and p.get("root") in ("roms", "install") + and isinstance(p.get("boards"), list) + and all(isinstance(b, str) for b in p["boards"]) + for p in places + ): + raise ValueError(f"invalid offered_boards for {key}") titles = data.get("system_titles") if titles is not None: if not isinstance(titles, dict) or len(titles) > MAX_MANIFEST_FILES: @@ -4883,6 +4898,33 @@ def designated_cores( return chosen +def offered_here( + manifest: dict, entry: dict, cores: set[str], target: str | None +) -> set[str]: + """The cores a board reads this file for. + + A roms folder can offer a core on some boards only (ROCKNIX writes + uae4arm for roms/amiga under the devices it builds it for): on another + board, that core reads nothing there, even when the board runs it for + another folder. `offered_boards` gives those boards. + """ + offered = manifest.get("offered_boards") or {} + if target is None or not offered: + return cores + tree = {"roms": "roms", None: "install"}.get(entry.get("root")) + if tree is None: + return cores + dest = str(entry.get("dest", "")) + return { + core for core in cores + if not any( + place["root"] == tree and dest.startswith(place["folder"] + "/") + and target not in place["boards"] + for place in offered.get(core) or [] + ) + } + + def narrow_to_cores( manifest: dict, names: list[str], @@ -4919,7 +4961,8 @@ def narrow_to_cores( result.append(_for_cores(entry, cores) or _settle_slot(entry, cores)) continue if entry.get("cores") is not None: - copy = _for_cores(entry, cores) + here = offered_here(manifest, entry, cores, target) + copy = _for_cores(entry, here) if copy is not None: result.append(copy) continue @@ -4927,7 +4970,7 @@ def narrow_to_cores( # wants the slot and has nothing held is a file left out. claim = next( (c for c in entry.get("absent_claims", []) - if cores.intersection(c.get("cores") or [])), + if here.intersection(c.get("cores") or [])), None, ) if claim is not None: diff --git a/install.sh b/install.sh index 747a6492d..c2c7b235a 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa" +DEFAULT_INSTALL_SHA256="8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0" # The pin above refuses any changed byte: this cap only stops a body that # cannot be the installer (a sign-in portal's page) before it is hashed. MAX_INSTALLER_BYTES=8388608 diff --git a/install/launchers/index.json b/install/launchers/index.json index 4dc4c488d..5519378ea 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1220974, - "sha256": "67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa", + "size": 1222786, + "sha256": "8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1274599, - "sha256": "8ea3d79c9bbcec747555c3970edef0692db5a00ada0ff45892aaf68dcfc38eb6" + "size": 1276411, + "sha256": "8008566a12fc1d18a19d9510c1884919d60ede9eb227bef25973082ac1b204e3" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1220974, - "sha256": "67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa", + "size": 1222786, + "sha256": "8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1220974, - "sha256": "67036bbe4db401de213d9a8ab44161b8b64bfde91834b438fff47802b5a461aa", + "size": 1222786, + "sha256": "8c8ead297c502f40f02f4407bdbb3edcb2af31b68f0a3fca06ed963c6cbc3cb0", "executable": false }, { diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 350b9b49f..fb059c527 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -142,6 +142,27 @@ "uniqueItems": true } }, + "offered_boards": { + "description": "Each core a roms folder of the platform offers on some boards only, with the folder (below the roms root, or the install tree) and the boards and board aliases it is offered on. install.py --target keeps the core's files in that folder on those boards only.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["root", "folder", "boards"], + "properties": { + "root": {"enum": ["roms", "install"]}, + "folder": {"type": "string", "minLength": 1}, + "boards": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + } + } + } + } + }, "system_cores": { "description": "Each platform system with the cores whose presence keeps it. A system absent here is kept whatever cores are available.", "type": "object", diff --git a/scripts/common.py b/scripts/common.py index fc519e4ab..f9e65c433 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -3412,6 +3412,107 @@ def _core_key(name: str) -> str: return str(name).lower().replace("-", "_") +# Set on a platform config built for one board (generate_pack, verify +# --target): a core a roms folder offers on other boards only is not placed +# there. +TARGET_KEY = "_target" + + +def _branch_holds(branch: str, board: str) -> bool: + """Whether one `VARIABLE PATTERN` branch of a device case admits a board. + + ROCKNIX files a board under the DEVICE value its target scraper reads, + lowercased. A branch on any other variable (PREFER_GLES) is no board + condition, and admits every board. + """ + variable, _, pattern = branch.strip().partition(" ") + if variable != "DEVICE" or not pattern.strip(): + return True + import fnmatch # noqa: PLC0415 + + return any( + fnmatch.fnmatchcase(board.upper(), p.strip().upper()) for p in pattern.split("|") + ) + + +def when_admits(when: str, board: str) -> bool: + """Whether an offer's `when` holds on a board: alternatives joined by + "; ", each the branches it is nested under joined by ", ".""" + return any( + all(_branch_holds(branch, board) for branch in alternative.split(", ")) + for alternative in when.split("; ") + ) + + +def offer_boards(config: dict) -> dict[tuple[str, str], frozenset[str] | None]: + """The boards on which each roms folder offers each core, by + (core key, folder), None where every board does. + + ROCKNIX writes a core under a `case "${DEVICE}"` branch for the devices + it builds (`when`), and a default for a named device in board_defaults + (DEVICE=RK3326). Boards are the platform's targets, aliases included. + """ + platform = str(config.get("_key") or "") + targets = list_available_targets( + platform, str(Path(__file__).resolve().parent.parent / "platforms") + ) if platform else [] + names = {t["name"]: [t["name"], *t.get("aliases", [])] for t in targets} + + def admitted(test: Callable[[str], bool]) -> set[str]: + return {n for board, spelled in names.items() if test(board) for n in spelled} + + every = admitted(lambda _board: True) + found: dict[tuple[str, str], set[str]] = {} + for row in config.get("_rom_folders") or []: + folder = str(row.get("folder") or "") + offers = row.get("offers") or [] + offered = {(o.get("emulator"), _core_key(o.get("core") or "")) for o in offers} + choices: list[tuple[dict, set[str]]] = [] + if row.get("core") and (row.get("emulator"), _core_key(row["core"])) not in offered: + choices.append((row, every)) + for key, spec in (row.get("board_defaults") or {}).items(): + branch = str(key).replace("=", " ", 1) + choices.append((spec, admitted(lambda b, branch=branch: _branch_holds(branch, b)))) + for offer in offers: + when = str(offer.get("when") or "") + choices.append((offer, admitted(lambda b, when=when: when_admits(when, b)) + if when else every)) + for choice, boards in choices: + if choice.get("core") and choice.get("emulator") in LIBRETRO_RUNNERS: + found.setdefault((_core_key(choice["core"]), folder), set()).update(boards) + return { + pair: None if not names or boards >= every else frozenset(boards) + for pair, boards in found.items() + } + + +def content_folder_boards(config: dict) -> dict[tuple[str, str], frozenset[str] | None]: + """Each (core key, roms folder) a platform launches a libretro core for, + with the boards it does so on, None for every board.""" + pairs: set[tuple[str, str]] = set() + for core, systems in (config.get("libretro_systems") or {}).items(): + pairs |= {(_core_key(core), str(s)) for s in systems or []} + for system in (config.get("systems") or {}).values(): + if isinstance(system, dict) and system.get("native_id"): + pairs |= {(_core_key(c), str(system["native_id"])) for c in system.get("cores") or []} + for record in config.get("launchers") or []: + if record.get("kind") == "package" and record.get("build") == "libretro": + package = str(record.get("emulator", "")) + if package.startswith("lr-mess"): + # Built with SUBTARGET=mess (lr-mess2016.sh:31): the MESS + # drivers alone, none of the arcade sets of the core it + # shares a repository with. + continue + core = _core_key(package.removeprefix("lr-")) + pairs |= {(core, str(s)) for s in record.get("systems") or []} + # Pairs from the platform's own lists hold on every board, an offer on + # the boards its frontend writes it for. + return { + **{pair: on for pair, on in offer_boards(config).items() if pair not in pairs}, + **{pair: None for pair in pairs}, + } + + def content_folder_tags(config: dict) -> set[str]: """The roms folders a platform launches each libretro core for, and the ROM search paths its commands hand emulators and cores, as tags. @@ -3435,34 +3536,22 @@ def content_folder_tags(config: dict) -> set[str]: return tags rows = config.get("_rom_folders") or [] folders = {str(r.get("folder")) for r in rows if r.get("folder")} - pairs: set[tuple[str, str]] = set() - for core, systems in (config.get("libretro_systems") or {}).items(): - pairs |= {(_core_key(core), str(s)) for s in systems or []} - for row in rows: - choices = [row, *(row.get("board_defaults") or {}).values(), *(row.get("offers") or [])] - for choice in choices: - if choice.get("core") and choice.get("emulator") in LIBRETRO_RUNNERS: - pairs.add((_core_key(choice["core"]), str(row["folder"]))) - for system in (config.get("systems") or {}).values(): - if isinstance(system, dict) and system.get("native_id"): - pairs |= {(_core_key(c), str(system["native_id"])) for c in system.get("cores") or []} - for record in config.get("launchers") or []: - if record.get("kind") == "package" and record.get("build") == "libretro": - package = str(record.get("emulator", "")) - if package.startswith("lr-mess"): - # Built with SUBTARGET=mess (lr-mess2016.sh:31): the MESS - # drivers alone, none of the arcade sets of the core it - # shares a repository with. - continue - core = _core_key(package.removeprefix("lr-")) - pairs |= {(core, str(s)) for s in record.get("systems") or []} + boards = content_folder_boards(config) + target = config.get(TARGET_KEY) + pairs = {pair for pair, on in boards.items() if not target or on is None or target in on} from launchers import _roms_place # noqa: PLC0415 def place(folder: str) -> list: return list(_roms_place(config, folder) or ()) + # Without a board, a core a folder offers on some boards only carries + # them, for a manifest the installer narrows to one. tags |= { - CONTENT_FOLDER_PREFIX + json.dumps([c, f, place(f)]) for c, f in pairs if f in folders + CONTENT_FOLDER_PREFIX + json.dumps([ + c, f, place(f), + None if target or boards.get((c, f)) is None else sorted(boards[(c, f)]), + ]) + for c, f in pairs if f in folders } for row in rows: for search in row.get("searched_by") or []: @@ -3601,12 +3690,19 @@ def content_layout( return {**entry, **where, "path": f"{folder}/{rel}", "layout_path": rel} if not standalone: - folders = sorted({ - tuple(where) for core, _folder, where in ( - json.loads(t[len(CONTENT_FOLDER_PREFIX):]) - for t in tags if t.startswith(CONTENT_FOLDER_PREFIX) - ) if core in names and where - }) + # A place keeps the boards its core is offered on there, every board + # when one folder offers it everywhere. + on_boards: dict[tuple, set[str] | None] = {} + for core, _folder, where, on in ( + json.loads(t[len(CONTENT_FOLDER_PREFIX):]) + for t in tags if t.startswith(CONTENT_FOLDER_PREFIX) + ): + if core not in names or not where: + continue + place = tuple(where) + known = on_boards.get(place, set()) + on_boards[place] = None if on is None or known is None else known | set(on) + folders = sorted(on_boards) # The folders a passed -rompath replaces the build's own search # path with stand for these: libretro MAME hands MAME # /mame/bios unless the content's command line names @@ -3633,7 +3729,11 @@ def content_layout( out.append({**plain, "unplaced": OUTSIDE_BIOS_FOLDER}) continue rel = str(f.get("path") or f.get("name") or "") - out.extend(roms_entry(plain, tree, folder, rel) for tree, folder in folders) + out.extend( + {**roms_entry(plain, tree, folder, rel), + **({"boards": sorted(on)} if (on := on_boards[(tree, folder)]) is not None else {})} + for tree, folder in folders + ) return {**view, "files": out} if changed else view pools = {str(v).strip("/") for v in (view.get("standalone_pools") or {}).values()} if not searches or "" not in pools: diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index dcf32b9bc..612af9ae7 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -39,6 +39,10 @@ sys.path.insert(0, os.path.dirname(__file__)) from alternatives import core_file_spares, settle_sets, slot_set_keys from common import resolve_platform_cores as _platform_cores from common import ( + ROMS_ROOT, + TARGET_KEY, + _core_key, + content_folder_boards, OUTER_ROOTS, outer_layout, installed_data_refs, @@ -895,6 +899,8 @@ def generate_pack( Returns the path to the generated ZIP, or None on failure. """ config = load_platform_config(platform_name, platforms_dir) + if target_name: + config = {**config, TARGET_KEY: target_name} if zip_contents is None: zip_contents = {} if emu_profiles is None: @@ -4861,6 +4867,8 @@ def generate_manifest( config = load_platform_config(platform_name, platforms_dir) if fold_case is not None: config = {**config, "case_insensitive_fs": fold_case} + if target_name: + config = {**config, TARGET_KEY: target_name} if zip_contents is None: zip_contents = {} if emu_profiles is None: @@ -5334,6 +5342,10 @@ def generate_manifest( **({"downloaded_cores": downloaded} if (downloaded := _downloaded_cores( config, emu_profiles, core_names, platforms_dir, )) else {}), + **({"offered_boards": offered} if (offered := _offered_boards( + config, emu_profiles, core_names, + [*manifest_files, *omitted_by_destination.values()], + )) else {}), "system_cores": system_cores, # The names the platform itself gives its systems (Batocera's psx), # which --system accepts beside the ids. @@ -5488,6 +5500,65 @@ def _manifest_data_directories( return trees +def _offered_boards( + config: dict, + profiles: dict[str, dict], + core_names: dict[str, list[str]], + entries: list[dict], +) -> dict[str, list[dict]]: + """The roms folders each profile is offered for on some boards only, + each with those boards, aliases included, where the manifest places a + file of that profile. + + ROCKNIX writes uae4arm for roms/amiga under the devices it builds it for + (emulators/package.mk:188), and a board's core list names uae4arm when + another folder offers it there. The pack builder reads the board from + the config (TARGET_KEY) and `install.py --target` reads its answer here. + A manifest built for one board needs none. + """ + if config.get(TARGET_KEY): + return {} + pairs = content_folder_boards(config) + if all(on is None for on in pairs.values()): + return {} + from launchers import _roms_place # noqa: PLC0415 + + by_key: dict[str, set[str]] = {} + for name, claimants in upstream_profile_index(profiles).items(): + by_key.setdefault(_core_key(name), set()).update(claimants) + free = { + (profile, folder) for (core, folder), on in pairs.items() if on is None + for profile in by_key.get(core, ()) + } + found: dict[tuple[str, str, str], set[str]] = {} + for (core, folder), on in pairs.items(): + where = _roms_place(config, folder) if on is not None else None + if where is None: + continue + for profile in by_key.get(core, ()): + if profile in core_names and (profile, folder) not in free: + found.setdefault((profile, where[0], where[1]), set()).update(on) + # An entry below the roms root names it, one in the install tree none. + trees = {ROMS_ROOT: ROMS_ROOT, None: "install"} + placed = { + (profile, trees[entry.get("root")], str(entry.get("dest", "")).rpartition("/")[0]) + for entry in entries if entry.get("root") in trees + for copy in [entry, *entry.get("alternatives", [])] + for profile in copy.get("cores") or [] + } + result: dict[str, list[dict]] = {} + for (profile, tree, folder), boards in sorted(found.items()): + if not any( + p == profile and t == tree and (d == folder or d.startswith(f"{folder}/")) + for p, t, d in placed + ): + continue + result.setdefault(profile, []).append( + {"root": tree, "folder": folder, "boards": sorted(boards)} + ) + return result + + def _downloaded_cores( config: dict, profiles: dict[str, dict], diff --git a/scripts/verify.py b/scripts/verify.py index a3fe5fb02..9cb11b855 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -46,6 +46,8 @@ from alternatives import ( slot_set_keys, ) from common import ( + TARGET_KEY, + canonical_target_name, OUTER_ROOTS, _foreign_tree, _owner_tokens, @@ -3302,6 +3304,11 @@ def _main(held: contextlib.ExitStack) -> None: for group_platforms, representative in groups: config = load_platform_config(representative, args.platforms_dir) tc = target_cores_cache.get(representative) if args.target else None + if tc is not None: + # A core a roms folder offers on other boards only is not placed + # there, as generate_pack builds the board's pack. + config = {**config, TARGET_KEY: canonical_target_name( + [representative], args.target, args.platforms_dir)} result = verify_platform( config, db, diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index de5cc71d9..ae45ab1af 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -145,6 +145,52 @@ class ACoreOnlyTheDownloaderBrings(unittest.TestCase): self.assertEqual([f["dest"] for f in files], ["kick31.rom", "roms/a4091.rom"]) + +class CoreOfferedOnSomeBoards(unittest.TestCase): + """ROCKNIX writes uae4arm for roms/amiga under the devices it builds it + for (emulators/package.mk:188), and s922x runs uae4arm for amigacd32: + the core list alone would keep its roms/amiga files there.""" + + MANIFEST = { + "core_names": {"uae4arm": [], "puae": []}, + "offered_boards": { + "uae4arm": [{"root": "roms", "folder": "amiga", "boards": ["h700", "rk3326"]}], + }, + "files": [ + {"dest": "amiga/kick.rom", "cores": ["uae4arm"], "root": "roms"}, + {"dest": "amigacd32/kick.rom", "cores": ["uae4arm"], "root": "roms"}, + {"dest": "amiga/shared.rom", "cores": ["uae4arm", "puae"], "root": "roms"}, + ], + "omitted_files": [], + } + + def dests(self, target): + files, _ = install.narrow_to_cores(self.MANIFEST, ["uae4arm"], target=target) + return [f["dest"] for f in files] + + def test_a_board_it_is_offered_on_keeps_the_folder(self): + self.assertEqual(self.dests("rk3326"), + ["amiga/kick.rom", "amigacd32/kick.rom", "amiga/shared.rom"]) + + def test_another_board_keeps_only_the_folders_offering_it(self): + self.assertEqual(self.dests("s922x"), ["amigacd32/kick.rom"]) + + def test_a_core_offered_everywhere_keeps_a_shared_file(self): + files, _ = install.narrow_to_cores(self.MANIFEST, ["uae4arm", "puae"], target="s922x") + self.assertEqual([f["dest"] for f in files], ["amigacd32/kick.rom", "amiga/shared.rom"]) + + def test_installed_cores_are_no_board(self): + files, _ = install.narrow_to_cores(self.MANIFEST, ["uae4arm"]) + self.assertEqual(len(files), 3) + + def test_the_manifest_check_refuses_a_malformed_map(self): + base = {"manifest_version": 2, "platform": "rocknix", "files": [], "omitted_files": []} + install._validate_manifest( + {**base, "offered_boards": self.MANIFEST["offered_boards"]}, "rocknix") + with self.assertRaisesRegex(ValueError, "offered_boards"): + install._validate_manifest( + {**base, "offered_boards": {"uae4arm": [{"folder": "amiga"}]}}, "rocknix") + class ACoreTheListDoesNotKnow(unittest.TestCase): """A core newer than the list (parallel_dc, issue 87) loses nothing its .info declares.""" diff --git a/tests/test_launcher_placement.py b/tests/test_launcher_placement.py index 70303f05d..97f93ad2e 100644 --- a/tests/test_launcher_placement.py +++ b/tests/test_launcher_placement.py @@ -13,6 +13,7 @@ import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache import hashlib import io +import json import shutil import sys import tempfile @@ -1208,6 +1209,59 @@ class EveryCoreAFolderOffers(unittest.TestCase): self.assertIn("arcade", folders, platform) +class CoreOfferedOnSomeBoards(unittest.TestCase): + """ROCKNIX writes some add_emu_core calls under a `case "${DEVICE}"` + branch (emulators/package.mk:188 offers uae4arm for amiga on the devices + it builds it for). A board's pack places such a core in that folder only + when the branch names the board, and a manifest for every board says + where it does.""" + + ROW = { + "folder": "amiga", "core": "puae", "emulator": "retroarch", + "offers": [ + {"emulator": "retroarch", "core": "puae"}, + {"emulator": "retroarch", "core": "uae4arm", "when": "DEVICE H700|RK3326"}, + {"emulator": "retroarch", "core": "puae2021", + "when": 'if [ "${PREFER_GLES}" = "yes" ]; then'}, + ], + } + + def config(self, **extra) -> dict: + base = load_platform_config("rocknix", str(REPO_ROOT / "platforms")) + return {**base, "_rom_folders": [self.ROW], **extra} + + def test_a_branch_names_the_boards(self) -> None: + from common import when_admits + + self.assertTrue(when_admits("DEVICE H700|RK3326", "rk3326")) + self.assertFalse(when_admits("DEVICE H700|RK3326", "s922x")) + self.assertTrue(when_admits("DEVICE AMD64; DEVICE RK3588*", "rk3588")) + self.assertTrue(when_admits('if [ "${PREFER_GLES}" = "yes" ]; then', "h700")) + + def test_an_offer_carries_the_boards_its_branch_names(self) -> None: + from common import offer_boards + + boards = offer_boards(self.config()) + self.assertEqual(boards[("uae4arm", "amiga")], frozenset({"h700", "rk3326"})) + self.assertIsNone(boards[("puae", "amiga")]) + self.assertIsNone(boards[("puae2021", "amiga")]) + + def test_a_board_pack_places_the_core_where_the_board_offers_it(self) -> None: + from common import CONTENT_FOLDER_PREFIX, TARGET_KEY, content_folder_tags + + def placed(config: dict) -> dict: + rows = [json.loads(t[len(CONTENT_FOLDER_PREFIX):]) for t in content_folder_tags(config) + if t.startswith(CONTENT_FOLDER_PREFIX)] + return {core: on for core, _folder, _where, on in rows} + + every = placed(self.config()) + self.assertEqual(every["uae4arm"], ["h700", "rk3326"]) + self.assertIsNone(every["puae"]) + self.assertIn("uae4arm", placed(self.config(**{TARGET_KEY: "rk3326"}))) + self.assertNotIn("uae4arm", placed(self.config(**{TARGET_KEY: "s922x"}))) + self.assertIn("puae", placed(self.config(**{TARGET_KEY: "s922x"}))) + + def platform_manifest(platform: str) -> dict: from common import load_database