mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
The launcher layer now reads the Saturn emulators whose option replaces the default path, DeSmuME's pair and firmware, the melonDS DSi files and NAND region, jzIntv arguments, BlastEm's SetBiosPath, Dolphin, Raine, GSplus, the DOSBox and capsimg copies, Tsugaru's folder chain, RPCS3, EKA2L1, BizHawk's GB switches and the folders that MAME, FBNeo, Snes9x, Flycast, ScummVM, XRoar, XM6 Pro, ArcadeDuck, Demul and B-Syndrome get. Every line of these generators that names the bios folder must be read by a record or a stated exception, or the scrape stops.
3114 lines
117 KiB
Python
3114 lines
117 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}/ArcadeDuck.Database.cs",
|
|
f"{GENERATORS}/ArcadeDuck.Generator.cs",
|
|
f"{GENERATORS}/Ares.Generator.cs",
|
|
f"{GENERATORS}/Bizhawk.CoreOptions.cs",
|
|
f"{GENERATORS}/Bizhawk.Generator.cs",
|
|
f"{GENERATORS}/Blastem.Generator.cs",
|
|
f"{GENERATORS}/Demul.Generator.cs",
|
|
f"{GENERATORS}/Desmume.Generator.cs",
|
|
f"{GENERATORS}/Dolphin.Generator.cs",
|
|
f"{GENERATORS}/DosBoxPure.Generator.cs",
|
|
f"{GENERATORS}/DosBoxStaging.Generator.cs",
|
|
f"{GENERATORS}/Duckstation.Generator.cs",
|
|
f"{GENERATORS}/Eka2l1.Generator.cs",
|
|
f"{GENERATORS}/Fbneo.Generator.cs",
|
|
f"{GENERATORS}/Flycast.Generator.cs",
|
|
f"{GENERATORS}/GsPlus.Generator.cs",
|
|
f"{GENERATORS}/Hatari.Generator.cs",
|
|
f"{GENERATORS}/Jgenesis.Generator.cs",
|
|
f"{GENERATORS}/Jzintv.Generator.cs",
|
|
f"{GENERATORS}/KegaFusion.Generator.cs",
|
|
f"{GENERATORS}/Kronos.Generator.cs",
|
|
f"{GENERATORS}/LibRetro.CoreOptions.cs",
|
|
f"{GENERATORS}/LibRetro.Generator.cs",
|
|
f"{GENERATORS}/Mame64.Generator.cs",
|
|
f"{GENERATORS}/Mednafen.Generator.cs",
|
|
f"{GENERATORS}/MelonDS.Generator.cs",
|
|
f"{GENERATORS}/Mesen.Generator.cs",
|
|
f"{GENERATORS}/MessSystem.cs",
|
|
f"{GENERATORS}/Mupen64.Generator.cs",
|
|
f"{GENERATORS}/OpenMSX.Generator.cs",
|
|
f"{GENERATORS}/PSXMame.Generator.cs",
|
|
f"{GENERATORS}/Pcsx2.Generator.cs",
|
|
f"{GENERATORS}/PortsLauncher.Configuration.cs",
|
|
f"{GENERATORS}/Project64.Generator.cs",
|
|
f"{GENERATORS}/Raine.Generator.cs",
|
|
f"{GENERATORS}/Rpcs3.Generator.cs",
|
|
f"{GENERATORS}/SSF.Generator.cs",
|
|
f"{GENERATORS}/ScummVm.Generator.cs",
|
|
f"{GENERATORS}/Snes9x.Generator.cs",
|
|
f"{GENERATORS}/Tsugaru.Generator.cs",
|
|
f"{GENERATORS}/Uae.Generator.cs",
|
|
f"{GENERATORS}/Xemu.Generator.cs",
|
|
f"{GENERATORS}/Xm6pro.Generator.cs",
|
|
f"{GENERATORS}/Xroar.Generator.cs",
|
|
f"{GENERATORS}/Yabasanshiro.Generator.cs",
|
|
f"{GENERATORS}/Ymir.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
|
|
self.code: str | None = None
|
|
self.bare: str | 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),
|
|
),
|
|
]
|
|
|
|
|
|
# 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)
|
|
]
|
|
|
|
|
|
# Expression rendering for the generators below: a path expression becomes
|
|
# bios/<folders>/<name>, <x> for AppConfig.GetFullPath("x"), <emulator> for
|
|
# the emulator's own folder, and None when it is built from anything else.
|
|
|
|
_PATH_PARAMS = {"path": "<emulator>", "emulatorPath": "<emulator>", "_path": "<emulator>"}
|
|
_KNOWN = {
|
|
"Path.GetDirectoryName(rom)": "<rom directory>",
|
|
"Path.GetDirectoryName(Path.GetFullPath(rom))": "<rom directory>",
|
|
"rom": "<rom>",
|
|
}
|
|
_WRAPPERS = ("EnsureDirectoryExists",)
|
|
|
|
Rendered = tuple[str, list[tuple[int, int]]]
|
|
|
|
|
|
def _comment_spans(text: str) -> Iterator[tuple[int, int]]:
|
|
"""Offsets of the comments of C# code, string and char literals skipped."""
|
|
i, n = 0, len(text)
|
|
while i < n:
|
|
if text.startswith("//", i):
|
|
end = text.find("\n", i)
|
|
end = n if end < 0 else end
|
|
yield i, end
|
|
i = end
|
|
elif text.startswith("/*", i):
|
|
close = text.find("*/", i + 2)
|
|
end = n if close < 0 else close + 2
|
|
yield i, end
|
|
i = end
|
|
elif 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
|
|
elif text[i] in "\"'":
|
|
quote = text[i]
|
|
i += 1
|
|
while i < n and text[i] != quote and text[i] != "\n":
|
|
i += 2 if text[i] == "\\" else 1
|
|
i += 1
|
|
else:
|
|
i += 1
|
|
|
|
|
|
def _code(src: Source) -> str:
|
|
"""The text with comments blanked, offsets and lines unchanged."""
|
|
if src.code is None:
|
|
chars = list(src.text)
|
|
for start, end in _comment_spans(src.text):
|
|
for i in range(start, end):
|
|
if chars[i] != "\n":
|
|
chars[i] = " "
|
|
src.code = "".join(chars)
|
|
return src.code
|
|
|
|
|
|
def _split_top(text: str, separator: str) -> list[str]:
|
|
"""text split on separator outside strings, parentheses and braces."""
|
|
code = {offset for offset, _ in _code_chars(text)}
|
|
parts, depth, start = [], 0, 0
|
|
for i, char in enumerate(text):
|
|
if i not in code:
|
|
continue
|
|
if char in "([{":
|
|
depth += 1
|
|
elif char in ")]}":
|
|
depth -= 1
|
|
elif depth == 0 and text.startswith(separator, i):
|
|
parts.append(text[start:i].strip())
|
|
start = i + len(separator)
|
|
parts.append(text[start:].strip())
|
|
return parts
|
|
|
|
|
|
def _wrapped(expr: str, opener: str) -> str | None:
|
|
"""The inside of opener(...) when the parentheses span the whole expression."""
|
|
if not expr.startswith(opener + "(") or not expr.endswith(")"):
|
|
return None
|
|
inner = expr[len(opener) + 1 : -1]
|
|
depth = 0
|
|
for offset, char in _code_chars(inner):
|
|
depth += {"(": 1, ")": -1}.get(char, 0)
|
|
if depth < 0:
|
|
return None
|
|
return inner
|
|
|
|
|
|
def _method_of(src: Source, offset: int) -> tuple[int, int]:
|
|
for block in sorted((b for b in src.blocks() if b[0] < offset < b[1]), reverse=True):
|
|
if _METHOD.match(src.header(block)):
|
|
return block
|
|
raise LauncherPatternMissing(src.path, f"method around line {src.line(offset)}")
|
|
|
|
|
|
def _bare(src: Source) -> str:
|
|
"""The code with comments and the inside of literals blanked."""
|
|
if src.bare is None:
|
|
chars = [" " if c != "\n" else c for c in src.text]
|
|
for offset, char in _code_chars(src.text):
|
|
chars[offset] = char
|
|
src.bare = "".join(chars)
|
|
return src.bare
|
|
|
|
|
|
def _statement_start(src: Source, offset: int) -> int:
|
|
code = _bare(src)
|
|
found = max(code.rfind(";", 0, offset), code.rfind("{", 0, offset), code.rfind("}", 0, offset))
|
|
start = found + 1
|
|
while start < offset and code[start].isspace():
|
|
start += 1
|
|
return start
|
|
|
|
|
|
def _statement_end(src: Source, offset: int) -> int:
|
|
"""Offset of the semicolon that ends the statement running at offset."""
|
|
depth = 0
|
|
for position, char in _code_chars(src.text[offset:]):
|
|
if char in "([{":
|
|
depth += 1
|
|
elif char in ")]}":
|
|
depth -= 1
|
|
elif char == ";" and depth == 0:
|
|
return offset + position
|
|
raise LauncherPatternMissing(src.path, f"end of the statement at line {src.line(offset)}")
|
|
|
|
|
|
_ATOM = r"!?[\w.]+(?:\((?:[^()]|\([^()]*\))*\))?"
|
|
|
|
|
|
def _negated(condition: str) -> str:
|
|
"""not condition, written as a C# reader would."""
|
|
if re.fullmatch(_ATOM, condition):
|
|
return condition[1:] if condition.startswith("!") else f"!{condition}"
|
|
return _negate(condition)
|
|
|
|
|
|
def _owner_condition(src: Source, start: int) -> str | None:
|
|
"""Condition of the if whose statement or block ends just before start."""
|
|
code = _code(src)
|
|
before = code[:start].rstrip()
|
|
if before.endswith("}"):
|
|
block = max(b for b in src.blocks() if b[1] == len(before))
|
|
owner = _IF.fullmatch(src.header(block))
|
|
return owner.group(1) if owner else None
|
|
if not before.endswith(";"):
|
|
return None
|
|
owner_start = _statement_start(src, len(before) - 1)
|
|
head = re.match(r"(?:else\s+)?if\s*\(", code[owner_start:])
|
|
if head is None:
|
|
return None
|
|
return " ".join(_call_args(src, owner_start + head.end() - 1).split())
|
|
|
|
|
|
def _braceless_if(src: Source, offset: int) -> str | None:
|
|
"""Condition of an if or else without braces that owns the statement at offset."""
|
|
code = _code(src)
|
|
start = _statement_start(src, offset)
|
|
if re.fullmatch(r"else\s*", code[start:offset]):
|
|
owner = _owner_condition(src, start)
|
|
return _negated(owner) if owner else None
|
|
head = re.search(r"(?:else\s+)?if\s*\(", code[start:offset])
|
|
if head is None or head.start() != 0:
|
|
return None
|
|
paren = start + head.end() - 1
|
|
condition = _call_args(src, paren)
|
|
rest = code[paren + len(condition) + 2 : offset]
|
|
if rest.strip():
|
|
return None
|
|
return " ".join(condition.split())
|
|
|
|
|
|
def _render(src: Source, expr: str, scope: int, before: int) -> Rendered | None:
|
|
"""A path expression as text, with the lines of the declarations it read."""
|
|
expr = " ".join(expr.split())
|
|
if re.fullmatch(_BIOS_ROOT, expr):
|
|
return "bios", []
|
|
changed = re.fullmatch(r'Path\.ChangeExtension\(exe, "(\.\w+)"\)', expr)
|
|
if changed:
|
|
return f"<exe>{changed.group(1)}", []
|
|
named = re.fullmatch(r'(?:Program\.)?AppConfig\.GetFullPath\("([\w.-]+)"\)', expr)
|
|
if named:
|
|
return f"<{named.group(1)}>", []
|
|
if expr in _KNOWN:
|
|
return _KNOWN[expr], []
|
|
replaced = re.fullmatch(rf"(.+)\.Replace\({STRING}, {STRING}\)", expr)
|
|
if replaced:
|
|
return _render(src, replaced.group(1), scope, before)
|
|
for wrapper in _WRAPPERS:
|
|
inner = _wrapped(expr, wrapper)
|
|
if inner is not None:
|
|
return _render(src, inner, scope, before)
|
|
ternary = _split_top(expr, "?")
|
|
if len(ternary) == 2:
|
|
branches = _split_top(ternary[1], ":")
|
|
if len(branches) == 2:
|
|
first = _render(src, branches[0], scope, before)
|
|
second = _render(src, branches[1], scope, before)
|
|
if first is None or second is None:
|
|
return None
|
|
return f"{first[0]} when {ternary[0]}, else {second[0]}", first[1] + second[1]
|
|
terms = _split_top(expr, " + ")
|
|
if len(terms) > 1:
|
|
while terms and terms[0] == '"\\""':
|
|
terms = terms[1:]
|
|
while terms and terms[-1] in ('"\\""', '"\\\\"'):
|
|
terms = terms[:-1]
|
|
pieces, spans = [], []
|
|
for term in terms:
|
|
if term == '";"':
|
|
pieces.append(";")
|
|
continue
|
|
done = _render(src, term, scope, before)
|
|
if done is None:
|
|
return None
|
|
pieces.append(done[0])
|
|
spans += done[1]
|
|
return "".join(pieces), spans
|
|
combine = _wrapped(expr, "Path.Combine")
|
|
if combine is not None:
|
|
base, *rest = _split_top(combine, ",")
|
|
root = _render(src, base, scope, before)
|
|
literals = [re.fullmatch(STRING, part) for part in rest]
|
|
if root is None or not all(literals):
|
|
return None
|
|
return "/".join([root[0], *(_unescape(m.group(1)) for m in literals if m)]), root[1]
|
|
if expr in _PATH_PARAMS:
|
|
return _PATH_PARAMS[expr], []
|
|
if re.fullmatch(r"\w+", expr):
|
|
code = _code(src)
|
|
decls = list(re.compile(rf"(?:string|var)\s+{expr}\s*=\s*").finditer(code, scope, before))
|
|
if not decls:
|
|
return (_PATH_PARAMS[expr], []) if expr in _PATH_PARAMS else None
|
|
decl = decls[-1]
|
|
decl_end = _statement_end(src, decl.end())
|
|
base = _render(src, src.text[decl.end() : decl_end], scope, decl.start())
|
|
if base is None:
|
|
return None
|
|
text, spans = base[0], [*base[1], src.lines(decl.start(), decl_end)]
|
|
for again in re.compile(rf"(?<![\w.]){expr}\s*=(?!=)\s*").finditer(code, decl_end, before):
|
|
again_end = _statement_end(src, again.end())
|
|
value = _render(src, src.text[again.end() : again_end], scope, again.start())
|
|
if value is None:
|
|
return None
|
|
condition = _braceless_if(src, again.start())
|
|
text += f", or {value[0]}" + (f" when {_readable(condition)}" if condition else "")
|
|
spans += [*value[1], src.lines(_statement_start(src, again.start()), again_end)]
|
|
return text, spans
|
|
return None
|
|
|
|
|
|
_OPTION_FORMS = (
|
|
(r'SystemConfig\.isOptSet\("(\w+)"\) && SystemConfig\.getOptBoolean\("\1"\)', r"\1 on"),
|
|
(
|
|
r'SystemConfig\.isOptSet\("(\w+)"\) && !string\.IsNullOrEmpty\(SystemConfig\["\1"\]\)',
|
|
r"\1 set",
|
|
),
|
|
(r'SystemConfig\.getOptBoolean\("(\w+)"\)', r"\1 on"),
|
|
(r'SystemConfig\["(\w+)"\]', r"\1"),
|
|
)
|
|
|
|
|
|
def _readable(condition: str) -> str:
|
|
text = " ".join(condition.split())
|
|
for pattern, replacement in _OPTION_FORMS:
|
|
text = re.sub(pattern, replacement, text)
|
|
return text
|
|
|
|
|
|
# Early returns taken when the emulator itself is not installed.
|
|
_EMULATOR_MISSING = (
|
|
r"!File\.Exists\(exe\)|!Directory\.Exists\(path\)|string\.IsNullOrEmpty\((?:path|exe)\)"
|
|
)
|
|
|
|
|
|
def _when(src: Source, offset: int) -> tuple[list[str], list[tuple[int, int]]]:
|
|
"""Conditions under which the statement at offset runs, outermost first,
|
|
with the lines of the early returns among them.
|
|
|
|
A braceless if, every enclosing if, else and loop up to the method, and
|
|
the early returns at the top of the method.
|
|
"""
|
|
found: list[str] = []
|
|
own = _braceless_if(src, offset)
|
|
if own:
|
|
found.append(_readable(own))
|
|
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 = [], []
|
|
code = _code(src)
|
|
for early in re.finditer(r"\bif \(", code[block[0] : offset]):
|
|
paren = block[0] + early.end() - 1
|
|
if src.enclosing(paren) != block:
|
|
continue
|
|
cond = _call_args(src, paren)
|
|
tail = re.compile(r"\s*return\b[^;]*;").match(code, paren + len(cond) + 2)
|
|
cond = " ".join(cond.split())
|
|
if tail and not re.fullmatch(_EMULATOR_MISSING, cond):
|
|
returns.append(_readable(_negated(cond)))
|
|
spans.append(src.lines(block[0] + early.start(), tail.end()))
|
|
return returns + found[::-1], spans
|
|
condition = _IF.fullmatch(header)
|
|
if condition:
|
|
found.append(_readable(condition.group(1)))
|
|
elif header == "else":
|
|
previous = max(b for b in src.blocks() if b[1] <= block[0])
|
|
owner = _IF.fullmatch(src.header(previous))
|
|
found.append(f"not ({_readable(owner.group(1))})" if owner else "else")
|
|
elif not _TRANSPARENT.fullmatch(header) and not header.startswith(("catch", "lock")):
|
|
found.append(header)
|
|
raise LauncherPatternMissing(src.path, f"method around line {src.line(offset)}")
|
|
|
|
|
|
def _argument_of(src: Source, method: tuple[int, int], parameter: str) -> Rendered | None:
|
|
"""A parameter of method, rendered from the argument its one caller passes."""
|
|
header = src.header(method)
|
|
signature = re.search(r"(\w+)\s*\((.*)\)\s*$", header)
|
|
if signature is None:
|
|
return None
|
|
names = [part.split("=")[0].split()[-1] for part in _split_top(signature.group(2), ",")]
|
|
if parameter not in names:
|
|
return None
|
|
code = _code(src)
|
|
calls = [
|
|
call
|
|
for call in re.finditer(rf"(?<![\w.]){signature.group(1)}\(", code)
|
|
if not _statement_start(src, method[0]) <= call.start() < method[1]
|
|
]
|
|
if len(calls) != 1:
|
|
return None
|
|
arguments = _split_top(_call_args(src, calls[0].end() - 1), ",")
|
|
caller = _method_of(src, calls[0].start())
|
|
return _render(src, arguments[names.index(parameter)], caller[0], calls[0].start())
|
|
|
|
|
|
def _config_label(src: Source, offset: int, writer: str) -> str:
|
|
"""The file the writer variable opens, as a path under the emulator folder."""
|
|
method = _method_of(src, offset)
|
|
code = _code(src)
|
|
for scope in (method, (0, len(code))):
|
|
for opened in re.finditer(
|
|
rf"\b{writer}\s*=\s*(?:\w+\.FromFile|\w+\.Load|new \w+)\(", code[scope[0] : scope[1]]
|
|
):
|
|
paren = scope[0] + opened.end() - 1
|
|
target = _split_top(_call_args(src, paren), ",")[0]
|
|
rendered = _render(src, target, _method_of(src, paren)[0], paren)
|
|
if rendered is None:
|
|
rendered = _argument_of(src, _method_of(src, paren), target)
|
|
if rendered is not None:
|
|
return rendered[0].replace("<emulator>/", "")
|
|
raise LauncherPatternMissing(src.path, f"configuration file opened as {writer}")
|
|
|
|
|
|
def _bios_lines(src: Source) -> list[int]:
|
|
code = _code(src)
|
|
return sorted(
|
|
{
|
|
src.line(m.start())
|
|
for m in re.finditer(r'AppConfig\.GetFullPath\("bios"\)|AppConfig\["bios"\]', code)
|
|
}
|
|
)
|
|
|
|
|
|
def _covered(src: Source, records: list[Record], skip: tuple[str, ...] = ()) -> list[Record]:
|
|
"""records, once every line of src that names the bios folder is in one.
|
|
|
|
A line no record cites and no skip pattern describes is a construct the
|
|
extractors do not read, and stops the scrape.
|
|
"""
|
|
covered: set[int] = set()
|
|
for record in records:
|
|
for part in str(record["source_ref"]).split(", "):
|
|
path, _, span = part.rpartition(":") if ":" in part else (src.path, "", part)
|
|
if path and path != src.path:
|
|
continue
|
|
first, _, last = span.partition("-")
|
|
covered.update(range(int(first), int(last or first) + 1))
|
|
lines = src.text.split("\n")
|
|
for line in _bios_lines(src):
|
|
if line in covered or any(re.search(p, lines[line - 1]) for p in skip):
|
|
continue
|
|
raise LauncherPatternMissing(src.path, f"reader for the bios reference at line {line}")
|
|
return records
|
|
|
|
|
|
def _path_parts(src: Source, *spans: tuple[int, int]) -> str:
|
|
return source_ref((src.path, list(spans)))
|
|
|
|
|
|
def _option_path_in(src: Source, features: Source, emulator: str) -> list[Record]:
|
|
"""A default name under bios that an option replaces before it is written."""
|
|
code = _code(src)
|
|
override = src.find(
|
|
rf"(?:string )?(\w+) = Path\.Combine\({_BIOS_ROOT}, SystemConfig\[\"(\w+)\"\]\);",
|
|
"path taken from an option",
|
|
)
|
|
var, option = override.groups()
|
|
method = _method_of(src, override.start())
|
|
gate = src.find(
|
|
rf'if \(SystemConfig\.isOptSet\("{option}"\)', f"{option} gate", method[0], override.start()
|
|
)
|
|
gate_block = src.block_at(gate.end())
|
|
if not gate_block[0] < override.start() < gate_block[1]:
|
|
raise LauncherPatternMissing(
|
|
src.path, f"{option} block around line {src.line(override.start())}"
|
|
)
|
|
write = re.compile(
|
|
rf'(\w+)\.WriteValue\("([^"]*)", "([^"]+)", ([^;\n]*\b{var}\b[^;\n]*)\);'
|
|
).search(code, override.end(), method[1])
|
|
if write is None:
|
|
raise LauncherPatternMissing(src.path, f"write of {var}")
|
|
writer, section, key = write.group(1), _unescape(write.group(2)), _unescape(write.group(3))
|
|
guarded = re.compile(rf"if \(File\.Exists\({var}\)[^\n]*\)\s*$").search(
|
|
code, method[0], write.start()
|
|
)
|
|
skipped = re.findall(
|
|
rf'SystemConfig\["{option}"\] != "(\w+)"', code[gate.start() : write.end()]
|
|
)
|
|
config = _config_label(src, write.start(), writer)
|
|
target = f"{config} [{section}] {key}" if section else f"{config} {key}"
|
|
suffix = ", only if the file exists" if guarded else ", written without an existence check"
|
|
fails = f"{key} not written" if guarded else "path written without an existence check"
|
|
write_span = src.lines(_statement_start(src, write.start()), write.end())
|
|
choices, choice_lines = feature_choices(features, emulator, option)
|
|
records = []
|
|
default = list(
|
|
re.compile(rf'string {var} = Path\.Combine\({_BIOS_ROOT}, "([^"]+)"\);').finditer(
|
|
code, method[0], gate.start()
|
|
)
|
|
)
|
|
if default:
|
|
records.append(
|
|
_record(
|
|
emulator,
|
|
"standalone",
|
|
"fixed_name",
|
|
[default[-1].group(1)],
|
|
_path_parts(src, src.lines(default[-1].start(), default[-1].end()), write_span),
|
|
case_insensitive=True,
|
|
search_dir="bios",
|
|
passes_to=target + suffix,
|
|
fails_when_missing=fails,
|
|
depends_on_option=f"{option} unset",
|
|
)
|
|
)
|
|
records.append(
|
|
_record(
|
|
emulator,
|
|
"standalone",
|
|
"core_option",
|
|
[c for c in choices if c not in skipped],
|
|
source_ref(
|
|
(src.path, [src.lines(gate.start(), gate_block[1]), write_span]),
|
|
(features.path, [choice_lines]),
|
|
),
|
|
case_insensitive=True,
|
|
search_dir="bios",
|
|
passes_to=f"{target} = <bios>/<value>{suffix}",
|
|
fails_when_missing=fails,
|
|
depends_on_option=option,
|
|
)
|
|
)
|
|
return records + _root_writes(src, emulator)
|
|
|
|
|
|
OPTION_PATHS = {
|
|
f"{GENERATORS}/Kronos.Generator.cs": "kronos",
|
|
f"{GENERATORS}/SSF.Generator.cs": "ssf",
|
|
f"{GENERATORS}/Yabasanshiro.Generator.cs": "yabasanshiro",
|
|
f"{GENERATORS}/Ymir.Generator.cs": "ymir",
|
|
}
|
|
|
|
|
|
def option_paths(sources: dict[str, Source]) -> list[Record]:
|
|
"""Saturn emulators: a default BIOS name an option replaces before the write."""
|
|
return [
|
|
record
|
|
for path, emulator in OPTION_PATHS.items()
|
|
for record in _covered(
|
|
sources[path], _option_path_in(sources[path], sources[FEATURES], emulator)
|
|
)
|
|
]
|
|
|
|
|
|
def _name_of(src: Source, var: str, offset: int) -> tuple[str, str, list[tuple[int, int]]]:
|
|
"""A variable holding bios/<folders>/<name>, as (folder, name, lines)."""
|
|
method = _method_of(src, offset)
|
|
rendered = _render(src, var, method[0], offset)
|
|
if rendered is None or not rendered[0].startswith("bios/") or ", " in rendered[0]:
|
|
raise LauncherPatternMissing(
|
|
src.path, f"bios path held by {var} at line {src.line(offset)}"
|
|
)
|
|
folder, _, name = rendered[0].rpartition("/")
|
|
return folder, name, rendered[1]
|
|
|
|
|
|
def desmume(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Desmume.Generator.cs"]
|
|
code = _code(src)
|
|
pair = src.find(
|
|
r"if \(File\.Exists\((\w+)\) && File\.Exists\((\w+)\)\)\s*\{", "BIOS pair check"
|
|
)
|
|
block = src.block_at(pair.end() - 1)
|
|
keys = {
|
|
value: key
|
|
for key, value in re.findall(
|
|
r'\w+\.WriteValue\("\w+", "(\w+)", (\w+)\);', code[block[0] : block[1]]
|
|
)
|
|
}
|
|
flag = re.search(r'\w+\.WriteValue\("(\w+)", "(\w+)", "1"\);', code[block[0] : block[1]])
|
|
if flag is None or any(var not in keys for var in pair.groups()):
|
|
raise LauncherPatternMissing(src.path, "BIOS pair writes")
|
|
names, spans = [], []
|
|
for var in pair.groups():
|
|
folder, name, lines = _name_of(src, var, pair.start())
|
|
names.append(name)
|
|
spans += lines
|
|
conditions, returns = _when(src, pair.start())
|
|
config = _config_label(src, pair.start(), "ini")
|
|
records = [
|
|
_record(
|
|
"desmume",
|
|
"standalone",
|
|
"fixed_name",
|
|
names,
|
|
_path_parts(src, *returns, *spans, src.lines(pair.start(), block[1])),
|
|
case_insensitive=True,
|
|
search_dir=folder,
|
|
passes_to=f"{config} [{flag.group(1)}] "
|
|
+ ", ".join(keys[var] for var in pair.groups())
|
|
+ ", only if every listed file exists",
|
|
fails_when_missing=f"{flag.group(2)} left at 0",
|
|
depends_on_option=", ".join(conditions) or None,
|
|
)
|
|
]
|
|
firmware = src.find(
|
|
rf'string (\w+) = Path\.Combine\({_BIOS_ROOT}, "([^"]+)"\);\s*'
|
|
r'if \(SystemConfig\.isOptSet\("(\w+)"\)[^\n]*\)\s*\{\s*'
|
|
r'string (\w+) = SystemConfig\["\3"\];\s*if \(File\.Exists\(\4\)\)\s*\1 = \4;\s*\}\s*'
|
|
r"if \(File\.Exists\(\1\)\)\s*\{",
|
|
"firmware default and user file",
|
|
)
|
|
fw_block = src.block_at(firmware.end() - 1)
|
|
fw_write = src.find(
|
|
rf'\w+\.WriteValue\("(\w+)", "(\w+)", {firmware.group(1)}\);',
|
|
"firmware write",
|
|
fw_block[0],
|
|
fw_block[1],
|
|
)
|
|
fw_flag = src.find(
|
|
rf'\w+\.WriteValue\("{fw_write.group(1)}", "(\w+)", "1"\);',
|
|
"firmware flag",
|
|
fw_block[0],
|
|
fw_block[1],
|
|
)
|
|
conditions, returns = _when(src, firmware.start())
|
|
records.append(
|
|
_record(
|
|
"desmume",
|
|
"standalone",
|
|
"fixed_name",
|
|
[firmware.group(2)],
|
|
_path_parts(src, *returns, src.lines(firmware.start(), fw_flag.end())),
|
|
case_insensitive=True,
|
|
search_dir="bios",
|
|
passes_to=f"{config} [{fw_write.group(1)}] {fw_write.group(2)}, only if the file exists",
|
|
fails_when_missing=f"{fw_flag.group(1)} left at 0",
|
|
depends_on_option=", ".join(
|
|
[*conditions, f"{firmware.group(3)} unset or not an existing file"]
|
|
),
|
|
)
|
|
)
|
|
return _covered(src, records)
|
|
|
|
|
|
def melonds_dsi(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/MelonDS.Generator.cs"]
|
|
features = sources[FEATURES]
|
|
code = _code(src)
|
|
check = src.find(
|
|
r"if \(((?:!File\.Exists\(\w+\)(?: \|\| )?)+)\)\s*throw new ApplicationException",
|
|
"DSi files check",
|
|
)
|
|
message, throw_line = _throw_message(src, check.start(), len(src.text))
|
|
method = _method_of(src, check.start())
|
|
variables = re.findall(r"!File\.Exists\((\w+)\)", check.group(1))
|
|
writes = {
|
|
var: key
|
|
for section, key, var in re.findall(
|
|
r'\w+\.WriteValue\("(\w+)", "(\w+)", "\\"" \+ (\w+)\.Replace',
|
|
code[check.end() : method[1]],
|
|
)
|
|
if section == "DSi"
|
|
}
|
|
if set(variables) - set(writes):
|
|
raise LauncherPatternMissing(src.path, "DSi path writes")
|
|
names, spans = [], []
|
|
folders = set()
|
|
for var in variables:
|
|
decl = src.find(
|
|
rf'string {var} = Path\.Combine\((\w+), "([^"]+)"\);',
|
|
f"{var} declaration",
|
|
method[0],
|
|
check.start(),
|
|
)
|
|
root = _render(src, decl.group(1), method[0], decl.start())
|
|
if root is None or not root[0].startswith("bios"):
|
|
raise LauncherPatternMissing(src.path, f"bios folder of {var}")
|
|
folders.add(root[0])
|
|
names.append(decl.group(2))
|
|
spans += [*root[1], src.lines(decl.start(), decl.end())]
|
|
if len(folders) != 1:
|
|
raise LauncherPatternMissing(src.path, "one folder for the DSi files")
|
|
folder = folders.pop()
|
|
last_write = (
|
|
max(
|
|
m.end()
|
|
for m in re.finditer(
|
|
r'\w+\.WriteValue\("DSi", "\w+", [^;]+;', code[check.end() : method[1]]
|
|
)
|
|
)
|
|
+ check.end()
|
|
)
|
|
dsi = src.find(
|
|
r'bool (\w+) = SystemConfig\.isOptSet\("(\w+)"\) && SystemConfig\["\2"\] == "(\w+)";',
|
|
"DSi console option",
|
|
method[0],
|
|
check.start(),
|
|
)
|
|
boot = src.find(
|
|
r'bool (\w+) = Path\.GetExtension\(rom\)\.ToLowerInvariant\(\) == "([^"]+)";',
|
|
"DSi NAND boot extension",
|
|
)
|
|
forced = src.find(
|
|
rf"if \({boot.group(1)}\)\s*{dsi.group(1)} = true;", "NAND boot forces DSi", dsi.end()
|
|
)
|
|
mode = f'{dsi.group(2)} == "{dsi.group(3)}" || rom extension "{boot.group(2)}"'
|
|
region = src.find(
|
|
r'if \(SystemConfig\.isOptSet\("(\w+)"\)[^\n]*\)\s*\{\s*string (\w+) = SystemConfig\["\1"\];\s*'
|
|
r'string (\w+) = Directory\.GetFiles\((\w+), "([^"]+)"\)\s*'
|
|
r"\.FirstOrDefault\(f => Path\.GetFileNameWithoutExtension\(f\)\s*"
|
|
r"\.IndexOf\(\2, StringComparison\.OrdinalIgnoreCase\) >= 0\);\s*"
|
|
r"if \(\3 != null\)\s*(\w+) = \3;",
|
|
"DSi NAND region match",
|
|
method[0],
|
|
check.start(),
|
|
)
|
|
region_block = src.block_at(region.start())
|
|
choices, choice_lines = feature_choices(features, "melonds", region.group(1))
|
|
stem, _, extension = region.group(5).rpartition(".")
|
|
nand_name = names[variables.index(region.group(6))]
|
|
config = _config_label(src, check.start(), "ini")
|
|
records = [
|
|
_record(
|
|
"melonds",
|
|
"standalone",
|
|
"fixed_name",
|
|
names,
|
|
_path_parts(
|
|
src,
|
|
src.lines(boot.start(), boot.end()),
|
|
src.lines(dsi.start(), forced.end()),
|
|
*spans,
|
|
(src.line(check.start()), throw_line),
|
|
src.lines(check.end(), last_write),
|
|
),
|
|
case_insensitive=True,
|
|
search_dir=folder,
|
|
passes_to=f"{config} [DSi] " + ", ".join(writes[v] for v in variables),
|
|
fails_when_missing=message,
|
|
depends_on_option=mode,
|
|
),
|
|
_record(
|
|
"melonds",
|
|
"standalone",
|
|
"scans_dir",
|
|
[f"{stem}{{region}}*.{extension}"],
|
|
source_ref(
|
|
(src.path, [src.statement_span(region_block)]),
|
|
(features.path, [choice_lines]),
|
|
),
|
|
expands={"region": choices},
|
|
case_insensitive=True,
|
|
search_dir=folder,
|
|
passes_to=f"{config} [DSi] {writes[region.group(6)]}, first file whose name holds the region",
|
|
fails_when_missing=f"falls back to {nand_name}",
|
|
depends_on_option=(
|
|
f'{dsi.group(2)} == "{dsi.group(3)}", {region.group(1)} set,'
|
|
f' rom extension not "{boot.group(2)}"'
|
|
),
|
|
),
|
|
]
|
|
return records
|
|
|
|
|
|
def jzintv(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Jzintv.Generator.cs"]
|
|
check = src.find(
|
|
r"if \(!File\.Exists\((\w+)\) \|\| !File\.Exists\((\w+)\)\)\s*throw new ApplicationException",
|
|
"exec and grom check",
|
|
)
|
|
message, throw_line = _throw_message(src, check.start(), len(src.text))
|
|
passed = []
|
|
for var in check.groups():
|
|
add = src.find(
|
|
rf'commandArray\.Add\("(-\w+)"\);\s*commandArray\.Add\("\\"" \+ {var} \+ "\\""\);',
|
|
f"flag passing {var}",
|
|
check.end(),
|
|
)
|
|
folder, name, lines = _name_of(src, var, check.start())
|
|
passed.append((add.start(), add.group(1), name, lines, add.end()))
|
|
passed.sort()
|
|
records = [
|
|
_record(
|
|
"jzintv",
|
|
"standalone",
|
|
"fixed_name",
|
|
[name for _, _, name, _, _ in passed],
|
|
_path_parts(
|
|
src,
|
|
*[span for p in passed for span in p[3]],
|
|
(src.line(check.start()), throw_line),
|
|
src.lines(passed[0][0], passed[-1][4]),
|
|
),
|
|
case_insensitive=True,
|
|
search_dir=folder,
|
|
passes_to="command line "
|
|
+ " ".join(f"{flag} <bios>/{name}" for _, flag, name, _, _ in passed),
|
|
fails_when_missing=message,
|
|
)
|
|
]
|
|
ecs = src.find(
|
|
rf'string (\w+) = Path\.Combine\({_BIOS_ROOT}, "([^"]+)"\);\s*if \(File\.Exists\(\1\)\)\s*\{{\s*'
|
|
r'commandArray\.Add\("(-\w+)"\);',
|
|
"ECS ROM",
|
|
)
|
|
warning = src.find(
|
|
r'Warning\("([^"]+)"\);\s*commandArray\.Add\("(-\w+)"\);', "ECS warning", ecs.end()
|
|
)
|
|
option = src.find(
|
|
r'if \(SystemConfig\.getOptBoolean\("(\w+)"\)\)\s*\{', "ECS option", 0, ecs.start()
|
|
)
|
|
commands = src.find(
|
|
r'string commandFile = rom\.Replace\(Path\.GetExtension\(rom\), "([^"]+)"\);\s*'
|
|
r'if \(!File\.Exists\(commandFile\)\)\s*commandFile = Path\.Combine\(AppConfig\.GetFullPath\("(\w+)"\),\s*"([^"]+)", "([^"]+)"\);\s*'
|
|
r"if \(File\.Exists\(commandFile\)\)",
|
|
"command file lookup",
|
|
)
|
|
records.append(
|
|
_record(
|
|
"jzintv",
|
|
"standalone",
|
|
"fixed_name",
|
|
[ecs.group(2)],
|
|
_path_parts(
|
|
src,
|
|
src.lines(commands.start(), commands.end()),
|
|
src.lines(option.start(), warning.end()),
|
|
),
|
|
case_insensitive=True,
|
|
search_dir="bios",
|
|
passes_to=f"command line {ecs.group(3)} <bios>/{ecs.group(2)}, only if the file exists",
|
|
fails_when_missing=f"warning '{warning.group(1)}', {warning.group(2)} passed",
|
|
depends_on_option=(
|
|
f"{option.group(1)} on, no <rom>{commands.group(1)} and no "
|
|
f"{commands.group(2)}/{commands.group(3)}/{commands.group(4)}"
|
|
),
|
|
)
|
|
)
|
|
return _covered(src, records)
|
|
|
|
|
|
def blastem(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Blastem.Generator.cs"]
|
|
helper = src.find(
|
|
r"private static void SetBiosPath\(\w+ (\w+), string (\w+), string (\w+), string (\w+)\)",
|
|
"SetBiosPath",
|
|
)
|
|
body = src.block_at(helper.end())
|
|
node, key, folder, name = helper.groups()
|
|
src.find(
|
|
rf"string (\w+) = Path\.Combine\({folder}, {name}\);\s*if \(File\.Exists\(\1\)\)\s*{node}\[{key}\] = \1;",
|
|
"SetBiosPath guarded write",
|
|
body[0],
|
|
body[1],
|
|
)
|
|
section = src.find(r'var (\w+) = cfg\.GetOrCreateSection\("(system)"\);', "system section")
|
|
config = src.find(
|
|
r'string targetCfg = Path\.Combine\(path, "([^"]+)"\);', "BlastEm config file"
|
|
)
|
|
records = []
|
|
for call in src.find_all(
|
|
r'SetBiosPath\((\w+), "(\w+)", (\w+), "([^"]+)"\);', "SetBiosPath call"
|
|
):
|
|
root = _render(src, call.group(3), _method_of(src, call.start())[0], call.start())
|
|
if root is None or root[0] != "bios":
|
|
raise LauncherPatternMissing(
|
|
src.path, f"bios folder passed at line {src.line(call.start())}"
|
|
)
|
|
records.append(
|
|
_record(
|
|
"blastem",
|
|
"standalone",
|
|
"fixed_name",
|
|
[call.group(4)],
|
|
_path_parts(
|
|
src,
|
|
*root[1],
|
|
src.lines(call.start(), call.end()),
|
|
(src.line(helper.start()), src.lines(*body)[1]),
|
|
),
|
|
case_insensitive=True,
|
|
search_dir="bios",
|
|
passes_to=f"{config.group(1)} {section.group(2)}.{call.group(2)}, only if the file exists",
|
|
fails_when_missing=f"info log, {call.group(2)} left as it is",
|
|
)
|
|
)
|
|
return _covered(src, records)
|
|
|
|
|
|
def _value_writes(
|
|
src: Source, emulator: str, build: str, start: int = 0, end: int | None = None
|
|
) -> list[Record]:
|
|
"""Configuration values that resolve under the bios folder.
|
|
|
|
A directory becomes passes_path, a file fixed_name, guarded or not by a
|
|
File.Exists on the variable it writes.
|
|
"""
|
|
code = _code(src)
|
|
records = []
|
|
for write in re.compile(r"(\w+)\.WriteValue\(").finditer(
|
|
code, start, len(code) if end is None else end
|
|
):
|
|
args = _split_top(_call_args(src, write.end() - 1), ",")
|
|
if len(args) != 3 or not re.fullmatch(rf"@?{STRING}|null", args[0]):
|
|
continue
|
|
key_literal = re.fullmatch(STRING, args[1])
|
|
if key_literal is None:
|
|
continue
|
|
writer, section, value = write.group(1), args[0].lstrip("@"), args[2]
|
|
key = _unescape(key_literal.group(1))
|
|
method = _method_of(src, write.start())
|
|
rendered = _render(src, value, method[0], write.start())
|
|
if rendered is None or not rendered[0].startswith("bios"):
|
|
continue
|
|
conditions, returns = _when(src, write.start())
|
|
section = _unescape(section[1:-1]) if section != "null" else ""
|
|
target = f"{_config_label(src, write.start(), writer)} " + (
|
|
f"[{section}] {key}" if section else key
|
|
)
|
|
span = src.lines(
|
|
_statement_start(src, write.start()),
|
|
write.end() + len(_call_args(src, write.end() - 1)),
|
|
)
|
|
ref = _path_parts(src, *returns, *rendered[1], span)
|
|
depends = ", ".join(conditions) or None
|
|
folder, _, name = rendered[0].rpartition("/")
|
|
if "." not in name or ", " in rendered[0]:
|
|
records.append(
|
|
_record(
|
|
emulator,
|
|
build,
|
|
"passes_path",
|
|
[],
|
|
ref,
|
|
search_dir=rendered[0],
|
|
passes_to=f"{target} = {rendered[0]}",
|
|
fails_when_missing=False,
|
|
depends_on_option=depends,
|
|
)
|
|
)
|
|
continue
|
|
variable = value.strip()
|
|
guarded = any(c == f"File.Exists({variable})" for c in conditions)
|
|
if guarded:
|
|
depends = ", ".join(c for c in conditions if c != f"File.Exists({variable})") or None
|
|
records.append(
|
|
_record(
|
|
emulator,
|
|
build,
|
|
"fixed_name",
|
|
[name],
|
|
ref,
|
|
case_insensitive=True,
|
|
search_dir=folder,
|
|
passes_to=target
|
|
+ (
|
|
", only if the file exists"
|
|
if guarded
|
|
else ", written without an existence check"
|
|
),
|
|
fails_when_missing=f"{key} not written"
|
|
if guarded
|
|
else "path written without an existence check",
|
|
depends_on_option=depends,
|
|
)
|
|
)
|
|
return records
|
|
|
|
|
|
def dolphin(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Dolphin.Generator.cs"]
|
|
records = _value_writes(src, "dolphin", "standalone")
|
|
if not records:
|
|
raise LauncherPatternMissing(src.path, "GBA BIOS write")
|
|
method = src.find(r"private void CopySegaBoot\(string path\)", "CopySegaBoot")
|
|
body = src.block_at(method.end())
|
|
target = src.find(
|
|
r"string target = (Path\.Combine\(path, [^;]+\));", "segaboot target", body[0], body[1]
|
|
)
|
|
array = src.find(
|
|
r"string\[\] candidates = new string\[\]\s*\{", "segaboot candidates", body[0], body[1]
|
|
)
|
|
block = src.block_at(array.end() - 1)
|
|
names = []
|
|
for item in _split_top(src.text[block[0] + 1 : block[1] - 1].strip(), ","):
|
|
rendered = _render(src, item, body[0], block[0])
|
|
if rendered is None or not rendered[0].startswith("bios/"):
|
|
raise LauncherPatternMissing(src.path, f"segaboot candidate {item}")
|
|
names.append(rendered[0][len("bios/") :])
|
|
src.find(
|
|
r"candidates\.FirstOrDefault\(f => File\.Exists\(f\)\)",
|
|
"first existing candidate",
|
|
block[1],
|
|
body[1],
|
|
)
|
|
warning = src.find(r'Warning\("([^"]+)"\);', "segaboot warning", block[1], body[1])
|
|
src.find(
|
|
r"if \(dst\.Exists && dst\.Length == src\.Length && dst\.LastWriteTimeUtc >= src\.LastWriteTimeUtc\)",
|
|
"up-to-date check",
|
|
block[1],
|
|
body[1],
|
|
)
|
|
call = src.find(r"CopySegaBoot\(path\);", "CopySegaBoot call")
|
|
conditions, returns = _when(src, call.start())
|
|
destination = _render(src, target.group(1), body[0], target.start())
|
|
records.append(
|
|
_record(
|
|
"dolphin",
|
|
"standalone",
|
|
"copies",
|
|
names,
|
|
_path_parts(
|
|
src,
|
|
*returns,
|
|
src.lines(_statement_start(src, call.start()), call.end()),
|
|
src.lines(method.start(), body[1]),
|
|
),
|
|
case_insensitive=True,
|
|
search_dir="bios",
|
|
passes_to=f"first found copied to {destination[0] if destination else target.group(1)},"
|
|
" skipped when the target is as large and not older",
|
|
fails_when_missing=f"warning '{_unescape(warning.group(1))}' when the target is absent too",
|
|
depends_on_option=", ".join(conditions) or None,
|
|
)
|
|
)
|
|
return _covered(src, records, skip=(r"DolphinTool\.exe",))
|
|
|
|
|
|
def raine(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Raine.Generator.cs"]
|
|
records = _value_writes(src, "raine", "standalone")
|
|
if not any(r["kind"] == "fixed_name" for r in records):
|
|
raise LauncherPatternMissing(src.path, "neocd_bios write")
|
|
return _covered(src, records)
|
|
|
|
|
|
def gsplus(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/GsPlus.Generator.cs"]
|
|
records = _value_writes(src, "gsplus", "standalone")
|
|
if not records:
|
|
raise LauncherPatternMissing(src.path, "ROM path write")
|
|
return _covered(src, records)
|
|
|
|
|
|
def _copies_if_absent(
|
|
src: Source, emulator: str, build: str, within: tuple[int, int] | None = None
|
|
) -> list[Record]:
|
|
"""File.Copy(source, target) when the target is absent and the source exists,
|
|
one record per method and target folder."""
|
|
code = _code(src)
|
|
start, end = within or (0, len(code))
|
|
groups: dict[
|
|
tuple[tuple[int, int], str], list[tuple[int, str, str, list[tuple[int, int]]]]
|
|
] = {}
|
|
for copy in re.compile(
|
|
r"if \(!File\.Exists\((\w+)\) && File\.Exists\((\w+)\)\)\s*(?:\{\s*)?try \{ File\.Copy\(\2, \1\); \}"
|
|
).finditer(code, start, end):
|
|
target_var, source_var = copy.groups()
|
|
method = _method_of(src, copy.start())
|
|
source = _render(src, source_var, method[0], copy.start())
|
|
target = _render(src, target_var, method[0], copy.start())
|
|
if source is None or target is None or not source[0].startswith("bios/"):
|
|
raise LauncherPatternMissing(src.path, f"copy at line {src.line(copy.start())}")
|
|
folder, _, name = source[0].rpartition("/")
|
|
destination = target[0].rpartition("/")[0]
|
|
spans = [*source[1], *target[1], src.lines(copy.start(), copy.end())]
|
|
groups.setdefault((method, destination), []).append((copy.start(), folder, name, spans))
|
|
records = []
|
|
for (_, destination), copies in groups.items():
|
|
folders = {folder for _, folder, _, _ in copies}
|
|
if len(folders) != 1:
|
|
raise LauncherPatternMissing(src.path, f"one source folder for {destination}")
|
|
conditions, returns = _when(src, copies[0][0])
|
|
records.append(
|
|
_record(
|
|
emulator,
|
|
build,
|
|
"copies",
|
|
[name for _, _, name, _ in copies],
|
|
_path_parts(src, *returns, *[s for c in copies for s in c[3]]),
|
|
case_insensitive=True,
|
|
search_dir=folders.pop(),
|
|
passes_to=f"copied to {destination}/<name>, only when the target does not exist",
|
|
fails_when_missing=False,
|
|
depends_on_option=", ".join(conditions) or None,
|
|
)
|
|
)
|
|
if not records:
|
|
raise LauncherPatternMissing(src.path, "copy when the target is absent")
|
|
return records
|
|
|
|
|
|
def dosbox_copies(sources: dict[str, Source]) -> list[Record]:
|
|
"""MT-32 ROMs and SoundFonts copied from bios/scummvm/extra."""
|
|
return [
|
|
record
|
|
for path, emulator in (
|
|
(f"{GENERATORS}/DosBoxPure.Generator.cs", "dosbox-pure"),
|
|
(f"{GENERATORS}/DosBoxStaging.Generator.cs", "dosbox-staging"),
|
|
)
|
|
for record in _covered(
|
|
sources[path], _copies_if_absent(sources[path], emulator, "standalone")
|
|
)
|
|
]
|
|
|
|
|
|
def capsimg(sources: dict[str, Source]) -> list[Record]:
|
|
"""capsimg.dll copied next to RetroArch for the cores that read .ipf images."""
|
|
src = sources[f"{GENERATORS}/LibRetro.Generator.cs"]
|
|
cores = src.find(
|
|
r"static readonly List<string> (capsimgCore) = new List<string>\(\) \{", "capsimg core list"
|
|
)
|
|
names, core_lines = _array_at(src, cores.end() - 1)
|
|
gate = src.find(
|
|
rf"if \(core != null && rom != null && {cores.group(1)}\.Contains\(core\) && "
|
|
r'Path\.GetExtension\(rom\)\.ToLowerInvariant\(\) == "([^"]+)"\)',
|
|
"capsimg gate",
|
|
)
|
|
block = src.block_at(gate.end())
|
|
[record] = _copies_if_absent(src, ", ".join(names), "libretro", block)
|
|
record["source_ref"] = source_ref(
|
|
(src.path, [(src.line(cores.start()), core_lines[1]), src.statement_span(block)])
|
|
)
|
|
record["depends_on_option"] = f'core in {cores.group(1)}, rom extension "{gate.group(1)}"'
|
|
return [record]
|
|
|
|
|
|
def tsugaru(sources: dict[str, Source]) -> list[Record]:
|
|
"""The FM Towns ROM folder, chosen by where FMT_SYS.ROM is."""
|
|
src = sources[f"{GENERATORS}/Tsugaru.Generator.cs"]
|
|
code = _code(src)
|
|
first = src.find(
|
|
rf'if \(Directory\.Exists\((Path\.Combine\({_BIOS_ROOT}, "[^"]+"\))\)\)\s*(\w+) = \1;',
|
|
"first FM Towns folder",
|
|
)
|
|
var = first.group(2)
|
|
method = _method_of(src, first.start())
|
|
unset = src.find(rf"string {var} = null;", f"{var} declaration", method[0], first.start())
|
|
folders = [_render(src, first.group(1), method[0], first.start())]
|
|
names, end = set(), first.end()
|
|
step = re.compile(
|
|
rf'\s*if \(!File\.Exists\(Path\.Combine\({var}, "([^"]+)"\)\)\)\s*{var} = ([^;]+);'
|
|
)
|
|
while more := step.match(code, end):
|
|
names.add(more.group(1))
|
|
folders.append(_render(src, more.group(2), method[0], more.start()))
|
|
end = more.end()
|
|
final = re.compile(
|
|
rf'\s*if \(!File\.Exists\(Path\.Combine\({var}, "([^"]+)"\)\)\)\s*\{{'
|
|
).match(code, end)
|
|
if final is None or any(folder is None for folder in folders):
|
|
raise LauncherPatternMissing(src.path, "FMT_SYS.ROM folder chain")
|
|
names.add(final.group(1))
|
|
block = src.block_at(final.end() - 1)
|
|
info = re.search(r'Info\("([^"]+)"\);\s*return null;', code[block[0] : block[1]])
|
|
cmos = src.find(
|
|
rf'commandArray\.Add\({var}\);\s*commandArray\.Add\("(-\w+)"\);\s*'
|
|
rf'commandArray\.Add\(Path\.Combine\({var}, "([^"]+)"\)\);',
|
|
"folder and CMOS arguments",
|
|
block[1],
|
|
method[1],
|
|
)
|
|
if info is None or len(names) != 1:
|
|
raise LauncherPatternMissing(src.path, "FMT_SYS.ROM missing branch")
|
|
record = _record(
|
|
"tsugaru",
|
|
"standalone",
|
|
"passes_path",
|
|
sorted(names),
|
|
_path_parts(
|
|
src, src.lines(unset.start(), unset.end()), src.lines(first.start(), cmos.end())
|
|
),
|
|
case_insensitive=True,
|
|
search_dir=", then ".join(folder[0] for folder in folders if folder),
|
|
passes_to=(
|
|
"command line: first argument <folder found>,"
|
|
f" {cmos.group(1)} <folder found>/{cmos.group(2)}"
|
|
),
|
|
fails_when_missing=f"info '{info.group(1)}', no launch",
|
|
depends_on_option=(
|
|
f"{folders[0][0] if folders[0] else ''} is a directory"
|
|
f" ({var} stays null otherwise when {final.group(1)} is checked)"
|
|
),
|
|
)
|
|
return _covered(src, [record])
|
|
|
|
|
|
def rpcs3(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Rpcs3.Generator.cs"]
|
|
marker = src.find(
|
|
rf"string (\w+) = (Path\.Combine\(path, [^;]+\));\s*string \w+ = {_BIOS_ROOT};",
|
|
"installed firmware marker",
|
|
)
|
|
pup = src.find(
|
|
rf'string (\w+) = {_BIOS_ROOT};\s*string (\w+) = Path\.Combine\(\1, "([^"]+)"\);',
|
|
"PS3 update file",
|
|
marker.start(),
|
|
)
|
|
method = _method_of(src, pup.start())
|
|
check = src.find(
|
|
rf"if \(!File\.Exists\({marker.group(1)}\) && !File\.Exists\({pup.group(2)}\)\)\s*throw",
|
|
"firmware check",
|
|
pup.end(),
|
|
)
|
|
message, _ = _throw_message(src, check.start(), method[1])
|
|
install = src.find(
|
|
rf'"(--\w+)",\s*StringExtensions\.QuoteString\({pup.group(2)}\)',
|
|
"firmware install argument",
|
|
check.end(),
|
|
)
|
|
returned = src.find(r"return new ProcessStartInfo\(\)", "install launch", install.end())
|
|
installed = _render(src, marker.group(2), method[0], marker.start())
|
|
record = _record(
|
|
"rpcs3",
|
|
"standalone",
|
|
"fixed_name",
|
|
[pup.group(3)],
|
|
_path_parts(src, src.lines(marker.start(), src.block_at(returned.end())[1])),
|
|
case_insensitive=True,
|
|
search_dir="bios",
|
|
passes_to=(
|
|
f"command line {install.group(1)} <bios>/{pup.group(3)}, launched instead of the game"
|
|
),
|
|
fails_when_missing=message,
|
|
depends_on_option=f"{installed[0] if installed else marker.group(2)} absent",
|
|
)
|
|
return _covered(src, [record])
|
|
|
|
|
|
def _indexer_writes(src: Source, emulator: str, writer: str) -> list[Record]:
|
|
"""writer["key"] = value, for each value that resolves to a folder under bios."""
|
|
code = _code(src)
|
|
records = []
|
|
for write in re.compile(rf'\b{writer}\["([^"]+)"\] = ([^;]+);').finditer(code):
|
|
method = _method_of(src, write.start())
|
|
rendered = _render(src, src.text[write.start(2) : write.end(2)], method[0], write.start())
|
|
if rendered is None or not rendered[0].startswith("bios"):
|
|
continue
|
|
conditions, returns = _when(src, write.start())
|
|
records.append(
|
|
_record(
|
|
emulator,
|
|
"standalone",
|
|
"passes_path",
|
|
[],
|
|
_path_parts(src, *returns, *rendered[1], src.lines(write.start(), write.end())),
|
|
search_dir=rendered[0],
|
|
passes_to=(
|
|
f"{_config_label(src, write.start(), writer)} {write.group(1)} = {rendered[0]}"
|
|
),
|
|
fails_when_missing=False,
|
|
depends_on_option=", ".join(conditions) or None,
|
|
)
|
|
)
|
|
if not records:
|
|
raise LauncherPatternMissing(src.path, f"{writer}[...] write of a bios folder")
|
|
return records
|
|
|
|
|
|
def eka2l1(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Eka2l1.Generator.cs"]
|
|
return _covered(src, _indexer_writes(src, "eka2l1", "yml"))
|
|
|
|
|
|
def fbneo(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Fbneo.Generator.cs"]
|
|
return _covered(src, _indexer_writes(src, "fbneo", "cfg"))
|
|
|
|
|
|
def bizhawk_gb(sources: dict[str, Source]) -> list[Record]:
|
|
"""Game Boy BIOS switches: the core reads the file only when the launcher saw it."""
|
|
src = sources[f"{GENERATORS}/Bizhawk.CoreOptions.cs"]
|
|
config = sources[f"{GENERATORS}/Bizhawk.Generator.cs"].find(
|
|
r'string configFile = Path\.Combine\(path, "([^"]+)"\);', "BizHawk config file"
|
|
)
|
|
code = _code(src)
|
|
records = []
|
|
for flag in src.find_all(
|
|
r'if \(File\.Exists\((\w+)\) && SystemConfig\.getOptBoolean\("(\w+)"\)\)\s*'
|
|
r'(\w+)\["(\w+)"\] = "(\w+)";\s*else\s*\3\["\4"\] = "(\w+)";',
|
|
"GB BIOS switch",
|
|
):
|
|
var, option, node, key, on, off = flag.groups()
|
|
method = _method_of(src, flag.start())
|
|
core = src.find(r'if \(core != "(\w+)"\)\s*return;', "core guard", method[0], flag.start())
|
|
container = src.find(
|
|
rf'var {node} = \w+\.GetOrCreateContainer\("([^"]+)"\);',
|
|
"sync settings container",
|
|
method[0],
|
|
flag.start(),
|
|
)
|
|
default = src.find(
|
|
rf'string {var} = Path\.Combine\({_BIOS_ROOT}, "([^"]+)"\);',
|
|
"default GB BIOS",
|
|
method[0],
|
|
flag.start(),
|
|
)
|
|
chosen = list(
|
|
re.compile(
|
|
rf'(?:else )?if \(system == "(\w+)"\)\s*'
|
|
rf'{var} = Path\.Combine\({_BIOS_ROOT}, "([^"]+)"\);'
|
|
).finditer(code, default.end(), flag.start())
|
|
)
|
|
if code[default.end() : flag.start()].count(f"{var} =") != len(chosen):
|
|
raise LauncherPatternMissing(
|
|
src.path, f"{var} choice at line {src.line(default.start())}"
|
|
)
|
|
ref = _path_parts(
|
|
src, src.lines(core.start(), core.end()), src.lines(default.start(), flag.end())
|
|
)
|
|
others = " && ".join(f'system != "{m.group(1)}"' for m in chosen)
|
|
slots = [("other systems", default.group(1), others)]
|
|
slots += [(m.group(1), m.group(2), f'system == "{m.group(1)}"') for m in chosen]
|
|
for slot, name, system in slots:
|
|
records.append(
|
|
_record(
|
|
"bizhawk",
|
|
"standalone",
|
|
"fixed_name",
|
|
[name],
|
|
ref,
|
|
slot=slot,
|
|
case_insensitive=True,
|
|
search_dir="bios",
|
|
passes_to=(
|
|
f"{config.group(1)} {container.group(1)} {key} = {on},"
|
|
" only if the file exists, the path itself is not written"
|
|
),
|
|
fails_when_missing=f"{key} = {off}",
|
|
depends_on_option=", ".join(
|
|
c for c in (f'core == "{core.group(1)}"', f"{option} on", system) if c
|
|
),
|
|
)
|
|
)
|
|
return _covered(
|
|
src,
|
|
records,
|
|
# A DSi NAND boot copies the game itself to bios/dsi_nand.bin.
|
|
skip=(r'string biosPath = AppConfig\.GetFullPath\("bios"\);',),
|
|
)
|
|
|
|
|
|
def _argument_paths(src: Source, emulator: str, build: str) -> list[tuple[Record, list[str]]]:
|
|
"""commandArray.Add(<path under bios>) after its flag, with its conditions."""
|
|
code = _code(src)
|
|
found = []
|
|
for add in re.finditer(r"commandArray\.Add\(", code):
|
|
expr = _call_args(src, add.end() - 1)
|
|
end = add.end() + len(expr) + 1
|
|
if re.fullmatch(STRING, expr.strip()):
|
|
continue
|
|
method = _method_of(src, add.start())
|
|
rendered = _render(src, expr, method[0], add.start())
|
|
if rendered is None or not rendered[0].startswith("bios"):
|
|
continue
|
|
flags = list(re.finditer(r'"(-[\w-]+)"', code[method[0] : add.start()]))
|
|
if not flags:
|
|
raise LauncherPatternMissing(src.path, f"flag before line {src.line(add.start())}")
|
|
flag_at = method[0] + flags[-1].start()
|
|
conditions, returns = _when(src, add.start())
|
|
found.append(
|
|
(
|
|
_record(
|
|
emulator,
|
|
build,
|
|
"passes_path",
|
|
[],
|
|
_path_parts(
|
|
src,
|
|
*returns,
|
|
*rendered[1],
|
|
src.lines(flag_at, flag_at + 1),
|
|
src.lines(_statement_start(src, add.start()), end),
|
|
),
|
|
search_dir=rendered[0],
|
|
passes_to=f"command line {flags[-1].group(1)} {rendered[0]}",
|
|
fails_when_missing=False,
|
|
),
|
|
conditions,
|
|
)
|
|
)
|
|
return found
|
|
|
|
|
|
def _with_conditions(record: Record, conditions: list[str]) -> Record:
|
|
out = {key: value for key, value in record.items() if key != "source_ref"}
|
|
if conditions:
|
|
out["depends_on_option"] = ", ".join(conditions)
|
|
out["source_ref"] = record["source_ref"]
|
|
return out
|
|
|
|
|
|
_BIOS_GUARD = r'string\.IsNullOrEmpty\(AppConfig\["bios"\]\)'
|
|
|
|
|
|
def mame(sources: dict[str, Source]) -> list[Record]:
|
|
"""MAME folders: the standalone build's own, and those of the MESS systems
|
|
that the standalone build and the libretro core share."""
|
|
own = sources[f"{GENERATORS}/Mame64.Generator.cs"]
|
|
found = _argument_paths(own, "mame64", "standalone")
|
|
if not found:
|
|
raise LauncherPatternMissing(own.path, "MAME folder arguments")
|
|
records = _covered(
|
|
own,
|
|
[_with_conditions(record, conditions) for record, conditions in found],
|
|
# The bezel artwork folder is read by the launcher, not passed.
|
|
skip=(_BIOS_GUARD, r"ConfigureBezels\("),
|
|
)
|
|
mess = sources[f"{GENERATORS}/MessSystem.cs"]
|
|
found = _argument_paths(mess, "mame64", "standalone")
|
|
if not found:
|
|
raise LauncherPatternMissing(mess.path, "MESS folder arguments")
|
|
shared = []
|
|
for record, conditions in found:
|
|
if "standalone" in conditions:
|
|
shared.append(_with_conditions(record, [c for c in conditions if c != "standalone"]))
|
|
continue
|
|
shared.append(_with_conditions(record, conditions))
|
|
libretro = dict(record, emulator="mame", build="libretro", names=[])
|
|
shared.append(_with_conditions(libretro, conditions))
|
|
return records + _covered(
|
|
mess,
|
|
shared,
|
|
# The softlist file and the Lua autoboot script are named after the game.
|
|
skip=(
|
|
_BIOS_GUARD,
|
|
r'"hash", (?:SystemConfig\["force_softlist"\]|romMedia) \+ "\.xml"',
|
|
r'"autoboot_scripts"',
|
|
),
|
|
)
|
|
|
|
|
|
def psxmame(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/PSXMame.Generator.cs"]
|
|
found = _argument_paths(src, "psxmame", "standalone")
|
|
if not found:
|
|
raise LauncherPatternMissing(src.path, "rompath argument")
|
|
return _covered(src, [_with_conditions(record, conditions) for record, conditions in found])
|
|
|
|
|
|
def xroar(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Xroar.Generator.cs"]
|
|
found = _argument_paths(src, "xroar", "standalone")
|
|
if not found:
|
|
raise LauncherPatternMissing(src.path, "rompath argument")
|
|
records = [_with_conditions(record, conditions) for record, conditions in found]
|
|
line = src.find(r'string (\w+) = ("rompath (?:[^"\\\n]|\\.)*");', "config rompath line")
|
|
written = _unescape(line.group(2)[1:-1])
|
|
relative = re.fullmatch(r'rompath "(.*)"', written)
|
|
method = _method_of(src, line.start())
|
|
setup = src.find(r'string setupPath = Path\.Combine\(path, "([^"]+)"\);', "xroar config file")
|
|
use = src.find(rf"lines\[i\] = {line.group(1)};", "rompath replacement", line.end(), method[1])
|
|
parts = [p for p in re.split(r"\\+", relative.group(1) if relative else "") if p]
|
|
if "bios" not in parts:
|
|
raise LauncherPatternMissing(src.path, "bios in the config rompath")
|
|
records.append(
|
|
_record(
|
|
"xroar",
|
|
"standalone",
|
|
"passes_path",
|
|
[],
|
|
_path_parts(
|
|
src, src.lines(line.start(), line.end()), src.lines(use.start(), use.end())
|
|
),
|
|
search_dir="/".join(parts[parts.index("bios") :]),
|
|
passes_to=f"{setup.group(1)} {written}",
|
|
fails_when_missing=False,
|
|
)
|
|
)
|
|
return _covered(src, records)
|
|
|
|
|
|
def snes9x(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Snes9x.Generator.cs"]
|
|
entry = src.find(rf'\{{ "([^"]+)", (Path\.Combine\({_BIOS_ROOT}\)) \}}', "bios folder entry")
|
|
loop = src.find(
|
|
r"foreach \(KeyValuePair<string, string> pair in (\w+)\)", "path loop", entry.end()
|
|
)
|
|
body = src.block_at(loop.end())
|
|
write = src.find(
|
|
r'(\w+)\.WriteValue\(@?"([^"]+)", pair\.Key, pair\.Value\);',
|
|
"path write",
|
|
body[0],
|
|
body[1],
|
|
)
|
|
config = _config_label(src, write.start(), write.group(1))
|
|
record = _record(
|
|
"snes9x",
|
|
"standalone",
|
|
"passes_path",
|
|
[],
|
|
_path_parts(src, src.lines(entry.start(), entry.end()), src.lines(loop.start(), body[1])),
|
|
search_dir="bios",
|
|
passes_to=f"{config} [{write.group(2)}] {entry.group(1)} = bios",
|
|
fails_when_missing=False,
|
|
)
|
|
return _covered(src, [record])
|
|
|
|
|
|
def value_folders(sources: dict[str, Source]) -> list[Record]:
|
|
"""Configuration keys given a folder under bios."""
|
|
records = []
|
|
for path, emulator, skip in (
|
|
# VMU saves are synchronised with the libretro core's bios/dc.
|
|
(f"{GENERATORS}/Flycast.Generator.cs", "flycast", (r"string libretroPath = ",)),
|
|
(f"{GENERATORS}/ScummVm.Generator.cs", "scummvm", ()),
|
|
):
|
|
found = _value_writes(sources[path], emulator, "standalone")
|
|
if not found:
|
|
raise LauncherPatternMissing(path, "bios folder write")
|
|
records += _covered(sources[path], found, skip=skip)
|
|
return records
|
|
|
|
|
|
def demul(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Demul.Generator.cs"]
|
|
listing = src.find(r"var (\w+) = new List<string>\s*\{", "rom paths list")
|
|
block = src.block_at(listing.end() - 1)
|
|
loop = src.find(
|
|
rf"for \(int i = 0; i < {listing.group(1)}\.Count; i\+\+\)\s*"
|
|
rf'(\w+)\.WriteValue\("(\w+)", "(\w+)" \+ i, {listing.group(1)}\[i\]\);',
|
|
"rom paths write",
|
|
block[1],
|
|
)
|
|
method = _method_of(src, listing.start())
|
|
config = _config_label(src, loop.start(), loop.group(1))
|
|
records = []
|
|
for index, item in enumerate(_split_top(src.text[block[0] + 1 : block[1] - 1].strip(), ",")):
|
|
rendered = _render(src, item, method[0], listing.start())
|
|
if rendered is None:
|
|
raise LauncherPatternMissing(src.path, f"rom path {item}")
|
|
if not rendered[0].startswith("bios"):
|
|
continue
|
|
records.append(
|
|
_record(
|
|
"demul",
|
|
"standalone",
|
|
"passes_path",
|
|
[],
|
|
_path_parts(
|
|
src,
|
|
*rendered[1],
|
|
src.lines(listing.start(), block[1]),
|
|
src.lines(loop.start(), loop.end()),
|
|
),
|
|
search_dir=rendered[0],
|
|
passes_to=f"{config} [{loop.group(2)}] {loop.group(3)}{index} = {rendered[0]}",
|
|
fails_when_missing=False,
|
|
)
|
|
)
|
|
return _covered(src, records)
|
|
|
|
|
|
def bsyndrome(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/PortsLauncher.Configuration.cs"]
|
|
records = []
|
|
for arg in src.find_all(
|
|
r'string (\w+) = "(--\w+)=\\"" \+ (\w+) \+ "\\"";\s*commandArray\.Add\(\1\);',
|
|
"path argument",
|
|
):
|
|
method = _method_of(src, arg.start())
|
|
rendered = _render(src, arg.group(3), method[0], arg.start())
|
|
if rendered is None or not rendered[0].startswith("bios"):
|
|
continue
|
|
conditions, returns = _when(src, arg.start())
|
|
records.append(
|
|
_record(
|
|
"bsyndrome",
|
|
"standalone",
|
|
"passes_path",
|
|
[],
|
|
_path_parts(src, *returns, *rendered[1], src.lines(arg.start(), arg.end())),
|
|
search_dir=rendered[0],
|
|
passes_to=f"command line {arg.group(2)}={rendered[0]}",
|
|
fails_when_missing=False,
|
|
depends_on_option=", ".join(conditions) or None,
|
|
)
|
|
)
|
|
if not records:
|
|
raise LauncherPatternMissing(src.path, "bios folder argument")
|
|
return _covered(src, records)
|
|
|
|
|
|
def xm6pro(sources: dict[str, Source]) -> list[Record]:
|
|
src = sources[f"{GENERATORS}/Xm6pro.Generator.cs"]
|
|
method = src.find(r"private void CopyBiosFiles\(string (\w+)\)", "CopyBiosFiles")
|
|
body = src.block_at(method.end())
|
|
loop = src.find(r"foreach \(string (\w+) in (\w+)\)", "copy loop", body[0], body[1])
|
|
table = src.find(
|
|
rf"static readonly string\[\] {loop.group(2)} = new string\[\] \{{", "X68000 BIOS list"
|
|
)
|
|
names, lines = _array_at(src, table.end() - 1)
|
|
copy = src.find(
|
|
rf"string (\w+) = Path\.Combine\((\w+), {loop.group(1)}\);\s*"
|
|
rf"string (\w+) = Path\.Combine\({method.group(1)}, {loop.group(1)}\);\s*"
|
|
r"if \(File\.Exists\(\1\) && !File\.Exists\(\3\)\)",
|
|
"copy when the target is absent",
|
|
loop.end(),
|
|
body[1],
|
|
)
|
|
folder = _render(src, copy.group(2), body[0], copy.start())
|
|
if folder is None or not folder[0].startswith("bios/"):
|
|
raise LauncherPatternMissing(src.path, "keropi folder")
|
|
conditions, _ = _when(src, loop.start())
|
|
record = _record(
|
|
"xm6pro",
|
|
"standalone",
|
|
"copies",
|
|
names,
|
|
_path_parts(src, (src.line(table.start()), lines[1]), src.lines(method.start(), body[1])),
|
|
case_insensitive=True,
|
|
search_dir=folder[0],
|
|
passes_to="copied to <emulator>/<name>, only when the target does not exist",
|
|
fails_when_missing=False,
|
|
depends_on_option=", ".join(conditions) or None,
|
|
)
|
|
return _covered(src, [record])
|
|
|
|
|
|
def arcadeduck(sources: dict[str, Source]) -> list[Record]:
|
|
"""The BIOS archive ArcadeDuck's own database names for the game's system."""
|
|
src = sources[f"{GENERATORS}/ArcadeDuck.Generator.cs"]
|
|
db = sources[f"{GENERATORS}/ArcadeDuck.Database.cs"]
|
|
database = db.find(
|
|
r'string dbFile = Path\.Combine\(emulatorPath, ((?:"[^"]+"(?:, )?)+)\);', "database file"
|
|
)
|
|
lookup = db.find(r"public string GetBiosArchive\(string systemId\)", "GetBiosArchive")
|
|
lookup_body = db.block_at(lookup.end())
|
|
fixed = db.find(
|
|
r'if \(systemId == "(\w+)"\)\s*return "([^"]+)";',
|
|
"built-in archive",
|
|
lookup_body[0],
|
|
lookup_body[1],
|
|
)
|
|
method = src.find(r"private string GetBiosPath\(string rom, string biosRoot\)", "GetBiosPath")
|
|
body = src.block_at(method.end())
|
|
src.find(
|
|
r"var candidates = new List<string> \{ Path\.GetDirectoryName\(Path\.GetFullPath\(rom\)\) \};",
|
|
"game folder first",
|
|
body[0],
|
|
body[1],
|
|
)
|
|
src.find(r"candidates\.Add\(biosRoot\);", "bios folder second", body[0], body[1])
|
|
message, _ = _throw_message(src, body[0], body[1])
|
|
root = src.find(rf"string biosPath = {_BIOS_ROOT};", "bios root")
|
|
write = src.find(r'ini\.WriteValue\("(\w+)", "(\w+)", biosPath\);', "SearchDirectory write")
|
|
config = _config_label(src, write.start(), "ini")
|
|
target = f"{config} [{write.group(1)}] {write.group(2)}"
|
|
database_file = "/".join(_strings(database.group(1)))
|
|
ref = source_ref(
|
|
(
|
|
src.path,
|
|
[
|
|
src.lines(root.start(), root.end()),
|
|
src.lines(method.start(), body[1]),
|
|
src.lines(write.start(), write.end()),
|
|
],
|
|
),
|
|
(
|
|
db.path,
|
|
[db.lines(database.start(), database.end()), db.lines(fixed.start(), fixed.end())],
|
|
),
|
|
)
|
|
records = [
|
|
_record(
|
|
"arcadeduck",
|
|
"standalone",
|
|
"names_searched",
|
|
[f"<bios archive of the game's system in {database_file}>"],
|
|
ref,
|
|
case_insensitive=False,
|
|
search_dir="<rom directory>, then bios",
|
|
passes_to=f"{target} = <folder found>",
|
|
fails_when_missing=message,
|
|
),
|
|
_record(
|
|
"arcadeduck",
|
|
"standalone",
|
|
"names_searched",
|
|
[fixed.group(2)],
|
|
ref,
|
|
slot=fixed.group(1),
|
|
case_insensitive=False,
|
|
search_dir="<rom directory>, then bios",
|
|
passes_to=f"{target} = <folder found>",
|
|
fails_when_missing=message,
|
|
depends_on_option=f"{database_file} names no archive for {fixed.group(1)}",
|
|
),
|
|
_record(
|
|
"arcadeduck",
|
|
"standalone",
|
|
"passes_path",
|
|
[],
|
|
ref,
|
|
search_dir="bios",
|
|
passes_to=f"{target} = bios",
|
|
fails_when_missing=False,
|
|
depends_on_option="no bios archive known for the game's system",
|
|
),
|
|
]
|
|
return _covered(src, records)
|
|
|
|
|
|
EXTRACTORS: tuple[Callable[[dict[str, Source]], list[Record]], ...] = (
|
|
amiberry,
|
|
arcadeduck,
|
|
ares,
|
|
bizhawk_gb,
|
|
blastem,
|
|
bsyndrome,
|
|
capsimg,
|
|
demul,
|
|
desmume,
|
|
dolphin,
|
|
dosbox_copies,
|
|
duckstation,
|
|
eka2l1,
|
|
fbneo,
|
|
gsplus,
|
|
guarded_writes,
|
|
hatari,
|
|
hatari_libretro,
|
|
jzintv,
|
|
mame,
|
|
melonds_dsi,
|
|
mesen,
|
|
openmsx,
|
|
option_paths,
|
|
pcsx2,
|
|
pcsx2_libretro,
|
|
psx_cascades,
|
|
psxmame,
|
|
raine,
|
|
rpcs3,
|
|
snes9x,
|
|
tsugaru,
|
|
value_folders,
|
|
winuae,
|
|
xemu,
|
|
xm6pro,
|
|
xroar,
|
|
)
|
|
|
|
|
|
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)]
|