mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 21:43:23 -05:00
feat: make the native export round-trip a platform file
This commit is contained in:
1 parent
7b93285e1d
commit
691ccbfca7
65 files changed
+18417
-2115
No files matched your search
@@ -1,81 +1,166 @@
|
||||
"""Abstract base class for platform exporters."""
|
||||
"""Contract shared by the platform exporters.
|
||||
|
||||
An exporter answers one question: what would this platform's own file look
|
||||
like if it were corrected. It is handed the platform's file when we have it,
|
||||
because several of these formats carry code, and a generator that emits only
|
||||
the data block hands the maintainer something that no longer runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from .baseline import NativeFile, NativeSystem, Report
|
||||
|
||||
|
||||
class BaseExporter(ABC):
|
||||
"""Base class for exporting truth data to native platform formats."""
|
||||
"""Base class for writing a platform's own BIOS file, corrected."""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def platform_name() -> str:
|
||||
"""Return the platform identifier this exporter targets."""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def export(
|
||||
def native_filename() -> str:
|
||||
"""Return the name the platform gives this file."""
|
||||
|
||||
@staticmethod
|
||||
def carries() -> frozenset[str]:
|
||||
"""Fields this format has somewhere to put.
|
||||
|
||||
A correction the file cannot state is not a correction it delivers,
|
||||
and counting it would announce a change the maintainer will not find
|
||||
in the diff.
|
||||
"""
|
||||
return frozenset({"md5"})
|
||||
|
||||
@staticmethod
|
||||
def native_sources() -> dict[str, str]:
|
||||
"""Return {relative path: URL} of the originals this exporter patches.
|
||||
|
||||
An exporter that can build its file from nothing returns an empty
|
||||
mapping. One whose format carries code cannot, and names the file it
|
||||
needs so the caller can fetch it.
|
||||
"""
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def needs_original() -> bool:
|
||||
"""Whether a faithful export requires the platform's own file."""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def may_write_nothing() -> bool:
|
||||
"""Whether producing no file is an outcome rather than a failure.
|
||||
|
||||
True only where the unit is a correction rather than a document:
|
||||
RetroPie has no BIOS list to rewrite, so a run with nothing to
|
||||
correct writes nothing and is right to.
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def render(
|
||||
self,
|
||||
truth_data: dict,
|
||||
output_path: str,
|
||||
scraped_data: dict | None = None,
|
||||
) -> None:
|
||||
"""Export truth data to the native platform format."""
|
||||
systems: dict[str, NativeSystem],
|
||||
report: Report,
|
||||
originals: dict[str, str],
|
||||
scraped: dict | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Return {relative output path: file content}."""
|
||||
|
||||
@abstractmethod
|
||||
def validate(self, truth_data: dict, output_path: str) -> list[str]:
|
||||
"""Validate exported file against truth data, return list of issues."""
|
||||
def validate(
|
||||
self,
|
||||
systems: dict[str, NativeSystem],
|
||||
produced: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Check the produced files against what was asked of them."""
|
||||
|
||||
# Shared helpers
|
||||
|
||||
@classmethod
|
||||
def exportable(
|
||||
cls,
|
||||
systems: dict[str, NativeSystem],
|
||||
require: str = "",
|
||||
) -> list[tuple[NativeSystem, list[NativeFile]]]:
|
||||
"""Systems paired with the files this format can actually carry.
|
||||
|
||||
`require` names a hash the format cannot omit. An entry the format
|
||||
has no way to express is dropped here and counted by the caller,
|
||||
never written as a half entry the platform would read as a file that
|
||||
can never verify.
|
||||
"""
|
||||
result: list[tuple[NativeSystem, list[NativeFile]]] = []
|
||||
for system in systems.values():
|
||||
files = [fe for fe in system.files if fe.name and cls.writable(fe, require)]
|
||||
if files:
|
||||
result.append((system, files))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _is_pattern(name: str) -> bool:
|
||||
"""Check if a filename is a placeholder pattern (not a real file)."""
|
||||
return "<" in name or ">" in name or "*" in name
|
||||
def requires() -> str:
|
||||
"""The hash this format cannot write a new entry without."""
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _dest(fe: dict) -> str:
|
||||
"""Get destination path for a file entry, falling back to name."""
|
||||
return fe.get("path") or fe.get("destination") or fe.get("name", "")
|
||||
def can_add() -> bool:
|
||||
"""Whether a file the platform does not declare can be stated at all.
|
||||
|
||||
False where an entry is a declaration in code rather than a row:
|
||||
BizHawk wires each firmware into an option list and a status that
|
||||
only its source expresses, and writing C# for one is not something
|
||||
an exporter can do safely.
|
||||
"""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def writable(cls, fe: NativeFile, require: str = "") -> bool:
|
||||
"""Whether this format can carry the entry.
|
||||
|
||||
An entry the platform already declares is always carried, hash or
|
||||
no hash: it is in their file today, and an export that drops it
|
||||
hands back a file poorer than the one it corrects. The requirement
|
||||
only gates what we would be adding.
|
||||
"""
|
||||
if fe.platform is not None:
|
||||
return True
|
||||
if not cls.can_add():
|
||||
return False
|
||||
require = require or cls.requires()
|
||||
return not require or bool(fe.hash(require))
|
||||
|
||||
def outcome(
|
||||
self,
|
||||
systems: dict[str, NativeSystem],
|
||||
produced: dict[str, str],
|
||||
) -> str | None:
|
||||
"""What this export did, when counting file entries would not say it.
|
||||
|
||||
Most formats state one entry per file, so the caller's own count is
|
||||
the answer. RetroPie states a sentence per package, and a count of
|
||||
file entries would describe work it did not do.
|
||||
"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _display_name(
|
||||
sys_id: str,
|
||||
scraped_sys: dict | None = None,
|
||||
) -> str:
|
||||
"""Get display name for a system from scraped data or slug."""
|
||||
if scraped_sys:
|
||||
name = scraped_sys.get("name")
|
||||
if name:
|
||||
return name
|
||||
# Fallback: convert slug to display name with acronym handling
|
||||
def display_name(system: NativeSystem) -> str:
|
||||
"""The name the platform shows for a system."""
|
||||
if system.name:
|
||||
return system.name
|
||||
for fe in system.files:
|
||||
native = fe.native("native_name", "")
|
||||
if native:
|
||||
return str(native)
|
||||
_UPPER = {
|
||||
"3do",
|
||||
"cdi",
|
||||
"cpc",
|
||||
"cps1",
|
||||
"cps2",
|
||||
"cps3",
|
||||
"dos",
|
||||
"gba",
|
||||
"gbc",
|
||||
"hle",
|
||||
"msx",
|
||||
"nes",
|
||||
"nds",
|
||||
"ngp",
|
||||
"psp",
|
||||
"psx",
|
||||
"sms",
|
||||
"snes",
|
||||
"stv",
|
||||
"tvc",
|
||||
"vb",
|
||||
"zx",
|
||||
"3do", "cdi", "cpc", "cps1", "cps2", "cps3", "dos", "gba", "gbc",
|
||||
"hle", "msx", "nes", "nds", "ngp", "psp", "psx", "sms", "snes",
|
||||
"stv", "tvc", "vb", "zx",
|
||||
}
|
||||
parts = sys_id.replace("-", " ").split()
|
||||
result = []
|
||||
for p in parts:
|
||||
if p.lower() in _UPPER:
|
||||
result.append(p.upper())
|
||||
else:
|
||||
result.append(p.capitalize())
|
||||
return " ".join(result)
|
||||
parts = system.native_id.replace("-", " ").replace("_", " ").split()
|
||||
return " ".join(
|
||||
p.upper() if p.lower() in _UPPER else p.capitalize() for p in parts
|
||||
)
|
||||
Reference in new issue
Block a user