"""Exporter for Recalbox's es_bios.xml. The file is validated by es_bios.xsd, which makes path, md5 and core required on every bios element. An entry we cannot give all three to is not written: Recalbox would reject the file whole. mandatory and hashMatchMandatory are separate axes. Recalbox reads a missing attribute as true for both, so each is written only when it is false, or when the platform stated it explicitly. """ from __future__ import annotations import sys from pathlib import Path from xml.etree.ElementTree import ParseError from xml.sax.saxutils import quoteattr sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from common import parse_untrusted_xml from .base_exporter import BaseExporter from .baseline import NativeFile, NativeSystem, Report SOURCE_URL = ( "https://gitlab.com/recalbox/recalbox/-/raw/master/board/recalbox/fsoverlay" "/recalbox/share_init/system/.emulationstation/es_bios.xml" ) SCHEMA_URL = ( "https://gitlab.com/recalbox/recalbox/-/raw/master/board/recalbox/fsoverlay" "/recalbox/share_init/system/.emulationstation/es_bios.xsd" ) class Exporter(BaseExporter): """Write Recalbox's es_bios.xml, corrected.""" @staticmethod def platform_name() -> str: return "recalbox" @staticmethod def native_filename() -> str: return "es_bios.xml" @staticmethod def carries() -> frozenset[str]: return frozenset({"md5", "required"}) @staticmethod def native_sources() -> dict[str, str]: return {"es_bios.xml": SOURCE_URL, "es_bios.xsd": SCHEMA_URL} def _path(self, fe: NativeFile) -> str: """The path Recalbox reads, pipe-joined when it accepts several. A path Recalbox already states is reproduced exactly: several of its entries sit at the BIOS root with no directory at all, and prefixing them with the system would point the frontend somewhere else. An addition goes where the core reads it, relative to the BIOS directory Recalbox gives its cores: DuckStation reads scph5000.bin at the root, not under psx/. """ if fe.platform is not None: path = str(fe.platform.get("destination") or fe.name) alternatives = fe.platform.get("alt_paths") or [] if alternatives: return "|".join([path, *[str(a) for a in alternatives]]) return path return fe.destination or fe.name def _bios_element(self, fe: NativeFile, native_id: str) -> str: attrs = [f"path={quoteattr(self._path(fe))}"] attrs.append(f'md5={quoteattr(",".join(fe.hashes("md5")))}') attrs.append(f'core={quoteattr(",".join(fe.cores()))}') if not fe.required: attrs.append('mandatory="false"') elif fe.native("mandatory_declared", None) is True: attrs.append('mandatory="true"') hash_match = fe.native("hash_match_mandatory", None) if hash_match is False: attrs.append('hashMatchMandatory="false"') elif hash_match is True: attrs.append('hashMatchMandatory="true"') # The platform's own note only: ours is prose about our profiles, with # file:line citations, and the format does not carry note corrections. note = " ".join(str((fe.platform or {}).get("note") or "").split()) if note: attrs.append(f"note={quoteattr(note)}") return f" " @classmethod def writable(cls, fe: NativeFile, require: str = "") -> bool: """es_bios.xsd makes md5 and core required; without them, no element. An entry Recalbox already ships has both, so this only ever gates what we would be adding. """ return bool(fe.hashes("md5")) and bool(fe.cores()) def render( self, systems: dict[str, NativeSystem], report: Report, originals: dict[str, str], scraped: dict | None = None, ) -> dict[str, str]: lines = [ '', '', ] # Recalbox's own file is the base, so an element our transcription # does not carry (added or renamed after a scrape) stays as written, # and its system order with it. theirs: list[tuple[str, str, list[dict[str, str]]]] = [] original = originals.get(self.native_filename(), "") if original: try: root = parse_untrusted_xml(original, self.native_filename()) except (ParseError, ValueError): root = None if root is not None: theirs = [ (s.get("platform", ""), s.get("fullname", ""), [dict(b.attrib) for b in s.iter("bios")]) for s in root.iter("system") ] ours: dict[str, tuple[NativeSystem, dict[str, str]]] = {} for system in systems.values(): writable = [fe for fe in system.files if self.writable(fe)] if writable: ours[system.native_id] = (system, { self._path(fe): self._bios_element(fe, system.native_id) for fe in writable }) def element(attrs: dict[str, str]) -> str: rendered = " ".join(f"{k}={quoteattr(v)}" for k, v in attrs.items()) return f" " def emit(platform: str, fullname: str, body: list[str]) -> None: lines.append( f" " ) lines.extend(body) lines.append(" ") for platform, fullname, bios in theirs: if platform not in ours: emit(platform, fullname, [element(attrs) for attrs in bios]) continue system, elements = ours.pop(platform) body = [elements.pop(attrs.get("path", ""), None) or element(attrs) for attrs in bios] body.extend(elements.values()) emit(platform, self.display_name(system), body) for platform in sorted(ours): system, elements = ours[platform] emit(platform, self.display_name(system), list(elements.values())) lines.append("") lines.append("") return {self.native_filename(): "\n".join(lines)} def validate( self, systems: dict[str, NativeSystem], produced: dict[str, str], ) -> list[str]: content = produced[self.native_filename()] issues: list[str] = [] try: root = parse_untrusted_xml(content, self.native_filename()) except (ParseError, ValueError) as exc: return [f"the XML does not parse: {exc}"] for element in root.iter("bios"): for attribute in ("path", "md5", "core"): if not element.get(attribute): issues.append( f"es_bios.xsd requires {attribute}: " f"{element.get('path', '?')}" ) for element in root.iter("system"): if not list(element): issues.append(f"empty system: {element.get('platform', '?')}") for attribute in ("fullname", "platform"): if not element.get(attribute): issues.append(f"es_bios.xsd requires {attribute} on system") exported = { element.get("path", "").casefold() for element in root.iter("bios") } for system in systems.values(): for fe in system.files: if not self.writable(fe): continue if self._path(fe).casefold() not in exported: issues.append(f"absent: {system.native_id}/{fe.name}") return issues