mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
The lone-dash check read MAME's -rompath and -samplepath, QUASI88's -romdir and RetroBat's -hash and -sp as half a dash glued to a word. Quoted as code they read as flags. A C loop quoted in romdir.py and a docstring in test_agnostic_scope no longer read as clause semicolons.
662 lines
27 KiB
Python
662 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""Batocera's launcher layer, read from configgen with ast.
|
|
|
|
The generator blocks below are copied from batocera-43.1 (155c2d8d) and
|
|
placed at the lines they hold there, so the source_ref an extraction writes
|
|
is checked against the revision itself.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
|
|
|
|
import dataclasses
|
|
import json
|
|
import sys
|
|
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.batocera_launchers import ( # noqa: E402
|
|
CONFIGGEN,
|
|
GENERATORS,
|
|
IMPORTER,
|
|
PATHS_MODULE,
|
|
Configgen,
|
|
Launcher,
|
|
LauncherShapeError,
|
|
config_directories,
|
|
duckstation,
|
|
extract_launchers,
|
|
hatari,
|
|
lexaloffle,
|
|
)
|
|
from scraper.retropie_launchers import Launcher as PackageLauncher # noqa: E402
|
|
|
|
PATHS = """from pathlib import Path
|
|
from typing import Final
|
|
|
|
USERDATA: Final = Path('/userdata')
|
|
BIOS: Final = USERDATA / 'bios'
|
|
"""
|
|
|
|
IMPORTER_TEXT = """from typing import Final
|
|
|
|
_LEGACY_GENERATOR_MAP: Final[dict[str, dict[str, tuple[str, str]]]] = {
|
|
'duckstation': {
|
|
'duckstation-legacy': ('duckstation_legacy.duckstationLegacyGenerator', 'X'),
|
|
},
|
|
}
|
|
|
|
_GENERATOR_MAP: Final[dict[str, tuple[str, str]]] = {
|
|
'gsplus': ('gsplus.gsplusGenerator', 'GSplusGenerator'),
|
|
}
|
|
"""
|
|
|
|
DUCKSTATION = f"{GENERATORS}/duckstation/duckstationGenerator.py"
|
|
HATARI = f"{GENERATORS}/hatari/hatariGenerator.py"
|
|
LEXALOFFLE = f"{GENERATORS}/lexaloffle/lexaloffleGenerator.py"
|
|
|
|
DUCKSTATION_HEAD = '''from __future__ import annotations
|
|
|
|
from ...batoceraPaths import BIOS
|
|
|
|
|
|
class DuckstationGenerator(Generator):
|
|
def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution):
|
|
settings = CaseSensitiveConfigParser(interpolation=None)'''
|
|
|
|
# duckstationGenerator.py:115-136
|
|
DUCKSTATION_BIOS = ''' ## [BIOS]
|
|
if not settings.has_section("BIOS"):
|
|
settings.add_section("BIOS")
|
|
settings.set("BIOS", "SearchDirectory", "/userdata/bios")
|
|
# Boot Logo
|
|
settings.set("BIOS", "PatchFastBoot", system.config.get("duckstation_PatchFastBoot", "false"))
|
|
# Find & populate BIOS
|
|
found_bios = find_bios(bios_lists)
|
|
|
|
if not found_bios:
|
|
raise BatoceraException("No PSX1 BIOS found")
|
|
|
|
# Set BIOS paths
|
|
if "Uni" in found_bios:
|
|
uni_bios = found_bios["Uni"]
|
|
settings.set("BIOS", "PathNTSCU", uni_bios)
|
|
settings.set("BIOS", "PathPAL", uni_bios)
|
|
settings.set("BIOS", "PathNTSCJ", uni_bios)
|
|
else:
|
|
region_mapping = {"NTSCU": "PathNTSCU", "PAL": "PathPAL", "NTSCJ": "PathNTSCJ"}
|
|
for region, bios in found_bios.items():
|
|
settings.set("BIOS", region_mapping[region], bios)'''
|
|
|
|
# duckstationGenerator.py:501-523
|
|
DUCKSTATION_FIND = '''def find_bios(bios_lists: Mapping[str, Sequence[str]]):
|
|
found_bios: dict[str, str] = {}
|
|
|
|
try:
|
|
files_lower = {f.name.lower(): f.name for f in BIOS.iterdir()}
|
|
except OSError as e:
|
|
raise BatoceraException(f"Unable to read BIOS directory: {BIOS}") from e
|
|
|
|
for region, bios_list in bios_lists.items():
|
|
for bios in bios_list:
|
|
if bios.lower() in files_lower:
|
|
found_bios[region] = files_lower[bios.lower()]
|
|
break
|
|
|
|
return found_bios
|
|
|
|
# Define BIOS lists
|
|
bios_lists = {
|
|
"NTSCU": ["scph101.bin", "scph1001.bin", "scph5501.bin", "scph7001.bin", "scph7501.bin"],
|
|
"PAL": ["scph1002.bin", "scph5502.bin", "scph5552.bin", "scph7002.bin", "scph7502.bin", "scph9002.bin", "scph102a.bin", "scph102b.bin"],
|
|
"NTSCJ": ["scph100.bin", "scph1000.bin", "scph3000.bin", "scph3500.bin", "scph5500.bin", "scph7000.bin", "scph7003.bin"],
|
|
"Uni": ["psxonpsp660.bin", "ps1_rom.bin"]
|
|
}'''
|
|
|
|
HATARI_HEAD = '''from __future__ import annotations
|
|
|
|
from ...batoceraPaths import BIOS
|
|
|
|
|
|
class HatariGenerator(Generator):'''
|
|
|
|
# hatariGenerator.py:32-75
|
|
HATARI_GENERATE = ''' def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution):
|
|
model_mapping = {
|
|
"520st_auto": { "machine": "st", "tos": "auto" },
|
|
"520st_100": { "machine": "st", "tos": "100" },
|
|
"520st_102": { "machine": "st", "tos": "102" },
|
|
"520st_104": { "machine": "st", "tos": "104" },
|
|
"520st_etos256": { "machine": "st", "tos": "etos256" },
|
|
"1040ste_auto": { "machine": "ste", "tos": "auto" },
|
|
"1040ste_106": { "machine": "ste", "tos": "106" },
|
|
"1040ste_162": { "machine": "ste", "tos": "162" },
|
|
"1040ste_etos256": { "machine": "ste", "tos": "etos256" },
|
|
"megaste_auto": { "machine": "megaste", "tos": "auto" },
|
|
"megaste_205": { "machine": "megaste", "tos": "205" },
|
|
"megaste_206": { "machine": "megaste", "tos": "206" },
|
|
"megaste_etos256": { "machine": "megaste", "tos": "etos256" },
|
|
"tt_auto": { "machine": "tt", "tos": "auto" },
|
|
"tt_306": { "machine": "tt", "tos": "306" },
|
|
"tt_etos512": { "machine": "tt", "tos": "etos512" },
|
|
"falcon_auto": { "machine": "falcon", "tos": "auto" },
|
|
"falcon_400": { "machine": "falcon", "tos": "400" },
|
|
"falcon_402": { "machine": "falcon", "tos": "402" },
|
|
"falcon_404": { "machine": "falcon", "tos": "404" },
|
|
"falcon_etos512": { "machine": "falcon", "tos": "etos512" },
|
|
}
|
|
|
|
# Start emulator fullscreen
|
|
commandArray: list[str | Path] = ["hatari", "--fullscreen"]
|
|
|
|
# Machine can be st (default), ste, megaste, tt, falcon
|
|
# st should use TOS 1.00 to TOS 1.04 (tos100 / tos102 / tos104 / emutos192k)
|
|
# ste should use TOS 1.06 at least (tos106 / tos162 / tos206 / emutos192K)
|
|
# megaste should use TOS 2.XX series (tos206 / emutos256k)
|
|
# tt should use tos 3.XX / emutos512k
|
|
# falcon should use tos 4.XX / emutos512k
|
|
|
|
model = system.config.get("model", "none")
|
|
mapped = model_mapping.get(model, {"machine": "st", "tos": "auto"})
|
|
machine = mapped["machine"]
|
|
tosversion = mapped["tos"]
|
|
toslang = system.config.get("language", "us")
|
|
|
|
commandArray += ["--machine", machine]
|
|
tos = HatariGenerator.findBestTos(BIOS, machine, tosversion, toslang)
|
|
commandArray += [ "--tos", tos]'''
|
|
|
|
# hatariGenerator.py:157-193
|
|
HATARI_FIND = ''' @staticmethod
|
|
def findBestTos(biosdir: Path, machine: str, tos_version: str, language: str, /) -> Path:
|
|
# all languages by preference, when value is "auto"
|
|
all_languages = ["us", "uk", "de", "es", "fr", "it", "nl", "ru", "se", ""]
|
|
|
|
# machine bioses by prefered orders, when value is "auto"
|
|
all_machines_bios = {
|
|
"st": ["etos256", "104", "102", "100"],
|
|
"ste": ["etos256", "162", "106"],
|
|
"megaste": ["etos256", "206", "205"],
|
|
"tt": ["etos512", "306"],
|
|
"falcon": ["etos512", "404", "402", "400"]
|
|
}
|
|
|
|
if machine in all_machines_bios:
|
|
l_tos = []
|
|
if tos_version != "auto":
|
|
l_tos = [tos_version]
|
|
l_tos.extend(all_machines_bios[machine])
|
|
for v_tos_version in l_tos:
|
|
l_lang = []
|
|
if language != "auto":
|
|
l_lang = [language]
|
|
l_lang.extend(all_languages)
|
|
for v_language in l_lang:
|
|
if "etos" in v_tos_version:
|
|
biosversion = v_tos_version
|
|
else:
|
|
biosversion = f"tos{v_tos_version}"
|
|
tos_path = biosdir / f"{biosversion}{v_language}.img"
|
|
if tos_path.exists():
|
|
_logger.debug("tos filename: %s", tos_path.name)
|
|
return tos_path
|
|
|
|
_logger.warning("tos filename %s not found", tos_path.name)
|
|
|
|
raise BatoceraException(f"No bios found for machine {machine}")'''
|
|
|
|
|
|
# lexaloffleGenerator.py, whole file
|
|
LEXALOFFLE_GENERATOR = '''from __future__ import annotations
|
|
|
|
import os
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from ... import Command
|
|
from ...batoceraPaths import BIOS, HOME, ROMS, SCREENSHOTS, ensure_parents_and_open
|
|
from ...controller import generate_sdl_game_controller_config
|
|
from ...exceptions import BatoceraException
|
|
from ..Generator import Generator
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from ...types import HotkeysContext
|
|
|
|
PICO8_BIN_PATH: Final = BIOS / "pico-8" / "pico8"
|
|
PICO8_ROOT_PATH: Final = ROMS / "pico8"
|
|
PICO8_CONTROLLERS: Final = HOME / ".lexaloffle" / "pico-8" / "sdl_controllers.txt"
|
|
VOX_BIN_PATH: Final = BIOS / "voxatron" / "vox"
|
|
VOX_ROOT_PATH: Final = ROMS / "voxatron"
|
|
VOX_CONTROLLERS: Final = HOME / ".lexaloffle" / "Voxatron" / "sdl_controllers.txt"
|
|
|
|
|
|
# Generator for the official pico8 binary from Lexaloffle
|
|
class LexaloffleGenerator(Generator):
|
|
|
|
def getHotkeysContext(self) -> HotkeysContext:
|
|
return {
|
|
"name": "lexaloffle",
|
|
"keys": { "exit": ["KEY_LEFTCTRL", "KEY_Q"], "menu": "KEY_ENTER", "reset": [ "KEY_LEFTCTRL", "KEY_R" ] }
|
|
}
|
|
|
|
def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution):
|
|
if (system.name == "pico8"):
|
|
LD_LIB=BIOS / "pico-8"
|
|
BIN_PATH=PICO8_BIN_PATH
|
|
CONTROLLERS=PICO8_CONTROLLERS
|
|
ROOT_PATH=PICO8_ROOT_PATH
|
|
elif (system.name == "voxatron"):
|
|
LD_LIB=BIOS / "voxatron"
|
|
BIN_PATH=VOX_BIN_PATH
|
|
CONTROLLERS=VOX_CONTROLLERS
|
|
ROOT_PATH=VOX_ROOT_PATH
|
|
else:
|
|
raise BatoceraException(f"The Lexaloffle generator has been called for an unknwon system: {system.name}.")
|
|
|
|
if not BIN_PATH.exists():
|
|
raise BatoceraException(f"Lexaloffle official binary not found at {BIN_PATH}")
|
|
|
|
if not os.access(BIN_PATH, os.X_OK):
|
|
raise BatoceraException(f"{BIN_PATH} is not set as executable")
|
|
|
|
# the command to run
|
|
commandArray: list[str | Path] = [BIN_PATH]
|
|
commandArray.extend(["-desktop", SCREENSHOTS]) # screenshots
|
|
commandArray.extend(["-windowed", "0"]) # full screen
|
|
# Display FPS
|
|
if system.config.show_fps:
|
|
commandArray.extend(["-show_fps", "1"])
|
|
else:
|
|
commandArray.extend(["-show_fps", "0"])
|
|
|
|
rombase = rom.stem
|
|
|
|
# .m3u support for multi-cart pico-8
|
|
if rom.suffix.lower() == ".m3u":
|
|
with rom.open() as fpin:
|
|
lines = fpin.readlines()
|
|
fullpath = rom.absolute().parent / lines[0].strip()
|
|
commandArray.extend(["-root_path", fullpath.parent])
|
|
rom = fullpath
|
|
else:
|
|
commandArray.extend(["-root_path", ROOT_PATH]) # store carts from splore
|
|
|
|
if (rombase.lower() == "splore" or rombase.lower() == "console"):
|
|
commandArray.extend(["-splore"])
|
|
else:
|
|
commandArray.extend(["-run", rom])
|
|
|
|
controllersconfig = generate_sdl_game_controller_config(playersControllers)
|
|
with ensure_parents_and_open(CONTROLLERS, "w") as file:
|
|
file.write(controllersconfig)
|
|
|
|
existing_library_path = os.environ.get("LD_LIBRARY_PATH")
|
|
|
|
return Command.Command(array=commandArray, env={
|
|
"SDL_AUDIODRIVER": "alsa",
|
|
"LD_LIBRARY_PATH": f"{LD_LIB}:{existing_library_path}" if existing_library_path else LD_LIB
|
|
})
|
|
|
|
def getInGameRatio(self, config, gameResolution, rom):
|
|
return 4/3'''
|
|
|
|
|
|
def placed(blocks: dict[int, str]) -> str:
|
|
"""Each block starting at its line, blank lines between."""
|
|
lines: list[str] = []
|
|
for start, block in sorted(blocks.items()):
|
|
if len(lines) >= start:
|
|
raise AssertionError(f"block at line {start} overlaps the previous one")
|
|
lines += [""] * (start - 1 - len(lines))
|
|
lines += block.splitlines()
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def configgen(files: dict[str, str]) -> Configgen:
|
|
tree = {PATHS_MODULE: PATHS, IMPORTER: IMPORTER_TEXT, **files}
|
|
return Configgen(tree.__getitem__)
|
|
|
|
|
|
# The generator goes on after the [BIOS] section: code between the two cited
|
|
# ranges keeps them apart.
|
|
DUCKSTATION_NEXT = ''' settings.set("CPU", "ExecutionMode", "Recompiler")'''
|
|
|
|
|
|
def duckstation_source(bios: str = DUCKSTATION_BIOS, find: str = DUCKSTATION_FIND) -> str:
|
|
return placed({1: DUCKSTATION_HEAD, 115: bios, 138: DUCKSTATION_NEXT, 501: find})
|
|
|
|
|
|
def duckstation_tree(bios: str = DUCKSTATION_BIOS, find: str = DUCKSTATION_FIND) -> Configgen:
|
|
return configgen({DUCKSTATION: duckstation_source(bios, find)})
|
|
|
|
|
|
def hatari_tree(generate: str = HATARI_GENERATE, find: str = HATARI_FIND) -> Configgen:
|
|
return configgen({HATARI: placed({1: HATARI_HEAD, 32: generate, 157: find})})
|
|
|
|
|
|
def lexaloffle_tree(text: str = LEXALOFFLE_GENERATOR) -> Configgen:
|
|
return configgen({LEXALOFFLE: text + "\n"})
|
|
|
|
|
|
class DuckStationSearch(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.records = duckstation(duckstation_tree())
|
|
|
|
def test_each_region_list_keeps_its_search_order(self):
|
|
self.assertEqual(
|
|
[(r.slot, r.names) for r in self.records],
|
|
[
|
|
("NTSCU", ["scph101.bin", "scph1001.bin", "scph5501.bin", "scph7001.bin",
|
|
"scph7501.bin"]),
|
|
("PAL", ["scph1002.bin", "scph5502.bin", "scph5552.bin", "scph7002.bin",
|
|
"scph7502.bin", "scph9002.bin", "scph102a.bin", "scph102b.bin"]),
|
|
("NTSCJ", ["scph100.bin", "scph1000.bin", "scph3000.bin", "scph3500.bin",
|
|
"scph5500.bin", "scph7000.bin", "scph7003.bin"]),
|
|
("Uni", ["psxonpsp660.bin", "ps1_rom.bin"]),
|
|
],
|
|
)
|
|
|
|
def test_the_lines_cited_are_the_search_and_the_lists(self):
|
|
refs = {r.source_ref for r in self.records}
|
|
self.assertEqual(refs, {f"{DUCKSTATION}:115-136, 501-523"})
|
|
|
|
def test_how_the_found_name_reaches_duckstation(self):
|
|
first, uni = self.records[0], self.records[-1]
|
|
self.assertEqual(first.emulator, "duckstation")
|
|
self.assertEqual(first.build, "standalone")
|
|
self.assertEqual(first.kind, "names_searched")
|
|
self.assertTrue(first.case_insensitive)
|
|
self.assertEqual(first.search_dir, "/userdata/bios")
|
|
self.assertEqual(first.passes_to, "[BIOS] PathNTSCU")
|
|
self.assertEqual(uni.passes_to, "[BIOS] PathNTSCU, PathPAL, PathNTSCJ")
|
|
|
|
def test_the_failure_is_raised_only_when_every_region_is_empty(self):
|
|
for record in self.records:
|
|
self.assertEqual(record.fails_when_missing, "No PSX1 BIOS found")
|
|
self.assertEqual(record.fails_scope, "emulator")
|
|
|
|
def test_an_exact_case_search_is_not_read_as_case_insensitive(self):
|
|
find = DUCKSTATION_FIND.replace("f.name.lower()", "f.name").replace("bios.lower()", "bios")
|
|
with self.assertRaises(LauncherShapeError):
|
|
duckstation(duckstation_tree(find=find))
|
|
|
|
def test_lists_that_are_no_longer_a_literal_raise(self):
|
|
find = DUCKSTATION_FIND.replace("bios_lists = {", "BIOS_LISTS = {")
|
|
with self.assertRaises(LauncherShapeError):
|
|
duckstation(duckstation_tree(find=find))
|
|
|
|
def test_paths_written_another_way_raise(self):
|
|
bios = DUCKSTATION_BIOS.replace("region_mapping[region]", "f'Path{region}'")
|
|
with self.assertRaises(LauncherShapeError):
|
|
duckstation(duckstation_tree(bios=bios))
|
|
|
|
|
|
class HatariTosSearch(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.records = hatari(hatari_tree())
|
|
|
|
def test_each_machine_tries_its_versions_in_order(self):
|
|
self.assertEqual(
|
|
[(r.slot, r.names) for r in self.records],
|
|
[
|
|
("st", ["etos256{L}.img", "tos104{L}.img", "tos102{L}.img", "tos100{L}.img"]),
|
|
("ste", ["etos256{L}.img", "tos162{L}.img", "tos106{L}.img"]),
|
|
("megaste", ["etos256{L}.img", "tos206{L}.img", "tos205{L}.img"]),
|
|
("tt", ["etos512{L}.img", "tos306{L}.img"]),
|
|
("falcon", ["etos512{L}.img", "tos404{L}.img", "tos402{L}.img", "tos400{L}.img"]),
|
|
],
|
|
)
|
|
|
|
def test_every_version_is_tried_in_every_language(self):
|
|
for record in self.records:
|
|
self.assertEqual(
|
|
record.expands, {"L": ["us", "uk", "de", "es", "fr", "it", "nl", "ru", "se", ""]}
|
|
)
|
|
|
|
def test_the_lines_cited_are_the_mapping_the_call_and_the_search(self):
|
|
refs = {r.source_ref for r in self.records}
|
|
self.assertEqual(refs, {f"{HATARI}:33-55, 67-75, 158-193"})
|
|
|
|
def test_how_the_tos_reaches_hatari(self):
|
|
st = self.records[0]
|
|
self.assertEqual(st.emulator, "hatari")
|
|
self.assertFalse(st.case_insensitive)
|
|
self.assertEqual(st.search_dir, "/userdata/bios")
|
|
self.assertEqual(st.passes_to, "--machine st --tos <file>")
|
|
self.assertEqual(st.fails_when_missing, "No bios found for machine st")
|
|
self.assertIsNone(st.fails_scope)
|
|
self.assertEqual(st.depends_on_option, "model, language")
|
|
|
|
def test_a_name_built_another_way_raises(self):
|
|
find = HATARI_FIND.replace(
|
|
'biosdir / f"{biosversion}{v_language}.img"', 'biosdir / "tos.img"'
|
|
)
|
|
with self.assertRaises(LauncherShapeError):
|
|
hatari(hatari_tree(find=find))
|
|
|
|
|
|
class LexaloffleBinaries(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.records = [r.entry() for r in lexaloffle(lexaloffle_tree())]
|
|
|
|
def test_each_system_runs_its_binary_and_loads_its_folder(self):
|
|
self.assertEqual(
|
|
[(r["slot"], r["kind"], r["names"], r["search_dir"], r["passes_to"])
|
|
for r in self.records],
|
|
[
|
|
("pico8", "fixed_name", ["pico-8/pico8"], "/userdata/bios", "exec"),
|
|
("pico8", "passes_path", [], "/userdata/bios/pico-8",
|
|
"environment LD_LIBRARY_PATH"),
|
|
("voxatron", "fixed_name", ["voxatron/vox"], "/userdata/bios", "exec"),
|
|
("voxatron", "passes_path", [], "/userdata/bios/voxatron",
|
|
"environment LD_LIBRARY_PATH"),
|
|
],
|
|
)
|
|
for record in self.records:
|
|
self.assertEqual((record["emulator"], record["build"]), ("lexaloffle", "standalone"))
|
|
|
|
def test_a_missing_or_non_executable_binary_stops_the_launch(self):
|
|
pico8, library, vox, _ = self.records
|
|
self.assertEqual(
|
|
pico8["fails_when_missing"],
|
|
"Lexaloffle official binary not found at /userdata/bios/pico-8/pico8, "
|
|
"/userdata/bios/pico-8/pico8 is not set as executable",
|
|
)
|
|
self.assertEqual(
|
|
vox["fails_when_missing"],
|
|
"Lexaloffle official binary not found at /userdata/bios/voxatron/vox, "
|
|
"/userdata/bios/voxatron/vox is not set as executable",
|
|
)
|
|
self.assertFalse(pico8["case_insensitive"])
|
|
self.assertFalse(library["fails_when_missing"])
|
|
|
|
def test_the_lines_cited_are_the_constant_the_branch_the_checks_and_the_command(self):
|
|
self.assertEqual(
|
|
[r["source_ref"] for r in self.records],
|
|
[
|
|
f"{LEXALOFFLE}:17, 35, 37, 48-55",
|
|
f"{LEXALOFFLE}:35-36, 89",
|
|
f"{LEXALOFFLE}:20, 40, 42, 48-55",
|
|
f"{LEXALOFFLE}:40-41, 89",
|
|
],
|
|
)
|
|
|
|
def test_a_binary_without_the_executable_check_raises(self):
|
|
text = LEXALOFFLE_GENERATOR.replace("os.access(BIN_PATH, os.X_OK)", "True")
|
|
with self.assertRaises(LauncherShapeError):
|
|
lexaloffle(lexaloffle_tree(text))
|
|
|
|
def test_a_binary_without_the_existence_check_raises(self):
|
|
text = LEXALOFFLE_GENERATOR.replace("BIN_PATH.exists()", "BIN_PATH")
|
|
with self.assertRaises(LauncherShapeError):
|
|
lexaloffle(lexaloffle_tree(text))
|
|
|
|
def test_a_command_started_another_way_raises(self):
|
|
text = LEXALOFFLE_GENERATOR.replace("= [BIN_PATH]", '= ["pico8"]')
|
|
with self.assertRaises(LauncherShapeError):
|
|
lexaloffle(lexaloffle_tree(text))
|
|
|
|
def test_a_library_path_set_elsewhere_raises(self):
|
|
text = LEXALOFFLE_GENERATOR.replace('"LD_LIBRARY_PATH": ', '"LIBRARY_PATH": ')
|
|
with self.assertRaises(LauncherShapeError):
|
|
lexaloffle(lexaloffle_tree(text))
|
|
|
|
def test_a_binary_outside_bios_raises(self):
|
|
text = LEXALOFFLE_GENERATOR.replace(
|
|
'VOX_BIN_PATH: Final = BIOS / "voxatron" / "vox"',
|
|
'VOX_BIN_PATH: Final = Path("/usr/bin/vox")',
|
|
)
|
|
with self.assertRaises(LauncherShapeError):
|
|
lexaloffle(lexaloffle_tree(text))
|
|
|
|
def test_a_generator_that_no_longer_branches_on_the_system_raises(self):
|
|
text = LEXALOFFLE_GENERATOR.replace("system.name ==", "system.emulator ==")
|
|
with self.assertRaises(LauncherShapeError):
|
|
lexaloffle(lexaloffle_tree(text))
|
|
|
|
|
|
OPENMSX = f"{GENERATORS}/openmsx/openmsxGenerator.py"
|
|
OPENMSX_FILEPOOL = ''' def generate(self, system, rom):
|
|
with settings_tcl.open("a") as file:
|
|
file.write("filepool add -path /userdata/bios/Machines -types system_rom -position 1\\n")
|
|
file.write("filepool add -path /userdata/bios/openmsx -types system_rom -position 2\\n")'''
|
|
|
|
|
|
MAME = f"{GENERATORS}/mame/mameGenerator.py"
|
|
MAME_PATHS = f"{GENERATORS}/mame/mamePaths.py"
|
|
MAME_HEAD = """from __future__ import annotations
|
|
from ...batoceraPaths import BIOS
|
|
from .mamePaths import MAME_BIOS
|
|
class MameGenerator:
|
|
def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution):"""
|
|
MAME_COMMAND = """ if messMode == -1:
|
|
commandArray += [ "-rompath", f"{romDirname};{MAME_BIOS};{BIOS}" ]"""
|
|
MAME_SAMPLES = """ commandArray += [ "-samplepath", MAME_BIOS / "samples" ]"""
|
|
MAME_BOOT = """ if boot_disk:
|
|
bootDisk = f"/userdata/bios/{boot_disk}.img"
|
|
commandArray += [ bootType, bootDisk ]"""
|
|
|
|
|
|
class MameSearchPaths(unittest.TestCase):
|
|
"""mameGenerator.py:129-142 at batocera-43.1: `-rompath` and `-samplepath`."""
|
|
|
|
def test_the_command_line_search_paths_are_recorded(self):
|
|
from scraper.batocera_launchers import search_paths
|
|
|
|
cg = configgen({
|
|
MAME: placed({1: MAME_HEAD, 129: MAME_COMMAND, 142: MAME_SAMPLES, 378: MAME_BOOT}),
|
|
MAME_PATHS: "from ...batoceraPaths import BIOS\nMAME_BIOS = BIOS / 'mame'\n",
|
|
})
|
|
records = [r.entry() for r in search_paths(cg)]
|
|
self.assertEqual(
|
|
[(r["emulator"], r["search_dir"], r["passes_to"], r.get("depends_on_option"))
|
|
for r in records],
|
|
[
|
|
("mame", "<romDirname>;/userdata/bios/mame;/userdata/bios",
|
|
"command line -rompath", "messMode == -1"),
|
|
("mame", "/userdata/bios/mame/samples", "command line -samplepath", None),
|
|
],
|
|
)
|
|
self.assertEqual(records[0]["source_ref"], f"{MAME}:129-130")
|
|
# The boot disk the command names is a file, no search path.
|
|
self.assertFalse([r for r in records if "boot_disk" in r["search_dir"]])
|
|
|
|
|
|
class ConfigDirectories(unittest.TestCase):
|
|
"""A BIOS directory written into the emulator's config, at batocera-43.1."""
|
|
|
|
def test_each_filepool_line_hands_its_directory(self):
|
|
cg = configgen({OPENMSX: placed({1: "class OpenmsxGenerator:", 86: OPENMSX_FILEPOOL})})
|
|
records = [r.entry() for r in config_directories(cg, ("openmsx.openmsxGenerator",))]
|
|
self.assertEqual(
|
|
[(r["emulator"], r["kind"], r["search_dir"], r["source_ref"]) for r in records],
|
|
[
|
|
("openmsx", "passes_path", "/userdata/bios/Machines", f"{OPENMSX}:88"),
|
|
("openmsx", "passes_path", "/userdata/bios/openmsx", f"{OPENMSX}:89"),
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
records[1]["passes_to"],
|
|
"written: filepool add -path /userdata/bios/openmsx -types system_rom -position 2",
|
|
)
|
|
|
|
def test_a_saved_setting_hands_its_directory(self):
|
|
amiberry = f"{GENERATORS}/amiberry/amiberryGenerator.py"
|
|
text = placed({
|
|
1: "from pathlib import Path\n_BIOS_DIR = Path('/userdata/bios/amiga')",
|
|
10: "def generate():\n amiberryconf.save('rom_path', _BIOS_DIR)",
|
|
})
|
|
(record,) = config_directories(configgen({amiberry: text}), ("amiberry.amiberryGenerator",))
|
|
self.assertEqual(
|
|
(record.search_dir, record.passes_to, record.source_ref),
|
|
("/userdata/bios/amiga", "rom_path", f"{amiberry}:11"),
|
|
)
|
|
|
|
def test_a_generator_handing_no_bios_directory_raises(self):
|
|
cg = configgen({OPENMSX: "def generate():\n file.write('savestate x')\n"})
|
|
with self.assertRaises(LauncherShapeError):
|
|
config_directories(cg, ("openmsx.openmsxGenerator",))
|
|
|
|
|
|
class ExtractionNeverEmpty(unittest.TestCase):
|
|
def test_a_generator_of_unknown_shape_stops_the_whole_scrape(self):
|
|
find = DUCKSTATION_FIND.replace("def find_bios(", "def locate_bios(")
|
|
tree = {PATHS_MODULE: PATHS, IMPORTER: IMPORTER_TEXT, DUCKSTATION: duckstation_source(find=find)}
|
|
with self.assertRaises(LauncherShapeError):
|
|
extract_launchers(tree.__getitem__)
|
|
|
|
def test_a_paths_module_without_bios_raises(self):
|
|
tree = {PATHS_MODULE: "from pathlib import Path\n", IMPORTER: IMPORTER_TEXT}
|
|
with self.assertRaises(LauncherShapeError):
|
|
Configgen(tree.__getitem__)
|
|
|
|
|
|
class PlatformRecords(unittest.TestCase):
|
|
"""The launchers platforms/batocera.yml carries, as the scraper wrote them."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.config = yaml.safe_load((REPO_ROOT / "platforms" / "batocera.yml").read_text())
|
|
cls.schema = json.loads((REPO_ROOT / "schemas" / "platform.schema.json").read_text())
|
|
|
|
def test_every_emulator_is_one_batocera_names(self):
|
|
cores = {*self.config["cores"], *self.config.get("standalone_cores", [])}
|
|
named = {
|
|
name for record in self.config["launchers"]
|
|
for name in record["emulator"].split(", ")
|
|
}
|
|
self.assertEqual(sorted(named - cores), [])
|
|
|
|
def test_every_reference_is_in_configgen(self):
|
|
# Or in a patch Batocera applies to an emulator's own path code.
|
|
from scraper.batocera_launchers import PATH_PATCHES
|
|
|
|
for record in self.config["launchers"]:
|
|
self.assertTrue(
|
|
record["source_ref"].startswith((f"{CONFIGGEN}/", *PATH_PATCHES)), record
|
|
)
|
|
|
|
def test_the_schema_describes_what_the_scraper_writes(self):
|
|
fields = {f.name for f in dataclasses.fields(Launcher)}
|
|
described = set(self.schema["$defs"]["launcher"]["properties"])
|
|
# package, repo and systems are written by package platforms (RetroPie).
|
|
package_fields = {f.name for f in dataclasses.fields(PackageLauncher)} - {"lines"}
|
|
self.assertEqual(sorted(fields - described), [])
|
|
self.assertEqual(fields | package_fields, described)
|
|
|
|
def test_the_wiki_names_every_launcher_field(self):
|
|
page = (REPO_ROOT / "wiki" / "adding-a-platform.md").read_text(encoding="utf-8")
|
|
described = self.schema["$defs"]["launcher"]["properties"]
|
|
self.assertEqual(sorted(f for f in described if f"`{f}`" not in page), [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|