From d5daf98e5eb72436b9abc96b107bb3168658032a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:51:52 +0100 Subject: [PATCH] feat: hle_fallback field + launcher filtering in verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added hle_fallback: true/false per file in emulator profiles. When a core has HLE and the file is missing, severity downgrades to INFO instead of CRITICAL — core works without it. verify.py builds an HLE index from emulator profiles and applies it during severity computation. Cross-reference now skips launcher profiles (type: launcher) and includes hle_fallback in undeclared file reports. 33 E2E tests (4 new: HLE severity, HLE index, launcher skip, cross-ref HLE). 0 regressions. Based on source code analysis: - RetroArch core_info.c:2233 — existence check only, no blocking - PCSX ReARMed psxbios.c:28 — full HLE BIOS replacement - Dolphin CommonPaths.h — all files optional with HLE - snes9x — DSP HLE built-in, coprocessor files optional --- scripts/verify.py | 41 +++++++++++++++++++++++------- tests/test_e2e.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/scripts/verify.py b/scripts/verify.py index 042dd0ed..c1149290 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -150,17 +150,24 @@ def verify_entry_md5( # Severity mapping per platform # --------------------------------------------------------------------------- -def compute_severity(status: str, required: bool, mode: str) -> str: - """Map (status, required, verification_mode) → severity. +def compute_severity( + status: str, required: bool, mode: str, hle_fallback: bool = False, +) -> str: + """Map (status, required, verification_mode, hle_fallback) → severity. - Based on native platform behavior: + Based on native platform behavior + emulator HLE capability: - RetroArch (existence): required+missing = warning, optional+missing = info - - Batocera (md5): no required distinction — all equal (batocera-systems has no mandatory field) - - Recalbox (md5): mandatory+missing = critical, optional+missing = warning (Bios.cpp:109-130) + - Batocera (md5): no required distinction (batocera-systems has no mandatory field) + - Recalbox (md5): mandatory+missing = critical, optional+missing = warning + - hle_fallback: core works without this file via HLE → always INFO when missing """ if status == Status.OK: return Severity.OK + # HLE fallback: core works without this file regardless of platform requirement + if hle_fallback and status == Status.MISSING: + return Severity.INFO + if mode == "existence": if status == Status.MISSING: return Severity.WARNING if required else Severity.INFO @@ -170,7 +177,7 @@ def compute_severity(status: str, required: bool, mode: str) -> str: if status == Status.MISSING: return Severity.CRITICAL if required else Severity.WARNING if status == Status.UNTESTED: - return Severity.WARNING if required else Severity.WARNING + return Severity.WARNING return Severity.OK @@ -213,6 +220,9 @@ def find_undeclared_files( undeclared = [] seen = set() for emu_name, profile in sorted(profiles.items()): + # Skip launchers — they don't use system_dir for BIOS + if profile.get("type") == "launcher": + continue emu_systems = set(profile.get("systems", [])) # Only check emulators whose systems overlap with this platform if not emu_systems & platform_systems: @@ -240,6 +250,7 @@ def find_undeclared_files( "emulator": profile.get("emulator", emu_name), "name": fname, "required": f.get("required", False), + "hle_fallback": f.get("hle_fallback", False), "in_repo": in_repo, "note": f.get("note", ""), }) @@ -267,6 +278,14 @@ def verify_platform( ) zip_contents = build_zip_contents_index(db) if has_zipped else {} + # Build HLE index from emulator profiles: {filename: True} if any core has HLE for it + profiles = emu_profiles if emu_profiles is not None else load_emulator_profiles(emulators_dir) + hle_index: dict[str, bool] = {} + for profile in profiles.values(): + for f in profile.get("files", []): + if f.get("hle_fallback"): + hle_index[f.get("name", "")] = True + # Per-entry results details = [] # Per-destination aggregation @@ -284,6 +303,7 @@ def verify_platform( else: result = verify_entry_md5(file_entry, local_path, resolve_status) result["system"] = sys_id + result["hle_fallback"] = hle_index.get(file_entry.get("name", ""), False) details.append(result) # Aggregate by destination @@ -296,7 +316,8 @@ def verify_platform( if prev is None or _STATUS_ORDER.get(cur, 0) > _STATUS_ORDER.get(prev, 0): file_status[dest] = cur file_required[dest] = required - sev = compute_severity(cur, required, mode) + hle = hle_index.get(file_entry.get("name", ""), False) + sev = compute_severity(cur, required, mode, hle) prev_sev = file_severity.get(dest) if prev_sev is None or _SEVERITY_ORDER.get(sev, 0) > _SEVERITY_ORDER.get(prev_sev, 0): file_severity[dest] = sev @@ -362,8 +383,9 @@ def print_platform_result(result: dict, group: list[str]) -> None: continue seen_details.add(key) req = "required" if d.get("required", True) else "optional" + hle = ", HLE available" if d.get("hle_fallback") else "" reason = d.get("reason", "") - print(f" UNTESTED ({req}): {key} — {reason}") + print(f" UNTESTED ({req}{hle}): {key} — {reason}") for d in result["details"]: if d["status"] == Status.MISSING: key = f"{d['system']}/{d['name']}" @@ -371,7 +393,8 @@ def print_platform_result(result: dict, group: list[str]) -> None: continue seen_details.add(key) req = "required" if d.get("required", True) else "optional" - print(f" MISSING ({req}): {key}") + hle = ", HLE available" if d.get("hle_fallback") else "" + print(f" MISSING ({req}{hle}): {key}") # Cross-reference: undeclared files used by cores undeclared = result.get("undeclared_files", []) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 750fd0f9..d6b9b1d3 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -280,6 +280,30 @@ class TestE2E(unittest.TestCase): with open(os.path.join(self.emulators_dir, "test_emu.yml"), "w") as fh: yaml.dump(emu, fh) + # Emulator with HLE fallback + emu_hle = { + "emulator": "TestHLE", + "type": "libretro", + "systems": ["console-a"], + "files": [ + {"name": "present_req.bin", "required": True, "hle_fallback": True}, + {"name": "hle_missing.bin", "required": True, "hle_fallback": True}, + {"name": "no_hle_missing.bin", "required": True, "hle_fallback": False}, + ], + } + with open(os.path.join(self.emulators_dir, "test_hle.yml"), "w") as fh: + yaml.dump(emu_hle, fh) + + # Launcher profile (should be excluded from cross-reference) + launcher = { + "emulator": "TestLauncher", + "type": "launcher", + "systems": ["console-a"], + "files": [{"name": "launcher_bios.bin", "required": True}], + } + with open(os.path.join(self.emulators_dir, "test_launcher.yml"), "w") as fh: + yaml.dump(launcher, fh) + # Alias profile (should be skipped) alias = {"emulator": "TestAlias", "type": "alias", "alias_of": "test_emu", "files": []} with open(os.path.join(self.emulators_dir, "test_alias.yml"), "w") as fh: @@ -455,6 +479,46 @@ class TestE2E(unittest.TestCase): # dd_covered.bin from TestEmuDD should NOT appear (data_dir match) self.assertNotIn("dd_covered.bin", names) + def test_44_cross_ref_skips_launchers(self): + config = load_platform_config("test_existence", self.platforms_dir) + profiles = load_emulator_profiles(self.emulators_dir) + undeclared = find_undeclared_files(config, self.emulators_dir, self.db, profiles) + names = {u["name"] for u in undeclared} + # launcher_bios.bin from TestLauncher should NOT appear + self.assertNotIn("launcher_bios.bin", names) + + def test_45_hle_fallback_downgrades_severity(self): + """Missing file with hle_fallback=true → INFO severity, not CRITICAL.""" + from verify import compute_severity, Severity + # required + missing + NO HLE = CRITICAL + sev = compute_severity("missing", True, "md5", hle_fallback=False) + self.assertEqual(sev, Severity.CRITICAL) + # required + missing + HLE = INFO + sev = compute_severity("missing", True, "md5", hle_fallback=True) + self.assertEqual(sev, Severity.INFO) + # required + missing + HLE + existence mode = INFO + sev = compute_severity("missing", True, "existence", hle_fallback=True) + self.assertEqual(sev, Severity.INFO) + + def test_46_hle_index_built_from_emulator_profiles(self): + """verify_platform reads hle_fallback from emulator profiles.""" + config = load_platform_config("test_existence", self.platforms_dir) + profiles = load_emulator_profiles(self.emulators_dir) + result = verify_platform(config, self.db, self.emulators_dir, profiles) + # present_req.bin has hle_fallback: true in TestHLE profile + for d in result["details"]: + if d["name"] == "present_req.bin": + self.assertTrue(d.get("hle_fallback", False)) + break + + def test_47_cross_ref_shows_hle_on_undeclared(self): + """Undeclared files include hle_fallback from emulator profile.""" + config = load_platform_config("test_existence", self.platforms_dir) + profiles = load_emulator_profiles(self.emulators_dir) + undeclared = find_undeclared_files(config, self.emulators_dir, self.db, profiles) + hle_files = {u["name"] for u in undeclared if u.get("hle_fallback")} + self.assertIn("hle_missing.bin", hle_files) + def test_50_platform_grouping_identical(self): groups = group_identical_platforms( ["test_existence", "test_inherited"], self.platforms_dir