feat: add knulli, amberelec and emuelec platforms

Each OS publishes Batocera's systems dict (knulli-systems, and
batocera-systems at AmberELEC's newest prerelease commit and EmuELEC v4.8),
read with the shared parser. Cores come from each OS's own ES list.
AmberELEC and EmuELEC declare neogeo/ and ports/ beside bios/ from
/storage/roms, so their packs extract there and core_destination puts
core files under bios/.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 03:42:20 +02:00
1 parent 2fc62bd249
commit 2bdac466a9
18 files changed
+12933 -24

No files matched your search

+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()
+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()