mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-11 05:53:23 -05:00
226 lines
8.4 KiB
Python
226 lines
8.4 KiB
Python
"""Exporter for EmuDeck's checkBIOS.sh.
|
|
|
|
checkBIOS.sh is a shell library: each system is a function EmuDeck calls by
|
|
name, and the only data in it is the MD5 list each function matches against.
|
|
Writing the functions from a table would publish a file missing whichever
|
|
checks the table forgot, so the original is patched instead and every
|
|
function it defines keeps its shape, its scan directory and its output.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from scraper.emudeck_scraper import FUNCTION_HASH_MAP, _RE_FUNC, _RE_LOCAL_HASHES
|
|
|
|
from .base_exporter import BaseExporter
|
|
from .baseline import NativeFile, NativeSystem, Report, _hash_values
|
|
|
|
SOURCE_URL = (
|
|
"https://raw.githubusercontent.com/dragoonDorise/EmuDeck/main"
|
|
"/functions/checkBIOS.sh"
|
|
)
|
|
_MD5 = re.compile(r"^[0-9a-f]{32}$")
|
|
|
|
|
|
class Exporter(BaseExporter):
|
|
"""Write EmuDeck's checkBIOS.sh, corrected."""
|
|
|
|
@staticmethod
|
|
def platform_name() -> str:
|
|
return "emudeck"
|
|
|
|
@staticmethod
|
|
def native_filename() -> str:
|
|
return "checkBIOS.sh"
|
|
|
|
@staticmethod
|
|
def requires() -> str:
|
|
return "md5"
|
|
|
|
@staticmethod
|
|
def carries() -> frozenset[str]:
|
|
return frozenset({"md5"})
|
|
|
|
@staticmethod
|
|
def native_sources() -> dict[str, str]:
|
|
return {"checkBIOS.sh": SOURCE_URL}
|
|
|
|
@staticmethod
|
|
def needs_original() -> bool:
|
|
# The checks are code, and EmuDeck calls them by name.
|
|
return True
|
|
|
|
@staticmethod
|
|
def can_add() -> bool:
|
|
"""A hash is corrected in place, never added to an array.
|
|
|
|
EmuDeck's frontend calls one check for several emulators
|
|
(EmulatorsDetailPage.jsx: 'ra' asks checkPS1BIOS, checkSegaCDBios,
|
|
checkSaturnBios, checkDSBios and checkDreamcastBios, and
|
|
'duckstation' asks checkPS1BIOS as well), and nothing in
|
|
checkBIOS.sh names them. Growing an array therefore changes answers
|
|
for consumers the array does not list: DuckStation boots from a PS2
|
|
image, the RetroArch PSX cores do not, so adding the ones
|
|
DuckStation accepts would report a BIOS to a card that has none.
|
|
Correcting a value in place changes no consumer's set.
|
|
"""
|
|
return False
|
|
|
|
@staticmethod
|
|
def _entry_md5s(fe: NativeFile) -> list[str]:
|
|
"""The values one platform entry puts in the array, corrected in place.
|
|
|
|
An entry keeps its own values unless the truth contradicts all of
|
|
them, and then the truth's replace them. An entry without a hash
|
|
adds nothing, and the truth's extra accepted revisions are not
|
|
appended: either would grow an array other consumers read too.
|
|
"""
|
|
theirs = _hash_values(fe.platform or {}, "md5")
|
|
if not theirs:
|
|
return []
|
|
if "md5" in fe.corrections:
|
|
return fe.hashes("md5")
|
|
return theirs
|
|
|
|
def states(self, fe: NativeFile, field_name: str) -> bool:
|
|
if field_name in fe.filled:
|
|
return False
|
|
if field_name == "md5" and fe.native_system in self._refused_systems():
|
|
# The array was left as EmuDeck wrote it: nothing was corrected.
|
|
return False
|
|
return super().states(fe, field_name)
|
|
|
|
def _expected_md5s(
|
|
self, systems: dict[str, NativeSystem], name: str, system_id: str
|
|
) -> list[str]:
|
|
"""What a check must now hold; a refused rewrite keeps EmuDeck's own array."""
|
|
if name in getattr(self, "_withdrawn", {}):
|
|
return []
|
|
return self._md5s(systems, system_id)
|
|
|
|
def _refused_systems(self) -> set[str]:
|
|
withdrawn = getattr(self, "_withdrawn", {})
|
|
return {FUNCTION_HASH_MAP[name] for name in withdrawn if name in FUNCTION_HASH_MAP}
|
|
|
|
@staticmethod
|
|
def _corrected_away(systems: dict[str, NativeSystem], system_id: str) -> set[str]:
|
|
"""The platform md5 values a correction of ours replaces."""
|
|
return {
|
|
value
|
|
for system in systems.values()
|
|
if system.native_id == system_id
|
|
for fe in system.files
|
|
if "md5" in fe.corrections
|
|
for value in _hash_values(fe.platform or {}, "md5")
|
|
}
|
|
|
|
@classmethod
|
|
def _md5s(cls, systems: dict[str, NativeSystem], system_id: str) -> list[str]:
|
|
"""Every MD5 the system accepts, in a stable order, deduplicated."""
|
|
seen: list[str] = []
|
|
for system in systems.values():
|
|
if system.native_id != system_id:
|
|
continue
|
|
for fe in system.files:
|
|
if not cls.writable(fe):
|
|
continue
|
|
for value in cls._entry_md5s(fe):
|
|
if _MD5.match(value) and value not in seen:
|
|
seen.append(value)
|
|
return seen
|
|
|
|
def _function_spans(self, script: str) -> list[tuple[str, int, int]]:
|
|
"""Name and byte span of every check the script defines."""
|
|
matches = list(_RE_FUNC.finditer(script))
|
|
spans: list[tuple[str, int, int]] = []
|
|
for index, match in enumerate(matches):
|
|
end = (
|
|
matches[index + 1].start()
|
|
if index + 1 < len(matches)
|
|
else len(script)
|
|
)
|
|
spans.append((match.group(1), match.start(), end))
|
|
return spans
|
|
|
|
def render(
|
|
self,
|
|
systems: dict[str, NativeSystem],
|
|
report: Report,
|
|
originals: dict[str, str],
|
|
scraped: dict | None = None,
|
|
) -> dict[str, str]:
|
|
script = originals.get(self.native_filename(), "")
|
|
if not script:
|
|
raise ValueError(
|
|
"checkBIOS.sh cannot be written without EmuDeck's own file: "
|
|
"the checks are code, not data"
|
|
)
|
|
|
|
self._withdrawn: dict[str, set[str]] = {}
|
|
pieces: list[str] = []
|
|
cursor = 0
|
|
for name, start, end in self._function_spans(script):
|
|
pieces.append(script[cursor:start])
|
|
body = script[start:end]
|
|
system_id = FUNCTION_HASH_MAP.get(name)
|
|
md5s = self._md5s(systems, system_id) if system_id else []
|
|
match = _RE_LOCAL_HASHES.search(body)
|
|
if md5s and match:
|
|
# An array compared by membership says nothing about order,
|
|
# so the same set is left as the maintainer wrote it.
|
|
theirs = set(match.group(1).split())
|
|
# The array was replaced wholesale, so any value EmuDeck holds
|
|
# and our model does not was deleted: a user whose dump matched
|
|
# it would stop passing the check. A rewrite that withdraws one
|
|
# is refused and reported instead.
|
|
# A correction replaces the value it corrects, which is not a
|
|
# withdrawal; any other value missing from ours would be.
|
|
withdrawn = theirs - set(md5s) - self._corrected_away(systems, system_id)
|
|
if withdrawn:
|
|
self._withdrawn.setdefault(name, set()).update(withdrawn)
|
|
elif set(md5s) != theirs:
|
|
body = (
|
|
body[: match.start(1)]
|
|
+ " ".join(md5s)
|
|
+ body[match.end(1) :]
|
|
)
|
|
pieces.append(body)
|
|
cursor = end
|
|
pieces.append(script[cursor:])
|
|
|
|
return {self.native_filename(): "".join(pieces)}
|
|
|
|
def validate(
|
|
self,
|
|
systems: dict[str, NativeSystem],
|
|
produced: dict[str, str],
|
|
) -> list[str]:
|
|
content = produced[self.native_filename()]
|
|
issues: list[str] = []
|
|
|
|
defined = {name for name, _, _ in self._function_spans(content)}
|
|
for name, system_id in FUNCTION_HASH_MAP.items():
|
|
if name not in defined:
|
|
issues.append(f"check absent from the output: {name}")
|
|
continue
|
|
md5s = self._expected_md5s(systems, name, system_id)
|
|
if not md5s:
|
|
continue
|
|
body = next(
|
|
content[start:end]
|
|
for fname, start, end in self._function_spans(content)
|
|
if fname == name
|
|
)
|
|
if not _RE_LOCAL_HASHES.search(body):
|
|
# A check with no hash list is a path check, not a hash check.
|
|
continue
|
|
for md5 in md5s:
|
|
if md5 not in body:
|
|
issues.append(f"absent from {name}: {md5}")
|
|
return issues
|