diff --git a/scripts/exporter/emudeck_exporter.py b/scripts/exporter/emudeck_exporter.py index 7e462c60..fea1a790 100644 --- a/scripts/exporter/emudeck_exporter.py +++ b/scripts/exporter/emudeck_exporter.py @@ -1,7 +1,12 @@ """Exporter for EmuDeck checkBIOS.sh format. -Produces a bash script compatible with EmuDeck's checkBIOS.sh, -containing MD5 hash arrays and per-system check functions. +Produces a bash script matching the exact pattern of EmuDeck's +functions/checkBIOS.sh: per-system check functions with MD5 arrays +inside the function body, iterating over $biosPath/* files. + +Two patterns: +- MD5 pattern: systems with known hashes, loop $biosPath/*, md5sum each, match +- File-exists pattern: systems with specific paths, check -f """ from __future__ import annotations @@ -11,47 +16,112 @@ from pathlib import Path from .base_exporter import BaseExporter -# System slug -> (bash array name, check function name) -_SYSTEM_BASH_MAP: dict[str, tuple[str, str]] = { - "sony-playstation": ("PSBios", "checkPS1BIOS"), - "sony-playstation-2": ("PS2Bios", "checkPS2BIOS"), - "sega-mega-cd": ("CDBios", "checkSegaCDBios"), - "sega-saturn": ("SaturnBios", "checkSaturnBios"), - "sega-dreamcast": ("DCBios", "checkDreamcastBios"), - "nintendo-ds": ("DSBios", "checkDSBios"), +# Map our system IDs to EmuDeck function naming conventions +_SYSTEM_CONFIG: dict[str, dict] = { + "sony-playstation": { + "func": "checkPS1BIOS", + "var": "PSXBIOS", + "array": "PSBios", + "pattern": "md5", + }, + "sony-playstation-2": { + "func": "checkPS2BIOS", + "var": "PS2BIOS", + "array": "PS2Bios", + "pattern": "md5", + }, + "sega-mega-cd": { + "func": "checkSegaCDBios", + "var": "SEGACDBIOS", + "array": "CDBios", + "pattern": "md5", + }, + "sega-saturn": { + "func": "checkSaturnBios", + "var": "SATURNBIOS", + "array": "SaturnBios", + "pattern": "md5", + }, + "sega-dreamcast": { + "func": "checkDreamcastBios", + "var": "BIOS", + "array": "hashes", + "pattern": "md5", + }, + "nintendo-ds": { + "func": "checkDSBios", + "var": "BIOS", + "array": "hashes", + "pattern": "md5", + }, + "nintendo-switch": { + "func": "checkCitronBios", + "pattern": "file-exists", + "firmware_path": "$biosPath/citron/firmware", + "keys_path": "$biosPath/citron/keys/prod.keys", + }, } -def _slug_to_bash_name(slug: str) -> str: - """Convert a system slug to a CamelCase bash identifier.""" - parts = slug.split("-") - return "".join(p.capitalize() for p in parts) + "Bios" +def _make_md5_function(cfg: dict, md5s: list[str]) -> list[str]: + """Generate a MD5-checking function matching EmuDeck's exact pattern.""" + func = cfg["func"] + var = cfg["var"] + array = cfg["array"] + md5_str = " ".join(md5s) + + return [ + f"{func}(){{", + "", + f'\t{var}="NULL"', + "", + '\tfor entry in "$biosPath/"*', + "\tdo", + '\t\tif [ -f "$entry" ]; then', + '\t\t\tmd5=($(md5sum "$entry"))', + f'\t\t\tif [[ "${var}" != true ]]; then', + f"\t\t\t\t{array}=({md5_str})", + f'\t\t\t\tfor i in "${{{array}[@]}}"', + "\t\t\t\tdo", + '\t\t\t\tif [[ "$md5" == *"${i}"* ]]; then', + f"\t\t\t\t\t{var}=true", + "\t\t\t\t\tbreak", + "\t\t\t\telse", + f"\t\t\t\t\t{var}=false", + "\t\t\t\tfi", + "\t\t\t\tdone", + "\t\t\tfi", + "\t\tfi", + "\tdone", + "", + "", + f"\tif [ ${var} == true ]; then", + '\t\techo "$entry true";', + "\telse", + '\t\techo "false";', + "\tfi", + "}", + ] -def _slug_to_func_name(slug: str) -> str: - """Convert a system slug to a check function name.""" - parts = slug.split("-") - return "check" + "".join(p.capitalize() for p in parts) + "Bios" +def _make_file_exists_function(cfg: dict) -> list[str]: + """Generate a file-exists function matching EmuDeck's pattern.""" + func = cfg["func"] + firmware = cfg.get("firmware_path", "") + keys = cfg.get("keys_path", "") - -def _collect_md5s(files: list[dict]) -> list[str]: - """Extract unique MD5 hashes from file entries.""" - hashes: list[str] = [] - seen: set[str] = set() - for fe in files: - md5 = fe.get("md5", "") - if isinstance(md5, list): - for h in md5: - h_lower = h.lower() - if h_lower and h_lower not in seen: - seen.add(h_lower) - hashes.append(h_lower) - elif md5: - h_lower = md5.lower() - if h_lower not in seen: - seen.add(h_lower) - hashes.append(h_lower) - return hashes + return [ + f"{func}(){{", + "", + f'\tlocal FIRMWARE="{firmware}"', + f'\tlocal KEYS="{keys}"', + '\tif [[ -f "$KEYS" ]] && [[ "$( ls -A "$FIRMWARE")" ]]; then', + '\t\t\techo "true";', + "\telse", + '\t\t\techo "false";', + "\tfi", + "}", + ] class Exporter(BaseExporter): @@ -67,135 +137,71 @@ class Exporter(BaseExporter): output_path: str, scraped_data: dict | None = None, ) -> None: + lines: list[str] = ["#!/bin/bash"] + systems = truth_data.get("systems", {}) - # Collect per-system hash arrays and file lists - sys_hashes: dict[str, list[str]] = {} - sys_files: dict[str, list[dict]] = {} - for sys_id in sorted(systems): - files = systems[sys_id].get("files", []) - valid_files = [ - f for f in files - if not f.get("name", "").startswith("_") - and not self._is_pattern(f.get("name", "")) - ] - if not valid_files: + for sys_id, cfg in sorted(_SYSTEM_CONFIG.items(), key=lambda x: x[1]["func"]): + sys_data = systems.get(sys_id) + if not sys_data: continue - sys_files[sys_id] = valid_files - sys_hashes[sys_id] = _collect_md5s(valid_files) - lines: list[str] = [ - "#!/bin/bash", - "# EmuDeck BIOS check script", - "# Generated from retrobios truth data", - "", - ] - - # Emit hash arrays for systems that have MD5s - for sys_id in sorted(sys_hashes): - hashes = sys_hashes[sys_id] - if not hashes: - continue - array_name, _ = _SYSTEM_BASH_MAP.get( - sys_id, (_slug_to_bash_name(sys_id), ""), - ) - lines.append(f"{array_name}=({' '.join(hashes)})") - lines.append("") - - # Emit check functions - for sys_id in sorted(sys_files): - hashes = sys_hashes.get(sys_id, []) - _, func_name = _SYSTEM_BASH_MAP.get( - sys_id, ("", _slug_to_func_name(sys_id)), - ) - - lines.append(f"{func_name}(){{") - - if hashes: - array_name, _ = _SYSTEM_BASH_MAP.get( - sys_id, (_slug_to_bash_name(sys_id), ""), - ) - lines.append(' localRONE="NULL"') - lines.append(' for entry in "$biosPath/"*') - lines.append(" do") - lines.append(' if [ -f "$entry" ]; then') - lines.append(' md5=($(md5sum "$entry"))') - lines.append( - f' for hash in "${{{array_name}[@]}}"; do', - ) - lines.append( - ' if [[ "$md5" == *"${hash}"* ]]; then', - ) - lines.append(' RONE=true') - lines.append(" fi") - lines.append(" done") - lines.append(" fi") - lines.append(" done") - lines.append(' if [ $RONE == true ]; then') - lines.append(' echo "true"') - lines.append(" else") - lines.append(' echo "false"') - lines.append(" fi") - else: - # No MD5 hashes — check file existence - for fe in sys_files[sys_id]: - dest = self._dest(fe) - if dest: - lines.append( - f' if [ -f "$biosPath/{dest}" ]; then', - ) - lines.append(' echo "true"') - lines.append(" return") - lines.append(" fi") - lines.append(' echo "false"') - - lines.append("}") lines.append("") - # Emit setBIOSstatus aggregator - lines.append("setBIOSstatus(){") - for sys_id in sorted(sys_files): - _, func_name = _SYSTEM_BASH_MAP.get( - sys_id, ("", _slug_to_func_name(sys_id)), - ) - var = re.sub(r"^check", "", func_name) - var = re.sub(r"Bios$", "BIOS", var) - var = re.sub(r"BIOS$", "_bios", var) - lines.append(f" {var}=$({func_name})") - lines.append("}") - lines.append("") + if cfg["pattern"] == "md5": + md5s: list[str] = [] + for fe in sys_data.get("files", []): + name = fe.get("name", "") + if self._is_pattern(name) or name.startswith("_"): + continue + md5 = fe.get("md5", "") + if isinstance(md5, list): + md5s.extend(m for m in md5 if m and re.fullmatch(r"[a-f0-9]{32}", m)) + elif md5 and re.fullmatch(r"[a-f0-9]{32}", md5): + md5s.append(md5) + if md5s: + lines.extend(_make_md5_function(cfg, md5s)) + elif cfg["pattern"] == "file-exists": + lines.extend(_make_file_exists_function(cfg)) + lines.append("") Path(output_path).write_text("\n".join(lines), encoding="utf-8") def validate(self, truth_data: dict, output_path: str) -> list[str]: content = Path(output_path).read_text(encoding="utf-8") issues: list[str] = [] - for sys_id, sys_data in truth_data.get("systems", {}).items(): - files = sys_data.get("files", []) - valid_files = [ - f for f in files - if not f.get("name", "").startswith("_") - and not self._is_pattern(f.get("name", "")) - ] - if not valid_files: + systems = truth_data.get("systems", {}) + for sys_id, cfg in _SYSTEM_CONFIG.items(): + if cfg["pattern"] != "md5": continue - - # Check that MD5 hashes appear in the output - for fe in valid_files: + sys_data = systems.get(sys_id) + if not sys_data: + continue + for fe in sys_data.get("files", []): md5 = fe.get("md5", "") if isinstance(md5, list): - for h in md5: - if h and h.lower() not in content: - issues.append(f"missing hash: {h} ({sys_id})") - elif md5 and md5.lower() not in content: - issues.append(f"missing hash: {md5} ({sys_id})") + md5 = md5[0] if md5 else "" + if md5 and re.fullmatch(r"[a-f0-9]{32}", md5) and md5 not in content: + issues.append(f"missing md5: {md5} ({fe.get('name', '')})") - # Check that a check function exists for this system - _, func_name = _SYSTEM_BASH_MAP.get( - sys_id, ("", _slug_to_func_name(sys_id)), - ) - if func_name not in content: - issues.append(f"missing function: {func_name} ({sys_id})") + for sys_id, cfg in _SYSTEM_CONFIG.items(): + func = cfg["func"] + if func in content: + continue + sys_data = systems.get(sys_id) + if not sys_data or not sys_data.get("files"): + continue + # Only flag if the system has usable data for the function type + if cfg["pattern"] == "md5": + has_md5 = any( + fe.get("md5") and isinstance(fe.get("md5"), str) + and re.fullmatch(r"[a-f0-9]{32}", fe["md5"]) + for fe in sys_data["files"] + ) + if has_md5: + issues.append(f"missing function: {func}") + elif cfg["pattern"] == "file-exists": + issues.append(f"missing function: {func}") return issues