feat: read RetroPie launchers from scriptmodules

The scraper walks each package's functions the way RetroPie runs them in one shell (assignments, arrays, for loops, brace expansion, $md_id, calls into other packages) and records every link, copy, move and setting that names $biosdir, the addEmulator flags (sdltrs -romfile3), the BIOS read outside $biosdir (basilisk --rom, gngeopi -B) and the launch scripts that exit without their file (fs-uae kick13.rom, reicast dc_boot.bin).

Each package also gets a package record with its rp_module_repo and the systems it registers, which maps a package to the build it compiles. The schema gains package, repo, systems and the moves and package kinds. platforms/retropie.yml is regenerated at 46128cd2: only the launchers section is new.
This commit is contained in:
Abdessamad Derraz committed 2026-10-20 21:13:09 +02:00
1 parent 1c23d4daaa
commit 15ffeddfe3
7 files changed
+3923 -6

No files matched your search

File diff suppressed because it is too large. Load diff
+19 -2
View File
@@ -153,9 +153,26 @@
"passes_path",
"links",
"copies",
"scans_dir"
"moves",
"scans_dir",
"package"
],
"description": "names_searched: tries the names in order, first found wins. fixed_name: writes or opens one path per name. core_option: sets a core option whose value names the file. passes_path: hands over a directory. links, copies: links or copies a file to where the emulator reads it. scans_dir: the emulator picks from a directory"
"description": "names_searched: tries the names in order, first found wins. fixed_name: writes or opens one path per name. core_option: sets a core option whose value names the file. passes_path: hands over a directory. links, copies: links or copies a file to where the emulator reads it. moves: moves a file the user left in one folder to the folder the launcher links from. scans_dir: the emulator picks from a directory. package: names no file, records what a package builds (`repo`) and the systems it registers (`systems`)"
},
"package": {
"type": "string",
"description": "The platform package whose script this record comes from, when the platform installs emulators as packages"
},
"repo": {
"type": "string",
"description": "What the package fetches and builds, as its script declares it: method, URL, then branch or commit"
},
"systems": {
"type": "array",
"items": {
"type": "string"
},
"description": "Systems the package registers, under the platform's own names, in the order the script adds them"
},
"slot": {
"type": "string",
+995
View File
@@ -0,0 +1,995 @@
"""RetroPie's launcher layer, read from the scriptmodules.
RetroPie publishes no BIOS list, but its package scripts decide where a file
dropped in ~/RetroPie/BIOS ends up: px68k links five names into ~/.keropi,
redream links dc_boot.bin under another name, sdltrs passes one ROM per
machine on its command line, the fs-uae launch script refuses to start
without kick13.rom. Each package also says which project it builds
(`rp_module_repo`) and which systems it registers (`addSystem`), which is
what ties a package to the emulator profile describing that build.
The bash is read as text, never run. A small evaluator follows what the
scripts do between a function's braces: local assignments, arrays, `for`
loops, brace expansion, `$md_id` and calls into other packages' functions,
the way RetroPie runs them in one shell. Every line of a package function
that names the BIOS directory must match a known construct, else the scrape
stops with LauncherPatternMissing: a use nobody classified would otherwise
drop out of the platform file without a word.
"""
from __future__ import annotations
import re
import shlex
from collections.abc import Iterator
from dataclasses import dataclass, field
from itertools import product
from pathlib import PurePosixPath
from typing import Any
SCRIPTMODULES = "scriptmodules"
SECTIONS = ("emulators", "libretrocores", "ports")
PACKAGES_SCRIPT = "retropie_packages.sh"
BIOSDIR = "$biosdir"
# The package whose config every lr- core runs under.
LIBRETRO_FRONTEND = "retroarch"
# The launch scripts copied next to an emulator spell the directory out.
BIOS_SPELLED = "RetroPie/BIOS"
# Commands whose $biosdir argument creates, owns or deletes a path and hands
# nothing to an emulator.
HOUSEKEEPING = frozenset({"mkUserDir", "chown", "chmod", "rm", "mkdir", "iniConfig"})
# Arguments that name a BIOS file or directory outside $biosdir, per package,
# as the emulator documents the flag. Each must still be in the scriptmodule.
OUTSIDE_BIOS: dict[str, tuple[str, ...]] = {
"basilisk": ("--rom",), # BasiliskII --rom <ROM file>
"gngeopi": ("-B",), # gngeo -B/--biospath <directory of neogeo.zip>
}
_FUNCTION = re.compile(r"^function\s+([\w.+-]+)\s*\(\)\s*\{\s*$")
_ASSIGN = re.compile(r"^(?:local\s+)?([A-Za-z_]\w*)=(.*)$")
_DECLARE = re.compile(r"^local\s+([A-Za-z_]\w*(?:\s+[A-Za-z_]\w*)*)\s*$")
_FOR = re.compile(r"^for\s+([A-Za-z_]\w*)\s+in\s+(.*?)\s*(?:;\s*do)?\s*$")
_HEREDOC = re.compile(r"(?<!<)<<-?(?!<)\s*['\"]?(\w+)['\"]?")
_VAR = re.compile(r"\$\{([A-Za-z_]\w*)(\[@\])?\}|\$([A-Za-z_]\w*|\d)")
_SUBST = re.compile(r"\$\(([\w.+-]+(?:\$\{md_id\}|\$md_id)?)\)")
_MD_ID_TEST = re.compile(r'^!?\s*\[\[\s*"\$md_id"\s*(==|!=)\s*"?([\w.+-]+)"?\s*\]\]$')
_MODULE_FIELD = re.compile(r'^rp_module_(id|repo)="([^"]*)"', re.MULTILINE)
_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,5}$")
class LauncherPatternMissing(ValueError):
"""A construct an extractor reads is absent from the scriptmodules."""
def __init__(self, path: str, what: str) -> None:
super().__init__(f"{path}: {what}")
@dataclass
class Launcher:
"""One thing a package hands an emulator, or the package itself."""
emulator: str
build: str
kind: str
names: list[str]
source_ref: str
package: str | None = None
repo: str | None = None
systems: list[str] | None = None
slot: str | None = None
case_insensitive: bool | None = None
search_dir: str | None = None
passes_to: str | None = None
fails_when_missing: bool | str | None = None
depends_on_option: str | None = None
lines: list[tuple[str, int]] = field(default_factory=list, repr=False)
def entry(self) -> dict[str, Any]:
"""The platform YAML form, in a fixed key order, unset keys left out."""
order = (
"emulator", "package", "repo", "systems", "build", "kind", "slot",
"names", "case_insensitive", "search_dir", "passes_to",
"fails_when_missing", "depends_on_option", "source_ref",
)
return {key: getattr(self, key) for key in order if getattr(self, key) is not None}
@dataclass
class Module:
"""One package script: its id, section, lines and functions."""
path: str
section: str
lines: list[str]
id: str
id_line: int
repo: str | None
repo_line: int | None
functions: dict[str, tuple[int, int]] = field(default_factory=dict)
@property
def build(self) -> str:
libretro = self.section == "libretrocores" or self.id == LIBRETRO_FRONTEND
return "libretro" if libretro else "standalone"
@dataclass
class Event:
"""A statement reached while walking a package's functions."""
path: str
line: int
words: list[list[str]]
raw: str
conditions: list[str]
heredoc: bool
opener: list[list[str]] = field(default_factory=list)
def _logical_lines(
lines: list[str], start: int, end: int
) -> Iterator[tuple[int, str, str | None]]:
"""(line number, text, heredoc opener or None) for lines start..end, continuations joined."""
tag: str | None = None
opener = ""
number = start
while number <= end:
text = lines[number - 1]
first = number
if tag is not None:
if text.strip() == tag:
tag = None
else:
yield first, text, opener
number += 1
continue
while text.rstrip().endswith("\\") and number < end:
number += 1
text = text.rstrip()[:-1] + " " + lines[number - 1].strip()
opened = _HEREDOC.search(text)
if opened is not None:
tag = opened.group(1)
opener = text.strip()
yield first, text, None
number += 1
def _brace(word: str) -> list[str]:
"""Bash brace expansion of comma lists, outside variable references."""
found = re.search(r"(?<!\$)\{([^{}$]*,[^{}$]*)\}", word)
if found is None:
return [word]
head, tail = word[: found.start()], word[found.end():]
return [
expanded
for option in found.group(1).split(",")
for expanded in _brace(head + option + tail)
]
def _split(text: str) -> list[str]:
"""Shell words with quotes removed, a line bash itself would accept."""
try:
return shlex.split(text, comments=True)
except ValueError:
return text.split()
class Scripts:
"""Every package script, with the functions RetroPie loads into one shell."""
def __init__(self, files: dict[str, str]) -> None:
self.files = files
self.modules: dict[str, Module] = {}
self.owner: dict[str, Module] = {}
self._walked: dict[str, list[Event]] = {}
for path in sorted(files):
parts = PurePosixPath(path).parts
is_package = (
len(parts) == 3
and parts[0] == SCRIPTMODULES
and parts[1] in SECTIONS
and parts[2].endswith(".sh")
)
if is_package:
module = self._parse(path, parts[1], files[path])
if module is not None:
self.modules[module.id] = module
if not self.modules:
raise LauncherPatternMissing(SCRIPTMODULES, "no package script declares rp_module_id")
for module in self.modules.values():
for name in module.functions:
self.owner[name] = module
@staticmethod
def _parse(path: str, section: str, text: str) -> Module | None:
lines = text.splitlines()
fields: dict[str, tuple[str, int]] = {}
for found in _MODULE_FIELD.finditer(text):
line = text.count("\n", 0, found.start()) + 1
fields.setdefault(found.group(1), (found.group(2), line))
if "id" not in fields:
return None
repo = fields.get("repo")
module = Module(
path=path, section=section, lines=lines,
id=fields["id"][0], id_line=fields["id"][1],
repo=repo[0] if repo else None, repo_line=repo[1] if repo else None,
)
opened: tuple[str, int] | None = None
tag: str | None = None
for number, line in enumerate(lines, 1):
# A generated script may define functions of its own.
if tag is not None:
if line.strip() == tag:
tag = None
continue
heredoc = _HEREDOC.search(line)
if heredoc is not None:
tag = heredoc.group(1)
header = _FUNCTION.match(line)
if header is not None:
opened = (header.group(1), number)
elif opened is not None and line.rstrip() == "}":
module.functions[opened[0]] = (opened[1], number)
opened = None
if opened is not None:
raise LauncherPatternMissing(path, f"function {opened[0]} has no closing brace")
return module
def echo_value(self, name: str) -> str | None:
"""What a function whose whole body is one `echo "X"` prints."""
module = self.owner.get(name)
if module is None:
return None
start, end = module.functions[name]
body = [line.strip() for line in module.lines[start:end - 1] if line.strip()]
if len(body) == 1:
words = _split(body[0])
if len(words) == 2 and words[0] == "echo":
return words[1]
return None
def walk(self, package: Module) -> list[Event]:
"""Every statement of a package's functions, as RetroPie runs them for it."""
if package.id not in self._walked:
events: dict[tuple[str, int], Event] = {}
for name in package.functions:
self._walk(package, name, {}, [], set(), events)
self._walked[package.id] = sorted(
events.values(), key=lambda e: (e.path != package.path, e.path, e.line)
)
return self._walked[package.id]
def _walk(
self,
package: Module,
name: str,
args: dict[str, list[str]],
conditions: list[str],
stack: set[str],
events: dict[tuple[str, int], Event],
) -> None:
if name in stack:
return
module = self.owner[name]
start, end = module.functions[name]
env: dict[str, list[str]] = dict(args)
loops: list[tuple[str | None, list[str] | None]] = []
branches: list[tuple[str, bool | None]] = []
array: tuple[str, list[str]] | None = None
for number, text, opener in _logical_lines(module.lines, start + 1, end - 1):
stripped = text.strip()
if array is not None:
body = re.sub(r"\s+#.*$", "", stripped)
closed = body.endswith(")")
array[1].append(body[:-1] if closed else body)
if closed:
words = _split(" ".join(array[1]))
env[array[0]] = [v for w in words for v in self.expand(w, package, env)]
array = None
continue
if opener is not None:
if self._live(branches):
words = [self.expand(w, package, env) for w in stripped.split()]
events.setdefault((module.path, number), Event(
module.path, number, words, stripped,
conditions + self._shown(branches, package, env), True,
[self.expand(w, package, env) for w in _split(opener)],
))
continue
if not stripped or stripped.startswith("#"):
continue
if self._branch(stripped, package, env, branches):
continue
if not self._live(branches):
continue
loop = _FOR.match(stripped)
if loop is not None:
values = [v for w in _split(loop.group(2)) for v in self.expand(w, package, env)]
loops.append((loop.group(1), env.get(loop.group(1))))
env[loop.group(1)] = values
continue
if re.match(r"^(while|until)\b|^for\s*\(\(", stripped):
loops.append((None, None))
continue
if stripped == "done" or stripped.startswith("done ") or stripped.startswith("done;"):
if loops:
var, previous = loops.pop()
if var is not None:
if previous is None:
env.pop(var, None)
else:
env[var] = previous
continue
if stripped in ("do", "then", "else", "fi", "esac", "{", "}"):
continue
guard = re.match(r"^\[\[\s*(.*?)\s*\]\]\s*&&\s*(.*)$", stripped)
shown = conditions + self._shown(branches, package, env)
if guard is not None:
verdict = self._md_id_test(f"[[ {guard.group(1)} ]]", package)
if verdict is False or "$md_mode" in guard.group(1) or "$mode" in guard.group(1):
continue
if verdict is None:
shown = shown + [self._render(guard.group(1), package, env)]
stripped = guard.group(2)
opens = re.match(r"^(?:local\s+)?([A-Za-z_]\w*)=\(([^)]*)$", stripped)
if opens is not None:
array = (opens.group(1), [opens.group(2)])
continue
if self._bind(stripped, package, env):
continue
words = [self.expand(w, package, env) for w in _split(stripped)]
# The first walk to reach a line is the one with its caller's arguments.
events.setdefault(
(module.path, number), Event(module.path, number, words, stripped, shown, False)
)
callee = words[0][0] if words and words[0] else None
if callee in self.owner and callee != name:
call_args = {str(i): w for i, w in enumerate(words[1:], 1)}
self._walk(package, callee, call_args, shown, stack | {name}, events)
def _branch(
self, stripped: str, package: Module, env: dict[str, list[str]],
branches: list[tuple[str, bool | None]],
) -> bool:
"""Track if/elif/else/fi, and say whether the line was one of them."""
opened = re.match(r"^(if|elif)\s+(.*?)\s*(?:;\s*then)?\s*$", stripped)
if opened is not None:
condition = opened.group(2)
verdict = self._md_id_test(condition, package)
if "$md_mode" in condition or "$mode" in condition:
verdict = True if "install" in condition else None
if opened.group(1) == "if":
branches.append((condition, verdict))
elif branches:
previous, previous_verdict = branches[-1]
if previous_verdict is True:
verdict = False
branches[-1] = (condition, verdict)
return True
if stripped == "else" and branches:
condition, verdict = branches[-1]
branches[-1] = (f"not ({condition})", None if verdict is None else not verdict)
return True
if stripped == "fi" or stripped.startswith("fi "):
if branches:
branches.pop()
return True
return False
@staticmethod
def _live(branches: list[tuple[str, bool | None]]) -> bool:
return all(verdict is not False for _c, verdict in branches)
def _shown(
self, branches: list[tuple[str, bool | None]], package: Module, env: dict[str, list[str]]
) -> list[str]:
return [
self._render(condition, package, env)
for condition, verdict in branches if verdict is None
]
def _render(self, condition: str, package: Module, env: dict[str, list[str]]) -> str:
"""A condition with its variables filled in when each takes one value."""
values = self.expand(condition, package, env)
return values[0] if len(values) == 1 else condition
@staticmethod
def _md_id_test(condition: str, package: Module) -> bool | None:
test = _MD_ID_TEST.match(condition.strip())
if test is None:
return None
equal = package.id == test.group(2)
return equal if test.group(1) == "==" else not equal
def _bind(self, stripped: str, package: Module, env: dict[str, list[str]]) -> bool:
"""Record a variable assignment, and say whether the line was one."""
if _DECLARE.match(stripped):
return True
assign = _ASSIGN.match(stripped)
if assign is None:
return False
name, value = assign.group(1), assign.group(2).strip()
if value.startswith("("):
closing = value.rfind(")")
inner = value[1:closing] if closing > 0 else value[1:]
env[name] = [v for w in _split(inner) for v in self.expand(w, package, env)]
return True
words = _split(value) or [""]
if "$(" in words[0] and _SUBST.fullmatch(words[0]) is None:
# A command's output: the variable stays a reference.
env.pop(name, None)
return True
expanded = self.expand(words[0], package, env)
if any("$(" in v for v in expanded):
env.pop(name, None)
else:
env[name] = expanded
return True
def expand(self, word: str, package: Module, env: dict[str, list[str]]) -> list[str]:
"""Every value a word takes: braces, $md_id, bound variables, $(echo functions)."""
word = re.sub(r"\$\{md_id\}|\$md_id\b", package.id, word)
def substitute(found: re.Match[str]) -> str:
printed = self.echo_value(found.group(1))
return printed if printed is not None else found.group(0)
word = _SUBST.sub(substitute, word)
results: list[str] = []
for braced in _brace(word):
names = [m.group(1) or m.group(3) for m in _VAR.finditer(braced)]
bound = [n for n in names if n in env]
if not bound:
results.append(braced)
continue
for values in product(*(env[n] for n in bound)):
chosen = dict(zip(bound, values))
def replace(found: re.Match[str]) -> str:
key = found.group(1) or found.group(3)
return chosen.get(key, found.group(0))
results.append(_VAR.sub(replace, braced))
return results
def _mentions_bios(text: str) -> bool:
return BIOSDIR in text or "${biosdir}" in text or BIOS_SPELLED in text
def _is_file(path: str) -> bool:
return bool(_EXTENSION.search(PurePosixPath(path).name)) and "*" not in path
def _split_path(path: str) -> tuple[str, str]:
pure = PurePosixPath(path.rstrip("/"))
return str(pure.parent), pure.name
class PackageReader:
"""Launcher records of one package, from the statements its functions run."""
def __init__(self, scripts: Scripts, package: Module) -> None:
self.scripts = scripts
self.package = package
self.records: list[Launcher] = []
self.installed_as: dict[str, str] = {}
def record(
self, kind: str, names: list[str], lines: list[tuple[str, int]], **fields: Any
) -> None:
self.records.append(Launcher(
emulator=fields.pop("emulator", self.package.id),
package=self.package.id,
build=self.package.build,
kind=kind,
names=names,
source_ref=source_ref(lines),
lines=list(lines),
**fields,
))
def final(self, path: str) -> str:
"""The file a scratch config ends up as, from the copyDefaultConfig that installs it."""
return self.installed_as.get(path, path)
def read(self) -> list[Launcher]:
events = self.scripts.walk(self.package)
for event in events:
if not event.heredoc and event.words and event.words[0] == ["copyDefaultConfig"]:
if len(event.words) >= 3 and len(event.words[1]) == 1 and len(event.words[2]) == 1:
self.installed_as.setdefault(event.words[1][0], event.words[2][0])
links: list[Event] = []
for event in events:
if not event.words or not event.words[0]:
continue
if links and not self._same_link_run(links[-1], event):
self._links(links)
links = []
if self._is_bios_link(event):
links.append(event)
continue
self._classify(event)
if links:
self._links(links)
self._outside_bios(events)
return self.records
@staticmethod
def _depends(event: Event) -> str | None:
return "; ".join(event.conditions) if event.conditions else None
def _classify(self, event: Event) -> None:
command = event.words[0][0]
if event.heredoc:
if _mentions_bios(event.raw):
self._heredoc_setting(event)
return
if command == "addEmulator":
self._add_emulator(event)
return
if not _mentions_bios(event.raw):
return
if command in HOUSEKEEPING:
return
handlers = {
"cp": self._copy,
"runCmd": self._run_cmd,
"unzip": self._unzip,
"mv": self._move,
"moveConfigFile": self._move,
"moveConfigDir": self._move_dir,
"iniSet": self._ini_set,
"copyDefaultConfig": self._written,
"cat": self._written,
"echo": self._written,
"cmake": self._build_flag,
}
handler = handlers.get(command)
if handler is None:
raise LauncherPatternMissing(
f"{event.path}:{event.line}", f"unrecognised use of the BIOS directory: {event.raw}"
)
handler(event)
def _is_bios_link(self, event: Event) -> bool:
words = event.words
if words[0][0] != "ln" or not _mentions_bios(event.raw):
return False
paths = [w for w in words[1:] if not w[0].startswith("-")]
if len(paths) != 2 or not all(_mentions_bios(v) for v in paths[0]):
raise LauncherPatternMissing(
f"{event.path}:{event.line}", f"link without a BIOS source: {event.raw}"
)
return True
@staticmethod
def _same_link_run(previous: Event, event: Event) -> bool:
return (
event.words[0][0] == "ln"
and previous.path == event.path
and event.line - previous.line <= 2
)
def _links(self, events: list[Event]) -> None:
sources: list[str] = []
targets: list[str] = []
for event in events:
paths = [w for w in event.words[1:] if not w[0].startswith("-")]
source, target = paths
if len(target) == 1 and len(source) > 1:
target = [f"{target[0].rstrip('/')}/{_split_path(s)[1]}" for s in source]
sources += source
targets += target
folders = {_split_path(s)[0] for s in sources}
whole_dir = all(
not _is_file(s) and "/" not in s.replace(BIOSDIR, "", 1).strip("/") for s in sources
)
if whole_dir:
names: list[str] = []
search_dir = sources[0]
passes_to = f"{targets[0]} is a symlink to it"
else:
if len(folders) != 1:
raise LauncherPatternMissing(
f"{events[0].path}:{events[0].line}",
"links from several BIOS folders in one run",
)
search_dir = folders.pop()
names = [_split_path(s)[1] for s in sources]
passes_to = _render_targets(names, targets)
self.record(
"links", names, [(e.path, e.line) for e in events],
case_insensitive=False, search_dir=search_dir, passes_to=passes_to,
fails_when_missing=False, depends_on_option=self._depends(events[0]),
)
def _copy(self, event: Event) -> None:
paths = [v for w in event.words[1:] if not w[0].startswith("-") for v in w]
*sources, target = paths
if not _mentions_bios(target):
raise LauncherPatternMissing(
f"{event.path}:{event.line}", f"copy out of the BIOS directory: {event.raw}"
)
folders = {_split_path(s)[0] for s in sources}
if len(folders) != 1:
raise LauncherPatternMissing(f"{event.path}:{event.line}", "copy from several folders")
self.record(
"copies", [_split_path(s)[1] for s in sources], [(event.path, event.line)],
search_dir=folders.pop(), passes_to=target.rstrip("/"),
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _run_cmd(self, event: Event) -> None:
inner = Event(event.path, event.line, event.words[1:], event.raw, event.conditions, False)
if not inner.words or inner.words[0][0] != "unzip":
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"runCmd of {event.raw}")
self._unzip(inner)
def _unzip(self, event: Event) -> None:
values = [v for w in event.words[1:] for v in w]
if "-d" not in values:
raise LauncherPatternMissing(f"{event.path}:{event.line}", "unzip without -d")
target = values[values.index("-d") + 1]
archives = [v for v in values if v.endswith(".zip")]
if len(archives) != 1 or not _mentions_bios(target):
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"unzip of {event.raw}")
self.record(
"copies", [], [(event.path, event.line)],
search_dir=archives[0], passes_to=target,
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _move(self, event: Event) -> None:
source, target = (w for w in event.words[1:] if not w[0].startswith("-"))
if not all(_mentions_bios(v) for v in source + target):
raise LauncherPatternMissing(
f"{event.path}:{event.line}", f"move across folders: {event.raw}"
)
folders = {_split_path(s)[0] for s in source}
destinations = {_split_path(t)[0] for t in target}
if len(folders) != 1 or len(destinations) != 1:
raise LauncherPatternMissing(
f"{event.path}:{event.line}", "move between several folders"
)
self.record(
"moves", [_split_path(s)[1] for s in source], [(event.path, event.line)],
case_insensitive=False, search_dir=folders.pop(), passes_to=destinations.pop(),
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _move_dir(self, event: Event) -> None:
link, real = (w[0] for w in event.words[1:3])
if not _mentions_bios(real) or _mentions_bios(link):
raise LauncherPatternMissing(
f"{event.path}:{event.line}", f"moveConfigDir into a non-BIOS folder: {event.raw}"
)
self.record(
"links", [], [(event.path, event.line)],
search_dir=real, passes_to=f"{link} is a symlink to it",
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _ini_set(self, event: Event) -> None:
key, value = event.words[1][0], event.words[2][0]
target = self._ini_file(event)
passes_to = f"{key} in {self.final(target)}" if target else key
if _is_file(value) and ";" not in value:
folder, name = _split_path(value)
self.record(
"fixed_name", [name], [(event.path, event.line)],
case_insensitive=False, search_dir=folder, passes_to=passes_to,
fails_when_missing=False, depends_on_option=self._depends(event),
)
return
self.record(
"passes_path", [], [(event.path, event.line)],
search_dir=value, passes_to=passes_to,
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _ini_file(self, event: Event) -> str | None:
"""The file the closest iniConfig above this iniSet points at."""
if len(event.words) > 3:
return event.words[3][0]
lines = self.scripts.files[event.path].splitlines()
for number in range(event.line - 1, 0, -1):
words = _split(lines[number - 1].strip())
if words and words[0] == "iniConfig" and len(words) >= 4:
return words[3]
if _FUNCTION.match(lines[number - 1]):
break
return None
def _written(self, event: Event) -> None:
"""A file the package writes into the BIOS directory itself."""
target = next(
(v for w in reversed(event.words[1:]) for v in w if _mentions_bios(v)), None
)
if target is None or not _is_file(target):
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"write of {event.raw}")
folder, name = _split_path(target)
self.record(
"copies", [name], [(event.path, event.line)],
search_dir=None, passes_to=folder,
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _build_flag(self, event: Event) -> None:
flags = [
v for w in event.words[1:] for v in w
if v.startswith("-D") and "=" in v and _mentions_bios(v)
]
if not flags:
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"cmake of {event.raw}")
for flag in flags:
option, value = flag.split("=", 1)
self.record(
"passes_path", [], [(event.path, event.line)],
search_dir=value, passes_to=f"build option {option}",
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _heredoc_setting(self, event: Event) -> None:
"""A `KEY = $biosdir/...` or `--flag="$biosdir"` line of a generated file."""
text = event.raw
setting = re.match(r'^([\w.-]+)\s*=\s*"?([^"]*)"?$', text)
flag = re.match(r'^(--?[\w-]+)(?:=|\s+)"?([^"]*)"?$', text)
found = flag or setting
if found is None:
raise LauncherPatternMissing(
f"{event.path}:{event.line}", f"generated line names the BIOS directory: {text}"
)
key, value = found.group(1), found.group(2)
kind, names, folder = "passes_path", [], value
if _is_file(value):
folder, name = _split_path(value)
kind, names = "fixed_name", [name]
written = _redirect_target(event.opener)
if written is None:
raise LauncherPatternMissing(
f"{event.path}:{event.line}", "generated file with no > or >> target"
)
self.record(
kind, names, [(event.path, event.line)],
case_insensitive=False if kind == "fixed_name" else None,
search_dir=folder, passes_to=f"{key} in {self.final(written)}",
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _add_emulator(self, event: Event) -> None:
if len(event.words) < 5:
return
command = event.words[4][0]
if not _mentions_bios(command):
return
names = event.words[2]
tokens = _split(command)
found = [
(flag, value) for flag, value in zip(tokens, tokens[1:])
if flag.startswith("-") and _mentions_bios(value)
]
found += [
tuple(token.split("=", 1)) for token in tokens
if token.startswith("-") and "=" in token and _mentions_bios(token.split("=", 1)[1])
]
if not found:
raise LauncherPatternMissing(
f"{event.path}:{event.line}",
f"addEmulator names the BIOS directory without a flag: {command}",
)
for emulator in names:
for flag, value in found:
self._command_line(event, emulator, flag, value)
def _command_line(self, event: Event, emulator: str, flag: str, value: str) -> None:
folder, name = _split_path(value)
if _is_file(value):
kind, names, search = "fixed_name", [name], folder
else:
kind, names, search = "passes_path", [], value
self.record(
kind, names, [(event.path, event.line)], emulator=emulator,
case_insensitive=False if kind == "fixed_name" else None,
search_dir=search, passes_to=f"command line {flag}",
fails_when_missing=False, depends_on_option=self._depends(event),
)
def _outside_bios(self, events: list[Event]) -> None:
flags = OUTSIDE_BIOS.get(self.package.id)
if not flags:
return
seen: set[str] = set()
for event in events:
if event.words[0][0] != "addEmulator" or len(event.words) < 5:
continue
tokens = _split(event.words[4][0])
for flag, value in zip(tokens, tokens[1:]):
if flag in flags:
seen.add(flag)
for emulator in event.words[2]:
self._command_line(event, emulator, flag, value)
missing = sorted(set(flags) - seen)
if missing:
raise LauncherPatternMissing(
self.package.path, f"addEmulator no longer passes {missing}"
)
def _redirect_target(words: list[list[str]]) -> str | None:
"""The file a `cat > FILE << EOF` or `cat << EOF >> FILE` line writes."""
flat = [w[0] for w in words if w]
for index, word in enumerate(flat[:-1]):
if word in (">", ">>"):
return flat[index + 1]
return None
def merge_records(records: list[Launcher]) -> list[Launcher]:
"""Records equal but for their lines become one, citing every line."""
merged: dict[tuple[Any, ...], Launcher] = {}
for record in records:
entry = record.entry()
entry.pop("source_ref")
key = tuple((k, repr(v)) for k, v in entry.items())
if key in merged:
kept = merged[key]
kept.lines += record.lines
kept.source_ref = source_ref(kept.lines)
else:
merged[key] = record
return list(merged.values())
def _render_targets(names: list[str], targets: list[str]) -> str:
"""Where linked names land: one folder when the names are kept, each path when renamed."""
folders = {_split_path(t)[0] for t in targets}
kept = [_split_path(t)[1] for t in targets] == names
if kept and len(folders) == 1:
return f"{folders.pop()}/<name>"
return ", ".join(targets)
def source_ref(lines: list[tuple[str, int]]) -> str:
"""`path:a-b, c` per file, in first-seen file order, adjacent lines joined."""
by_path: dict[str, list[int]] = {}
for path, number in lines:
by_path.setdefault(path, []).append(number)
parts = []
for path, numbers in by_path.items():
ranges: list[list[int]] = []
for number in sorted(set(numbers)):
if ranges and number == ranges[-1][1] + 1:
ranges[-1][1] = number
else:
ranges.append([number, number])
spans = ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in ranges)
parts.append(f"{path}:{spans}")
return ", ".join(parts)
def package_record(scripts: Scripts, package: Module) -> Launcher:
"""What the package builds and the systems it registers."""
systems: list[str] = []
lines: list[tuple[str, int]] = [(package.path, package.id_line)]
if package.repo_line is not None:
lines.append((package.path, package.repo_line))
for event in scripts.walk(package):
if event.heredoc or not event.words or not event.words[0]:
continue
command = event.words[0][0]
if command == "addSystem":
values = event.words[1] if len(event.words) > 1 else []
elif command == "addPort":
values = ["ports"]
else:
continue
if not values:
raise LauncherPatternMissing(f"{event.path}:{event.line}", f"{command} takes no value")
for value in values:
if "$" in value or not value:
raise LauncherPatternMissing(
f"{event.path}:{event.line}", f"system name {value!r} not resolved"
)
if value not in systems:
systems.append(value)
lines.append((event.path, event.line))
return Launcher(
emulator=package.id, package=package.id, repo=package.repo,
systems=systems or None, build=package.build, kind="package", names=[],
source_ref=source_ref(lines),
)
def launch_script_records(scripts: Scripts) -> list[Launcher]:
"""The launch scripts installed next to an emulator that refuse to start without a file."""
records = []
for path, text in sorted(scripts.files.items()):
parts = PurePosixPath(path).parts
is_script = (
len(parts) == 4 and parts[0] == SCRIPTMODULES and parts[1] in SECTIONS
and path.endswith(".sh")
)
if not is_script or not _mentions_bios(text):
continue
package = scripts.modules.get(parts[2])
if package is None:
raise LauncherPatternMissing(path, f"launch script of no package {parts[2]}")
records += _launch_script(path, text.splitlines(), package)
return records
def _launch_script(path: str, lines: list[str], package: Module) -> list[Launcher]:
env: dict[str, str] = {"HOME": "$HOME"}
records = []
checks = 0
number = 0
while number < len(lines):
number += 1
stripped = lines[number - 1].strip()
if not stripped or stripped.startswith("#"):
continue
assign = re.match(r'^([A-Za-z_]\w*)="([^"]*)"$', stripped)
if assign is not None:
env[assign.group(1)] = _VAR.sub(
lambda m: env.get(m.group(1) or m.group(3), m.group(0)), assign.group(2)
)
continue
check = re.match(r'^if \[\[ ! -f "([^"]+)" \]\]; then$', stripped)
if check is None:
continue
tested = check.group(1)
resolved = _VAR.sub(lambda m: env.get(m.group(1) or m.group(3), m.group(0)), tested)
if not _mentions_bios(resolved):
continue
block_start = number
message: str | None = None
exits = False
while number < len(lines) and lines[number].strip() != "fi":
number += 1
body = lines[number - 1].strip()
exits = exits or body == "exit 1"
pause = re.search(r'--pause "([^"]*)"', body)
if pause is not None:
message = pause.group(1)
number += 1
if not exits:
continue
checks += 1
variable = re.fullmatch(r"\$\{?(\w+)\}?", tested)
passes_to = None
cited = list(range(block_start, number + 1))
if variable is not None:
for later, line in enumerate(lines[number:], number + 1):
flag = re.search(r'(--?[\w-]+)="\$' + variable.group(1) + r'"', line)
if flag is not None:
passes_to = f"command line {flag.group(1)}"
cited.append(later)
break
folder, name = _split_path(resolved)
records.append(Launcher(
emulator=package.id, package=package.id, build=package.build,
kind="fixed_name", names=[name], case_insensitive=False,
search_dir=folder, passes_to=passes_to,
fails_when_missing=message if message is not None else True,
source_ref=source_ref([(path, n) for n in cited]),
))
if not checks:
raise LauncherPatternMissing(path, "names the BIOS directory but checks no file")
return records
def extract_launchers(files: dict[str, str]) -> list[dict[str, Any]]:
"""Every launcher record, from the scriptmodules keyed by repo-relative path."""
scripts = Scripts(files)
records: list[Launcher] = []
for package in sorted(scripts.modules.values(), key=lambda m: m.path):
records.append(package_record(scripts, package))
records += merge_records(PackageReader(scripts, package).read())
records += launch_script_records(scripts)
return [record.entry() for record in records]
+22 -2
View File
@@ -15,6 +15,10 @@ RetroArch: RetroPie claimed every libretro core in existence while shipping
96 of them, and claimed none of the standalone emulators it also packages
(openmsx, pcsx2, xroar, sdltrs, amiberry and the rest). Both halves were
wrong, in opposite directions.
The same scripts carry the launcher layer (retropie_launchers): where each
package links, copies or passes a BIOS file, what it builds and which
systems it registers.
"""
from __future__ import annotations
@@ -27,8 +31,14 @@ import urllib.request
try:
from .base_scraper import BaseScraper, BiosRequirement, fetch_branch_head
from .retropie_launchers import SCRIPTMODULES
from .retropie_launchers import SECTIONS as _SCRIPT_SECTIONS
from .retropie_launchers import extract_launchers
except ImportError:
from base_scraper import BaseScraper, BiosRequirement, fetch_branch_head
from retropie_launchers import SCRIPTMODULES
from retropie_launchers import SECTIONS as _SCRIPT_SECTIONS
from retropie_launchers import extract_launchers
PLATFORM_NAME = "retropie"
@@ -43,7 +53,7 @@ _VERSION_FILE = "retropie_packages.sh"
_VERSION = re.compile(r'^__version="([^"]+)"', re.MULTILINE)
# Sections RetroPie does not build as emulators: setup helpers, themes,
# drivers and the like carry no core.
_PACKAGE_DIRS = ("emulators", "libretrocores", "ports")
_PACKAGE_DIRS = _SCRIPT_SECTIONS
class Scraper(BaseScraper):
@@ -56,6 +66,8 @@ class Scraper(BaseScraper):
super().__init__(url=url)
self._modules: list[str] | None = None
self._version = ""
# Package scripts and the launch scripts beside them, by repo path.
self._scripts: dict[str, str] = {}
def _fetch_archive(self) -> bytes:
request = urllib.request.Request(
@@ -89,12 +101,19 @@ class Scraper(BaseScraper):
and parts[0] == "scriptmodules"
and parts[1] in _PACKAGE_DIRS
)
if not (is_version_file or is_package):
is_launch_script = (
len(parts) == 4 and parts[0] == SCRIPTMODULES and parts[1] in _SCRIPT_SECTIONS
)
if not (is_version_file or is_package or is_launch_script):
continue
handle = archive.extractfile(member)
if handle is None:
continue
text = handle.read().decode("utf-8", errors="replace")
if is_package or is_launch_script:
self._scripts[relative] = text
if is_launch_script:
continue
match = (_VERSION if is_version_file else _MODULE_ID).search(text)
if not match:
continue
@@ -139,6 +158,7 @@ class Scraper(BaseScraper):
"source": self.url,
"base_destination": "BIOS",
"cores": cores,
"launchers": extract_launchers(self._scripts),
}
+5 -1
View File
@@ -31,6 +31,7 @@ from scraper.batocera_launchers import ( # noqa: E402
extract_launchers,
hatari,
)
from scraper.retropie_launchers import Launcher as PackageLauncher # noqa: E402
PATHS = """from pathlib import Path
from typing import Final
@@ -372,7 +373,10 @@ class PlatformRecords(unittest.TestCase):
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"])
self.assertEqual(fields, described)
# 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")
+364
View File
@@ -0,0 +1,364 @@
"""RetroPie's launcher layer read from the scriptmodules.
Each fixture copies the lines an extractor reads from RetroPie-Setup
46128cd252cdecae852a4d0bafa2aaf9d03ab563, placed at the line numbers they
have there, so the source_ref a record carries is the one platforms/retropie.yml
cites. A construct that is gone or unknown must stop the scrape.
"""
from __future__ import annotations
import dataclasses
import json
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 batocera_launchers # noqa: E402
from scraper import retropie_launchers as rl # noqa: E402
EMULATORS = "scriptmodules/emulators"
LIBRETRO = "scriptmodules/libretrocores"
def at(first_line: int, text: str) -> str:
"""Text placed so its first line is line `first_line` of the file."""
return "\n" * (first_line - 1) + textwrap.dedent(text).lstrip("\n")
def header(package: str, repo: str | None = None, repo_line: int = 15) -> str:
lines = {12: f'rp_module_id="{package}"'}
if repo is not None:
lines[repo_line] = f'rp_module_repo="{repo}"'
return "\n".join(lines.get(n, "") for n in range(1, max(lines) + 1)) + "\n"
def module(package: str, body: str, first_line: int, repo: str | None = None,
repo_line: int = 15) -> str:
head = header(package, repo, repo_line)
padding = first_line - 1 - head.count("\n")
return head + "\n" * padding + textwrap.dedent(body).lstrip("\n")
def records(files: dict[str, str], package: str | None = None) -> list[dict]:
found = rl.extract_launchers(files)
return [r for r in found if package is None or r["package"] == package]
PX68K = module("px68k", """
function configure_px68k() {
mkRomDir "x68000"
moveConfigDir "$home/.keropi" "$md_conf_root/x68000"
mkUserDir "$biosdir/keropi"
local bios
for bios in cgrom.dat iplrom30.dat iplromco.dat iplrom.dat iplromxv.dat; do
if [[ -f "$biosdir/$bios" ]]; then
mv "$biosdir/$bios" "$biosdir/keropi/$bios"
fi
ln -sf "$biosdir/keropi/$bios" "$md_conf_root/x68000/$bios"
done
addEmulator 1 "$md_id" "x68000" "$md_inst/px68k.sdl2 %ROM%"
addSystem "x68000"
}
""", 47, repo="git https://github.com/TurtleBazooka/px68k.git master")
REDREAM = module("redream", """
function configure_redream() {
mkUserDir "$biosdir/dc"
ln -sf "$biosdir/dc/dc_boot.bin" "$md_inst/boot.bin"
ln -sf "$biosdir/dc/dc_flash.bin" "$md_inst/flash.bin"
}
""", 56)
SDLTRS = module("sdltrs", """
function configure_sdltrs() {
local common_args
mkRomDir "trs-80"
common_args="-fullscreen -nomousepointer -showled"
addEmulator 1 "$md_id-model1" "trs-80" "$md_inst/sdl2trs $common_args -m1 -romfile $biosdir/level2.rom -disk0 %ROM%"
addEmulator 0 "$md_id-model3" "trs-80" "$md_inst/sdl2trs $common_args -m3 -romfile3 $biosdir/level3.rom -disk0 %ROM%"
addEmulator 0 "$md_id-model4" "trs-80" "$md_inst/sdl2trs $common_args -m4 -romfile3 $biosdir/level4.rom -disk0 %ROM%"
addEmulator 0 "$md_id-model4p" "trs-80" "$md_inst/sdl2trs $common_args -m4p -romfile4p $biosdir/level4p.rom -disk0 %ROM%"
addSystem "trs-80"
}
""", 44)
BASILISK = module("basilisk", """
function configure_basilisk() {
addEmulator 1 "$md_id" "macintosh" "$md_inst/bin/BasiliskII --rom $romdir/macintosh/mac.rom --disk $romdir/macintosh/disk.img"
addSystem "macintosh"
}
""", 55, repo="git https://github.com/cebix/macemu.git master", repo_line=16)
GNGEOPI = module("gngeopi", """
function configure_gngeopi() {
addEmulator 0 "$md_id" "arcade" "$md_inst/bin/gngeo -i $romdir/neogeo -B $md_inst/neogeobios %ROM%"
addEmulator 0 "$md_id" "neogeo" "$md_inst/bin/gngeo -i $romdir/neogeo -B $md_inst/neogeobios %ROM%"
addSystem "arcade"
addSystem "neogeo"
}
""", 59)
FS_UAE = module("fs-uae", """
function configure_fs-uae() {
mkRomDir "amiga"
addEmulator 1 "$md_id" "amiga" "CON:bash $md_inst/bin/fs-uae.sh %ROM%"
addSystem "amiga"
}
""", 76)
FS_UAE_LAUNCHER = at(15, """
rootdir="/opt/retropie"
datadir="$HOME/RetroPie"
romdir="$datadir/roms/amiga"
biosdir="$datadir/BIOS"
kickfile="$biosdir/kick13.rom"
source "$rootdir/lib/archivefuncs.sh"
if [[ ! -f "$kickfile" ]]; then
dialog --no-cancel --pause "You need to copy the Amiga kickstart file (kick13.rom) to the folder $biosdir to boot the Amiga emulator." 22 76 15
exit 1
fi
""") + "\n" * 14 + 'fs-uae --floppy_drive_0="$rom" --kickstart_file="$kickfile" --floppies_dir="$romdir"\n'
DOSBOX = module("dosbox", """
function configure_dosbox() {
local def=0
mkRomDir "pc"
addEmulator "$def" "$md_id" "pc" "bash $romdir/pc/launcher %ROM%"
addSystem "pc"
cat > "$romdir/pc/launcher" << _EOF_
function midi_synth() {
echo start
}
_EOF_
}
""", 64)
DOSBOX_SDL2 = module("dosbox-sdl2", """
function configure_dosbox-sdl2() {
configure_dosbox
}
""", 42, repo="git https://github.com/duganchen/dosbox.git master", repo_line=16)
FBNEO = module("lr-fbneo", """
function configure_lr-fbneo() {
local systems=(
"arcade"
"neogeo"
"pcengine"
)
local system
for system in "${systems[@]}"; do
addSystem "$system"
done
}
""", 94)
MAME2003 = module("lr-mame2003", """
function _get_dir_name_lr-mame2003() {
echo "mame2003"
}
function configure_lr-mame2003() {
addSystem "arcade"
local dir_name="$(_get_dir_name_${md_id})"
cp "$md_inst/metadata/"{hiscore.dat,cheat.dat} "$biosdir/$dir_name/"
if [[ "$md_id" == "lr-mame2003-plus" ]]; then
cp "$md_inst/metadata/artwork/"* "$biosdir/$dir_name/artwork/"
fi
}
""", 19)
MAME2003_PLUS = module("lr-mame2003-plus", """
function _get_dir_name_lr-mame2003-plus() {
echo "mame2003-plus"
}
function configure_lr-mame2003-plus() {
configure_lr-mame2003
}
""", 19)
class LinksAndCopies(unittest.TestCase):
def test_px68k_links_only_its_five_names(self):
found = records({f"{EMULATORS}/px68k.sh": PX68K}, "px68k")
links = next(r for r in found if r["kind"] == "links")
self.assertEqual(
links["names"],
["cgrom.dat", "iplrom30.dat", "iplromco.dat", "iplrom.dat", "iplromxv.dat"],
)
self.assertNotIn("cgrom30.dat", links["names"])
self.assertEqual(links["search_dir"], "$biosdir/keropi")
self.assertEqual(links["passes_to"], "$md_conf_root/x68000/<name>")
self.assertEqual(links["source_ref"], f"{EMULATORS}/px68k.sh:58")
def test_px68k_moves_a_root_copy_first(self):
found = records({f"{EMULATORS}/px68k.sh": PX68K}, "px68k")
moves = next(r for r in found if r["kind"] == "moves")
self.assertEqual((moves["search_dir"], moves["passes_to"]), ("$biosdir", "$biosdir/keropi"))
self.assertEqual(moves["depends_on_option"], '[[ -f "$biosdir/$bios" ]]')
self.assertEqual(moves["source_ref"], f"{EMULATORS}/px68k.sh:56")
def test_redream_renames_both_files(self):
found = records({f"{EMULATORS}/redream.sh": REDREAM}, "redream")
links = next(r for r in found if r["kind"] == "links")
self.assertEqual(links["names"], ["dc_boot.bin", "dc_flash.bin"])
self.assertEqual(links["passes_to"], "$md_inst/boot.bin, $md_inst/flash.bin")
self.assertEqual(links["source_ref"], f"{EMULATORS}/redream.sh:58-59")
def test_a_copy_kept_only_for_the_package_the_test_names(self):
files = {
f"{LIBRETRO}/lr-mame2003.sh": MAME2003,
f"{LIBRETRO}/lr-mame2003-plus.sh": MAME2003_PLUS,
}
base = [r for r in records(files, "lr-mame2003") if r["kind"] == "copies"]
plus = [r for r in records(files, "lr-mame2003-plus") if r["kind"] == "copies"]
self.assertEqual([r["passes_to"] for r in base], ["$biosdir/mame2003"])
self.assertEqual(
[r["passes_to"] for r in plus],
["$biosdir/mame2003-plus", "$biosdir/mame2003-plus/artwork"],
)
self.assertEqual(base[0]["names"], ["hiscore.dat", "cheat.dat"])
class CommandLines(unittest.TestCase):
def test_sdltrs_passes_one_rom_per_machine(self):
found = records({f"{EMULATORS}/sdltrs.sh": SDLTRS}, "sdltrs")
found = [r for r in found if r["kind"] == "fixed_name"]
self.assertEqual(
[(r["emulator"], r["names"], r["passes_to"]) for r in found],
[
("sdltrs-model1", ["level2.rom"], "command line -romfile"),
("sdltrs-model3", ["level3.rom"], "command line -romfile3"),
("sdltrs-model4", ["level4.rom"], "command line -romfile3"),
("sdltrs-model4p", ["level4p.rom"], "command line -romfile4p"),
],
)
self.assertEqual(found[2]["source_ref"], f"{EMULATORS}/sdltrs.sh:51")
def test_a_bios_read_outside_the_bios_folder(self):
files = {f"{EMULATORS}/basilisk.sh": BASILISK, f"{EMULATORS}/gngeopi.sh": GNGEOPI}
rom = next(r for r in records(files, "basilisk") if r["kind"] == "fixed_name")
self.assertEqual((rom["names"], rom["search_dir"]), (["mac.rom"], "$romdir/macintosh"))
bios = next(r for r in records(files, "gngeopi") if r["kind"] == "passes_path")
self.assertEqual(bios["search_dir"], "$md_inst/neogeobios")
self.assertEqual(bios["source_ref"], f"{EMULATORS}/gngeopi.sh:60-61")
def test_a_flag_dropped_from_the_command_raises(self):
files = {
f"{EMULATORS}/basilisk.sh": BASILISK.replace("--rom ", "--romfile "),
f"{EMULATORS}/gngeopi.sh": GNGEOPI,
}
with self.assertRaises(rl.LauncherPatternMissing):
rl.extract_launchers(files)
class LaunchScripts(unittest.TestCase):
def test_fs_uae_refuses_to_start_without_kick13(self):
files = {f"{EMULATORS}/fs-uae.sh": FS_UAE, f"{EMULATORS}/fs-uae/fs-uae.sh": FS_UAE_LAUNCHER}
check = next(r for r in records(files, "fs-uae") if r["kind"] == "fixed_name")
self.assertEqual(check["names"], ["kick13.rom"])
self.assertEqual(check["search_dir"], "$HOME/RetroPie/BIOS")
self.assertEqual(check["passes_to"], "command line --kickstart_file")
message = check["fails_when_missing"]
self.assertTrue(message.startswith("You need to copy the Amiga kickstart"))
self.assertEqual(check["source_ref"], f"{EMULATORS}/fs-uae/fs-uae.sh:23-26, 41")
def test_a_launch_script_without_a_check_raises(self):
script = FS_UAE_LAUNCHER.replace("exit 1", "true")
files = {f"{EMULATORS}/fs-uae.sh": FS_UAE, f"{EMULATORS}/fs-uae/fs-uae.sh": script}
with self.assertRaises(rl.LauncherPatternMissing):
records(files)
class Packages(unittest.TestCase):
def test_the_package_names_what_it_builds_and_registers(self):
found = records({f"{EMULATORS}/px68k.sh": PX68K}, "px68k")
package = next(r for r in found if r["kind"] == "package")
self.assertEqual(package["repo"], "git https://github.com/TurtleBazooka/px68k.git master")
self.assertEqual(package["systems"], ["x68000"])
self.assertEqual(package["source_ref"], f"{EMULATORS}/px68k.sh:12, 15, 62")
def test_a_package_inherits_the_systems_of_the_function_it_runs(self):
files = {f"{EMULATORS}/dosbox.sh": DOSBOX, f"{EMULATORS}/dosbox-sdl2.sh": DOSBOX_SDL2}
package = next(r for r in records(files, "dosbox-sdl2") if r["kind"] == "package")
self.assertEqual(package["systems"], ["pc"])
self.assertIn(f"{EMULATORS}/dosbox.sh:68", package["source_ref"])
def test_systems_from_a_multi_line_array(self):
found = records({f"{LIBRETRO}/lr-fbneo.sh": FBNEO}, "lr-fbneo")
package = next(r for r in found if r["kind"] == "package")
self.assertEqual(package["systems"], ["arcade", "neogeo", "pcengine"])
self.assertEqual(package["build"], "libretro")
def test_a_system_name_left_unresolved_raises(self):
broken = FBNEO.replace('"${systems[@]}"', '"${others[@]}"')
with self.assertRaises(rl.LauncherPatternMissing):
records({f"{LIBRETRO}/lr-fbneo.sh": broken})
class UnknownConstructs(unittest.TestCase):
def test_an_unclassified_use_of_the_bios_folder_raises(self):
broken = REDREAM.replace('mkUserDir "$biosdir/dc"', 'rsync -a "$biosdir/dc" "$md_inst"')
with self.assertRaises(rl.LauncherPatternMissing):
records({f"{EMULATORS}/redream.sh": broken})
def test_scripts_without_a_package_raise(self):
with self.assertRaises(rl.LauncherPatternMissing):
rl.Scripts({f"{EMULATORS}/redream.sh": "function x() {\n}\n"})
class PlatformRecords(unittest.TestCase):
"""The launchers platforms/retropie.yml carries, as the scraper wrote them."""
@classmethod
def setUpClass(cls) -> None:
cls.config = yaml.safe_load((REPO_ROOT / "platforms" / "retropie.yml").read_text())
cls.schema = json.loads((REPO_ROOT / "schemas" / "platform.schema.json").read_text())
def test_one_package_record_per_core(self):
packages = [r["package"] for r in self.config["launchers"] if r["kind"] == "package"]
self.assertEqual(len(packages), len(set(packages)))
stripped = {p.removeprefix("lr-") for p in packages}
self.assertEqual(stripped, set(self.config["cores"]))
def test_every_record_belongs_to_a_package(self):
packages = {r["package"] for r in self.config["launchers"] if r["kind"] == "package"}
for record in self.config["launchers"]:
self.assertIn(record["package"], packages, record)
self.assertTrue(record["source_ref"].startswith("scriptmodules/"), record)
def test_the_standalone_builds_name_their_own_project(self):
repos = {
r["package"]: r.get("repo") for r in self.config["launchers"] if r["kind"] == "package"
}
self.assertEqual(repos["minivmac"], "git https://github.com/minivmac/minivmac.git master")
self.assertEqual(repos["quasi88"], "git https://github.com/winterheart/quasi88.git master")
self.assertEqual(repos["px68k"], "git https://github.com/TurtleBazooka/px68k.git master")
def test_the_schema_describes_every_scraper_field(self):
fields = {
f.name for cls in (batocera_launchers.Launcher, rl.Launcher)
for f in dataclasses.fields(cls)
} - {"lines"}
self.assertEqual(fields, set(self.schema["$defs"]["launcher"]["properties"]))
if __name__ == "__main__":
unittest.main()
+19 -1
View File
@@ -224,7 +224,10 @@ launchers:
|-------|---------|
| `emulator` | Emulator or core as the platform names it |
| `build` | `standalone` or `libretro` |
| `kind` | `names_searched` (tried in order, first found wins), `fixed_name` (one path per name), `core_option` (an option value names the file), `passes_path`, `links`, `copies`, `scans_dir` |
| `kind` | `names_searched` (tried in order, first found wins), `fixed_name` (one path per name), `core_option` (an option value names the file), `passes_path`, `links`, `copies`, `moves` (a file left in one folder is moved to the one the launcher links from), `scans_dir`, `package` (no file: what a package builds and registers) |
| `package` | The platform package whose script the record comes from (RetroPie's `rp_module_id`) |
| `repo` | What the package fetches and builds, as the script writes it (`rp_module_repo`: method, URL, branch or commit) |
| `systems` | Systems the package registers, under the platform's names (`addSystem`, `addPort` for ports) |
| `slot` | The key the code files the list under: a region, a machine |
| `names` | In the order the code tries or writes them, relative to `search_dir` |
| `expands` | Values a `{X}` placeholder in `names` takes, in the order tried (Hatari tries each TOS in ten languages) |
@@ -265,6 +268,21 @@ reads it from there. `emulator: libretro` stands for every core Recalbox runs,
for the default directory and for the per-system overrides applied after the
per-core choice, and `<system>` in `search_dir` is the system's own name.
Shell platforms are read as text. RetroPie's scriptmodules are walked the way
RetroPie runs them, one shell for every package: local assignments, arrays,
`for` loops, brace expansion, `$md_id` and calls into another package's
functions (`dosbox-sdl2` runs `configure_dosbox`). Paths keep the variables
the scripts write, `$biosdir` being `~/RetroPie/BIOS` (`retropie_packages.sh:48-49`).
Each package also gets one `kind: package` record, and that record is what
maps a package to the profile of the build it compiles: `repo` names the
project (`minivmac` builds github.com/minivmac/minivmac, Mini vMac 37, not the
libretro port), so the mapping never rests on stripping `lr-` from a name.
RetroDECK's records come from each component's `component_prepare.sh`,
`component_launcher.sh` and `component_functions.sh`, and from the presets
they copy, with `$bios_path` as the framework names it. In both, every line
that names the BIOS directory must match a known construct, else the scrape
stops with `LauncherPatternMissing`.
### Inheritance
If the new platform inherits from an existing one (e.g. Lakka inherits RetroArch),