mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
The Start here pages ran 460 to 2,100 words and the first command of Get your files sat 254 words down. Each page now opens on its action, with short sentences and one fact per block. The facts a newcomer does not act on moved one level down: the legal analysis to wiki/legal.md, the project terms to wiki/reference-terms.md, the annotated run and the installer failures to the installer page, collection figures to a new Explore index. tests/test_site_copy.py holds the budgets on the generated pages, and the testing guide states them with their reasons. Four statements the repository contradicts are rewritten: prboom.wad is compiled into PrBoom (emulators/prboom.yml), Batocera reports a mismatch as UNTESTED (batocera-systems checkBios), 13 hle_fallback entries are required, and the gaps headline splits missing firmware from game data and unread entries.
437 lines
18 KiB
Python
437 lines
18 KiB
Python
"""MinUI, NextUI and MyMinUI hand each core a system directory of its own.
|
|
|
|
minarch sets ``core.bios_dir`` to SDCARD_PATH "/Bios/<TAG>", 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/<TAG>/."""
|
|
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)
|
|
|
|
class TheSitePageOfACardPlatform(unittest.TestCase):
|
|
"""A platform that names no file showed "0/0 Coverage (0%)" and an empty
|
|
systems table, which read as "not supported". Its page lists the folders
|
|
the frontend hands its cores and the files the pack puts in each."""
|
|
|
|
def test_folders_replace_the_empty_coverage(self):
|
|
import generate_site
|
|
from common import load_database, load_platform_registry
|
|
from generate_readme import compute_coverage
|
|
|
|
if not (REPO_ROOT / "database.json").is_file():
|
|
self.skipTest("database.json absent")
|
|
registry = load_platform_registry(str(REPO_ROOT / "platforms"))
|
|
db = load_database(str(REPO_ROOT / "database.json"))
|
|
cov = compute_coverage("minui", str(REPO_ROOT / "platforms"), db)
|
|
page = generate_site.generate_platform_page("minui", cov, registry, {})
|
|
self.assertNotIn("0/0", page)
|
|
self.assertNotIn("## Systems overview", page)
|
|
self.assertIn("| `Bios/PS/` | [pcsx_rearmed](../emulators/pcsx_rearmed.md) |", page)
|
|
self.assertIn("| Unzip the pack into | `<SD card>/` |", page)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|