diff --git a/scripts/exporter/__init__.py b/scripts/exporter/__init__.py new file mode 100644 index 00000000..bd9097ee --- /dev/null +++ b/scripts/exporter/__init__.py @@ -0,0 +1,35 @@ +"""Exporter plugin discovery module. + +Auto-detects *_exporter.py files and exposes their exporters. +Each exporter module must define an Exporter class inheriting BaseExporter. +""" + +from __future__ import annotations + +import importlib +import pkgutil +from pathlib import Path + +from .base_exporter import BaseExporter + +_exporters: dict[str, type] = {} + + +def discover_exporters() -> dict[str, type]: + """Auto-discover *_exporter.py modules, return {platform: ExporterClass}.""" + if _exporters: + return _exporters + + package_dir = Path(__file__).parent + + for _finder, name, _ispkg in pkgutil.iter_modules([str(package_dir)]): + if not name.endswith("_exporter") or name == "base_exporter": + continue + + module = importlib.import_module(f".{name}", package=__package__) + exporter_class = getattr(module, "Exporter", None) + + if exporter_class and issubclass(exporter_class, BaseExporter): + _exporters[exporter_class.platform_name()] = exporter_class + + return _exporters diff --git a/scripts/exporter/base_exporter.py b/scripts/exporter/base_exporter.py new file mode 100644 index 00000000..aecac1af --- /dev/null +++ b/scripts/exporter/base_exporter.py @@ -0,0 +1,27 @@ +"""Abstract base class for platform exporters.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class BaseExporter(ABC): + """Base class for exporting truth data to native platform formats.""" + + @staticmethod + @abstractmethod + def platform_name() -> str: + """Return the platform identifier this exporter targets.""" + + @abstractmethod + def export( + self, + truth_data: dict, + output_path: str, + scraped_data: dict | None = None, + ) -> None: + """Export truth data to the native platform format.""" + + @abstractmethod + def validate(self, truth_data: dict, output_path: str) -> list[str]: + """Validate exported file against truth data, return list of issues.""" diff --git a/scripts/exporter/systemdat_exporter.py b/scripts/exporter/systemdat_exporter.py new file mode 100644 index 00000000..8d041f3e --- /dev/null +++ b/scripts/exporter/systemdat_exporter.py @@ -0,0 +1,104 @@ +"""Exporter for libretro System.dat (clrmamepro DAT format).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from scraper.dat_parser import parse_dat + +from .base_exporter import BaseExporter + + +def _slug_to_native(slug: str) -> str: + """Convert a system slug to a native 'Manufacturer - Console' name.""" + parts = slug.split("-", 1) + if len(parts) == 1: + return parts[0].title() + manufacturer = parts[0].replace("-", " ").title() + console = parts[1].replace("-", " ").title() + return f"{manufacturer} - {console}" + + +class Exporter(BaseExporter): + """Export truth data to libretro System.dat format.""" + + @staticmethod + def platform_name() -> str: + return "retroarch" + + def export( + self, + truth_data: dict, + output_path: str, + scraped_data: dict | None = None, + ) -> None: + native_map: dict[str, str] = {} + if scraped_data: + for sys_id, sys_data in scraped_data.get("systems", {}).items(): + nid = sys_data.get("native_id") + if nid: + native_map[sys_id] = nid + + lines: list[str] = [] + lines.append('clrmamepro (') + lines.append('\tname "System.dat"') + lines.append(')') + + systems = truth_data.get("systems", {}) + for sys_id in sorted(systems): + sys_data = systems[sys_id] + native_name = native_map.get(sys_id, _slug_to_native(sys_id)) + + for fe in sys_data.get("files", []): + name = fe.get("name", "") + if name.startswith("_"): + continue + + dest = fe.get("path", name) + size = fe.get("size", 0) + crc = fe.get("crc32", "") + md5 = fe.get("md5", "") + sha1 = fe.get("sha1", "") + + rom_parts = [f'name "{name}"'] + rom_parts.append(f"size {size}") + if crc: + rom_parts.append(f"crc {crc}") + if md5: + rom_parts.append(f"md5 {md5}") + if sha1: + rom_parts.append(f"sha1 {sha1}") + rom_str = " ".join(rom_parts) + + game_name = f"{native_name}/{dest}" + lines.append("") + lines.append("game (") + lines.append(f'\tname "{game_name}"') + lines.append(f'\tdescription "{name}"') + lines.append(f"\trom ( {rom_str} )") + lines.append(")") + + 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") + parsed = parse_dat(content) + + exported_names: set[str] = set() + for rom in parsed: + exported_names.add(rom.name) + + issues: list[str] = [] + for sys_id, sys_data in truth_data.get("systems", {}).items(): + for fe in sys_data.get("files", []): + name = fe.get("name", "") + if name.startswith("_"): + continue + if name not in exported_names: + issues.append(f"missing: {name} (system {sys_id})") + + return issues diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 07210f93..95d4c0f9 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -3048,5 +3048,65 @@ class TestE2E(unittest.TestCase): self.assertEqual(snes["native_id"], "snes") + # --------------------------------------------------------------- + # Exporter: System.dat round-trip + # --------------------------------------------------------------- + + def test_systemdat_exporter_round_trip(self): + """Export truth data to System.dat and validate round-trip.""" + from exporter import discover_exporters + from exporter.systemdat_exporter import Exporter as SystemDatExporter + + truth = { + "platform": "retroarch", + "systems": { + "sony-playstation": { + "files": [ + { + "name": "scph5501.bin", + "path": "scph5501.bin", + "size": 524288, + "md5": "490f666e1afb15ed6c63b88fc7571f2f", + "sha1": "b056ee5a4d65937e1a3a17e1e78f3258ea49c38e", + "crc32": "71af80b4", + "required": True, + "_cores": ["beetle_psx"], + "_source_refs": ["libretro.c:50"], + }, + ], + }, + }, + } + scraped = { + "systems": { + "sony-playstation": { + "native_id": "Sony - PlayStation", + "files": [ + {"name": "scph5501.bin", "destination": "scph5501.bin"}, + ], + }, + }, + } + + out_path = os.path.join(self.root, "System.dat") + exporter = SystemDatExporter() + exporter.export(truth, out_path, scraped_data=scraped) + + with open(out_path) as fh: + content = fh.read() + self.assertIn("Sony - PlayStation", content) + self.assertIn("scph5501.bin", content) + self.assertIn("b056ee5a4d65937e1a3a17e1e78f3258ea49c38e", content) + self.assertIn('name "System.dat"', content) + + issues = exporter.validate(truth, out_path) + self.assertEqual(issues, []) + + # Discovery finds the systemdat exporter + exporters = discover_exporters() + self.assertIn("retroarch", exporters) + self.assertIs(exporters["retroarch"], SystemDatExporter) + + if __name__ == "__main__": unittest.main()