mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-04-13 12:22:33 -05:00
feat: add batocera, recalbox, retrobat native exporters
This commit is contained in:
80
scripts/exporter/batocera_exporter.py
Normal file
80
scripts/exporter/batocera_exporter.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Exporter for Batocera batocera-systems format (Python dict)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .base_exporter import BaseExporter
|
||||
|
||||
|
||||
class Exporter(BaseExporter):
|
||||
"""Export truth data to Batocera batocera-systems format."""
|
||||
|
||||
@staticmethod
|
||||
def platform_name() -> str:
|
||||
return "batocera"
|
||||
|
||||
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] = [
|
||||
"#!/usr/bin/env python3",
|
||||
"# Generated batocera-systems BIOS declarations",
|
||||
"from collections import OrderedDict",
|
||||
"",
|
||||
"systems = {",
|
||||
]
|
||||
|
||||
systems = truth_data.get("systems", {})
|
||||
for sys_id in sorted(systems):
|
||||
sys_data = systems[sys_id]
|
||||
files = sys_data.get("files", [])
|
||||
if not files:
|
||||
continue
|
||||
|
||||
native_id = native_map.get(sys_id, sys_id)
|
||||
lines.append(f' "{native_id}": {{')
|
||||
lines.append(' "biosFiles": [')
|
||||
|
||||
for fe in files:
|
||||
name = fe.get("name", "")
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
dest = fe.get("destination", name)
|
||||
md5 = fe.get("md5", "")
|
||||
if isinstance(md5, list):
|
||||
md5 = md5[0] if md5 else ""
|
||||
|
||||
lines.append(" {")
|
||||
lines.append(f' "file": "bios/{dest}",')
|
||||
lines.append(f' "md5": "{md5}",')
|
||||
lines.append(" },")
|
||||
|
||||
lines.append(" ],")
|
||||
lines.append(" },")
|
||||
|
||||
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")
|
||||
issues: list[str] = []
|
||||
for sys_data in truth_data.get("systems", {}).values():
|
||||
for fe in sys_data.get("files", []):
|
||||
name = fe.get("name", "")
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
if name not in content:
|
||||
issues.append(f"missing: {name}")
|
||||
return issues
|
||||
90
scripts/exporter/recalbox_exporter.py
Normal file
90
scripts/exporter/recalbox_exporter.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Exporter for Recalbox es_bios.xml format."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import Element, SubElement, ElementTree, indent
|
||||
|
||||
from .base_exporter import BaseExporter
|
||||
|
||||
|
||||
class Exporter(BaseExporter):
|
||||
"""Export truth data to Recalbox es_bios.xml format."""
|
||||
|
||||
@staticmethod
|
||||
def platform_name() -> str:
|
||||
return "recalbox"
|
||||
|
||||
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
|
||||
|
||||
root = Element("biosList")
|
||||
|
||||
systems = truth_data.get("systems", {})
|
||||
for sys_id in sorted(systems):
|
||||
sys_data = systems[sys_id]
|
||||
files = sys_data.get("files", [])
|
||||
if not files:
|
||||
continue
|
||||
|
||||
native_id = native_map.get(sys_id, sys_id)
|
||||
system_el = SubElement(root, "system", platform=native_id)
|
||||
|
||||
for fe in files:
|
||||
name = fe.get("name", "")
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
|
||||
dest = fe.get("destination", name)
|
||||
md5 = fe.get("md5", "")
|
||||
if isinstance(md5, list):
|
||||
md5 = ",".join(md5)
|
||||
required = fe.get("required", False)
|
||||
|
||||
attrs = {
|
||||
"path": dest,
|
||||
"md5": md5,
|
||||
"mandatory": "true" if required else "false",
|
||||
"hashMatchMandatory": "true" if required else "false",
|
||||
}
|
||||
SubElement(system_el, "bios", **attrs)
|
||||
|
||||
indent(root, space=" ")
|
||||
tree = ElementTree(root)
|
||||
tree.write(output_path, encoding="unicode", xml_declaration=True)
|
||||
# Add trailing newline
|
||||
with open(output_path, "a") as f:
|
||||
f.write("\n")
|
||||
|
||||
def validate(self, truth_data: dict, output_path: str) -> list[str]:
|
||||
from xml.etree.ElementTree import parse as xml_parse
|
||||
|
||||
tree = xml_parse(output_path)
|
||||
root = tree.getroot()
|
||||
|
||||
exported_paths: set[str] = set()
|
||||
for bios_el in root.iter("bios"):
|
||||
path = bios_el.get("path", "")
|
||||
if path:
|
||||
exported_paths.add(path)
|
||||
|
||||
issues: list[str] = []
|
||||
for sys_data in truth_data.get("systems", {}).values():
|
||||
for fe in sys_data.get("files", []):
|
||||
name = fe.get("name", "")
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
dest = fe.get("destination", name)
|
||||
if dest not in exported_paths:
|
||||
issues.append(f"missing: {dest}")
|
||||
return issues
|
||||
87
scripts/exporter/retrobat_exporter.py
Normal file
87
scripts/exporter/retrobat_exporter.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Exporter for RetroBat batocera-systems.json format."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .base_exporter import BaseExporter
|
||||
|
||||
|
||||
class Exporter(BaseExporter):
|
||||
"""Export truth data to RetroBat batocera-systems.json format."""
|
||||
|
||||
@staticmethod
|
||||
def platform_name() -> str:
|
||||
return "retrobat"
|
||||
|
||||
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
|
||||
|
||||
output: dict[str, dict] = {}
|
||||
|
||||
systems = truth_data.get("systems", {})
|
||||
for sys_id in sorted(systems):
|
||||
sys_data = systems[sys_id]
|
||||
files = sys_data.get("files", [])
|
||||
if not files:
|
||||
continue
|
||||
|
||||
native_id = native_map.get(sys_id, sys_id)
|
||||
bios_files: list[dict] = []
|
||||
|
||||
for fe in files:
|
||||
name = fe.get("name", "")
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
dest = fe.get("destination", name)
|
||||
md5 = fe.get("md5", "")
|
||||
if isinstance(md5, list):
|
||||
md5 = md5[0] if md5 else ""
|
||||
|
||||
entry = {"file": f"bios/{dest}"}
|
||||
if md5:
|
||||
entry["md5"] = md5
|
||||
bios_files.append(entry)
|
||||
|
||||
if bios_files:
|
||||
output[native_id] = {"biosFiles": bios_files}
|
||||
|
||||
Path(output_path).write_text(
|
||||
json.dumps(output, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def validate(self, truth_data: dict, output_path: str) -> list[str]:
|
||||
data = json.loads(Path(output_path).read_text(encoding="utf-8"))
|
||||
|
||||
exported_files: set[str] = set()
|
||||
for sys_data in data.values():
|
||||
for bf in sys_data.get("biosFiles", []):
|
||||
path = bf.get("file", "")
|
||||
# Strip bios/ prefix, index both full path and basename
|
||||
stripped = path.removeprefix("bios/")
|
||||
exported_files.add(stripped)
|
||||
basename = path.split("/")[-1] if "/" in path else path
|
||||
exported_files.add(basename)
|
||||
|
||||
issues: list[str] = []
|
||||
for sys_data in truth_data.get("systems", {}).values():
|
||||
for fe in sys_data.get("files", []):
|
||||
name = fe.get("name", "")
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
dest = fe.get("destination", name)
|
||||
if name not in exported_files and dest not in exported_files:
|
||||
issues.append(f"missing: {name}")
|
||||
return issues
|
||||
@@ -3500,5 +3500,109 @@ class TestE2E(unittest.TestCase):
|
||||
self.assertEqual(status, "agnostic_fallback")
|
||||
|
||||
|
||||
def test_179_batocera_exporter_round_trip(self):
|
||||
"""Batocera exporter produces valid Python dict format."""
|
||||
from exporter.batocera_exporter import Exporter
|
||||
|
||||
truth = {
|
||||
"systems": {
|
||||
"sony-playstation": {
|
||||
"_coverage": {"cores_profiled": ["c"]},
|
||||
"files": [
|
||||
{"name": "scph5501.bin", "destination": "scph5501.bin",
|
||||
"required": True, "md5": "b" * 32,
|
||||
"_cores": ["c"], "_source_refs": []},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
scraped = {
|
||||
"systems": {
|
||||
"sony-playstation": {"native_id": "psx", "files": []},
|
||||
}
|
||||
}
|
||||
out = os.path.join(self.root, "batocera-systems")
|
||||
exp = Exporter()
|
||||
exp.export(truth, out, scraped_data=scraped)
|
||||
|
||||
content = open(out).read()
|
||||
self.assertIn('"psx"', content)
|
||||
self.assertIn("scph5501.bin", content)
|
||||
self.assertIn("b" * 32, content)
|
||||
self.assertEqual(exp.validate(truth, out), [])
|
||||
|
||||
def test_180_recalbox_exporter_round_trip(self):
|
||||
"""Recalbox exporter produces valid es_bios.xml."""
|
||||
from exporter.recalbox_exporter import Exporter
|
||||
|
||||
truth = {
|
||||
"systems": {
|
||||
"sony-playstation": {
|
||||
"_coverage": {"cores_profiled": ["c"]},
|
||||
"files": [
|
||||
{"name": "scph5501.bin", "destination": "scph5501.bin",
|
||||
"required": True, "md5": "b" * 32,
|
||||
"_cores": ["c"], "_source_refs": []},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
scraped = {
|
||||
"systems": {
|
||||
"sony-playstation": {"native_id": "psx", "files": []},
|
||||
}
|
||||
}
|
||||
out = os.path.join(self.root, "es_bios.xml")
|
||||
exp = Exporter()
|
||||
exp.export(truth, out, scraped_data=scraped)
|
||||
|
||||
content = open(out).read()
|
||||
self.assertIn("<biosList>", content)
|
||||
self.assertIn('platform="psx"', content)
|
||||
self.assertIn("scph5501.bin", content)
|
||||
self.assertIn('mandatory="true"', content)
|
||||
self.assertEqual(exp.validate(truth, out), [])
|
||||
|
||||
def test_181_retrobat_exporter_round_trip(self):
|
||||
"""RetroBat exporter produces valid JSON."""
|
||||
import json as _json
|
||||
from exporter.retrobat_exporter import Exporter
|
||||
|
||||
truth = {
|
||||
"systems": {
|
||||
"sony-playstation": {
|
||||
"_coverage": {"cores_profiled": ["c"]},
|
||||
"files": [
|
||||
{"name": "scph5501.bin", "destination": "scph5501.bin",
|
||||
"required": True, "md5": "b" * 32,
|
||||
"_cores": ["c"], "_source_refs": []},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
scraped = {
|
||||
"systems": {
|
||||
"sony-playstation": {"native_id": "psx", "files": []},
|
||||
}
|
||||
}
|
||||
out = os.path.join(self.root, "batocera-systems.json")
|
||||
exp = Exporter()
|
||||
exp.export(truth, out, scraped_data=scraped)
|
||||
|
||||
data = _json.loads(open(out).read())
|
||||
self.assertIn("psx", data)
|
||||
self.assertTrue(any("scph5501" in bf["file"] for bf in data["psx"]["biosFiles"]))
|
||||
self.assertEqual(exp.validate(truth, out), [])
|
||||
|
||||
def test_182_exporter_discovery(self):
|
||||
"""All exporters are discovered by the plugin system."""
|
||||
from exporter import discover_exporters
|
||||
exporters = discover_exporters()
|
||||
self.assertIn("retroarch", exporters)
|
||||
self.assertIn("batocera", exporters)
|
||||
self.assertIn("recalbox", exporters)
|
||||
self.assertIn("retrobat", exporters)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user