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()