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:
Abdessamad Derraz committed 2026-10-22 03:58:48 +02:00
commit 259fb14954
38 files changed
+299417 -284

No files matched your search

+3
View File
@@ -42,6 +42,9 @@ body:
- RetroDECK
- RomM
- ROCKNIX
- KNULLI
- AmberELEC
- EmuELEC
- BizHawk
- MiSTer FPGA
- Other
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+64075
View File
File diff suppressed because it is too large. Load diff
+160353
View File
File diff suppressed because it is too large. Load diff
+100 -47
View File
@@ -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
+4820
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -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
View File
@@ -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()
+46
View File
@@ -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}
+42 -11
View File
@@ -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():
+32
View File
@@ -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}
+31
View File
@@ -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}
+18 -2
View File
@@ -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
+161
View File
@@ -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
View File
@@ -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 = {
+148
View File
@@ -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()
+88
View File
@@ -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)
+127
View File
@@ -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()
+16
View File
@@ -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
+208
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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):
+1
View File
@@ -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
+6
View File
@@ -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"},
}
+5 -2
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+1 -1
View File
@@ -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
+9
View File
@@ -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 |
+2
View File
@@ -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/`
+23 -1
View File
@@ -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`