From 82a6b2f8333b9f472be91aa741253dcf5ebed7eb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:34:03 +0100 Subject: [PATCH] feat: read the emuelec launchers emuelec_launchers.py reads emuelecRunEmu.sh and the scripts and configs it starts at the tag batocera-systems is read at (v4.8): 36 records for RetroArch, retrorun, freej2me, MAME, PICO-8, Flycast, Amiberry, DuckStation, Yabasanshiro, jzIntv, Mupen64Plus, Hatari, AdvanceMAME, ScummVM, Dolphin and the MIDI synths. --- platforms/emuelec.yml | 358 +++++++++++++ scripts/scraper/emuelec_launchers.py | 755 +++++++++++++++++++++++++++ scripts/scraper/emuelec_scraper.py | 14 + tests/test_launchers_emuelec.py | 655 +++++++++++++++++++++++ 4 files changed, 1782 insertions(+) create mode 100644 scripts/scraper/emuelec_launchers.py create mode 100644 tests/test_launchers_emuelec.py diff --git a/platforms/emuelec.yml b/platforms/emuelec.yml index bc476e3b0..0e208d183 100644 --- a/platforms/emuelec.yml +++ b/platforms/emuelec.yml @@ -4226,3 +4226,361 @@ systems: native_name: Phillips CDI native_id: cdi name: Phillips CDI +launchers: +- emulator: retroarch + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios + passes_to: "retroarch.cfg system_directory (--config)" + fails_when_missing: false + source_ref: "packages/sx05re/libretro_base/retroarch/package.mk:117, packages/sx05re/emuelec/bin/emuelecRunEmu.sh:380" +- emulator: retrorun + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios + passes_to: retrorun -d + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:110, 466, 476" +- emulator: freej2me + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/jdk + passes_to: environment JAVA_HOME + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:129-133, packages/sx05re/libretro/freej2me/scripts/freej2me.sh:11" +- emulator: freej2me + build: libretro + kind: copies + names: + - freej2me-lr.jar + search_dir: /usr/lib/libretro + passes_to: "${HOME}/roms/bios" + fails_when_missing: false + depends_on_option: /storage/roms/bios/jdk/eeversion exists + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:129-130, packages/sx05re/libretro/freej2me/scripts/freej2me.sh:11-12" +- emulator: mame + build: libretro + kind: copies + names: + - fm* + search_dir: /usr/config/emuelec/configs/mame/hash + passes_to: /storage/roms/bios/mame/hash + fails_when_missing: false + depends_on_option: /storage/roms/bios/mame/hash/fmtowns_cd.xml absent + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:6-8" +- emulator: mame + build: libretro + kind: copies + names: + - app* + search_dir: /usr/config/emuelec/configs/mame/hash + passes_to: /storage/roms/bios/mame/hash + fails_when_missing: false + depends_on_option: /storage/roms/bios/mame/hash/apple2_flop_orig.xml absent + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:11-13" +- emulator: mame + build: libretro + kind: copies + names: + - fm* + search_dir: /usr/config/emuelec/configs/mame/ini + passes_to: /storage/roms/bios/mame/ini + fails_when_missing: false + depends_on_option: /storage/roms/bios/mame/ini/fmtownsux.ini absent + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:16-18" +- emulator: mame + build: libretro + kind: copies + names: + - mame* + search_dir: /usr/config/emuelec/configs/mame/ini + passes_to: /storage/roms/bios/mame/ini + fails_when_missing: false + depends_on_option: /storage/roms/bios/mame/ini/mame.ini absent + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame + passes_to: mame.ini homepath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:10, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame/bios + passes_to: mame.ini rompath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:11, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame/hash + passes_to: mame.ini hashpath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:12, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame/samples + passes_to: mame.ini samplepath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:13, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame/artwork + passes_to: mame.ini artpath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:14, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame/ini + passes_to: mame.ini inipath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:16, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame/cheat + passes_to: mame.ini cheatpath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:18, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame/crosshair + passes_to: mame.ini crosshairpath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:19, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: /storage/roms/bios/mame/plugins + passes_to: mame.ini pluginspath + fails_when_missing: false + depends_on_option: mame_read_config = enabled + source_ref: "packages/sx05re/libretro/mame/config/ini/mame.ini:20, packages/sx05re/libretro/mame/config/MAME/MAME.opt:19,\ + \ packages/sx05re/emuelec/bin/emuelecRunEmu.sh:357-358, packages/sx05re/libretro/mame/scripts/mame.sh:21-23" +- emulator: pico8 + build: standalone + kind: moves + names: + - pico8_dyn + case_insensitive: false + search_dir: /storage/roms/bios/pico-8 + passes_to: /emuelec/bin/pico-8 + fails_when_missing: false + depends_on_option: /storage/roms/bios/pico-8 exists + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:305-307, packages/sx05re/emuelec/bin/pico8.sh:9-13,\ + \ 40-42" +- emulator: flycastsa + build: standalone + kind: links + names: [] + search_dir: /storage/roms/bios/dc + passes_to: symlink /storage/.local/share/flycast -> /storage/roms/bios/dc + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:198-200, packages/sx05re/emulators/flycastsa/scripts/flycast.sh:11,\ + \ 14" +- emulator: flycast_dojo + build: standalone + kind: links + names: [] + search_dir: /storage/roms/bios/dc_dojo + passes_to: symlink /storage/.local/share/flycast-dojo -> /storage/roms/bios/dc_dojo + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:201-203, packages/sx05re/emulators/flycast-dojo/scripts/flycastdojo.sh:11-12,\ + \ 28-30" +- emulator: amiberry + build: standalone + kind: links + names: [] + search_dir: /storage/roms/bios + passes_to: symlink /usr/config/amiberry/kickstarts -> /storage/roms/bios + fails_when_missing: false + source_ref: "packages/sx05re/emulators/amiberry/package.mk:36" +- emulator: amiberry-lite + build: standalone + kind: links + names: [] + search_dir: /storage/roms/bios + passes_to: symlink /usr/config/amiberry-lite/kickstarts -> /storage/roms/bios + fails_when_missing: false + source_ref: "packages/sx05re/emulators/amiberry-lite/package.mk:36" +- emulator: timidity + build: standalone + kind: links + names: [] + search_dir: /storage/roms/bios/timidity/freepats + passes_to: symlink /usr/config/timidity/freepats -> /storage/roms/bios/timidity/freepats + fails_when_missing: false + source_ref: "packages/sx05re/tools/sound/timidity/package.mk:38" +- emulator: duckstation + build: standalone + kind: scans_dir + names: [] + search_dir: /storage/roms/bios + passes_to: "settings.ini [BIOS] SearchDirectory (-settings), [BIOS] PathNTSCU, PathNTSCJ,\ + \ PathPAL unset" + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:207-209, packages/sx05re/emulators/duckstation/config/settings.ini:6-9,\ + \ packages/sx05re/emulators/duckstation/package.mk:56, packages/sx05re/emulators/duckstation/scripts/duckstation.sh:40" +- emulator: yabasanshiroSA + build: standalone + kind: fixed_name + names: + - saturn_bios.bin + case_insensitive: false + search_dir: /storage/roms/bios + passes_to: yabasanshiro -b + fails_when_missing: false + depends_on_option: hlebios != 1 + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:343-345, packages/sx05re/emulators/yabasanshiroSA_1_11/scripts/yabasanshiro.sh:30-34,\ + \ 49" +- emulator: yabasanshiroSA1_5 + build: standalone + kind: fixed_name + names: + - saturn_bios.bin + case_insensitive: false + search_dir: /storage/roms/bios + passes_to: yabasanshiro1_5 -b + fails_when_missing: false + depends_on_option: hlebios != 1 + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:346-348, packages/sx05re/emulators/yabasanshiroSA_1_5/scripts/yabasanshiro1_5.sh:29-33,\ + \ 47" +- emulator: jzintv + build: standalone + kind: passes_path + names: [] + search_dir: /storage/roms/bios + passes_to: jzintv -p + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:337-339, packages/sx05re/emulators/jzintv/scripts/jzintv.sh:49" +- emulator: rice, glide64mk2 + build: standalone + kind: names_searched + names: + - "N64DD IPLROM (J).n64" + - IPL.n64 + case_insensitive: false + search_dir: /storage/roms/bios/Mupen64plus + passes_to: mupen64plus --dd-ipl-rom + fails_when_missing: false + depends_on_option: "-f \"${GAMEDIR}/${GAMENAME}.ndd\"" + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:232-237, packages/sx05re/emulators/mupen64plussa/mupen64plussa-core/m64p.sh:16,\ + \ 65-68, 96-98" +- emulator: HATARISA + build: standalone + kind: fixed_name + names: + - tos.img + search_dir: /storage/roms/bios + passes_to: "Atari-ST-default.cfg [ROM] szTosImageFileName (--configfile)" + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:172-174, packages/sx05re/emulators/hatarisa/config/conf/Atari-ST-default.cfg:200,\ + \ packages/sx05re/emulators/hatarisa/package.mk:39, packages/sx05re/emulators/hatarisa/scripts/hatari.start:36,\ + \ 66, 73" +- emulator: AdvanceMame + build: standalone + kind: passes_path + names: [] + search_dir: /storage/roms/bios/advmame/samples + passes_to: advmame.rc dir_sample + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:213-215, packages/sx05re/emulators/advancemame/config/advmame.rc_oga:9,\ + \ packages/sx05re/emulators/advancemame/config/advmame.rc_gf:9, packages/sx05re/emulators/advancemame/config/advmame.rc:12,\ + \ packages/sx05re/emulators/advancemame/package.mk:45-49, packages/sx05re/emulators/advancemame/bin/advmame.sh:80" +- emulator: SCUMMVMSA + build: standalone + kind: passes_path + names: [] + search_dir: /storage/.config/scummvm/extra + passes_to: scummvm --extrapath + fails_when_missing: false + depends_on_option: /storage/.config/scummvm/extra exists + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:251-255, packages/sx05re/emulators/scummvmsa/bin/scummvm.start:38-39,\ + \ 45" +- emulator: scummvm + build: libretro + kind: copies + names: + - scummvm.ini + search_dir: /storage/.config/scummvm + passes_to: /storage/roms/bios/scummvm.ini + fails_when_missing: false + depends_on_option: scummvm.start add + source_ref: "packages/sx05re/emulators/scummvmsa/bin/scummvm.start:9, 49-51, 54" +- emulator: dolphin + build: standalone + kind: passes_path + names: [] + search_dir: /storage/roms/dolphin + passes_to: environment XDG_DATA_HOME + fails_when_missing: false + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:270-272, packages/sx05re/emulators/dolphinSA/scripts/dolphin.sh:28" +- emulator: fluidsynth + build: standalone + kind: fixed_name + slot: fluidsynth + names: + - sc/SC-55.sf2 + case_insensitive: false + search_dir: /storage/roms/bios + passes_to: fluidsynth -is + fails_when_missing: false + depends_on_option: ra_midi_output = fluidsynth + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:123-125, packages/sx05re/emuelec/bin/emuelec-utils:54,\ + \ 66-67" +- emulator: mt32d + build: standalone + kind: passes_path + slot: cm32l + names: [] + search_dir: /roms/bios + passes_to: mt32d -f + fails_when_missing: false + depends_on_option: ra_midi_output = cm32l + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:123-125, packages/sx05re/emuelec/bin/emuelec-utils:54,\ + \ 75-76" +- emulator: mt32d + build: standalone + kind: passes_path + slot: mt32 + names: [] + search_dir: /roms/bios + passes_to: mt32d -f + fails_when_missing: false + depends_on_option: ra_midi_output = mt32 + source_ref: "packages/sx05re/emuelec/bin/emuelecRunEmu.sh:123-125, packages/sx05re/emuelec/bin/emuelec-utils:54,\ + \ 79-80" diff --git a/scripts/scraper/emuelec_launchers.py b/scripts/scraper/emuelec_launchers.py new file mode 100644 index 000000000..4698c8795 --- /dev/null +++ b/scripts/scraper/emuelec_launchers.py @@ -0,0 +1,755 @@ +"""EmuELEC's launcher layer, read from its packages at the pinned tag. + +emuelecRunEmu.sh starts every game. A libretro core runs in RetroArch (or +retrorun) with /storage/roms/bios as its system directory. A standalone +emulator runs from the script the `case ${PLATFORM}` arm names for the core +ES passes (`"${EMU}" = "flycastsa"`), and that script links, moves, copies +or passes the file. Some packages ship the config the emulator reads +(duckstation, hatari, advancemame, mame), others link a BIOS folder at +install time (amiberry, timidity). The emulator of each record is the core +name the dispatch tests, or the package that installs the link. +""" + +from __future__ import annotations + +import posixpath +import re +from collections.abc import Callable +from dataclasses import dataclass, field + +try: + from .configgen_launchers import Launcher + from .rocknix_launchers import fixed_paths + from .shell_launchers import ( + LauncherPatternMissing, + Link, + RepoTree, + Script, + describe_keys, + settings, + source_ref, + words, + ) +except ImportError: + from configgen_launchers import Launcher + from rocknix_launchers import fixed_paths + from shell_launchers import ( + LauncherPatternMissing, + Link, + RepoTree, + Script, + describe_keys, + settings, + source_ref, + words, + ) + +SX05RE = "packages/sx05re" +BIN = f"{SX05RE}/emuelec/bin" +RUN_EMU = f"{BIN}/emuelecRunEmu.sh" +UTILS = f"{BIN}/emuelec-utils" +EMULATORS = f"{SX05RE}/emulators" +LIBRETRO = f"{SX05RE}/libretro" +RETROARCH_MK = f"{SX05RE}/libretro_base/retroarch/package.mk" +BIOS_ROOT = "/storage/roms/bios" + +_EMU_TEST = re.compile(r'\$\{?EMU\}?"?\s*==?\s*"([^"]+)"') +_ARM = re.compile(r'^\s*((?:"[^"]+"\|?)+)\)\s*$') +_RUNTHIS = re.compile(r"""^\s*RUNTHIS\+?=(['"])(.*)\1\s*$""") +_SCRIPT = re.compile(r"[\w./-]+\.(?:sh|start)$") +_ADVMAME_INSTALL = re.compile(r"cp -r \$\{PKG_DIR\}/config/(advmame\.rc\w*) \S+/advmame\.rc$") + + +@dataclass +class Dispatch: + """The cores emuelecRunEmu.sh hands to one script, and the lines that do it.""" + + emulators: list[str] = field(default_factory=list) + lines: set[int] = field(default_factory=set) + + +class Runs: + """emuelecRunEmu.sh, read once: which core starts which script.""" + + def __init__(self, tree: RepoTree) -> None: + self.script = Script(RUN_EMU, tree.read(RUN_EMU)) + self.by_script: dict[str, Dispatch] = {} + emu: tuple[str, int] | None = None + arm: tuple[list[str], int] | None = None + for number, line in self.script.code(): + if found := _ARM.match(line): + arm = ([p.strip('"') for p in found.group(1).split("|") if p], number) + emu = None + continue + if re.match(r"\s*(;;|esac\b)", line): + arm, emu = None, None + continue + if found := _EMU_TEST.search(line): + emu = (found.group(1).removesuffix("_libretro"), number) + continue + if re.match(r"\s*fi\b", line): + emu = None + continue + run = _RUNTHIS.match(line) + command = words(run.group(2) if run else line) + name = next( + (posixpath.basename(w.text) for w in command if _SCRIPT.fullmatch(w.text)), None + ) + if name is None or (emu is None and arm is None): + continue + names, at = ([emu[0]], emu[1]) if emu else arm # type: ignore[misc] + entry = self.by_script.setdefault(name, Dispatch()) + entry.emulators.extend(n for n in names if n not in entry.emulators) + entry.lines |= {at, number} + if not self.by_script: + raise self.script.missing("script started for a core") + + def of(self, name: str) -> Dispatch: + if name not in self.by_script: + raise self.script.missing(f"start of {name}") + return self.by_script[name] + + def emulator(self, name: str) -> str: + return ", ".join(self.of(name).emulators) + + def ref(self, name: str) -> tuple[str, set[int]]: + return (RUN_EMU, set(self.of(name).lines)) + + +def _read(tree: RepoTree, path: str) -> Script: + return Script(path, tree.read(path)) + + +def _under(path: str, root: str = BIOS_ROOT) -> bool: + return path.rstrip("/") == root or path.startswith(root + "/") + + +def retroarch(tree: RepoTree, runs: Runs) -> list[Launcher]: + """system_directory, written by sed into the retroarch.cfg the package installs.""" + mk = _read(tree, RETROARCH_MK) + sed_line, sed = mk.find( + r"s/# system_directory =/system_directory =((?:\\/|[^/])+)/", "system_directory sed" + ) + run_line, _ = runs.script.find( + r"RUNTHIS='\$\{RABIN\}.* -L /tmp/cores/\$\{EMU\}\.so --config \$\{RACONF\}", + "RetroArch start with --config", + ) + return [ + Launcher( + emulator="retroarch", + build="libretro", + kind="passes_path", + names=[], + source_ref=source_ref([(mk.path, {sed_line}), (RUN_EMU, {run_line})]), + search_dir=sed.group(1).replace("\\/", "/"), + passes_to="retroarch.cfg system_directory (--config)", + ) + ] + + +def retrorun(tree: RepoTree, runs: Runs) -> list[Launcher]: + script = runs.script + base_line, base = script.find(r'^\s*RUNTHIS="(\w+)"\s*$', "retrorun command") + test_line, _ = script.find( + rf'if \[\[ "\$\{{EMULATOR\}}" = "{base.group(1)}" \]\]', f"{base.group(1)} test" + ) + run_line, run = script.find(r"RUNTHIS\+=.*\s(-d)\s+(\S+)\s", "retrorun -d", base_line) + return [ + Launcher( + emulator=base.group(1), + build="libretro", + kind="passes_path", + names=[], + source_ref=source_ref([(RUN_EMU, {test_line, base_line, run_line})]), + search_dir=run.group(2), + passes_to=f"{base.group(1)} {run.group(1)}", + ) + ] + + +def freej2me(tree: RepoTree, runs: Runs) -> list[Launcher]: + """JAVA_HOME set for the core, and the jar its script copies beside the JDK.""" + script = runs.script + test_line, test = script.find(r'if \[ \$\{EMU\} == "(\w+)_libretro" \]', "core test") + call_line, call = script.find(r"^\s*(\S+\.sh)\s*$", "script call", test_line) + java_line, java = script.find(r"^\s*(JAVA_HOME)='([^']+)'", "JAVA_HOME", test_line) + export_line, _ = script.find(rf"^\s*export {java.group(1)}\s*$", "export", java_line) + core = test.group(1) + path = f"{LIBRETRO}/{core}/scripts/{call.group(1)}" + started = _read(tree, path) + guard_line, guard = started.find(r'if \[\[ -f "([^"]+)" \]\]; then$', "JDK version test") + copy_line, copy = started.find(r"^\s*cp -rf (\S+) (\S+)$", "jar copy", guard_line) + source = copy.group(1) + return [ + Launcher( + emulator=core, + build="libretro", + kind="passes_path", + names=[], + source_ref=source_ref( + [(RUN_EMU, {test_line, call_line, java_line, export_line}), (path, {guard_line})] + ), + search_dir=java.group(2), + passes_to=f"environment {java.group(1)}", + ), + Launcher( + emulator=core, + build="libretro", + kind="copies", + names=[posixpath.basename(source)], + source_ref=source_ref( + [(RUN_EMU, {test_line, call_line}), (path, {guard_line, copy_line})] + ), + search_dir=posixpath.dirname(source), + passes_to=copy.group(2), + depends_on_option=f"{guard.group(1)} exists", + ), + ] + + +def pico8(tree: RepoTree, runs: Runs) -> list[Launcher]: + """The folder the user drops in bios/ is moved where the binary runs from.""" + path = f"{BIN}/pico8.sh" + script = _read(tree, path) + guard_line, guard = script.find(r'if \[\[ -e "([^"]+)" \]\]; then$', "new version test") + folder = guard.group(1) + copy_line, copy = script.find( + rf"^\s*cp -rf {re.escape(folder)}/\* (\S+)$", "copy of the folder", guard_line + ) + target = copy.group(1) + remove_line, _ = script.find(rf"^\s*rm -rf {re.escape(folder)}$", "removal", copy_line) + mode_line, mode = script.find( + rf"^\s*chmod \+x {re.escape(target)}/([^/\s]+)$", "executable bit", remove_line + ) + binary = mode.group(1) + starts = { + n for n, line in script.code() if re.match(rf"\s*{re.escape(target)}/{binary} -", line) + } + if not starts: + raise script.missing(f"start of {target}/{binary}") + name = posixpath.basename(path) + return [ + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="moves", + names=[binary], + source_ref=source_ref( + [runs.ref(name), (path, {guard_line, copy_line, remove_line, mode_line, *starts})] + ), + case_insensitive=False, + search_dir=folder, + passes_to=target, + fails_when_missing=False, + depends_on_option=f"{folder} exists", + ) + ] + + +def link_record(emulator: str, script: Script, link: Link, extra: set[int]) -> Launcher: + return Launcher( + emulator=emulator, + build="standalone", + kind="links", + names=list(link.names), + source_ref=source_ref([(script.path, link.lines | extra)]), + expands=link.expands or None, + search_dir=link.bios_side, + passes_to=f"symlink {link.link} -> {link.target}", + fails_when_missing=False, + ) + + +def _link_guard(script: Script, link: Link) -> set[int]: + """The `[ ! -L ]` test the link waits for, when it is the one just above.""" + first = max(link.lines) + tests = [ + number + for number, line in script.code() + if number < first + and re.search(r"\[ ! -L ", line) + and link.link in script.expand(line, number, set()) + ] + return {tests[-1]} if tests and first - tests[-1] <= 4 else set() + + +# Start scripts whose ln -s commands put a BIOS folder where the emulator reads. +LINK_SCRIPTS = ( + f"{EMULATORS}/flycastsa/scripts/flycast.sh", + f"{EMULATORS}/flycast-dojo/scripts/flycastdojo.sh", +) + +# Packages whose package.mk links a BIOS folder into the installed tree. +LINK_PACKAGES = ( + f"{EMULATORS}/amiberry", + f"{EMULATORS}/amiberry-lite", + f"{SX05RE}/tools/sound/timidity", +) + + +def links(tree: RepoTree, runs: Runs) -> list[Launcher]: + records = [] + for path in LINK_SCRIPTS: + script = _read(tree, path) + found = script.links(BIOS_ROOT) + if not found: + raise script.missing(f"ln -s of a path under {BIOS_ROOT}") + name = posixpath.basename(path) + for link in found: + record = link_record(runs.emulator(name), script, link, _link_guard(script, link)) + record.source_ref = f"{source_ref([runs.ref(name)])}, {record.source_ref}" + records.append(record) + for package in LINK_PACKAGES: + script = _read(tree, f"{package}/package.mk") + found = script.links(BIOS_ROOT, fixed={"INSTALL": ""}) + if not found: + raise script.missing(f"ln -s of a path under {BIOS_ROOT}") + records.extend( + link_record(posixpath.basename(package), script, link, set()) for link in found + ) + return records + + +def duckstation(tree: RepoTree, runs: Runs) -> list[Launcher]: + """The settings.ini the package ships hands a directory and leaves the paths empty.""" + package = f"{EMULATORS}/duckstation" + path = f"{package}/config/settings.ini" + found = {(s.section, s.key): s for s in settings(tree.read(path))} + folder_key = ("BIOS", "SearchDirectory") + file_keys = (("BIOS", "PathNTSCU"), ("BIOS", "PathNTSCJ"), ("BIOS", "PathPAL")) + if folder_key not in found: + raise LauncherPatternMissing(path, "[BIOS] SearchDirectory") + set_files = [k for k in file_keys if k in found and found[k].value] + if set_files: + raise LauncherPatternMissing(path, f"empty [BIOS] {set_files[0][1]}") + mk = _read(tree, f"{package}/package.mk") + mk_line, _ = mk.find(r"cp -rf \$\{PKG_DIR\}/config/\* ", "config install") + script = _read(tree, f"{package}/scripts/duckstation.sh") + run_line, _ = script.find(r'-settings "\$\{CONFIG_DIR\}/settings\.ini"', "-settings") + name = posixpath.basename(script.path) + lines = {found[k].line for k in (folder_key, *file_keys) if k in found} + unset = describe_keys("", file_keys).strip() + return [ + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="scans_dir", + names=[], + source_ref=source_ref( + [runs.ref(name), (path, lines), (mk.path, {mk_line}), (script.path, {run_line})] + ), + search_dir=found[folder_key].value, + passes_to=f"settings.ini [BIOS] SearchDirectory (-settings), {unset} unset", + ) + ] + + +YABASANSHIRO_SCRIPTS = ( + f"{EMULATORS}/yabasanshiroSA_1_11/scripts/yabasanshiro.sh", + f"{EMULATORS}/yabasanshiroSA_1_5/scripts/yabasanshiro1_5.sh", +) + + +def yabasanshiro(tree: RepoTree, runs: Runs) -> list[Launcher]: + """The BIOS flag, set when the HLE option is off and the file exists.""" + records = [] + for path in YABASANSHIRO_SCRIPTS: + script = _read(tree, path) + option_line, option = script.find(r"^\s*(\w+)=\$\(get_ee_setting (\w+) ", "HLE option") + test_line, test = script.find( + rf'if \[ "\$\{{{option.group(1)}\}}" != (\w+) \]', "HLE option test", option_line + ) + exists_line, exists = script.find(r'if \[ -e "([^"]+)" \]', "BIOS test", test_line) + bios = exists.group(1) + flag_line, flag = script.find( + rf'^\s*(\w+)="(-\w) {re.escape(bios)}"$', "BIOS flag", exists_line + ) + run_line, run = script.find( + rf"\{{\s*(\S+) .*\$\{{{flag.group(1)}\}}", "start with the flag", flag_line + ) + name = posixpath.basename(path) + records.append( + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="fixed_name", + names=[posixpath.basename(bios)], + source_ref=source_ref( + [ + runs.ref(name), + (path, {option_line, test_line, exists_line, flag_line, run_line}), + ] + ), + case_insensitive=False, + search_dir=posixpath.dirname(bios), + passes_to=f"{run.group(1)} {flag.group(2)}", + fails_when_missing=False, + depends_on_option=f"{option.group(2)} != {test.group(1)}", + ) + ) + return records + + +def jzintv(tree: RepoTree, runs: Runs) -> list[Launcher]: + path = f"{EMULATORS}/jzintv/scripts/jzintv.sh" + script = _read(tree, path) + run_line, run = script.find(r"^\s*exec (\S+) .*\s(-p)\s+(\S+)\s", "start with -p") + name = posixpath.basename(path) + return [ + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="passes_path", + names=[], + source_ref=source_ref([runs.ref(name), (path, {run_line})]), + search_dir=run.group(3).rstrip("/"), + passes_to=f"{run.group(1)} {run.group(2)}", + ) + ] + + +def mupen64plus(tree: RepoTree, runs: Runs) -> list[Launcher]: + """The 64DD IPL names, tried in order, passed only beside a .ndd disk.""" + path = f"{EMULATORS}/mupen64plussa/mupen64plussa-core/m64p.sh" + script = _read(tree, path) + fallbacks = [ + (number, found) + for number, line in script.code() + if (found := re.search(r'\[ ! -f "\$\{(\w+)\}" \] && \1="([^"]+)"', line)) + ] + if not fallbacks: + raise script.missing("fallback name test") + var = fallbacks[0][1].group(1) + first = script.value(var, fallbacks[0][0]) + if first is None: + raise script.missing(f"assignment of {var}") + used = {first.line, *(n for n, _ in fallbacks)} + paths = [ + script.expand(v, n, used) + for n, v in [(first.line, first.value), *((n, f.group(2)) for n, f in fallbacks)] + ] + folders = {posixpath.dirname(p) for p in paths} + if len(folders) != 1: + raise script.missing(f"one folder for the {var} names") + test_line, test = script.find( + rf'if \[ -f "([^"]+)" \] && \[ -f "\$\{{{var}\}}" \]', "disk and IPL test" + ) + passes = [ + (number, found) + for number, line in script.code() + if (found := re.search(rf'^\s*(\S+) .*(--[\w-]+) "\$\{{{var}\}}"', line)) + ] + if not passes: + raise script.missing(f"flag that hands {var}") + flags = sorted({f"{f.group(1)} {f.group(2)}" for _, f in passes}) + name = posixpath.basename(path) + return [ + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="names_searched", + names=[posixpath.basename(p) for p in paths], + source_ref=source_ref( + [runs.ref(name), (path, used | {test_line} | {n for n, _ in passes})] + ), + case_insensitive=False, + search_dir=folders.pop(), + passes_to=", ".join(flags), + fails_when_missing=False, + depends_on_option=f'-f "{test.group(1)}"', + ) + ] + + +def hatari(tree: RepoTree, runs: Runs) -> list[Launcher]: + package = f"{EMULATORS}/hatarisa" + script = _read(tree, f"{package}/scripts/hatari.start") + copy_line, copy = script.find(r'^\s*cp \S+/([\w.-]+\.cfg) "\$(\w+)"', "default config copy") + run_lines = { + n + for n, line in script.code() + if n > copy_line and f'--configfile "${copy.group(2)}"' in line + } + if not run_lines: + raise script.missing(f"--configfile ${copy.group(2)}") + path = f"{package}/config/conf/{copy.group(1)}" + found = settings(tree.read(path)) + keys = [ + (s.section, s.key) + for s in found + if _under(s.value) and "." in posixpath.basename(s.value) + ] + if not keys: + raise LauncherPatternMissing(path, f"key naming a file under {BIOS_ROOT}") + value, lines = fixed_paths(found, keys, path) + mk = _read(tree, f"{package}/package.mk") + mk_line, _ = mk.find(r"cp -R \$\{PKG_DIR\}/config/\* ", "config install") + name = posixpath.basename(script.path) + return [ + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="fixed_name", + names=list(value.names), + source_ref=source_ref( + [runs.ref(name), (path, lines), (mk.path, {mk_line}), + (script.path, {copy_line, *run_lines})] + ), + search_dir=value.search_dir, + passes_to=f"{describe_keys(copy.group(1), value.keys)} (--configfile)", + ) + ] + + +def advancemame(tree: RepoTree, runs: Runs) -> list[Launcher]: + """Folder keys of the advmame.rc package.mk installs, one per device branch.""" + package = f"{EMULATORS}/advancemame" + mk = _read(tree, f"{package}/package.mk") + installs = [ + (number, found.group(1)) + for number, line in mk.code() + if (found := _ADVMAME_INSTALL.search(line)) + ] + if not installs: + raise mk.missing("advmame.rc install") + by_value: dict[tuple[tuple[str, str], ...], list[tuple[str, set[int], int]]] = {} + for mk_line, file in installs: + path = f"{package}/config/{file}" + found = [s for s in settings(tree.read(path), separator=" ") if _under(s.value)] + if not found: + raise LauncherPatternMissing(path, f"key naming a folder under {BIOS_ROOT}") + value = tuple((s.key, s.value) for s in found) + by_value.setdefault(value, []).append((path, {s.line for s in found}, mk_line)) + if len(by_value) != 1: + raise mk.missing("one set of BIOS folders for every device") + ((value, configs),) = by_value.items() + script = _read(tree, f"{package}/bin/advmame.sh") + run_line, _ = script.find(r"^\s*(?:\w+=\S+ )*advmame ", "advmame start") + name = posixpath.basename(script.path) + refs = [ + runs.ref(name), + *((path, lines) for path, lines, _ in configs), + (mk.path, {mk_line for _, _, mk_line in configs}), + (script.path, {run_line}), + ] + return [ + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="passes_path", + names=[], + source_ref=source_ref(refs), + search_dir=folder, + passes_to=f"advmame.rc {key}", + ) + for key, folder in value + ] + + +def mame(tree: RepoTree, runs: Runs) -> list[Launcher]: + """The files mame.sh copies into bios/mame, and the folders mame.ini hands the core.""" + package = f"{LIBRETRO}/mame" + script = _read(tree, f"{package}/scripts/mame.sh") + name = posixpath.basename(script.path) + emulator = runs.emulator(name) + records = [] + guard: tuple[int, str] | None = None + for number, line in script.code(): + if found := re.search(r'if \[ ! -f "([^"]+)" \]', line): + guard = (number, found.group(1)) + continue + copy = re.match(r'\s*cp -rf "([^"]+)/"(\S+) "([^"]+)"\s*$', line) + if copy is None: + continue + if guard is None: + raise script.missing(f"test before the copy at line {number}") + if not _under(copy.group(3)): + continue + records.append( + Launcher( + emulator=emulator, + build="libretro", + kind="copies", + names=[copy.group(2)], + source_ref=source_ref([runs.ref(name), (script.path, {guard[0], number})]), + search_dir=copy.group(1), + passes_to=copy.group(3), + fails_when_missing=False, + depends_on_option=f"{guard[1]} absent", + ) + ) + if not records: + raise script.missing(f"copy into {BIOS_ROOT}") + ini = next((r for r in records if r.names == ["mame*"]), None) + if ini is None: + raise script.missing("copy of mame.ini") + option_path = f"{package}/config/MAME/MAME.opt" + option = next( + (s for s in settings(tree.read(option_path)) if s.key == "mame_read_config"), None + ) + if option is None: + raise LauncherPatternMissing(option_path, "mame_read_config") + ini_path = f"{package}/config/ini/mame.ini" + # MAME reads from the folders its *path options name and writes to its + # *_directory ones. + folders = [ + (s, [p for p in s.value.split(";") if _under(p)]) + for s in settings(tree.read(ini_path), separator=" ") + if s.key.endswith("path") + ] + folders = [(s, parts) for s, parts in folders if parts] + if not folders: + raise LauncherPatternMissing(ini_path, f"path under {BIOS_ROOT}") + for setting, parts in folders: + for part in parts: + records.append( + Launcher( + emulator=emulator, + build="libretro", + kind="passes_path", + names=[], + source_ref=source_ref( + [(ini_path, {setting.line}), (option_path, {option.line})] + ) + f", {ini.source_ref}", + search_dir=part.rstrip("/"), + passes_to=f"mame.ini {setting.key}", + depends_on_option=f"mame_read_config = {option.value}", + ) + ) + return records + + +def scummvm(tree: RepoTree, runs: Runs) -> list[Launcher]: + """--extrapath for the standalone build, and the scummvm.ini copied for the core.""" + path = f"{EMULATORS}/scummvmsa/bin/scummvm.start" + script = _read(tree, path) + name = posixpath.basename(path) + test_line, test = script.find(r'^\s*if \[ -d "([^"]+)" \]; then\s*$', "extra folder test") + set_line, set_ = script.find(r'^\s*(\w+)="(--[\w-]+)=([^"]+)"', "extra path", test_line) + run_line, run = script.find(rf"^\s*(\S+) .*\${set_.group(1)}\b", "start with it", set_line) + core_line, core = script.find(r"-L /tmp/cores/(\w+)_libretro\.so", "libretro start") + arm_line, arm = script.find(r'^\s*"(\w+)"\)', "setup arm", core_line) + copy_line, copy = script.find(r"^\s*cp (\S+) (\S+)$", "copy into the BIOS folder", arm_line) + used = {copy_line} + source = script.expand(copy.group(1), copy_line, used) + target = script.expand(copy.group(2), copy_line, used) + if not _under(target): + raise script.missing(f"copy into {BIOS_ROOT}") + return [ + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="passes_path", + names=[], + source_ref=source_ref([runs.ref(name), (path, {test_line, set_line, run_line})]), + search_dir=set_.group(3), + passes_to=f"{run.group(1)} {set_.group(2)}", + depends_on_option=f"{test.group(1)} exists", + ), + Launcher( + emulator=core.group(1), + build="libretro", + kind="copies", + names=[posixpath.basename(source)], + source_ref=source_ref([(path, {core_line, arm_line} | used)]), + search_dir=posixpath.dirname(source), + passes_to=target, + fails_when_missing=False, + depends_on_option=f"{name} {arm.group(1)}", + ), + ] + + +def dolphin(tree: RepoTree, runs: Runs) -> list[Launcher]: + path = f"{EMULATORS}/dolphinSA/scripts/dolphin.sh" + script = _read(tree, path) + run_line, run = script.find(r"\b(XDG_DATA_HOME)=(\S+) .*\bdolphin-emu", "XDG_DATA_HOME start") + name = posixpath.basename(path) + return [ + Launcher( + emulator=runs.emulator(name), + build="standalone", + kind="passes_path", + names=[], + source_ref=source_ref([runs.ref(name), (path, {run_line})]), + search_dir=run.group(2), + passes_to=f"environment {run.group(1)}", + ) + ] + + +def midi(tree: RepoTree, runs: Runs) -> list[Launcher]: + """The MIDI synth set_midi_source starts for the ra_midi_output setting.""" + script = runs.script + setting_line, setting = script.find( + r'^\s*(\w+)=\$\(get_ee_setting "(\w*midi\w*)" ', "MIDI setting" + ) + call_line, _ = script.find( + rf'set_midi_source "\$\{{{setting.group(1)}\}}"', "set_midi_source call", setting_line + ) + utils = _read(tree, UTILS) + head_line, _ = utils.find(r"^\s*function set_midi_source\(\)", "set_midi_source") + end_line, _ = utils.find(r"^\s*esac\b", "end of the source case", head_line) + records = [] + arm: tuple[int, str] | None = None + for number, line in utils.code(): + if number <= head_line or number >= end_line: + continue + if found := re.match(r"\s*([\w-]+)\)\s*$", line): + arm = (number, found.group(1)) + continue + command = [w.text for w in words(line)] + paths = [ + (command[i - 1], value) + for i, value in enumerate(command) + if i and command[i - 1].startswith("-") and re.search(r"(^|/)bios(/|$)", value) + ] + if not paths or arm is None: + continue + ((flag, value),) = paths + is_file = "." in posixpath.basename(value) and _under(value) + records.append( + Launcher( + emulator=command[0], + build="standalone", + kind="fixed_name" if is_file else "passes_path", + names=[posixpath.relpath(value, BIOS_ROOT)] if is_file else [], + source_ref=source_ref( + [(RUN_EMU, {setting_line, call_line}), (UTILS, {head_line, arm[0], number})] + ), + slot=arm[1], + case_insensitive=False if is_file else None, + search_dir=BIOS_ROOT if is_file else value.rstrip("/"), + passes_to=f"{command[0]} {flag}", + fails_when_missing=False, + depends_on_option=f"{setting.group(2)} = {arm[1]}", + ) + ) + if not records: + raise utils.missing("MIDI synth started with a BIOS path") + return records + + +EXTRACTORS: tuple[Callable[[RepoTree, Runs], list[Launcher]], ...] = ( + retroarch, + retrorun, + freej2me, + mame, + pico8, + links, + duckstation, + yabasanshiro, + jzintv, + mupen64plus, + hatari, + advancemame, + scummvm, + dolphin, + midi, +) + + +def extract_launchers(tree: RepoTree) -> list[dict]: + """Every launcher record the extractors find, in extractor order.""" + runs = Runs(tree) + return [record.entry() for extractor in EXTRACTORS for record in extractor(tree, runs)] diff --git a/scripts/scraper/emuelec_scraper.py b/scripts/scraper/emuelec_scraper.py index 065fa4eaa..a0729952e 100644 --- a/scripts/scraper/emuelec_scraper.py +++ b/scripts/scraper/emuelec_scraper.py @@ -43,6 +43,7 @@ from .batocera_scraper import ( platform_systems, requirements_from_systems, ) +from .emuelec_launchers import extract_launchers from .es_emulators import ( SystemEmulators, attach_system_cores, @@ -50,6 +51,7 @@ from .es_emulators import ( json_systems, system_cores, ) +from .shell_launchers import RepoTree PLATFORM_NAME = "emuelec" @@ -120,6 +122,14 @@ BUILDS_OF = { PORTS_SYSTEM = "ports" +def launcher_revision(url: str) -> str: + """The tag or commit a batocera-systems URL is read at.""" + found = re.search(rf"/{REPO}/([^/]+)/packages/", url) + if found is None: + raise ValueError(f"{url} names no {REPO} revision") + return found.group(1) + + def port_engines(text: str) -> list[tuple[str, str, str | None, bool]]: """(port, engine, check_bios key, libretro?) for each entry of ports.yaml. @@ -215,6 +225,10 @@ class Scraper(BaseScraper): "standalone_cores": standalone, "dual_mode_cores": dual, "systems": systems, + # Read at the revision the BIOS list comes from. + "launchers": extract_launchers( + RepoTree.github(REPO, launcher_revision(self.url)) + ), } diff --git a/tests/test_launchers_emuelec.py b/tests/test_launchers_emuelec.py new file mode 100644 index 000000000..18cf54160 --- /dev/null +++ b/tests/test_launchers_emuelec.py @@ -0,0 +1,655 @@ +"""EmuELEC's launcher layer read from its packages. + +Each fixture is a cut-down copy of the lines an extractor reads at EmuELEC +v4.8 (02b45fe53b85c45c75dbad8e44a648092dec2e72). The records must name the +core emuelecRunEmu.sh dispatches, carry the names in the order the code +tries them, point at the fixture's own lines, and a construct that is gone +must stop the scrape. +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import sys +import textwrap +import unittest +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from scraper import emuelec_launchers as el # noqa: E402 +from scraper.emuelec_scraper import launcher_revision # noqa: E402 +from scraper.shell_launchers import LauncherPatternMissing, RepoTree # noqa: E402 + +EMU = el.EMULATORS + + +def text(block: str) -> str: + return textwrap.dedent(block).lstrip("\n") + + +RUN_EMU = text( + """ + if [[ "${EMULATOR}" = "libretro" ]]; then + EMU="${CORE}_libretro" + fi + if [[ "${EMULATOR}" = "retrorun" ]]; then + EMU="${CORE}_libretro" + fi + MIDI_OUTPUT=$(get_ee_setting "ra_midi_output" "${PLATFORM}" "${BASEROMNAME}") + if [[ ! -z "${MIDI_OUTPUT}" ]]; then + emuelec-utils set_midi_source "${MIDI_OUTPUT}" "${EMULATOR}" + fi + if [ ${EMU} == "freej2me_libretro" ]; then + freej2me.sh + + JAVA_HOME='/storage/roms/bios/jdk' + export JAVA_HOME + fi + case ${PLATFORM} in + "atarist") + if [ "${EMU}" = "HATARISA" ]; then + RUNTHIS='${TBASH} hatari.start "${ROMNAME}"' + fi + ;; + "dreamcast"|"naomi") + if [ "${EMU}" = "flycastsa" ]; then + RUNTHIS='${TBASH} flycast.sh "${ROMNAME}"' + elif [ "${EMU}" = "flycast_dojo" ]; then + RUNTHIS='flycastdojo.sh "${ROMNAME}"' + fi + ;; + "psx") + if [ "${EMU}" = "duckstation" ]; then + RUNTHIS='${TBASH} duckstation.sh "${ROMNAME}"' + fi + ;; + "mame") + if [ "${EMU}" = "AdvanceMame" ]; then + RUNTHIS='${TBASH} advmame.sh "${PLATFORM}" "${ROMNAME}"' + fi + ;; + "n64") + if [ "${EMU}" = "rice" ]; then + RUNTHIS='${TBASH} m64p.sh "${ROMNAME}"' + elif [ "${EMU}" = "glide64mk2" ]; then + RUNTHIS='${TBASH} m64p.sh "${ROMNAME}" m64p_gl64mk2' + fi + ;; + "scummvm") + if [ "${EMU}" = "SCUMMVMSA" ]; then + RUNTHIS='${TBASH} scummvm.start sa "${ROMNAME}"' + fi + ;; + "wii"|"gamecube") + if [ "${EMU}" = "dolphin" ]; then + RUNTHIS='${TBASH} dolphin.sh "${ROMNAME}"' + fi + ;; + "pico8") + RUNTHIS='${TBASH} pico8.sh "${ROMNAME}"' + ;; + "intellivision") + if [ "${EMU}" = "jzintv" ]; then + RUNTHIS='jzintv.sh "${ROMNAME}"' + fi + ;; + "saturn") + if [ "${EMU}" = "yabasanshiroSA" ]; then + RUNTHIS='yabasanshiro.sh "${ROMNAME}"' + elif [ "${EMU}" = "yabasanshiroSA1_5" ]; then + RUNTHIS='yabasanshiro1_5.sh "${ROMNAME}"' + fi + ;; + esac + case ${PLATFORM} in + "arcade"|"mame") + if [ "$EMU" = "mame_libretro" ]; then + mame.sh + fi + ;; + esac + RUNTHIS='${RABIN} ${VERBOSE} ${RA_ARGS} -L /tmp/cores/${EMU}.so --config ${RACONF} "${ROMNAME}"' + RUNTHIS="retrorun" + RUNTHIS+=' ${CMD_ROTATE} --triggers -g -d /storage/roms/bios /tmp/cores/${EMU}.so "${ROMNAME}"' +""" +) + +UTILS = text( + """ + function set_midi_source() + { + case ${midi_source} in + fluidsynth) + fluidsynth -is /storage/roms/bios/sc/SC-55.sf2 & + ;; + timidity) + timidity -iA --module 1 & + ;; + mt32) + mt32d -o 2 -i 24 -f /roms/bios & + ;; + esac + } +""" +) + +RETROARCH_MK = text( + """ + sed -i -e "s/# savestate_directory =/savestate_directory =\\/storage\\/roms\\/savestates/" ${INSTALL}/etc/retroarch.cfg + sed -i -e "s/# system_directory =/system_directory =\\/storage\\/roms\\/bios/" ${INSTALL}/etc/retroarch.cfg +""" +) + +FREEJ2ME_SH = text( + """ + install_java + + if [[ -f "/storage/roms/bios/jdk/eeversion" ]]; then + cp -rf /usr/lib/libretro/freej2me-lr.jar ${HOME}/roms/bios + else + exit 1 + fi +""" +) + +PICO8_SH = text( + """ + if [[ -e "/storage/roms/bios/pico-8" ]]; then + mkdir -p /emuelec/bin/pico-8 + cp -rf /storage/roms/bios/pico-8/* /emuelec/bin/pico-8 + rm -rf /storage/roms/bios/pico-8 + chmod +x /emuelec/bin/pico-8/pico8_dyn + fi + if [[ "${CART}" == *"/splore"* ]]; then + /emuelec/bin/pico-8/pico8_dyn -splore -home /emuelec/configs/pico-8 + else + /emuelec/bin/pico-8/pico8_dyn -run "${CART}" -home /emuelec/configs/pico-8 + fi +""" +) + +FLYCAST_SH = text( + """ + if [ ! -L "/storage/.local/share/flycast" ]; then + mkdir -p "/storage/roms/bios/dc" + rm -rf "/storage/.local/share/flycast" + ln -sf "/storage/roms/bios/dc" "/storage/.local/share/flycast" + fi +""" +) + +FLYCASTDOJO_SH = text( + """ + LOCAL_DATA="/storage/roms/bios/dc_dojo" + LOCAL_HOME="/storage/.local/share/flycast-dojo" + if [ ! -L "${LOCAL_HOME}" ]; then + rm -rf "${LOCAL_HOME}" + ln -sf "${LOCAL_DATA}" "${LOCAL_HOME}" + fi +""" +) + +AMIBERRY_MK = text( + """ + cp -a ${PKG_DIR}/config/* ${INSTALL}/usr/config/amiberry/ + ln -s /storage/roms/bios ${INSTALL}/usr/config/amiberry/kickstarts +""" +) + +TIMIDITY_MK = text( + """ + ln -sf /storage/roms/bios/timidity/freepats ${INSTALL}/usr/config/timidity/freepats + ln -sf /storage/.config/timidity/freepats.cfg ${INSTALL}/etc/timidity/freepats.cfg +""" +) + +DUCKSTATION_INI = text( + """ + [BIOS] + SearchDirectory = /storage/roms/bios + PathNTSCU = + PathNTSCJ = + PathPAL = +""" +) + +DUCKSTATION_MK = text( + """ + cp -rf ${PKG_DIR}/config/* ${INSTALL}/usr/config/emuelec/configs/duckstation +""" +) + +DUCKSTATION_SH = text( + """ + CONFIG_DIR="/emuelec/configs/duckstation" + duckstation-nogui -fullscreen -settings "${CONFIG_DIR}/settings.ini" -- "${1}" +""" +) + + +def yabasanshiro_sh(program: str) -> str: + return text( + f""" + BIOS="" + HLEBIOS=$(get_ee_setting hlebios saturn "${{ROMNAME}}") + if [ "${{HLEBIOS}}" != 1 ]; then + if [ -e "/storage/roms/bios/saturn_bios.bin" ]; then + BIOS="-b /storage/roms/bios/saturn_bios.bin" + else + BIOS="" + fi + fi + {{ {program} -r 2 -i "${{1}}" ${{BIOS}}; }} > /emuelec/logs/emuelec.log 2>&1 +""" + ) + + +JZINTV_SH = text( + """ + exec jzintv -f1 -z "$RES" -p /storage/roms/bios/ "$ROM" $KBD_OPT +""" +) + +M64P_SH = text( + """ + BIOSDIR="/storage/roms/bios/Mupen64plus" + IPLROM="${BIOSDIR}/N64DD IPLROM (J).n64" + [ ! -f "${IPLROM}" ] && IPLROM="${BIOSDIR}/IPL.n64" + if [ -f "${GAMEDIR}/${GAMENAME}.ndd" ] && [ -f "${IPLROM}" ]; then + RENMODE="1" + else + IPLROM="" + fi + mupen64plus --fullscreen --dd-ipl-rom "${IPLROM}" --dd-disk "${DDDISK}" "${FILE}" ;; +""" +) + +HATARI_START = text( + """ + HATARI_TMP_CONFIG="$HATARI_TMP_DIR"/.hatari-temp.cfg + cp $HATARI_CONFIG_DIR/Atari-ST-default.cfg "$HATARI_TMP_CONFIG" + hatari --configfile "$HATARI_TMP_CONFIG" --disk-a "$1" +""" +) + +HATARI_CFG = text( + """ + [HardDisk] + szHardDiskDirectory = /storage/.hatari + + [ROM] + szTosImageFileName = /storage/roms/bios/tos.img + szCartridgeImageFileName = +""" +) + +HATARI_MK = text( + """ + cp -R ${PKG_DIR}/config/* ${INSTALL}/usr/config/hatari +""" +) + +ADVMAME_MK = text( + """ + if [ "${DEVICE}" == "OdroidGoAdvance" ]; then + cp -r ${PKG_DIR}/config/advmame.rc_oga ${INSTALL}/usr/config/emuelec/configs/advmame/advmame.rc + else + cp -r ${PKG_DIR}/config/advmame.rc ${INSTALL}/usr/config/emuelec/configs/advmame/advmame.rc + fi +""" +) + +ADVMAME_RC = text( + """ + dir_rom /storage/roms/mame + dir_sample /storage/roms/bios/advmame/samples +""" +) + +ADVMAME_SH = text( + """ + SDL_AUDIODRIVER=alsa advmame ${ARG} -quiet +""" +) + +MAME_SH = text( + """ + if [ ! -f "/storage/roms/bios/mame/hash/fmtowns_cd.xml" ]; then + mkdir -p /storage/roms/bios/mame/hash + cp -rf "/usr/config/emuelec/configs/mame/hash/"fm* "/storage/roms/bios/mame/hash" + fi + if [ ! -f "/storage/roms/bios/mame/ini/mame.ini" ]; then + mkdir -p /storage/roms/bios/mame/ini + cp -rf "/usr/config/emuelec/configs/mame/ini/"mame* "/storage/roms/bios/mame/ini" + fi + if [ ! -f "/storage/.config/retroarch/config/MAME/MAME.opt" ]; then + cp -rf "/usr/config/emuelec/configs/mame/MAME/"MAME* "/storage/.config/retroarch/config/MAME" + fi +""" +) + +MAME_INI = text( + """ + rompath /storage/roms/bios/mame/bios;/storage/roms/mame;/storage/roms/arcade + samplepath /storage/roms/bios/mame/samples + cfg_directory /storage/roms/bios/mame/mame/cfg +""" +) + +MAME_OPT = text( + """ + mame_boot_to_bios = "disabled" + mame_read_config = "enabled" +""" +) + +SCUMMVM_START = text( + """ + CONFIG_DIR="/storage/.config/scummvm" + if [ -d "/storage/.config/scummvm/extra" ]; then + EXTRA="--extrapath=/storage/.config/scummvm/extra" + fi + case $1 in + "sa") + scummvm --fullscreen --joystick=0 $EXTRA "${GAME}" + ;; + "libretro") + retroarch -L /tmp/cores/scummvm_libretro.so --config ${RATMPCONF} "${GAME}" + ;; + "add") + scummvm --add --path="/storage/roms/scummvm" --recursive + cp ${CONFIG_DIR}/scummvm.ini /storage/roms/bios/scummvm.ini + ;; + esac +""" +) + +DOLPHIN_SH = text( + """ + XDG_CONFIG_HOME=/emuelec/configs XDG_DATA_HOME=/storage/roms/dolphin /usr/bin/dolphin-emu-nogui -p fbdev "${1}" +""" +) + +FILES = { + el.RUN_EMU: RUN_EMU, + el.UTILS: UTILS, + el.RETROARCH_MK: RETROARCH_MK, + f"{el.LIBRETRO}/freej2me/scripts/freej2me.sh": FREEJ2ME_SH, + f"{el.BIN}/pico8.sh": PICO8_SH, + f"{EMU}/flycastsa/scripts/flycast.sh": FLYCAST_SH, + f"{EMU}/flycast-dojo/scripts/flycastdojo.sh": FLYCASTDOJO_SH, + f"{EMU}/amiberry/package.mk": AMIBERRY_MK, + f"{EMU}/amiberry-lite/package.mk": AMIBERRY_MK.replace("amiberry/", "amiberry-lite/"), + f"{el.SX05RE}/tools/sound/timidity/package.mk": TIMIDITY_MK, + f"{EMU}/duckstation/config/settings.ini": DUCKSTATION_INI, + f"{EMU}/duckstation/package.mk": DUCKSTATION_MK, + f"{EMU}/duckstation/scripts/duckstation.sh": DUCKSTATION_SH, + el.YABASANSHIRO_SCRIPTS[0]: yabasanshiro_sh("yabasanshiro"), + el.YABASANSHIRO_SCRIPTS[1]: yabasanshiro_sh("yabasanshiro1_5"), + f"{EMU}/jzintv/scripts/jzintv.sh": JZINTV_SH, + f"{EMU}/mupen64plussa/mupen64plussa-core/m64p.sh": M64P_SH, + f"{EMU}/hatarisa/scripts/hatari.start": HATARI_START, + f"{EMU}/hatarisa/config/conf/Atari-ST-default.cfg": HATARI_CFG, + f"{EMU}/hatarisa/package.mk": HATARI_MK, + f"{EMU}/advancemame/package.mk": ADVMAME_MK, + f"{EMU}/advancemame/config/advmame.rc": ADVMAME_RC, + f"{EMU}/advancemame/config/advmame.rc_oga": ADVMAME_RC, + f"{EMU}/advancemame/bin/advmame.sh": ADVMAME_SH, + f"{el.LIBRETRO}/mame/scripts/mame.sh": MAME_SH, + f"{el.LIBRETRO}/mame/config/ini/mame.ini": MAME_INI, + f"{el.LIBRETRO}/mame/config/MAME/MAME.opt": MAME_OPT, + f"{EMU}/scummvmsa/bin/scummvm.start": SCUMMVM_START, + f"{EMU}/dolphinSA/scripts/dolphin.sh": DOLPHIN_SH, +} + + +def tree(**changes: str | None) -> RepoTree: + files = dict(FILES) + for path, content in changes.items(): + if content is None: + files.pop(path, None) + else: + files[path] = content + return RepoTree.from_files(files) + + +def records(extractor, **changes: str | None) -> list[dict]: + built = tree(**changes) + return [r.entry() for r in extractor(built, el.Runs(built))] + + +def ref(path: str, lines: str) -> str: + return f"{path}:{lines}" + + +class Dispatch(unittest.TestCase): + def test_each_script_is_named_after_the_core_that_starts_it(self): + runs = el.Runs(tree()) + self.assertEqual(runs.emulator("flycast.sh"), "flycastsa") + self.assertEqual(runs.emulator("flycastdojo.sh"), "flycast_dojo") + self.assertEqual(runs.emulator("m64p.sh"), "rice, glide64mk2") + self.assertEqual(runs.emulator("mame.sh"), "mame") + self.assertEqual(runs.emulator("freej2me.sh"), "freej2me") + + def test_an_arm_without_a_core_test_names_its_platform(self): + self.assertEqual(el.Runs(tree()).emulator("pico8.sh"), "pico8") + + def test_a_script_no_longer_started_raises(self): + runs = el.Runs(tree()) + with self.assertRaises(LauncherPatternMissing): + runs.of("gone.sh") + + def test_a_dispatch_without_any_script_raises(self): + with self.assertRaises(LauncherPatternMissing): + el.Runs(tree(**{el.RUN_EMU: "echo nothing\n"})) + + +class LibretroRecords(unittest.TestCase): + def test_retroarch_system_directory_comes_from_the_sed(self): + (record,) = records(el.retroarch) + self.assertEqual(record["search_dir"], "/storage/roms/bios") + self.assertEqual(record["kind"], "passes_path") + self.assertEqual( + record["source_ref"], f"{ref(el.RETROARCH_MK, '2')}, {ref(el.RUN_EMU, '80')}" + ) + + def test_retrorun_is_handed_the_folder_with_d(self): + (record,) = records(el.retrorun) + self.assertEqual( + (record["emulator"], record["search_dir"], record["passes_to"]), + ("retrorun", "/storage/roms/bios", "retrorun -d"), + ) + self.assertEqual(record["source_ref"], ref(el.RUN_EMU, "4, 81-82")) + + def test_freej2me_gets_java_home_and_its_jar_copied(self): + java, jar = records(el.freej2me) + self.assertEqual(java["passes_to"], "environment JAVA_HOME") + self.assertEqual(java["search_dir"], "/storage/roms/bios/jdk") + self.assertEqual(jar["kind"], "copies") + self.assertEqual(jar["names"], ["freej2me-lr.jar"]) + self.assertEqual(jar["passes_to"], "${HOME}/roms/bios") + self.assertEqual(jar["depends_on_option"], "/storage/roms/bios/jdk/eeversion exists") + + def test_mame_copies_its_own_files_and_reads_only_path_options(self): + found = records(el.mame) + copies = [r for r in found if r["kind"] == "copies"] + self.assertEqual([r["names"] for r in copies], [["fm*"], ["mame*"]]) + folders = [(r["passes_to"], r["search_dir"]) for r in found if r["kind"] == "passes_path"] + self.assertEqual( + folders, + [ + ("mame.ini rompath", "/storage/roms/bios/mame/bios"), + ("mame.ini samplepath", "/storage/roms/bios/mame/samples"), + ], + ) + self.assertTrue(all(r["depends_on_option"] == "mame_read_config = enabled" + for r in found if r["kind"] == "passes_path")) + + def test_mame_without_the_read_config_option_raises(self): + with self.assertRaises(LauncherPatternMissing): + records(el.mame, **{f"{el.LIBRETRO}/mame/config/MAME/MAME.opt": "x = 1\n"}) + + +class StandaloneRecords(unittest.TestCase): + def test_pico8_moves_the_folder_and_runs_the_binary(self): + (record,) = records(el.pico8) + self.assertEqual(record["kind"], "moves") + self.assertEqual(record["names"], ["pico8_dyn"]) + self.assertEqual(record["search_dir"], "/storage/roms/bios/pico-8") + self.assertEqual(record["passes_to"], "/emuelec/bin/pico-8") + self.assertEqual( + record["source_ref"], + f"{ref(el.RUN_EMU, '57-58')}, {ref(f'{el.BIN}/pico8.sh', '1-5, 8-10')}", + ) + + def test_pico8_without_the_removal_is_not_a_move(self): + changed = PICO8_SH.replace(" rm -rf /storage/roms/bios/pico-8\n", "") + with self.assertRaises(LauncherPatternMissing): + records(el.pico8, **{f"{el.BIN}/pico8.sh": changed}) + + def test_links_from_scripts_and_packages(self): + found = records(el.links) + self.assertEqual( + [(r["emulator"], r["search_dir"]) for r in found], + [ + ("flycastsa", "/storage/roms/bios/dc"), + ("flycast_dojo", "/storage/roms/bios/dc_dojo"), + ("amiberry", "/storage/roms/bios"), + ("amiberry-lite", "/storage/roms/bios"), + ("timidity", "/storage/roms/bios/timidity/freepats"), + ], + ) + self.assertEqual( + found[1]["source_ref"], + f"{ref(el.RUN_EMU, '26-27')}, " + f"{ref(f'{EMU}/flycast-dojo/scripts/flycastdojo.sh', '1-5')}", + ) + self.assertEqual( + found[2]["passes_to"], "symlink /usr/config/amiberry/kickstarts -> /storage/roms/bios" + ) + + def test_a_script_without_a_bios_link_raises(self): + with self.assertRaises(LauncherPatternMissing): + records(el.links, **{f"{EMU}/flycastsa/scripts/flycast.sh": "flycast \"${1}\"\n"}) + + def test_duckstation_scans_the_folder_its_config_names(self): + (record,) = records(el.duckstation) + self.assertEqual((record["kind"], record["search_dir"]), ("scans_dir", "/storage/roms/bios")) + + def test_duckstation_with_a_fixed_bios_path_raises(self): + changed = DUCKSTATION_INI.replace("PathPAL =", "PathPAL = /storage/roms/bios/scph5502.bin") + with self.assertRaises(LauncherPatternMissing): + records(el.duckstation, **{f"{EMU}/duckstation/config/settings.ini": changed}) + + def test_yabasanshiro_passes_the_bios_unless_hle_is_chosen(self): + new, old = records(el.yabasanshiro) + self.assertEqual((new["emulator"], old["emulator"]), ("yabasanshiroSA", "yabasanshiroSA1_5")) + self.assertEqual(new["names"], ["saturn_bios.bin"]) + self.assertEqual(new["passes_to"], "yabasanshiro -b") + self.assertEqual(old["passes_to"], "yabasanshiro1_5 -b") + self.assertEqual(new["depends_on_option"], "hlebios != 1") + self.assertIs(new["fails_when_missing"], False) + + def test_jzintv_is_handed_the_bios_folder(self): + (record,) = records(el.jzintv) + self.assertEqual((record["search_dir"], record["passes_to"]), ("/storage/roms/bios", "jzintv -p")) + + def test_mupen64plus_tries_its_ipl_names_in_order(self): + (record,) = records(el.mupen64plus) + self.assertEqual(record["kind"], "names_searched") + self.assertEqual(record["names"], ["N64DD IPLROM (J).n64", "IPL.n64"]) + self.assertEqual(record["search_dir"], "/storage/roms/bios/Mupen64plus") + self.assertEqual(record["passes_to"], "mupen64plus --dd-ipl-rom") + self.assertEqual(record["depends_on_option"], '-f "${GAMEDIR}/${GAMENAME}.ndd"') + + def test_mupen64plus_without_a_fallback_raises(self): + changed = M64P_SH.replace('[ ! -f "${IPLROM}" ] && IPLROM="${BIOSDIR}/IPL.n64"\n', "") + with self.assertRaises(LauncherPatternMissing): + records(el.mupen64plus, **{f"{EMU}/mupen64plussa/mupen64plussa-core/m64p.sh": changed}) + + def test_hatari_reads_the_tos_named_by_its_default_config(self): + (record,) = records(el.hatari) + self.assertEqual(record["names"], ["tos.img"]) + self.assertEqual( + record["passes_to"], "Atari-ST-default.cfg [ROM] szTosImageFileName (--configfile)" + ) + + def test_advancemame_one_record_when_every_device_agrees(self): + (record,) = records(el.advancemame) + self.assertEqual(record["search_dir"], "/storage/roms/bios/advmame/samples") + self.assertNotIn("device", record) + + def test_advancemame_devices_that_disagree_raise(self): + changed = ADVMAME_RC.replace("bios/advmame", "bios/other") + with self.assertRaises(LauncherPatternMissing): + records(el.advancemame, **{f"{EMU}/advancemame/config/advmame.rc_oga": changed}) + + def test_scummvm_extrapath_and_the_ini_copied_for_the_core(self): + extra, ini = records(el.scummvm) + self.assertEqual(extra["passes_to"], "scummvm --extrapath") + self.assertEqual(extra["depends_on_option"], "/storage/.config/scummvm/extra exists") + self.assertEqual((ini["emulator"], ini["build"]), ("scummvm", "libretro")) + self.assertEqual(ini["search_dir"], "/storage/.config/scummvm") + self.assertEqual(ini["passes_to"], "/storage/roms/bios/scummvm.ini") + + def test_dolphin_data_home(self): + (record,) = records(el.dolphin) + self.assertEqual(record["passes_to"], "environment XDG_DATA_HOME") + self.assertEqual(record["search_dir"], "/storage/roms/dolphin") + + def test_midi_sources_that_read_the_bios_folder(self): + found = records(el.midi) + self.assertEqual( + [(r["emulator"], r["kind"], r["names"], r["search_dir"]) for r in found], + [ + ("fluidsynth", "fixed_name", ["sc/SC-55.sf2"], "/storage/roms/bios"), + ("mt32d", "passes_path", [], "/roms/bios"), + ], + ) + self.assertEqual(found[0]["depends_on_option"], "ra_midi_output = fluidsynth") + self.assertEqual( + found[1]["source_ref"], f"{ref(el.RUN_EMU, '7-9')}, {ref(el.UTILS, '1, 10-11')}" + ) + + def test_every_extractor_runs_on_the_fixtures(self): + found = el.extract_launchers(tree()) + self.assertEqual(len(found), 26) + + +class Revision(unittest.TestCase): + def test_the_revision_is_the_one_the_bios_list_is_read_at(self): + url = ( + "https://raw.githubusercontent.com/EmuELEC/EmuELEC/v4.8/" + "packages/sx05re/emuelec/bin/batocera/batocera-systems" + ) + self.assertEqual(launcher_revision(url), "v4.8") + + def test_a_url_without_a_revision_raises(self): + with self.assertRaises(ValueError): + launcher_revision("https://example.org/batocera-systems") + + +class PlatformRecords(unittest.TestCase): + """The launchers platforms/emuelec.yml carries, as the scraper wrote them.""" + + @classmethod + def setUpClass(cls) -> None: + cls.config = yaml.safe_load((REPO_ROOT / "platforms" / "emuelec.yml").read_text()) + + def test_the_platform_carries_launchers(self): + self.assertTrue(self.config.get("launchers")) + + def test_every_reference_is_in_the_emuelec_packages(self): + for record in self.config["launchers"]: + for part in record["source_ref"].split(", "): + if ":" in part: + self.assertTrue(part.startswith(f"{el.SX05RE}/"), record) + + +if __name__ == "__main__": + unittest.main()