From 8bad9ca1e761b55de908a1a5eeb1ff311762c0b4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:24:50 +0200 Subject: [PATCH 1/2] fix: note the code names behind saturn aliases RetroBat's launcher layer now records the Saturn names its generators pick. The ymir aliases are the libretro port's own kBiosFilenames, and the SSF aliases label dumps of a slot whose file name SSF leaves free. Both entries now say so next to their source_ref. --- emulators/ssf.yml | 2 +- emulators/ymir.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/emulators/ssf.yml b/emulators/ssf.yml index 0dd191c8..b0dde7a5 100644 --- a/emulators/ssf.yml +++ b/emulators/ssf.yml @@ -75,7 +75,7 @@ files: hle_fallback: true size: 524288 description: "Saturn BIOS" - note: "Any dump of the 512 KiB console ROM, the compatible machines included. Area code is chosen by [Peripheral] Areacode and AutoAreacode, not by the image, so no region selects this file." + note: "Any dump of the 512 KiB console ROM, the compatible machines included. Area code is chosen by [Peripheral] Areacode and AutoAreacode, not by the image, so no region selects this file. Each alias labels one of those dumps: mpr-17933.bin, sega_100.bin, sega_100a.bin, sega1003.bin, hisaturn.bin and vsaturn.bin." source_ref: "SSF.exe 0x43d770 (SaturnBIOS 0x129b1f8), SSF.exe 0x129a2f8 with the filter at 0x129a310, SSF.exe 0x43ff96 (NoBIOS 0x129b4cc), libnative-lib.so SSF::CStateSaveBase::_GetBIOSHash 0x134b8ec-0x134b924, SSF-archive Document/Readme.txt BIOSファイルについて" - name: "epr-20091.ic8" diff --git a/emulators/ymir.yml b/emulators/ymir.yml index 35cf6600..19fc4143 100644 --- a/emulators/ymir.yml +++ b/emulators/ymir.yml @@ -63,6 +63,7 @@ files: validation: [size] aliases: [mpr-17933.bin, saturn_bios.bin] description: "Saturn IPL ROM (BIOS). Any region accepted." + note: "The libretro port reads sega_101.bin, mpr-17933.bin and saturn_bios.bin from its system directory (kBiosFilenames)." source_ref: standalone: "apps/ymir-sdl3/src/util/rom_loader.cpp:9-22, apps/ymir-sdl3/src/app/rom_manager.cpp:14-53, libs/ymir-core/include/ymir/sys/memory_defs.hpp:9" libretro: "apps/ymir-libretro/src/libretro.cpp:305-309, apps/ymir-libretro/src/libretro.cpp:651-690, apps/ymir-libretro/src/libretro.cpp:692-703" From bd6ab38fe14f0a6cf78fd8484449990875b29c43 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:39:15 +0200 Subject: [PATCH 2/2] feat: extract the remaining retrobat generators 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. --- platforms/retrobat.yml | 749 +++++++++++ scripts/scraper/retrobat_launchers.py | 1692 ++++++++++++++++++++++++- tests/test_launchers_retrobat.py | 672 ++++++++++ 3 files changed, 3112 insertions(+), 1 deletion(-) diff --git a/platforms/retrobat.yml b/platforms/retrobat.yml index 122618d9..d0adca3d 100644 --- a/platforms/retrobat.yml +++ b/platforms/retrobat.yml @@ -3190,6 +3190,42 @@ launchers: fails_when_missing: warning, kickstart_ext_rom_file not written source_ref: "emulatorLauncher/Generators/Amiberry.Generator.cs:147-154, 553-557,\ \ 686-711" +- emulator: arcadeduck + build: standalone + kind: names_searched + names: + - + case_insensitive: false + search_dir: , then bios + passes_to: "settings.ini [BIOS] SearchDirectory = " + fails_when_missing: "ApplicationException 'Missing BIOS '' : place\ + \ it next to the game or in the RetroBat bios folder.'" + source_ref: "emulatorLauncher/Generators/ArcadeDuck.Generator.cs:52, 102-122, 219,\ + \ emulatorLauncher/Generators/ArcadeDuck.Database.cs:62, 221-222" +- emulator: arcadeduck + build: standalone + kind: names_searched + names: + - konamigv.zip + slot: konami_gv + case_insensitive: false + search_dir: , then bios + passes_to: "settings.ini [BIOS] SearchDirectory = " + fails_when_missing: "ApplicationException 'Missing BIOS '' : place\ + \ it next to the game or in the RetroBat bios folder.'" + depends_on_option: resources/arcadedb.yaml names no archive for konami_gv + source_ref: "emulatorLauncher/Generators/ArcadeDuck.Generator.cs:52, 102-122, 219,\ + \ emulatorLauncher/Generators/ArcadeDuck.Database.cs:62, 221-222" +- emulator: arcadeduck + build: standalone + kind: passes_path + names: [] + search_dir: bios + passes_to: "settings.ini [BIOS] SearchDirectory = bios" + fails_when_missing: false + depends_on_option: no bios archive known for the game's system + source_ref: "emulatorLauncher/Generators/ArcadeDuck.Generator.cs:52, 102-122, 219,\ + \ emulatorLauncher/Generators/ArcadeDuck.Database.cs:62, 221-222" - emulator: ares build: standalone kind: names_searched @@ -3213,6 +3249,265 @@ launchers: passes_to: "settings.bml LaserActiveSEGAPAC/Firmware/BIOS.US (first found)" fails_when_missing: false source_ref: "emulatorLauncher/Generators/Ares.Generator.cs:524-531" +- emulator: bizhawk + build: standalone + kind: fixed_name + names: + - gb_bios.bin + slot: other systems + case_insensitive: true + search_dir: bios + passes_to: config.ini BizHawk.Emulation.Cores.Nintendo.Sameboy.Sameboy EnableBIOS + = true, only if the file exists, the path itself is not written + fails_when_missing: EnableBIOS = false + depends_on_option: core == "SameBoy", bizhawk_gb_bios on, system != "gbc" + source_ref: "emulatorLauncher/Generators/Bizhawk.CoreOptions.cs:517-518, 524-531" +- emulator: bizhawk + build: standalone + kind: fixed_name + names: + - gbc_bios.bin + slot: gbc + case_insensitive: true + search_dir: bios + passes_to: config.ini BizHawk.Emulation.Cores.Nintendo.Sameboy.Sameboy EnableBIOS + = true, only if the file exists, the path itself is not written + fails_when_missing: EnableBIOS = false + depends_on_option: core == "SameBoy", bizhawk_gb_bios on, system == "gbc" + source_ref: "emulatorLauncher/Generators/Bizhawk.CoreOptions.cs:517-518, 524-531" +- emulator: bizhawk + build: standalone + kind: fixed_name + names: + - gb_bios.bin + slot: other systems + case_insensitive: true + search_dir: bios + passes_to: config.ini BizHawk.Emulation.Cores.Nintendo.Gameboy.Gameboy EnableBIOS + = true, only if the file exists, the path itself is not written + fails_when_missing: EnableBIOS = false + depends_on_option: core == "Gambatte", bizhawk_gb_bios on, system != "gbc" && system + != "gba" + source_ref: "emulatorLauncher/Generators/Bizhawk.CoreOptions.cs:536-537, 543-552" +- emulator: bizhawk + build: standalone + kind: fixed_name + names: + - gbc_bios.bin + slot: gbc + case_insensitive: true + search_dir: bios + passes_to: config.ini BizHawk.Emulation.Cores.Nintendo.Gameboy.Gameboy EnableBIOS + = true, only if the file exists, the path itself is not written + fails_when_missing: EnableBIOS = false + depends_on_option: core == "Gambatte", bizhawk_gb_bios on, system == "gbc" + source_ref: "emulatorLauncher/Generators/Bizhawk.CoreOptions.cs:536-537, 543-552" +- emulator: bizhawk + build: standalone + kind: fixed_name + names: + - gba_bios.bin + slot: gba + case_insensitive: true + search_dir: bios + passes_to: config.ini BizHawk.Emulation.Cores.Nintendo.Gameboy.Gameboy EnableBIOS + = true, only if the file exists, the path itself is not written + fails_when_missing: EnableBIOS = false + depends_on_option: core == "Gambatte", bizhawk_gb_bios on, system == "gba" + source_ref: "emulatorLauncher/Generators/Bizhawk.CoreOptions.cs:536-537, 543-552" +- emulator: blastem + build: standalone + kind: fixed_name + names: + - bios_CD_U.bin + case_insensitive: true + search_dir: bios + passes_to: blastem.cfg system.scd_bios_us, only if the file exists + fails_when_missing: info log, scd_bios_us left as it is + source_ref: "emulatorLauncher/Generators/Blastem.Generator.cs:258, 262, 272-279" +- emulator: blastem + build: standalone + kind: fixed_name + names: + - bios_CD_E.bin + case_insensitive: true + search_dir: bios + passes_to: blastem.cfg system.scd_bios_eu, only if the file exists + fails_when_missing: info log, scd_bios_eu left as it is + source_ref: "emulatorLauncher/Generators/Blastem.Generator.cs:258, 263, 272-279" +- emulator: blastem + build: standalone + kind: fixed_name + names: + - bios_CD_J.bin + case_insensitive: true + search_dir: bios + passes_to: blastem.cfg system.scd_bios_jp, only if the file exists + fails_when_missing: info log, scd_bios_jp left as it is + source_ref: "emulatorLauncher/Generators/Blastem.Generator.cs:258, 264, 272-279" +- emulator: blastem + build: standalone + kind: fixed_name + names: + - colecovision.rom + case_insensitive: true + search_dir: bios + passes_to: blastem.cfg system.coleco_bios_path, only if the file exists + fails_when_missing: info log, coleco_bios_path left as it is + source_ref: "emulatorLauncher/Generators/Blastem.Generator.cs:258, 265, 272-279" +- emulator: blastem + build: standalone + kind: fixed_name + names: + - 32X_M_BIOS.BIN + case_insensitive: true + search_dir: bios + passes_to: blastem.cfg system.s32x_main_bios, only if the file exists + fails_when_missing: info log, s32x_main_bios left as it is + source_ref: "emulatorLauncher/Generators/Blastem.Generator.cs:258, 267, 272-279" +- emulator: blastem + build: standalone + kind: fixed_name + names: + - 32X_S_BIOS.BIN + case_insensitive: true + search_dir: bios + passes_to: blastem.cfg system.s32x_sub_bios, only if the file exists + fails_when_missing: info log, s32x_sub_bios left as it is + source_ref: "emulatorLauncher/Generators/Blastem.Generator.cs:258, 268, 272-279" +- emulator: blastem + build: standalone + kind: fixed_name + names: + - 32X_G_BIOS.BIN + case_insensitive: true + search_dir: bios + passes_to: blastem.cfg system.s32x_68k_bios, only if the file exists + fails_when_missing: info log, s32x_68k_bios left as it is + source_ref: "emulatorLauncher/Generators/Blastem.Generator.cs:258, 269, 272-279" +- emulator: bsyndrome + build: standalone + kind: passes_path + names: [] + search_dir: bios/bsyndrome/MUSIC + passes_to: command line --musicpath=bios/bsyndrome/MUSIC + fails_when_missing: false + depends_on_option: _emulator == "bsyndrome" + source_ref: "emulatorLauncher/Generators/PortsLauncher.Configuration.cs:133-134,\ + \ 153, 157-158" +- emulator: amiberry, hatari, hatarib, puae + build: libretro + kind: copies + names: + - capsimg.dll + case_insensitive: true + search_dir: bios + passes_to: copied to /, only when the target does not exist + fails_when_missing: false + depends_on_option: core in capsimgCore, rom extension ".ipf" + source_ref: "emulatorLauncher/Generators/LibRetro.Generator.cs:1008-1017, 2659" +- emulator: demul + build: standalone + kind: passes_path + names: [] + search_dir: bios/dc + passes_to: "Demul.ini [files] roms0 = bios/dc" + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Demul.Generator.cs:106, 109-114, 123-124" +- emulator: demul + build: standalone + kind: passes_path + names: [] + search_dir: bios + passes_to: "Demul.ini [files] roms1 = bios" + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Demul.Generator.cs:106, 109-114, 123-124" +- emulator: desmume + build: standalone + kind: fixed_name + names: + - bios7.bin + - bios9.bin + case_insensitive: true + search_dir: bios + passes_to: "desmume.ini [BIOS] ARM7BIOSFile, ARM9BIOSFile, only if every listed\ + \ file exists" + fails_when_missing: UseExtBIOS left at 0 + depends_on_option: desmume_UseExtBIOS on + source_ref: "emulatorLauncher/Generators/Desmume.Generator.cs:97-105" +- emulator: desmume + build: standalone + kind: fixed_name + names: + - firmware.bin + case_insensitive: true + search_dir: bios + passes_to: "desmume.ini [Firmware] FirmwareFile, only if the file exists" + fails_when_missing: UseExtFirmware left at 0 + depends_on_option: desmume_UseExtFirmware on, desmume_firmwareFile unset or not + an existing file + source_ref: "emulatorLauncher/Generators/Desmume.Generator.cs:111-123" +- emulator: dolphin + build: standalone + kind: fixed_name + names: + - gba_bios.bin + case_insensitive: true + search_dir: bios + passes_to: "User/Config/Dolphin.ini [GBA] BIOS, written without an existence check" + fails_when_missing: path written without an existence check + source_ref: "emulatorLauncher/Generators/Dolphin.Generator.cs:742-743" +- emulator: dolphin + build: standalone + kind: copies + names: + - triforce/segaboot.gcm + - segaboot.gcm + case_insensitive: true + search_dir: bios + passes_to: first found copied to /User/Triforce/segaboot.gcm, skipped + when the target is as large and not older + fails_when_missing: "warning '[WARNING] Triforce: segaboot.gcm not found in bios\\\ + triforce, test menu will not be available.' when the target is absent too" + depends_on_option: _triforce, !_crediar + source_ref: "emulatorLauncher/Generators/Dolphin.Generator.cs:782-783, 1048-1083" +- emulator: dosbox-pure + build: standalone + kind: copies + names: + - MT32_CONTROL.ROM + - MT32_PCM.ROM + - Roland_SC-55.sf2 + - Roland_SC-88.sf2 + case_insensitive: true + search_dir: bios/scummvm/extra + passes_to: copied to /system/, only when the target does not exist + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/DosBoxPure.Generator.cs:132, 137-155" +- emulator: dosbox-staging + build: standalone + kind: copies + names: + - MT32_CONTROL.ROM + - MT32_PCM.ROM + case_insensitive: true + search_dir: bios/scummvm/extra + passes_to: copied to /mt32-roms/, only when the target does not + exist + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/DosBoxStaging.Generator.cs:328, 333-341" +- emulator: dosbox-staging + build: standalone + kind: copies + names: + - Roland_SC-55.sf2 + - Roland_SC-88.sf2 + case_insensitive: true + search_dir: bios/scummvm/extra + passes_to: copied to /soundfonts/, only when the target does not + exist + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/DosBoxStaging.Generator.cs:325, 343-351" - emulator: duckstation build: standalone kind: passes_path @@ -3238,6 +3533,62 @@ 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: eka2l1 + build: standalone + kind: passes_path + names: [] + search_dir: bios/eka2l1/data + passes_to: config.yml data-storage = bios/eka2l1/data + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Eka2l1.Generator.cs:153, 156" +- emulator: fbneo + build: standalone + kind: passes_path + names: [] + search_dir: bios + passes_to: "config/fbneo64.ini szAppRomPaths[1] = bios" + fails_when_missing: false + depends_on_option: "File.Exists(iniFile)" + source_ref: "emulatorLauncher/Generators/Fbneo.Generator.cs:111" +- emulator: fbneo + build: standalone + kind: passes_path + names: [] + search_dir: bios/fbneo + passes_to: "config/fbneo64.ini szAppRomPaths[2] = bios/fbneo" + fails_when_missing: false + depends_on_option: "File.Exists(iniFile)" + source_ref: "emulatorLauncher/Generators/Fbneo.Generator.cs:113, 116" +- emulator: fbneo + build: standalone + kind: passes_path + names: [] + search_dir: bios/fbneo/cheats + passes_to: config/fbneo64.ini szAppCheatsPath = bios/fbneo/cheats + fails_when_missing: false + depends_on_option: "File.Exists(iniFile)" + source_ref: "emulatorLauncher/Generators/Fbneo.Generator.cs:113, 118, 121" +- emulator: fbneo + build: standalone + kind: passes_path + names: [] + search_dir: bios/fbneo/samples + passes_to: config/fbneo64.ini szAppSamplesPath = bios/fbneo/samples + fails_when_missing: false + depends_on_option: "File.Exists(iniFile)" + source_ref: "emulatorLauncher/Generators/Fbneo.Generator.cs:123, 126" +- emulator: gsplus + build: standalone + kind: fixed_name + names: + - APPLE2GS.ROM + case_insensitive: true + search_dir: bios + passes_to: config.txt g_cfg_rom_path, written without an existence check + fails_when_missing: path written without an existence check + depends_on_option: "!string.IsNullOrEmpty(AppConfig[\"bios\"]) && Directory.Exists(AppConfig[\"\ + bios\"])" + source_ref: "emulatorLauncher/Generators/GsPlus.Generator.cs:66-67" - emulator: jgenesis build: standalone kind: fixed_name @@ -3559,6 +3910,189 @@ launchers: fails_when_missing: option not written when the file is absent depends_on_option: hatarib_tos source_ref: "emulatorLauncher/Generators/LibRetro.CoreOptions.cs:3184-3187, .emulationstation/es_features.cfg:6667-6676" +- emulator: jzintv + build: standalone + kind: fixed_name + names: + - exec.bin + - grom.bin + case_insensitive: true + search_dir: bios + passes_to: command line -e /exec.bin -g /grom.bin + fails_when_missing: ApplicationException 'grom.bin or exec.bin not found in bios + folder!' + source_ref: "emulatorLauncher/Generators/Jzintv.Generator.cs:35-44" +- emulator: jzintv + build: standalone + kind: fixed_name + names: + - ecs.bin + case_insensitive: true + search_dir: bios + passes_to: command line -E /ecs.bin, only if the file exists + fails_when_missing: "warning '[WARN] ECS option enabled but ecs.bin not found in\ + \ bios folder!', -s0 passed" + depends_on_option: jzintv_useECS on, no .commands and no roms/intellivision/default.commands + source_ref: "emulatorLauncher/Generators/Jzintv.Generator.cs:47-52, 65-79" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios; + passes_to: command line -rp bios; + fails_when_missing: false + depends_on_option: "messMode == null || messMode.Name == \"mame\" || messMode.Name\ + \ == \"hbmame\", !string.IsNullOrEmpty(AppConfig[\"bios\"]) && Directory.Exists(AppConfig[\"\ + bios\"])" + source_ref: "emulatorLauncher/Generators/Mame64.Generator.cs:90-93" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios/hbmame/samples when hbmame, else bios/mame/samples + passes_to: command line -sp bios/hbmame/samples when hbmame, else bios/mame/samples + fails_when_missing: false + depends_on_option: "messMode == null || messMode.Name == \"mame\" || messMode.Name\ + \ == \"hbmame\", !string.IsNullOrEmpty(samplePath) && Directory.Exists(samplePath)" + source_ref: "emulatorLauncher/Generators/Mame64.Generator.cs:98, 103-104" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios/mame/artwork + passes_to: command line -artpath bios/mame/artwork + fails_when_missing: false + depends_on_option: "messMode == null || messMode.Name == \"mame\" || messMode.Name\ + \ == \"hbmame\", !string.IsNullOrEmpty(artPath) && Directory.Exists(artPath),\ + \ disable_artwork on" + source_ref: "emulatorLauncher/Generators/Mame64.Generator.cs:108, 115-117" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios/mame/artwork;/artwork;/hbmame/artwork when hbmame, + else /mame/artwork + passes_to: command line -artpath bios/mame/artwork;/artwork;/hbmame/artwork + when hbmame, else /mame/artwork + fails_when_missing: false + depends_on_option: "messMode == null || messMode.Name == \"mame\" || messMode.Name\ + \ == \"hbmame\", !string.IsNullOrEmpty(artPath) && Directory.Exists(artPath),\ + \ not (disable_artwork on)" + source_ref: "emulatorLauncher/Generators/Mame64.Generator.cs:108-109, 115, 118-119" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios/hbmame/ini when hbmame, else bios/mame/ini, or bios/groovymame/ini + when _groovy + passes_to: command line -inipath bios/hbmame/ini when hbmame, else bios/mame/ini, + or bios/groovymame/ini when _groovy + fails_when_missing: false + depends_on_option: "messMode == null || messMode.Name == \"mame\" || messMode.Name\ + \ == \"hbmame\", !string.IsNullOrEmpty(iniPath) && Directory.Exists(iniPath)" + source_ref: "emulatorLauncher/Generators/Mame64.Generator.cs:71-73, 164-165" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios/hbmame/hash when hbmame, else bios/mame/hash + passes_to: command line -hash bios/hbmame/hash when hbmame, else bios/mame/hash + fails_when_missing: false + depends_on_option: "messMode == null || messMode.Name == \"mame\" || messMode.Name\ + \ == \"hbmame\", !string.IsNullOrEmpty(hashPath) && Directory.Exists(hashPath)" + source_ref: "emulatorLauncher/Generators/Mame64.Generator.cs:169, 174-175" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios/mess;bios; + passes_to: command line -rp bios/mess;bios; + fails_when_missing: false + depends_on_option: "!string.IsNullOrEmpty(AppConfig[\"bios\"]) && Directory.Exists(AppConfig.GetFullPath(\"\ + bios\")), Directory.Exists(Path.Combine(bios, \"mess\"))" + source_ref: "emulatorLauncher/Generators/MessSystem.cs:612, 660, 663-664" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: bios/mess;bios; + passes_to: command line -rp bios/mess;bios; + fails_when_missing: false + depends_on_option: "!string.IsNullOrEmpty(AppConfig[\"bios\"]) && Directory.Exists(AppConfig.GetFullPath(\"\ + bios\")), Directory.Exists(Path.Combine(bios, \"mess\"))" + source_ref: "emulatorLauncher/Generators/MessSystem.cs:612, 660, 663-664" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios; + passes_to: command line -rp bios; + fails_when_missing: false + depends_on_option: "!string.IsNullOrEmpty(AppConfig[\"bios\"]) && Directory.Exists(AppConfig.GetFullPath(\"\ + bios\")), !Directory.Exists(Path.Combine(bios, \"mess\"))" + source_ref: "emulatorLauncher/Generators/MessSystem.cs:612, 660, 665-666" +- emulator: mame + build: libretro + kind: passes_path + names: [] + search_dir: bios; + passes_to: command line -rp bios; + fails_when_missing: false + depends_on_option: "!string.IsNullOrEmpty(AppConfig[\"bios\"]) && Directory.Exists(AppConfig.GetFullPath(\"\ + bios\")), !Directory.Exists(Path.Combine(bios, \"mess\"))" + source_ref: "emulatorLauncher/Generators/MessSystem.cs:612, 660, 665-666" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios/mame/ini + passes_to: command line -inipath bios/mame/ini + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/MessSystem.cs:612, 702-703" +- emulator: mame64 + build: standalone + kind: passes_path + names: [] + search_dir: bios/mame/hash + passes_to: command line -hash bios/mame/hash + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/MessSystem.cs:612, 705-706" +- emulator: melonds + build: standalone + kind: fixed_name + names: + - dsi_firmware.bin + - dsi_bios7.bin + - dsi_bios9.bin + - dsi_nand.bin + case_insensitive: true + search_dir: bios + passes_to: "melonDS.toml [DSi] FirmwarePath, BIOS7Path, BIOS9Path, NANDPath" + fails_when_missing: ApplicationException 'Cannot run dsi system, dsi bios files + are missing' + depends_on_option: melonds_console == "dsi" || rom extension ".bin" + source_ref: "emulatorLauncher/Generators/MelonDS.Generator.cs:34, 134-137, 187-191,\ + \ 221-227" +- emulator: melonds + build: standalone + kind: scans_dir + names: + - "dsi_nand*{region}*.bin" + expands: + region: + - USA + - EUR + - JPN + - KOR + - AUS + - CHN + case_insensitive: true + search_dir: bios + passes_to: "melonDS.toml [DSi] NANDPath, first file whose name holds the region" + fails_when_missing: falls back to dsi_nand.bin + depends_on_option: melonds_console == "dsi", melonds_nandregion set, rom extension + not ".bin" + source_ref: "emulatorLauncher/Generators/MelonDS.Generator.cs:193-202, .emulationstation/es_features.cfg:15797-15804" - emulator: mesen build: standalone kind: copies @@ -3722,6 +4256,101 @@ launchers: fails_when_missing: ApplicationException ' machine has missing BIOS file '' in 'bios\openmsx\share\systemroms' folder.' source_ref: "emulatorLauncher/Generators/OpenMSX.Generator.cs:156-159, 467" +- emulator: kronos + build: standalone + kind: fixed_name + names: + - saturn_bios.bin + case_insensitive: true + search_dir: bios + passes_to: "kronos.ini [1.0] General\\Bios, only if the file exists" + fails_when_missing: General\Bios not written + depends_on_option: kronos_force_bios unset + source_ref: "emulatorLauncher/Generators/Kronos.Generator.cs:161, 169-170" +- emulator: kronos + build: standalone + kind: core_option + names: + - saturn_bios.bin + - sega_101.bin + - mpr-17933.bin + case_insensitive: true + search_dir: bios + passes_to: "kronos.ini [1.0] General\\Bios = /, only if the file exists" + fails_when_missing: General\Bios not written + depends_on_option: kronos_force_bios + source_ref: "emulatorLauncher/Generators/Kronos.Generator.cs:162-170, .emulationstation/es_features.cfg:13529-13534" +- emulator: ssf + build: standalone + kind: core_option + names: + - saturn_bios.bin + - sega_101.bin + - mpr-17933.bin + case_insensitive: true + search_dir: bios + passes_to: "SSF.ini [Peripheral] SaturnBIOS = /, only if the file exists" + fails_when_missing: SaturnBIOS not written + depends_on_option: use_external_bios + source_ref: "emulatorLauncher/Generators/SSF.Generator.cs:76-83, .emulationstation/es_features.cfg:19240-19245" +- emulator: yabasanshiro + build: standalone + kind: fixed_name + names: + - saturn_bios.bin + case_insensitive: true + search_dir: bios + passes_to: "yabause.ini [0.9.11] General\\Bios, only if the file exists" + fails_when_missing: General\Bios not written + depends_on_option: yabasanshiro_force_bios unset + source_ref: "emulatorLauncher/Generators/Yabasanshiro.Generator.cs:122, 130-131" +- emulator: yabasanshiro + build: standalone + kind: core_option + names: + - saturn_bios.bin + - sega_101.bin + - mpr-17933.bin + case_insensitive: true + search_dir: bios + passes_to: "yabause.ini [0.9.11] General\\Bios = /, only if the file\ + \ exists" + fails_when_missing: General\Bios not written + depends_on_option: yabasanshiro_force_bios + source_ref: "emulatorLauncher/Generators/Yabasanshiro.Generator.cs:123-131, .emulationstation/es_features.cfg:21111-21116" +- emulator: ymir + build: standalone + kind: fixed_name + names: + - saturn_bios.bin + case_insensitive: true + search_dir: bios + passes_to: "Ymir.toml [System.IPL] Path, written without an existence check" + fails_when_missing: path written without an existence check + depends_on_option: ymir_force_bios unset + source_ref: "emulatorLauncher/Generators/Ymir.Generator.cs:118, 125" +- emulator: ymir + build: standalone + kind: core_option + names: + - saturn_bios.bin + - sega_101.bin + - mpr-17933.bin + case_insensitive: true + search_dir: bios + passes_to: "Ymir.toml [System.IPL] Path = /, written without an existence\ + \ check" + fails_when_missing: path written without an existence check + depends_on_option: ymir_force_bios + source_ref: "emulatorLauncher/Generators/Ymir.Generator.cs:120-125, .emulationstation/es_features.cfg:21170-21174" +- emulator: ymir + build: standalone + kind: passes_path + names: [] + search_dir: bios + passes_to: "Ymir.toml [General.PathOverrides] IPLROMImages = bios" + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Ymir.Generator.cs:143, 150" - emulator: pcsx2 build: standalone kind: names_searched @@ -3888,6 +4517,95 @@ launchers: passes_to: swanstation_BIOS_PathNTSCJ, swanstation_BIOS_PathNTSCU, swanstation_BIOS_PathPAL fails_when_missing: names written without an existence check source_ref: "emulatorLauncher/Generators/LibRetro.CoreOptions.cs:5982-6009" +- emulator: psxmame + build: standalone + kind: passes_path + names: [] + search_dir: bios/psxmame/bios; + passes_to: command line -rompath bios/psxmame/bios; + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/PSXMame.Generator.cs:38, 42, 47-48" +- emulator: raine + build: standalone + kind: passes_path + names: [] + search_dir: bios + passes_to: "config/raine32_sdl.cfg [Directories] rom_dir_1 = bios" + fails_when_missing: false + depends_on_option: "!string.IsNullOrEmpty(biosPath)" + source_ref: "emulatorLauncher/Generators/Raine.Generator.cs:87, 92" +- emulator: raine + build: standalone + kind: fixed_name + names: + - neocdz.zip + case_insensitive: true + search_dir: bios + passes_to: "config/raine32_sdl.cfg [neocd] neocd_bios, written without an existence\ + \ check" + fails_when_missing: path written without an existence check + depends_on_option: "!string.IsNullOrEmpty(biosPath)" + source_ref: "emulatorLauncher/Generators/Raine.Generator.cs:87, 93" +- emulator: rpcs3 + build: standalone + kind: fixed_name + names: + - PS3UPDAT.PUP + case_insensitive: true + search_dir: bios + passes_to: command line --installfw /PS3UPDAT.PUP, launched instead of the + game + fails_when_missing: ApplicationException 'PS3 firmware is not installed in rpcs3 + emulator, either place it in \bios folder, or launch the emulator and install + the firware.' + depends_on_option: /dev_flash/vsh/etc/version.txt absent + source_ref: "emulatorLauncher/Generators/Rpcs3.Generator.cs:195-218" +- emulator: snes9x + build: standalone + kind: passes_path + names: [] + search_dir: bios + passes_to: "snes9x.conf [Settings\\Win\\Files] Dir:Bios = bios" + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Snes9x.Generator.cs:126-134" +- emulator: tsugaru + build: standalone + kind: passes_path + names: + - FMT_SYS.ROM + case_insensitive: true + search_dir: bios/fmtowns, then bios/fmtownsux, then /roms, then /fmtownsux + passes_to: "command line: first argument , -CMOS /CMOS.DAT" + fails_when_missing: "info '[TsugaruGenerator] Bios path not found', no launch" + depends_on_option: "bios/fmtowns is a directory (biosPath stays null otherwise when\ + \ FMT_SYS.ROM is checked)" + source_ref: "emulatorLauncher/Generators/Tsugaru.Generator.cs:37-56" +- emulator: flycast + build: standalone + kind: passes_path + names: [] + search_dir: bios/dc + passes_to: "emu.cfg [config] Dreamcast.BiosPath = bios/dc" + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Flycast.Generator.cs:149, 167" +- emulator: scummvm + build: standalone + kind: passes_path + names: [] + search_dir: bios/scummvm/theme + passes_to: ".ini [scummvm] themepath = bios/scummvm/theme" + fails_when_missing: false + depends_on_option: "Directory.Exists(themePath)" + source_ref: "emulatorLauncher/Generators/ScummVm.Generator.cs:265-266, 271" +- emulator: scummvm + build: standalone + kind: passes_path + names: [] + search_dir: bios/scummvm/extra + passes_to: ".ini [scummvm] extrapath = bios/scummvm/extra" + fails_when_missing: false + depends_on_option: "Directory.Exists(extraPath)" + source_ref: "emulatorLauncher/Generators/ScummVm.Generator.cs:265-267, 275" - emulator: winuae build: standalone kind: names_searched @@ -4094,3 +4812,34 @@ launchers: fails_when_missing: "warning '[Generator] Chihiro media board flash missing next\ \ to the BIOS.'" source_ref: "emulatorLauncher/Generators/Xemu.Generator.cs:438-441" +- emulator: xm6pro + build: standalone + kind: copies + names: + - IPLROM.DAT + - CGROM.DAT + - IPLROMXV.DAT + - IPLROMCO.DAT + - IPLROM30.DAT + case_insensitive: true + search_dir: bios/keropi + passes_to: copied to /, only when the target does not exist + fails_when_missing: false + depends_on_option: "!string.IsNullOrEmpty(biosRoot), Directory.Exists(biosPath)" + source_ref: "emulatorLauncher/Generators/Xm6pro.Generator.cs:118-141" +- emulator: xroar + build: standalone + kind: passes_path + names: [] + search_dir: bios/dragon, or bios/coco when system == "coco" + passes_to: command line -rompath bios/dragon, or bios/coco when system == "coco" + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Xroar.Generator.cs:76-80" +- emulator: xroar + build: standalone + kind: passes_path + names: [] + search_dir: bios/dragon + passes_to: xroar.conf rompath ".\\..\\..\\bios\\dragon" + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Xroar.Generator.cs:176, 191" diff --git a/scripts/scraper/retrobat_launchers.py b/scripts/scraper/retrobat_launchers.py index eeb68a5d..e0f17f64 100644 --- a/scripts/scraper/retrobat_launchers.py +++ b/scripts/scraper/retrobat_launchers.py @@ -19,22 +19,52 @@ 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, ) @@ -61,6 +91,8 @@ class Source: 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) @@ -1291,7 +1323,7 @@ def _root_writes(src: Source, emulator: str) -> list[Record]: continue method = root_reach[0] for write in re.compile( - rf'(\w+)\.WriteValue\("([^"]*)", "([^"]+)", {root.group(1)}\);' + rf'(\w+)\.WriteValue\("([^"]*)", "([^"]+)", (?:"\'" \+ )?{root.group(1)}(?: \+ "\'")?\);' ).finditer(src.text, root.end(), method[1]): reached = _conditions(src, write.start()) if reached is None: @@ -1397,20 +1429,1678 @@ def guarded_writes(sources: dict[str, Source]) -> list[Record]: ] +# Expression rendering for the generators below: a path expression becomes +# bios//, for AppConfig.GetFullPath("x"), for +# the emulator's own folder, and None when it is built from anything else. + +_PATH_PARAMS = {"path": "", "emulatorPath": "", "_path": ""} +_KNOWN = { + "Path.GetDirectoryName(rom)": "", + "Path.GetDirectoryName(Path.GetFullPath(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"{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"(? 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"(? 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("/", "") + 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} = /{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//, 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} /{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)} /{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 {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}/, 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 (capsimgCore) = new List\(\) \{", "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 ," + f" {cmos.group(1)} /{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)} /{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() 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 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\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 /, 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 \{ 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""], + ref, + case_insensitive=False, + search_dir=", then bios", + passes_to=f"{target} = ", + fails_when_missing=message, + ), + _record( + "arcadeduck", + "standalone", + "names_searched", + [fixed.group(2)], + ref, + slot=fixed.group(1), + case_insensitive=False, + search_dir=", then bios", + passes_to=f"{target} = ", + 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, ) diff --git a/tests/test_launchers_retrobat.py b/tests/test_launchers_retrobat.py index 4405e212..4a5d80fa 100644 --- a/tests/test_launchers_retrobat.py +++ b/tests/test_launchers_retrobat.py @@ -464,6 +464,678 @@ class GuardedConfigWrites(unittest.TestCase): self.assertEqual(rl.GUARDED_WRITES[f"{G}/KegaFusion.Generator.cs"], "kega-fusion") +SATURN_FEATURES = """ + + + + + + + + + + + + + + + + + + + + + +""" + +KRONOS = """ + private void SetupConfig(string path) + { + string iniFile = Path.Combine(path, "kronos.ini"); + using (var ini = new IniFile(iniFile, IniOptions.KeepEmptyValues)) + { + string bios = Path.Combine(AppConfig.GetFullPath("bios"), "saturn_bios.bin"); + if (SystemConfig.isOptSet("kronos_force_bios") && !string.IsNullOrEmpty(SystemConfig["kronos_force_bios"])) + { + if (SystemConfig["kronos_force_bios"] != "hle") + bios = Path.Combine(AppConfig.GetFullPath("bios"), SystemConfig["kronos_force_bios"]); + else + hleBios = true; + } + if (File.Exists(bios)) + ini.WriteValue("1.0", "General\\\\Bios", bios.Replace("\\\\", "/")); + } + } +""" + +YMIR = """ + private void SetupConfiguration(string path) + { + string iniFile = Path.Combine(path, "Ymir.toml"); + using (var ini = new IniFile(iniFile)) + { + string saturnBiosPath = Path.Combine(AppConfig.GetFullPath("bios"), "saturn_bios.bin"); + + if (SystemConfig.isOptSet("ymir_force_bios") && !string.IsNullOrEmpty(SystemConfig["ymir_force_bios"])) + { + saturnBiosPath = Path.Combine(AppConfig.GetFullPath("bios"), SystemConfig["ymir_force_bios"]); + } + + ini.WriteValue("System.IPL", "Path", "'" + saturnBiosPath + "'"); + string iplPath = AppConfig.GetFullPath("bios"); + ini.WriteValue("General.PathOverrides", "IPLROMImages", "'" + iplPath + "'"); + } + } +""" + +SSF = """ + private void SetupConfiguration(string path) + { + string iniFile = Path.Combine(path, "SSF.ini"); + using (var ini = IniFile.FromFile(iniFile, IniOptions.KeepEmptyValues)) + { + if (SystemConfig.isOptSet("use_external_bios") && !string.IsNullOrEmpty(SystemConfig["use_external_bios"])) + { + string saturnBios = Path.Combine(AppConfig.GetFullPath("bios"), SystemConfig["use_external_bios"]); + if (File.Exists(saturnBios) && SystemConfig["use_external_bios"] != "hle") + ini.WriteValue("Peripheral", "SaturnBIOS", "\\"" + saturnBios + "\\""); + else + ini.WriteValue("Peripheral", "SaturnBIOS", "\\"" + "" + "\\""); + } + } + } +""" + +DESMUME = """ + private void SetupConfiguration(string path, string rom) + { + string settingsFile = Path.Combine(path, "desmume.ini"); + using (IniFile ini = IniFile.FromFile(settingsFile, IniOptions.KeepEmptyValues)) + { + ini.WriteValue("BIOS", "UseExtBIOS", "0"); + if (SystemConfig.isOptSet("desmume_UseExtBIOS") && SystemConfig.getOptBoolean("desmume_UseExtBIOS")) + { + string bios7 = Path.Combine(AppConfig.GetFullPath("bios"), "bios7.bin"); + string bios9 = Path.Combine(AppConfig.GetFullPath("bios"), "bios9.bin"); + + if (File.Exists(bios7) && File.Exists(bios9)) + { + ini.WriteValue("BIOS", "ARM9BIOSFile", bios9); + ini.WriteValue("BIOS", "ARM7BIOSFile", bios7); + ini.WriteValue("BIOS", "UseExtBIOS", "1"); + } + } + + if (SystemConfig.isOptSet("desmume_UseExtFirmware") && SystemConfig.getOptBoolean("desmume_UseExtFirmware")) + { + string firmware = Path.Combine(AppConfig.GetFullPath("bios"), "firmware.bin"); + + if (SystemConfig.isOptSet("desmume_firmwareFile") && !string.IsNullOrEmpty(SystemConfig["desmume_firmwareFile"])) + { + string newFirmware = SystemConfig["desmume_firmwareFile"]; + if (File.Exists(newFirmware)) + firmware = newFirmware; + } + + if (File.Exists(firmware)) + { + ini.WriteValue("Firmware", "FirmwareFile", firmware); + ini.WriteValue("Firmware", "UseExtFirmware", "1"); + } + } + } + } +""" + +MELONDS_DSI = """ + bool bootToDSINand = Path.GetExtension(rom).ToLowerInvariant() == ".bin"; + + private void SetupConfiguration(string path, string rom, bool bootToDSINand = false) + { + string settingsFile = Path.Combine(path, "melonDS.toml"); + using (IniTomlFile ini = IniTomlFile.FromFile(settingsFile, IniTomlOptions.UseSpaces)) + { + bool dsi = SystemConfig.isOptSet("melonds_console") && SystemConfig["melonds_console"] == "dsi"; + + if (bootToDSINand) + dsi = true; + + if (dsi) + { + string biosPath = AppConfig.GetFullPath("bios"); + string dsibios9 = Path.Combine(biosPath, "dsi_bios9.bin"); + string dsinand = Path.Combine(biosPath, "dsi_nand.bin"); + + if (SystemConfig.isOptSet("melonds_nandregion") && !string.IsNullOrEmpty(SystemConfig["melonds_nandregion"])) + { + string region = SystemConfig["melonds_nandregion"]; + string match = Directory.GetFiles(biosPath, "dsi_nand*.bin") + .FirstOrDefault(f => Path.GetFileNameWithoutExtension(f) + .IndexOf(region, StringComparison.OrdinalIgnoreCase) >= 0); + + if (match != null) + dsinand = match; + } + + if (!File.Exists(dsibios9) || !File.Exists(dsinand)) + throw new ApplicationException("Cannot run dsi system, dsi bios files are missing"); + + ini.WriteValue("DSi", "BIOS9Path", "\\"" + dsibios9.Replace("\\\\", "/") + "\\""); + ini.WriteValue("DSi", "NANDPath", "\\"" + dsinand.Replace("\\\\", "/") + "\\""); + } + } + } +""" + +MELONDS_FEATURES = """ + + + + + + +""" + +JZINTV = """ + public override ProcessStartInfo Generate(string system, string rom) + { + string gromPath = Path.Combine(AppConfig.GetFullPath("bios"), "grom.bin"); + string execPath = Path.Combine(AppConfig.GetFullPath("bios"), "exec.bin"); + + if (!File.Exists(gromPath) || !File.Exists(execPath)) + throw new ApplicationException("grom.bin or exec.bin not found in bios folder!"); + + commandArray.Add("-e"); + commandArray.Add("\\"" + execPath + "\\""); + commandArray.Add("-g"); + commandArray.Add("\\"" + gromPath + "\\""); + + string commandFile = rom.Replace(Path.GetExtension(rom), ".commands"); + + if (!File.Exists(commandFile)) + commandFile = Path.Combine(AppConfig.GetFullPath("roms"),"intellivision", "default.commands"); + + if (File.Exists(commandFile)) + { + } + else + { + if (SystemConfig.getOptBoolean("jzintv_useECS")) + { + string ecsPath = Path.Combine(AppConfig.GetFullPath("bios"), "ecs.bin"); + + if (File.Exists(ecsPath)) + { + commandArray.Add("-E"); + commandArray.Add("\\"" + ecsPath + "\\""); + } + else + { + SimpleLogger.Instance.Warning("[WARN] ecs.bin not found in bios folder!"); + commandArray.Add("-s0"); + } + } + } + } +""" + +BLASTEM = """ + private void SetupConfiguration(string path) + { + string targetCfg = Path.Combine(path, "blastem.cfg"); + var sys = cfg.GetOrCreateSection("system"); + } + + private void SetupSystem(BlastemConfigNode sys, string path) + { + string bios = AppConfig.GetFullPath("bios"); + SetBiosPath(sys, "scd_bios_us", bios, "bios_CD_U.bin"); + SetBiosPath(sys, "coleco_bios_path", bios, "colecovision.rom"); + } + + private static void SetBiosPath(BlastemConfigNode sys, string key, string biosPath, string fileName) + { + string file = Path.Combine(biosPath, fileName); + if (File.Exists(file)) + sys[key] = file; + else + SimpleLogger.Instance.Info("[Generator] BIOS not found, leaving " + key + " alone : " + file); + } +""" + +DOLPHIN = """ + private void SetupGeneralConfig(string path) + { + string iniFile = Path.Combine(path, "User", "Config", "Dolphin.ini"); + using (var ini = new IniFile(iniFile, IniOptions.UseSpaces)) + { + string gbaBiosPath = Path.Combine(AppConfig.GetFullPath("bios"), "gba_bios.bin"); + ini.WriteValue("GBA", "BIOS", gbaBiosPath); + + if (_triforce) + { + if (!_crediar) + CopySegaBoot(path); + } + } + } + + private void CopySegaBoot(string path) + { + string target = Path.Combine(path, "User", "Triforce", "segaboot.gcm"); + + string[] candidates = new string[] + { + Path.Combine(AppConfig.GetFullPath("bios"), "triforce", "segaboot.gcm"), + Path.Combine(AppConfig.GetFullPath("bios"), "segaboot.gcm") + }; + + string source = candidates.FirstOrDefault(f => File.Exists(f)); + if (source == null) + { + if (!File.Exists(target)) + SimpleLogger.Instance.Warning("[WARNING] segaboot.gcm not found"); + return; + } + + if (dst.Exists && dst.Length == src.Length && dst.LastWriteTimeUtc >= src.LastWriteTimeUtc) + return; + } + + private static bool GetRvzHash(string rvzPath) + { + string dolphinTool = Path.Combine(Program.AppConfig.GetFullPath("bios"), "dolphin-emu", "DolphinTool.exe"); + } +""" + +DOSBOX = """ + private void SearchAndCopySoundFiles(string path) + { + string targetPathFluidSynth = Path.Combine(path, "soundfonts"); + string targetPathmt32 = Path.Combine(path, "mt32-roms"); + + try + { + string mtcontrolsource = Path.Combine(AppConfig.GetFullPath("bios"), "scummvm", "extra", "MT32_CONTROL.ROM"); + string mtcontroltarget = Path.Combine(targetPathmt32, "MT32_CONTROL.ROM"); + if (!File.Exists(mtcontroltarget) && File.Exists(mtcontrolsource)) + try { File.Copy(mtcontrolsource, mtcontroltarget); } catch { } + + string roland55source = Path.Combine(AppConfig.GetFullPath("bios"), "scummvm", "extra", "Roland_SC-55.sf2"); + string roland55target = Path.Combine(targetPathFluidSynth, "Roland_SC-55.sf2"); + if (!File.Exists(roland55target) && File.Exists(roland55source)) + try { File.Copy(roland55source, roland55target); } catch { } + } + catch + { + } + } +""" + +TSUGARU = """ + public override ProcessStartInfo Generate(string system, string rom) + { + string biosPath = null; + + if (Directory.Exists(Path.Combine(AppConfig.GetFullPath("bios"), "fmtowns"))) + biosPath = Path.Combine(AppConfig.GetFullPath("bios"), "fmtowns"); + if (!File.Exists(Path.Combine(biosPath, "FMT_SYS.ROM"))) + biosPath = Path.Combine(AppConfig.GetFullPath("bios"), "fmtownsux"); + if (!File.Exists(Path.Combine(biosPath, "FMT_SYS.ROM"))) + biosPath = Path.Combine(path, "roms"); + if (!File.Exists(Path.Combine(biosPath, "FMT_SYS.ROM"))) + { + SimpleLogger.Instance.Info("[TsugaruGenerator] Bios path not found"); + return null; + } + + commandArray.Add(biosPath); + commandArray.Add("-CMOS"); + commandArray.Add(Path.Combine(biosPath, "CMOS.DAT")); + } +""" + +EKA2L1 = """ + public override ProcessStartInfo Generate(string system, string rom) + { + /*if (rom != null) + { + string bios = Path.Combine(AppConfig.GetFullPath("bios"), "N-Gage Installer.sis"); + }*/ + } + + private void SetupConfiguration(string path) + { + var yml = YmlFile.Load(Path.Combine(path, "config.yml")); + string dataFolder = Path.Combine(AppConfig.GetFullPath("bios"), "eka2l1", "data").Replace("\\\\", "/"); + yml["data-storage"] = dataFolder; + } +""" + +BIZHAWK_GB = """ + private void ConfigureGambatte(DynamicJson coreSyncSettings, string core, string system) + { + if (core != "Gambatte") + return; + + var gambatteSync = coreSyncSettings.GetOrCreateContainer("BizHawk.Emulation.Cores.Nintendo.Gameboy.Gameboy"); + + string gbBios = Path.Combine(AppConfig.GetFullPath("bios"), "gb_bios.bin"); + if (system == "gbc") + gbBios = Path.Combine(AppConfig.GetFullPath("bios"), "gbc_bios.bin"); + else if (system == "gba") + gbBios = Path.Combine(AppConfig.GetFullPath("bios"), "gba_bios.bin"); + + if (File.Exists(gbBios) && SystemConfig.getOptBoolean("bizhawk_gb_bios")) + gambatteSync["EnableBIOS"] = "true"; + else + gambatteSync["EnableBIOS"] = "false"; + } +""" + +BIZHAWK_CONFIG = """ + string configFile = Path.Combine(path, "config.ini"); +""" + +MESS = """ + public List GetMameCommandLineArguments(string system, string rom, bool standalone = false) + { + string bios = AppConfig.GetFullPath("bios"); + + commandArray.Add("-rp"); + if (!string.IsNullOrEmpty(AppConfig["bios"]) && Directory.Exists(AppConfig.GetFullPath("bios"))) + { + if (Directory.Exists(Path.Combine(bios, "mess"))) + commandArray.Add(Path.Combine(bios, "mess") + ";" + bios + ";" + Path.GetDirectoryName(rom)); + else + commandArray.Add(bios + ";" + Path.GetDirectoryName(rom)); + } + + if (standalone) + { + commandArray.Add("-hash"); + commandArray.Add(EnsureDirectoryExists(Path.Combine(bios, "mame", "hash"))); + } + } +""" + +MAME64 = """ + public override ProcessStartInfo Generate(string system, string rom) + { + if (messMode == null) + { + List commandArray = new List + { + "-skip_gameinfo", + "-rp" + }; + if (!string.IsNullOrEmpty(AppConfig["bios"]) && Directory.Exists(AppConfig["bios"])) + commandArray.Add(AppConfig.GetFullPath("bios") + ";" + Path.GetDirectoryName(rom)); + + string samplePath = hbmame? Path.Combine(AppConfig.GetFullPath("bios"), "hbmame", "samples") : Path.Combine(AppConfig.GetFullPath("bios"), "mame", "samples"); + commandArray.Add("-sp"); + commandArray.Add(samplePath); + } + } +""" + +XROAR = """ + public override ProcessStartInfo Generate(string system, string rom) + { + commandArray.Add("-rompath"); + string romPath = Path.Combine(AppConfig.GetFullPath("bios"), "dragon"); + if (system == "coco") + romPath = Path.Combine(AppConfig.GetFullPath("bios"), "coco"); + commandArray.Add("\\"" + romPath + "\\""); + string setupPath = Path.Combine(path, "xroar.conf"); + } + + private void SetupConfiguration(string setupPath) + { + string rompath = "rompath \\".\\\\\\\\..\\\\\\\\..\\\\\\\\bios\\\\\\\\dragon\\""; + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].StartsWith("rompath ")) + lines[i] = rompath; + } + } +""" + + +def _feature_sources(path: str, text: str, features: str) -> dict[str, rl.Source]: + return _sources(**{path: text, rl.FEATURES: features}) + + +class OptionReplacesThePath(unittest.TestCase): + def test_kronos_default_then_option_without_hle(self): + path = f"{G}/Kronos.Generator.cs" + sources = _feature_sources(path, KRONOS, SATURN_FEATURES) + fixed, option = rl._option_path_in(sources[path], sources[rl.FEATURES], "kronos") + self.assertEqual(fixed["names"], ["saturn_bios.bin"]) + self.assertEqual(fixed["depends_on_option"], "kronos_force_bios unset") + self.assertEqual( + fixed["passes_to"], "kronos.ini [1.0] General\\Bios, only if the file exists" + ) + self.assertEqual(fixed["source_ref"], f"{path}:6, 14-15") + self.assertEqual(option["kind"], "core_option") + self.assertEqual(option["names"], ["saturn_bios.bin", "sega_101.bin"]) + self.assertEqual(option["source_ref"], f"{path}:7-15, {rl.FEATURES}:3-7") + + def test_ymir_writes_without_a_check_and_passes_the_folder(self): + path = f"{G}/Ymir.Generator.cs" + sources = _feature_sources(path, YMIR, SATURN_FEATURES) + fixed, option, folder = rl._option_path_in(sources[path], sources[rl.FEATURES], "ymir") + self.assertEqual( + fixed["passes_to"], "Ymir.toml [System.IPL] Path, written without an existence check" + ) + self.assertEqual(option["names"], ["saturn_bios.bin", "mpr-17933.bin"]) + self.assertEqual(folder["kind"], "passes_path") + self.assertEqual( + folder["passes_to"], "Ymir.toml [General.PathOverrides] IPLROMImages = bios" + ) + + def test_ssf_has_no_default_name(self): + path = f"{G}/SSF.Generator.cs" + sources = _feature_sources(path, SSF, SATURN_FEATURES) + [option] = rl._option_path_in(sources[path], sources[rl.FEATURES], "ssf") + self.assertEqual(option["names"], ["sega_101.bin"]) + self.assertEqual( + option["passes_to"], + "SSF.ini [Peripheral] SaturnBIOS = /, only if the file exists", + ) + + +class FilesNeededTogether(unittest.TestCase): + def test_desmume_pair_and_firmware_override(self): + pair, firmware = rl.desmume(_sources(**{f"{G}/Desmume.Generator.cs": DESMUME})) + self.assertEqual(pair["names"], ["bios7.bin", "bios9.bin"]) + self.assertEqual( + pair["passes_to"], + "desmume.ini [BIOS] ARM7BIOSFile, ARM9BIOSFile, only if every listed file exists", + ) + self.assertEqual(pair["fails_when_missing"], "UseExtBIOS left at 0") + self.assertEqual(pair["depends_on_option"], "desmume_UseExtBIOS on") + self.assertEqual(firmware["names"], ["firmware.bin"]) + self.assertEqual( + firmware["depends_on_option"], + "desmume_UseExtFirmware on, desmume_firmwareFile unset or not an existing file", + ) + + def test_melonds_dsi_files_and_nand_region(self): + fixed, region = rl.melonds_dsi( + _sources(**{f"{G}/MelonDS.Generator.cs": MELONDS_DSI, rl.FEATURES: MELONDS_FEATURES}) + ) + self.assertEqual(fixed["names"], ["dsi_bios9.bin", "dsi_nand.bin"]) + self.assertEqual(fixed["passes_to"], "melonDS.toml [DSi] BIOS9Path, NANDPath") + self.assertEqual( + fixed["fails_when_missing"], + "ApplicationException 'Cannot run dsi system, dsi bios files are missing'", + ) + self.assertEqual( + fixed["depends_on_option"], 'melonds_console == "dsi" || rom extension ".bin"' + ) + self.assertEqual(region["kind"], "scans_dir") + self.assertEqual(region["names"], ["dsi_nand*{region}*.bin"]) + self.assertEqual(region["expands"], {"region": ["USA", "JPN"]}) + self.assertEqual(region["fails_when_missing"], "falls back to dsi_nand.bin") + + def test_jzintv_flags_in_command_line_order(self): + both, ecs = rl.jzintv(_sources(**{f"{G}/Jzintv.Generator.cs": JZINTV})) + self.assertEqual(both["names"], ["exec.bin", "grom.bin"]) + self.assertEqual(both["passes_to"], "command line -e /exec.bin -g /grom.bin") + self.assertEqual(ecs["names"], ["ecs.bin"]) + self.assertEqual( + ecs["depends_on_option"], + "jzintv_useECS on, no .commands and no roms/intellivision/default.commands", + ) + self.assertEqual( + ecs["fails_when_missing"], + "warning '[WARN] ecs.bin not found in bios folder!', -s0 passed", + ) + + +class HelpersAndCopies(unittest.TestCase): + def test_blastem_one_record_per_helper_call(self): + records = rl.blastem(_sources(**{f"{G}/Blastem.Generator.cs": BLASTEM})) + self.assertEqual( + [(r["names"], r["passes_to"]) for r in records], + [ + (["bios_CD_U.bin"], "blastem.cfg system.scd_bios_us, only if the file exists"), + ( + ["colecovision.rom"], + "blastem.cfg system.coleco_bios_path, only if the file exists", + ), + ], + ) + self.assertEqual(records[0]["source_ref"], f"{G}/Blastem.Generator.cs:9-10, 14-21") + + def test_dolphin_unchecked_write_and_segaboot_candidates(self): + gba, segaboot = rl.dolphin(_sources(**{f"{G}/Dolphin.Generator.cs": DOLPHIN})) + self.assertEqual(gba["names"], ["gba_bios.bin"]) + self.assertEqual( + gba["passes_to"], + "User/Config/Dolphin.ini [GBA] BIOS, written without an existence check", + ) + self.assertEqual(segaboot["kind"], "copies") + self.assertEqual(segaboot["names"], ["triforce/segaboot.gcm", "segaboot.gcm"]) + self.assertEqual(segaboot["depends_on_option"], "_triforce, !_crediar") + + def test_dosbox_copies_grouped_by_target_folder(self): + sources = _sources( + **{f"{G}/DosBoxPure.Generator.cs": DOSBOX, f"{G}/DosBoxStaging.Generator.cs": DOSBOX} + ) + records = rl.dosbox_copies(sources) + self.assertEqual( + [(r["emulator"], r["names"], r["passes_to"]) for r in records[:2]], + [ + ( + "dosbox-pure", + ["MT32_CONTROL.ROM"], + "copied to /mt32-roms/, only when the target does not exist", + ), + ( + "dosbox-pure", + ["Roland_SC-55.sf2"], + "copied to /soundfonts/, only when the target does not exist", + ), + ], + ) + self.assertEqual(records[0]["search_dir"], "bios/scummvm/extra") + + def test_tsugaru_folder_chain(self): + [record] = rl.tsugaru(_sources(**{f"{G}/Tsugaru.Generator.cs": TSUGARU})) + self.assertEqual(record["names"], ["FMT_SYS.ROM"]) + self.assertEqual( + record["search_dir"], "bios/fmtowns, then bios/fmtownsux, then /roms" + ) + self.assertEqual( + record["fails_when_missing"], "info '[TsugaruGenerator] Bios path not found', no launch" + ) + + def test_commented_code_is_not_read(self): + [record] = rl.eka2l1(_sources(**{f"{G}/Eka2l1.Generator.cs": EKA2L1})) + self.assertEqual(record["passes_to"], "config.yml data-storage = bios/eka2l1/data") + + def test_bizhawk_gb_one_slot_per_system(self): + records = rl.bizhawk_gb( + _sources( + **{ + f"{G}/Bizhawk.CoreOptions.cs": BIZHAWK_GB, + f"{G}/Bizhawk.Generator.cs": BIZHAWK_CONFIG, + } + ) + ) + self.assertEqual( + [(r["slot"], r["names"]) for r in records], + [ + ("other systems", ["gb_bios.bin"]), + ("gbc", ["gbc_bios.bin"]), + ("gba", ["gba_bios.bin"]), + ], + ) + self.assertEqual( + records[0]["depends_on_option"], + 'core == "Gambatte", bizhawk_gb_bios on, system != "gbc" && system != "gba"', + ) + self.assertEqual(records[0]["fails_when_missing"], "EnableBIOS = false") + + +class FolderArguments(unittest.TestCase): + def test_mess_folders_shared_with_the_libretro_core(self): + sources = _sources(**{f"{G}/MessSystem.cs": MESS, f"{G}/Mame64.Generator.cs": MAME64}) + records = rl.mame(sources) + mess = [r for r in records if r["source_ref"].startswith(f"{G}/MessSystem.cs")] + self.assertEqual( + [(r["emulator"], r["passes_to"]) for r in mess], + [ + ("mame64", "command line -rp bios/mess;bios;"), + ("mame", "command line -rp bios/mess;bios;"), + ("mame64", "command line -rp bios;"), + ("mame", "command line -rp bios;"), + ("mame64", "command line -hash bios/mame/hash"), + ], + ) + self.assertTrue( + mess[2]["depends_on_option"].endswith('!Directory.Exists(Path.Combine(bios, "mess"))') + ) + self.assertNotIn("depends_on_option", mess[4]) + + def test_mame64_ternary_folder(self): + sources = _sources(**{f"{G}/MessSystem.cs": MESS, f"{G}/Mame64.Generator.cs": MAME64}) + own = [r for r in rl.mame(sources) if r["source_ref"].startswith(f"{G}/Mame64")] + self.assertEqual(own[0]["passes_to"], "command line -rp bios;") + self.assertEqual( + own[1]["search_dir"], "bios/hbmame/samples when hbmame, else bios/mame/samples" + ) + + def test_xroar_reassigned_folder_and_config_line(self): + argument, config = rl.xroar(_sources(**{f"{G}/Xroar.Generator.cs": XROAR})) + self.assertEqual(argument["search_dir"], 'bios/dragon, or bios/coco when system == "coco"') + self.assertEqual(config["search_dir"], "bios/dragon") + self.assertEqual( + config["passes_to"], 'xroar.conf rompath ".\\\\..\\\\..\\\\bios\\\\dragon"' + ) + + +class EveryBiosReferenceIsRead(unittest.TestCase): + def test_unread_reference_stops_the_scrape(self): + extra = EKA2L1 + ( + "\n private void Other()\n {\n" + ' string other = Path.Combine(AppConfig.GetFullPath("bios"), "other.bin");\n' + " }\n" + ) + with self.assertRaises(rl.LauncherPatternMissing) as caught: + rl.eka2l1(_sources(**{f"{G}/Eka2l1.Generator.cs": extra})) + self.assertIn("bios reference at line", str(caught.exception)) + + def test_semicolon_inside_a_string_does_not_end_the_value(self): + src = rl.Source( + "x.cs", + 'void f(string rom)\n{\n string p = AppConfig.GetFullPath("bios") + ";" + Path.GetDirectoryName(rom);\n}\n', + ) + self.assertEqual(rl._render(src, "p", 0, len(src.text))[0], "bios;") + + 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}