mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
standalone_path is relative to the directories a standalone build resolves it against, now declared per profile (standalone_directories). A launcher record that links or passes a whole folder of the BIOS tree under one of them gives that folder, and platform_layouts rebases the standalone view once for the builder, the manifest, verify and slots (launchers.standalone_root, common.standalone_destination). Flycast on RetroPie and ROCKNIX reads bios/dc, on EmuDeck bios/flycast/bios, on Batocera (FLYCAST_BIOS_PATH) and RetroBat (Dreamcast.BiosPath) bios/dc. The ROCKNIX, Batocera and RetroBat scrapers now record the patched data directory, the environment variable and the setting that say so.
1455 lines
53 KiB
Python
1455 lines
53 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}/Flycast.Generator.cs",
|
|
f"{GENERATORS}/Hatari.Generator.cs",
|
|
f"{GENERATORS}/Jgenesis.Generator.cs",
|
|
f"{GENERATORS}/KegaFusion.Generator.cs",
|
|
f"{GENERATORS}/LibRetro.CoreOptions.cs",
|
|
f"{GENERATORS}/Mednafen.Generator.cs",
|
|
f"{GENERATORS}/MelonDS.Generator.cs",
|
|
f"{GENERATORS}/Mesen.Generator.cs",
|
|
f"{GENERATORS}/Mupen64.Generator.cs",
|
|
f"{GENERATORS}/OpenMSX.Generator.cs",
|
|
f"{GENERATORS}/Pcsx2.Generator.cs",
|
|
f"{GENERATORS}/Project64.Generator.cs",
|
|
f"{GENERATORS}/Uae.Generator.cs",
|
|
f"{GENERATORS}/Xemu.Generator.cs",
|
|
f"{GENERATORS}/mGBA.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
|
|
self._ends: list[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 header(self, block: tuple[int, int]) -> str:
|
|
"""The statement that owns a block's opening brace, comments removed."""
|
|
if self._ends is None:
|
|
self._ends = [o for o, c in _code_chars(self.text) if c in ";{}"]
|
|
previous = bisect.bisect_left(self._ends, block[0]) - 1
|
|
start = self._ends[previous] + 1 if previous >= 0 else 0
|
|
text = re.sub(r"//[^\n]*|/\*.*?\*/", "", self.text[start : block[0]], flags=re.S)
|
|
return " ".join(text.split())
|
|
|
|
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),
|
|
),
|
|
]
|
|
|
|
|
|
def flycast(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Flycast.Generator.cs"]
|
|
folder = src.find(
|
|
r'string biosPath = Path\.Combine\(' + BIOS + r', "(\w+)"\);', "biosPath under bios"
|
|
)
|
|
write = src.find(
|
|
r'ini\.WriteValue\("(\w+)", "(Dreamcast\.BiosPath)", biosPath\);',
|
|
"Dreamcast.BiosPath write",
|
|
folder.end(),
|
|
)
|
|
return [
|
|
_record(
|
|
"flycast",
|
|
"standalone",
|
|
"passes_path",
|
|
[],
|
|
source_ref(
|
|
(
|
|
src.path,
|
|
[src.lines(folder.start(), folder.end()), src.lines(write.start(), write.end())],
|
|
)
|
|
),
|
|
search_dir=f"bios/{folder.group(1)}",
|
|
passes_to=f"emu.cfg [{write.group(1)}] {write.group(2)} = bios/{folder.group(1)}",
|
|
fails_when_missing=False,
|
|
)
|
|
]
|
|
|
|
|
|
# Generators that write each BIOS path into the emulator's configuration
|
|
# only when the file exists: string x = Path.Combine(bios, ..., "name");
|
|
# if (File.Exists(x)) ini.WriteValue("section", "key", x).
|
|
GUARDED_WRITES = {
|
|
f"{GENERATORS}/Jgenesis.Generator.cs": "jgenesis",
|
|
f"{GENERATORS}/KegaFusion.Generator.cs": "kega-fusion",
|
|
f"{GENERATORS}/MelonDS.Generator.cs": "melonds",
|
|
f"{GENERATORS}/Mupen64.Generator.cs": "mupen64",
|
|
f"{GENERATORS}/Project64.Generator.cs": "project64",
|
|
f"{GENERATORS}/mGBA.Generator.cs": "mgba",
|
|
}
|
|
|
|
_METHOD = re.compile(
|
|
r"(?:(?:public|private|protected|internal|static|override|virtual)\s+)+[\w<>\[\],. ]+\s\w+\s*\("
|
|
)
|
|
_TRANSPARENT = re.compile(r"(?:using\s*\(.*\)|try)")
|
|
_IF = re.compile(r"(?:else\s+)?if\s*\((.*)\)")
|
|
_GUARD = re.compile(
|
|
r'if \(File\.Exists\((\w+)\)\)\s*(\w+)\.WriteValue\("([^"]*)", "([^"]+)", ([^;\n]+)\);'
|
|
)
|
|
_BIOS_ROOT = r'(?:Program\.)?AppConfig\.GetFullPath\("bios"\)'
|
|
|
|
|
|
def _negate(condition: str) -> str:
|
|
"""The condition under which an early return is not taken.
|
|
|
|
A conjunction of inequalities becomes the disjunction of equalities,
|
|
anything else is wrapped in not (...).
|
|
"""
|
|
tests = condition.split(" && ")
|
|
compared = [re.fullmatch(rf"(\w+) != ({STRING})", t) for t in tests]
|
|
if all(compared):
|
|
return " || ".join(f"{m.group(1)} == {m.group(2)}" for m in compared)
|
|
return f"not ({condition})"
|
|
|
|
|
|
Reach = tuple[tuple[int, int], list[str], list[tuple[int, int]]]
|
|
|
|
|
|
def _conditions(src: Source, offset: int) -> Reach | None:
|
|
"""The method holding offset, the conditions under which it is reached,
|
|
and the lines of the early returns among them.
|
|
|
|
Each enclosing if adds its condition, and an early return at the top of
|
|
the method adds the condition that does not return. None when a block in
|
|
between is not an if, a using or a try: a loop or an else would need its
|
|
own reading.
|
|
"""
|
|
enclosing: list[str] = []
|
|
for block in sorted((b for b in src.blocks() if b[0] < offset < b[1]), reverse=True):
|
|
header = src.header(block)
|
|
if _METHOD.match(header):
|
|
returns, spans = [], []
|
|
for found in re.finditer(r"\bif \(", src.text[block[0] : offset]):
|
|
paren = block[0] + found.end() - 1
|
|
if src.enclosing(paren) != block:
|
|
continue
|
|
cond = _call_args(src, paren)
|
|
tail = re.compile(r"\s*return\b[^;]*;").match(src.text, paren + len(cond) + 2)
|
|
if tail:
|
|
returns.append(_negate(" ".join(cond.split())))
|
|
spans.append(src.lines(block[0] + found.start(), tail.end()))
|
|
return block, returns + enclosing[::-1], spans
|
|
condition = _IF.fullmatch(header)
|
|
if condition:
|
|
text = " ".join(condition.group(1).split())
|
|
switch = re.fullmatch(
|
|
r'SystemConfig\.isOptSet\("(\w+)"\) && SystemConfig\.getOptBoolean\("\1"\)', text
|
|
)
|
|
enclosing.append(f"{switch.group(1)} on" if switch else text)
|
|
elif not _TRANSPARENT.fullmatch(header):
|
|
return None
|
|
return None
|
|
|
|
|
|
def _config_name(src: Source, method: tuple[int, int], writer: str) -> str:
|
|
"""The file the writer variable opens, read in its method or else in the file."""
|
|
opener = r"(\w+)\s*=\s*(?:\w+\.FromFile|new \w+)\((\w+)"
|
|
declared = {
|
|
m.group(1): "/".join(_strings(m.group(2)))
|
|
for m in re.finditer(
|
|
r'string (\w+) = Path\.Combine\(path, ((?:"[^"]+"(?:, )?)+)\);', src.text
|
|
)
|
|
}
|
|
local = [
|
|
m.group(2)
|
|
for m in re.finditer(opener, src.text[method[0] : method[1]])
|
|
if m.group(1) == writer and m.group(2) in declared
|
|
]
|
|
if local:
|
|
return declared[local[0]]
|
|
files = {declared[m.group(2)] for m in re.finditer(opener, src.text) if m.group(2) in declared}
|
|
if len(files) != 1:
|
|
raise LauncherPatternMissing(src.path, f"configuration file opened as {writer}")
|
|
return files.pop()
|
|
|
|
|
|
def _root_writes(src: Source, emulator: str) -> list[Record]:
|
|
"""Configuration keys that receive the bios folder itself."""
|
|
records = []
|
|
for root in re.finditer(rf"string (\w+) = {_BIOS_ROOT};", src.text):
|
|
root_reach = _conditions(src, root.start())
|
|
if root_reach is None:
|
|
continue
|
|
method = root_reach[0]
|
|
for write in re.compile(
|
|
rf'(\w+)\.WriteValue\("([^"]*)", "([^"]+)", {root.group(1)}\);'
|
|
).finditer(src.text, root.end(), method[1]):
|
|
reached = _conditions(src, write.start())
|
|
if reached is None:
|
|
continue
|
|
writer, section, key = write.groups()
|
|
target = f"[{section}] {key}" if section else key
|
|
records.append(
|
|
_record(
|
|
emulator,
|
|
"standalone",
|
|
"passes_path",
|
|
[],
|
|
source_ref(
|
|
(
|
|
src.path,
|
|
[
|
|
*reached[2],
|
|
src.lines(root.start(), root.end()),
|
|
src.lines(write.start(), write.end()),
|
|
],
|
|
)
|
|
),
|
|
search_dir="bios",
|
|
passes_to=f"{_config_name(src, method, writer)} {target} = bios",
|
|
fails_when_missing=False,
|
|
depends_on_option=", ".join(reached[1]) or None,
|
|
)
|
|
)
|
|
return records
|
|
|
|
|
|
def _guarded_write(src: Source, emulator: str, guard: re.Match[str]) -> Record | None:
|
|
"""The record of one guarded write, None when its shape is another one.
|
|
|
|
The written value must be the checked path itself (quoted or with its
|
|
separators replaced), the path a literal under the bios folder that
|
|
nothing reassigns before the check, and every block around the write an
|
|
if, a using or a try.
|
|
"""
|
|
var, writer, section, key, value = guard.groups()
|
|
if not re.fullmatch(
|
|
rf"(?:{STRING} \+ )?{var}(?:\.Replace\({STRING}, {STRING}\))?(?: \+ {STRING})?",
|
|
value.strip(),
|
|
):
|
|
return None
|
|
reached = _conditions(src, guard.start())
|
|
if reached is None:
|
|
return None
|
|
method, conditions, returns = reached
|
|
decls = list(
|
|
re.compile(
|
|
rf"string {var} = Path\.Combine\((\w+|{_BIOS_ROOT}), ((?:{STRING}, )*{STRING})\);"
|
|
).finditer(src.text, method[0], guard.start())
|
|
)
|
|
if not decls or re.search(rf"\b{var}\s*=[^=]", src.text[decls[-1].end() : guard.start()]):
|
|
return None
|
|
decl = decls[-1]
|
|
root = decl.group(1)
|
|
if not re.fullmatch(_BIOS_ROOT, root) and not re.search(
|
|
rf"string {root} = {_BIOS_ROOT};", src.text[method[0] : decl.start()]
|
|
):
|
|
return None
|
|
*folders, name = _strings(decl.group(2))
|
|
target = f"[{section}] {key}" if section else key
|
|
return _record(
|
|
emulator,
|
|
"standalone",
|
|
"fixed_name",
|
|
[name],
|
|
source_ref(
|
|
(
|
|
src.path,
|
|
[
|
|
*returns,
|
|
src.lines(decl.start(), decl.end()),
|
|
src.lines(guard.start(), guard.end()),
|
|
],
|
|
)
|
|
),
|
|
case_insensitive=True,
|
|
search_dir="/".join(["bios", *folders]),
|
|
passes_to=f"{_config_name(src, method, writer)} {target}, only if the file exists",
|
|
fails_when_missing=f"{key} not written",
|
|
depends_on_option=", ".join(conditions) or None,
|
|
)
|
|
|
|
|
|
def guarded_writes_in(src: Source, emulator: str) -> list[Record]:
|
|
"""Every guarded write of one generator, then the keys given the bios folder."""
|
|
found = [_guarded_write(src, emulator, guard) for guard in _GUARD.finditer(src.text)]
|
|
records = [record for record in found if record is not None]
|
|
if not records:
|
|
raise LauncherPatternMissing(src.path, "File.Exists guarded WriteValue")
|
|
return records + _root_writes(src, emulator)
|
|
|
|
|
|
def guarded_writes(sources: dict[str, Source]) -> list[Record]:
|
|
"""One fixed name per configuration key, written only when the file exists."""
|
|
return [
|
|
record
|
|
for path, emulator in GUARDED_WRITES.items()
|
|
for record in guarded_writes_in(sources[path], emulator)
|
|
]
|
|
|
|
|
|
EXTRACTORS: tuple[Callable[[dict[str, Source]], list[Record]], ...] = (
|
|
amiberry,
|
|
ares,
|
|
duckstation,
|
|
flycast,
|
|
guarded_writes,
|
|
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)]
|