Files
libretro/scripts/scraper/retrobat_launchers.py
T
Abdessamad Derraz 419d68d1f2 feat: read RetroBat launcher records
The scraper reads emulatorLauncher's generators and es_features.cfg at the commit the BIOS list is pinned to: Ares, Hatari, Mesen, Amiberry, WinUAE, PCSX2, the PSX BIOS cascades, Xemu, openMSX and DuckStation. Each extractor raises when its construct is gone.
2026-10-20 20:14:52 +02:00

1195 lines
43 KiB
Python

"""RetroBat's launcher layer, read from emulatorLauncher's C# generators.
emulatorLauncher decides, per emulator, which file names it looks for in the
BIOS directory, in what order, and what it writes into the emulator's
configuration. The C# is read as text, never compiled: each extractor targets
one construct the generators use and raises LauncherPatternMissing when that
construct is gone, so an upstream rewrite stops the scrape instead of
publishing an empty list.
"""
from __future__ import annotations
import bisect
import re
from collections.abc import Callable, Iterator
GENERATORS = "emulatorLauncher/Generators"
FEATURES = ".emulationstation/es_features.cfg"
SOURCE_FILES = (
f"{GENERATORS}/Amiberry.Generator.cs",
f"{GENERATORS}/Ares.Generator.cs",
f"{GENERATORS}/Bizhawk.Generator.cs",
f"{GENERATORS}/Duckstation.Generator.cs",
f"{GENERATORS}/Hatari.Generator.cs",
f"{GENERATORS}/LibRetro.CoreOptions.cs",
f"{GENERATORS}/Mednafen.Generator.cs",
f"{GENERATORS}/Mesen.Generator.cs",
f"{GENERATORS}/OpenMSX.Generator.cs",
f"{GENERATORS}/Pcsx2.Generator.cs",
f"{GENERATORS}/Uae.Generator.cs",
f"{GENERATORS}/Xemu.Generator.cs",
FEATURES,
)
BIOS = r'AppConfig\.GetFullPath\("bios"\)'
STRING = r'"((?:[^"\\\n]|\\.)*)"'
Record = dict[str, object]
class LauncherPatternMissing(ValueError):
"""A construct an extractor reads is absent from the launcher source."""
def __init__(self, path: str, what: str) -> None:
super().__init__(f"{path}: {what} not found")
class Source:
"""One source file with line lookup and C#-aware brace matching."""
def __init__(self, path: str, text: str) -> None:
self.path = path
self.text = text
self._starts = [0] + [m.end() for m in re.finditer(r"\n", text)]
self._blocks: list[tuple[int, int]] | None = None
def line(self, offset: int) -> int:
return bisect.bisect_right(self._starts, offset)
def lines(self, start: int, end: int) -> tuple[int, int]:
return self.line(start), self.line(max(start, end - 1))
def find(
self, pattern: str, what: str, start: int = 0, end: int | None = None, flags: int = 0
) -> re.Match[str]:
found = re.compile(pattern, flags).search(
self.text, start, len(self.text) if end is None else end
)
if found is None:
raise LauncherPatternMissing(self.path, what)
return found
def find_all(
self, pattern: str, what: str, start: int = 0, end: int | None = None
) -> list[re.Match[str]]:
found = list(
re.compile(pattern).finditer(self.text, start, len(self.text) if end is None else end)
)
if not found:
raise LauncherPatternMissing(self.path, what)
return found
def blocks(self) -> list[tuple[int, int]]:
"""Every brace pair, as (offset of "{", offset after "}")."""
if self._blocks is None:
self._blocks = []
stack: list[int] = []
for offset, char in _code_chars(self.text):
if char == "{":
stack.append(offset)
elif char == "}" and stack:
self._blocks.append((stack.pop(), offset + 1))
return self._blocks
def block_at(self, open_offset: int) -> tuple[int, int]:
"""The block opened by the first "{" at or after open_offset."""
candidates = [b for b in self.blocks() if b[0] >= open_offset]
if not candidates:
raise LauncherPatternMissing(self.path, f"block after line {self.line(open_offset)}")
return min(candidates)
def enclosing(self, offset: int) -> tuple[int, int]:
"""The innermost block holding offset."""
holding = [b for b in self.blocks() if b[0] < offset < b[1]]
if not holding:
raise LauncherPatternMissing(self.path, f"block around line {self.line(offset)}")
return max(holding)
def statement_span(self, block: tuple[int, int]) -> tuple[int, int]:
"""Lines of a block, from the statement that owns its opening brace."""
first, last = self.lines(*block)
line_text = self.text[self._starts[first - 1] : block[0]].strip()
return (first - 1 if not line_text and first > 1 else first), last
def _code_chars(text: str) -> Iterator[tuple[int, str]]:
"""Characters of C# code outside string, char literals and comments."""
i, n = 0, len(text)
while i < n:
char = text[i]
if text.startswith("//", i):
i = text.find("\n", i)
i = n if i < 0 else i
continue
if text.startswith("/*", i):
close = text.find("*/", i + 2)
i = n if close < 0 else close + 2
continue
if text.startswith('@"', i):
i += 2
while i < n and not (text[i] == '"' and not text.startswith('""', i)):
i += 2 if text.startswith('""', i) else 1
i += 1
continue
if char in "\"'":
i += 1
while i < n and text[i] != char and text[i] != "\n":
i += 2 if text[i] == "\\" else 1
i += 1
continue
yield i, char
i += 1
def _unescape(literal: str) -> str:
return re.sub(r"\\(.)", lambda m: {"n": "\n", "t": "\t"}.get(m.group(1), m.group(1)), literal)
def _strings(text: str) -> list[str]:
return [_unescape(m.group(1)) for m in re.finditer(STRING, text)]
def _unique(names: list[str]) -> list[str]:
return list(dict.fromkeys(names))
def _span(first: int, last: int) -> str:
return str(first) if first == last else f"{first}-{last}"
def _merge(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Spans sorted, joined when only one line separates them."""
merged: list[tuple[int, int]] = []
for first, last in sorted(spans):
if merged and first <= merged[-1][1] + 2:
merged[-1] = (merged[-1][0], max(last, merged[-1][1]))
else:
merged.append((first, last))
return merged
def source_ref(*parts: tuple[str, list[tuple[int, int]]]) -> str:
"""path:a-b, c-d for each file, a later range of one file left bare."""
refs = []
for path, spans in parts:
ranges = [_span(*s) for s in _merge(spans)]
refs.append(f"{path}:{', '.join(ranges)}")
return ", ".join(refs)
def _array_at(src: Source, offset: int) -> tuple[list[str], tuple[int, int]]:
"""String literals of the array initializer opened at offset."""
block = src.block_at(offset)
return _strings(src.text[block[0] : block[1]]), src.lines(*block)
def _call_args(src: Source, open_paren: int) -> str:
depth = 0
for offset, char in _code_chars(src.text[open_paren:]):
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
return src.text[open_paren + 1 : open_paren + offset]
raise LauncherPatternMissing(src.path, f"closing parenthesis after line {src.line(open_paren)}")
def render_concat(expression: str) -> str:
"""A C# string concatenation as text, each non-literal operand as <operand>."""
parts, depth, current, in_string = [], 0, "", False
i = 0
while i < len(expression):
char = expression[i]
if in_string:
current += char
if char == "\\":
current += expression[i + 1]
i += 1
elif char == '"':
in_string = False
elif char == '"':
in_string = True
current += char
elif char in "([":
depth += 1
current += char
elif char in ")]":
depth -= 1
current += char
elif char == "+" and depth == 0:
parts.append(current.strip())
current = ""
else:
current += char
i += 1
parts.append(current.strip())
rendered = []
for part in parts:
literal = re.fullmatch(STRING, part)
rendered.append(_unescape(literal.group(1)) if literal else f"<{part}>")
return "".join(rendered)
def _throw_message(src: Source, start: int, end: int) -> tuple[str, int]:
found = src.find(r"throw new ApplicationException\(", "ApplicationException", start, end)
message = render_concat(_call_args(src, found.end() - 1))
return f"ApplicationException '{message}'", src.line(found.start())
def feature_choices(
src: Source, emulator: str, option: str, core: str | None = None
) -> tuple[list[str], tuple[int, int]]:
"""Values es_features.cfg offers for an option of one emulator or core."""
text = src.text
scope_start, scope_end = 0, len(text)
for found in re.finditer(r'<emulator name="([^"]*)"', text):
if emulator in [name.strip() for name in found.group(1).split(",")]:
scope_start = found.start()
scope_end = text.find("</emulator>", scope_start)
break
else:
raise LauncherPatternMissing(src.path, f"emulator {emulator}")
if core is not None:
found = src.find(
rf'<core name="{re.escape(core)}"', f"core {core} of {emulator}", scope_start, scope_end
)
scope_start, scope_end = found.start(), text.find("</core>", found.start())
feature = src.find(
rf'<feature [^>]*value="{re.escape(option)}"',
f"feature {option} of {emulator}",
scope_start,
scope_end,
)
close = text.find("</feature>", feature.end())
values = re.findall(r'<choice [^>]*value="([^"]*)"', text[feature.end() : close])
if not values:
raise LauncherPatternMissing(src.path, f"choices of {option}")
return values, src.lines(feature.start(), close + len("</feature>"))
def _record(
emulator: str, build: str, kind: str, names: list[str], ref: str, **fields: object
) -> Record:
record: Record = {"emulator": emulator, "build": build, "kind": kind, "names": names}
record.update({key: value for key, value in fields.items() if value is not None})
record["source_ref"] = ref
return record
# Each extractor takes the sources by path and returns its records.
def ares(sources: dict[str, Source]) -> list[Record]:
"""Fallback chains: x = Path.Combine(dir, a), then if (!File.Exists(x)) x = b."""
src = sources[f"{GENERATORS}/Ares.Generator.cs"]
records = []
head = r'string (\w+) = Path\.Combine\((\w+), "([^"]+)"\);'
for found in src.find_all(head, "Path.Combine fallback chain"):
var, folder = found.group(1), found.group(2)
names, end = [found.group(3)], found.end()
step = re.compile(
rf'\s*if \(!File\.Exists\({var}\)\)\s*{var} = Path\.Combine\({folder}, "([^"]+)"\);'
)
while more := step.match(src.text, end):
names.append(more.group(1))
end = more.end()
if len(names) == 1:
continue
block = src.enclosing(found.start())
target = src.find(
rf'firmware\["([^"]+)"\] = {var}\.', f"firmware key for {var}", end, block[1]
)
container = list(
re.compile(r'GetOrCreateContainer\("(\w+)"\)').finditer(
src.text, block[0], found.start()
)
)
folder_decl = src.find(
rf'string {folder} = Path\.Combine\({BIOS}, "([^"]+)"\);',
f"folder {folder}",
block[0],
found.start(),
)
sections = "/".join(m.group(1) for m in container)
records.append(
_record(
"ares",
"standalone",
"names_searched",
names,
source_ref((src.path, [(src.line(found.start()), src.line(target.end()))])),
case_insensitive=True,
search_dir=f"bios/{folder_decl.group(1)}",
passes_to=f"settings.bml {sections}/{target.group(1)} (first found)",
fails_when_missing=False,
)
)
if not records:
raise LauncherPatternMissing(src.path, "File.Exists fallback chain")
return records
def hatari(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/Hatari.Generator.cs"]
features = sources[FEATURES]
default = src.find(rf'string (\w+) = Path\.Combine\({BIOS}, "([^"]+)"\);', "default TOS path")
var, name = default.group(1), default.group(2)
override = src.find(
rf'isOptSet\("(\w+)"\)[^\n]*\n\s*{var} = Path\.Combine\({BIOS}, ((?:"[^"]+", )+)SystemConfig\["\1"\]\);',
"TOS option override",
default.end(),
)
option, folders = override.group(1), _strings(override.group(2))
write = src.find(
rf'ini\.WriteValue\("(\w+)", "(\w+)", {var}\);', "TOS ini write", override.end()
)
message, _ = _throw_message(src, override.end(), write.start())
passes = f"hatari.cfg [{write.group(1)}] {write.group(2)}"
choices, choice_lines = feature_choices(features, "hatari", option)
return [
_record(
"hatari",
"standalone",
"fixed_name",
[name],
source_ref((src.path, [src.lines(default.start(), write.end())])),
case_insensitive=True,
search_dir="bios",
passes_to=f"{passes}=<bios>/{name}",
fails_when_missing=message,
depends_on_option=f"{option} unset",
),
_record(
"hatari",
"standalone",
"core_option",
choices,
source_ref(
(src.path, [src.lines(override.start(), write.end())]),
(features.path, [choice_lines]),
),
case_insensitive=True,
search_dir="/".join(["bios", *folders]),
passes_to=f"{passes}=<bios>/{'/'.join(folders)}/<value>",
fails_when_missing=message,
depends_on_option=option,
),
]
def hatari_libretro(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/LibRetro.CoreOptions.cs"]
features = sources[FEATURES]
records = []
tosimage = src.find(
r'if \(SystemConfig\.isOptSet\("(\w+)"\)[^\n]*\n\s*coreSettings\["(hatari_tosimage)"\] = SystemConfig\["\1"\];'
r'\s*else\s*coreSettings\["\2"\] = "(\w+)";',
"hatari_tosimage",
)
choices, choice_lines = feature_choices(features, "libretro", tosimage.group(1), core="hatari")
records.append(
_record(
"hatari",
"libretro",
"core_option",
choices,
source_ref(
(src.path, [src.lines(tosimage.start(), tosimage.end())]),
(features.path, [choice_lines]),
),
search_dir="bios",
passes_to=f"{tosimage.group(2)} = <value> | {tosimage.group(3)}",
fails_when_missing="not checked by the launcher",
depends_on_option=tosimage.group(1),
)
)
guard = src.find(r'if \(core != "hatarib"\)', "hatarib guard")
method = src.enclosing(guard.start())
found = src.find(
rf'else if \(File\.Exists\(Path\.Combine\({BIOS}, "([^"]+)"\)\)\)\s*'
r'coreSettings\["(hatarib_tos)"\] = "([^"]+)";\s*else\s*coreSettings\["hatarib_tos"\] = "([^"]+)";',
"hatarib tos.img fallback",
method[0],
method[1],
)
records.append(
_record(
"hatarib",
"libretro",
"fixed_name",
[found.group(1)],
source_ref((src.path, [src.lines(found.start(), found.end())])),
case_insensitive=True,
search_dir="bios",
passes_to=f"{found.group(2)} = {found.group(3)}",
fails_when_missing=f"falls back to {found.group(2)} = {found.group(4)}",
depends_on_option=f"{found.group(2)} unset",
)
)
picked = src.find(
rf'string (\w+) = Path\.Combine\({BIOS}, "([^"]+)", SystemConfig\["(hatarib_tos)"\]\);',
"hatarib option path",
method[0],
method[1],
)
write = src.find(
r'coreSettings\["hatarib_tos"\] = (\w+);', "hatarib option write", picked.end()
)
skipped = re.findall(
r'SystemConfig\["hatarib_tos"\] == "(\w+)"', src.text[method[0] : picked.start()]
)
prefixes = re.findall(
r'SystemConfig\["hatarib_tos"\]\.StartsWith\("(\w+)"\)',
src.text[method[0] : picked.start()],
)
choices, choice_lines = feature_choices(features, "libretro", picked.group(3), core="hatarib")
files = [c for c in choices if c not in skipped and not c.startswith(tuple(prefixes))]
records.append(
_record(
"hatarib",
"libretro",
"core_option",
files,
source_ref(
(src.path, [src.lines(picked.start(), write.end())]),
(features.path, [choice_lines]),
),
case_insensitive=True,
search_dir=f"bios/{picked.group(2)}",
passes_to=f"hatarib_tos = {picked.group(2)}/<value>",
fails_when_missing="option not written when the file is absent",
depends_on_option=picked.group(3),
)
)
return records
def mesen(sources: dict[str, Source]) -> list[Record]:
"""Files copied from bios into Mesen's Firmware folder, renamed on the way."""
src = sources[f"{GENERATORS}/Mesen.Generator.cs"]
records = []
for folder in src.find_all(
r'string targetFirmwarePath = Path\.Combine\(\w+, "Firmware"\);', "Firmware folder"
):
block = src.enclosing(folder.start())
body = src.text[block[0] : block[1]]
targets = dict(
re.findall(
r'string (targetFirmware\d+) = Path\.Combine\(targetFirmwarePath, "([^"]+)"\);',
body,
)
)
pairs = re.findall(
rf'string (sourceFirmware\d+) = Path\.Combine\({BIOS}, "([^"]+)"\);', body
)
copies = re.findall(
r"!File\.Exists\((targetFirmware\d+)\)[^\n]*\n\s*File\.Copy\((sourceFirmware\d+), \1\);",
body,
)
if not pairs or len(copies) != len(pairs):
raise LauncherPatternMissing(
src.path, f"guarded File.Copy at line {src.line(folder.start())}"
)
by_source = {source: target for target, source in copies}
names = [name for _, name in pairs]
destinations = [targets[by_source[var]] for var, _ in pairs]
try:
fails: str | bool = _throw_message(src, folder.start(), block[1])[0]
except LauncherPatternMissing:
fails = False
records.append(
_record(
"mesen",
"standalone",
"copies",
names,
source_ref((src.path, [src.statement_span(block)])),
case_insensitive=True,
search_dir="bios",
passes_to="copied to <mesen>/Firmware/"
+ ", ".join(destinations)
+ ", only when the target does not exist",
fails_when_missing=fails,
)
)
return records
def _kickstart_table(
src: Source,
entry: str,
find_method: str,
) -> tuple[list[tuple[str, list[str], list[tuple[int, int]]]], str, tuple[int, int]]:
"""Kickstart candidate lists of an Amiga model table, identical lists merged."""
lists: dict[tuple[str, tuple[str, ...]], list[tuple[int, int]]] = {}
for model in src.find_all(rf'\{{\s*"[\w+]+",\s*new {entry}\s*\{{', f"{entry} table"):
block = src.block_at(model.start())
for field in re.finditer(
r"\b(Kickstart|KickstartExt)\s*=\s*new\s*(?:string)?\[\]\s*\{",
src.text[block[0] : block[1]],
):
names, (_, last) = _array_at(src, block[0] + field.end() - 1)
lists.setdefault((field.group(1), tuple(names)), []).append(
(src.line(model.start()), last)
)
method = src.find(find_method, "Kickstart lookup method")
body = src.block_at(method.end())
dirs = src.find(
r'new\s*(?:List<string>\s*)?(?:\[\])?\s*\{\s*biosPath((?:, Path\.Combine\(biosPath, "[^"]+"\))+)\s*\}',
"Kickstart search directories",
body[0],
body[1],
)
search = ", ".join(["bios", *(f"bios/{d}" for d in _strings(dirs.group(1)))])
method_span = (src.line(method.start()), src.lines(*body)[1])
return (
[(field, list(names), spans) for (field, names), spans in lists.items()],
search,
method_span,
)
def amiberry(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/Amiberry.Generator.cs"]
lists, search, method_span = _kickstart_table(
src, "AmigaModel", r"private string FindBios\(string\[\] candidates\)"
)
warnings = {
"Kickstart": src.find(
r'Warning\("\[Amiberry\] No Kickstart ROM found', "Kickstart warning"
),
"KickstartExt": src.find(
r'Warning\("\[Amiberry\] No extended ROM found', "extended ROM warning"
),
}
keys = {"Kickstart": "kickstart_rom_file", "KickstartExt": "kickstart_ext_rom_file"}
records = []
for field, names, spans in lists:
stage = src.find(rf"StageKickstart\(_model\.{field}\)", f"StageKickstart({field})")
records.append(
_record(
"amiberry",
"standalone",
"names_searched",
names,
source_ref(
(
src.path,
[*spans, src.lines(stage.start(), warnings[field].end()), method_span],
)
),
case_insensitive=True,
search_dir=search,
passes_to=f"copied to <amiberry roms>/{names[0]}, {keys[field]}",
fails_when_missing=f"warning, {keys[field]} not written",
)
)
return records
def winuae(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/Uae.Generator.cs"]
lists, search, method_span = _kickstart_table(
src, "Candidate", r"public static string Find\(string\[\] candidates\)"
)
writer = src.find(r"private void WriteKickstartRom\(", "WriteKickstartRom")
body = src.block_at(writer.end())
keys = {"Kickstart": "kickstart_rom_file", "KickstartExt": "kickstart_ext_rom_file"}
records = []
for field, names, spans in lists:
lookup = src.find(
rf"AmigaBios\.Find\(candidate\.{field}\)", f"Find({field})", body[0], body[1]
)
message, throw_line = _throw_message(src, lookup.end(), body[1])
records.append(
_record(
"winuae",
"standalone",
"names_searched",
names,
source_ref(
(src.path, [*spans, (src.line(lookup.start()), throw_line + 1), method_span])
),
case_insensitive=True,
search_dir=search,
passes_to=f"game .uae {keys[field]}=<found path>",
fails_when_missing=message,
)
)
table = src.find(
r"WhdBootKickstartEntry\[\] _whdBootKickstarts = new\[\]", "WHDBoot Kickstart table"
)
table_block = src.block_at(table.end())
targets = re.findall(
r'new WhdBootKickstartEntry\("([^"]+)", AmigaBios\.BySystem\["[^"]+"\]\.Kickstart\)',
src.text[table_block[0] : table_block[1]],
)
loop = src.find(r"foreach \(var entry in _whdBootKickstarts\)", "WHDBoot copy loop")
loop_block = src.block_at(loop.end())
folder = src.find(
r'Path\.Combine\(whdBoot, ((?:"[^"]+"(?:, )?)+)\)',
"WHDBoot Kickstarts folder",
0,
loop.start(),
)
records.append(
_record(
"winuae",
"standalone",
"copies",
targets,
source_ref(
(
src.path,
[
(src.line(loop.start()), src.lines(*loop_block)[1]),
(src.line(table.start()), src.lines(*table_block)[1]),
],
)
),
case_insensitive=True,
search_dir=search,
passes_to=(
f"copied to <WHDBoot>/{'/'.join(_strings(folder.group(1)))}/<name>"
" from the system's Kickstart candidates"
),
fails_when_missing=False,
)
)
return records
def pcsx2(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/Pcsx2.Generator.cs"]
records = []
qt = src.find(r"private void SetupConfigurationQT\(", "SetupConfigurationQT")
qt_body = src.block_at(qt.end())
folder = src.find(
rf'string biosPath = Path\.Combine\({BIOS}, ((?:"[^"]+"(?:, )?)+)\);',
"Qt BIOS folder",
qt_body[0],
qt_body[1],
)
src.find(
r"Directory\.GetFiles\(biosPath\)\.Length == 0\)[^\n]*\n\s*biosPath = " + BIOS,
"empty folder fallback",
folder.end(),
qt_body[1],
)
write = src.find(
r'ini\.WriteValue\("Filenames", "BIOS", biosFile\);',
"Qt BIOS write",
folder.end(),
qt_body[1],
)
message, _ = _throw_message(src, folder.end(), write.start())
src.find(r'SystemConfig\["pcsx2_forcebios"\]', "pcsx2_forcebios", folder.end(), write.start())
sub = "/".join(_strings(folder.group(1)))
for var in ("biosList", "arcadeBiosList"):
decl = src.find(rf"var {var} = new string\[\] \{{", f"Qt {var}", qt_body[0], qt_body[1])
names, lines = _array_at(src, decl.end() - 1)
records.append(
_record(
"pcsx2",
"standalone",
"names_searched",
names,
source_ref(
(
src.path,
[
(src.line(decl.start()), lines[1]),
src.lines(folder.start(), write.end()),
],
)
),
case_insensitive=True,
search_dir=f"bios/{sub} (bios when bios/{sub} holds no file)",
passes_to="inis/PCSX2.ini [Folders] Bios, [Filenames] BIOS",
fails_when_missing=message,
depends_on_option="pcsx2_forcebios",
)
)
legacy = src.find(r"private void SetupPaths\(", "SetupPaths")
legacy_body = src.block_at(legacy.end())
decl = src.find(
r"var biosList = new string\[\] \{", "1.6 biosList", legacy_body[0], legacy_body[1]
)
names, lines = _array_at(src, decl.end() - 1)
case = src.find(r'case "BIOS":', "1.6 BIOS case", legacy_body[0], legacy_body[1])
default = src.find(
r'value = "([^"]+)";\s*break;', "1.6 default BIOS", case.end(), legacy_body[1]
)
folder = src.find(
r'File\.Exists\(Path\.Combine\(biosPath, ((?:"[^"]+", )+)b\)\)',
"1.6 BIOS folder",
legacy_body[0],
legacy_body[1],
)
sub = "/".join(_strings(folder.group(1)))
records.append(
_record(
"pcsx2",
"standalone",
"names_searched",
names,
source_ref(
(
src.path,
[(src.line(decl.start()), lines[1]), src.lines(case.start(), default.end())],
)
),
case_insensitive=True,
search_dir=f"bios/{sub} if any listed name is there, else bios",
passes_to="inis/PCSX2_ui.ini [Folders] Bios, [Filenames] BIOS",
fails_when_missing=f"no error: BIOS name defaults to {default.group(1)}",
depends_on_option="pcsx2_forcebios",
)
)
return records
def pcsx2_libretro(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/LibRetro.CoreOptions.cs"]
features = sources[FEATURES]
guard = src.find(r'if \(core != "pcsx2"\)', "pcsx2 guard")
method = src.enclosing(guard.start())
folder = src.find(
r'string ps2BiosFolder = Path\.Combine\(biosRoot, ((?:"[^"]+"(?:, )?)+)\);',
"LRPS2 folder",
method[0],
method[1],
)
option = src.find(r'isOptSet\("(lrps2_bios)"\)', "lrps2_bios", folder.end(), method[1])
write = src.find(
r'coreSettings\["(pcsx2_bios)"\] = \w+;', "pcsx2_bios write", option.end(), method[1]
)
message, _ = _throw_message(src, option.end(), write.start())
choices, choice_lines = feature_choices(features, "libretro", option.group(1), core="pcsx2")
sub = "/".join(_strings(folder.group(1)))
records = [
_record(
"pcsx2",
"libretro",
"core_option",
choices,
source_ref(
(src.path, [src.lines(folder.start(), write.end())]),
(features.path, [choice_lines]),
),
case_insensitive=True,
search_dir=f"<system_directory>/{sub}",
passes_to=f"{write.group(1)} = <value>",
fails_when_missing=message,
depends_on_option=option.group(1),
)
]
scan = src.find(
r"private static string GetFirstPS2Bios\(string biosFolder\)", "GetFirstPS2Bios"
)
scan_body = src.block_at(scan.end())
preferred = src.find(
r'n\.Equals\("([^"]+)", StringComparison\.OrdinalIgnoreCase\)',
"preferred PS2 BIOS",
scan_body[0],
scan_body[1],
)
sizes = src.find(
r"MIN_BIOS_SIZE = (\d+) \* 1024 \* 1024;\s*const long MAX_BIOS_SIZE = (\d+) \* 1024 \* 1024;",
"BIOS size bounds",
scan_body[0],
scan_body[1],
)
auto = src.find(
r"string autoBios = GetFirstPS2Bios\(ps2BiosFolder\);",
"GetFirstPS2Bios call",
write.end(),
method[1],
)
auto_write = src.find(
r'coreSettings\["pcsx2_bios"\] = autoBios;', "autoBios write", auto.end(), method[1]
)
auto_message, _ = _throw_message(src, auto.end(), auto_write.start())
records.append(
_record(
"pcsx2",
"libretro",
"scans_dir",
[
preferred.group(1),
f"<first file {sizes.group(1)}-{sizes.group(2)} MiB, ordinal-ignore-case>",
],
source_ref(
(
src.path,
[
(src.line(scan.start()), src.lines(*scan_body)[1]),
src.lines(auto.start(), auto_write.end()),
],
)
),
case_insensitive=True,
search_dir=f"<system_directory>/{sub}",
passes_to="pcsx2_bios = <chosen name>",
fails_when_missing=auto_message,
depends_on_option=f"{option.group(1)} unset",
)
)
return records
PSX_CASCADES = {
f"{GENERATORS}/Bizhawk.Generator.cs": ("bizhawk", "standalone"),
f"{GENERATORS}/Mednafen.Generator.cs": ("mednafen", "standalone"),
f"{GENERATORS}/LibRetro.CoreOptions.cs": (None, "libretro"),
}
def _top_level_else(src: Source, block: tuple[int, int]) -> int | None:
"""Offset of the last else of an if chain at the top of a block, not an else if."""
depth, last = 0, None
for offset, char in _code_chars(src.text[block[0] + 1 : block[1] - 1]):
absolute = block[0] + 1 + offset
if char in "{(":
depth += 1
elif char in "})":
depth -= 1
elif (
depth == 0
and src.text.startswith("else", absolute)
and not re.match(r"else\s+if\b", src.text[absolute:])
):
last = absolute
return last
def psx_cascades(sources: dict[str, Source]) -> list[Record]:
"""psxonpsp660.bin, then ps1_rom.bin, then one file per region."""
records = []
for path, (fixed_emulator, build) in PSX_CASCADES.items():
src = sources[path]
for cascade in src.find_all(
r'if \(!SystemConfig\.getOptBoolean\("(\w+_original_bios)"\)\)', "PSX BIOS cascade"
):
emulator = fixed_emulator
if emulator is None:
guards = re.findall(r'if \(core != "([^"]+)"\)', src.text[: cascade.start()])
if not guards:
raise LauncherPatternMissing(path, "core guard before the PSX cascade")
emulator = guards[-1]
branch = src.block_at(cascade.end())
after = re.compile(r"\s*else\s*(\{|[^;]*;)").match(src.text, branch[1])
if after is None:
raise LauncherPatternMissing(
path, f"else after the PSX cascade at line {src.line(cascade.start())}"
)
original_end = src.block_at(after.start(1))[1] if after.group(1) == "{" else after.end()
final_else = _top_level_else(src, branch)
if final_else is None:
raise LauncherPatternMissing(
path, f"final else of the PSX cascade at line {src.line(cascade.start())}"
)
searched = _unique(
re.findall(rf'Path\.Combine\({BIOS}, "([^"]+)"\)', src.text[branch[0] : final_else])
)
if not searched:
raise LauncherPatternMissing(path, "searched PSX BIOS names")
keys = _unique(re.findall(r'\w+\["([^"]+)"\]\s*=', src.text[branch[0] : branch[1]]))
ref = source_ref((path, [src.lines(cascade.start(), original_end)]))
records.append(
_record(
emulator,
build,
"names_searched",
searched,
ref,
case_insensitive=True,
search_dir="bios",
passes_to=", ".join(keys),
fails_when_missing=False,
depends_on_option=f"{cascade.group(1)} off",
)
)
fallback_text = src.text[final_else : branch[1]]
fallback = _unique([n for n in _strings(fallback_text) if n.lower().endswith(".bin")])
if fallback:
checked = "File.Exists" in fallback_text
records.append(
_record(
emulator,
build,
"fixed_name",
fallback,
ref,
case_insensitive=True,
search_dir="bios",
passes_to=", ".join(keys),
fails_when_missing=(
False if checked else "names written without an existence check"
),
)
)
return records
def xemu(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/Xemu.Generator.cs"]
features = sources[FEATURES]
records = []
mcpx = src.find(
rf'if \(File\.Exists\(Path\.Combine\({BIOS}, "([^"]+)"\)\)\)\s*(\w+) = Path\.Combine',
"MCPX boot ROM",
)
bootrom = src.find(
rf'ini\.WriteValue\("([\w.]+)", "(bootrom_path)", "\'" \+ {mcpx.group(2)}',
"bootrom_path write",
)
records.append(
_record(
"xemu",
"standalone",
"fixed_name",
[mcpx.group(1)],
source_ref(
(
src.path,
[
src.lines(mcpx.start(), mcpx.end()),
src.lines(bootrom.start(), bootrom.end()),
],
)
),
case_insensitive=True,
search_dir="bios",
passes_to=f"xemu.toml [{bootrom.group(1)}] {bootrom.group(2)}, only if the file exists",
fails_when_missing=f"{bootrom.group(2)} not written",
)
)
flash = src.find(r'isOptSet\("(xemu_flashrom)"\)', "xemu_flashrom")
flash_default = src.find(
r'ini\.WriteValue\("([\w.]+)", "(flashrom_path)", "\'" \+ Path\.Combine\(flashromPath, "([^"]+)"\)',
"flashrom default",
flash.end(),
)
choices, choice_lines = feature_choices(features, "xemu", flash.group(1))
records.append(
_record(
"xemu",
"standalone",
"core_option",
_unique([flash_default.group(3), *choices]),
source_ref(
(src.path, [src.lines(flash.start(), flash_default.end())]),
(features.path, [choice_lines]),
),
case_insensitive=True,
search_dir="bios",
passes_to=(
f"xemu.toml [{flash_default.group(1)}] {flash_default.group(2)}=<bios>/<value>,"
f" default {flash_default.group(3)}"
),
fails_when_missing="path written without an existence check",
depends_on_option=flash.group(1),
)
)
setup = src.find(r"private void SetupChihiroConfiguration\(", "SetupChihiroConfiguration")
body = src.block_at(setup.end())
chain = src.find(
r'string (\w+) = Path\.Combine\(biosPath, ((?:"[^"]+", )*)"([^"]+)"\);\s*'
r'if \(!File\.Exists\(\1\)\)\s*\1 = Path\.Combine\(biosPath, ((?:"[^"]+", )*)"([^"]+)"\);',
"Chihiro BIOS chain",
body[0],
body[1],
)
dirs = [
"/".join(["bios", *_strings(chain.group(2))]),
"/".join(["bios", *_strings(chain.group(4))]),
]
bios_write = src.find(
r'ini\.WriteValue\("([\w.]+)", "(bios_path)"', "Chihiro bios_path", chain.end(), body[1]
)
warning = src.find(
r'else\s*SimpleLogger\.Instance\.Warning\("([^"]+)"\);',
"Chihiro missing warning",
bios_write.end(),
body[1],
)
records.append(
_record(
"xemu",
"standalone",
"names_searched",
_unique([chain.group(3), chain.group(5)]),
source_ref((src.path, [src.lines(chain.start(), warning.end())])),
case_insensitive=True,
search_dir=", then ".join(dirs),
passes_to=f"xemu.toml [{bios_write.group(1)}] {bios_write.group(2)}",
fails_when_missing=f"warning '{warning.group(1)}', {bios_write.group(2)} not written",
)
)
companions = src.find(
r"foreach \(var f in new\[\] \{", "Chihiro companion files", chain.end(), body[1]
)
names, _ = _array_at(src, companions.end() - 1)
loop = src.block_at(src.block_at(companions.end() - 1)[1])
records.append(
_record(
"xemu",
"standalone",
"fixed_name",
names,
source_ref((src.path, [(src.line(companions.start()), src.lines(*loop)[1])])),
case_insensitive=True,
search_dir="directory of the Chihiro BIOS found",
passes_to="nothing passed: existence checked only",
fails_when_missing="warning per missing file",
)
)
board = src.find(
r'if \((!File\.Exists\(Path\.Combine\(biosDir, "[^"]+"\)\)(?:\s*&&\s*)?)+\)\s*'
r'SimpleLogger\.Instance\.Warning\("([^"]+)"\);',
"media board flash names",
companions.end(),
body[1],
)
records.append(
_record(
"xemu",
"standalone",
"names_searched",
re.findall(r'Path\.Combine\(biosDir, "([^"]+)"\)', board.group(0)),
source_ref((src.path, [src.lines(board.start(), board.end())])),
case_insensitive=True,
search_dir="directory of the Chihiro BIOS found",
passes_to="nothing passed: warning if none exists",
fails_when_missing=f"warning '{board.group(2)}'",
)
)
return records
def openmsx(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/OpenMSX.Generator.cs"]
table = src.find(
r"Dictionary<string, string\[\]> biosFiles = new Dictionary<string, string\[\]>\(\)\s*\{",
"biosFiles table",
)
block = src.block_at(table.end() - 1)
check = src.find(r"foreach \(var c in biosFileRequired\)", "per-file BIOS check")
message, throw_line = _throw_message(src, check.end(), len(src.text))
folder = re.search(r"in '([^']+)' folder", message)
if folder is None:
raise LauncherPatternMissing(src.path, "systemroms folder in the BIOS message")
records = []
for entry in re.finditer(
r'\{ "([^"]+)", new string\[\] \{([^}]*)\} \}', src.text[block[0] : block[1]]
):
start = block[0] + entry.start()
records.append(
_record(
"openmsx",
"standalone",
"fixed_name",
_strings(entry.group(2)),
source_ref(
(
src.path,
[
src.lines(start, block[0] + entry.end()),
(src.line(check.start()), throw_line),
],
)
),
case_insensitive=False,
search_dir=folder.group(1).replace("\\", "/"),
passes_to=f"-machine {entry.group(1)}",
fails_when_missing=message,
)
)
if not records:
raise LauncherPatternMissing(src.path, "biosFiles entries")
return records
def duckstation(sources: dict[str, Source]) -> list[Record]:
src = sources[f"{GENERATORS}/Duckstation.Generator.cs"]
features = sources[FEATURES]
search = src.find(
r'ini\.WriteValue\("BIOS", "SearchDirectory", biosPath\);', "SearchDirectory write"
)
option = src.find(r'isOptSet\("(duck_bios)"\)', "duck_bios", search.end())
block = src.block_at(option.end())
keys = re.findall(
r'ini\.WriteValue\("BIOS", "(\w+)", SystemConfig\["duck_bios"\]\);',
src.text[block[0] : block[1]],
)
if not keys:
raise LauncherPatternMissing(src.path, "duck_bios writes")
cleared = src.block_at(block[1])
choices, choice_lines = feature_choices(features, "duckstation", option.group(1))
return [
_record(
"duckstation",
"standalone",
"passes_path",
[],
source_ref((src.path, [src.lines(search.start(), search.end()), src.lines(*cleared)])),
search_dir="bios",
passes_to="settings.ini [BIOS] SearchDirectory = bios",
fails_when_missing=False,
depends_on_option=f"{option.group(1)} unset",
),
_record(
"duckstation",
"standalone",
"core_option",
choices,
source_ref(
(src.path, [src.lines(option.start(), block[1])]), (features.path, [choice_lines])
),
search_dir="bios",
passes_to="settings.ini [BIOS] " + " = ".join(keys) + " = <value>",
fails_when_missing="not checked by the launcher",
depends_on_option=option.group(1),
),
]
EXTRACTORS: tuple[Callable[[dict[str, Source]], list[Record]], ...] = (
amiberry,
ares,
duckstation,
hatari,
hatari_libretro,
mesen,
openmsx,
pcsx2,
pcsx2_libretro,
psx_cascades,
winuae,
xemu,
)
def extract_launchers(texts: dict[str, str]) -> list[Record]:
"""Every launcher record the extractors find, in extractor order."""
missing = [path for path in SOURCE_FILES if path not in texts]
if missing:
raise LauncherPatternMissing(missing[0], "source file")
sources = {path: Source(path, text) for path, text in texts.items()}
return [record for extractor in EXTRACTORS for record in extractor(sources)]