mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-18 09:23:26 -05:00
feat: scrape Lakka per-device core lists
Lakka builds LIBRETRO_CORES minus the exclusions each project, device and GLES image adds. Without a list, --target refused Lakka and its RPi1 pack carried firmware for cores the image does not build.
This commit is contained in:
1 parent
bac91d0824
commit
c3162dcd27
15 files changed
+6542
-6
No files matched your search
@@ -7,7 +7,7 @@ profiled_date: "2026-03-23"
|
||||
source_commit: "aa11568fff267fca3424cbdfa6da788ddc42e468"
|
||||
core_version: "2.1.0"
|
||||
display_name: "Nintendo - SNES / SFC (bsnes-jg)"
|
||||
cores: [bsnes-jg]
|
||||
cores: [bsnes-jg, bsnes_jg]
|
||||
systems:
|
||||
- nintendo-snes
|
||||
- nintendo-sgb
|
||||
|
||||
@@ -7,7 +7,7 @@ profiled_date: "2026-03-23"
|
||||
source_commit: "422b688009ccb9f2917c086a8fe79c8cecb030ae"
|
||||
core_version: "SVN (2015 snapshot)"
|
||||
display_name: "Nintendo - DS (DeSmuME 2015)"
|
||||
cores: [desmume2015]
|
||||
cores: [desmume2015, desmume_2015]
|
||||
systems: [nintendo-ds]
|
||||
exclusion_note: ".info declares firmware_count=3 but libretro frontend never enables external BIOS loading (UseExtBIOS defaults false in desmume/src/NDSSystem.h:497, never set in libretro.cpp)"
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ core_version: "v0.0.2.19"
|
||||
display_name: "PICO-8 (FAKE-08)"
|
||||
cores:
|
||||
- fake08
|
||||
- fake_08
|
||||
systems:
|
||||
- pico8
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ core_version: "1.6.0"
|
||||
display_name: "Sinclair - ZX Spectrum (Fuse)"
|
||||
cores:
|
||||
- fuse
|
||||
- fuse_libretro
|
||||
systems:
|
||||
- sinclair-zxspectrum
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ core_version: "Git"
|
||||
display_name: "Handheld Electronic (GW)"
|
||||
cores:
|
||||
- gw
|
||||
- gw_libretro
|
||||
systems:
|
||||
- handheld-electronic
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ core_version: "v1.2"
|
||||
display_name: "LowRes NX"
|
||||
cores:
|
||||
- lowresnx
|
||||
- lowres_nx
|
||||
systems:
|
||||
- lowresnx
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ core_version: "0.10-dev"
|
||||
display_name: "Nintendo - Game Boy Advance (mGBA)"
|
||||
cores:
|
||||
- mgba
|
||||
- mgba_fork
|
||||
systems:
|
||||
- nintendo-gba
|
||||
- nintendo-gb
|
||||
|
||||
@@ -9,6 +9,7 @@ core_version: "1.0.3"
|
||||
display_name: "Moonlight"
|
||||
cores:
|
||||
- moonlight
|
||||
- lr_moonlight
|
||||
systems: []
|
||||
|
||||
notes: |
|
||||
|
||||
@@ -7,7 +7,7 @@ profiled_date: "2026-03-24"
|
||||
source_commit: "5fdbb2187f6b14ca7bf668dd52b36c820b989995"
|
||||
core_version: "0.86"
|
||||
display_name: "NEC - PC-98 (Neko Project II)"
|
||||
cores: [nekop2]
|
||||
cores: [nekop2, meowpc98]
|
||||
systems: [pc-98]
|
||||
|
||||
# Neko Project II (NP2) by Yui, ported to libretro by meepingsnesroms.
|
||||
|
||||
@@ -9,6 +9,7 @@ core_version: "0.2.1"
|
||||
display_name: "PuzzleScript (pzretro)"
|
||||
cores:
|
||||
- puzzlescript
|
||||
- pzretro
|
||||
systems: []
|
||||
|
||||
notes: |
|
||||
|
||||
@@ -295,9 +295,10 @@ platforms:
|
||||
# files there, not the one RetroArch's Windows and macOS builds see.
|
||||
case_insensitive_fs: false
|
||||
schedule: weekly
|
||||
# No target list yet: Lakka builds its cores per device image, and no
|
||||
# scraper reads those lists.
|
||||
target_scraper: null
|
||||
# Lakka builds its cores per device image: libretro_cores/package.mk
|
||||
# excludes cores by PROJECT, DEVICE and OpenGL ES.
|
||||
target_scraper: lakka_targets
|
||||
target_source: https://github.com/libretro/Lakka-LibreELEC/blob/master/packages/lakka/libretro_cores/package.mk
|
||||
install:
|
||||
detect:
|
||||
- os: linux
|
||||
|
||||
@@ -107,6 +107,13 @@ emudeck:
|
||||
# to scaffold new installers; it is not an emulator.
|
||||
remove_cores: [template]
|
||||
|
||||
lakka:
|
||||
targets:
|
||||
generic:
|
||||
aliases: [x86_64, pc]
|
||||
rpi:
|
||||
aliases: [rpi1]
|
||||
|
||||
retropie:
|
||||
targets:
|
||||
_default:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,271 @@
|
||||
"""Scraper for Lakka per-device core availability.
|
||||
|
||||
Sources (libretro/Lakka-LibreELEC, at the latest Lakka release tag):
|
||||
- packages/lakka/libretro_cores/package.mk -- LIBRETRO_CORES, then the
|
||||
EXCLUDE_LIBRETRO_CORES each PROJECT/DEVICE and OPENGLES_SUPPORT add
|
||||
- projects/<PROJECT>/options and projects/<PROJECT>/devices/<DEVICE>/options
|
||||
-- OPENGLES, which config/graphic turns into OPENGLES_SUPPORT
|
||||
|
||||
A Lakka RPi1 image carries neither beetle_psx nor mupen64plus_next: without
|
||||
a target list, its pack shipped their firmware anyway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import yaml
|
||||
|
||||
from . import BaseTargetScraper
|
||||
|
||||
PLATFORM_NAME = "lakka"
|
||||
|
||||
REPO = "libretro/Lakka-LibreELEC"
|
||||
RAW_BASE = "https://raw.githubusercontent.com/" + REPO + "/{tag}"
|
||||
API_BASE = "https://api.github.com/repos/" + REPO
|
||||
CORES_MK = "packages/lakka/libretro_cores/package.mk"
|
||||
|
||||
try:
|
||||
from ..base_scraper import fetch_bytes, github_headers
|
||||
except ImportError:
|
||||
from scraper.base_scraper import fetch_bytes, github_headers
|
||||
|
||||
_IF_RE = re.compile(r"^(if|elif)\s+\[\s+(.*?)\s+\];?\s*(?:then)?\s*$")
|
||||
_TEST_RE = re.compile(r'^(!\s+)?"\$\{?(\w+)\}?"\s*(=|!=)\s*"?([^"\s]*)"?$')
|
||||
_SET_RE = re.compile(r"^\s*-([nz])\s+\"\$\{?(\w+)\}?\"\s*$")
|
||||
_ASSIGN_RE = re.compile(r'^(\w+)(\+?=)"([^"]*)"\s*$')
|
||||
|
||||
|
||||
class ShellSubsetError(RuntimeError):
|
||||
"""A construct the evaluator does not read decided a value it needs."""
|
||||
|
||||
|
||||
def _condition(text: str, env: dict[str, str]) -> bool | None:
|
||||
"""`[ "${A}" = "x" -o "${B}" != "y" ]`, `-n`, `-z`; None when a clause
|
||||
tests a variable nothing has set and no other clause is true."""
|
||||
results: list[bool | None] = []
|
||||
for clause in re.split(r"\s+-o\s+", text):
|
||||
clause = clause.strip()
|
||||
test = _TEST_RE.match(clause)
|
||||
if test:
|
||||
negate, name, op, value = test.groups()
|
||||
if name not in env:
|
||||
results.append(None)
|
||||
continue
|
||||
outcome = (env[name] == value) == (op == "=")
|
||||
results.append(not outcome if negate else outcome)
|
||||
continue
|
||||
emptiness = _SET_RE.match(clause)
|
||||
if emptiness:
|
||||
kind, name = emptiness.groups()
|
||||
filled = bool(env.get(name, ""))
|
||||
results.append(filled if kind == "n" else not filled)
|
||||
continue
|
||||
results.append(None)
|
||||
if any(result is True for result in results):
|
||||
return True
|
||||
return None if None in results else False
|
||||
|
||||
|
||||
# The variables this scraper reads. Set inside a case statement, which the
|
||||
# evaluator does not follow, they stop the scrape; anything else is skipped.
|
||||
WATCHED = frozenset({"OPENGLES", "LIBRETRO_CORES", "EXCLUDE_LIBRETRO_CORES"})
|
||||
|
||||
|
||||
def _expand(value: str, env: dict[str, str]) -> str:
|
||||
"""${NAME} and $NAME from what is set so far; unset expands to nothing."""
|
||||
return re.sub(
|
||||
r"\$\{(\w+)\}|\$(\w+)",
|
||||
lambda m: env.get(m.group(1) or m.group(2), ""),
|
||||
value,
|
||||
)
|
||||
|
||||
|
||||
def evaluate(text: str, env: dict[str, str]) -> dict[str, str]:
|
||||
"""Run the assignments and if/elif/else blocks of a LibreELEC options file.
|
||||
|
||||
Commands are skipped, and so are loop bodies: the libretro_cores loops
|
||||
only reorder the list or remove the exclusions, which device_cores applies
|
||||
as a set difference. A watched variable set inside a case statement is
|
||||
ambiguous and stops the scrape rather than guess.
|
||||
"""
|
||||
env = dict(env)
|
||||
lines = re.sub(r"\\\n", " ", text).splitlines()
|
||||
# One frame per open if: [parent state, branch taken, this branch's state],
|
||||
# each True, False, or None where a condition read an unknown variable.
|
||||
stack: list[list[bool | None]] = []
|
||||
case_depth = loop_depth = 0
|
||||
|
||||
def state() -> bool | None:
|
||||
states = [frame[2] for frame in stack]
|
||||
if False in states:
|
||||
return False
|
||||
return None if None in states else True
|
||||
|
||||
for raw in lines:
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
if re.match(r"^case\b", line):
|
||||
case_depth += 1
|
||||
continue
|
||||
if line == "esac":
|
||||
case_depth -= 1
|
||||
continue
|
||||
if re.match(r"^(for|while)\b", line):
|
||||
loop_depth += 1
|
||||
continue
|
||||
if line == "done":
|
||||
loop_depth -= 1
|
||||
continue
|
||||
if loop_depth:
|
||||
continue
|
||||
branch = _IF_RE.match(line)
|
||||
if branch and branch.group(1) == "if":
|
||||
parent = state()
|
||||
go = False if parent is False else (
|
||||
None if parent is None else _condition(branch.group(2), env)
|
||||
)
|
||||
stack.append([parent, go, go])
|
||||
continue
|
||||
if branch and branch.group(1) == "elif":
|
||||
frame = stack[-1]
|
||||
if frame[0] is False or frame[1] is True:
|
||||
go = False
|
||||
elif frame[0] is None or frame[1] is None:
|
||||
go = None
|
||||
else:
|
||||
go = _condition(branch.group(2), env)
|
||||
frame[1] = True if go is True else (None if go is None else frame[1])
|
||||
frame[2] = go
|
||||
continue
|
||||
if line == "else":
|
||||
frame = stack[-1]
|
||||
if frame[0] is False or frame[1] is True:
|
||||
go = False
|
||||
elif frame[0] is None or frame[1] is None:
|
||||
go = None
|
||||
else:
|
||||
go = True
|
||||
frame[1], frame[2] = True, go
|
||||
continue
|
||||
if line == "fi":
|
||||
stack.pop()
|
||||
continue
|
||||
assign = _ASSIGN_RE.match(line)
|
||||
current = state()
|
||||
if not assign or current is False:
|
||||
continue
|
||||
name, op, value = assign.groups()
|
||||
if case_depth or current is None:
|
||||
if name in WATCHED:
|
||||
where = "a case statement" if case_depth else "an undecidable branch"
|
||||
raise ShellSubsetError(f"{name} assigned inside {where}")
|
||||
continue
|
||||
value = " ".join(_expand(value, env).split())
|
||||
env[name] = f"{env.get(name, '')} {value}".strip() if op == "+=" else value
|
||||
return env
|
||||
|
||||
|
||||
def device_cores(cores_mk: str, env: dict[str, str]) -> list[str]:
|
||||
"""The cores one PROJECT/DEVICE image builds."""
|
||||
result = evaluate(cores_mk, env)
|
||||
wanted = set(result.get("LIBRETRO_CORES", "").split())
|
||||
if not wanted:
|
||||
raise RuntimeError(f"no LIBRETRO_CORES in {CORES_MK}")
|
||||
return sorted(wanted - set(result.get("EXCLUDE_LIBRETRO_CORES", "").split()))
|
||||
|
||||
|
||||
def device_arch(project: str, options: str) -> str:
|
||||
"""The architecture Lakka builds a device for: aarch64 where its options
|
||||
list it, else arm; the Generic images are x86_64."""
|
||||
if project == "Generic":
|
||||
return "x86_64"
|
||||
return "aarch64" if re.search(r"^\s*aarch64\)", options, re.MULTILINE) else "arm"
|
||||
|
||||
|
||||
class Scraper(BaseTargetScraper):
|
||||
"""Per-device core lists from Lakka's libretro_cores package."""
|
||||
|
||||
def __init__(self, url: str = "https://github.com/" + REPO) -> None:
|
||||
super().__init__(url=url)
|
||||
self.tag = ""
|
||||
self.paths: set[str] = set()
|
||||
|
||||
def _raw(self, path: str) -> str:
|
||||
return fetch_bytes(f"{RAW_BASE.format(tag=self.tag)}/{path}").decode("utf-8")
|
||||
|
||||
def _latest_tag(self) -> str:
|
||||
release = json.loads(fetch_bytes(f"{API_BASE}/releases/latest", headers=github_headers()))
|
||||
tag = release.get("tag_name") if isinstance(release, dict) else None
|
||||
if not tag:
|
||||
raise RuntimeError(f"{REPO} names no latest release")
|
||||
return tag
|
||||
|
||||
def _devices(self) -> list[tuple[str, str]]:
|
||||
tree = json.loads(fetch_bytes(
|
||||
f"{API_BASE}/git/trees/{self.tag}?recursive=1", headers=github_headers()
|
||||
))
|
||||
if tree.get("truncated"):
|
||||
raise RuntimeError(f"the {self.tag} tree listing is truncated")
|
||||
self.paths = {item.get("path", "") for item in tree.get("tree", [])}
|
||||
found = sorted({
|
||||
(match.group(1), match.group(2))
|
||||
for item in tree.get("tree", [])
|
||||
if (match := re.match(r"^projects/([^/]+)/devices/([^/]+)/options$", item.get("path", "")))
|
||||
})
|
||||
if not found:
|
||||
raise RuntimeError(f"no projects/*/devices/*/options at {self.tag}")
|
||||
return found
|
||||
|
||||
def fetch_targets(self) -> dict:
|
||||
self.tag = self._latest_tag()
|
||||
cores_mk = self._raw(CORES_MK)
|
||||
targets: dict[str, dict] = {}
|
||||
for project, device in self._devices():
|
||||
# Qualcomm keeps its options per device only.
|
||||
project_path = f"projects/{project}/options"
|
||||
project_options = self._raw(project_path) if project_path in self.paths else ""
|
||||
device_options = self._raw(f"projects/{project}/devices/{device}/options")
|
||||
env = {"DISTRO": "Lakka", "PROJECT": project, "DEVICE": device}
|
||||
env["ARCH"] = device_arch(project, device_options)
|
||||
graphics = evaluate(device_options, evaluate(project_options, env))
|
||||
env["OPENGLES_SUPPORT"] = "no" if graphics.get("OPENGLES", "no") == "no" else "yes"
|
||||
cores = device_cores(cores_mk, env)
|
||||
targets[device.lower()] = {"architecture": env["ARCH"], "cores": cores}
|
||||
print(f" {device.lower()} ({env['ARCH']}): {len(cores)} cores", file=sys.stderr)
|
||||
return {
|
||||
"platform": PLATFORM_NAME,
|
||||
"source": f"{self.url}/blob/{self.tag}/{CORES_MK}",
|
||||
"scraped_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"targets": targets,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
allow_abbrev=False, description="Scrape Lakka per-device cores"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Show target summary")
|
||||
parser.add_argument("--output", "-o", help="Output YAML file")
|
||||
args = parser.parse_args()
|
||||
|
||||
scraper = Scraper()
|
||||
data = scraper.fetch_targets()
|
||||
if args.dry_run:
|
||||
for name, info in data["targets"].items():
|
||||
print(f" {name} ({info['architecture']}): {len(info['cores'])} cores")
|
||||
return
|
||||
if args.output:
|
||||
scraper.write_output(data, args.output)
|
||||
print(f"Written to {args.output}")
|
||||
return
|
||||
print(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Lakka's per-device core lists, read from its build files.
|
||||
|
||||
Without a target list a Lakka RPi1 pack carried the firmware of cores that
|
||||
image does not build (beetle_psx, mupen64plus_next), and --target refused the
|
||||
platform outright.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from scripts.scraper.targets import lakka_targets_scraper as lakka
|
||||
|
||||
CORES_MK = '''PKG_NAME="libretro_cores"
|
||||
LIBRETRO_CORES="\\
|
||||
2048 \\
|
||||
beetle_psx \\
|
||||
kronos \\
|
||||
lr_moonlight \\
|
||||
panda3ds \\
|
||||
snes9x \\
|
||||
"
|
||||
EARLY_START_LR_CORES="snes9x"
|
||||
for core in ${EARLY_START_LR_CORES} ; do
|
||||
LIBRETRO_CORES="${LIBRETRO_CORES// ${core} /}"
|
||||
done
|
||||
LIBRETRO_CORES="${EARLY_START_LR_CORES} ${LIBRETRO_CORES}"
|
||||
if [ -n "${CUSTOM_LIBRETRO_CORES}" ]; then
|
||||
LIBRETRO_CORES="${CUSTOM_LIBRETRO_CORES}"
|
||||
fi
|
||||
if [ "${OPENGLES_SUPPORT}" = "yes" ]; then
|
||||
EXCLUDE_LIBRETRO_CORES+=" kronos"
|
||||
fi
|
||||
if [ "${PROJECT}" = "Generic" ]; then
|
||||
EXCLUDE_LIBRETRO_CORES+=" lr_moonlight"
|
||||
elif [ "${PROJECT}" = "RPi" ]; then
|
||||
EXCLUDE_LIBRETRO_CORES+=" lr_moonlight"
|
||||
if [ "${DEVICE}" = "RPi" -o "${DEVICE}" = "RPiZero-GPiCase" ]; then
|
||||
EXCLUDE_LIBRETRO_CORES+=" beetle_psx \\
|
||||
snes9x"
|
||||
elif [ "${DEVICE}" = "RPi2" ]; then
|
||||
EXCLUDE_LIBRETRO_CORES+=" panda3ds"
|
||||
fi
|
||||
fi
|
||||
'''
|
||||
|
||||
GENERIC_OPTIONS = ''' OPENGL="no"
|
||||
OPENGLES="mesa"
|
||||
if [ "${DISTRO}" = "Lakka" ]; then
|
||||
# Use OPENGL instead of OPENGLES
|
||||
OPENGL="mesa"
|
||||
OPENGLES="no"
|
||||
fi
|
||||
'''
|
||||
|
||||
|
||||
def _env(project: str, device: str, gles: str) -> dict[str, str]:
|
||||
return {"DISTRO": "Lakka", "PROJECT": project, "DEVICE": device,
|
||||
"ARCH": "arm", "OPENGLES_SUPPORT": gles}
|
||||
|
||||
|
||||
class DeviceCores(unittest.TestCase):
|
||||
def test_the_rpi1_list_drops_its_exclusions(self):
|
||||
cores = lakka.device_cores(CORES_MK, _env("RPi", "RPi", "yes"))
|
||||
self.assertEqual(cores, ["2048", "panda3ds"])
|
||||
|
||||
def test_a_gles_image_drops_kronos_and_a_desktop_one_keeps_it(self):
|
||||
self.assertNotIn("kronos", lakka.device_cores(CORES_MK, _env("RPi", "RPi4", "yes")))
|
||||
self.assertIn("kronos", lakka.device_cores(CORES_MK, _env("Generic", "Generic", "no")))
|
||||
|
||||
def test_the_prepended_early_start_core_is_kept_once(self):
|
||||
cores = lakka.device_cores(CORES_MK, _env("RPi", "RPi4", "yes"))
|
||||
self.assertEqual(cores.count("snes9x"), 1)
|
||||
|
||||
|
||||
class OptionsEvaluation(unittest.TestCase):
|
||||
def test_lakka_overrides_generic_to_desktop_gl(self):
|
||||
env = lakka.evaluate(GENERIC_OPTIONS, {"DISTRO": "Lakka"})
|
||||
self.assertEqual(env["OPENGLES"], "no")
|
||||
|
||||
def test_an_unknown_condition_is_harmless_where_nothing_watched_changes(self):
|
||||
text = 'if [ "${DEBUG}" = "no" ]; then\n TARGET_CPU="x"\nfi\n OPENGLES="mesa"\n'
|
||||
self.assertEqual(lakka.evaluate(text, {})["OPENGLES"], "mesa")
|
||||
|
||||
def test_an_unknown_condition_deciding_a_watched_value_stops(self):
|
||||
text = 'if [ "${DEBUG}" = "no" ]; then\n OPENGLES="mesa"\nfi\n'
|
||||
with self.assertRaises(lakka.ShellSubsetError):
|
||||
lakka.evaluate(text, {})
|
||||
|
||||
def test_a_watched_value_set_in_a_case_stops(self):
|
||||
text = 'case $TARGET_ARCH in\n arm)\n OPENGLES="mesa"\n ;;\nesac\n'
|
||||
with self.assertRaises(lakka.ShellSubsetError):
|
||||
lakka.evaluate(text, {})
|
||||
|
||||
|
||||
class Architecture(unittest.TestCase):
|
||||
def test_aarch64_where_listed_else_arm(self):
|
||||
self.assertEqual(lakka.device_arch("RPi", "case $TARGET_ARCH in\n aarch64)\n"), "aarch64")
|
||||
self.assertEqual(lakka.device_arch("RPi", "case $TARGET_ARCH in\n arm)\n"), "arm")
|
||||
self.assertEqual(lakka.device_arch("Generic", ""), "x86_64")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user