mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
chore: merge the amberelec, emuelec, knulli lists
AmberELEC, PAN4ELEC and EmuELEC detection now installs their own lists at /storage/roms, the root those lists are written against; KNULLI its own list at /userdata/bios. JELOS, KOLOS, UnofficialOS and Arch R keep RetroArch's list with the folder their retroarch.cfg names.
This commit is contained in:
commit
259fb14954
38 files changed
+299417
-284
No files matched your search
@@ -42,6 +42,9 @@ body:
|
||||
- RetroDECK
|
||||
- RomM
|
||||
- ROCKNIX
|
||||
- KNULLI
|
||||
- AmberELEC
|
||||
- EmuELEC
|
||||
- BizHawk
|
||||
- MiSTer FPGA
|
||||
- Other
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "6ae8bdd7bbe9a0c3e6cb2d92947bb4e135e38d6446d206ae30b988a045eaa536"
|
||||
$defaultInstallSha256 = "7273e615d62c11711c3aa2841d3aeeef3bdd0ad14304c03edcfb7973e2ad2426"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+17
-14
@@ -185,7 +185,7 @@ OTHER_SYSTEMS = "other"
|
||||
AVAILABLE_PLATFORMS = (
|
||||
"retroarch", "batocera", "recalbox", "retrobat", "emudeck",
|
||||
"lakka", "retrodeck", "rocknix", "romm", "bizhawk", "misterfpga",
|
||||
"retropie",
|
||||
"retropie", "amberelec", "emuelec", "knulli",
|
||||
)
|
||||
|
||||
# The names the frontends give themselves, as each manifest's display_name
|
||||
@@ -197,6 +197,7 @@ PLATFORM_NAMES = {
|
||||
"retrobat": "RetroBat", "emudeck": "EmuDeck", "lakka": "Lakka",
|
||||
"retrodeck": "RetroDECK", "rocknix": "ROCKNIX", "romm": "RomM",
|
||||
"bizhawk": "BizHawk", "misterfpga": "MiSTer FPGA", "retropie": "RetroPie",
|
||||
"amberelec": "AmberELEC", "emuelec": "EmuELEC", "knulli": "KNULLI",
|
||||
}
|
||||
|
||||
SITE_URL = "https://abdess.github.io/retrobios/"
|
||||
@@ -213,6 +214,10 @@ DEFAULT_DESTS = {
|
||||
"retrodeck": Path.home() / "retrodeck",
|
||||
"emudeck": Path.home() / "Emulation" / "bios",
|
||||
"rocknix": Path("/storage/roms/bios"),
|
||||
# Their BIOS lists name files under /storage/roms beside bios/.
|
||||
"amberelec": Path("/storage/roms"),
|
||||
"emuelec": Path("/storage/roms"),
|
||||
"knulli": Path("/userdata/bios"),
|
||||
"misterfpga": Path("/media/fat/games"),
|
||||
"retropie": Path.home() / "RetroPie" / "BIOS",
|
||||
"romm": Path("/romm/library/bios"),
|
||||
@@ -746,13 +751,12 @@ _BOARD_SOURCES: dict[str, tuple[str, str]] = {
|
||||
}
|
||||
|
||||
|
||||
# A derivative installed under a platform's name, with its own board file.
|
||||
# A platform that names its board without publishing a core list per board.
|
||||
# Knulli moves /usr/share/batocera to /usr/share/knulli (knulli-linux
|
||||
# 57afa662, board/scripts/post-build-script.sh:264-278) and writes its board
|
||||
# to knulli.arch (package/system/knulli-system/knulli-system.mk:98-99). Its
|
||||
# packages are its own, so Batocera's per-board core lists do not apply.
|
||||
# to knulli.arch (package/system/knulli-system/knulli-system.mk:98-99).
|
||||
_DERIVATIVE_BOARDS: dict[str, tuple[str, str]] = {
|
||||
"batocera": ("Knulli", "/usr/share/knulli/knulli.arch"),
|
||||
"knulli": ("Knulli", "/usr/share/knulli/knulli.arch"),
|
||||
}
|
||||
|
||||
|
||||
@@ -852,13 +856,12 @@ class _OsRule(NamedTuple):
|
||||
base: str = ""
|
||||
|
||||
|
||||
# Systems whose own platform is not published yet. Their files come from the
|
||||
# platform whose core set they ship, and this is the one place that changes
|
||||
# when the manifest of their own is published.
|
||||
# The platform whose files each of these systems gets. They publish their own
|
||||
# BIOS list, and these platforms transcribe it.
|
||||
_STANDIN_MANIFESTS = {
|
||||
"AmberELEC": "retroarch",
|
||||
"EmuELEC": "retroarch",
|
||||
"KNULLI": "batocera",
|
||||
"AmberELEC": "amberelec",
|
||||
"EmuELEC": "emuelec",
|
||||
"KNULLI": "knulli",
|
||||
}
|
||||
|
||||
# Where the ELEC family keeps RetroArch: the config runemu writes and copies
|
||||
@@ -916,18 +919,18 @@ _EMBEDDED_RULES: tuple[_OsRule, ...] = (
|
||||
# (packages/games/tools/retroarch/sources/retroarch.cfg:611), and
|
||||
# emustation.service:6 sets HOME=/storage.
|
||||
_OsRule(_STANDIN_MANIFESTS["AmberELEC"], "AmberELEC", "os_release", ("NAME=AmberELEC",),
|
||||
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
|
||||
"/storage/roms"),
|
||||
# PAN4ELEC (Kegg1701/P4ELEC 9c19bfb2), an overlay on an AmberELEC image:
|
||||
# AmberELEC/usr/bin/batocera-info:101 prints its name.
|
||||
_OsRule(_STANDIN_MANIFESTS["AmberELEC"], "PAN4ELEC", "file_matches",
|
||||
("/usr/bin/batocera-info", r"OPERATING SYSTEM: PAN4ELEC"),
|
||||
"/storage/roms/bios", _ELEC_CONFIGS, "/storage", base="AmberELEC"),
|
||||
"/storage/roms", base="AmberELEC"),
|
||||
# EmuELEC d4c7b2f1: scripts/image:172-176 NAME and ID from DISTRONAME,
|
||||
# EmuELEC in distributions/EmuELEC/options:4. setsettings.sh:22 names the
|
||||
# config, and libretro_base/retroarch/package.mk:117 sets its
|
||||
# system_directory to /storage/roms/bios.
|
||||
_OsRule(_STANDIN_MANIFESTS["EmuELEC"], "EmuELEC", "os_release", ("NAME=EmuELEC",),
|
||||
"/storage/roms/bios", _ELEC_CONFIGS, "/storage"),
|
||||
"/storage/roms"),
|
||||
# JELOS 7b2e116c: scripts/image:118-120 OS_NAME="${DISTRONAME}", JELOS in
|
||||
# distributions/JELOS/options:17. runemu.sh:37 names the config, whose
|
||||
# default is "~/roms/bios" (retroarch/sources/RK3566/retroarch.cfg:716),
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ esac
|
||||
TEMP_INSTALLER=""
|
||||
TEMP_DIRECTORY=""
|
||||
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
DEFAULT_INSTALL_SHA256="6ae8bdd7bbe9a0c3e6cb2d92947bb4e135e38d6446d206ae30b988a045eaa536"
|
||||
DEFAULT_INSTALL_SHA256="7273e615d62c11711c3aa2841d3aeeef3bdd0ad14304c03edcfb7973e2ad2426"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
+61696
File diff suppressed because it is too large.
Load diff
+64075
File diff suppressed because it is too large.
Load diff
+160353
File diff suppressed because it is too large.
Load diff
+100
-47
@@ -291,37 +291,6 @@ platforms:
|
||||
method: file_exists
|
||||
file: /mnt/mmc/MUOS
|
||||
bios_path: /mnt/mmc/MUOS/bios
|
||||
# AmberELEC: AmberELEC 5a3882cb scripts/image:150-152, options:17, runemu.py:22,
|
||||
# retroarch.cfg:611 ("~/roms/bios"), emustation.service:6 (HOME). CHAos
|
||||
# (tech4bot/AmberELEC c0a4b0c6) writes the same name. Served by RetroArch's
|
||||
# file list until its own platform is published.
|
||||
- os: linux
|
||||
name: AmberELEC
|
||||
method: os_release
|
||||
match: [NAME=AmberELEC]
|
||||
bios_path: /storage/roms/bios
|
||||
config: [/storage/.config/retroarch/retroarch.cfg]
|
||||
home: /storage
|
||||
# PAN4ELEC: Kegg1701/P4ELEC 9c19bfb2, AmberELEC/usr/bin/batocera-info:101.
|
||||
- os: linux
|
||||
name: PAN4ELEC
|
||||
method: file_matches
|
||||
file: /usr/bin/batocera-info
|
||||
pattern: 'OPERATING SYSTEM: PAN4ELEC'
|
||||
bios_path: /storage/roms/bios
|
||||
config: [/storage/.config/retroarch/retroarch.cfg]
|
||||
home: /storage
|
||||
derives_from: AmberELEC
|
||||
# EmuELEC: EmuELEC d4c7b2f1 scripts/image:172-176, options:4, setsettings.sh:22,
|
||||
# libretro_base/retroarch/package.mk:117. Served by RetroArch's file list
|
||||
# until its own platform is published.
|
||||
- os: linux
|
||||
name: EmuELEC
|
||||
method: os_release
|
||||
match: [NAME=EmuELEC]
|
||||
bios_path: /storage/roms/bios
|
||||
config: [/storage/.config/retroarch/retroarch.cfg]
|
||||
home: /storage
|
||||
# JELOS: JustEnoughLinuxOS/distribution 7b2e116c scripts/image:118-120,
|
||||
# options:17, runemu.sh:37, RK3566/retroarch.cfg:716.
|
||||
- os: linux
|
||||
@@ -518,22 +487,6 @@ platforms:
|
||||
target_source: https://github.com/batocera-linux/batocera.linux
|
||||
install:
|
||||
detect:
|
||||
# Knulli, a Batocera derivative (knulli-cfw/knulli-linux 57afa662):
|
||||
# OS_NAME in os-release (board/scripts/post-build-script.sh:233-236),
|
||||
# and knulli.version (package/system/knulli-system/knulli-system.mk:98-102).
|
||||
# Served by Batocera's file list until its own platform is published.
|
||||
- os: linux
|
||||
name: KNULLI
|
||||
method: os_release
|
||||
match: [OS_NAME=knulli]
|
||||
bios_path: /userdata/bios
|
||||
derives_from: Batocera
|
||||
- os: linux
|
||||
name: KNULLI
|
||||
method: file_exists
|
||||
file: /usr/share/knulli/knulli.version
|
||||
bios_path: /userdata/bios
|
||||
derives_from: Batocera
|
||||
# batocera.linux 38845725, package/batocera/core/batocera-system/
|
||||
# batocera-system.mk:21-22 writes batocera.arch and batocera.version.
|
||||
- os: linux
|
||||
@@ -786,6 +739,106 @@ platforms:
|
||||
method: os_release
|
||||
match: [OS_NAME=rocknix, ID=rocknix]
|
||||
bios_path: /storage/roms/bios
|
||||
# AmberELEC's checker (batocera-systems) md5s each file under /storage/roms,
|
||||
# where its list also names neogeo/neogeo.zip and neogeo/aes.zip: the pack
|
||||
# is extracted there, not into bios/. EmulationStation's BIOS screen runs
|
||||
# it (ApiSystem.cpp:603-613 at e3846751). The launch-failure check in
|
||||
# 99-distribution.conf:320 passes --strictfilter, an argument the script
|
||||
# does not handle, so that path reports nothing. No release since
|
||||
# 20230203: the scraper reads the commit of the newest prerelease.
|
||||
# Several device images, no per-device core list: no target file.
|
||||
amberelec:
|
||||
config: amberelec.yml
|
||||
status: active
|
||||
extract_to: /storage/roms/
|
||||
logo: https://avatars.githubusercontent.com/u/102820909
|
||||
scraper: amberelec
|
||||
source_url: https://github.com/AmberELEC/AmberELEC/blob/dev/packages/amberelec/sources/scripts/batocera-systems
|
||||
source_format: python_dict
|
||||
hash_type: md5
|
||||
schedule: monthly
|
||||
target_scraper: null
|
||||
target_source: null
|
||||
install:
|
||||
detect:
|
||||
# AmberELEC 5a3882cb scripts/image:150-152, options:17. CHAos
|
||||
# (tech4bot/AmberELEC c0a4b0c6) writes the same name. Its list is
|
||||
# written against /storage/roms (bios/, ports/, neogeo/ side by side).
|
||||
- os: linux
|
||||
name: AmberELEC
|
||||
method: os_release
|
||||
match: [NAME=AmberELEC]
|
||||
bios_path: /storage/roms
|
||||
# PAN4ELEC: Kegg1701/P4ELEC 9c19bfb2, AmberELEC/usr/bin/batocera-info:101.
|
||||
- os: linux
|
||||
name: PAN4ELEC
|
||||
method: file_matches
|
||||
file: /usr/bin/batocera-info
|
||||
pattern: 'OPERATING SYSTEM: PAN4ELEC'
|
||||
bios_path: /storage/roms
|
||||
derives_from: AmberELEC
|
||||
# EmuELEC's checker md5s each file under /storage/roms and never reads
|
||||
# zippedFile: its FM Towns entries hash the whole archive against a
|
||||
# member's md5 and report UNTESTED whatever the archive holds. Run by
|
||||
# EmulationStation's BIOS screen and by ee_check_bios after a failed
|
||||
# launch (99-emuelec.conf:261). naomi and Dinothawr are written twice in
|
||||
# the dict and the later entry is the one Python keeps.
|
||||
emuelec:
|
||||
config: emuelec.yml
|
||||
status: active
|
||||
extract_to: /storage/roms/
|
||||
logo: https://avatars.githubusercontent.com/u/57574835
|
||||
scraper: emuelec
|
||||
source_url: https://github.com/EmuELEC/EmuELEC/blob/master/packages/sx05re/emuelec/bin/batocera/batocera-systems
|
||||
source_format: python_dict
|
||||
hash_type: md5
|
||||
schedule: monthly
|
||||
target_scraper: null
|
||||
target_source: null
|
||||
install:
|
||||
detect:
|
||||
# EmuELEC d4c7b2f1 scripts/image:172-176, options:4.
|
||||
- os: linux
|
||||
name: EmuELEC
|
||||
method: os_release
|
||||
match: [NAME=EmuELEC]
|
||||
bios_path: /storage/roms
|
||||
# KNULLI's knulli-systems is its own fork of Batocera's list, checked the
|
||||
# same way (md5, checkInsideZip, emulator/core gates) by its
|
||||
# EmulationStation (knulli-cfw/batocera-emulationstation 10834d00,
|
||||
# ApiSystem.cpp:764-774). Arch R (archr-linux/Arch-R dbd45d45) ships the
|
||||
# same dict as archr-systems: lines 1-870 are identical, only the prefix
|
||||
# (/storage/roms instead of /userdata) and the es_systems.cfg path differ.
|
||||
# Many boards, no per-board core list: no target file.
|
||||
knulli:
|
||||
config: knulli.yml
|
||||
status: active
|
||||
extract_to: /userdata/bios/
|
||||
logo: https://avatars.githubusercontent.com/u/166268572
|
||||
scraper: knulli
|
||||
source_url: https://github.com/knulli-cfw/knulli-linux/blob/knulli-main/package/system/knulli-scripts/scripts/knulli-systems
|
||||
source_format: python_dict
|
||||
hash_type: md5
|
||||
schedule: monthly
|
||||
target_scraper: null
|
||||
target_source: null
|
||||
install:
|
||||
detect:
|
||||
# knulli-linux 57afa662: OS_NAME in os-release
|
||||
# (board/scripts/post-build-script.sh:233-236) and knulli.version
|
||||
# (package/system/knulli-system/knulli-system.mk:98-102).
|
||||
- os: linux
|
||||
name: KNULLI
|
||||
method: os_release
|
||||
match: [OS_NAME=knulli]
|
||||
bios_path: /userdata/bios
|
||||
derives_from: Batocera
|
||||
- os: linux
|
||||
name: KNULLI
|
||||
method: file_exists
|
||||
file: /usr/share/knulli/knulli.version
|
||||
bios_path: /userdata/bios
|
||||
derives_from: Batocera
|
||||
retropie:
|
||||
config: retropie.yml
|
||||
status: archived
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -66,6 +66,11 @@
|
||||
"type": "string",
|
||||
"description": "Base directory for BIOS files on this platform"
|
||||
},
|
||||
"core_destination": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "With an empty base_destination, the directory core files go under (AmberELEC and EmuELEC declare bios/, neogeo/ and ports/ from /storage/roms, and their cores read bios/)"
|
||||
},
|
||||
"hash_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
+5
-1
@@ -2842,10 +2842,14 @@ def declared_root(config: dict) -> str:
|
||||
Only a platform whose base_destination is explicitly empty does that
|
||||
(RetroDECK writes bios/ in front of each file), and core files go under
|
||||
the same directory. A config that does not state a base says nothing,
|
||||
and its common folder is a system's, not the BIOS root.
|
||||
and its common folder is a system's, not the BIOS root. A platform whose
|
||||
list also names folders beside the BIOS one states it as
|
||||
core_destination: EmuELEC's ports/ entries are a quarter of its list.
|
||||
"""
|
||||
if config.get("base_destination", None) != "":
|
||||
return ""
|
||||
if config.get("core_destination"):
|
||||
return str(config["core_destination"])
|
||||
roots = [
|
||||
d.split("/", 1)[0]
|
||||
for system in config.get("systems", {}).values()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Exporter for AmberELEC's batocera-systems.
|
||||
|
||||
Batocera's script shape, so only the systems dict is rewritten. Two things
|
||||
differ. Paths are relative to /storage/roms, where the list also names
|
||||
neogeo/ beside bios/, and our destinations start there too. No entry carries
|
||||
an emulator or core gate: coreExists() lets an ungated item through for
|
||||
every build, and an addition is written the same way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base_exporter import BaseExporter
|
||||
from .baseline import NativeFile
|
||||
from .batocera_exporter import Exporter as BatoceraExporter
|
||||
|
||||
SOURCE_URL = (
|
||||
"https://raw.githubusercontent.com/AmberELEC/AmberELEC/dev"
|
||||
"/packages/amberelec/sources/scripts/batocera-systems"
|
||||
)
|
||||
|
||||
|
||||
class Exporter(BatoceraExporter):
|
||||
"""Write AmberELEC's batocera-systems, corrected."""
|
||||
|
||||
PATH_PREFIX = ""
|
||||
|
||||
@classmethod
|
||||
def writable(cls, fe: NativeFile, require: str = "") -> bool:
|
||||
# The list gates nothing on a runner, so an addition needs none.
|
||||
return BaseExporter.writable.__func__(cls, fe, require)
|
||||
|
||||
def _addition_keys(self, fe: NativeFile) -> list[tuple[str, str | None]] | None:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def platform_name() -> str:
|
||||
return "amberelec"
|
||||
|
||||
@staticmethod
|
||||
def file_modes() -> dict[str, int]:
|
||||
# 100755 at a21975bf (git/trees).
|
||||
return {"batocera-systems": 0o755}
|
||||
|
||||
@staticmethod
|
||||
def native_sources() -> dict[str, str]:
|
||||
return {"batocera-systems": SOURCE_URL}
|
||||
@@ -35,6 +35,27 @@ def _commented(text: str, position: int) -> bool:
|
||||
return "#" in _STRING.sub("", text[line_start:position])
|
||||
|
||||
|
||||
def _closing_line(text: str, start: int) -> int | None:
|
||||
"""Index of the line holding the brace that closes `systems = {`.
|
||||
|
||||
Read from the parsed module, never executed. None when the script does
|
||||
not parse or names no such assignment at line `start`.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return None
|
||||
for node in tree.body:
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and node.lineno - 1 == start
|
||||
and isinstance(node.value, ast.Dict)
|
||||
and node.end_lineno is not None
|
||||
):
|
||||
return node.end_lineno - 1
|
||||
return None
|
||||
|
||||
|
||||
def _note_key(text: str) -> tuple[str, str]:
|
||||
"""(file, zippedFile) of the item a line holds, ("", "") for none."""
|
||||
path = re.search(r'"file"\s*:\s*"([^"]+)"', text)
|
||||
@@ -55,6 +76,11 @@ class Exporter(BaseExporter):
|
||||
# (duckstation-sa), which its checker does not know.
|
||||
STANDALONE_RUNNERS = True
|
||||
|
||||
# What the script's paths add in front of a destination. Batocera's
|
||||
# paths are relative to /userdata and our destinations to its bios/;
|
||||
# AmberELEC's and EmuELEC's destinations already start at /storage/roms.
|
||||
PATH_PREFIX = "bios/"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
# The emulator and core each system entry of the original is gated
|
||||
@@ -200,8 +226,7 @@ class Exporter(BaseExporter):
|
||||
alt = fe.native("alt_md5", "")
|
||||
if alt:
|
||||
item.append(f'"altmd5": "{alt}"')
|
||||
declared = fe.native("native_path", "")
|
||||
path = str(declared) if declared else f"bios/{fe.destination}"
|
||||
path = self.declared_path(fe)
|
||||
item.append(f'"file": "{path}"')
|
||||
zipped = fe.native("zipped_file", "")
|
||||
if zipped:
|
||||
@@ -209,6 +234,12 @@ class Exporter(BaseExporter):
|
||||
item.extend(self._runner_keys(originals.get(path)) if runner is None else runner)
|
||||
return "{ " + ", ".join(item) + " }"
|
||||
|
||||
@classmethod
|
||||
def declared_path(cls, fe: NativeFile) -> str:
|
||||
"""The `file` the script names for an entry."""
|
||||
declared = fe.native("native_path", "")
|
||||
return str(declared) if declared else f"{cls.PATH_PREFIX}{fe.destination}"
|
||||
|
||||
@staticmethod
|
||||
def _own_entry(system: NativeSystem, before: dict | None) -> dict:
|
||||
"""The maintainer's entry for this system, out of the parsed pair."""
|
||||
@@ -396,6 +427,9 @@ class Exporter(BaseExporter):
|
||||
(i for i in range(start + 1, len(lines)) if lines[i].startswith("}")),
|
||||
None,
|
||||
)
|
||||
if end is None:
|
||||
# EmuELEC closes the dict with an indented brace.
|
||||
end = _closing_line(original, start)
|
||||
if end is None:
|
||||
raise ValueError("batocera-systems: the systems dict is not closed")
|
||||
return lines[: start + 1], lines[start + 1 : end], lines[end:]
|
||||
@@ -520,14 +554,12 @@ class Exporter(BaseExporter):
|
||||
content = produced[self.native_filename()]
|
||||
issues: list[str] = []
|
||||
|
||||
block = content.split("\nsystems = {", 1)
|
||||
if len(block) != 2:
|
||||
return ["no systems dict in the output"]
|
||||
end = block[1].find("\n}")
|
||||
if end < 0:
|
||||
return ["the systems dict is not closed"]
|
||||
try:
|
||||
exported = ast.literal_eval("{" + block[1][:end] + "\n}")
|
||||
_head, body, _tail = self._split_original(content)
|
||||
except ValueError as exc:
|
||||
return [str(exc)]
|
||||
try:
|
||||
exported = ast.literal_eval("{" + "\n".join(body) + "\n}")
|
||||
except (SyntaxError, ValueError) as exc:
|
||||
return [f"the systems dict does not parse: {exc}"]
|
||||
|
||||
@@ -541,8 +573,7 @@ class Exporter(BaseExporter):
|
||||
continue
|
||||
declared = {bios.get("file", "") for bios in entry.get("biosFiles", [])}
|
||||
for fe in files:
|
||||
path = str(fe.native("native_path", "")) or f"bios/{fe.destination}"
|
||||
if path not in declared:
|
||||
if self.declared_path(fe) not in declared:
|
||||
issues.append(f"absent: {system.native_id}/{fe.name}")
|
||||
|
||||
for native_id, entry in exported.items():
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Exporter for EmuELEC's batocera-systems.
|
||||
|
||||
AmberELEC's case: paths relative to /storage/roms with ports/ and neogeo/
|
||||
beside bios/, and no runner gate, since EmuELEC's checkBios never reads one.
|
||||
The dict closes on an indented brace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .amberelec_exporter import Exporter as AmberELECExporter
|
||||
|
||||
SOURCE_URL = (
|
||||
"https://raw.githubusercontent.com/EmuELEC/EmuELEC/master"
|
||||
"/packages/sx05re/emuelec/bin/batocera/batocera-systems"
|
||||
)
|
||||
|
||||
|
||||
class Exporter(AmberELECExporter):
|
||||
"""Write EmuELEC's batocera-systems, corrected."""
|
||||
|
||||
@staticmethod
|
||||
def platform_name() -> str:
|
||||
return "emuelec"
|
||||
|
||||
@staticmethod
|
||||
def file_modes() -> dict[str, int]:
|
||||
# 100755 at v4.8 (git/trees).
|
||||
return {"batocera-systems": 0o755}
|
||||
|
||||
@staticmethod
|
||||
def native_sources() -> dict[str, str]:
|
||||
return {"batocera-systems": SOURCE_URL}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Exporter for KNULLI's knulli-systems.
|
||||
|
||||
A fork of Batocera's script, checked the same way: es_systems names a
|
||||
libretro core under `libretro` and a standalone by itself, and entries carry
|
||||
Batocera's emulator and core gates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .batocera_exporter import Exporter as BatoceraExporter
|
||||
|
||||
SOURCE_URL = (
|
||||
"https://raw.githubusercontent.com/knulli-cfw/knulli-linux/knulli-main"
|
||||
"/package/system/knulli-scripts/scripts/knulli-systems"
|
||||
)
|
||||
|
||||
|
||||
class Exporter(BatoceraExporter):
|
||||
"""Write KNULLI's knulli-systems, corrected."""
|
||||
|
||||
@staticmethod
|
||||
def platform_name() -> str:
|
||||
return "knulli"
|
||||
|
||||
@staticmethod
|
||||
def native_filename() -> str:
|
||||
return "knulli-systems"
|
||||
|
||||
@staticmethod
|
||||
def native_sources() -> dict[str, str]:
|
||||
return {"knulli-systems": SOURCE_URL}
|
||||
@@ -5025,6 +5025,9 @@ def generate_wiki_data_model(db: dict, profiles: dict) -> str:
|
||||
"`base_destination` is the prefix the pack applies to every entry. It is",
|
||||
"empty when the upstream destinations already carry their own root, which",
|
||||
"is why the RetroDECK pack ships `bios/` and `roms/` at its top level.",
|
||||
"Core files then go under `core_destination` when the platform states one",
|
||||
"(`bios` for AmberELEC and EmuELEC, whose lists also name `neogeo/` and",
|
||||
"`ports/`), else under the root nearly every destination shares.",
|
||||
"",
|
||||
"## Emulator YAML",
|
||||
"",
|
||||
@@ -5248,6 +5251,9 @@ ZIP: extract them all into the same folder.
|
||||
| Android (Retroid Pocket, Odin, etc.) | RetroArch for Android | [RetroArch]({rel}) | `RetroArch/system/` on internal storage or SD card |
|
||||
| [ROCKNIX](https://rocknix.org/) | Linux OS for ARM and x86 handhelds, among them the RG35XX (H700) and the RG552 (RK3399) | [ROCKNIX]({rel}) | `/storage/roms/bios/`, over SSH or the network share |
|
||||
| [Batocera](https://batocera.org/) | Also has images for handheld chips, among them H700 and RK3326 | [Batocera]({rel}) | `/userdata/bios/` |
|
||||
| [KNULLI](https://knulli.org/) | Batocera-based OS for handhelds, among them the RG35XX family (H700) and the TrimUI Smart Pro | [KNULLI]({rel}) | `/userdata/bios/` |
|
||||
| [AmberELEC](https://amberelec.org/) | Linux OS for Anbernic handhelds: RG351P, RG351V, RG351MP, RG353, RG503 and RG552 | [AmberELEC]({rel}) | `/storage/roms/`, which the pack fills with `bios/` and `neogeo/` |
|
||||
| [EmuELEC](https://emuelec.org/) | Linux OS for Amlogic TV boxes, the Odroid Go Advance, GameForce and RK356x devices | [EmuELEC]({rel}) | `/storage/roms/`, which the pack fills with `bios/`, `neogeo/` and `ports/` |
|
||||
|
||||
### FPGA
|
||||
|
||||
@@ -5315,6 +5321,16 @@ def _link_pack_cells(page: str) -> str:
|
||||
raise KeyError(f"no registered platform is displayed as {label!r}")
|
||||
return f"| {download_links(keys[label], record, REPO_URL, label)} |"
|
||||
|
||||
def released(line: str) -> bool:
|
||||
# A platform registered after the recorded release has no pack in
|
||||
# it yet: its row would send the reader to a release without one.
|
||||
found = cell.search(line)
|
||||
if not found or not record.get("packs"):
|
||||
return True
|
||||
label = found.group(1)
|
||||
return label not in keys or release_record.pack_for(keys[label], record) is not None
|
||||
|
||||
page = "\n".join(line for line in page.split("\n") if released(line))
|
||||
return cell.sub(link, page)
|
||||
|
||||
|
||||
@@ -5702,8 +5718,8 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
mkdocs_static = """\
|
||||
site_name: RetroBIOS
|
||||
site_description: Source-verified BIOS and firmware packs for RetroArch, Batocera,
|
||||
Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX
|
||||
and MiSTer FPGA.
|
||||
Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX,
|
||||
KNULLI, AmberELEC, EmuELEC and MiSTer FPGA.
|
||||
site_url: https://abdess.github.io/retrobios/
|
||||
repo_url: https://github.com/Abdess/retrobios
|
||||
repo_name: Abdess/retrobios
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scraper for AmberELEC's batocera-systems.
|
||||
|
||||
Source: packages/amberelec/sources/scripts/batocera-systems
|
||||
Format: Python source, Batocera's `systems` dict, parsed by batocera_scraper
|
||||
Hash: MD5
|
||||
|
||||
AmberELEC has published no release since 20230203. Its builds come from the
|
||||
dev branch as prereleases in AmberELEC/AmberELEC-prerelease, each naming the
|
||||
commit it was built from. The scraper reads the list at the commit of the
|
||||
newest one. That list accepts every md5 the 20230203 one does for the files
|
||||
it keeps (syscard3.pce 38179df8, BS-X.bin 96cf17bf) and adds
|
||||
neogeo/aes.zip.
|
||||
|
||||
Verification (batocera-systems at that commit):
|
||||
- checkBios (lines 583-643): md5sum() of the file at /storage/roms/<file>
|
||||
against the declared md5, an empty md5 meaning presence only. For a
|
||||
zippedFile entry, checkInsideZip() (lines 530-561) matches the member name
|
||||
with casefold() and hashes it.
|
||||
- Run with no argument by EmulationStation's BIOS screen
|
||||
(AmberELEC/emulationstation e3846751, es-app/src/ApiSystem.cpp:603-613),
|
||||
which also passes `--filter <system>`. ee_check_bios
|
||||
(packages/amberelec/profile.d/99-distribution.conf:320) passes
|
||||
`--strictfilter`, which the script's argument switch (lines 665-687) does
|
||||
not handle: that call prints nothing and exits 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .base_scraper import BaseScraper, BiosRequirement, github_headers
|
||||
from .batocera_scraper import (
|
||||
SYSTEM_SLUG_MAP as BATOCERA_SLUGS,
|
||||
)
|
||||
from .batocera_scraper import (
|
||||
extract_systems_dict,
|
||||
platform_systems,
|
||||
requirements_from_systems,
|
||||
)
|
||||
from .es_emulators import es_cores, xml_emulators
|
||||
|
||||
PLATFORM_NAME = "amberelec"
|
||||
|
||||
REPO = "AmberELEC/AmberELEC"
|
||||
PRERELEASE_REPO = "AmberELEC/AmberELEC-prerelease"
|
||||
_RAW = "https://raw.githubusercontent.com/AmberELEC/AmberELEC/{ref}/"
|
||||
SOURCE_TEMPLATE = _RAW + "packages/amberelec/sources/scripts/batocera-systems"
|
||||
ES_SYSTEMS_TEMPLATE = _RAW + "packages/ui/emulationstation/config/es_systems.cfg"
|
||||
|
||||
# Batocera's names, plus the keys AmberELEC writes that Batocera does not,
|
||||
# filed under the slug the other scrapers already give them.
|
||||
SYSTEM_SLUG_MAP = {
|
||||
**BATOCERA_SLUGS,
|
||||
"atari7800": "atari-7800",
|
||||
"atarilynx": "atari-lynx",
|
||||
"n64": "nintendo-64",
|
||||
"o2em": "magnavox-odyssey2",
|
||||
}
|
||||
|
||||
# runemu.py: `retroarch` and `retrorun` load the libretro core es_systems.cfg
|
||||
# names. `mame` lists MAME machines and runs them on the libretro MAME core
|
||||
# (lines 233-261 set self.core = 'mame', line 355 builds the RetroArch command).
|
||||
RUNNERS: dict[str, str | None] = {"retroarch": None, "retrorun": None, "mame": "mame"}
|
||||
# runemu.py:113, PPSSPPSDL runs /usr/bin/ppsspp.sh, installed by the
|
||||
# ppssppsa package (packages/games/emulators/ppssppsa).
|
||||
RENAMED = {"PPSSPPSDL": "ppsspp"}
|
||||
|
||||
_PRERELEASE_COMMIT = re.compile(r"AmberELEC/AmberELEC@([0-9a-f]{40})")
|
||||
|
||||
|
||||
def prerelease_commit(body: str) -> str:
|
||||
"""The commit a prerelease's notes say it was built from."""
|
||||
found = _PRERELEASE_COMMIT.search(body or "")
|
||||
if found is None:
|
||||
raise RuntimeError("the newest AmberELEC prerelease names no commit")
|
||||
return found.group(1)
|
||||
|
||||
|
||||
def fetch_newest_prerelease() -> tuple[str, str]:
|
||||
"""(tag, commit) of the newest AmberELEC prerelease."""
|
||||
url = f"https://api.github.com/repos/{PRERELEASE_REPO}/releases?per_page=1"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=github_headers())
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
releases = json.loads(resp.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
||||
raise RuntimeError(f"cannot list the releases of {PRERELEASE_REPO}: {e}") from e
|
||||
if not releases or not releases[0].get("tag_name"):
|
||||
raise RuntimeError(f"{PRERELEASE_REPO} lists no release")
|
||||
return releases[0]["tag_name"], prerelease_commit(releases[0].get("body", ""))
|
||||
|
||||
|
||||
class Scraper(BaseScraper):
|
||||
"""Scraper for AmberELEC's batocera-systems."""
|
||||
|
||||
def __init__(self, url: str | None = None) -> None:
|
||||
if url:
|
||||
self.tag, self.commit = "", ""
|
||||
else:
|
||||
self.tag, self.commit = fetch_newest_prerelease()
|
||||
super().__init__(url=url or SOURCE_TEMPLATE.format(ref=self.commit))
|
||||
|
||||
def _fetch_text(self, url: str) -> str:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "retrobios-scraper/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
except urllib.error.URLError as e:
|
||||
raise ConnectionError(f"Failed to fetch {url}: {e}") from e
|
||||
|
||||
def validate_format(self, raw_data: str) -> bool:
|
||||
has_dict = re.search(r"^systems\s*=\s*\{", raw_data, re.MULTILINE) is not None
|
||||
return has_dict and "def checkBios" in raw_data and '"biosFiles"' in raw_data
|
||||
|
||||
def fetch_requirements(self) -> list[BiosRequirement]:
|
||||
raw = self._fetch_raw()
|
||||
if not self.validate_format(raw):
|
||||
raise ValueError("batocera-systems format validation failed")
|
||||
# Paths are relative to /storage/roms (`prefix`, lines 667-675): the
|
||||
# list names neogeo/neogeo.zip beside bios/.
|
||||
return requirements_from_systems(extract_systems_dict(raw), SYSTEM_SLUG_MAP, strip="")
|
||||
|
||||
def generate_platform_yaml(self) -> dict:
|
||||
systems = platform_systems(
|
||||
self.fetch_requirements(), extract_systems_dict(self._fetch_raw()), SYSTEM_SLUG_MAP
|
||||
)
|
||||
cores, standalone, dual = es_cores(
|
||||
xml_emulators(self._fetch_text(ES_SYSTEMS_TEMPLATE.format(ref=self.commit))),
|
||||
RUNNERS,
|
||||
RENAMED,
|
||||
)
|
||||
return {
|
||||
"platform": "AmberELEC",
|
||||
"version": self.tag,
|
||||
"homepage": "https://amberelec.org",
|
||||
"source": self.url,
|
||||
"base_destination": "",
|
||||
# RetroArch's system_directory is ~/roms/bios
|
||||
# (packages/games/tools/retroarch/sources/retroarch.cfg:611).
|
||||
"core_destination": "bios",
|
||||
"hash_type": "md5",
|
||||
"verification_mode": "md5",
|
||||
"cores": cores,
|
||||
"standalone_cores": standalone,
|
||||
"dual_mode_cores": dual,
|
||||
"systems": systems,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from .base_scraper import scraper_cli
|
||||
|
||||
scraper_cli(Scraper, "Scrape AmberELEC BIOS requirements")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+185
-172
@@ -86,9 +86,9 @@ ES_SYSTEMS_URL = (
|
||||
OVERRIDES_PATH = Path(__file__).resolve().parents[2] / "platforms" / "targets" / "_overrides.yml"
|
||||
|
||||
|
||||
def _not_cores() -> set[str]:
|
||||
def _not_cores(platform: str = "batocera") -> set[str]:
|
||||
data = yaml_load(OVERRIDES_PATH.read_text(encoding="utf-8")) or {}
|
||||
default = (data.get("batocera", {}).get("targets", {}) or {}).get("_default", {}) or {}
|
||||
default = (data.get(platform, {}).get("targets", {}) or {}).get("_default", {}) or {}
|
||||
return {str(name) for name in default.get("remove_cores", [])}
|
||||
|
||||
|
||||
@@ -262,6 +262,177 @@ def _resolve_truncated_md5(md5: str, md5_index: dict[str, str]) -> str:
|
||||
return md5
|
||||
|
||||
|
||||
def extract_systems_dict(raw: str) -> dict:
|
||||
"""The `systems` dict of a batocera-systems style script.
|
||||
|
||||
Read with ast.literal_eval, never executed: the text is the platform's
|
||||
code. A key written twice keeps its later value, as it does when Python
|
||||
runs the script.
|
||||
"""
|
||||
match = re.search(r"^systems\s*=\s*\{", raw, re.MULTILINE)
|
||||
if not match:
|
||||
raise ValueError("no 'systems = {' in the script")
|
||||
|
||||
start = match.start() + raw[match.start() :].index("{")
|
||||
depth = 0
|
||||
i = start
|
||||
in_str = False
|
||||
str_ch = None
|
||||
while i < len(raw):
|
||||
ch = raw[i]
|
||||
if in_str:
|
||||
if ch == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if ch == str_ch:
|
||||
in_str = False
|
||||
elif ch in ('"', "'"):
|
||||
in_str = True
|
||||
str_ch = ch
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
elif ch == "#":
|
||||
while i < len(raw) and raw[i] != "\n":
|
||||
i += 1
|
||||
i += 1
|
||||
|
||||
dict_str = raw[start : i + 1]
|
||||
|
||||
lines = []
|
||||
for line in dict_str.split("\n"):
|
||||
in_string = False
|
||||
string_char = None
|
||||
clean = []
|
||||
j = 0
|
||||
while j < len(line):
|
||||
ch = line[j]
|
||||
if ch == "\\" and j + 1 < len(line):
|
||||
clean.append(ch)
|
||||
clean.append(line[j + 1])
|
||||
j += 2
|
||||
continue
|
||||
if ch in ('"', "'") and not in_string:
|
||||
in_string = True
|
||||
string_char = ch
|
||||
clean.append(ch)
|
||||
elif ch == string_char and in_string:
|
||||
in_string = False
|
||||
clean.append(ch)
|
||||
elif ch == "#" and not in_string:
|
||||
break
|
||||
else:
|
||||
clean.append(ch)
|
||||
j += 1
|
||||
lines.append("".join(clean))
|
||||
|
||||
clean_dict_str = "\n".join(lines)
|
||||
|
||||
# OrderedDict({...}) -> just the inner dict literal
|
||||
clean_dict_str = re.sub(r"OrderedDict\(\s*\{", "{", clean_dict_str)
|
||||
clean_dict_str = re.sub(r"\}\s*\)", "}", clean_dict_str)
|
||||
|
||||
try:
|
||||
return ast.literal_eval(clean_dict_str)
|
||||
except (SyntaxError, ValueError) as e:
|
||||
raise ValueError(f"Failed to parse systems dict: {e}") from e
|
||||
|
||||
|
||||
def requirements_from_systems(
|
||||
systems: dict, slug_map: dict[str, str], strip: str = "bios/"
|
||||
) -> list[BiosRequirement]:
|
||||
"""One requirement per biosFiles item, filed under our system slug.
|
||||
|
||||
`strip` is the part of each declared path that the platform's
|
||||
base_destination already stands for: `bios/` where the pack is extracted
|
||||
into the BIOS folder, nothing where it is extracted into the folder the
|
||||
script's prefix names (AmberELEC and EmuELEC declare `neogeo/neogeo.zip`
|
||||
and `ports/...` beside `bios/`).
|
||||
"""
|
||||
requirements = []
|
||||
md5_index = _load_md5_index()
|
||||
for sys_key, sys_data in systems.items():
|
||||
system_slug = slug_map.get(sys_key, sys_key)
|
||||
for bios in sys_data.get("biosFiles", []):
|
||||
declared_path = bios.get("file", "")
|
||||
file_path = declared_path
|
||||
md5 = _resolve_truncated_md5(bios.get("md5", ""), md5_index)
|
||||
zipped_file = bios.get("zippedFile", "")
|
||||
if strip and file_path.startswith(strip):
|
||||
file_path = file_path[len(strip):]
|
||||
requirements.append(
|
||||
BiosRequirement(
|
||||
name=file_path.split("/")[-1],
|
||||
system=system_slug,
|
||||
md5=md5 or None,
|
||||
destination=file_path,
|
||||
native_path=declared_path,
|
||||
required=True,
|
||||
zipped_file=zipped_file or None,
|
||||
native_id=sys_key,
|
||||
native={"native_name": sys_data.get("name", "")},
|
||||
)
|
||||
)
|
||||
return requirements
|
||||
|
||||
|
||||
def platform_systems(
|
||||
requirements: list[BiosRequirement], source_dict: dict, slug_map: dict[str, str]
|
||||
) -> dict:
|
||||
"""The `systems:` section of a platform YAML, in the script's order."""
|
||||
display_names: dict[str, str] = {}
|
||||
for sys_key, sys_data in source_dict.items():
|
||||
dname = sys_data.get("name", "")
|
||||
if dname:
|
||||
display_names[slug_map.get(sys_key, sys_key)] = dname
|
||||
systems: dict[str, dict] = {}
|
||||
for req in requirements:
|
||||
if req.system not in systems:
|
||||
sys_entry: dict = {"files": []}
|
||||
if req.native_id:
|
||||
sys_entry["native_id"] = req.native_id
|
||||
dname = display_names.get(req.system)
|
||||
if dname:
|
||||
sys_entry["name"] = dname
|
||||
systems[req.system] = sys_entry
|
||||
systems[req.system]["files"].append(requirement_entry(req))
|
||||
return systems
|
||||
|
||||
|
||||
def configgen_cores(
|
||||
defaults_text: str, es_systems_text: str, dropped: set[str]
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Core names, standalone cores and dual-mode cores of a configgen build.
|
||||
|
||||
Standalone cores are those with emulator != "libretro" in
|
||||
configgen-defaults.yml or listed outside `libretro:` in es_systems.yml,
|
||||
dual-mode ones those es_systems.yml also offers under libretro (dolphin
|
||||
for GameCube). `dropped` names what es_systems.yml files under an
|
||||
emulator without being one.
|
||||
"""
|
||||
data = yaml_load_upstream(defaults_text)
|
||||
cores, standalone = es_systems_emulators(es_systems_text)
|
||||
libretro = es_systems_libretro(es_systems_text)
|
||||
if not cores:
|
||||
raise RuntimeError("es_systems.yml names no emulator")
|
||||
for system, cfg in data.items():
|
||||
if system == "default" or not isinstance(cfg, dict):
|
||||
continue
|
||||
emulator = cfg.get("emulator", "")
|
||||
core = cfg.get("core", "")
|
||||
if core:
|
||||
cores.add(core)
|
||||
if emulator and emulator != "libretro":
|
||||
standalone.add(emulator)
|
||||
if core and core != emulator:
|
||||
standalone.add(core)
|
||||
dual = (standalone & libretro) - dropped
|
||||
return sorted(cores - dropped), sorted(standalone - dropped), sorted(dual)
|
||||
|
||||
|
||||
class Scraper(BaseScraper):
|
||||
"""Scraper for batocera-systems Python dict."""
|
||||
|
||||
@@ -270,44 +441,12 @@ class Scraper(BaseScraper):
|
||||
super().__init__(url=SOURCE_URL.format(tag=self.tag))
|
||||
|
||||
def _fetch_cores(self) -> tuple[list[str], list[str], list[str]]:
|
||||
"""Extract core names and standalone cores from configgen-defaults.yml.
|
||||
|
||||
Returns (all_cores, standalone_cores, dual_mode_cores): standalone
|
||||
cores are those with emulator != "libretro", dual-mode ones those
|
||||
es_systems.yml also offers under libretro (dolphin for GameCube).
|
||||
"""
|
||||
url = CONFIGGEN_DEFAULTS_URL.format(tag=self.tag)
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "retrobios-scraper/1.0"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
except urllib.error.URLError as e:
|
||||
raise ConnectionError(
|
||||
f"Failed to fetch {url}: {e}"
|
||||
) from e
|
||||
data = yaml_load_upstream(raw)
|
||||
es_systems = self._fetch_text(ES_SYSTEMS_URL.format(tag=self.tag))
|
||||
cores, standalone = es_systems_emulators(es_systems)
|
||||
libretro = es_systems_libretro(es_systems)
|
||||
if not cores:
|
||||
raise RuntimeError("es_systems.yml names no emulator")
|
||||
for system, cfg in data.items():
|
||||
if system == "default" or not isinstance(cfg, dict):
|
||||
continue
|
||||
emulator = cfg.get("emulator", "")
|
||||
core = cfg.get("core", "")
|
||||
if core:
|
||||
cores.add(core)
|
||||
if emulator and emulator != "libretro":
|
||||
standalone.add(emulator)
|
||||
if core and core != emulator:
|
||||
standalone.add(core)
|
||||
dropped = _not_cores()
|
||||
dual = (standalone & libretro) - dropped
|
||||
return sorted(cores - dropped), sorted(standalone - dropped), sorted(dual)
|
||||
"""Core names, standalone cores and dual-mode cores at the pinned tag."""
|
||||
return configgen_cores(
|
||||
self._fetch_text(CONFIGGEN_DEFAULTS_URL.format(tag=self.tag)),
|
||||
self._fetch_text(ES_SYSTEMS_URL.format(tag=self.tag)),
|
||||
_not_cores(),
|
||||
)
|
||||
|
||||
def _fetch_text(self, url: str) -> str:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "retrobios-scraper/1.0"})
|
||||
@@ -317,120 +456,12 @@ class Scraper(BaseScraper):
|
||||
except urllib.error.URLError as e:
|
||||
raise ConnectionError(f"Failed to fetch {url}: {e}") from e
|
||||
|
||||
def _extract_systems_dict(self, raw: str) -> dict:
|
||||
"""Extract and parse the 'systems' dict from the Python source via ast.literal_eval."""
|
||||
match = re.search(r"^systems\s*=\s*\{", raw, re.MULTILINE)
|
||||
if not match:
|
||||
raise ValueError("Could not find 'systems = {' in batocera-systems")
|
||||
|
||||
start = match.start() + raw[match.start() :].index("{")
|
||||
depth = 0
|
||||
i = start
|
||||
in_str = False
|
||||
str_ch = None
|
||||
while i < len(raw):
|
||||
ch = raw[i]
|
||||
if in_str:
|
||||
if ch == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if ch == str_ch:
|
||||
in_str = False
|
||||
elif ch in ('"', "'"):
|
||||
in_str = True
|
||||
str_ch = ch
|
||||
elif ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
elif ch == "#":
|
||||
while i < len(raw) and raw[i] != "\n":
|
||||
i += 1
|
||||
i += 1
|
||||
|
||||
dict_str = raw[start : i + 1]
|
||||
|
||||
lines = []
|
||||
for line in dict_str.split("\n"):
|
||||
in_string = False
|
||||
string_char = None
|
||||
clean = []
|
||||
j = 0
|
||||
while j < len(line):
|
||||
ch = line[j]
|
||||
if ch == "\\" and j + 1 < len(line):
|
||||
clean.append(ch)
|
||||
clean.append(line[j + 1])
|
||||
j += 2
|
||||
continue
|
||||
if ch in ('"', "'") and not in_string:
|
||||
in_string = True
|
||||
string_char = ch
|
||||
clean.append(ch)
|
||||
elif ch == string_char and in_string:
|
||||
in_string = False
|
||||
clean.append(ch)
|
||||
elif ch == "#" and not in_string:
|
||||
break
|
||||
else:
|
||||
clean.append(ch)
|
||||
j += 1
|
||||
lines.append("".join(clean))
|
||||
|
||||
clean_dict_str = "\n".join(lines)
|
||||
|
||||
# OrderedDict({...}) -> just the inner dict literal
|
||||
clean_dict_str = re.sub(r"OrderedDict\(\s*\{", "{", clean_dict_str)
|
||||
clean_dict_str = re.sub(r"\}\s*\)", "}", clean_dict_str)
|
||||
|
||||
try:
|
||||
return ast.literal_eval(clean_dict_str)
|
||||
except (SyntaxError, ValueError) as e:
|
||||
raise ValueError(f"Failed to parse systems dict: {e}") from e
|
||||
|
||||
def fetch_requirements(self) -> list[BiosRequirement]:
|
||||
"""Parse batocera-systems and return BIOS requirements."""
|
||||
raw = self._fetch_raw()
|
||||
|
||||
if not self.validate_format(raw):
|
||||
raise ValueError("batocera-systems format validation failed")
|
||||
|
||||
systems = self._extract_systems_dict(raw)
|
||||
requirements = []
|
||||
md5_index = _load_md5_index()
|
||||
|
||||
for sys_key, sys_data in systems.items():
|
||||
system_slug = SYSTEM_SLUG_MAP.get(sys_key, sys_key)
|
||||
bios_files = sys_data.get("biosFiles", [])
|
||||
|
||||
for bios in bios_files:
|
||||
declared_path = bios.get("file", "")
|
||||
file_path = declared_path
|
||||
md5 = _resolve_truncated_md5(bios.get("md5", ""), md5_index)
|
||||
zipped_file = bios.get("zippedFile", "")
|
||||
|
||||
if file_path.startswith("bios/"):
|
||||
file_path = file_path[5:]
|
||||
|
||||
name = file_path.split("/")[-1] if "/" in file_path else file_path
|
||||
|
||||
requirements.append(
|
||||
BiosRequirement(
|
||||
name=name,
|
||||
system=system_slug,
|
||||
md5=md5 or None,
|
||||
destination=file_path,
|
||||
native_path=declared_path,
|
||||
required=True,
|
||||
zipped_file=zipped_file or None,
|
||||
native_id=sys_key,
|
||||
native={"native_name": sys_data.get("name", "")},
|
||||
)
|
||||
)
|
||||
|
||||
return requirements
|
||||
return requirements_from_systems(extract_systems_dict(raw), SYSTEM_SLUG_MAP)
|
||||
|
||||
def validate_format(self, raw_data: str) -> bool:
|
||||
"""Validate batocera-systems format."""
|
||||
@@ -442,29 +473,11 @@ class Scraper(BaseScraper):
|
||||
|
||||
def generate_platform_yaml(self) -> dict:
|
||||
"""Generate a platform YAML config dict from scraped data."""
|
||||
requirements = self.fetch_requirements()
|
||||
|
||||
raw = self._fetch_raw()
|
||||
source_dict = self._extract_systems_dict(raw)
|
||||
display_names: dict[str, str] = {}
|
||||
for sys_key, sys_data in source_dict.items():
|
||||
dname = sys_data.get("name", "")
|
||||
if dname:
|
||||
slug = SYSTEM_SLUG_MAP.get(sys_key, sys_key)
|
||||
display_names[slug] = dname
|
||||
|
||||
systems = {}
|
||||
for req in requirements:
|
||||
if req.system not in systems:
|
||||
sys_entry: dict = {"files": []}
|
||||
if req.native_id:
|
||||
sys_entry["native_id"] = req.native_id
|
||||
dname = display_names.get(req.system)
|
||||
if dname:
|
||||
sys_entry["name"] = dname
|
||||
systems[req.system] = sys_entry
|
||||
|
||||
systems[req.system]["files"].append(requirement_entry(req))
|
||||
systems = platform_systems(
|
||||
self.fetch_requirements(),
|
||||
extract_systems_dict(self._fetch_raw()),
|
||||
SYSTEM_SLUG_MAP,
|
||||
)
|
||||
|
||||
cores, standalone, dual = self._fetch_cores()
|
||||
result = {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scraper for EmuELEC's batocera-systems.
|
||||
|
||||
Source: packages/sx05re/emuelec/bin/batocera/batocera-systems
|
||||
Format: Python source, Batocera's `systems` dict, parsed by batocera_scraper
|
||||
Hash: MD5
|
||||
|
||||
`naomi` and `Dinothawr` are written twice in the dict. Python keeps the later
|
||||
value of a repeated key when the script runs, and so does the parser: naomi
|
||||
is the entry at line 461 (naomi_boot.bin and naomi.zip, line 444 naming
|
||||
naomi.zip alone), Dinothawr the one at line 776 (dinothawr.game, line 494
|
||||
naming level_3-3.tmx).
|
||||
|
||||
Verification (batocera-systems at the release tag):
|
||||
- checkBios (lines 817-833): md5sum() of the file at /storage/roms/<file>
|
||||
against the declared md5, an empty md5 meaning presence only. It never
|
||||
reads zippedFile: the FM Towns entries that name a member hash the whole
|
||||
archive against the member's md5. There is no emulator or core gate.
|
||||
- Run with no argument by EmulationStation's BIOS screen
|
||||
(EmuELEC/emuelec-emulationstation 09356d0d, es-app/src/ApiSystem.cpp:
|
||||
645-655, `--filter <system>` for one system), and with `--strictfilter
|
||||
<platform>` by ee_check_bios after a failed launch
|
||||
(packages/sx05re/emuelec/profile.d/99-emuelec.conf:261, called from
|
||||
emuelecRunEmu.sh:605).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .base_scraper import BaseScraper, BiosRequirement, fetch_github_latest_version
|
||||
from .batocera_scraper import (
|
||||
SYSTEM_SLUG_MAP as BATOCERA_SLUGS,
|
||||
)
|
||||
from .batocera_scraper import (
|
||||
extract_systems_dict,
|
||||
platform_systems,
|
||||
requirements_from_systems,
|
||||
)
|
||||
from .es_emulators import es_cores, json_emulators
|
||||
|
||||
PLATFORM_NAME = "emuelec"
|
||||
|
||||
REPO = "EmuELEC/EmuELEC"
|
||||
_RAW = "https://raw.githubusercontent.com/EmuELEC/EmuELEC/{ref}/"
|
||||
SOURCE_TEMPLATE = _RAW + "packages/sx05re/emuelec/bin/batocera/batocera-systems"
|
||||
ES_SYSTEMS_TEMPLATE = _RAW + "packages/sx05re/emuelec-emulationstation/config/es_systems.json"
|
||||
|
||||
# Batocera's names, plus the keys EmuELEC writes that Batocera does not:
|
||||
# console keys under the slug the other scrapers give them, and each port
|
||||
# under the system of the profile of the engine its name gives
|
||||
# (devilutionx, prboom, opentyrian, cannonball, nxengine, tyrquake,
|
||||
# reminiscence, xrick, vvvvvv, eduke32, sonic2013, sonic-mania, dinothawr).
|
||||
SYSTEM_SLUG_MAP = {
|
||||
**BATOCERA_SLUGS,
|
||||
"atari7800": "atari-7800",
|
||||
"atarilynx": "atari-lynx",
|
||||
"n64": "nintendo-64",
|
||||
"o2em": "magnavox-odyssey2",
|
||||
"Diablo": "diablo",
|
||||
"Hellfire": "diablo",
|
||||
"Dinothawr": "dinothawr",
|
||||
"Quake": "quake",
|
||||
"VVVVVV": "vvvvvv",
|
||||
"DOOM1": "doom",
|
||||
"DOOM2": "doom",
|
||||
"OpenTyrian": "tyrian",
|
||||
"Cannonball": "sega-outrun",
|
||||
"CaveStory": "cave-story",
|
||||
"REminiscence": "flashback",
|
||||
"RickDangerous": "xrick",
|
||||
"eduke32": "duke3d",
|
||||
"sonic1": "sonic-1-2013",
|
||||
"sonic2": "sonic-2-2013",
|
||||
"sonicmania": "sonic-mania",
|
||||
}
|
||||
|
||||
# es_systems.json names libretro cores under `libretro`, and `retrorun`
|
||||
# loads the libretro core it lists.
|
||||
RUNNERS: dict[str, str | None] = {"libretro": None, "retrorun": None}
|
||||
# packages/sx05re/emulators/PPSSPPSDL builds PPSSPP's SDL front end.
|
||||
RENAMED = {"PPSSPPSDL": "ppsspp"}
|
||||
|
||||
|
||||
class Scraper(BaseScraper):
|
||||
"""Scraper for EmuELEC's batocera-systems."""
|
||||
|
||||
def __init__(self, url: str | None = None) -> None:
|
||||
self.tag = "" if url else fetch_github_latest_version(REPO)
|
||||
super().__init__(url=url or SOURCE_TEMPLATE.format(ref=self.tag))
|
||||
|
||||
def _fetch_text(self, url: str) -> str:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "retrobios-scraper/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
except urllib.error.URLError as e:
|
||||
raise ConnectionError(f"Failed to fetch {url}: {e}") from e
|
||||
|
||||
def validate_format(self, raw_data: str) -> bool:
|
||||
has_dict = re.search(r"^systems\s*=\s*\{", raw_data, re.MULTILINE) is not None
|
||||
return has_dict and "def checkBios" in raw_data and '"biosFiles"' in raw_data
|
||||
|
||||
def fetch_requirements(self) -> list[BiosRequirement]:
|
||||
raw = self._fetch_raw()
|
||||
if not self.validate_format(raw):
|
||||
raise ValueError("batocera-systems format validation failed")
|
||||
# Paths are relative to /storage/roms (`prefix`, lines 859-865): the
|
||||
# list names neogeo/ and ports/ beside bios/.
|
||||
return requirements_from_systems(extract_systems_dict(raw), SYSTEM_SLUG_MAP, strip="")
|
||||
|
||||
def generate_platform_yaml(self) -> dict:
|
||||
systems = platform_systems(
|
||||
self.fetch_requirements(), extract_systems_dict(self._fetch_raw()), SYSTEM_SLUG_MAP
|
||||
)
|
||||
cores, standalone, dual = es_cores(
|
||||
json_emulators(self._fetch_text(ES_SYSTEMS_TEMPLATE.format(ref=self.tag))),
|
||||
RUNNERS,
|
||||
RENAMED,
|
||||
)
|
||||
return {
|
||||
"platform": "EmuELEC",
|
||||
"version": self.tag.removeprefix("v"),
|
||||
"homepage": "https://emuelec.org",
|
||||
"source": self.url,
|
||||
"base_destination": "",
|
||||
# RetroArch's system_directory is /storage/roms/bios
|
||||
# (packages/sx05re/libretro_base/retroarch/package.mk:117).
|
||||
"core_destination": "bios",
|
||||
"hash_type": "md5",
|
||||
"verification_mode": "md5",
|
||||
"cores": cores,
|
||||
"standalone_cores": standalone,
|
||||
"dual_mode_cores": dual,
|
||||
"systems": systems,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from .base_scraper import scraper_cli
|
||||
|
||||
scraper_cli(Scraper, "Scrape EmuELEC BIOS requirements")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""The emulators an EmulationStation system list offers, by profile names.
|
||||
|
||||
AmberELEC writes its list as es_systems.cfg XML, EmuELEC as an es_systems.json
|
||||
it turns into that XML at build time. Both name, per system, the emulators a
|
||||
user may pick and the cores under each. Some emulators are libretro front
|
||||
ends (RetroArch, retrorun) whose cores are libretro core names. Every other
|
||||
emulator is a standalone program named by the OS (`HATARISA`, `PPSSPPSDL`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from safeparse import parse_untrusted_xml
|
||||
|
||||
|
||||
def xml_emulators(text: str) -> dict[str, set[str]]:
|
||||
"""Emulator -> cores, from an es_systems.cfg."""
|
||||
root = parse_untrusted_xml(text, "es_systems.cfg")
|
||||
pairs: dict[str, set[str]] = {}
|
||||
for emulator in root.iter("emulator"):
|
||||
name = emulator.get("name")
|
||||
if name:
|
||||
pairs.setdefault(name, set()).update(
|
||||
core.text.strip() for core in emulator.iter("core") if core.text
|
||||
)
|
||||
if not pairs:
|
||||
raise ValueError("es_systems.cfg names no emulator")
|
||||
return pairs
|
||||
|
||||
|
||||
def json_emulators(text: str) -> dict[str, set[str]]:
|
||||
"""Emulator -> cores, from an es_systems.json."""
|
||||
pairs: dict[str, set[str]] = {}
|
||||
for system in json.loads(text).get("systems", []):
|
||||
for emulator in system.get("emulators") or []:
|
||||
pairs.setdefault(emulator["name"], set()).update(
|
||||
core["name"] for core in emulator.get("cores") or []
|
||||
)
|
||||
if not pairs:
|
||||
raise ValueError("es_systems.json names no emulator")
|
||||
return pairs
|
||||
|
||||
|
||||
def standalone_names(emulator: str) -> list[str]:
|
||||
"""The names a profile may know a standalone emulator by.
|
||||
|
||||
The OS's own spelling, its lowercase, and the lowercase without the
|
||||
`sa` suffix the ELEC family appends to a standalone build of an emulator
|
||||
that also exists as a core (`HATARISA`, `scummvmsa`, `yabasanshiroSA`).
|
||||
"""
|
||||
lower = emulator.lower()
|
||||
names = [emulator, lower]
|
||||
stripped = re.sub(r"-?sa$", "", lower)
|
||||
if stripped:
|
||||
names.append(stripped)
|
||||
return list(dict.fromkeys(names))
|
||||
|
||||
|
||||
def es_cores(
|
||||
pairs: dict[str, set[str]],
|
||||
runners: dict[str, str | None],
|
||||
renamed: dict[str, str] | None = None,
|
||||
) -> tuple[list[str], list[str], list[str]]:
|
||||
"""(cores, standalone_cores, dual_mode_cores) of an ES system list.
|
||||
|
||||
`runners` maps each emulator that runs libretro cores to None when the
|
||||
cores it lists are libretro core names, or to the one core it always
|
||||
loads (AmberELEC's `mame` lists MAME machines and loads the MAME core).
|
||||
`renamed` gives the emulator a standalone name stands for when the
|
||||
suffix rule cannot (`PPSSPPSDL` is PPSSPP), each one read in the OS's
|
||||
launch code.
|
||||
"""
|
||||
renamed = renamed or {}
|
||||
libretro: set[str] = set()
|
||||
for runner, fixed in runners.items():
|
||||
if runner in pairs:
|
||||
libretro.update([fixed] if fixed else pairs[runner])
|
||||
standalone: set[str] = set()
|
||||
for emulator in pairs:
|
||||
if emulator in runners:
|
||||
continue
|
||||
standalone.update(standalone_names(emulator))
|
||||
if emulator in renamed:
|
||||
standalone.add(renamed[emulator])
|
||||
dual = {name for name in standalone if name in libretro}
|
||||
return sorted(libretro | standalone), sorted(standalone), sorted(dual)
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scraper for KNULLI's knulli-systems.
|
||||
|
||||
Source: package/system/knulli-scripts/scripts/knulli-systems
|
||||
Format: Python source, Batocera's `systems` dict, parsed by batocera_scraper
|
||||
Hash: MD5
|
||||
|
||||
`msx1` and `msx2+` are written twice in the dict. Python keeps the later
|
||||
value of a repeated key when the script runs, and so does the parser.
|
||||
|
||||
Verification (knulli-systems at the release tag):
|
||||
- checkBios (lines 867-927): md5sum() of the file at /userdata/<file>
|
||||
against the declared md5, an empty md5 meaning presence only. For a
|
||||
zippedFile entry, checkInsideZip() (lines 814-845) matches the member name
|
||||
with casefold() and hashes it. A system or file carrying `emulator` and
|
||||
`core` is checked only when es_systems.cfg offers that pair (coreExists,
|
||||
lines 847-865).
|
||||
- Run by EmulationStation's BIOS screen (knulli-cfw/batocera-emulationstation
|
||||
10834d00, es-app/src/ApiSystem.cpp:764-774) and by knulli-support:69.
|
||||
|
||||
es_systems.yml and configgen-defaults.yml have Batocera's shape, so the cores
|
||||
are read with Batocera's code. The configgen generators do not: the hatari,
|
||||
melonds and gsplus generators and libretroOptions.py are older forms than the
|
||||
ones batocera_launchers reads, so no launcher layer is written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .base_scraper import BaseScraper, BiosRequirement, fetch_github_latest_version
|
||||
from .batocera_scraper import (
|
||||
SYSTEM_SLUG_MAP as BATOCERA_SLUGS,
|
||||
)
|
||||
from .batocera_scraper import (
|
||||
_not_cores,
|
||||
configgen_cores,
|
||||
extract_systems_dict,
|
||||
platform_systems,
|
||||
requirements_from_systems,
|
||||
)
|
||||
|
||||
PLATFORM_NAME = "knulli"
|
||||
|
||||
REPO = "knulli-cfw/knulli-linux"
|
||||
_RAW = "https://raw.githubusercontent.com/knulli-cfw/knulli-linux/{ref}/"
|
||||
SOURCE_TEMPLATE = _RAW + "package/system/knulli-scripts/scripts/knulli-systems"
|
||||
CONFIGGEN_DEFAULTS_TEMPLATE = (
|
||||
_RAW + "package/system/knulli-configgen/configs/configgen-defaults.yml"
|
||||
)
|
||||
ES_SYSTEMS_TEMPLATE = _RAW + "package/emulationstation/knulli-es-system/es_systems.yml"
|
||||
|
||||
# Batocera's names, plus the keys KNULLI writes that Batocera does not,
|
||||
# filed under the slug the other scrapers already give them. msu-md holds the
|
||||
# Mega-CD BIOS files Batocera files under megadrive-msu.
|
||||
SYSTEM_SLUG_MAP = {
|
||||
**BATOCERA_SLUGS,
|
||||
"o2em": "magnavox-odyssey2",
|
||||
"msu-md": "sega-mega-cd",
|
||||
"xbox": "microsoft-xbox",
|
||||
}
|
||||
|
||||
|
||||
class Scraper(BaseScraper):
|
||||
"""Scraper for KNULLI's knulli-systems."""
|
||||
|
||||
def __init__(self, url: str | None = None) -> None:
|
||||
self.tag = "" if url else fetch_github_latest_version(REPO)
|
||||
super().__init__(url=url or SOURCE_TEMPLATE.format(ref=self.tag))
|
||||
|
||||
def _fetch_text(self, url: str) -> str:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "retrobios-scraper/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
except urllib.error.URLError as e:
|
||||
raise ConnectionError(f"Failed to fetch {url}: {e}") from e
|
||||
|
||||
def validate_format(self, raw_data: str) -> bool:
|
||||
has_dict = re.search(r"^systems\s*=\s*\{", raw_data, re.MULTILINE) is not None
|
||||
return has_dict and "def checkBios" in raw_data and '"biosFiles"' in raw_data
|
||||
|
||||
def fetch_requirements(self) -> list[BiosRequirement]:
|
||||
raw = self._fetch_raw()
|
||||
if not self.validate_format(raw):
|
||||
raise ValueError("knulli-systems format validation failed")
|
||||
return requirements_from_systems(extract_systems_dict(raw), SYSTEM_SLUG_MAP)
|
||||
|
||||
def generate_platform_yaml(self) -> dict:
|
||||
systems = platform_systems(
|
||||
self.fetch_requirements(), extract_systems_dict(self._fetch_raw()), SYSTEM_SLUG_MAP
|
||||
)
|
||||
# es_systems.yml files machine selectors and plugins under an
|
||||
# emulator as Batocera's does: Batocera's list of them applies.
|
||||
cores, standalone, dual = configgen_cores(
|
||||
self._fetch_text(CONFIGGEN_DEFAULTS_TEMPLATE.format(ref=self.tag)),
|
||||
self._fetch_text(ES_SYSTEMS_TEMPLATE.format(ref=self.tag)),
|
||||
_not_cores("batocera"),
|
||||
)
|
||||
result = {
|
||||
"platform": "KNULLI",
|
||||
"version": self.tag,
|
||||
"homepage": "https://knulli.org",
|
||||
"source": self.url,
|
||||
"base_destination": "bios",
|
||||
"hash_type": "md5",
|
||||
"verification_mode": "md5",
|
||||
"cores": cores,
|
||||
"systems": systems,
|
||||
}
|
||||
if standalone:
|
||||
result["standalone_cores"] = standalone
|
||||
if dual:
|
||||
result["dual_mode_cores"] = dual
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
from .base_scraper import scraper_cli
|
||||
|
||||
scraper_cli(Scraper, "Scrape KNULLI BIOS requirements")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -152,6 +152,22 @@ class DownloadLinksNameTheFile(unittest.TestCase):
|
||||
for asset in pack["assets"]:
|
||||
self.assertIn(f"/releases/download/{RECORD['tag']}/{asset})", page)
|
||||
|
||||
def test_a_platform_the_release_lacks_has_no_row(self):
|
||||
"""KNULLI, AmberELEC and EmuELEC were registered after the recorded
|
||||
release: a row for them linked a release page holding no such pack."""
|
||||
if not RECORD.get("packs"):
|
||||
self.skipTest("release.json is not written")
|
||||
from common import list_registered_platforms, load_platform_config
|
||||
from release_record import pack_for
|
||||
|
||||
page = _which_pack()
|
||||
for key in list_registered_platforms("platforms", include_archived=True):
|
||||
if pack_for(key, RECORD) is not None:
|
||||
continue
|
||||
label = load_platform_config(key, "platforms").get("platform", key)
|
||||
with self.subTest(platform=key):
|
||||
self.assertNotIn(f"| [{label}](", page)
|
||||
|
||||
|
||||
class SlicesLeaveWithTheirRelease(unittest.TestCase):
|
||||
"""Parts named `.zip.001` were cut by v2026.09.04, deleted since. A page
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""AmberELEC, EmuELEC and KNULLI publish Batocera's `systems` dict.
|
||||
|
||||
Each scraper reads its list with batocera_scraper's parser, so a dict written
|
||||
with a key twice keeps the later value as Python does, paths keep the folder
|
||||
the OS's checker prefixes, and the cores come from the OS's own
|
||||
EmulationStation list. Trimmed copies of the three lists stand in for the
|
||||
upstream files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from scraper import amberelec_scraper, emuelec_scraper, knulli_scraper # noqa: E402
|
||||
from scraper.es_emulators import es_cores, json_emulators, xml_emulators # noqa: E402
|
||||
|
||||
CHECKER = '''
|
||||
|
||||
def checkBios(systems, prefix):
|
||||
pass
|
||||
'''
|
||||
|
||||
AMBERELEC = '''#!/usr/bin/env python
|
||||
systems = {
|
||||
"atarilynx": {"name": "Lynx", "biosFiles": [{"md5": "fcd403db69f54290b51035d82f835e7b", "file": "bios/lynxboot.img"}]},
|
||||
# Neo Geo
|
||||
"neogeo": {"name": "NeoGeo", "biosFiles": [{"md5": "", "file": "neogeo/neogeo.zip"},
|
||||
{"md5": "", "file": "neogeo/aes.zip"}]},
|
||||
}
|
||||
''' + CHECKER
|
||||
|
||||
EMUELEC = '''#!/usr/bin/env python
|
||||
systems = {
|
||||
"naomi": { "name": "Naomi", "biosFiles": [ { "md5": "e63d892cdb8b532351dc7020bb60b6f4", "file": "bios/dc/naomi.zip" } ] },
|
||||
"fmtowns": { "name": "Fujistu FM-Towns", "biosFiles": [
|
||||
{ "md5": "34847786d7de94b5d1133c956ab1d75d", "zippedFile": "fmt_dic.rom", "file": "bios/fmtowns.zip"} ] },
|
||||
"Diablo": { "name": "Diablo 1", "biosFiles": [ { "md5": "", "file": "ports/diablo/diabdat.mpq" } ] },
|
||||
"naomi": { "name": "Naomi", "biosFiles": [ { "md5": "3bffafac42a7767d8dcecf771f5552ba", "file": "bios/dc/naomi_boot.bin" },
|
||||
{ "md5": "e63d892cdb8b532351dc7020bb60b6f4", "file": "bios/dc/naomi.zip" } ] },
|
||||
}
|
||||
''' + CHECKER
|
||||
|
||||
KNULLI = '''#!/usr/bin/env python
|
||||
systems = {
|
||||
"msx1": { "name": "MSX1", "biosFiles": [ { "md5": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "file": "bios/MSX.ROM" } ] },
|
||||
"msu-md": { "name": "MSU-MD", "biosFiles": [ { "md5": "e66fa1dc5820d254611fdcdba0662372", "file": "bios/bios_CD_E.bin" } ] },
|
||||
"msx1": { "name": "MSX1", "emulator": "libretro", "core": "bluemsx", "biosFiles": [
|
||||
{ "md5": "", "file": "bios/Machines/Shared Roms/MSX.rom" } ] },
|
||||
}
|
||||
''' + CHECKER
|
||||
|
||||
AMBERELEC_ES = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<systemList>
|
||||
<system><name>advision</name><emulators>
|
||||
<emulator name="mame"><cores><core default="true">advision</core></cores></emulator>
|
||||
</emulators></system>
|
||||
<system><name>atarist</name><emulators>
|
||||
<emulator name="retroarch"><cores><core>hatari</core></cores></emulator>
|
||||
<emulator name="HATARISA"><cores><core>HATARISA</core></cores></emulator>
|
||||
</emulators></system>
|
||||
<system><name>psp</name><emulators>
|
||||
<emulator name="retroarch"><cores><core>ppsspp</core></cores></emulator>
|
||||
<emulator name="PPSSPPSDL"><cores><core>PPSSPPSDL</core></cores></emulator>
|
||||
</emulators></system>
|
||||
<system><name>dreamcast</name><emulators>
|
||||
<emulator name="retrorun"><cores><core>flycast</core></cores></emulator>
|
||||
</emulators></system>
|
||||
</systemList>
|
||||
"""
|
||||
|
||||
EMUELEC_ES = """{"systems": [
|
||||
{"name": "imageviewer", "command": "ignore"},
|
||||
{"name": "saturn", "emulators": [
|
||||
{"name": "libretro", "cores": [{"name": "yabasanshiro"}]},
|
||||
{"name": "yabasanshiro", "cores": [{"name": "yabasanshiroSA"}]}]},
|
||||
{"name": "atari2600", "emulators": [{"name": "STELLASA", "cores": [{"name": "STELLASA"}]}]}
|
||||
]}"""
|
||||
|
||||
|
||||
def _scraper(module, raw: str):
|
||||
scraper = module.Scraper(url="https://example.test/list")
|
||||
scraper._raw_data = raw
|
||||
return scraper
|
||||
|
||||
|
||||
class TheListsReadAsPythonReadsThem(unittest.TestCase):
|
||||
def test_amberelec_keeps_paths_beside_bios(self):
|
||||
reqs = _scraper(amberelec_scraper, AMBERELEC).fetch_requirements()
|
||||
self.assertEqual(
|
||||
[(r.system, r.destination, r.native_id) for r in reqs],
|
||||
[("atari-lynx", "bios/lynxboot.img", "atarilynx"),
|
||||
("snk-neogeo", "neogeo/neogeo.zip", "neogeo"),
|
||||
("snk-neogeo", "neogeo/aes.zip", "neogeo")],
|
||||
)
|
||||
|
||||
def test_emuelec_keeps_the_later_of_a_repeated_key(self):
|
||||
reqs = _scraper(emuelec_scraper, EMUELEC).fetch_requirements()
|
||||
naomi = [r.destination for r in reqs if r.native_id == "naomi"]
|
||||
self.assertEqual(naomi, ["bios/dc/naomi_boot.bin", "bios/dc/naomi.zip"])
|
||||
self.assertEqual(len(reqs), 4)
|
||||
|
||||
def test_emuelec_files_a_port_under_its_engine_system(self):
|
||||
reqs = _scraper(emuelec_scraper, EMUELEC).fetch_requirements()
|
||||
diablo = next(r for r in reqs if r.native_id == "Diablo")
|
||||
self.assertEqual((diablo.system, diablo.destination),
|
||||
("diablo", "ports/diablo/diabdat.mpq"))
|
||||
|
||||
def test_emuelec_carries_the_member_its_list_names(self):
|
||||
reqs = _scraper(emuelec_scraper, EMUELEC).fetch_requirements()
|
||||
towns = next(r for r in reqs if r.native_id == "fmtowns")
|
||||
self.assertEqual(towns.zipped_file, "fmt_dic.rom")
|
||||
|
||||
def test_knulli_strips_bios_and_keeps_the_later_key(self):
|
||||
reqs = _scraper(knulli_scraper, KNULLI).fetch_requirements()
|
||||
self.assertEqual(
|
||||
[(r.system, r.destination, r.native_path) for r in reqs],
|
||||
[("microsoft-msx", "Machines/Shared Roms/MSX.rom", "bios/Machines/Shared Roms/MSX.rom"),
|
||||
("sega-mega-cd", "bios_CD_E.bin", "bios/bios_CD_E.bin")],
|
||||
)
|
||||
|
||||
def test_a_list_without_its_checker_is_refused(self):
|
||||
for module, raw in ((amberelec_scraper, AMBERELEC), (emuelec_scraper, EMUELEC),
|
||||
(knulli_scraper, KNULLI)):
|
||||
with self.subTest(scraper=module.__name__), self.assertRaises(ValueError):
|
||||
_scraper(module, raw.replace("def checkBios", "def other")).fetch_requirements()
|
||||
|
||||
|
||||
class TheCoresComeFromTheOsList(unittest.TestCase):
|
||||
def test_amberelec_runners_and_standalone_names(self):
|
||||
cores, standalone, dual = es_cores(
|
||||
xml_emulators(AMBERELEC_ES), amberelec_scraper.RUNNERS, amberelec_scraper.RENAMED
|
||||
)
|
||||
# mame lists MAME machines and loads the MAME core: advision is no core.
|
||||
self.assertIn("mame", cores)
|
||||
self.assertNotIn("advision", cores)
|
||||
self.assertNotIn("mame", standalone)
|
||||
self.assertIn("flycast", cores)
|
||||
self.assertTrue({"HATARISA", "hatari", "PPSSPPSDL", "ppsspp"} <= set(standalone))
|
||||
self.assertEqual(dual, ["hatari", "ppsspp"])
|
||||
|
||||
def test_emuelec_runners_and_standalone_names(self):
|
||||
cores, standalone, dual = es_cores(
|
||||
json_emulators(EMUELEC_ES), emuelec_scraper.RUNNERS, emuelec_scraper.RENAMED
|
||||
)
|
||||
self.assertIn("stella", standalone)
|
||||
self.assertNotIn("yabasanshiroSA", cores)
|
||||
self.assertEqual(dual, ["yabasanshiro"])
|
||||
|
||||
def test_the_newest_prerelease_names_its_commit(self):
|
||||
body = "This is a prerelease based on the commit: AmberELEC/AmberELEC@" + "a" * 40 + "."
|
||||
self.assertEqual(amberelec_scraper.prerelease_commit(body), "a" * 40)
|
||||
with self.assertRaises(RuntimeError):
|
||||
amberelec_scraper.prerelease_commit("no commit here")
|
||||
|
||||
|
||||
class CoreFilesGoUnderTheStatedFolder(unittest.TestCase):
|
||||
"""EmuELEC's ports/ entries are a quarter of its list, so the most common
|
||||
root of its destinations fell under the 90% rule and core files went to
|
||||
/storage/roms itself, where no core reads them."""
|
||||
|
||||
def test_core_destination_wins_over_the_guess(self):
|
||||
from common import declared_root
|
||||
|
||||
files = [{"destination": f"bios/f{i}"} for i in range(3)]
|
||||
files.append({"destination": "ports/diablo/diabdat.mpq"})
|
||||
config = {"base_destination": "", "systems": {"s": {"files": files}}}
|
||||
self.assertEqual(declared_root(config), "")
|
||||
self.assertEqual(declared_root({**config, "core_destination": "bios"}), "bios")
|
||||
self.assertEqual(declared_root({**config, "base_destination": "bios",
|
||||
"core_destination": "x"}), "")
|
||||
|
||||
def test_the_platforms_state_it(self):
|
||||
import yaml
|
||||
|
||||
for name in ("amberelec", "emuelec"):
|
||||
with self.subTest(platform=name):
|
||||
config = yaml.safe_load((REPO_ROOT / "platforms" / f"{name}.yml").read_text())
|
||||
self.assertEqual((config["base_destination"], config["core_destination"]),
|
||||
("", "bios"))
|
||||
|
||||
|
||||
class TheExportKeepsTheirShape(unittest.TestCase):
|
||||
def test_an_indented_closing_brace_is_found(self):
|
||||
from exporter.emuelec_exporter import Exporter
|
||||
|
||||
head, body, tail = Exporter._split_original(EMUELEC)
|
||||
self.assertEqual(head[-1], "systems = {")
|
||||
self.assertEqual(tail[0], " }")
|
||||
self.assertIn("Diablo", "\n".join(body))
|
||||
|
||||
def test_paths_start_where_the_list_starts(self):
|
||||
from exporter.amberelec_exporter import Exporter as Amber
|
||||
from exporter.baseline import NativeFile
|
||||
from exporter.knulli_exporter import Exporter as Knulli
|
||||
|
||||
fe = NativeFile("neogeo.zip", "neogeo/neogeo.zip", "neogeo")
|
||||
self.assertEqual(Amber.declared_path(fe), "neogeo/neogeo.zip")
|
||||
fe = NativeFile("bios_CD_E.bin", "bios_CD_E.bin", "msu-md")
|
||||
self.assertEqual(Knulli.declared_path(fe), "bios/bios_CD_E.bin")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -586,7 +586,7 @@ class ExporterRegistry(unittest.TestCase):
|
||||
def test_an_exporter_that_patches_code_says_it_needs_the_original(self):
|
||||
"""Regenerating a script from data alone drops the code around it."""
|
||||
for name in ("batocera", "rocknix", "emudeck", "retrodeck", "bizhawk",
|
||||
"misterfpga"):
|
||||
"misterfpga", "knulli", "amberelec", "emuelec"):
|
||||
with self.subTest(platform=name):
|
||||
self.assertTrue(discover_exporters()[name].needs_original())
|
||||
|
||||
|
||||
+26
-13
@@ -1088,7 +1088,7 @@ class DetectionFollowsTheRegistry(unittest.TestCase):
|
||||
lambda p: str(p) in ("/usr/share/knulli/knulli.version",
|
||||
"/usr/share/batocera/batocera.version"),
|
||||
):
|
||||
self.assertEqual(install._detect_embedded(), [("batocera", Path("/userdata/bios"))])
|
||||
self.assertEqual(install._detect_embedded(), [("knulli", Path("/userdata/bios"))])
|
||||
# What ROCKNIX writes since 2025: OS_NAME and HW_DEVICE, no ID.
|
||||
with unittest.mock.patch.object(install, "_parse_os_release",
|
||||
return_value={"OS_NAME": "ROCKNIX", "HW_DEVICE": "RK3566"}):
|
||||
@@ -4398,27 +4398,31 @@ class HandheldSystemsByTheirOwnFiles(unittest.TestCase):
|
||||
return root, found[0], names[0]
|
||||
|
||||
def _elec(self, release: str, name: str, extra: dict | None = None,
|
||||
plat: str = "retroarch"):
|
||||
plat: str = "retroarch", folder: str = "storage/roms/bios"):
|
||||
cfg = {self.ELEC_CFG: 'system_directory = "~/roms/bios"\n'}
|
||||
root, (found_plat, path), found_name = self._one({**cfg, **(extra or {})}, release)
|
||||
self.assertEqual((found_plat, path, found_name),
|
||||
(plat, root / "storage/roms/bios", name))
|
||||
(plat, root / folder, name))
|
||||
|
||||
# AmberELEC and EmuELEC publish their own lists, written against
|
||||
# /storage/roms (bios/, ports/ and neogeo/ side by side).
|
||||
def test_amberelec(self):
|
||||
self._elec('NAME="AmberELEC"\nID="coreelec"\n', "AmberELEC",
|
||||
plat=install._STANDIN_MANIFESTS["AmberELEC"])
|
||||
plat="amberelec", folder="storage/roms")
|
||||
|
||||
def test_chaos_names_itself_amberelec(self):
|
||||
# tech4bot/AmberELEC writes the same os-release as AmberELEC.
|
||||
self._elec('NAME="AmberELEC"\nID="coreelec"\nCOREELEC_DEVICE="RG552"\n', "AmberELEC")
|
||||
self._elec('NAME="AmberELEC"\nID="coreelec"\nCOREELEC_DEVICE="RG552"\n', "AmberELEC",
|
||||
plat="amberelec", folder="storage/roms")
|
||||
|
||||
def test_pan4elec_over_amberelec(self):
|
||||
self._elec('NAME="AmberELEC"\n', "PAN4ELEC",
|
||||
{"/usr/bin/batocera-info": 'echo "OPERATING SYSTEM: PAN4ELEC"\n'})
|
||||
{"/usr/bin/batocera-info": 'echo "OPERATING SYSTEM: PAN4ELEC"\n'},
|
||||
plat="amberelec", folder="storage/roms")
|
||||
|
||||
def test_emuelec(self):
|
||||
self._elec('NAME="EmuELEC"\nID="emuelec"\n', "EmuELEC",
|
||||
plat=install._STANDIN_MANIFESTS["EmuELEC"])
|
||||
plat="emuelec", folder="storage/roms")
|
||||
|
||||
def test_jelos(self):
|
||||
self._elec('OS_NAME="JELOS"\nHW_DEVICE="RK3566"\n', "JELOS")
|
||||
@@ -4560,9 +4564,9 @@ class HandheldSystemsByTheirOwnFiles(unittest.TestCase):
|
||||
self.assertIn("JELOS and Koriki are both detected here", err.getvalue())
|
||||
|
||||
def test_the_system_is_announced_by_its_own_name(self):
|
||||
root, found, _name = self._one({self.ELEC_CFG: ""}, 'NAME="AmberELEC"\n')
|
||||
root, found, _name = self._one({self.ELEC_CFG: ""}, 'OS_NAME="JELOS"\n')
|
||||
self.assertEqual(install.found_label(*found),
|
||||
f"AmberELEC at {root / 'storage/roms/bios'} (RetroArch files)")
|
||||
f"JELOS at {root / 'storage/roms/bios'} (RetroArch files)")
|
||||
|
||||
def test_list_platforms_names_every_system(self):
|
||||
with unittest.mock.patch.object(sys, "argv", ["install.py", "--list-platforms"]), \
|
||||
@@ -4592,16 +4596,25 @@ class BatoceraAndKnulliByWhatTheirBuildsWrite(unittest.TestCase):
|
||||
|
||||
def test_knulli_by_its_os_release_or_version_file(self):
|
||||
self.assertEqual(self._detect({"OS_NAME": "knulli"}, ()),
|
||||
[("batocera", Path("/userdata/bios"))])
|
||||
[("knulli", Path("/userdata/bios"))])
|
||||
self.assertEqual(self._detect({}, ("/usr/share/knulli/knulli.version",)),
|
||||
[("batocera", Path("/userdata/bios"))])
|
||||
[("knulli", Path("/userdata/bios"))])
|
||||
|
||||
def test_amberelec_and_emuelec_by_their_os_release(self):
|
||||
"""Both write os-release from DISTRONAME in scripts/image. AmberELEC
|
||||
keeps CoreELEC's ID, so its NAME tells it apart."""
|
||||
self.assertEqual(self._detect({"NAME": "AmberELEC", "ID": "coreelec"}, ()),
|
||||
[("amberelec", Path("/storage/roms"))])
|
||||
self.assertEqual(self._detect({"NAME": "EmuELEC", "ID": "emuelec"}, ()),
|
||||
[("emuelec", Path("/storage/roms"))])
|
||||
self.assertEqual(self._detect({"NAME": "CoreELEC", "ID": "coreelec"}, ()), [])
|
||||
|
||||
def test_a_knulli_board_is_named_and_not_narrowed(self):
|
||||
board = Path(_tempdir(self)) / "knulli.arch"
|
||||
board.write_text("a133")
|
||||
with unittest.mock.patch.dict(install._DERIVATIVE_BOARDS,
|
||||
{"batocera": ("Knulli", str(board))}):
|
||||
self.assertEqual(install.derivative_board("batocera"), ("Knulli", "a133"))
|
||||
{"knulli": ("Knulli", str(board))}):
|
||||
self.assertEqual(install.derivative_board("knulli"), ("Knulli", "a133"))
|
||||
self.assertIsNone(install.derivative_board("recalbox"))
|
||||
|
||||
def test_the_run_says_why_every_board_is_installed(self):
|
||||
|
||||
@@ -32,6 +32,7 @@ EMULATORS = REPO_ROOT / "emulators"
|
||||
PLATFORM_WORDS = (
|
||||
"retroarch", "batocera", "recalbox", "retrobat", "emudeck", "lakka",
|
||||
"retrodeck", "romm", "rocknix", "retropie", "bizhawk", "mister",
|
||||
"knulli", "amberelec", "emuelec",
|
||||
)
|
||||
|
||||
# Launcher sources: configgen and emulatorLauncher generators, EmuDeck and
|
||||
|
||||
@@ -558,6 +558,9 @@ class UnreadableReleaseStopsTheScrape(unittest.TestCase):
|
||||
"scraper.emudeck_scraper",
|
||||
"scraper.misterfpga_scraper",
|
||||
"scraper.retropie_scraper",
|
||||
"scraper.amberelec_scraper",
|
||||
"scraper.emuelec_scraper",
|
||||
"scraper.knulli_scraper",
|
||||
):
|
||||
module = importlib.import_module(name)
|
||||
with self.subTest(scraper=name), mock.patch(
|
||||
@@ -839,8 +842,11 @@ class OneNativeIdOneSystem(unittest.TestCase):
|
||||
# A platform system no profile names gets no core extra and answers no
|
||||
# --system. Named here: Batocera's family folds Naomi and Atomiswave into
|
||||
# one system the profiles keep apart, and MiSTer cores nothing profiles.
|
||||
# EmuELEC lists Duke Nukem 2's data for RigelEngine, which no profile
|
||||
# describes yet.
|
||||
UNMATCHED = {
|
||||
"*": {"sega-dreamcast-arcade"},
|
||||
"emuelec": {"RigelEngine"},
|
||||
"misterfpga": {"gameking", "interact", "marty", "studio-ii", "superacan",
|
||||
"videobrain"},
|
||||
}
|
||||
|
||||
@@ -250,7 +250,10 @@ systems:
|
||||
|
||||
`base_destination` is what the pack prefixes every entry with. Leave it empty
|
||||
when the upstream destinations already carry their own root, as RetroDECK does
|
||||
with `bios/` and `roms/`.
|
||||
with `bios/` and `roms/`. Core files then go under the root most destinations
|
||||
share, or under `core_destination` when the list names other folders too:
|
||||
AmberELEC and EmuELEC declare `neogeo/` and `ports/` beside `bios/`, and set
|
||||
`core_destination: bios`.
|
||||
|
||||
### Launchers
|
||||
|
||||
@@ -411,7 +414,7 @@ to use:
|
||||
| Mode | Behavior | Example platforms |
|
||||
|------|----------|-------------------|
|
||||
| `existence` | File must exist, no hash check | RetroArch, Lakka, RetroPie |
|
||||
| `md5` | MD5 must match the declared hash | Batocera, Recalbox, RetroBat, EmuDeck, RetroDECK, RomM, ROCKNIX, MiSTer FPGA |
|
||||
| `md5` | MD5 must match the declared hash | Batocera, Recalbox, RetroBat, EmuDeck, RetroDECK, RomM, ROCKNIX, KNULLI, AmberELEC, EmuELEC, MiSTer FPGA |
|
||||
| `sha1` | SHA1 must match | BizHawk |
|
||||
|
||||
Inheriting platforms take the parent's mode: Lakka and RetroPie do not declare
|
||||
|
||||
+2
-1
@@ -212,7 +212,8 @@ checks its DSP ROMs (`dsp_rom.bin` and `dsp_coef.bin`, hashed byte-swapped).
|
||||
`IPL.bin` is not one of them: Dolphin loads it without a hash check.
|
||||
|
||||
Verification uses whichever one the platform itself uses: MD5 for Batocera,
|
||||
RetroBat, Recalbox, EmuDeck, RetroDECK, RomM, ROCKNIX and MiSTer FPGA, SHA1 for
|
||||
RetroBat, Recalbox, EmuDeck, RetroDECK, RomM, ROCKNIX, KNULLI, AmberELEC, EmuELEC
|
||||
and MiSTer FPGA, SHA1 for
|
||||
BizHawk, and nothing at all for RetroArch, Lakka and RetroPie, which only check
|
||||
that the file exists.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ touched.
|
||||
## Step 1. Run the command
|
||||
|
||||
On Linux, macOS, a Steam Deck, or a handheld running Batocera, Recalbox,
|
||||
KNULLI, ROCKNIX or Lakka:
|
||||
KNULLI, ROCKNIX, AmberELEC, EmuELEC or Lakka:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh
|
||||
|
||||
+18
-1
@@ -198,6 +198,22 @@ others) whose files only the RetroPie pack carries, laid out for
|
||||
|
||||
Accessible via SSH or Samba, like Lakka.
|
||||
|
||||
### KNULLI
|
||||
|
||||
```
|
||||
/userdata/bios/
|
||||
```
|
||||
|
||||
### AmberELEC and EmuELEC
|
||||
|
||||
```
|
||||
/storage/roms/
|
||||
```
|
||||
|
||||
Their BIOS lists name files under `/storage/roms`: `bios/` for most, and
|
||||
`neogeo/` (and `ports/` on EmuELEC) for the rest. The pack holds those
|
||||
folders, so it is extracted into `/storage/roms/` itself.
|
||||
|
||||
### MiSTer FPGA
|
||||
|
||||
```
|
||||
@@ -247,7 +263,8 @@ dump shows up.
|
||||
The installer's `--check` hashes every file against the list on every
|
||||
platform, so it catches a wrong revision even where the frontend would not:
|
||||
RetroArch, Lakka and RetroPie only check that a file exists, while Batocera,
|
||||
RetroBat, Recalbox, EmuDeck, RetroDECK, RomM, ROCKNIX and MiSTer FPGA compare
|
||||
RetroBat, Recalbox, EmuDeck, RetroDECK, RomM, ROCKNIX, KNULLI, AmberELEC,
|
||||
EmuELEC and MiSTer FPGA compare
|
||||
MD5 and BizHawk compares SHA1.
|
||||
|
||||
For a single system, or the files a single emulator core reads (`--list-systems`
|
||||
|
||||
+14
-14
@@ -301,19 +301,19 @@ rewrites both to `/roms2/bios`: a second card is followed through them.
|
||||
| Evidence | System | File list | BIOS directory |
|
||||
|----------|--------|-----------|----------------|
|
||||
| `/etc/os-release` `OS_NAME=ROCKNIX` (or `ID=rocknix` on images before 2025) | ROCKNIX | ROCKNIX | `/storage/roms/bios` |
|
||||
| `/etc/os-release` `OS_NAME=knulli` or `/usr/share/knulli/knulli.version` | KNULLI | Batocera | `/userdata/bios` |
|
||||
| `/etc/os-release` `OS_NAME=knulli` or `/usr/share/knulli/knulli.version` | KNULLI | KNULLI | `/userdata/bios` |
|
||||
| `/etc/os-release` `ID=lakka` | Lakka | Lakka | `/storage/system` |
|
||||
| `/usr/share/batocera/batocera.version` | Batocera | Batocera | `/userdata/bios` |
|
||||
| `/recalbox/recalbox.version` or `/usr/bin/recalbox-settings` | Recalbox | Recalbox | `/recalbox/share/bios` |
|
||||
| `/opt/muos` | muOS | RetroArch | `system_directory` of `/opt/muos/share/info/config/retroarch.cfg`, else of the default it is copied from: `/run/muos/storage/bios`, which muOS binds to `MUOS/bios` on the second card when that card holds one, else the first. Cores are read from that config's `libretro_directory` (`/opt/muos/share/core`) |
|
||||
| `/mnt/mmc/MUOS/` | muOS | RetroArch | `/mnt/mmc/MUOS/bios` |
|
||||
| `/etc/os-release` `NAME=AmberELEC` (CHAos writes the same) | AmberELEC | RetroArch | `/storage/.config/retroarch/retroarch.cfg`, else `/storage/roms/bios` |
|
||||
| `/usr/bin/batocera-info` naming PAN4ELEC, on AmberELEC | PAN4ELEC | RetroArch | as AmberELEC |
|
||||
| `/etc/os-release` `NAME=EmuELEC` | EmuELEC | RetroArch | as AmberELEC |
|
||||
| `/etc/os-release` `OS_NAME=JELOS` | JELOS | RetroArch | as AmberELEC |
|
||||
| `/usr/lib/systemd/system/jelos-ready.service`, on JELOS | KOLOS | RetroArch | as AmberELEC |
|
||||
| `/etc/os-release` `OS_NAME=UnofficialOS` | UnofficialOS | RetroArch | as AmberELEC |
|
||||
| `/etc/os-release` `OS_NAME=ArchR` | ArchR | RetroArch | as AmberELEC |
|
||||
| `/etc/os-release` `NAME=AmberELEC` (CHAos writes the same) | AmberELEC | AmberELEC | `/storage/roms` (its list names `bios/`, `ports/` and `neogeo/` there) |
|
||||
| `/usr/bin/batocera-info` naming PAN4ELEC, on AmberELEC | PAN4ELEC | AmberELEC | as AmberELEC |
|
||||
| `/etc/os-release` `NAME=EmuELEC` | EmuELEC | EmuELEC | as AmberELEC |
|
||||
| `/etc/os-release` `OS_NAME=JELOS` | JELOS | RetroArch | `/storage/.config/retroarch/retroarch.cfg`, else `/storage/roms/bios` |
|
||||
| `/usr/lib/systemd/system/jelos-ready.service`, on JELOS | KOLOS | RetroArch | as JELOS |
|
||||
| `/etc/os-release` `OS_NAME=UnofficialOS` | UnofficialOS | RetroArch | as JELOS |
|
||||
| `/etc/os-release` `OS_NAME=ArchR` | ArchR | RetroArch | as JELOS |
|
||||
| `/home/ark` and `/opt/system`, or `title=ArkOS` in `/usr/share/plymouth/themes/text.plymouth` | ArkOS | RetroArch | `/home/ark/.config/retroarch/retroarch.cfg` then `retroarch32/retroarch.cfg`, else `/roms/bios` |
|
||||
| `title=dArkOS` in the same file (the dArkOSRE, dArkOSen and ArkOS4Clone images keep it) | dArkOS | RetroArch | as ArkOS |
|
||||
| `title=RetroOZ` in the same file, `/opt/.retrooz` or `/etc/retrooz_version` | RetroOZ | RetroArch | `/home/odroid/.config/retroarch/retroarch.cfg` then `retroarch32/retroarch.cfg`, else `/roms/bios` |
|
||||
@@ -329,17 +329,16 @@ rewrites both to `/roms2/bios`: a second card is followed through them.
|
||||
| `/mnt/SDCARD/.system/res` or `/mnt/sdcard/.system/res` | MinUI | none | MinUI, MyMinUI and NextUI give each core its own `Bios/<tag>/`, a layout not written yet: named, not installed to |
|
||||
| `/mnt/vendor/bin/dmenu.bin` | Anbernic stock firmware | RetroArch | `/mnt/mmc/bios` |
|
||||
|
||||
AmberELEC, EmuELEC and KNULLI get the file list of the platform whose cores
|
||||
they ship until a list of their own is published. TheRA is not detected: no
|
||||
AmberELEC, EmuELEC and KNULLI install the BIOS list they publish themselves. TheRA is not detected: no
|
||||
image or source of its Odroid Go builds is published, so neither its marker
|
||||
files nor its folder could be read.
|
||||
|
||||
Batocera, Recalbox, ROCKNIX and Lakka also name the board they were built for, and
|
||||
that board narrows the install unless `--target` or `--every-board` says
|
||||
otherwise (see the options above). A board with no core list of its own is
|
||||
named and gets the files of every board. Knulli names its board in
|
||||
`/usr/share/knulli/knulli.arch` but builds its own set of cores, so Batocera's
|
||||
per-board lists are not applied to it and the run says so.
|
||||
named and gets the files of every board. KNULLI names its board in
|
||||
`/usr/share/knulli/knulli.arch` but publishes no core list per board, so the
|
||||
run says so and installs the files of every board.
|
||||
|
||||
Some platforms keep their BIOS in a fixed directory, and that directory is the
|
||||
evidence. They are looked for after the configurations below, for a platform
|
||||
@@ -423,7 +422,8 @@ When a platform named with `--platform` is not found on the machine, the
|
||||
destination falls back to the directory its detection looks for:
|
||||
`/userdata/bios` for Batocera, `/recalbox/share/bios` for Recalbox,
|
||||
`/storage/system` for Lakka, `~/retrodeck` for RetroDECK, `~/Emulation/bios`
|
||||
for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms/bios` for ROCKNIX, `/media/fat/games` for MiSTer,
|
||||
for EmuDeck (`C:\Emulation\bios` on Windows), `/storage/roms/bios` for ROCKNIX,
|
||||
`/userdata/bios` for KNULLI, `/storage/roms` for AmberELEC and EmuELEC, `/media/fat/games` for MiSTer,
|
||||
`~/RetroPie/BIOS` for RetroPie, `/romm/library/bios` for RomM,
|
||||
`C:\RetroBat\bios` for RetroBat, and RetroArch's own default system
|
||||
directory for RetroArch. BizHawk has no such folder: it reads `Firmware`
|
||||
|
||||
@@ -45,7 +45,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` |
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_selection` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
|
||||
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
@@ -143,6 +143,9 @@ Verification modes per platform:
|
||||
| RetroDECK | md5 | MD5 per file via component manifests |
|
||||
| RomM | md5 | size + any hash (MD5/SHA1/CRC32), no ZIP inspection |
|
||||
| ROCKNIX | md5 | MD5 hash match, same shape as Batocera |
|
||||
| KNULLI | md5 | Batocera's `checkBios` in `knulli-systems`, `checkInsideZip` and emulator/core gates included |
|
||||
| AmberELEC | md5 | MD5 of each file under `/storage/roms`, `checkInsideZip` for `zippedFile` entries |
|
||||
| EmuELEC | md5 | MD5 of each file under `/storage/roms`; `zippedFile` is never read |
|
||||
| MiSTer FPGA | md5 | MD5 of the file at its destination, no ZIP inspection |
|
||||
| BizHawk | sha1 | SHA1 per firmware from `FirmwareDatabase.cs` |
|
||||
|
||||
@@ -846,6 +849,9 @@ stand alone.
|
||||
| `retrodeck_scraper` | component manifests | JSON per component |
|
||||
| `romm_scraper` | known_bios_files.json | JSON |
|
||||
| `rocknix_scraper` | rocknix-systems script | Python dict |
|
||||
| `knulli_scraper` | knulli-systems script, es_systems.yml and configgen-defaults.yml (cores) | Python dict, YAML |
|
||||
| `amberelec_scraper` | batocera-systems script at the newest prerelease's commit, es_systems.cfg (cores) | Python dict, XML |
|
||||
| `emuelec_scraper` | batocera-systems script, es_systems.json (cores) | Python dict, JSON |
|
||||
| `retropie_scraper` | RetroPie-Setup scriptmodules (`rp_module_id`, `rp_module_help`) and `retropie_packages.sh` (version) | Bash |
|
||||
| `misterfpga_scraper` | BiosDB_MiSTer bios_db.json.zip | JSON in ZIP |
|
||||
| `coreinfo_scraper` | .info files from libretro-core-info | INI-like |
|
||||
@@ -991,6 +997,9 @@ Located in `scripts/exporter/`. Each inherits `BaseExporter`, defined in
|
||||
| `lakka_exporter` | the same `System.dat`, which Lakka reads unchanged |
|
||||
| `batocera_exporter` | the systems block of the `batocera-systems` script, patched entry by entry |
|
||||
| `rocknix_exporter` | the systems mapping of the `rocknix-systems` script, patched the same way |
|
||||
| `knulli_exporter` | the systems mapping of the `knulli-systems` script, patched the same way |
|
||||
| `amberelec_exporter` | AmberELEC's `batocera-systems`, paths from `/storage/roms`, additions without a runner gate |
|
||||
| `emuelec_exporter` | EmuELEC's `batocera-systems`, as AmberELEC's |
|
||||
| `retrobat_exporter` | `batocera-systems.json` |
|
||||
| `recalbox_exporter` | `es_bios.xml`, valid against `es_bios.xsd` |
|
||||
| `emudeck_exporter` | the MD5 lists of the `checkBIOS.sh` functions, patched in place |
|
||||
|
||||
@@ -62,6 +62,8 @@ Each platform expects BIOS files in a specific base directory:
|
||||
- Recalbox: `/recalbox/share/bios/`
|
||||
- RetroPie: `~/RetroPie/BIOS/`
|
||||
- ROCKNIX: `/storage/roms/bios/`
|
||||
- KNULLI: `/userdata/bios/`
|
||||
- AmberELEC and EmuELEC: `/storage/roms/` (the pack holds `bios/` and the other folders their lists name)
|
||||
- MiSTer FPGA: `/media/fat/games/`
|
||||
- BizHawk: `Firmware/` inside the BizHawk directory
|
||||
- EmuDeck: `~/Emulation/bios/`
|
||||
|
||||
@@ -28,7 +28,7 @@ Lakka and RetroPie inherit this behavior through platform config inheritance
|
||||
|
||||
## MD5 mode
|
||||
|
||||
**Platforms**: Batocera, RetroBat, Recalbox, EmuDeck, RetroDECK, RomM, ROCKNIX, MiSTer FPGA
|
||||
**Platforms**: Batocera, RetroBat, Recalbox, EmuDeck, RetroDECK, RomM, ROCKNIX, KNULLI, AmberELEC, EmuELEC, MiSTer FPGA
|
||||
|
||||
All MD5-mode platforms compute a hash of the file and compare it against an expected value.
|
||||
The details vary by platform.
|
||||
@@ -137,6 +137,28 @@ shape: `md5sum()` on the file, `checkInsideZip()` for archive entries, and an
|
||||
platform declares is mandatory in its own data, so the platform YAML marks all
|
||||
38 files `required: true` and none of them use `zippedFile`.
|
||||
|
||||
### KNULLI, AmberELEC and EmuELEC verification
|
||||
|
||||
**Source**: `knulli-systems` (KNULLI) and `batocera-systems` (AmberELEC,
|
||||
EmuELEC), function `checkBios`
|
||||
|
||||
All three keep Batocera's `systems` dict and its checker, run by their
|
||||
EmulationStation's BIOS screen.
|
||||
|
||||
- **KNULLI** runs Batocera's `checkBios` on files under `/userdata`: `md5sum()`,
|
||||
`checkInsideZip()` for `zippedFile` entries, and the `emulator`/`core` gates
|
||||
read from `es_systems.cfg`.
|
||||
- **AmberELEC** runs the same checker on files under `/storage/roms`. Its list
|
||||
sets no gate, and names `neogeo/neogeo.zip` and `neogeo/aes.zip` beside
|
||||
`bios/`, so the pack extracts into `/storage/roms`.
|
||||
- **EmuELEC** hashes the whole file under `/storage/roms` and never reads
|
||||
`zippedFile`: an entry that names a member inside an archive compares the
|
||||
archive's MD5 with the member's and reports `UNTESTED` whatever the archive
|
||||
holds. Its list also names game data under `ports/`.
|
||||
|
||||
A key written twice in the dict (`naomi` and `Dinothawr` in EmuELEC, `msx1`
|
||||
and `msx2+` in KNULLI) keeps its later value, as when Python runs the script.
|
||||
|
||||
### MiSTer FPGA verification
|
||||
|
||||
**Source**: `Downloader_MiSTer`, `jobs/process_db_index_worker.py`
|
||||
|
||||
Reference in new issue
Block a user