From 887fc34bf6743846942cd3c5a28de51c4e5bced3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:35:59 +0200 Subject: [PATCH] feat: lay out standalone files by build mode --- platforms/recalbox.yml | 32 +++++++++++++++++-------- scripts/packextras.py | 5 ++-- scripts/scraper/recalbox_scraper.py | 36 ++++++++++++++++++----------- scripts/slots.py | 5 ++-- scripts/verify.py | 10 +++----- tests/test_e2e.py | 21 +++++++++++++++++ tests/test_scraper_contract.py | 19 +++++++++++++++ tests/test_slots.py | 16 +++++++++++-- 8 files changed, 107 insertions(+), 37 deletions(-) diff --git a/platforms/recalbox.yml b/platforms/recalbox.yml index d322cf18..490d517b 100644 --- a/platforms/recalbox.yml +++ b/platforms/recalbox.yml @@ -1,7 +1,7 @@ platform: Recalbox -version: '10.1' +version: 10.1.1 homepage: "https://www.recalbox.com" -source: "https://gitlab.com/recalbox/recalbox/-/raw/10.1/board/recalbox/fsoverlay/recalbox/share_init/system/.emulationstation/es_bios.xml" +source: "https://gitlab.com/recalbox/recalbox/-/raw/10.1.1/board/recalbox/fsoverlay/recalbox/share_init/system/.emulationstation/es_bios.xml" base_destination: bios hash_type: md5 verification_mode: md5 @@ -14,6 +14,7 @@ cores: - libretro-hatari - libretro/ mame2003-plus - libretro/a5200 +- libretro/amiberry - libretro/atari800 - libretro/bk - libretro/bluemsx @@ -98,6 +99,19 @@ cores: - ti99sim - xemu - xroar +standalone_cores: +- amiberry +- dolphin +- duckstation +- gsplus +- hatari +- oricutron +- pcsx2 +- reicast +- scummvm +- ti99sim +- xemu +- xroar systems: commodore-amiga: files: @@ -113,7 +127,7 @@ systems: required: false md5: 82a21c1890cae844b3df741f2762d48d native_system: amiga600 - core: amiberry,libretro/uae,libretro/uae4arm,libretro/uae4all + core: amiberry,libretro/amiberry,libretro/uae,libretro/uae4arm,libretro/uae4all mandatory_declared: false native_name: "Amiga 600 (ECS/OCS)" - name: kick37175.A500 @@ -128,7 +142,7 @@ systems: required: false md5: e40a5dfb3d017ba8779faba30cbd1c8e native_system: amiga600 - core: amiberry,libretro/uae,libretro/uae4arm + core: amiberry,libretro/amiberry,libretro/uae,libretro/uae4arm mandatory_declared: false native_name: "Amiga 600 (ECS/OCS)" - name: kick02019.AROS.ext @@ -161,14 +175,14 @@ systems: required: true md5: 646773759326fbac3b2311fd8c8793ee native_system: amiga1200 - core: amiberry,libretro/uae,libretro/uae4arm + core: amiberry,libretro/amiberry,libretro/uae,libretro/uae4arm native_name: "Amiga 1200 (AGA)" - name: kick40068.A4000 destination: amiga/bios/kick40068.A4000 required: true md5: 9bdedde6a4f33555b4a270c8ca53297d native_system: amiga1200 - core: amiberry,libretro/uae + core: amiberry,libretro/amiberry,libretro/uae native_name: "Amiga 1200 (AGA)" note: This bios is required only for A4000 emulation - name: kick34005.CDTV @@ -176,7 +190,7 @@ systems: required: true md5: 89da1838a24460e4b93f4f0c5d92d48d,d98112f18792ee3714df16a6eb421b89,d1145ab3a0f89340f94c9e734762c198 native_system: amigacdtv - core: amiberry,libretro/uae,libretro/uae4arm + core: amiberry,libretro/amiberry,libretro/uae,libretro/uae4arm hash_match_mandatory: true mandatory_declared: true native_name: Amiga CDTV @@ -186,7 +200,7 @@ systems: required: true md5: f2f241bf094168cfb9e7805dc2856433,5f8924d013dd57a89cf349f4cdedc6b1 native_system: amigacd32 - core: amiberry,libretro/uae,libretro/uae4arm + core: amiberry,libretro/amiberry,libretro/uae,libretro/uae4arm hash_match_mandatory: false mandatory_declared: true native_name: Amiga CD32 @@ -197,7 +211,7 @@ systems: required: false md5: bb72565701b1b6faece07d68ea5da639 native_system: amigacd32 - core: amiberry,libretro/uae,libretro/uae4arm + core: amiberry,libretro/amiberry,libretro/uae,libretro/uae4arm mandatory_declared: false native_name: Amiga CD32 note: Extended ROM rev 40.060 diff --git a/scripts/packextras.py b/scripts/packextras.py index e989e952..0765cda2 100644 --- a/scripts/packextras.py +++ b/scripts/packextras.py @@ -10,6 +10,7 @@ from common import expand_platform_declared_names from common import load_emulator_profiles from common import resolution_is_hash_exact from common import resolve_local_file +from common import runs_standalone from common import sanitize_pack_path def _emulator_systems_index(emu_profiles: dict | None) -> dict[str, list[str]]: """Map both the profile key and its display name to the profile's systems. @@ -469,9 +470,7 @@ def _collect_emulator_extras( continue if emu_name not in relevant: continue - is_standalone = emu_name in standalone_set or bool( - standalone_set & {str(c) for c in profile.get("cores", [])} - ) + is_standalone = runs_standalone(emu_name, profile, standalone_set) for f in profile.get("files", []): fname = f.get("name", "") if not fname: diff --git a/scripts/scraper/recalbox_scraper.py b/scripts/scraper/recalbox_scraper.py index c3854de3..ecd60e88 100644 --- a/scripts/scraper/recalbox_scraper.py +++ b/scripts/scraper/recalbox_scraper.py @@ -99,26 +99,34 @@ SYSTEM_SLUG_MAP = { } +def split_cores(names) -> tuple[list[str], list[str]]: + """(every core name, the standalone ones) from es_bios.xml ``core`` values.""" + cores = {name for name in names if name} + standalone = {name for name in cores if not name.startswith("libretro")} + return sorted(cores), sorted(standalone) + + class Scraper(BaseScraper): """Scraper for Recalbox es_bios.xml.""" def __init__(self, url: str = SOURCE_URL): super().__init__(url=url) - def _fetch_cores(self) -> list[str]: - """Extract unique core names from es_bios.xml bios elements.""" + def _fetch_cores(self) -> tuple[list[str], list[str]]: + """Core names from es_bios.xml, and those Recalbox runs standalone. + + The ``core`` attribute writes a libretro core as ``libretro/`` + and a standalone emulator by its bare name (``dolphin``, ``pcsx2``, + ``xemu``), so the prefix is the build mode. One entry spells + ``libretro-hatari``; a name that starts with ``libretro`` is a core. + """ raw = self._fetch_raw() root = parse_untrusted_xml(raw, "es_bios.xml") - cores: set[str] = set() - for bios_elem in root.findall(".//system/bios"): - raw_core = bios_elem.get("core", "").strip() - if not raw_core: - continue - for part in raw_core.split(","): - name = part.strip() - if name: - cores.add(name) - return sorted(cores) + return split_cores( + part.strip() + for bios_elem in root.findall(".//system/bios") + for part in bios_elem.get("core", "").split(",") + ) def fetch_requirements(self) -> list[BiosRequirement]: """Parse es_bios.xml and return BIOS requirements.""" @@ -213,6 +221,7 @@ class Scraper(BaseScraper): if not version: version = "10.0" + cores, standalone = self._fetch_cores() return { "platform": "Recalbox", "version": version, @@ -221,7 +230,8 @@ class Scraper(BaseScraper): "base_destination": "bios", "hash_type": "md5", "verification_mode": "md5", - "cores": self._fetch_cores(), + "cores": cores, + "standalone_cores": standalone, "systems": systems, } diff --git a/scripts/slots.py b/scripts/slots.py index 0fa9c4bc..ae35cab3 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -24,6 +24,7 @@ from common import ( load_data_dir_registry, resolution_is_hash_exact, resolve_local_file, + runs_standalone, ) # A profile entry can prove a slot without declaring a hash: Dolphin names no @@ -148,9 +149,7 @@ def profile_claims( for emu_name, profile in sorted(profiles.items()): if profile.get("type") in ("launcher", "alias"): continue - is_standalone = emu_name in standalone_cores or bool( - standalone_cores & {str(c) for c in profile.get("cores", [])} - ) + is_standalone = runs_standalone(emu_name, profile, standalone_cores) for entry in profile.get("files") or []: if not isinstance(entry, dict): continue diff --git a/scripts/verify.py b/scripts/verify.py index 7853cad2..1f64c222 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -52,6 +52,7 @@ from common import ( resolve_local_file, ProfileSelectionError, resolve_platform_cores, + runs_standalone, sanitize_pack_path, select_emulator_profiles, ) @@ -455,10 +456,7 @@ def find_undeclared_files( if profile.get("bios_mode") == "agnostic": continue - # Check if this profile is standalone: match profile name or any cores: alias - is_standalone = emu_name in standalone_set or bool( - standalone_set & {str(c) for c in profile.get("cores", [])} - ) + is_standalone = runs_standalone(emu_name, profile, standalone_set) for f in profile.get("files", []): fname = f.get("name", "") @@ -673,9 +671,7 @@ def find_exclusion_notes( # Count standalone-only files -but only report as excluded if the # platform does NOT use this emulator in standalone mode standalone_set = set(str(c) for c in config.get("standalone_cores", [])) - is_standalone = emu_name in standalone_set or bool( - standalone_set & {str(c) for c in profile.get("cores", [])} - ) + is_standalone = runs_standalone(emu_name, profile, standalone_set) if not is_standalone: standalone_files = [ f for f in profile.get("files", []) if f.get("mode") == "standalone" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index ffb1045e..0e469a2a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -6184,5 +6184,26 @@ struct BurnDriver BurnDrvneogeo = { cross_reference.get_mame_clone_map = original +class StandaloneModeRequiresAStandaloneBuild(unittest.TestCase): + """A platform names its standalone emulators by its own names, which can + equal the key of a libretro-only profile (Recalbox and Batocera both + call their standalone ScummVM ``scummvm``). Laid out standalone, such a + profile loses every ``path:`` and drops its files at the root.""" + + def test_libretro_only_profile_keeps_its_layout(self): + from common import runs_standalone + + libretro_only = {"type": "libretro", "cores": ["scummvm"]} + dual = {"type": "standalone + libretro", "cores": ["dolphin"]} + standalone = {"type": "standalone", "cores": ["xemu"]} + named = {"scummvm", "dolphin", "xemu", "hatari"} + self.assertFalse(runs_standalone("scummvm", libretro_only, named)) + self.assertTrue(runs_standalone("dolphin", dual, named)) + self.assertTrue(runs_standalone("xemu", standalone, named)) + self.assertTrue(runs_standalone("other-key", {"type": "standalone", "cores": ["hatari"]}, named)) + self.assertFalse(runs_standalone("dolphin", dual, set())) + self.assertFalse(runs_standalone("dolphin", dual, {"pcsx2"})) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_scraper_contract.py b/tests/test_scraper_contract.py index 6516859f..0e10dbe9 100644 --- a/tests/test_scraper_contract.py +++ b/tests/test_scraper_contract.py @@ -188,5 +188,24 @@ class RetroPiePackageList(unittest.TestCase): self.assertEqual(scraper.fetch_requirements(), []) +class RecalboxBuildMode(unittest.TestCase): + """es_bios.xml names a libretro core as libretro/ and a standalone + emulator by its bare name: the prefix is the build mode, and a platform + that runs an emulator standalone must say so or the pack lays its files + out for the libretro build.""" + + def test_bare_names_are_the_standalone_emulators(self): + from scraper.recalbox_scraper import split_cores + + cores, standalone = split_cores( + ["libretro/uae", "amiberry", "", "dolphin", "libretro/dolphin", "libretro-hatari", "hatari"] + ) + self.assertEqual( + cores, + ["amiberry", "dolphin", "hatari", "libretro-hatari", "libretro/dolphin", "libretro/uae"], + ) + self.assertEqual(standalone, ["amiberry", "dolphin", "hatari"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_slots.py b/tests/test_slots.py index fc48e1f8..efe28173 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -393,7 +393,8 @@ class TestBuildModeDecidesWhoClaims(unittest.TestCase): entry["mode"] = mode if standalone_path: entry["standalone_path"] = standalone_path - return {"ares": {"cores": ["ares"], "files": [entry]}} + return {"ares": {"type": "standalone + libretro", "cores": ["ares"], + "files": [entry]}} def test_a_standalone_only_file_does_not_claim_a_core_slot(self): conflicts = slots.find_conflicts( @@ -428,7 +429,8 @@ class TestBuildModeDecidesWhoClaims(unittest.TestCase): self.assertEqual(claims[0].destination, "elsewhere/IPL.bin") def test_the_mode_follows_a_core_alias_not_only_the_profile_name(self): - profile = {"ares_core": {"cores": ["ares"], + profile = {"ares_core": {"type": "standalone + libretro", + "cores": ["ares"], "files": [{"name": "IPL.bin", "path": "GC/JAP/IPL.bin", "mode": "standalone"}]}} @@ -438,6 +440,16 @@ class TestBuildModeDecidesWhoClaims(unittest.TestCase): ) self.assertEqual(len(conflicts), 1) + def test_a_profile_without_a_standalone_build_keeps_its_core_layout(self): + profile = {"ares": {"type": "libretro", "cores": ["ares"], + "files": [{"name": "IPL.bin", + "path": "GC/JAP/IPL.bin", + "standalone_path": "elsewhere/IPL.bin"}]}} + claims = slots.profile_claims( + profile, REGIONS_DB, standalone_cores={"ares"} + ) + self.assertEqual(claims[0].destination, "GC/JAP/IPL.bin") + class TestProvenEvidence(unittest.TestCase): """What counts as proof that a claim is about content, not about a name."""