From d0802c5360e203c3be65f3e11e9900b751cad8c8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:04:31 +0200 Subject: [PATCH 1/3] feat: place core files per handed system dir A platform can hand each libretro core a system directory of its own (core_system_dirs). platform_layouts then gives one view per directory, so the gap report, the builder, the manifest, slots and cross_reference place a core's files below each folder it is handed, and a core handed none gets no file. Resolution strips the folder again and searches the collection by the core's own path. --- schemas/platform.schema.json | 50 ++++++++++++++ scripts/common.py | 74 ++++++++++++++++++++- scripts/cross_reference.py | 5 +- scripts/packextras.py | 123 +++++++++++++++++++++++------------ scripts/slots.py | 11 +++- scripts/verify.py | 10 ++- 6 files changed, 222 insertions(+), 51 deletions(-) diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index f45be8a6..b7658db6 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -53,6 +53,56 @@ "uniqueItems": true, "description": "Emulators of standalone_cores the platform also launches as a libretro core: both layouts get their files" }, + "core_system_dirs": { + "type": "object", + "description": "The libretro system directory the platform hands each core, keyed by that directory relative to the pack root. A platform that declares it gives a core no other directory: the core's files are placed below each directory listed for it, and a core listed nowhere gets none (MinUI hands each launch tag its own Bios//)", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "cores", + "source_ref" + ], + "properties": { + "cores": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + }, + "description": "Libretro cores the platform starts with this directory, by the name of their .so" + }, + "devices": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + }, + "description": "Devices whose build ships a launcher for this directory" + }, + "source_ref": { + "type": "string", + "description": "file:line of the launcher that names the core, at the revision `source` pins" + } + } + } + }, + "core_repositories": { + "type": "object", + "description": "Repositories each core of core_system_dirs is built from, read from the platform's build recipes", + "additionalProperties": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + } + }, + "system_directory_ref": { + "type": "string", + "description": "file:line, at the revision `source` pins, of the code that sets the system directory core_system_dirs lists" + }, "homepage": { "type": "string", "format": "uri", diff --git a/scripts/common.py b/scripts/common.py index e67f3fb0..96003159 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -1221,6 +1221,12 @@ def resolve_local_file( """ sha1 = file_entry.get("sha1") name = file_entry.get("name", "") + # A platform that hands each core a system directory of its own (MinUI's + # Bios//) writes that folder in front of the core's path. The tail + # the collection indexes is the core's path alone. + system_dir = file_entry.get("system_dir") + if system_dir and dest_hint.startswith(f"{system_dir}/"): + dest_hint = dest_hint[len(system_dir) + 1:] # A name written with its directory is the destination when no path is # given: gam4980/E.BIN resolved by its basename took another model's OS # in verify --emulator and the emulator pack, while the platform pack, @@ -2106,6 +2112,11 @@ def group_identical_platforms( # One archive cannot serve a filesystem that folds case and one # that does not: Lakka keeps bios.col beside BIOS.col. bool(config.get("case_insensitive_fs")), + # Where each core's files go: the same cores handed other + # folders make another pack. + sorted( + tag for tag in standalone_set(config) if tag.startswith(CORE_DIR_PREFIX) + ), ] fp = hashlib.sha1(f"{fp}|{emulators}".encode()).hexdigest() if target_cores_cache: @@ -2252,6 +2263,55 @@ DUAL_MODE_PREFIX = "dual:" # Marks, in the same set, a whole directory the platform's launcher hands a # standalone emulator (launchers.handed_directories), as JSON. HANDED_PREFIX = "handed:" +# Marks, in the same set, a libretro core and the system directory the +# platform hands it (``core_system_dirs``), as JSON. +CORE_DIR_PREFIX = "coredir:" + + +def core_system_dirs( + emu_name: str, profile: dict, standalone_cores: Iterable[str] +) -> list[str] | None: + """The system directories a platform hands this libretro core, or None. + + None when the platform declares no per-core directory: the core reads + the one system directory every core shares. A platform that declares + them (MinUI hands each launch tag its own Bios//) gives a core it + lists no directory at all, so the list is then empty. + """ + pairs = [ + json.loads(tag[len(CORE_DIR_PREFIX):]) + for tag in standalone_cores if tag.startswith(CORE_DIR_PREFIX) + ] + if not pairs: + return None + names = {emu_name, *(str(c) for c in profile.get("cores") or [])} + return sorted({directory for core, directory in pairs if core in names}) + + +def system_dir_view(profile: dict, directory: str) -> dict: + """The profile as a core reads it from one handed system directory. + + Each file keeps its own path, relative to the system directory, and + carries the directory: destinations put it in front, resolution takes + it off again (resolve_local_file), so the collection is still searched + by the core's own path. A core's archive subfolder moves with it. + """ + view = { + **profile, + "files": [ + {**f, "system_dir": directory} if isinstance(f, dict) else f + for f in profile.get("files") or [] + ], + } + if profile.get("archive_prefix"): + view["archive_prefix"] = f"{directory}/{profile['archive_prefix']}" + return view + + +def system_dir_path(entry: dict, path: str) -> str: + """``path`` below the system directory the entry carries, if any.""" + directory = entry.get("system_dir") + return f"{directory}/{path}" if directory else path def standalone_set(config: dict) -> set[str]: @@ -2263,7 +2323,14 @@ def standalone_set(config: dict) -> set[str]: """ names = {str(c) for c in config.get("standalone_cores") or []} names |= {DUAL_MODE_PREFIX + str(c) for c in config.get("dual_mode_cores") or []} - if names: + # The system directory each core is handed travels the same way, so + # platform_layouts places a core's files below it for every reader. + names |= { + CORE_DIR_PREFIX + json.dumps([str(core), directory]) + for directory, entry in (config.get("core_system_dirs") or {}).items() + for core in entry.get("cores") or [] + } + if names - {n for n in names if n.startswith(CORE_DIR_PREFIX)}: from launchers import handed_directories # noqa: PLC0415 names |= { @@ -2328,7 +2395,10 @@ def platform_layouts( (what the build reads from the system directory, where it reads it). """ if not runs_standalone(emu_name, profile, standalone_cores): - return [(profile, False)] + directories = core_system_dirs(emu_name, profile, standalone_cores) + if directories is None: + return [(profile, False)] + return [(system_dir_view(profile, d), False) for d in directories] standalone = profile root = standalone_root_for(emu_name, profile, standalone_cores) if root: diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index a235f8d7..090204c3 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -41,6 +41,7 @@ from common import ( require_yaml, resolve_local_file, platform_layouts, + system_dir_path, standalone_set, select_emulator_profiles, ) @@ -652,7 +653,7 @@ def slot_key(f: dict, standalone: bool = False) -> tuple: return ( fname, f.get("archive"), - where or fname, + system_dir_path(f, where or fname), f.get("system"), f.get("variant_group"), f.get("mode", "both"), @@ -727,7 +728,7 @@ def _cross_reference_profile( if is_standalone: effective_path = f.get("standalone_path") or f.get("path") or fname else: - effective_path = f.get("path") or fname + effective_path = system_dir_path(f, f.get("path") or fname) slot = slot_key(f, is_standalone) seen_key = slot if len(layouts) > 1: diff --git a/scripts/packextras.py b/scripts/packextras.py index 34ad6feb..847475ab 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -15,7 +15,7 @@ from common import load_emulator_profiles from common import resolution_is_hash_exact from common import resolve_local_file from common import resolve_platform_cores -from common import platform_layouts +from common import core_system_dirs, platform_layouts, system_dir_path from common import standalone_set as platform_standalone_set from common import sanitize_pack_path from common import size_fits @@ -331,7 +331,9 @@ def _extra_dest(u: dict) -> str: the file's name after it ("cafeLibs/" + "snd_user.rpl"). """ archive = u.get("archive") - raw = archive if archive else (u.get("path") or u["name"]) + # An archive goes at the root of the core's system directory, which a + # platform handing each core its own folder writes in the path. + raw = (u.get("path") or archive) if archive else (u.get("path") or u["name"]) return f"{raw}{u['name']}" if raw.endswith("/") else raw @@ -479,6 +481,8 @@ def _agnostic_scan_extras( seed = f.get("path") or "" if standalone: seed = f.get("standalone_path") or seed + else: + seed = system_dir_path(f, seed or fname) folder = seed.rsplit("/", 1)[0] if "/" in seed else "" dest = f"{folder}/{scan_name}" if folder else scan_name full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest @@ -727,6 +731,7 @@ def _archive_prefix_extras( extras_prefix: str, by_name: dict, taken: dict[str, dict] | None = None, + standalone_set: set[str] | None = None, ) -> list[dict]: """A second copy of an archive under the subdirectory a core reads. @@ -744,45 +749,75 @@ def _archive_prefix_extras( continue if emu_name not in relevant: continue - prefix = profile.get("archive_prefix", "") - if not prefix: + # A platform that hands each core its own system directory moves the + # subfolder with it, once per directory. + views = [profile] + if core_system_dirs(emu_name, profile, standalone_set or set()) is not None: + views = [ + view for view, standalone + in platform_layouts(emu_name, profile, standalone_set or set()) + if not standalone + ] + for view in views: + extras.extend(_archive_prefix_copies( + emu_name, view, covered_names, seen_dests, extras_prefix, + by_name, taken, + )) + return extras + + +def _archive_prefix_copies( + emu_name: str, + profile: dict, + covered_names: set, + seen_dests: set, + extras_prefix: str, + by_name: dict, + taken: dict[str, dict] | None, +) -> list[dict]: + """The prefixed copies of one layout's archives.""" + extras: list[dict] = [] + prefix = profile.get("archive_prefix", "") + if not prefix: + return extras + profile_archives: dict[str, dict] = {} + for f in profile.get("files", []): + archive = f.get("archive", "") + if archive: + profile_archives.setdefault(archive, f) + for archive_name, archive_entry in sorted(profile_archives.items()): + if archive_name not in covered_names: continue - profile_archives: dict[str, dict] = {} - for f in profile.get("files", []): - archive = f.get("archive", "") - if archive: - profile_archives.setdefault(archive, f) - for archive_name, archive_entry in sorted(profile_archives.items()): - if archive_name not in covered_names: - continue - dest = f"{prefix}/{archive_name}" - full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest - if full_dest in seen_dests and (taken is None or full_dest not in taken): - continue - if not by_name.get(archive_name): - continue - extra = { - "name": archive_name, - "destination": dest, - "required": True, - "hle_fallback": False, - "source_emulator": profile.get("emulator", emu_name), - "source_profile": emu_name, - # Keyed as the gap report keys an archive: name and path alike. - "source_name": archive_name, - "source_path": archive_name, - "category": archive_entry.get("category", "bios"), - "source_system": archive_entry.get("system"), - "source_systems": entry_system_ids(archive_entry, profile), - "region": archive_entry.get("region"), - "variant_group": archive_entry.get("variant_group"), - } - members = archive_members(archive_name, profile.get("files", [])) - if members: - extra["members"] = members - extra["claim_order"] = (3, 0, emu_name) - if _take(extra, full_dest, seen_dests, taken): - extras.append(extra) + dest = f"{prefix}/{archive_name}" + full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest + if full_dest in seen_dests and (taken is None or full_dest not in taken): + continue + if not by_name.get(archive_name): + continue + extra = { + "name": archive_name, + "destination": dest, + "required": True, + "hle_fallback": False, + "source_emulator": profile.get("emulator", emu_name), + "source_profile": emu_name, + # Keyed as the gap report keys an archive: name and path alike. + "source_name": archive_name, + "source_path": system_dir_path(archive_entry, archive_name), + "category": archive_entry.get("category", "bios"), + "source_system": archive_entry.get("system"), + "source_systems": entry_system_ids(archive_entry, profile), + "region": archive_entry.get("region"), + "variant_group": archive_entry.get("variant_group"), + } + members = archive_members(archive_name, profile.get("files", [])) + if members: + extra["members"] = members + if archive_entry.get("system_dir"): + extra["system_dir"] = archive_entry["system_dir"] + extra["claim_order"] = (3, 0, emu_name) + if _take(extra, full_dest, seen_dests, taken): + extras.append(extra) return extras @@ -1046,7 +1081,7 @@ def _collect_emulator_extras( if is_standalone: raw = f.get("standalone_path") or f.get("path") or fname else: - raw = f.get("path") or fname + raw = system_dir_path(f, f.get("path") or fname) dest = f"{raw}{fname}" if raw.endswith("/") else raw # A bare name is a destination too: the core reads it at the root # of the directory core files go to, which the platform may not @@ -1087,6 +1122,8 @@ def _collect_emulator_extras( extra[identity] = f[identity] if f.get("contents"): extra["contents"] = f["contents"] + if f.get("system_dir"): + extra["system_dir"] = f["system_dir"] extra["claim_order"] = (2, 0, emu_name) if _take(extra, full_dest, seen_dests, taken): extras.append(extra) @@ -1094,7 +1131,7 @@ def _collect_emulator_extras( extras.extend( _archive_prefix_extras( profiles, relevant, covered_names, seen_dests, extras_prefix, - by_name, taken, + by_name, taken, standalone_set, ) ) @@ -1211,6 +1248,8 @@ def _report_extra(u: dict, name: str, dest: str) -> dict: } if u.get("members"): extra["members"] = u["members"] + if u.get("system_dir"): + extra["system_dir"] = u["system_dir"] # The archive layout the core opens ranks same-named copies by what # they hold. Without it the first copy indexed won. if u.get("contents"): diff --git a/scripts/slots.py b/scripts/slots.py index f43aa125..a26a0d14 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -35,6 +35,7 @@ from common import ( resolve_platform_cores, platform_layouts, standalone_set as platform_standalone_set, + system_dir_path, variant_base_name, ) from packresolve import preferred_entries @@ -227,7 +228,7 @@ def profile_claims( dest = ( (entry.get("standalone_path") or entry.get("path")) if is_standalone - else entry.get("path") + else system_dir_path(entry, entry.get("path") or entry.get("name") or "") ) or entry.get("name") or "" if not dest: continue @@ -299,8 +300,12 @@ def _archive_claims( members = entry.get("members") if not members: continue - system = next(f.get("system") for f in files if f.get("archive") == archive) - for dest in (archive, f"{prefix}/{archive}") if prefix else (archive,): + member = next(f for f in files if f.get("archive") == archive) + system = member.get("system") + if member.get("system_dir"): + entry = {**entry, "system_dir": member["system_dir"]} + root = system_dir_path(entry, archive) + for dest in (root, f"{prefix}/{archive}") if prefix else (root,): if placement is None: full = f"{base_dest}/{dest}" if base_dest else dest else: diff --git a/scripts/verify.py b/scripts/verify.py index 887b9666..8481cb4c 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -76,6 +76,7 @@ from common import ( entry_system_ids, sanitize_pack_path, select_emulator_profiles, + system_dir_path, ) yaml = require_yaml() @@ -576,7 +577,7 @@ def find_undeclared_files( if is_standalone: dest = f.get("standalone_path") or f.get("path") or fname else: - dest = f.get("path") or fname + dest = system_dir_path(f, f.get("path") or fname) raw_regions = f.get("region") or [] region_key = tuple( str(value) for value in ( @@ -615,6 +616,7 @@ def find_undeclared_files( if archive: archive_key = ( archive, + f.get("system_dir"), f.get("system"), f.get("variant_group"), region_key, @@ -634,7 +636,7 @@ def find_undeclared_files( "variant_group": f.get("variant_group"), "name": archive, "archive": archive, - "path": archive, + "path": system_dir_path(f, archive), "required": False, "hle_fallback": False, "category": f.get("category", "bios"), @@ -647,6 +649,8 @@ def find_undeclared_files( "archive_required_count": 0, "members": archive_members(archive, profile.get("files", [])), } + if f.get("system_dir"): + archive_entries[archive_key]["system_dir"] = f["system_dir"] entry = archive_entries[archive_key] entry["archive_file_count"] += 1 if f.get("required", False): @@ -738,6 +742,8 @@ def find_undeclared_files( } if f.get("contents"): entry["contents"] = f["contents"] + if f.get("system_dir"): + entry["system_dir"] = f["system_dir"] members = with_archive_members(f, profile.get("files", [])).get("members") if members: entry["members"] = members From 43bdf83973f19552a4eb2d765030d81133b20d80 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:04:36 +0200 Subject: [PATCH 2/3] feat: add minui, nextui and myminui platforms minarch hands every core SDCARD_PATH/Bios/ as its system directory and opens no file in it. The scrapers read each pak's launch.sh (core, tag, devices), each device's core makefile (the project a library is built from: MyMinUI's dosbox_libretro.so is dosbox-pure) and the minarch statement, at a pinned commit. The installer recognises the card from .system/version.txt and refuses a folder inside it. --- .github/ISSUE_TEMPLATE/missing-bios.yml | 3 + install.ps1 | 2 +- install.py | 68 +++- install.sh | 2 +- platforms/_registry.yml | 83 +++++ platforms/minui.yml | 354 ++++++++++++++++++++ platforms/myminui.yml | 426 ++++++++++++++++++++++++ platforms/nextui.yml | 399 ++++++++++++++++++++++ scripts/generate_pack.py | 1 + scripts/generate_readme.py | 22 +- scripts/generate_site.py | 34 +- scripts/packreadme.py | 67 +++- scripts/scraper/minui_scraper.py | 348 +++++++++++++++++++ scripts/scraper/myminui_scraper.py | 30 ++ scripts/scraper/nextui_scraper.py | 30 ++ tests/test_exporters.py | 25 +- tests/test_minui_platform.py | 415 +++++++++++++++++++++++ 17 files changed, 2292 insertions(+), 17 deletions(-) create mode 100644 platforms/minui.yml create mode 100644 platforms/myminui.yml create mode 100644 platforms/nextui.yml create mode 100644 scripts/scraper/minui_scraper.py create mode 100644 scripts/scraper/myminui_scraper.py create mode 100644 scripts/scraper/nextui_scraper.py create mode 100644 tests/test_minui_platform.py diff --git a/.github/ISSUE_TEMPLATE/missing-bios.yml b/.github/ISSUE_TEMPLATE/missing-bios.yml index 650e7104..20306a6d 100644 --- a/.github/ISSUE_TEMPLATE/missing-bios.yml +++ b/.github/ISSUE_TEMPLATE/missing-bios.yml @@ -44,6 +44,9 @@ body: - ROCKNIX - BizHawk - MiSTer FPGA + - MinUI + - NextUI + - MyMinUI - Other validations: required: true diff --git a/install.ps1 b/install.ps1 index 16e613e2..843870cb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724" + $defaultInstallSha256 = "526b405a2bef8e7ae181fc5554225a09e74afa25396db9084a15fb3a51e4b7eb" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 9cd3e52e..f204949e 100644 --- a/install.py +++ b/install.py @@ -185,7 +185,7 @@ OTHER_SYSTEMS = "other" AVAILABLE_PLATFORMS = ( "retroarch", "batocera", "recalbox", "retrobat", "emudeck", "lakka", "retrodeck", "rocknix", "romm", "bizhawk", "misterfpga", - "retropie", + "retropie", "minui", "nextui", "myminui", ) # The names the frontends give themselves, as each manifest's display_name @@ -197,8 +197,23 @@ PLATFORM_NAMES = { "retrobat": "RetroBat", "emudeck": "EmuDeck", "lakka": "Lakka", "retrodeck": "RetroDECK", "rocknix": "ROCKNIX", "romm": "RomM", "bizhawk": "BizHawk", "misterfpga": "MiSTer FPGA", "retropie": "RetroPie", + "minui": "MinUI", "nextui": "NextUI", "myminui": "MyMinUI", } +# Frontends whose BIOS live on their SD card, installed from a computer the +# card is mounted on. Each release unpacks as .system/ at the card root and +# writes its release name as the first line of .system/version.txt (MinUI +# makefile:22-24 and 152-157, NextUI makefile:33-35 and 249, MyMinUI +# makefile:16-19 and 124). The registry publishes the same rules as each +# manifest's `detect` (card_marker), and a test holds the two equal. +CARD_MARKER = ".system/version.txt" +_CARD_RULES: tuple[tuple[str, str, str], ...] = ( + ("minui", CARD_MARKER, "MinUI-"), + ("nextui", CARD_MARKER, "NextUI-"), + ("myminui", CARD_MARKER, "MyMinUI-"), +) +CARD_PLATFORMS = frozenset(name for name, _file, _prefix in _CARD_RULES) + SITE_URL = "https://abdess.github.io/retrobios/" INSTALLER_PAGE = f"{SITE_URL}wiki/installer/" TROUBLESHOOTING_PAGE = f"{SITE_URL}wiki/troubleshooting/" @@ -4830,6 +4845,39 @@ def _no_core_found(plat: str, bios_path: Path, searched: list[Path], dest_given: sys.exit(1) +def card_platform(root: Path) -> str | None: + """The frontend whose card ``root`` is, from the release name it wrote.""" + for name, marker, prefix in _CARD_RULES: + try: + with open(root / marker, encoding="utf-8", errors="replace") as handle: + first = handle.readline().strip() + except OSError: + continue + if first.startswith(prefix): + return name + return None + + +def _check_card_root(plat: str, dest: Path) -> None: + """Refuse a folder inside the card where its root is meant. + + The file list writes Bios// below --dest: given the card's Bios + folder, every file would land in Bios/Bios//, which no core reads. + """ + found = card_platform(dest) + if found is None: + for parent in list(dest.absolute().parents)[:2]: + holder = card_platform(parent) + if holder: + print(f"Error: {dest} is inside a {display_name(holder)} card. " + f"Name the card's root: --dest {parent}", file=sys.stderr) + sys.exit(1) + return + if found != plat: + print(f" Note: this card holds {display_name(found)} ({CARD_MARKER}), and the " + f"{display_name(plat)} file list was asked for.") + + def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: """The file list for a --dest given without --platform. @@ -4839,6 +4887,11 @@ def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> str: terminal the user picks, Enter taking the one found. Otherwise the one found is used, RetroArch when none is, and several found are refused. """ + card = card_platform(dest) + if card: + print(f" File list: {display_name(card)} (the card's {CARD_MARKER}). " + f"Same choice without detection: --platform {card}") + return card print("Detecting platform...") found = list(dict.fromkeys(name for name, _path in detect_platforms(os_type))) default = found[0] if len(found) == 1 else "" if found else "retroarch" @@ -4896,6 +4949,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: if args.platform: args.platform = normalize_platform(args.platform) if args.platform and args.dest: + if args.platform in CARD_PLATFORMS: + _check_card_root(args.platform, args.dest) platforms = [(args.platform, args.dest)] elif args.platform: print("Detecting platform...") @@ -4908,6 +4963,17 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # needed, and asking BizHawk's for one refused a question asked # before downloading BizHawk. platforms = [(args.platform, _default_dest(os_type, args.platform) or Path.cwd())] + elif args.platform in CARD_PLATFORMS: + # Its files go on its SD card, which is never this machine's + # own folder: the card's mount point is the user's to name. + print( + f" {display_name(args.platform)} keeps its BIOS files on its SD card. " + f"Mount the card and name its root with --dest, for example: " + f"--platform {args.platform} --dest /media/$USER/ " + f"(Windows: --dest E:\\)", + file=sys.stderr, + ) + sys.exit(1) elif args.platform in PORTABLE_ONLY: # A portable emulator reads the folder beside its executable: # any default would be a folder it never opens. diff --git a/install.sh b/install.sh index 6f563d44..ad63b0a6 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="fe8de4eda0919c268d4d2c0d571c7d0dc1cf675d7604263766f5f99a35aa3724" +DEFAULT_INSTALL_SHA256="526b405a2bef8e7ae181fc5554225a09e74afa25396db9084a15fb3a51e4b7eb" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/platforms/_registry.yml b/platforms/_registry.yml index 790d0f31..fe429fb7 100644 --- a/platforms/_registry.yml +++ b/platforms/_registry.yml @@ -650,3 +650,86 @@ platforms: method: path_exists path: /media/fat/MiSTer bios_path: /media/fat/games + minui: + config: minui.yml + status: active + # The card root: minarch hands each core SDCARD_PATH/Bios/ + # (workspace/all/minarch/minarch.c:2943). + extract_to: / + logo: https://avatars.githubusercontent.com/u/225589 + scraper: minui + # minarch hands each core a folder and names no file (workspace/all/minarch/minarch.c:2943): + # there is no BIOS list of its own to export corrected. + native_list: none + source_url: https://github.com/shauninman/MinUI + source_format: pak_launch_scripts + hash_type: sha1 + # The primary card is formatted FAT32 (skeleton/BASE/README.txt:15). + case_insensitive_fs: true + schedule: null + target_scraper: null + target_source: null + install: + detect: + # The release zip holds the build's SYSTEM tree as .system/ at the card + # root with version.txt, whose first line is the release name + # (makefile:22-24, 152-157). The installer runs on the computer the + # card is mounted on and reads it from --dest. + - os: any + method: card_marker + file: .system/version.txt + first_line_prefix: MinUI- + nextui: + config: nextui.yml + status: active + # minarch hands each core SDCARD_PATH/Bios/ + # (workspace/all/minarch/ma_core.c:76). + extract_to: / + logo: https://avatars.githubusercontent.com/u/202386615 + scraper: nextui + # minarch hands each core a folder and names no file (workspace/all/minarch/ma_core.c:76): + # there is no BIOS list of its own to export corrected. + native_list: none + source_url: https://github.com/LoveRetro/NextUI + source_format: pak_launch_scripts + hash_type: sha1 + # The primary card is formatted FAT32 (skeleton/BASE/README.txt:17). + case_insensitive_fs: true + schedule: null + target_scraper: null + target_source: null + install: + detect: + # makefile:33-35 and 249: version.txt in .system/, first line the + # release name. + - os: any + method: card_marker + file: .system/version.txt + first_line_prefix: NextUI- + myminui: + config: myminui.yml + status: active + # minarch hands each core SDCARD_PATH/Bios/ + # (workspace/all/minarch/minarch.c:3532). + extract_to: / + logo: https://avatars.githubusercontent.com/u/10894566 + scraper: myminui + # minarch hands each core a folder and names no file (workspace/all/minarch/minarch.c:3532): + # there is no BIOS list of its own to export corrected. + native_list: none + source_url: https://github.com/Turro75/MyMinUI + source_format: pak_launch_scripts + hash_type: sha1 + # The card is formatted FAT32 (MANUAL.md:13). + case_insensitive_fs: true + schedule: null + target_scraper: null + target_source: null + install: + detect: + # makefile:16-19 and 124: version.txt in .system/, first line the + # release name. + - os: any + method: card_marker + file: .system/version.txt + first_line_prefix: MyMinUI- diff --git a/platforms/minui.yml b/platforms/minui.yml new file mode 100644 index 00000000..cc248994 --- /dev/null +++ b/platforms/minui.yml @@ -0,0 +1,354 @@ +platform: MinUI +version: '2025-11-27' +homepage: "https://github.com/shauninman/MinUI" +source: "https://github.com/shauninman/MinUI/tree/dbf89435a5179788418ff3573042cf9c7e75d9e0" +base_destination: '' +hash_type: sha1 +verification_mode: existence +system_directory_ref: "workspace/all/minarch/minarch.c:2943" +cores: +- fake08 +- fceumm +- gambatte +- gpsp +- mednafen_pce_fast +- mednafen_supafaust +- mednafen_vb +- mgba +- pcsx_rearmed +- picodrive +- pokemini +- race +- snes9x2005_plus +core_system_dirs: + Bios/FC: + cores: + - fceumm + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/SYSTEM/gkdpixel/paks/Emus/FC.pak/launch.sh:3" + Bios/GB: + cores: + - gambatte + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/SYSTEM/gkdpixel/paks/Emus/GB.pak/launch.sh:3" + Bios/GBA: + cores: + - gpsp + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/SYSTEM/gkdpixel/paks/Emus/GBA.pak/launch.sh:3" + Bios/GBC: + cores: + - gambatte + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/SYSTEM/gkdpixel/paks/Emus/GBC.pak/launch.sh:3" + Bios/GG: + cores: + - picodrive + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/gkdpixel/GG.pak/launch.sh:3" + Bios/MD: + cores: + - picodrive + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/SYSTEM/gkdpixel/paks/Emus/MD.pak/launch.sh:3" + Bios/MGBA: + cores: + - mgba + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/gkdpixel/MGBA.pak/launch.sh:3" + Bios/NGP: + cores: + - race + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/gkdpixel/NGP.pak/launch.sh:3" + Bios/NGPC: + cores: + - race + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/gkdpixel/NGPC.pak/launch.sh:3" + Bios/P8: + cores: + - fake08 + devices: + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/m17/P8.pak/launch.sh:3" + Bios/PCE: + cores: + - mednafen_pce_fast + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/gkdpixel/PCE.pak/launch.sh:3" + Bios/PKM: + cores: + - pokemini + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/gkdpixel/PKM.pak/launch.sh:3" + Bios/PS: + cores: + - pcsx_rearmed + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/SYSTEM/gkdpixel/paks/Emus/PS.pak/launch.sh:3" + Bios/SFC: + cores: + - snes9x2005_plus + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/SYSTEM/gkdpixel/paks/Emus/SFC.pak/launch.sh:3" + Bios/SGB: + cores: + - mgba + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/gkdpixel/SGB.pak/launch.sh:3" + Bios/SMS: + cores: + - picodrive + devices: + - gkdpixel + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/gkdpixel/SMS.pak/launch.sh:3" + Bios/SUPA: + cores: + - mednafen_supafaust + devices: + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/m17/SUPA.pak/launch.sh:3" + Bios/VB: + cores: + - mednafen_vb + devices: + - m17 + - magicmini + - miyoomini + - my282 + - my355 + - rg35xx + - rg35xxplus + - rgb30 + - tg5040 + - trimuismart + - zero28 + source_ref: "skeleton/EXTRAS/Emus/m17/VB.pak/launch.sh:3" +core_repositories: + fake08: + - "https://github.com/jtothebell/fake-08" + fceumm: + - "https://github.com/libretro/libretro-fceumm" + gambatte: + - "https://github.com/libretro/gambatte-libretro" + gpsp: + - "https://github.com/libretro/gpsp" + mednafen_pce_fast: + - "https://github.com/libretro/beetle-pce-fast-libretro" + mednafen_supafaust: + - "https://github.com/libretro/supafaust" + mednafen_vb: + - "https://github.com/libretro/beetle-vb-libretro" + mgba: + - "https://github.com/libretro/mgba" + pcsx_rearmed: + - "https://github.com/libretro/pcsx_rearmed" + picodrive: + - "https://github.com/irixxxx/picodrive" + pokemini: + - "https://github.com/libretro/PokeMini" + race: + - "https://github.com/libretro/race" + snes9x2005_plus: + - "https://github.com/libretro/snes9x2005" +systems: {} diff --git a/platforms/myminui.yml b/platforms/myminui.yml new file mode 100644 index 00000000..2f896f18 --- /dev/null +++ b/platforms/myminui.yml @@ -0,0 +1,426 @@ +platform: MyMinUI +version: '2026-08-09' +homepage: "https://github.com/Turro75/MyMinUI" +source: "https://github.com/Turro75/MyMinUI/tree/72ed4f3dd80d96646c11bf9363242565320d9566" +base_destination: '' +hash_type: sha1 +verification_mode: existence +system_directory_ref: "workspace/all/minarch/minarch.c:3532" +cores: +- a5200 +- dosbox_pure +- fake08 +- fbneo +- fceumm +- fuse +- gambatte +- gpsp +- mame2003_plus +- mednafen_ngp +- mednafen_pce_fast +- mednafen_supafaust +- mednafen_vb +- mednafen_wswan +- mgba +- neocd +- pcsx_rearmed +- picodrive +- pokemini +- prboom +- prosystem +- puae2021 +- snes9x2005_plus +- stella2014 +- tyrquake +core_system_dirs: + Bios/A2600: + cores: + - stella2014 + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/A2600.pak/launch.sh:3" + Bios/A5200: + cores: + - a5200 + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/A5200.pak/launch.sh:3" + Bios/A7800: + cores: + - prosystem + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/A7800.pak/launch.sh:3" + Bios/DOOM: + cores: + - prboom + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/DOOM.pak/launch.sh:3" + Bios/DOS: + cores: + - dosbox_pure + devices: + - h700 + - m21 + - miyoomini + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/DOS.pak/launch.sh:3" + Bios/FBN: + cores: + - fbneo + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/FBN.pak/launch.sh:3" + Bios/FC: + cores: + - fceumm + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/FC.pak/launch.sh:3" + Bios/GB: + cores: + - gambatte + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/GB.pak/launch.sh:3" + Bios/GBA: + cores: + - gpsp + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/GBA.pak/launch.sh:3" + Bios/GBC: + cores: + - gambatte + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/GBC.pak/launch.sh:3" + Bios/GG: + cores: + - picodrive + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/GG.pak/launch.sh:3" + Bios/MAME: + cores: + - mame2003_plus + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/MAME.pak/launch.sh:3" + Bios/MD: + cores: + - picodrive + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/MD.pak/launch.sh:3" + Bios/MGBA: + cores: + - mgba + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/MGBA.pak/launch.sh:3" + Bios/NG: + cores: + - fbneo + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/NG.pak/launch.sh:3" + Bios/NGCD: + cores: + - neocd + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/NGCD.pak/launch.sh:3" + Bios/NGP: + cores: + - mednafen_ngp + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/NGP.pak/launch.sh:3" + Bios/NGPC: + cores: + - mednafen_ngp + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/NGPC.pak/launch.sh:3" + Bios/P8: + cores: + - fake08 + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/P8.pak/launch.sh:3" + Bios/PCE: + cores: + - mednafen_pce_fast + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/PCE.pak/launch.sh:3" + Bios/PKM: + cores: + - pokemini + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/PKM.pak/launch.sh:3" + Bios/PS: + cores: + - pcsx_rearmed + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/PS.pak/launch.sh:3" + Bios/PUAE: + cores: + - puae2021 + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/PUAE.pak/launch.sh:3" + Bios/QUAKE: + cores: + - tyrquake + devices: + - h700 + - m21 + - miyoomini + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/QUAKE.pak/launch.sh:3" + Bios/SFC: + cores: + - snes9x2005_plus + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/SFC.pak/launch.sh:3" + Bios/SGB: + cores: + - mgba + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/SGB.pak/launch.sh:3" + Bios/SMS: + cores: + - picodrive + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/SMS.pak/launch.sh:3" + Bios/SUPA: + cores: + - mednafen_supafaust + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/SUPA.pak/launch.sh:3" + Bios/VB: + cores: + - mednafen_vb + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/VB.pak/launch.sh:3" + Bios/WS: + cores: + - mednafen_wswan + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/WS.pak/launch.sh:3" + Bios/ZXS: + cores: + - fuse + devices: + - h700 + - m21 + - miyoomini + - my282 + - r36s + - rg35xx + source_ref: "skeleton/EXTRAS/Emus/h700/ZXS.pak/launch.sh:3" +core_repositories: + a5200: + - "https://github.com/libretro/a5200" + dosbox_pure: + - "https://github.com/libretro/dosbox-pure" + fake08: + - "https://github.com/jtothebell/fake-08" + fbneo: + - "https://github.com/libretro/fbneo" + fceumm: + - "https://github.com/libretro/libretro-fceumm" + fuse: + - "https://github.com/libretro/fuse-libretro" + gambatte: + - "https://github.com/libretro/gambatte-libretro" + gpsp: + - "https://github.com/libretro/gpsp" + mame2003_plus: + - "https://github.com/libretro/mame2003-plus-libretro" + mednafen_ngp: + - "https://github.com/libretro/beetle-ngp-libretro" + mednafen_pce_fast: + - "https://github.com/libretro/beetle-pce-fast-libretro" + mednafen_supafaust: + - "https://github.com/libretro/supafaust" + mednafen_vb: + - "https://github.com/libretro/beetle-vb-libretro" + mednafen_wswan: + - "https://github.com/libretro/beetle-wswan-libretro" + mgba: + - "https://github.com/libretro/mgba" + neocd: + - "https://github.com/libretro/neocd_libretro" + pcsx_rearmed: + - "https://github.com/libretro/pcsx_rearmed" + picodrive: + - "https://github.com/irixxxx/picodrive" + pokemini: + - "https://github.com/libretro/PokeMini" + prboom: + - "https://github.com/libretro/libretro-prboom" + prosystem: + - "https://github.com/libretro/prosystem-libretro" + puae2021: + - "https://github.com/libretro/libretro-uae" + snes9x2005_plus: + - "https://github.com/libretro/snes9x2005" + stella2014: + - "https://github.com/libretro/stella2014-libretro" + tyrquake: + - "https://github.com/libretro/tyrquake" +systems: {} diff --git a/platforms/nextui.yml b/platforms/nextui.yml new file mode 100644 index 00000000..35c3ce22 --- /dev/null +++ b/platforms/nextui.yml @@ -0,0 +1,399 @@ +platform: NextUI +version: '2026-10-20' +homepage: "https://nextui.loveretro.games" +source: "https://github.com/LoveRetro/NextUI/tree/5291344b3aceae5fa197437a70824afb41e2d338" +base_destination: '' +hash_type: sha1 +verification_mode: existence +system_directory_ref: "workspace/all/minarch/ma_core.c:76" +cores: +- a5200 +- bluemsx +- cap32 +- fake08 +- fbneo +- fceumm +- gambatte +- gearcoleco +- gpsp +- handy +- mednafen_pce_fast +- mednafen_supafaust +- mednafen_vb +- mgba +- pcsx_rearmed +- picodrive +- pokemini +- prboom +- prosystem +- puae2021 +- race +- snes9x +- stella2014 +- vice_x128 +- vice_x64 +- vice_xpet +- vice_xplus4 +- vice_xvic +core_system_dirs: + Bios/32X: + cores: + - picodrive + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/32X.pak/launch.sh:3" + Bios/A2600: + cores: + - stella2014 + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/A2600.pak/launch.sh:3" + Bios/A5200: + cores: + - a5200 + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/A5200.pak/launch.sh:3" + Bios/A7800: + cores: + - prosystem + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/A7800.pak/launch.sh:3" + Bios/C128: + cores: + - vice_x128 + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/C128.pak/launch.sh:3" + Bios/C64: + cores: + - vice_x64 + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/C64.pak/launch.sh:3" + Bios/COLECO: + cores: + - gearcoleco + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/COLECO.pak/launch.sh:3" + Bios/CPC: + cores: + - cap32 + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/CPC.pak/launch.sh:3" + Bios/FBN: + cores: + - fbneo + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/FBN.pak/launch.sh:3" + Bios/FC: + cores: + - fceumm + devices: + - desktop + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/SYSTEM/desktop/paks/Emus/FC.pak/launch.sh:3" + Bios/FDS: + cores: + - fceumm + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/FDS.pak/launch.sh:3" + Bios/GB: + cores: + - gambatte + devices: + - desktop + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/SYSTEM/desktop/paks/Emus/GB.pak/launch.sh:3" + Bios/GBA: + cores: + - gpsp + devices: + - desktop + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/SYSTEM/desktop/paks/Emus/GBA.pak/launch.sh:3" + Bios/GBC: + cores: + - gambatte + devices: + - desktop + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/SYSTEM/desktop/paks/Emus/GBC.pak/launch.sh:3" + Bios/GG: + cores: + - picodrive + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/GG.pak/launch.sh:3" + Bios/LYNX: + cores: + - handy + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/LYNX.pak/launch.sh:3" + Bios/MD: + cores: + - picodrive + devices: + - desktop + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/SYSTEM/desktop/paks/Emus/MD.pak/launch.sh:3" + Bios/MGBA: + cores: + - mgba + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/MGBA.pak/launch.sh:3" + Bios/MSX: + cores: + - bluemsx + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/MSX.pak/launch.sh:3" + Bios/NGP: + cores: + - race + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/NGP.pak/launch.sh:3" + Bios/NGPC: + cores: + - race + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/NGPC.pak/launch.sh:3" + Bios/P8: + cores: + - fake08 + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/P8.pak/launch.sh:3" + Bios/PCE: + cores: + - mednafen_pce_fast + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/PCE.pak/launch.sh:3" + Bios/PET: + cores: + - vice_xpet + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/PET.pak/launch.sh:3" + Bios/PKM: + cores: + - pokemini + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/PKM.pak/launch.sh:3" + Bios/PLUS4: + cores: + - vice_xplus4 + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/PLUS4.pak/launch.sh:3" + Bios/PRBOOM: + cores: + - prboom + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/PRBOOM.pak/launch.sh:7" + Bios/PS: + cores: + - pcsx_rearmed + devices: + - desktop + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/SYSTEM/desktop/paks/Emus/PS.pak/launch.sh:3" + Bios/PUAE: + cores: + - puae2021 + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/PUAE.pak/launch.sh:3" + Bios/SEGACD: + cores: + - picodrive + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/SEGACD.pak/launch.sh:3" + Bios/SFC: + cores: + - snes9x + devices: + - desktop + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/SYSTEM/desktop/paks/Emus/SFC.pak/launch.sh:3" + Bios/SG1000: + cores: + - picodrive + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/SG1000.pak/launch.sh:3" + Bios/SGB: + cores: + - mgba + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/SGB.pak/launch.sh:3" + Bios/SMS: + cores: + - picodrive + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/SMS.pak/launch.sh:3" + Bios/SUPA: + cores: + - mednafen_supafaust + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/SUPA.pak/launch.sh:3" + Bios/VB: + cores: + - mednafen_vb + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/VB.pak/launch.sh:3" + Bios/VIC: + cores: + - vice_xvic + devices: + - h700 + - tg5040 + - tg5050 + source_ref: "skeleton/EXTRAS/Emus/h700/VIC.pak/launch.sh:3" +core_repositories: + a5200: + - "https://github.com/libretro/a5200" + bluemsx: + - "https://github.com/libretro/blueMSX-libretro" + cap32: + - "https://github.com/libretro/libretro-cap32" + fake08: + - "https://github.com/jtothebell/fake-08" + fbneo: + - "https://github.com/libretro/FBNeo" + fceumm: + - "https://github.com/libretro/libretro-fceumm" + gambatte: + - "https://github.com/libretro/gambatte-libretro" + gearcoleco: + - "https://github.com/drhelius/Gearcoleco" + gpsp: + - "https://github.com/libretro/gpsp" + handy: + - "https://github.com/libretro/libretro-handy" + mednafen_pce_fast: + - "https://github.com/libretro/beetle-pce-fast-libretro" + mednafen_supafaust: + - "https://github.com/libretro/supafaust" + mednafen_vb: + - "https://github.com/libretro/beetle-vb-libretro" + mgba: + - "https://github.com/libretro/mgba" + pcsx_rearmed: + - "https://github.com/libretro/pcsx_rearmed" + picodrive: + - "https://github.com/irixxxx/picodrive" + pokemini: + - "https://github.com/libretro/PokeMini" + prboom: + - "https://github.com/libretro/libretro-prboom" + prosystem: + - "https://github.com/libretro/prosystem-libretro" + puae2021: + - "https://github.com/libretro/libretro-uae" + race: + - "https://github.com/libretro/race" + snes9x: + - "https://github.com/libretro/snes9x" + stella2014: + - "https://github.com/libretro/stella2014-libretro" + vice_x128: + - "https://github.com/libretro/vice-libretro" + vice_x64: + - "https://github.com/libretro/vice-libretro" + vice_xpet: + - "https://github.com/libretro/vice-libretro" + vice_xplus4: + - "https://github.com/libretro/vice-libretro" + vice_xvic: + - "https://github.com/libretro/vice-libretro" +systems: {} diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 9f3f180a..43777c99 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -1157,6 +1157,7 @@ def generate_pack( standalone_copies=( _pack_registry.get(platform_name, {}).get("install", {}) or {} ).get("standalone_copies", []), + launch_folders=len(config.get("core_system_dirs") or {}), ) _write_generated_member(zf, "README.txt", readme_text) diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index f99b9f67..fd2ffd86 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -22,6 +22,7 @@ sys.path.insert(0, os.path.dirname(__file__)) from common import ( compute_composition, count_catalog_matched, + declared_root, github_token, list_registered_platforms, load_database, @@ -167,6 +168,19 @@ def extract_targets(platforms_dir: str) -> list[tuple[str, str]]: return sorted(rows, key=lambda row: row[0].casefold()) +def own_root(config: dict) -> str: + """The folder a pack written from an empty base_destination starts with. + + RetroDECK writes bios/ in front of its files, the MinUI family hands its + cores folders below Bios/. + """ + root = declared_root(config) + if root: + return root + tops = {directory.split("/", 1)[0] for directory in config.get("core_system_dirs") or {}} + return tops.pop() if len(tops) == 1 else "bios" + + def extract_notes(platforms_dir: str) -> list[str]: """A pack whose entries carry their own root extracts above the BIOS folder.""" folders = dict(extract_targets(platforms_dir)) @@ -176,8 +190,8 @@ def extract_notes(platforms_dir: str) -> list[str]: display = config.get("platform", key) if config.get("base_destination") == "" and display in folders: notes.append( - f"The {display} pack already carries its own `bios/` folder, so it" - f" extracts into `{folders[display]}` rather than into the BIOS folder." + f"The {display} pack already carries its own `{own_root(config)}/` folder," + f" so it extracts into `{folders[display]}` rather than into the BIOS folder." ) return notes @@ -470,7 +484,9 @@ def generate_readme(db: dict, platforms_dir: str) -> str: " a checksum, the fingerprint of a file's contents, which catches a" " corrupt or unexpected copy. RetroArch, Lakka and RetroPie check" " that the file is present under its name without reading its" - " contents (`path_is_valid` in RetroArch's `core_info.c`): the" + " contents (`path_is_valid` in RetroArch's `core_info.c`), and" + " MinUI, NextUI and MyMinUI hand each core its folder without" + " opening a file in it: the" " Coverage table says which applies to you. Independently of that," " the collection records five fingerprints per file, and wherever" " an emulator's code states an expected size or hash, that value is" diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 01b379ce..9a5c91c6 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -2158,7 +2158,9 @@ def generate_platform_page( f"| Contributed by | [@{username}](https://github.com/{username}):" f" {contribution}{pr_link} |" ) - lines.extend(["", *_platform_install_block(name, display)]) + detect = ((registry or {}).get(name, {}).get("install") or {}).get("detect") or [] + on_card = any(rule.get("method") == "card_marker" for rule in detect) + lines.extend(["", *_platform_install_block(name, display, on_card)]) lines.extend( [ "", @@ -3565,9 +3567,35 @@ def _install_block(name: str, profile: dict) -> list[str]: ] -def _platform_install_block(name: str, display: str) -> list[str]: +def _platform_install_block(name: str, display: str, on_card: bool = False) -> list[str]: """The platform's pack, installed file by file: what the ZIP holds, with - resume, verification and only what is missing.""" + resume, verification and only what is missing. + + ``on_card``: the files go on the frontend's SD card, installed from the + computer it is mounted on, whose root is named. + """ + if on_card: + return [ + '???+ tip "Install with one command"', + "", + f" With the {display} SD card mounted, the installer downloads the files " + "of the pack that are missing or differ from it into `Bios//` on " + "the card, checks each one, and resumes where it stopped. `--dest` " + "names the card's root.", + "", + " Linux, macOS:", + "", + " ```sh", + f" {install_command('sh', f'--platform {name} --dest /path/to/card')}", + " ```", + "", + " Windows (PowerShell, E: being the card):", + "", + " ```powershell", + " " + install_command("powershell", f"--platform {name} --dest E:\\"), + " ```", + "", + ] return [ '???+ tip "Install with one command"', "", diff --git a/scripts/packreadme.py b/scripts/packreadme.py index d58b74d7..3b8bccc8 100644 --- a/scripts/packreadme.py +++ b/scripts/packreadme.py @@ -12,9 +12,46 @@ def _detect_systems(detect: list[dict]) -> list[str]: """The operating systems the registry's detection rules cover, in order.""" order = ("linux", "darwin", "windows") seen = {str(rule.get("os", "")) for rule in detect} + if "any" in seen: + return list(order) return [name for name in order if name in seen] +def _card_section(platform_name: str, display: str) -> str: + """The one-line install for a frontend whose files live on its SD card. + + The installer runs on the computer the card is mounted on, and the card + root is named: no folder on that computer is one the frontend reads. + """ + return ( + " Option A: Automatic (recommended)\n" + " ---------------------------------\n" + " With the SD card mounted on a computer, name its root with --dest.\n" + " Linux and macOS: run this in a terminal:\n\n" + f" curl -fsSL {INSTALL_SH} | sh -s -- --platform {platform_name} --dest /path/to/card\n\n" + " Windows: run this in PowerShell (E: being the card):\n\n" + f" &([scriptblock]::Create((irm {INSTALL_PS1}))) --platform {platform_name} --dest E:\\\n\n" + f" The installer writes each file where {display} hands it to the core\n" + " and checks every file.\n\n" + ) + + +def _card_manual(display: str) -> str: + """Where the files of a per-tag Bios/ layout go, copied by hand.""" + return ( + " Option B: Manual\n" + " ----------------\n" + " 1. Put the SD card in your PC\n" + " 2. Extract all files from this archive directly into the root of the\n" + " card: the archive holds the Bios/ folder, with one subfolder per\n" + " launch tag (Bios/PS/, Bios/GBA/, ...)\n" + " 3. Overwrite if asked\n\n" + f" {display} hands each core the folder named after the tag in\n" + " parentheses of its Roms folder (\"Sony PlayStation (PS)\" reads\n" + " Bios/PS/). A Roms folder can be renamed as long as the tag stays.\n\n" + ) + + def _automatic_section( display: str, detect: list[dict], standalone_copies: list[dict] | None, ) -> str: @@ -234,24 +271,40 @@ def _build_readme( system_filter: list[str] | None = None, detect: list[dict] | None = None, standalone_copies: list[dict] | None = None, + launch_folders: int = 0, ) -> str: - """Build a personalized step-by-step README for each platform pack.""" + """Build a personalized step-by-step README for each platform pack. + + ``launch_folders`` counts the folders a platform that hands each core + its own system directory gives them, where it names no system. + """ narrowings = narrowings or [] sep = "=" * 50 + scope = ( + f"{launch_folders} launch folders" if launch_folders and not num_systems + else f"{num_systems} systems" + ) header = ( f"{sep}\n" f" RETROBIOS - {platform_display} BIOS Pack\n" - f" {total_files} files for {num_systems} systems\n" + f" {total_files} files for {scope}\n" f"{sep}\n\n" ) detect = list(detect or []) bios_paths = [str(rule.get("bios_path", "")) for rule in detect] - guide = ( - "INSTALLATION GUIDE\n\n" - + _automatic_section(platform_display, detect, standalone_copies) - + MANUAL_GUIDES.get(platform_name, _generic_manual(bios_paths)) - ) + if any(rule.get("method") == "card_marker" for rule in detect): + guide = ( + "INSTALLATION GUIDE\n\n" + + _card_section(platform_name, platform_display) + + _card_manual(platform_display) + ) + else: + guide = ( + "INSTALLATION GUIDE\n\n" + + _automatic_section(platform_display, detect, standalone_copies) + + MANUAL_GUIDES.get(platform_name, _generic_manual(bios_paths)) + ) if regions: region_help = ( diff --git a/scripts/scraper/minui_scraper.py b/scripts/scraper/minui_scraper.py new file mode 100644 index 00000000..b595f91f --- /dev/null +++ b/scripts/scraper/minui_scraper.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Scraper for MinUI and the frontends built on it (NextUI, MyMinUI). + +Source: the frontend's repository at a pinned commit +Format: one emulator pak per launch tag, ``skeleton/SYSTEM//paks/Emus/ +.pak/launch.sh`` (base install) or ``skeleton/EXTRAS/Emus// +.pak/launch.sh`` (extras bundle), whose script names the libretro core +in ``EMU_EXE`` and starts it under minarch. + +None of the three publishes a BIOS list in code: the frontend hands every +core the folder ``/Bios//`` as its libretro system directory +(``core.bios_dir``, returned for RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY) and +never opens or hashes a BIOS file itself. What the code does state is which +core each tag runs, and that tag decides the folder. So the platform file +carries no file entry: it maps each folder to the cores launched with it, +and the files are the ones the core profiles document, placed below that +folder. + +Paks that start a standalone binary (NDS.pak runs DraStic, P8-NATIVE.pak +the PICO-8 executable) set no ``EMU_EXE`` and are not libretro launches: +they are counted and left out. +""" + +from __future__ import annotations + +import json +import re +import urllib.parse +from collections.abc import Callable, Mapping +from dataclasses import dataclass + +try: + from .base_scraper import ( + BaseScraper, + BiosRequirement, + fetch_branch_head, + fetch_bytes, + github_headers, + ) +except ImportError: + from base_scraper import ( + BaseScraper, + BiosRequirement, + fetch_branch_head, + fetch_bytes, + github_headers, + ) + +PLATFORM_NAME = "minui" + + +@dataclass(frozen=True) +class Variant: + """One frontend of the family: where its code lives and what it is called.""" + + key: str + name: str + repo: str + branch: str + homepage: str + # The file that sets core.bios_dir. + minarch_source: str + + +VARIANTS: dict[str, Variant] = { + "minui": Variant( + key="minui", + name="MinUI", + repo="shauninman/MinUI", + branch="main", + homepage="https://github.com/shauninman/MinUI", + minarch_source="workspace/all/minarch/minarch.c", + ), + "nextui": Variant( + key="nextui", + name="NextUI", + repo="LoveRetro/NextUI", + branch="main", + homepage="https://nextui.loveretro.games", + minarch_source="workspace/all/minarch/ma_core.c", + ), + "myminui": Variant( + key="myminui", + name="MyMinUI", + repo="Turro75/MyMinUI", + branch="main", + homepage="https://github.com/Turro75/MyMinUI", + minarch_source="workspace/all/minarch/minarch.c", + ), +} + +# The folder minarch hands a core: SDCARD_PATH "/Bios/" and the launch tag. +BIOS_ROOT = "Bios" +_BIOS_DIR = re.compile( + r'sprintf\(\s*\(char\s*\*\)\s*core\.bios_dir\s*,\s*SDCARD_PATH\s*"/Bios/%s"\s*,\s*core\.tag\s*\)' +) +_PAK = re.compile( + r"^skeleton/(?:SYSTEM/(?P[^/]+)/paks/Emus|EXTRAS/Emus/(?P[^/]+))" + r"/(?P[^/]+)\.pak/launch\.sh$" +) +_EMU_EXE = re.compile(r"^[ \t]*EMU_EXE=([A-Za-z0-9_.-]+)[ \t]*$", re.MULTILINE) +# Both ways a pak starts its core: minarch on the core's .so directly, or +# MyMinUI's launch_rom.sh, which runs minarch (or RetroArch with +# system_directory set to the same folder) on ${EMU_EXE}_libretro.so. +_STARTS_CORE = re.compile( + r'^[ \t]*(?:nice\s+-?\d+\s+)?minarch\.elf\s+"\$CORES_PATH/\$\{EMU_EXE\}_libretro\.so"' + r"|^[ \t]*launch_rom\.sh\s", + re.MULTILINE, +) +# Each device builds its cores from workspace//cores/makefile through +# the template in workspace/all/cores/makefile: `_REPO ?= +# https://github.com/libretro/` and the library `_libretro.so` unless +# `_CORE` names another. +_RECIPE = re.compile(r"^workspace/(?P[^/]+)/cores/makefile$") +_CORES_LIST = re.compile(r"^CORES\s*\+?=(.*)$", re.MULTILINE) +_CORE_VAR = re.compile(r"^(\S+?)_(REPO|CORE)\s*\??=\s*(\S+)", re.MULTILINE) +# A library built from a project other than the libretro core of that name: +# MyMinUI builds dosbox-pure into dosbox_libretro.so. The core is the project +# the bytes come from. +BUILT_AS = { + "https://github.com/libretro/dosbox-pure": "dosbox_pure", +} + + +@dataclass(frozen=True) +class Pak: + """One emulator pak as its launch script states it.""" + + tag: str + device: str + core: str + path: str + line: int + + +def read_pak(path: str, text: str) -> Pak | None: + """The libretro launch a pak's script makes, or None for a standalone one. + + A script that names a core but starts it in a way not read here raises: + the shape of the paks changed and the scrape must say so. + """ + match = _PAK.match(path) + if not match: + return None + exe = _EMU_EXE.search(text) + if not exe: + return None + if not _STARTS_CORE.search(text): + raise ValueError(f"{path}: EMU_EXE={exe.group(1)} but no minarch launch") + line = text.count("\n", 0, exe.start()) + 1 + return Pak( + tag=match.group("tag"), + device=match.group("base") or match.group("extra"), + core=exe.group(1), + path=path, + line=line, + ) + + +def normalize_repository(url: str) -> str: + """A repository URL without the .git suffix or a trailing slash.""" + url = url.strip().rstrip("/") + return url[: -len(".git")] if url.endswith(".git") else url + + +def read_recipes(text: str) -> dict[str, str]: + """Library file name to source repository, for one device's core makefile.""" + text = "\n".join(line.split("#", 1)[0] for line in text.splitlines()) + names = [name for match in _CORES_LIST.finditer(text) for name in match.group(1).split()] + values: dict[tuple[str, str], str] = { + (match.group(1), match.group(2)): match.group(3) for match in _CORE_VAR.finditer(text) + } + return { + values.get((name, "CORE"), f"{name}_libretro.so"): normalize_repository( + values.get((name, "REPO"), f"https://github.com/libretro/{name}") + ) + for name in names + } + + +def bios_dir_ref(source: str, text: str) -> str: + """``file:line`` of the statement that sets the per-tag system directory.""" + match = _BIOS_DIR.search(text) + if not match: + raise ValueError(f"{source}: core.bios_dir is no longer SDCARD_PATH/Bios/") + return f"{source}:{text.count(chr(10), 0, match.start()) + 1}" + + +def core_identity(library: str, repositories: Mapping[str, set[str]]) -> str: + """The core a pak's library is, by the project it is built from.""" + stem = library.removesuffix("_libretro.so") + built = sorted({BUILT_AS[r] for r in repositories.get(library, set()) if r in BUILT_AS}) + if len(built) > 1: + raise ValueError(f"{library} is built from projects of {', '.join(built)}") + return built[0] if built else stem + + +def core_system_dirs( + paks: list[Pak], repositories: Mapping[str, set[str]] | None = None +) -> dict[str, dict]: + """Each Bios/ folder with the cores its paks run and where they ship.""" + repositories = repositories or {} + by_dir: dict[str, dict] = {} + for pak in sorted(paks, key=lambda p: (p.tag, p.device, p.path)): + entry = by_dir.setdefault( + f"{BIOS_ROOT}/{pak.tag}", + {"cores": [], "devices": [], "source_ref": f"{pak.path}:{pak.line}"}, + ) + core = core_identity(f"{pak.core}_libretro.so", repositories) + if core not in entry["cores"]: + entry["cores"].append(core) + if pak.device not in entry["devices"]: + entry["devices"].append(pak.device) + for entry in by_dir.values(): + entry["cores"].sort() + entry["devices"].sort() + return dict(sorted(by_dir.items())) + + +def platform_config( + variant: Variant, revision: str, date: str, files: Mapping[str, str] +) -> dict: + """The platform file for one frontend, from its sources at ``revision``. + + ``files`` maps repository paths to their text: every pak launch script, + every device's core makefile and the minarch source that sets the + system directory. + """ + if variant.minarch_source not in files: + raise ValueError(f"{variant.repo}@{revision}: no {variant.minarch_source}") + reference = bios_dir_ref(variant.minarch_source, files[variant.minarch_source]) + paks = [pak for path, text in sorted(files.items()) if (pak := read_pak(path, text))] + if not paks: + raise ValueError(f"{variant.repo}@{revision}: no pak starts a libretro core") + repositories: dict[str, set[str]] = {} + for path, text in files.items(): + if _RECIPE.match(path): + for library, repository in read_recipes(text).items(): + repositories.setdefault(library, set()).add(repository) + dirs = core_system_dirs(paks, repositories) + built_from = { + core_identity(f"{pak.core}_libretro.so", repositories): sorted( + repositories.get(f"{pak.core}_libretro.so", set()) + ) + for pak in paks + } + return { + "platform": variant.name, + "version": date, + "homepage": variant.homepage, + "source": f"https://github.com/{variant.repo}/tree/{revision}", + # The card root: every core reads below Bios// on it. + "base_destination": "", + "hash_type": "sha1", + # minarch hands the folder and never reads or hashes a file in it. + "verification_mode": "existence", + "system_directory_ref": reference, + "cores": sorted({core for entry in dirs.values() for core in entry["cores"]}), + "core_system_dirs": dirs, + # Where each core's library is built from, read from the device + # makefiles. A pak may ship a library no makefile builds. + "core_repositories": {core: repos for core, repos in sorted(built_from.items()) if repos}, + "systems": {}, + } + + +class Scraper(BaseScraper): + """Pak scraper for one frontend of the MinUI family.""" + + lists_files = False + variant: Variant = VARIANTS["minui"] + + def __init__( + self, + url: str | None = None, + fetch: Callable[[str, dict[str, str] | None], bytes] | None = None, + ) -> None: + self._fetch = fetch or (lambda u, h=None: fetch_bytes(u, headers=h)) + self.revision = "" + if url is None: + self.revision = fetch_branch_head(self.variant.repo, self.variant.branch) + url = f"https://github.com/{self.variant.repo}/tree/{self.revision}" + super().__init__(url=url) + self._config: dict | None = None + + def _api(self, path: str) -> dict: + url = f"https://api.github.com/repos/{self.variant.repo}/{path}" + try: + return json.loads(self._fetch(url, github_headers())) + except (ConnectionError, json.JSONDecodeError) as exc: + raise RuntimeError(f"cannot read {url}: {exc}") from exc + + def _raw(self, path: str) -> str: + url = ( + f"https://raw.githubusercontent.com/{self.variant.repo}/{self.revision}/" + f"{urllib.parse.quote(path)}" + ) + return self._fetch(url, None).decode("utf-8", errors="replace") + + def config(self) -> dict: + """The platform file, read once.""" + if self._config is not None: + return self._config + if not self.revision: + self.revision = self.url.rstrip("/").rsplit("/", 1)[-1] + tree = self._api(f"git/trees/{self.revision}?recursive=1") + if tree.get("truncated"): + raise RuntimeError(f"{self.variant.repo}@{self.revision}: tree listing truncated") + paths = [ + item["path"] for item in tree.get("tree", []) + if item.get("type") == "blob" + and ( + _PAK.match(item["path"]) + or _RECIPE.match(item["path"]) + or item["path"] == self.variant.minarch_source + ) + ] + commit = self._api(f"commits/{self.revision}") + date = commit.get("commit", {}).get("committer", {}).get("date", "")[:10] + if not date: + raise RuntimeError(f"{self.variant.repo}@{self.revision}: commit has no date") + files = {path: self._raw(path) for path in paths} + self._config = platform_config(self.variant, self.revision, date, files) + return self._config + + def fetch_requirements(self) -> list[BiosRequirement]: + """None: the frontend names no BIOS file in code, its cores do.""" + self.config() + return [] + + def validate_format(self, raw_data: str) -> bool: + return bool(self.config().get("core_system_dirs")) + + def generate_platform_yaml(self) -> dict: + return self.config() + + +def main() -> None: + try: + from .base_scraper import scraper_cli + except ImportError: + from base_scraper import scraper_cli + + scraper_cli(Scraper, "Scrape the MinUI pak list") + + +if __name__ == "__main__": + main() diff --git a/scripts/scraper/myminui_scraper.py b/scripts/scraper/myminui_scraper.py new file mode 100644 index 00000000..e2408731 --- /dev/null +++ b/scripts/scraper/myminui_scraper.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Scraper for MyMinUI's pak list: the MinUI scraper on MyMinUI's repository.""" + +from __future__ import annotations + +try: + from . import minui_scraper +except ImportError: + import minui_scraper + +PLATFORM_NAME = "myminui" + + +class Scraper(minui_scraper.Scraper): + """Pak scraper for MyMinUI.""" + + variant = minui_scraper.VARIANTS["myminui"] + + +def main() -> None: + try: + from .base_scraper import scraper_cli + except ImportError: + from base_scraper import scraper_cli + + scraper_cli(Scraper, "Scrape the MyMinUI pak list") + + +if __name__ == "__main__": + main() diff --git a/scripts/scraper/nextui_scraper.py b/scripts/scraper/nextui_scraper.py new file mode 100644 index 00000000..923bc169 --- /dev/null +++ b/scripts/scraper/nextui_scraper.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Scraper for NextUI's pak list: the MinUI scraper on NextUI's repository.""" + +from __future__ import annotations + +try: + from . import minui_scraper +except ImportError: + import minui_scraper + +PLATFORM_NAME = "nextui" + + +class Scraper(minui_scraper.Scraper): + """Pak scraper for NextUI.""" + + variant = minui_scraper.VARIANTS["nextui"] + + +def main() -> None: + try: + from .base_scraper import scraper_cli + except ImportError: + from base_scraper import scraper_cli + + scraper_cli(Scraper, "Scrape the NextUI pak list") + + +if __name__ == "__main__": + main() diff --git a/tests/test_exporters.py b/tests/test_exporters.py index cdaedfac..87f411aa 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -56,6 +56,17 @@ from exporter.baseline import ( # noqa: E402 UPSTREAM_CACHE = REPO_ROOT / ".cache" / "upstream-native" + +def _without_native_list() -> set[str]: + """Registered platforms whose code names no BIOS file to hand back.""" + import yaml # noqa: PLC0415 + + registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text()) + return { + name for name, entry in registry["platforms"].items() + if entry.get("native_list") == "none" + } + TRUTH = { "systems": { "sony-playstation": { @@ -552,7 +563,19 @@ class ExporterRegistry(unittest.TestCase): str(REPO_ROOT / "platforms"), include_archived=True ) ) - self.assertEqual(registered - set(exporters), set()) + self.assertEqual(registered - set(exporters) - _without_native_list(), set()) + + def test_a_platform_without_a_native_list_declares_no_file(self): + """`native_list: none` says the platform's code names no BIOS file + (MinUI hands each core a folder and opens nothing in it). A platform + whose file declares one has a list to hand back.""" + for name in _without_native_list(): + config = load_platform_config(name, str(REPO_ROOT / "platforms")) + with self.subTest(platform=name): + self.assertNotIn(name, discover_exporters()) + self.assertFalse(any( + system.get("files") for system in (config.get("systems") or {}).values() + )) def test_no_exporter_is_registered_for_an_unregistered_platform(self): """A dead exporter is one nobody will notice has stopped working.""" diff --git a/tests/test_minui_platform.py b/tests/test_minui_platform.py new file mode 100644 index 00000000..10d35201 --- /dev/null +++ b/tests/test_minui_platform.py @@ -0,0 +1,415 @@ +"""MinUI, NextUI and MyMinUI hand each core a system directory of its own. + +minarch sets ``core.bios_dir`` to SDCARD_PATH "/Bios/", the tag being +the pak's folder name and the one in parentheses of the Roms folder, and +returns it for RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY. A core run from +GB.pak reads gb_bios.bin from Bios/GB/, the same core run from GBC.pak from +Bios/GBC/. The frontends name no BIOS file, so the platform file maps each +folder to the cores its paks start, and the files come from the core +profiles, placed below that folder. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import sys +import tempfile +import unittest +import zipfile +import zlib +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) +sys.path.insert(0, str(REPO_ROOT)) + +from common import load_emulator_profiles, load_platform_config # noqa: E402 +from scraper import minui_scraper # noqa: E402 + +MINARCH = ( + "void Core_open(const char* core_path, const char* tag_name) {\n" + '\tsprintf((char*)core.saves_dir, SDCARD_PATH "/Saves/%s", core.tag);\n' + '\tsprintf((char*)core.bios_dir, SDCARD_PATH "/Bios/%s", core.tag);\n' + "}\n" +) +BASE_PAK = ( + "#!/bin/sh\n\n" + "EMU_EXE=gambatte\n\n" + "###############################\n\n" + 'EMU_TAG=$(basename "$(dirname "$0")" .pak)\n' + 'mkdir -p "$BIOS_PATH/$EMU_TAG"\n' + 'nice -20 minarch.elf "$CORES_PATH/${EMU_EXE}_libretro.so" "$ROM" &> "$LOGS_PATH/$EMU_TAG.txt"\n' +) +# MyMinUI's shape: the pak hands its folder to launch_rom.sh. +EXTRA_PAK = ( + "#!/bin/sh\n\n" + "EMU_EXE=dosbox\n\n" + "RUN=minarch\nRUN2=minarch\n" + 'CURDIR="$(dirname "$0")"\n' + 'launch_rom.sh "$1" "$2" "$3" "$4" ${RUN} ${RUN2} "$CURDIR" "$EMU_EXE" $CPU_OC\n' +) +STANDALONE_PAK = ( + "#!/bin/sh\n" + 'if [ -f /mnt/SDCARD/Bios/NDS/drastic ]; then\n' + ' /mnt/SDCARD/Bios/NDS/drastic "$1"\n' + "fi\n" +) +RECIPE = ( + "CORES = gambatte dosbox # mesen\n" + "dosbox_REPO = https://github.com/libretro/dosbox-pure.git\n" + "dosbox_CORE = dosbox_libretro.so\n" +) +FIXTURE = { + "workspace/all/minarch/minarch.c": MINARCH, + "skeleton/SYSTEM/miyoomini/paks/Emus/GB.pak/launch.sh": BASE_PAK, + "skeleton/SYSTEM/tg5040/paks/Emus/GB.pak/launch.sh": BASE_PAK, + "skeleton/EXTRAS/Emus/tg5040/DOS.pak/launch.sh": EXTRA_PAK, + "skeleton/EXTRAS/Emus/miyoomini/NDS.pak/launch.sh": STANDALONE_PAK, + "workspace/tg5040/cores/makefile": RECIPE, +} + + +class ScraperReadsThePaks(unittest.TestCase): + """Two paks, one per bundle, and a standalone one left out.""" + + def config(self, files: dict[str, str] | None = None) -> dict: + return minui_scraper.platform_config( + minui_scraper.VARIANTS["minui"], "f" * 40, "2026-01-01", files or FIXTURE + ) + + def test_each_tag_is_a_folder_below_bios(self): + dirs = self.config()["core_system_dirs"] + self.assertEqual(sorted(dirs), ["Bios/DOS", "Bios/GB"]) + self.assertEqual(dirs["Bios/GB"]["cores"], ["gambatte"]) + self.assertEqual(dirs["Bios/GB"]["devices"], ["miyoomini", "tg5040"]) + self.assertEqual( + dirs["Bios/GB"]["source_ref"], + "skeleton/SYSTEM/miyoomini/paks/Emus/GB.pak/launch.sh:3", + ) + + def test_the_core_is_the_project_the_library_is_built_from(self): + """MyMinUI builds dosbox-pure into dosbox_libretro.so: the pak's + EMU_EXE=dosbox is DOSBox Pure, whose profile reads other files than + the dosbox core's.""" + config = self.config() + self.assertEqual(config["core_system_dirs"]["Bios/DOS"]["cores"], ["dosbox_pure"]) + self.assertEqual( + config["core_repositories"]["dosbox_pure"], + ["https://github.com/libretro/dosbox-pure"], + ) + self.assertEqual(config["cores"], ["dosbox_pure", "gambatte"]) + + def test_the_folder_rule_is_read_from_minarch(self): + config = self.config() + self.assertEqual(config["system_directory_ref"], "workspace/all/minarch/minarch.c:3") + self.assertEqual(config["base_destination"], "") + self.assertEqual(config["verification_mode"], "existence") + + def test_a_standalone_pak_is_not_a_core(self): + dirs = self.config()["core_system_dirs"] + self.assertNotIn("Bios/NDS", dirs) + + def test_a_named_core_started_another_way_raises(self): + files = {**FIXTURE, "skeleton/SYSTEM/miyoomini/paks/Emus/GB.pak/launch.sh": + "EMU_EXE=gambatte\nretroarch -L core.so\n"} + with self.assertRaises(ValueError): + self.config(files) + + def test_a_moved_system_directory_raises(self): + files = {**FIXTURE, "workspace/all/minarch/minarch.c": + 'sprintf((char*)core.bios_dir, SDCARD_PATH "/BIOS");\n'} + with self.assertRaises(ValueError): + self.config(files) + + def test_the_scraper_reads_the_pinned_revision(self): + """Tree, commit and file reads all name the same commit.""" + revision = "a" * 40 + asked: list[str] = [] + + def fetch(url: str, _headers: dict[str, str] | None = None) -> bytes: + asked.append(url) + if url.endswith(f"git/trees/{revision}?recursive=1"): + tree = [{"path": p, "type": "blob"} for p in FIXTURE] + tree.append({"path": "skeleton/BASE/README.txt", "type": "blob"}) + return json.dumps({"tree": tree, "truncated": False}).encode() + if url.endswith(f"commits/{revision}"): + return json.dumps( + {"commit": {"committer": {"date": "2026-02-03T04:05:06Z"}}} + ).encode() + prefix = f"https://raw.githubusercontent.com/shauninman/MinUI/{revision}/" + self.assertTrue(url.startswith(prefix), url) + return FIXTURE[url[len(prefix):]].encode() + + scraper = minui_scraper.Scraper( + url=f"https://github.com/shauninman/MinUI/tree/{revision}", fetch=fetch + ) + config = scraper.generate_platform_yaml() + self.assertEqual(config["version"], "2026-02-03") + self.assertEqual(config["source"], f"https://github.com/shauninman/MinUI/tree/{revision}") + self.assertNotIn( + f"https://raw.githubusercontent.com/shauninman/MinUI/{revision}/skeleton/BASE/README.txt", + asked, + ) + + +def _crc(data: bytes) -> str: + return format(zlib.crc32(data) & 0xFFFFFFFF, "08x") + + +class PackPlacesCoreFilesUnderTheTag(unittest.TestCase): + """The pack, the report and the manifest put a core's files in each + folder the platform hands it, and nowhere else.""" + + def setUp(self): + from common import _emulator_profiles_cache + + _emulator_profiles_cache.clear() + self.root = tempfile.mkdtemp(dir=REPO_ROOT / "tmp" / "tests") + self.bios = os.path.join(self.root, "bios") + self.platforms = os.path.join(self.root, "platforms") + self.emulators = os.path.join(self.root, "emulators") + for folder in (self.bios, self.platforms, self.emulators): + os.makedirs(folder) + self.records: dict[str, dict] = {} + self._file("Nintendo/Game Boy/gb_bios.bin", b"DMG BOOT ROM") + # Two palettes of one name: the core's path, below its folder, + # must still choose its own. + self._file("Other/gambatte/palettes/default.pal", b"GAMBATTE PALETTE") + self._file("Other/elsewhere/default.pal", b"ANOTHER PALETTE") + self.member = b"ARCADE BIOS ROM" + buffer = os.path.join(self.root, "arc.zip") + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("rom_a.bin", self.member) + self._file("Arcade/arc.zip", Path(buffer).read_bytes()) + self._file("Other/unlisted/unlisted.bin", b"NOT HANDED") + self.db = self._database() + + platform = { + "platform": "MinUI Test", + "base_destination": "", + "verification_mode": "existence", + "cores": ["gambatte", "arcadecore", "unlisted"], + "core_system_dirs": { + "Bios/GB": {"cores": ["gambatte"], "source_ref": "GB.pak/launch.sh:3"}, + "Bios/GBC": {"cores": ["gambatte"], "source_ref": "GBC.pak/launch.sh:3"}, + "Bios/ARC": {"cores": ["arcade_lib"], "source_ref": "ARC.pak/launch.sh:3"}, + }, + "systems": {}, + } + Path(self.platforms, "test_minui.yml").write_text(yaml.safe_dump(platform)) + profiles = { + "gambatte": { + "emulator": "Gambatte", "type": "libretro", "systems": ["nintendo-gb"], + "files": [ + {"name": "gb_bios.bin", "required": False}, + {"name": "default.pal", "path": "palettes/default.pal", "required": False}, + ], + }, + "arcadecore": { + "emulator": "Arcade", "type": "libretro", "systems": ["arcade"], + "cores": ["arcade_lib"], "archive_prefix": "arcadecore", + "files": [ + {"name": "rom_a.bin", "archive": "arc.zip", "required": True, + "crc32": _crc(self.member)}, + ], + }, + "unlisted": { + "emulator": "Unlisted", "type": "libretro", "systems": ["other"], + "files": [{"name": "unlisted.bin", "required": True}], + }, + } + for key, profile in profiles.items(): + Path(self.emulators, f"{key}.yml").write_text(yaml.safe_dump(profile)) + + def tearDown(self): + shutil.rmtree(self.root) + + def _file(self, relative: str, data: bytes) -> None: + path = os.path.join(self.bios, relative) + os.makedirs(os.path.dirname(path), exist_ok=True) + Path(path).write_bytes(data) + self.records[relative] = { + "path": path, + "name": os.path.basename(relative), + "sha1": hashlib.sha1(data).hexdigest(), + "md5": hashlib.md5(data).hexdigest(), + "crc32": _crc(data), + "size": len(data), + } + + def _database(self) -> dict: + files, by_name, by_md5, by_crc32, by_suffix = {}, {}, {}, {}, {} + for relative, record in self.records.items(): + sha1 = record["sha1"] + files[sha1] = {k: v for k, v in record.items() if k != "sha1"} + by_name.setdefault(record["name"], []).append(sha1) + by_md5[record["md5"]] = sha1 + by_crc32[record["crc32"]] = sha1 + parts = relative.split("/") + for start in range(len(parts) - 1): + by_suffix.setdefault("/".join(parts[start:]), []).append(sha1) + return {"files": files, "indexes": { + "by_name": by_name, "by_md5": by_md5, "by_crc32": by_crc32, + "by_path_suffix": by_suffix, + }} + + def _pack(self) -> dict[str, bytes]: + from generate_pack import generate_pack + + output = os.path.join(self.root, "dist") + os.makedirs(output) + zip_path = generate_pack( + "test_minui", self.platforms, self.db, self.bios, output, + emulators_dir=self.emulators, offline=True, + ) + self.assertIsNotNone(zip_path) + with zipfile.ZipFile(zip_path) as archive: + return { + name: archive.read(name) for name in archive.namelist() + if name not in ("README.txt", "manifest.json") + } + + def test_a_core_file_lands_in_every_folder_of_the_core(self): + packed = self._pack() + self.assertEqual(packed["Bios/GB/gb_bios.bin"], b"DMG BOOT ROM") + self.assertEqual(packed["Bios/GBC/gb_bios.bin"], b"DMG BOOT ROM") + + def test_the_core_path_still_chooses_the_copy(self): + packed = self._pack() + self.assertEqual(packed["Bios/GB/palettes/default.pal"], b"GAMBATTE PALETTE") + + def test_an_archive_and_its_core_subfolder_move_with_the_folder(self): + packed = self._pack() + self.assertIn("Bios/ARC/arc.zip", packed) + self.assertIn("Bios/ARC/arcadecore/arc.zip", packed) + + def test_nothing_outside_a_handed_folder(self): + """A core no pak starts gets no folder, so none of its files.""" + packed = self._pack() + outside = [ + name for name in packed + if "/".join(name.split("/")[:2]) not in ("Bios/GB", "Bios/GBC", "Bios/ARC") + ] + self.assertEqual(outside, []) + + def test_the_same_cores_in_other_folders_make_another_pack(self): + from common import group_identical_platforms + + twin = yaml.safe_load(Path(self.platforms, "test_minui.yml").read_text()) + twin["core_system_dirs"]["Bios/GB"]["cores"] = ["arcade_lib"] + Path(self.platforms, "test_twin.yml").write_text(yaml.safe_dump(twin)) + groups = group_identical_platforms(["test_minui", "test_twin"], self.platforms) + self.assertEqual(len(groups), 2) + + def test_the_report_and_the_manifest_agree_with_the_pack(self): + from generate_pack import generate_manifest + from verify import verify_platform + + packed = set(self._pack()) + config = load_platform_config("test_minui", self.platforms) + profiles = load_emulator_profiles(self.emulators) + report = verify_platform(config, self.db, self.emulators, profiles) + self.assertEqual(set(report["pack_destinations"]), packed) + manifest = generate_manifest( + "test_minui", self.platforms, self.db, self.bios, + os.path.join(self.platforms, "_registry.yml"), emulators_dir=self.emulators, + emu_profiles=profiles, offline=True, + ) + self.assertEqual({entry["dest"] for entry in manifest["files"]}, packed) + + +class PublishedPlatformsHoldTheRule(unittest.TestCase): + """The committed files of the family, read as the tools read them.""" + + FAMILY = ("minui", "nextui", "myminui") + + def test_every_core_resolves_to_a_profile_of_its_project(self): + """A core named in core_system_dirs is the profile built from the + repository the platform's recipe names: a library renamed from + another project (dosbox-pure as dosbox) would hand the wrong + profile's files to the folder.""" + from common import resolve_platform_cores + + profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) + + def norm(url: str) -> str: + url = str(url).strip().rstrip("/").lower() + url = url.split("/tree/", 1)[0] + return url[: -len(".git")] if url.endswith(".git") else url + + for platform in self.FAMILY: + config = load_platform_config(platform, str(REPO_ROOT / "platforms")) + resolved = resolve_platform_cores(config, profiles) + for core, repositories in config["core_repositories"].items(): + matches = [ + key for key in resolved + if (key == core or core in [str(c) for c in profiles[key].get("cores") or []]) + and profiles[key].get("type") not in ("alias", "launcher") + ] + with self.subTest(platform=platform, core=core): + self.assertTrue(matches, f"{core} resolves to no profile") + for key in matches: + declared = [] + for field in ("source", "upstream"): + value = profiles[key].get(field) + declared += list(value.values()) if isinstance(value, dict) else [value] + self.assertTrue( + {norm(r) for r in repositories} & {norm(d) for d in declared if d}, + f"{key} documents {declared}, {platform} builds {repositories}", + ) + + def test_the_registry_detects_the_card_the_installer_reads(self): + import install + + registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text()) + rules = { + (name, rule["file"], rule["first_line_prefix"]) + for name, entry in registry["platforms"].items() + for rule in (entry.get("install") or {}).get("detect", []) + if rule["method"] == "card_marker" + } + self.assertEqual(set(install._CARD_RULES), rules) + self.assertEqual({name for name, _f, _p in rules}, set(self.FAMILY)) + + +class InstallerReadsTheCard(unittest.TestCase): + """install.py runs on the computer the card is mounted on.""" + + def setUp(self): + self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp" / "tests")) + (self.root / ".system").mkdir() + + def tearDown(self): + shutil.rmtree(self.root) + + def test_the_release_name_names_the_frontend(self): + import install + + for first_line, expected in ( + ("MinUI-20251127-0", "minui"), + ("MyMinUI-20260809b-0", "myminui"), + ("NextUI-20261020-1", "nextui"), + ("Onion-4.3", None), + ): + (self.root / ".system" / "version.txt").write_text(f"{first_line}\nabc123\n") + with self.subTest(first_line=first_line): + self.assertEqual(install.card_platform(self.root), expected) + + def test_a_folder_inside_the_card_is_refused(self): + """Bios/ given for the card would install into Bios/Bios//.""" + import install + + (self.root / ".system" / "version.txt").write_text("MinUI-20251127-0\n") + (self.root / "Bios").mkdir() + with self.assertRaises(SystemExit): + install._check_card_root("minui", self.root / "Bios") + install._check_card_root("minui", self.root) + + +if __name__ == "__main__": + unittest.main() From c7be31699fddb44125bf4d87c0e9ee5567ac4819 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:04:40 +0200 Subject: [PATCH 3/3] docs: document the minui family platforms --- wiki/adding-a-platform.md | 30 ++++++++++++++++++++++++++++-- wiki/faq.md | 2 +- wiki/getting-started.md | 30 +++++++++++++++++++++++++++++- wiki/installer.md | 11 ++++++++++- wiki/testing-guide.md | 2 +- wiki/tools.md | 2 ++ wiki/troubleshooting.md | 3 +++ wiki/verification-modes.md | 11 +++++++++-- 8 files changed, 83 insertions(+), 8 deletions(-) diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index ee7ed3c8..95b8842e 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -151,7 +151,8 @@ platforms: source_url: https://... # upstream data URL source_format: json # json, xml, clrmamepro_dat, python_dict, # bash_script+csv, csharp_firmware_database, - # github_component_manifests, mister_downloader_db + # github_component_manifests, mister_downloader_db, + # pak_launch_scripts hash_type: md5 # primary hash in the upstream data cores: # which emulator profiles apply - core_a @@ -194,6 +195,17 @@ profile's layout. and `hatari`): both layouts are packed. A profile without a standalone build keeps its libretro layout whatever the platform calls it. +A platform that hands each libretro core a system directory of its own declares +them in `core_system_dirs`, keyed by the directory relative to the pack root, +each with the `cores` started with it and the `source_ref` of the launcher that +names them. MinUI, NextUI and MyMinUI give every launch tag `Bios//` +(`system_directory_ref` cites the minarch statement). A core's files are then +placed below each directory listed for it, an archive's core subfolder +(`archive_prefix`) moves with it, and a core listed nowhere gets no file: the +platform hands it no folder. `platform_layouts` builds one view per directory, +so the builder, the manifest, verify and slots read the same placement, and the +resolver still looks the collection up by the core's own path. + ### Optional fields ```yaml @@ -205,6 +217,8 @@ its libretro layout whatever the platform calls it. zip_digest: composite # the frontend hashes a .zip by its members only firmware_folder_flat: true # the frontend lists each system folder without # descending into it + native_list: none # the platform's code names no BIOS file, so no + # exporter hands one back source_wiki: https://... # human-readable upstream reference target_scraper: myplatform_targets # hardware target scraper name, null if static target_source: https://... # target data source URL @@ -220,6 +234,18 @@ its libretro layout whatever the platform calls it. parse_key: bios_directory ``` +A frontend whose BIOS live on its SD card is installed from the computer the +card is mounted on. Its rule is `os: any`, `method: card_marker`, with `file` +(a path below the card root) and `first_line_prefix`: `install.py` reads that +file under `--dest` and names the platform when its first line starts with the +prefix. MinUI, NextUI and MyMinUI write their release name to +`.system/version.txt`. + +`native_list: none` marks a platform whose own code publishes no BIOS list +(the MinUI family hands each core a folder and names no file in it). The test +that every registered platform has an exporter exempts it, and a second test +holds that its platform YAML declares no file. + `zip_digest: composite` is set where the frontend's own check takes `Zip::Md5Composite` for every `.zip` and never the container's MD5 (Recalbox, `Bios.cpp:64-68`). Each `.zip` entry is then checked against its composite alone, @@ -410,7 +436,7 @@ to use: | Mode | Behavior | Example platforms | |------|----------|-------------------| -| `existence` | File must exist, no hash check | RetroArch, Lakka, RetroPie | +| `existence` | File must exist, no hash check | RetroArch, Lakka, RetroPie, MinUI, NextUI, MyMinUI | | `md5` | MD5 must match the declared hash | Batocera, Recalbox, RetroBat, EmuDeck, RetroDECK, RomM, ROCKNIX, MiSTer FPGA | | `sha1` | SHA1 must match | BizHawk | diff --git a/wiki/faq.md b/wiki/faq.md index eba96b3d..b68d6833 100644 --- a/wiki/faq.md +++ b/wiki/faq.md @@ -220,7 +220,7 @@ that the file exists. `untested` means the file exists on disk but its hash does not match the expected value. This happens on MD5 and SHA1 platforms (Batocera, Recalbox, BizHawk, ROCKNIX, MiSTer FPGA, and the rest) when the file is present but contains different data than what the platform declares. -On existence-mode platforms (RetroArch, Lakka, RetroPie), files are never `untested` because the platform only checks presence, not content. Those files show as `ok` if present, whatever they contain. +On existence-mode platforms (RetroArch, Lakka, RetroPie, MinUI, NextUI, MyMinUI), files are never `untested` because the platform only checks presence, not content. Those files show as `ok` if present, whatever they contain. Running `verify.py --emulator --verbose` shows the emulator-level ground truth, which can confirm whether the file's hash matches what the source code expects. The platform report applies the same check on its own: `verify.py --platform retroarch` prints a `DISCREPANCY` line for every file the platform accepts and a profiled emulator rejects. diff --git a/wiki/getting-started.md b/wiki/getting-started.md index cb2ee08a..cbe79081 100644 --- a/wiki/getting-started.md +++ b/wiki/getting-started.md @@ -209,6 +209,33 @@ Files go under the per-core subdirectory the MiSTer BIOS database declares lists with a download URL are in scope: the rest ship with the MiSTer distribution and install themselves. +### MinUI, NextUI and MyMinUI + +``` +/Bios// +``` + +These frontends give each core a folder of its own, named after the tag in +parentheses of the Roms folder the game sits in: a game in +`Roms/Sony PlayStation (PS)/` runs with `Bios/PS/` as its system directory, one +in `Roms/Game Boy (GB)/` with `Bios/GB/` (MinUI +`workspace/all/minarch/minarch.c:2943`, NextUI +`workspace/all/minarch/ma_core.c:76`, MyMinUI +`workspace/all/minarch/minarch.c:3532`). The pack holds the `Bios/` folder: +extract it at the root of the card. A core started from two tags (Gambatte for +GB and GBC) gets its files in both folders. + +The installer runs on the computer the card is mounted on, with the card's root +as `--dest`: + +```bash +curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --platform minui --dest /media/$USER/CARD +``` + +Without `--platform`, it reads the release name the frontend writes in +`.system/version.txt` on the card. A pak that starts a standalone program +instead of a libretro core (DraStic for NDS, native PICO-8) is not covered. + ### BizHawk ``` @@ -246,7 +273,8 @@ dump shows up. The installer's `--check` hashes every file against the list on every platform, so it catches a wrong revision even where the frontend would not: -RetroArch, Lakka and RetroPie only check that a file exists, while Batocera, +RetroArch, Lakka, RetroPie, MinUI, NextUI and MyMinUI only check that a file +exists, while Batocera, RetroBat, Recalbox, EmuDeck, RetroDECK, RomM, ROCKNIX and MiSTer FPGA compare MD5 and BizHawk compares SHA1. diff --git a/wiki/installer.md b/wiki/installer.md index 57dae073..9ccb91de 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -322,6 +322,11 @@ not already found that way. | `%USERPROFILE%\BizHawk\Firmware` | BizHawk | the same | | `EmuHawk.exe` or `EmuHawkMono.sh` in a `BizHawk*` folder of the home folder or one level down (`~/Games/BizHawk-2.11.1`), the newest | BizHawk | `Firmware` beside it | +A `--dest` given without `--platform` is first read as an SD card: the MinUI +family writes the release name as the first line of `.system/version.txt` at +the card root (`MinUI-`, `NextUI-`, `MyMinUI-`), and that line names the file +list. + Desktop installs are read from their own configuration, and several can be found on one machine. @@ -393,7 +398,11 @@ for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms/bios` for ROCKNIX, `C:\RetroBat\bios` for RetroBat, and RetroArch's own default system directory for RetroArch. BizHawk has no such folder: it reads `Firmware` beside its executable wherever the archive was extracted, so a forced -`--platform bizhawk` that finds no install stops and asks for `--dest`. On Android a forced `--platform retroarch` falls back to +`--platform bizhawk` that finds no install stops and asks for `--dest`. MinUI, +NextUI and MyMinUI keep their files on the SD card, which the installer reads +from a computer it is mounted on: a forced `--platform minui` stops and asks +for the card's root as `--dest`, and a `--dest` that is a folder inside such a +card (its `Bios/`) is refused with the root to name instead. On Android a forced `--platform retroarch` falls back to `/RetroArch/system` instead, a home directory there belonging to Termux rather than to the frontend. Pass `--dest` to say where instead of relying on that. diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index b3a50f9c..fc3b9160 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -40,7 +40,7 @@ Every module in `tests/`, by the area it guards. | Core regression suites | synthetic | `test_e2e`, `test_audit_regressions`, `test_tool_messages` | | File resolution and identity | synthetic + collection | `test_adler32_resolution`, `test_agnostic_scan`, `test_agnostic_scan_mode`, `test_agnostic_scope`, `test_agnostic_substitute`, `test_archive_choice`, `test_bios_companions`, `test_bundled_identity`, `test_casefold_cache`, `test_db_aliases`, `test_declared_names`, `test_derivations`, `test_directory_entries`, `test_extra_identity`, `test_foreign_tree`, `test_foreign_tree_names`, `test_frontend_digest`, `test_game_data_homonyms`, `test_hashing`, `test_identity`, `test_md5_pack_context`, `test_name_tiebreak`, `test_nec_filing`, `test_owner_resolution`, `test_path_suffix`, `test_resolution_evidence`, `test_sega_filing`, `test_shipped_content`, `test_twin_declarations`, `test_validated_choice`, `test_variant_choice`, `test_variant_identity`, `test_zipped_member` | | Verification, cross-reference and profile checks | synthetic + profiles | `test_case_insensitive_platforms`, `test_checks_have_values`, `test_core_file_fills_declaration`, `test_core_size_refusal`, `test_cross_reference_sources`, `test_crypto_aes`, `test_crypto_unique_data`, `test_crypto_verify`, `test_file_consumers`, `test_fileless_audit`, `test_flycast_layout`, `test_gap_settlement`, `test_hidden_extras`, `test_launcher_aliases`, `test_launcher_layer`, `test_native_mode`, `test_no_case_collisions`, `test_path_conflict_status`, `test_path_null_copies`, `test_profile_checks`, `test_profile_repositories`, `test_romm_hash_rule`, `test_shared_groups`, `test_standalone_links`, `test_standalone_mode`, `test_undeclared_dedup`, `test_unplaced_extras`, `test_validation_scope`, `test_verify_all_dir`, `test_verify_emulator_evidence`, `test_verify_listing`, `test_verify_pack_choice`, `test_verify_release_asset` | -| Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_pack_conformance_dirs`, `test_pack_core_extras`, `test_pack_counts`, `test_pack_destinations`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_required_only_archives`, `test_required_only_twins`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` | +| Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_minui_platform`, `test_pack_conformance_dirs`, `test_pack_core_extras`, `test_pack_counts`, `test_pack_destinations`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_required_only_archives`, `test_required_only_twins`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` | | Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` | | Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` | | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_selection` | diff --git a/wiki/tools.md b/wiki/tools.md index 00bfe665..bf00832d 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -145,6 +145,7 @@ Verification modes per platform: | ROCKNIX | md5 | MD5 hash match, same shape as Batocera | | MiSTer FPGA | md5 | MD5 of the file at its destination, no ZIP inspection | | BizHawk | sha1 | SHA1 per firmware from `FirmwareDatabase.cs` | +| MinUI, NextUI, MyMinUI | existence | none: minarch hands each core `Bios//` and opens no file | Full details and severity mapping: [verification modes](verification-modes.md). `--region` narrows the report to the file set a regional pack would carry, and a @@ -848,6 +849,7 @@ stand alone. | `rocknix_scraper` | rocknix-systems script | Python dict | | `retropie_scraper` | RetroPie-Setup scriptmodules (`rp_module_id`, `rp_module_help`) and `retropie_packages.sh` (version) | Bash | | `misterfpga_scraper` | BiosDB_MiSTer bios_db.json.zip | JSON in ZIP | +| `minui_scraper`, `nextui_scraper`, `myminui_scraper` | each pak's `launch.sh` (`EMU_EXE`, the tag from the pak folder), each device's `cores/makefile` (the repository a library is built from) and the minarch statement that sets `core.bios_dir` | Shell, Make, C | | `coreinfo_scraper` | .info files from libretro-core-info | INI-like | | `bizhawk_scraper` | FirmwareDatabase.cs | C# source | | `mame_hash_scraper` | mamedev/mame source tree. `--release mameNNNN` writes the profile of one release a platform builds. `--devices --listxml FILE` adds the device ROM sets of the release's `-listxml` | C source (sparse clone), listxml | diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index ce099d3b..40226bc9 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -67,6 +67,9 @@ Each platform expects BIOS files in a specific base directory: - EmuDeck: `~/Emulation/bios/` - RetroDECK: `~/retrodeck/bios/` - RetroBat: `bios/` inside the RetroBat directory +- MinUI, NextUI, MyMinUI: `Bios//` at the root of the SD card, one folder + per tag in parentheses of the Roms folders (`Bios/PS/` for + `Roms/Sony PlayStation (PS)/`) Some cores expect files in subdirectories (e.g. `dc/` for Dreamcast, `pcsx2/bios/` for PlayStation 2). Check the `path:` field in the emulator profile for the exact diff --git a/wiki/verification-modes.md b/wiki/verification-modes.md index efaee1fe..33afd0e2 100644 --- a/wiki/verification-modes.md +++ b/wiki/verification-modes.md @@ -6,9 +6,9 @@ of each platform so that verification results match what the platform itself wou ## Existence mode -**Platforms**: RetroArch, Lakka, RetroPie +**Platforms**: RetroArch, Lakka, RetroPie, MinUI, NextUI, MyMinUI -**Source**: RetroArch `core_info.c`, function `path_is_valid()` +**Source**: RetroArch `core_info.c`, function `path_is_valid()`; minarch for the MinUI family The most straightforward mode. A file is OK if it exists at the expected path. No hash is checked. Any file with the correct name passes, regardless of content. @@ -25,6 +25,13 @@ upstream code: `core_info.c` only calls `path_is_valid()` and does not open or h Lakka and RetroPie inherit this behavior through platform config inheritance (`inherits: retroarch` in the platform YAML). +MinUI, NextUI and MyMinUI check nothing at all: minarch returns the folder +`Bios//` for `RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY` and never opens a +file in it (MinUI `workspace/all/minarch/minarch.c:1889-1893` and `:2943`). The +core decides what it loads, so the files are the ones its profile documents, +placed below each folder a pak hands it (`core_system_dirs` in the platform +YAML). + ## MD5 mode