diff --git a/emulators/kega-fusion.yml b/emulators/kega-fusion.yml index 458bd4d8..3b17be7e 100644 --- a/emulators/kega-fusion.yml +++ b/emulators/kega-fusion.yml @@ -37,13 +37,6 @@ notes: | file names the binary fixes. Each file is opened with fopen "rb" and read with a fixed count, no length, checksum or header is compared. - RetroBat's generator writes SCDEURBIOS, SCDUSABIOS and SCDJAPBIOS as - bios_CD_E.bin, bios_CD_U.bin and bios_CD_J.bin, and the three 32X keys as - 32X_G_BIOS.BIN, 32X_M_BIOS.BIN and 32X_S_BIOS.BIN in its bios folder, each - only when the file exists. GenesisBIOS receives the bios folder itself, so - the fopen fails and no TMSS ROM is loaded. The 8-bit keys are not written. - ref: emulatorlauncher Generators/KegaFusion.Generator.cs:99-128 - CDTray.raw, Cursor_8.raw, STATIC_8.RAW and gg_nor.all are looked up by name in an archive embedded in the executable (0x47a530), not on disk. Plugins (*.rpi) and the KGV1 video codec ship with the program. Fusion.ini, battery diff --git a/platforms/retrobat.yml b/platforms/retrobat.yml index 87050c44..122618d9 100644 --- a/platforms/retrobat.yml +++ b/platforms/retrobat.yml @@ -3238,6 +3238,261 @@ launchers: fails_when_missing: not checked by the launcher depends_on_option: duck_bios source_ref: "emulatorLauncher/Generators/Duckstation.Generator.cs:204-209, .emulationstation/es_features.cfg:11638-11646" +- emulator: jgenesis + build: standalone + kind: fixed_name + names: + - gba_bios.bin + case_insensitive: true + search_dir: bios + passes_to: "jgenesis-config.toml [game_boy_advance] bios_path, only if the file\ + \ exists" + fails_when_missing: bios_path not written + depends_on_option: system == "game_boy_advance" + source_ref: "emulatorLauncher/Generators/Jgenesis.Generator.cs:257-263" +- emulator: jgenesis + build: standalone + kind: fixed_name + names: + - bios_CD_U.bin + case_insensitive: true + search_dir: bios + passes_to: "jgenesis-config.toml [sega_cd] bios_path, only if the file exists" + fails_when_missing: bios_path not written + depends_on_option: system == "genesis" || system == "sega_cd" || system == "sega_32x", + system == "sega_cd" + source_ref: "emulatorLauncher/Generators/Jgenesis.Generator.cs:282-283, 317, 323-324" +- emulator: jgenesis + build: standalone + kind: fixed_name + names: + - bios_CD_J.bin + case_insensitive: true + search_dir: bios + passes_to: "jgenesis-config.toml [sega_cd] jp_bios_path, only if the file exists" + fails_when_missing: jp_bios_path not written + depends_on_option: system == "genesis" || system == "sega_cd" || system == "sega_32x", + system == "sega_cd" + source_ref: "emulatorLauncher/Generators/Jgenesis.Generator.cs:282-283, 318, 325-326" +- emulator: jgenesis + build: standalone + kind: fixed_name + names: + - bios_CD_E.bin + case_insensitive: true + search_dir: bios + passes_to: "jgenesis-config.toml [sega_cd] eu_bios_path, only if the file exists" + fails_when_missing: eu_bios_path not written + depends_on_option: system == "genesis" || system == "sega_cd" || system == "sega_32x", + system == "sega_cd" + source_ref: "emulatorLauncher/Generators/Jgenesis.Generator.cs:282-283, 319, 327-328" +- emulator: kega-fusion + build: standalone + kind: fixed_name + names: + - bios_CD_E.bin + case_insensitive: true + search_dir: bios + passes_to: Fusion.ini SCDEURBIOS, only if the file exists + fails_when_missing: SCDEURBIOS not written + source_ref: "emulatorLauncher/Generators/KegaFusion.Generator.cs:106-108" +- emulator: kega-fusion + build: standalone + kind: fixed_name + names: + - bios_CD_U.bin + case_insensitive: true + search_dir: bios + passes_to: Fusion.ini SCDUSABIOS, only if the file exists + fails_when_missing: SCDUSABIOS not written + source_ref: "emulatorLauncher/Generators/KegaFusion.Generator.cs:110-112" +- emulator: kega-fusion + build: standalone + kind: fixed_name + names: + - bios_CD_J.bin + case_insensitive: true + search_dir: bios + passes_to: Fusion.ini SCDJAPBIOS, only if the file exists + fails_when_missing: SCDJAPBIOS not written + source_ref: "emulatorLauncher/Generators/KegaFusion.Generator.cs:114-116" +- emulator: kega-fusion + build: standalone + kind: fixed_name + names: + - 32X_G_BIOS.BIN + case_insensitive: true + search_dir: bios + passes_to: Fusion.ini 32XM68KBIOS, only if the file exists + fails_when_missing: 32XM68KBIOS not written + source_ref: "emulatorLauncher/Generators/KegaFusion.Generator.cs:118-120" +- emulator: kega-fusion + build: standalone + kind: fixed_name + names: + - 32X_M_BIOS.BIN + case_insensitive: true + search_dir: bios + passes_to: Fusion.ini 32XMSH2BIOS, only if the file exists + fails_when_missing: 32XMSH2BIOS not written + source_ref: "emulatorLauncher/Generators/KegaFusion.Generator.cs:122-124" +- emulator: kega-fusion + build: standalone + kind: fixed_name + names: + - 32X_S_BIOS.BIN + case_insensitive: true + search_dir: bios + passes_to: Fusion.ini 32XSSH2BIOS, only if the file exists + fails_when_missing: 32XSSH2BIOS not written + source_ref: "emulatorLauncher/Generators/KegaFusion.Generator.cs:126-128" +- emulator: kega-fusion + build: standalone + kind: passes_path + names: [] + search_dir: bios + passes_to: Fusion.ini GenesisBIOS = bios + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/KegaFusion.Generator.cs:99, 103" +- emulator: melonds + build: standalone + kind: fixed_name + names: + - firmware.bin + case_insensitive: true + search_dir: bios + passes_to: "melonDS.toml [DS] FirmwarePath, only if the file exists" + fails_when_missing: FirmwarePath not written + depends_on_option: melonds_externalBIOS on + source_ref: "emulatorLauncher/Generators/MelonDS.Generator.cs:167, 174-175" +- emulator: melonds + build: standalone + kind: fixed_name + names: + - bios7.bin + case_insensitive: true + search_dir: bios + passes_to: "melonDS.toml [DS] BIOS7Path, only if the file exists" + fails_when_missing: BIOS7Path not written + depends_on_option: melonds_externalBIOS on + source_ref: "emulatorLauncher/Generators/MelonDS.Generator.cs:168, 177-178" +- emulator: melonds + build: standalone + kind: fixed_name + names: + - bios9.bin + case_insensitive: true + search_dir: bios + passes_to: "melonDS.toml [DS] BIOS9Path, only if the file exists" + fails_when_missing: BIOS9Path not written + depends_on_option: melonds_externalBIOS on + source_ref: "emulatorLauncher/Generators/MelonDS.Generator.cs:169, 180-181" +- emulator: mupen64 + build: standalone + kind: fixed_name + names: + - IPL_JAP.n64 + case_insensitive: true + search_dir: bios/Mupen64plus + passes_to: "Config/mupen64plus.cfg [Rosalie's Mupen GUI Core 64DD] 64DD_JapaneseIPL,\ + \ only if the file exists" + fails_when_missing: 64DD_JapaneseIPL not written + source_ref: "emulatorLauncher/Generators/Mupen64.Generator.cs:228-230" +- emulator: mupen64 + build: standalone + kind: fixed_name + names: + - IPL_USA.n64 + case_insensitive: true + search_dir: bios/Mupen64plus + passes_to: "Config/mupen64plus.cfg [Rosalie's Mupen GUI Core 64DD] 64DD_AmericanIPL,\ + \ only if the file exists" + fails_when_missing: 64DD_AmericanIPL not written + source_ref: "emulatorLauncher/Generators/Mupen64.Generator.cs:232-234" +- emulator: mupen64 + build: standalone + kind: fixed_name + names: + - IPL_DEV.n64 + case_insensitive: true + search_dir: bios/Mupen64plus + passes_to: "Config/mupen64plus.cfg [Rosalie's Mupen GUI Core 64DD] 64DD_DevelopmentIPL,\ + \ only if the file exists" + fails_when_missing: 64DD_DevelopmentIPL not written + source_ref: "emulatorLauncher/Generators/Mupen64.Generator.cs:236-238" +- emulator: project64 + build: standalone + kind: fixed_name + names: + - IPL_JAP.n64 + case_insensitive: true + search_dir: bios/Mupen64plus + passes_to: "Config/Project64.cfg [Settings] Disk IPL ROM Path, only if the file\ + \ exists" + fails_when_missing: Disk IPL ROM Path not written + source_ref: "emulatorLauncher/Generators/Project64.Generator.cs:91-93" +- emulator: project64 + build: standalone + kind: fixed_name + names: + - IPL_USA.n64 + case_insensitive: true + search_dir: bios/Mupen64plus + passes_to: "Config/Project64.cfg [Settings] Disk IPL USA ROM Path, only if the file\ + \ exists" + fails_when_missing: Disk IPL USA ROM Path not written + source_ref: "emulatorLauncher/Generators/Project64.Generator.cs:95-97" +- emulator: project64 + build: standalone + kind: fixed_name + names: + - IPL_DEV.n64 + case_insensitive: true + search_dir: bios/Mupen64plus + passes_to: "Config/Project64.cfg [Settings] Disk IPL TOOL ROM Path, only if the\ + \ file exists" + fails_when_missing: Disk IPL TOOL ROM Path not written + source_ref: "emulatorLauncher/Generators/Project64.Generator.cs:99-101" +- emulator: mgba + build: standalone + kind: fixed_name + names: + - gb_bios.bin + case_insensitive: true + search_dir: bios + passes_to: "config.ini [ports.qt] gb.bios, only if the file exists" + fails_when_missing: gb.bios not written + source_ref: "emulatorLauncher/Generators/mGBA.Generator.cs:112-114" +- emulator: mgba + build: standalone + kind: fixed_name + names: + - sgb2_boot.bin + case_insensitive: true + search_dir: bios + passes_to: "config.ini [ports.qt] sgb.bios, only if the file exists" + fails_when_missing: sgb.bios not written + source_ref: "emulatorLauncher/Generators/mGBA.Generator.cs:116-118" +- emulator: mgba + build: standalone + kind: fixed_name + names: + - gbc_bios.bin + case_insensitive: true + search_dir: bios + passes_to: "config.ini [ports.qt] gbc.bios, only if the file exists" + fails_when_missing: gbc.bios not written + source_ref: "emulatorLauncher/Generators/mGBA.Generator.cs:120-122" +- emulator: mgba + build: standalone + kind: fixed_name + names: + - gba_bios.bin + case_insensitive: true + search_dir: bios + passes_to: "config.ini [ports.qt] gba.bios, only if the file exists" + fails_when_missing: gba.bios not written + source_ref: "emulatorLauncher/Generators/mGBA.Generator.cs:124-126" - emulator: hatari build: standalone kind: fixed_name diff --git a/scripts/scraper/retrobat_launchers.py b/scripts/scraper/retrobat_launchers.py index 3444df79..eeb68a5d 100644 --- a/scripts/scraper/retrobat_launchers.py +++ b/scripts/scraper/retrobat_launchers.py @@ -23,13 +23,19 @@ SOURCE_FILES = ( f"{GENERATORS}/Bizhawk.Generator.cs", f"{GENERATORS}/Duckstation.Generator.cs", f"{GENERATORS}/Hatari.Generator.cs", + f"{GENERATORS}/Jgenesis.Generator.cs", + f"{GENERATORS}/KegaFusion.Generator.cs", f"{GENERATORS}/LibRetro.CoreOptions.cs", f"{GENERATORS}/Mednafen.Generator.cs", + f"{GENERATORS}/MelonDS.Generator.cs", f"{GENERATORS}/Mesen.Generator.cs", + f"{GENERATORS}/Mupen64.Generator.cs", f"{GENERATORS}/OpenMSX.Generator.cs", f"{GENERATORS}/Pcsx2.Generator.cs", + f"{GENERATORS}/Project64.Generator.cs", f"{GENERATORS}/Uae.Generator.cs", f"{GENERATORS}/Xemu.Generator.cs", + f"{GENERATORS}/mGBA.Generator.cs", FEATURES, ) @@ -54,6 +60,7 @@ class Source: self.text = text self._starts = [0] + [m.end() for m in re.finditer(r"\n", text)] self._blocks: list[tuple[int, int]] | None = None + self._ends: list[int] | None = None def line(self, offset: int) -> int: return bisect.bisect_right(self._starts, offset) @@ -107,6 +114,15 @@ class Source: 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) @@ -1169,10 +1185,223 @@ def duckstation(sources: dict[str, Source]) -> list[Record]: ] +# Generators that write each BIOS path into the emulator's configuration +# only when the file exists: string x = Path.Combine(bios, ..., "name"); +# if (File.Exists(x)) ini.WriteValue("section", "key", x). +GUARDED_WRITES = { + f"{GENERATORS}/Jgenesis.Generator.cs": "jgenesis", + f"{GENERATORS}/KegaFusion.Generator.cs": "kega-fusion", + f"{GENERATORS}/MelonDS.Generator.cs": "melonds", + f"{GENERATORS}/Mupen64.Generator.cs": "mupen64", + f"{GENERATORS}/Project64.Generator.cs": "project64", + f"{GENERATORS}/mGBA.Generator.cs": "mgba", +} + +_METHOD = re.compile( + r"(?:(?:public|private|protected|internal|static|override|virtual)\s+)+[\w<>\[\],. ]+\s\w+\s*\(" +) +_TRANSPARENT = re.compile(r"(?:using\s*\(.*\)|try)") +_IF = re.compile(r"(?:else\s+)?if\s*\((.*)\)") +_GUARD = re.compile( + r'if \(File\.Exists\((\w+)\)\)\s*(\w+)\.WriteValue\("([^"]*)", "([^"]+)", ([^;\n]+)\);' +) +_BIOS_ROOT = r'(?:Program\.)?AppConfig\.GetFullPath\("bios"\)' + + +def _negate(condition: str) -> str: + """The condition under which an early return is not taken. + + A conjunction of inequalities becomes the disjunction of equalities, + anything else is wrapped in not (...). + """ + tests = condition.split(" && ") + compared = [re.fullmatch(rf"(\w+) != ({STRING})", t) for t in tests] + if all(compared): + return " || ".join(f"{m.group(1)} == {m.group(2)}" for m in compared) + return f"not ({condition})" + + +Reach = tuple[tuple[int, int], list[str], list[tuple[int, int]]] + + +def _conditions(src: Source, offset: int) -> Reach | None: + """The method holding offset, the conditions under which it is reached, + and the lines of the early returns among them. + + Each enclosing if adds its condition, and an early return at the top of + the method adds the condition that does not return. None when a block in + between is not an if, a using or a try: a loop or an else would need its + own reading. + """ + enclosing: list[str] = [] + for block in sorted((b for b in src.blocks() if b[0] < offset < b[1]), reverse=True): + header = src.header(block) + if _METHOD.match(header): + returns, spans = [], [] + for found in re.finditer(r"\bif \(", src.text[block[0] : offset]): + paren = block[0] + found.end() - 1 + if src.enclosing(paren) != block: + continue + cond = _call_args(src, paren) + tail = re.compile(r"\s*return\b[^;]*;").match(src.text, paren + len(cond) + 2) + if tail: + returns.append(_negate(" ".join(cond.split()))) + spans.append(src.lines(block[0] + found.start(), tail.end())) + return block, returns + enclosing[::-1], spans + condition = _IF.fullmatch(header) + if condition: + text = " ".join(condition.group(1).split()) + switch = re.fullmatch( + r'SystemConfig\.isOptSet\("(\w+)"\) && SystemConfig\.getOptBoolean\("\1"\)', text + ) + enclosing.append(f"{switch.group(1)} on" if switch else text) + elif not _TRANSPARENT.fullmatch(header): + return None + return None + + +def _config_name(src: Source, method: tuple[int, int], writer: str) -> str: + """The file the writer variable opens, read in its method or else in the file.""" + opener = r"(\w+)\s*=\s*(?:\w+\.FromFile|new \w+)\((\w+)" + declared = { + m.group(1): "/".join(_strings(m.group(2))) + for m in re.finditer( + r'string (\w+) = Path\.Combine\(path, ((?:"[^"]+"(?:, )?)+)\);', src.text + ) + } + local = [ + m.group(2) + for m in re.finditer(opener, src.text[method[0] : method[1]]) + if m.group(1) == writer and m.group(2) in declared + ] + if local: + return declared[local[0]] + files = {declared[m.group(2)] for m in re.finditer(opener, src.text) if m.group(2) in declared} + if len(files) != 1: + raise LauncherPatternMissing(src.path, f"configuration file opened as {writer}") + return files.pop() + + +def _root_writes(src: Source, emulator: str) -> list[Record]: + """Configuration keys that receive the bios folder itself.""" + records = [] + for root in re.finditer(rf"string (\w+) = {_BIOS_ROOT};", src.text): + root_reach = _conditions(src, root.start()) + if root_reach is None: + continue + method = root_reach[0] + for write in re.compile( + rf'(\w+)\.WriteValue\("([^"]*)", "([^"]+)", {root.group(1)}\);' + ).finditer(src.text, root.end(), method[1]): + reached = _conditions(src, write.start()) + if reached is None: + continue + writer, section, key = write.groups() + target = f"[{section}] {key}" if section else key + records.append( + _record( + emulator, + "standalone", + "passes_path", + [], + source_ref( + ( + src.path, + [ + *reached[2], + src.lines(root.start(), root.end()), + src.lines(write.start(), write.end()), + ], + ) + ), + search_dir="bios", + passes_to=f"{_config_name(src, method, writer)} {target} = bios", + fails_when_missing=False, + depends_on_option=", ".join(reached[1]) or None, + ) + ) + return records + + +def _guarded_write(src: Source, emulator: str, guard: re.Match[str]) -> Record | None: + """The record of one guarded write, None when its shape is another one. + + The written value must be the checked path itself (quoted or with its + separators replaced), the path a literal under the bios folder that + nothing reassigns before the check, and every block around the write an + if, a using or a try. + """ + var, writer, section, key, value = guard.groups() + if not re.fullmatch( + rf"(?:{STRING} \+ )?{var}(?:\.Replace\({STRING}, {STRING}\))?(?: \+ {STRING})?", + value.strip(), + ): + return None + reached = _conditions(src, guard.start()) + if reached is None: + return None + method, conditions, returns = reached + decls = list( + re.compile( + rf"string {var} = Path\.Combine\((\w+|{_BIOS_ROOT}), ((?:{STRING}, )*{STRING})\);" + ).finditer(src.text, method[0], guard.start()) + ) + if not decls or re.search(rf"\b{var}\s*=[^=]", src.text[decls[-1].end() : guard.start()]): + return None + decl = decls[-1] + root = decl.group(1) + if not re.fullmatch(_BIOS_ROOT, root) and not re.search( + rf"string {root} = {_BIOS_ROOT};", src.text[method[0] : decl.start()] + ): + return None + *folders, name = _strings(decl.group(2)) + target = f"[{section}] {key}" if section else key + return _record( + emulator, + "standalone", + "fixed_name", + [name], + source_ref( + ( + src.path, + [ + *returns, + src.lines(decl.start(), decl.end()), + src.lines(guard.start(), guard.end()), + ], + ) + ), + case_insensitive=True, + search_dir="/".join(["bios", *folders]), + passes_to=f"{_config_name(src, method, writer)} {target}, only if the file exists", + fails_when_missing=f"{key} not written", + depends_on_option=", ".join(conditions) or None, + ) + + +def guarded_writes_in(src: Source, emulator: str) -> list[Record]: + """Every guarded write of one generator, then the keys given the bios folder.""" + found = [_guarded_write(src, emulator, guard) for guard in _GUARD.finditer(src.text)] + records = [record for record in found if record is not None] + if not records: + raise LauncherPatternMissing(src.path, "File.Exists guarded WriteValue") + return records + _root_writes(src, emulator) + + +def guarded_writes(sources: dict[str, Source]) -> list[Record]: + """One fixed name per configuration key, written only when the file exists.""" + return [ + record + for path, emulator in GUARDED_WRITES.items() + for record in guarded_writes_in(sources[path], emulator) + ] + + EXTRACTORS: tuple[Callable[[dict[str, Source]], list[Record]], ...] = ( amiberry, ares, duckstation, + guarded_writes, hatari, hatari_libretro, mesen, diff --git a/tests/test_launchers_retrobat.py b/tests/test_launchers_retrobat.py index 561d4b1c..4405e212 100644 --- a/tests/test_launchers_retrobat.py +++ b/tests/test_launchers_retrobat.py @@ -201,6 +201,94 @@ HATARI_FEATURES = """ """ +KEGA = """ + private void SetupConfiguration(string path, string system) + { + string iniFile = Path.Combine(path, "Fusion.ini"); + + using (var ini = IniFile.FromFile(iniFile)) + { + string biosPath = AppConfig.GetFullPath("bios"); + if (!Directory.Exists(biosPath)) try { Directory.CreateDirectory(biosPath); } + catch { } + + ini.WriteValue("", "GenesisBIOS", biosPath); + + // BIOS + string euBios = Path.Combine(biosPath, "bios_CD_E.bin"); + if (File.Exists(euBios)) + ini.WriteValue("", "SCDEURBIOS", euBios); + + string bios32Xg = Path.Combine(biosPath, "32X_G_BIOS.BIN"); + if (File.Exists(bios32Xg)) + ini.WriteValue("", "32XM68KBIOS", bios32Xg); + } + } +""" + +JGENESIS = """ + private void SetupTomlConfiguration(string path) + { + string settingsFile = Path.Combine(path, "jgenesis-config.toml"); + using (IniFileJGenesis ini = new IniFileJGenesis(settingsFile, IniOptionsJGenesis.UseSpaces)) + { + ConfigureGenesis(ini, system); + } + } + + private void ConfigureGenesis(IniFileJGenesis ini, string system) + { + if (system != "genesis" && system != "sega_cd") + return; + + if (system == "sega_cd") + { + string segaCdBios = Path.Combine(AppConfig.GetFullPath("bios"), "bios_CD_U.bin"); + + ini.WriteValue("sega_cd", "per_region_bios", "true"); + + if (File.Exists(segaCdBios)) + ini.WriteValue("sega_cd", "bios_path", "'" + segaCdBios + "'"); + } + } +""" + +MELONDS = """ + private void SetupConfiguration(string path, string rom) + { + string settingsFile = Path.Combine(path, "melonDS.toml"); + + using (IniTomlFile ini = IniTomlFile.FromFile(settingsFile, IniTomlOptions.UseSpaces)) + { + if (SystemConfig.isOptSet("melonds_externalBIOS") && SystemConfig.getOptBoolean("melonds_externalBIOS")) + { + string bios7 = Path.Combine(AppConfig.GetFullPath("bios"), "bios7.bin"); + + if (File.Exists(bios7)) + ini.WriteValue("DS", "BIOS7Path", "\\"" + bios7.Replace("\\\\", "/") + "\\""); + } + } + } +""" + +# Kronos: the path is replaced by an option before the check, so the name +# checked is not the literal. +REASSIGNED = """ + private void SetupConfiguration(string path) + { + string conf = Path.Combine(path, "kronos.ini"); + using (var ini = IniFile.FromFile(conf)) + { + string bios = Path.Combine(AppConfig.GetFullPath("bios"), "saturn_bios.bin"); + if (SystemConfig.isOptSet("kronos_force_bios")) + bios = Path.Combine(AppConfig.GetFullPath("bios"), SystemConfig["kronos_force_bios"]); + if (File.Exists(bios)) + ini.WriteValue("1.0", "General\\\\Bios", bios.Replace("\\\\", "/")); + } + } +""" + + def _psx_sources() -> dict[str, rl.Source]: return _sources( **{ @@ -314,6 +402,68 @@ class NamesInLauncherOrder(unittest.TestCase): self.assertEqual(option["search_dir"], "bios/hatari/tos") +def _one(text: str, emulator: str, path: str = f"{G}/X.Generator.cs") -> list[dict]: + return rl.guarded_writes_in(_sources(**{path: text})[path], emulator) + + +class GuardedConfigWrites(unittest.TestCase): + def test_kega_fusion_one_record_per_key(self): + records = _one(KEGA, "kega-fusion") + fixed = [r for r in records if r["kind"] == "fixed_name"] + self.assertEqual( + [(r["names"], r["passes_to"], r["fails_when_missing"]) for r in fixed], + [ + (["bios_CD_E.bin"], "Fusion.ini SCDEURBIOS, only if the file exists", + "SCDEURBIOS not written"), + (["32X_G_BIOS.BIN"], "Fusion.ini 32XM68KBIOS, only if the file exists", + "32XM68KBIOS not written"), + ], + ) + self.assertEqual(fixed[0]["source_ref"], f"{G}/X.Generator.cs:14-16") + self.assertTrue(all(r["search_dir"] == "bios" and r["case_insensitive"] for r in fixed)) + self.assertNotIn("depends_on_option", fixed[0]) + + def test_kega_fusion_genesis_key_receives_the_folder(self): + (folder,) = [r for r in _one(KEGA, "kega-fusion") if r["kind"] == "passes_path"] + self.assertEqual(folder["names"], []) + self.assertEqual(folder["passes_to"], "Fusion.ini GenesisBIOS = bios") + self.assertEqual(folder["source_ref"], f"{G}/X.Generator.cs:7, 11") + + def test_early_return_and_enclosing_if_are_conditions(self): + (record,) = _one(JGENESIS, "jgenesis") + self.assertEqual( + record["depends_on_option"], + 'system == "genesis" || system == "sega_cd", system == "sega_cd"', + ) + self.assertEqual( + record["passes_to"], "jgenesis-config.toml [sega_cd] bios_path, only if the file exists" + ) + self.assertEqual(record["source_ref"], f"{G}/X.Generator.cs:12-13, 17, 21-22") + + def test_option_switch_is_named(self): + (record,) = _one(MELONDS, "melonds") + self.assertEqual(record["names"], ["bios7.bin"]) + self.assertEqual(record["depends_on_option"], "melonds_externalBIOS on") + self.assertEqual( + record["passes_to"], "melonDS.toml [DS] BIOS7Path, only if the file exists" + ) + + def test_reassigned_path_is_not_a_fixed_name(self): + with self.assertRaises(rl.LauncherPatternMissing): + _one(REASSIGNED, "kronos") + + def test_write_inside_a_loop_is_not_read(self): + looped = KEGA.replace("// BIOS", "foreach (var x in xs)\n {").replace( + '"32XM68KBIOS", bios32Xg);', '"32XM68KBIOS", bios32Xg);\n }' + ) + with self.assertRaises(rl.LauncherPatternMissing): + _one(looped, "kega-fusion") + + def test_pinned_kega_fusion_generator_is_read(self): + self.assertIn(f"{G}/KegaFusion.Generator.cs", rl.SOURCE_FILES) + self.assertEqual(rl.GUARDED_WRITES[f"{G}/KegaFusion.Generator.cs"], "kega-fusion") + + class AbsentConstructStopsTheScrape(unittest.TestCase): def test_each_extractor_raises_on_an_empty_source(self): empty = {path: rl.Source(path, "") for path in rl.SOURCE_FILES}