feat: hle_fallback field + launcher filtering in verify

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
This commit is contained in:
Abdessamad Derraz
2026-03-19 12:51:52 +01:00
parent 13e5dd37f4
commit d5daf98e5e
2 changed files with 96 additions and 9 deletions

View File

@@ -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", [])