diff --git a/platforms/knulli.yml b/platforms/knulli.yml index 947545a94..1ca669064 100644 --- a/platforms/knulli.yml +++ b/platforms/knulli.yml @@ -5394,3 +5394,641 @@ libretro_systems: - saturn zc210: - zc210 +launchers: +- emulator: duckstation + build: standalone + kind: names_searched + slot: NTSCU + names: + - scph101.bin + - scph1001.bin + - scph5501.bin + - scph7001.bin + - scph7501.bin + case_insensitive: true + search_dir: /userdata/bios + passes_to: "[BIOS] PathNTSCU" + fails_when_missing: No PSX1 BIOS found + fails_scope: emulator + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/duckstation/duckstationGenerator.py:119-143,\ + \ 589-611" +- emulator: duckstation + build: standalone + kind: names_searched + slot: PAL + names: + - scph1002.bin + - scph5502.bin + - scph5552.bin + - scph7002.bin + - scph7502.bin + - scph9002.bin + - scph102a.bin + - scph102b.bin + case_insensitive: true + search_dir: /userdata/bios + passes_to: "[BIOS] PathPAL" + fails_when_missing: No PSX1 BIOS found + fails_scope: emulator + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/duckstation/duckstationGenerator.py:119-143,\ + \ 589-611" +- emulator: duckstation + build: standalone + kind: names_searched + slot: NTSCJ + names: + - scph100.bin + - scph1000.bin + - scph3000.bin + - scph3500.bin + - scph5500.bin + - scph7000.bin + - scph7003.bin + case_insensitive: true + search_dir: /userdata/bios + passes_to: "[BIOS] PathNTSCJ" + fails_when_missing: No PSX1 BIOS found + fails_scope: emulator + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/duckstation/duckstationGenerator.py:119-143,\ + \ 589-611" +- emulator: duckstation + build: standalone + kind: names_searched + slot: Uni + names: + - psxonpsp660.bin + - ps1_rom.bin + case_insensitive: true + search_dir: /userdata/bios + passes_to: "[BIOS] PathNTSCU, PathPAL, PathNTSCJ" + fails_when_missing: No PSX1 BIOS found + fails_scope: emulator + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/duckstation/duckstationGenerator.py:119-143,\ + \ 589-611" +- emulator: duckstation-legacy + build: standalone + kind: names_searched + slot: NTSCU + names: + - scph101.bin + - scph1001.bin + - scph5501.bin + - scph7001.bin + - scph7501.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: "[BIOS] PathNTSCU" + fails_when_missing: No PSX1 BIOS found + fails_scope: emulator + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/duckstation_legacy/duckstationLegacyGenerator.py:116-153" +- emulator: duckstation-legacy + build: standalone + kind: names_searched + slot: PAL + names: + - scph1002.bin + - scph5502.bin + - scph5552.bin + - scph7002.bin + - scph7502.bin + - scph9002.bin + - scph102a.bin + - scph102b.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: "[BIOS] PathPAL" + fails_when_missing: No PSX1 BIOS found + fails_scope: emulator + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/duckstation_legacy/duckstationLegacyGenerator.py:116-153" +- emulator: duckstation-legacy + build: standalone + kind: names_searched + slot: NTSCJ + names: + - scph100.bin + - scph1000.bin + - scph3000.bin + - scph3500.bin + - scph5500.bin + - scph7000.bin + - scph7003.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: "[BIOS] PathNTSCJ" + fails_when_missing: No PSX1 BIOS found + fails_scope: emulator + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/duckstation_legacy/duckstationLegacyGenerator.py:116-153" +- emulator: hatari + build: standalone + kind: names_searched + slot: st + names: + - "etos256{L}.img" + - "tos104{L}.img" + - "tos102{L}.img" + - "tos100{L}.img" + expands: + L: &id001 + - us + - uk + - de + - es + - fr + - it + - nl + - ru + - se + - '' + case_insensitive: false + search_dir: /userdata/bios + passes_to: --machine st --tos + fails_when_missing: no bios found for machine st + depends_on_option: model, language + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/hatari/hatariGenerator.py:33-55,\ + \ 67-78, 172-207" +- emulator: hatari + build: standalone + kind: names_searched + slot: ste + names: + - "etos256{L}.img" + - "tos162{L}.img" + - "tos106{L}.img" + expands: + L: *id001 + case_insensitive: false + search_dir: /userdata/bios + passes_to: --machine ste --tos + fails_when_missing: no bios found for machine ste + depends_on_option: model, language + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/hatari/hatariGenerator.py:33-55,\ + \ 67-78, 172-207" +- emulator: hatari + build: standalone + kind: names_searched + slot: megaste + names: + - "etos256{L}.img" + - "tos206{L}.img" + - "tos205{L}.img" + expands: + L: *id001 + case_insensitive: false + search_dir: /userdata/bios + passes_to: --machine megaste --tos + fails_when_missing: no bios found for machine megaste + depends_on_option: model, language + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/hatari/hatariGenerator.py:33-55,\ + \ 67-78, 172-207" +- emulator: hatari + build: standalone + kind: names_searched + slot: tt + names: + - "etos512{L}.img" + - "tos306{L}.img" + expands: + L: *id001 + case_insensitive: false + search_dir: /userdata/bios + passes_to: --machine tt --tos + fails_when_missing: no bios found for machine tt + depends_on_option: model, language + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/hatari/hatariGenerator.py:33-55,\ + \ 67-78, 172-207" +- emulator: hatari + build: standalone + kind: names_searched + slot: falcon + names: + - "etos512{L}.img" + - "tos404{L}.img" + - "tos402{L}.img" + - "tos400{L}.img" + expands: + L: *id001 + case_insensitive: false + search_dir: /userdata/bios + passes_to: --machine falcon --tos + fails_when_missing: no bios found for machine falcon + depends_on_option: model, language + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/hatari/hatariGenerator.py:33-55,\ + \ 67-78, 172-207" +- emulator: xemu + build: standalone + kind: fixed_name + names: + - cerbios.bin + - Complex_4627.bin + - mcpx_1.0.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: "[sys.files] flashrom_path, [sys.files] bootrom_path" + fails_when_missing: false + depends_on_option: system.name == 'chihiro' + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/xemu/xemuConfig.py:72-78" +- emulator: melonds + build: standalone + kind: fixed_name + names: + - firmware.bin + - bios7.bin + - bios9.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: DS.FirmwarePath, DS.BIOS7Path, DS.BIOS9Path + fails_when_missing: false + depends_on_option: melonds_console + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/melonds/melondsGenerator.py:52-56,\ + \ 74-77, 173" +- emulator: melonds + build: standalone + kind: fixed_name + names: + - dsi_firmware.bin + - bios9.bin + - bios7.bin + - dsi_nand.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: DSi.FirmwarePath, DSi.BIOS9Path, DSi.BIOS7Path, DSi.NANDPath + fails_when_missing: false + depends_on_option: melonds_console + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/melonds/melondsGenerator.py:62-77,\ + \ 173" +- emulator: mupen64plus + build: standalone + kind: fixed_name + names: + - 64DD_IPL.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: "[64DD] IPL-ROM" + fails_when_missing: false + depends_on_option: system.name == 'n64dd' + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/mupen/mupenConfig.py:200-204" +- emulator: x16emu + build: standalone + kind: fixed_name + names: + - commanderx16/rom.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: command line -rom + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/x16emu/x16emuGenerator.py:31" +- emulator: flycast + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/dc + passes_to: environment FLYCAST_BIOS_PATH + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/flycast/flycastGenerator.py:239" +- emulator: lexaloffle + build: standalone + kind: fixed_name + slot: pico8 + names: + - pico-8/pico8 + case_insensitive: false + search_dir: /userdata/bios + passes_to: exec + fails_when_missing: Lexaloffle official binary not found at /userdata/bios/pico-8/pico8, + File /userdata/bios/pico-8/pico8 is not set as executable + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/lexaloffle/lexaloffleGenerator.py:20,\ + \ 43-44, 54-59, 140-141" +- emulator: lexaloffle + build: standalone + kind: fixed_name + slot: voxatron + names: + - voxatron/vox + case_insensitive: false + search_dir: /userdata/bios + passes_to: exec + fails_when_missing: Lexaloffle official binary not found at /userdata/bios/voxatron/vox, + File /userdata/bios/voxatron/vox is not set as executable + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/lexaloffle/lexaloffleGenerator.py:27,\ + \ 47-48, 54-59, 140-141" +- emulator: o2em + build: libretro + kind: core_option + names: + - g7400.bin + - o2rom.bin + passes_to: core option o2em_bios + fails_when_missing: false + depends_on_option: "o2em_bios (default by system.name == 'videopacplus')" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroOptions.py:675-676,\ + \ 680-685" +- emulator: neocd + build: libretro + kind: core_option + names: + - "neocd_z.rom (CDZ)" + passes_to: core option neocd_bios + fails_when_missing: false + depends_on_option: neocd_bios + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroOptions.py:2558-2559,\ + \ 2566-2569" +- emulator: lexaloffle + build: standalone + kind: moves + names: + - pico-8/pico8 + - pico-8/pico8_dyn + - pico-8/pico8_gpio + - pico-8/pico8.dat + case_insensitive: false + search_dir: /userdata/bios + passes_to: moved from /userdata/roms/pico8/pico-8*raspi*.zip into /userdata/bios/pico-8 + fails_when_missing: Please add the Raspberry Pi version of Pico-8 to roms/pico8 + and try again. + source_ref: "board/fsoverlay/usr/bin/knulli-install-pico8:3-11, 16-19, 66-69" +- emulator: dolphin + build: standalone + kind: fixed_name + names: + - "GC/{region}/IPL.bin" + expands: + region: + - USA + - EUR + - JAP + case_insensitive: false + search_dir: /userdata/bios + passes_to: "presence sets [Core] SkipIPL = False" + fails_when_missing: false + depends_on_option: "system.isOptSet('dolphin_SkipIPL') and system.getOptBoolean('dolphin_SkipIPL')" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/dolphin/dolphinGenerator.py:192-193,\ + \ 196-197" +- emulator: yabasanshiro + build: standalone + kind: fixed_name + names: + - saturn_bios.bin + case_insensitive: false + search_dir: /userdata/bios + passes_to: command line -b + fails_when_missing: false + depends_on_option: "not (system.isOptSet('yaba_bios_hle') and system.config['yaba_bios_hle']\ + \ == '1')" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/yabasanshiro/yabasanshiroGenerator.py:309-311" +- emulator: rpcs3 + build: standalone + kind: fixed_name + names: + - PS3UPDAT.PUP + case_insensitive: false + search_dir: /userdata/bios + passes_to: command line --installfw + fails_when_missing: false + depends_on_option: "Rpcs3Generator.getFirmwareVersion() is None" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/rpcs3/rpcs3Generator.py:355-358" +- emulator: ryujinx + build: standalone + kind: copies + names: + - switch/prod.keys + case_insensitive: false + search_dir: /userdata/bios + passes_to: copied to /userdata/system/configs/Ryujinx/system/prod.keys + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/ryujinx/ryujinxGenerator.py:107-109" +- emulator: pcsx2 + build: standalone + kind: copies + names: + - ps2/patches.zip + case_insensitive: false + search_dir: /userdata/bios + passes_to: copied from /usr/share/knulli/datainit/bios/ps2/patches.zip + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/pcsx2/pcsx2Generator.py:78,\ + \ 121-122" +- emulator: aethersx2 + build: standalone + kind: copies + names: + - ps2/patches.zip + case_insensitive: false + search_dir: /userdata/bios + passes_to: copied from /usr/share/knulli/datainit/bios/ps2/patches.zip + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/aethersx2/aethersx2Generator.py:59,\ + \ 93-94" +- emulator: gsplus + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios + passes_to: g_cfg_rom_path + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/gsplus/gsplusGenerator.py:106" +- emulator: tsugaru + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/fmtowns + passes_to: command line argument 1 + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/tsugaru/tsugaruGenerator.py:18-19" +- emulator: scummvm + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/scummvm/extra + passes_to: command line --extrapath + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/scummvm/scummvmGenerator.py:117-123" +- emulator: amiberry + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/amiga/ + passes_to: rom_path + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/amiberry/amiberryGenerator.py:40" +- emulator: fsuae + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/amiga + passes_to: command line --kickstarts_dir + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/fsuae/fsuaeGenerator.py:63-69" +- emulator: mame + build: standalone + kind: passes_path + names: [] + search_dir: ;/userdata/bios/mame;/userdata/bios + passes_to: command line -rompath + fails_when_missing: false + depends_on_option: messMode == -1 + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/mame/mameGenerator.py:116-117" +- emulator: mame + build: standalone + kind: passes_path + names: [] + search_dir: ;/userdata/bios/mame;/userdata/bios;/userdata/roms/mame; + passes_to: command line -rompath + fails_when_missing: false + depends_on_option: "not (messMode == -1) and softList in subdirSoftList" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/mame/mameGenerator.py:116,\ + \ 119-120" +- emulator: mame + build: standalone + kind: passes_path + names: [] + search_dir: ;/userdata/bios/mame;/userdata/bios;/userdata/roms/mame + passes_to: command line -rompath + fails_when_missing: false + depends_on_option: "not (messMode == -1) and not (softList in subdirSoftList)" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/mame/mameGenerator.py:116,\ + \ 119, 122" +- emulator: mame + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/mame/samples + passes_to: command line -samplepath + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/mame/mameGenerator.py:129" +- emulator: mame + build: standalone + kind: passes_path + names: [] + search_dir: /var/run/mame_artwork/;/usr/bin/mame/artwork/;/userdata/bios/mame/artwork;/userdata/decorations + passes_to: command line -artpath + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/mame/mameGenerator.py:130" +- emulator: mame + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/mame/artwork/crosshairs + passes_to: command line -crosshairpath + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/mame/mameGenerator.py:180" +- emulator: openmsx + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/Machines + passes_to: "written: filepool add -path /userdata/bios/Machines -types system_rom\ + \ -position 1" + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/openmsx/openmsxGenerator.py:95" +- emulator: openmsx + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/openmsx + passes_to: "written: filepool add -path /userdata/bios/openmsx -types system_rom\ + \ -position 2" + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/openmsx/openmsxGenerator.py:96" +- emulator: fpinball + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios + passes_to: environment W_CACHE + fails_when_missing: false + depends_on_option: "not wsh57_done.exists()" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/fpinball/fpinballGenerator.py:37,\ + \ 39-43" +- emulator: retroarch + build: libretro + kind: passes_path + names: [] + search_dir: /userdata/bios/ + passes_to: system_directory + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroRetroarchCustom.py:98" +- emulator: puae, puae2021, uae4arm + build: libretro + kind: passes_path + names: [] + search_dir: /userdata/bios/amiga/ + passes_to: system_directory + fails_when_missing: false + depends_on_option: "system.config['core'] == 'puae' or system.config['core'] ==\ + \ 'puae2021' or system.config['core'] == 'uae4arm'" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroConfig.py:293-295" +- emulator: mame, mess, mamevirtual, same_cdi + build: libretro + kind: passes_path + names: [] + search_dir: ;/userdata/bios/mame/;/userdata/bios/ + passes_to: command line -rompath + fails_when_missing: false + depends_on_option: "system.name in ['mame', 'neogeo', 'lcdgames', 'plugnplay', 'vis']" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:64,\ + \ 77" +- emulator: mame, mess, mamevirtual, same_cdi + build: libretro + kind: passes_path + names: [] + search_dir: ;/userdata/bios/ + passes_to: command line -rompath + fails_when_missing: false + depends_on_option: "not (system.name in ['mame', 'neogeo', 'lcdgames', 'plugnplay',\ + \ 'vis']) and messSysName[messMode] == ''" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:64,\ + \ 123, 132" +- emulator: mame, mess, mamevirtual, same_cdi + build: libretro + kind: passes_path + names: [] + search_dir: ;/userdata/bios/ + passes_to: command line -rompath + fails_when_missing: false + depends_on_option: "not (system.name in ['mame', 'neogeo', 'lcdgames', 'plugnplay',\ + \ 'vis']) and not (messSysName[messMode] == '') and softList != ''" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:64,\ + \ 123, 197, 204" +- emulator: mame, mess, mamevirtual, same_cdi + build: libretro + kind: passes_path + names: [] + search_dir: ;/userdata/bios/ + passes_to: command line -rompath + fails_when_missing: false + depends_on_option: "not (system.name in ['mame', 'neogeo', 'lcdgames', 'plugnplay',\ + \ 'vis']) and not (messSysName[messMode] == '') and not (softList != '')" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:64,\ + \ 123, 197, 263" +- emulator: mame, mess, mamevirtual, same_cdi + build: libretro + kind: passes_path + names: [] + search_dir: /var/run/mame_artwork/;/usr/bin/mame/artwork/;/userdata/bios/lr-mame/artwork;/userdata/bios/mame/artwork;/userdata/decorations + passes_to: command line -artpath + fails_when_missing: false + depends_on_option: "system.config['core'] != 'same_cdi' and not (system.isOptSet('sharemameart')\ + \ and (not system.getOptBoolean('sharemameart'))) and not system.name == 'ti99'" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:412-416,\ + \ 419-420" +- emulator: mame, mess, mamevirtual, same_cdi + build: libretro + kind: passes_path + names: [] + search_dir: /var/run/mame_artwork/;/usr/bin/mame/artwork/;/userdata/bios/lr-mame/artwork + passes_to: command line -artpath + fails_when_missing: false + depends_on_option: "system.config['core'] != 'same_cdi' and not (not (system.isOptSet('sharemameart')\ + \ and (not system.getOptBoolean('sharemameart')))) and not system.name == 'ti99'" + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:412-415,\ + \ 418-420" +- emulator: mame, mess, mamevirtual, same_cdi + build: libretro + kind: passes_path + names: [] + search_dir: /userdata/bios/mame/samples + passes_to: command line -samplepath + fails_when_missing: false + depends_on_option: not system.name == 'ti99' + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:431-432,\ + \ 435" diff --git a/scripts/scraper/knulli_launchers.py b/scripts/scraper/knulli_launchers.py new file mode 100644 index 000000000..8ebc0fd59 --- /dev/null +++ b/scripts/scraper/knulli_launchers.py @@ -0,0 +1,839 @@ +"""KNULLI's launcher layer, read from its configgen and its board scripts. + +KNULLI is built on Batocera and keeps configgen under its own package +(package/system/knulli-configgen). Where a generator is still Batocera's +code (DuckStation, xemu, mupen64plus, x16emu, the flycast environment, the +hatari and melonDS forms batocera_launchers accepts), Batocera's extractor +reads it. The others are older or KNULLI's own: the lexaloffle generator logs +and returns instead of raising, libretroOptions is one function of +`if (system.config['core'] == ...)` blocks, and the PICO-8 binary is +installed by board/fsoverlay/usr/bin/knulli-install-pico8. + +The remaining generators name a /userdata/bios path in a command line, a +config key, an environment variable, a copy or a presence test. bios_sites() +reads each of those uses with ast and the context that consumes it, and +GENERIC_SITES says, per generator, whether the path is a file or a directory. +A generator in that table that no longer names a BIOS path raises +LauncherShapeError, as every other extractor does. +""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from typing import Any + +try: + from .batocera_launchers import ( + Configgen, + _path_value, + _render, + _system_branches, + duckstation, + duckstation_legacy, + environment_directories, + hatari, + melonds, + mupen64plus, + x16emu, + xemu, + ) + from .configgen_launchers import ( + Launcher, + LauncherShapeError, + Source, + _assigned_name, + ) + from .shell_launchers import LauncherPatternMissing, Script, source_ref +except ImportError: + from batocera_launchers import ( # type: ignore[no-redef] + Configgen, + _path_value, + _render, + _system_branches, + duckstation, + duckstation_legacy, + environment_directories, + hatari, + melonds, + mupen64plus, + x16emu, + xemu, + ) + from configgen_launchers import ( # type: ignore[no-redef] + Launcher, + LauncherShapeError, + Source, + _assigned_name, + ) + from shell_launchers import ( # type: ignore[no-redef] + LauncherPatternMissing, + Script, + source_ref, + ) + +CONFIGGEN = "package/system/knulli-configgen/configgen/configgen" +GENERATORS = f"{CONFIGGEN}/generators" +LIBRETRO_OPTIONS = f"{GENERATORS}/libretro/libretroOptions.py" +PICO8_INSTALLER = "board/fsoverlay/usr/bin/knulli-install-pico8" + +# Batocera's extractors that read KNULLI's generators unchanged. +SHARED = ( + duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus, x16emu, + environment_directories, +) + + +def _lines(node: ast.AST) -> tuple[int, int]: + """First and last line of a node.""" + first: int = getattr(node, "lineno", 0) + return first, getattr(node, "end_lineno", None) or first + + +def _bound(node: ast.AST) -> ast.expr | None: + """The value of an assignment statement.""" + value = getattr(node, "value", None) + return value if isinstance(value, ast.expr) else None + + +def configgen(read: Callable[[str], str]) -> Configgen: + """KNULLI's configgen: Batocera's reader at KNULLI's package, no legacy map.""" + return Configgen(read, CONFIGGEN, legacy_map=False) + + +def _error_guard( + src: Source, scope: ast.FunctionDef, test: Callable[[ast.expr], bool], what: str +) -> tuple[ast.If, ast.JoinedStr]: + """`if : eslog.error(f"..."); return -1` and its message.""" + found = [] + for node in ast.walk(scope): + if not (isinstance(node, ast.If) and test(node.test) and len(node.body) == 2): + continue + logged, returned = node.body + if ( + isinstance(logged, ast.Expr) + and isinstance(logged.value, ast.Call) + and ast.unparse(logged.value.func) == "eslog.error" + and logged.value.args + and isinstance(logged.value.args[0], ast.JoinedStr) + and isinstance(returned, ast.Return) + ): + found.append((node, logged.value.args[0])) + if len(found) != 1: + src.fail(f"expected one `if {what}: eslog.error(...); return`, found {len(found)}") + return found[0] + + +def lexaloffle(cg: Configgen) -> list[Launcher]: + """Per system, BIN_PATH under /userdata/bios run as the emulator binary.""" + module = "lexaloffle.lexaloffleGenerator" + src = cg.generator(module) + generate = src.function("generate") + defined = { + name: node for node in src.tree.body if (name := _assigned_name(node)) is not None + } + constants = dict(cg.paths) + for name, node in defined.items(): + value = _path_value(_bound(node), constants) + if value is not None: + constants[name] = value + missing, missing_message = _error_guard( + src, generate, + lambda t: isinstance(t, ast.UnaryOp) and isinstance(t.op, ast.Not) + and isinstance(t.operand, ast.Call) and ast.unparse(t.operand.func).endswith(".exists"), + "not .exists()", + ) + binary = ast.unparse(missing.test.operand.func).removesuffix(".exists") # type: ignore[attr-defined] + access, access_message = _error_guard( + src, generate, + lambda t: isinstance(t, ast.UnaryOp) and isinstance(t.op, ast.Not) + and ast.unparse(t.operand).startswith(f"os.access({binary}"), + f"not os.access({binary}, ...)", + ) + commands = [ + node for node in ast.walk(generate) + if isinstance(node, ast.Assign) and isinstance(node.value, ast.List) + and node.value.elts and ast.unparse(node.value.elts[0]) == binary + ] + if len(commands) != 1: + src.fail(f"expected one command line starting with {binary}, found {len(commands)}") + prefix = cg.bios.rstrip("/") + "/" + launchers = [] + for system, branch in _system_branches(generate): + bound = [ + stmt for stmt in branch.body + if _assigned_name(stmt) == binary and isinstance(_bound(stmt), ast.Name) + ] + if len(bound) != 1: + src.fail(f"the {system} branch does not set {binary} once") + constant = ast.unparse(bound[0].value) # type: ignore[attr-defined] + path = constants.get(constant) + if path is None or not path.startswith(prefix): + src.fail(f"{constant} is not a path under {cg.bios}") + messages = [_render(m, binary, path) for m in (missing_message, access_message)] + launchers.append(Launcher( + emulator=cg.emulator(module), build="standalone", kind="fixed_name", + slot=system, names=[path[len(prefix):]], case_insensitive=False, + search_dir=cg.bios, passes_to="exec", + fails_when_missing=", ".join(messages), + source_ref=src.ref([ + src.span(defined[constant]), src.span(branch.test), src.span(bound[0]), + _lines(missing), _lines(access), + src.span(commands[0]), + ]), + )) + if not launchers: + src.fail("generate no longer branches on system.name") + return launchers + + +def pico8_installer(read: Callable[[str], str], bios: str) -> list[Launcher]: + """knulli-install-pico8: unzips the Raspberry Pi build and moves it into the BIOS folder.""" + script = Script(PICO8_INSTALLER, read(PICO8_INSTALLER)) + lines: set[int] = set() + archive_line, archive = script.find(r"\$\(ls -d (\S+\.zip)", "the PICO-8 zip lookup") + lines.add(archive_line) + guard_line, _ = script.find(r'^if \[ -z \$PICO_8_ZIPFILE \]', "the missing zip test") + message_line, message = script.find(r'^\s*echo "([^"]+)"', "the missing zip message", guard_line) + lines |= {guard_line, message_line} + names: list[str] = [] + prefix = bios.rstrip("/") + "/" + for number, line in script.code(): + moved = re.match(r"\s*mv\s+(\S+)\s+(\S+)\s*$", line) + if moved is None: + continue + used: set[int] = set() + source = script.expand(moved.group(1), number, used) + target = script.expand(moved.group(2).strip('"'), number, used) + if not target.startswith(prefix): + continue + if target.endswith("/"): + target += source.rsplit("/", 1)[-1] + names.append(target[len(prefix):]) + lines |= used | {number} + if not names: + raise script.missing(f"mv into {bios}") + folder = names[0].rsplit("/", 1)[0] + return [Launcher( + emulator="lexaloffle", build="standalone", kind="moves", names=names, + case_insensitive=False, search_dir=bios, + passes_to=f"moved from {archive.group(1)} into {prefix}{folder}", + fails_when_missing=message.group(1), + source_ref=source_ref([(PICO8_INSTALLER, lines)]), + )] + + +def _quoted(node: ast.AST) -> str | None: + """The value of a '"name"' core option literal.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + value = node.value + if len(value) >= 2 and value[0] == value[-1] == '"': + return value[1:-1] + return None + + +def _cores_of(test: ast.expr) -> list[str]: + """Core names `system.config['core'] == 'x'`, `in [...]` or an `or` of them tests.""" + if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.Or): + return [core for value in test.values for core in _cores_of(value)] + if ( + isinstance(test, ast.Compare) + and ast.unparse(test.left) == "system.config['core']" + and len(test.ops) == 1 + ): + right = test.comparators[0] + if isinstance(test.ops[0], ast.Eq) and isinstance(right, ast.Constant): + return [str(right.value)] + if isinstance(test.ops[0], ast.In) and isinstance(right, (ast.List, ast.Tuple)): + return [str(e.value) for e in right.elts if isinstance(e, ast.Constant)] + return [] + + +# libretro core options whose value names the BIOS file the core opens, +# the same two batocera_launchers.FILE_OPTIONS reads. +FILE_OPTIONS = ("o2em_bios", "neocd_bios") + + +def core_file_options(cg: Configgen) -> list[Launcher]: + """if isOptSet(o): save(o, option) elif ...: save(o, '"a"') else: save(o, '"b"').""" + src = cg.source(LIBRETRO_OPTIONS) + function = src.function("generateCoreSettings") + launchers = [] + for setting in FILE_OPTIONS: + chains = [ + node for node in ast.walk(function) + if isinstance(node, ast.If) + and ast.unparse(node.test) == f"system.isOptSet({setting!r})" + ] + if len(chains) != 1: + src.fail(f"expected one `if system.isOptSet({setting!r})`, found {len(chains)}") + chain = chains[0] + names: list[str] = [] + conditions: list[str] = [] + branch: ast.If | None = chain + tail: list[ast.stmt] = chain.orelse + while branch is not None: + tail = branch.orelse + if branch is not chain: + saved = _saved(branch.body, setting) + if saved is None: + src.fail(f"a branch of {setting} saves no literal name") + names.append(saved) + conditions.append(ast.unparse(branch.test)) + branch = tail[0] if len(tail) == 1 and isinstance(tail[0], ast.If) else None + default = _saved(tail, setting) + if default is None: + src.fail(f"{setting} has no literal default") + names.append(default) + holder = next( + ( + node for node in ast.walk(function) + if isinstance(node, ast.If) and _cores_of(node.test) + and any(child is chain for child in ast.walk(node)) + ), + None, + ) + if holder is None: + src.fail(f"{setting} is not set under a core test") + depends = setting if not conditions else f"{setting} (default by {', '.join(conditions)})" + launchers.append(Launcher( + emulator=", ".join(_cores_of(holder.test)), build="libretro", kind="core_option", + names=names, passes_to=f"core option {setting}", depends_on_option=depends, + source_ref=src.ref([src.span(holder.test), _lines(chain)]), + )) + return launchers + + +def _saved(body: list[ast.stmt], setting: str) -> str | None: + """The literal a one-statement body saves under setting.""" + if len(body) != 1 or not isinstance(body[0], ast.Expr): + return None + call = body[0].value + if ( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "save" + and len(call.args) == 2 + and isinstance(call.args[0], ast.Constant) + and call.args[0].value == setting + ): + return _quoted(call.args[1]) + return None + + +# --------------------------------------------------------------------------- +# Generic reading of the /userdata/bios paths a generator hands over. + + +@dataclass(frozen=True) +class GenericSites: + """A generator module whose BIOS path uses bios_sites() reads. + + kind is fixed_name when the path names a file, passes_path when it names + a directory. A copy is recorded as `copies` whatever the kind. caller + names the module and function whose core test selects the cores this + helper module runs for, emulator the name when no core test does. + """ + + path: str + kind: str + build: str = "standalone" + emulator: str | None = None + caller: tuple[str, str] | None = None + + +# The names configgen gives the command it builds. +COMMAND_NAMES = {"commandArray", "commandLine"} + +GENERIC_SITES = ( + GenericSites("dolphin/dolphinGenerator.py", "fixed_name"), + GenericSites("yabasanshiro/yabasanshiroGenerator.py", "fixed_name"), + GenericSites("rpcs3/rpcs3Generator.py", "fixed_name"), + GenericSites("ryujinx/ryujinxGenerator.py", "fixed_name"), + GenericSites("pcsx2/pcsx2Generator.py", "fixed_name"), + GenericSites("aethersx2/aethersx2Generator.py", "fixed_name"), + GenericSites("gsplus/gsplusGenerator.py", "passes_path"), + GenericSites("tsugaru/tsugaruGenerator.py", "passes_path"), + GenericSites("scummvm/scummvmGenerator.py", "passes_path"), + GenericSites("amiberry/amiberryGenerator.py", "passes_path"), + GenericSites("fsuae/fsuaeGenerator.py", "passes_path"), + GenericSites("mame/mameGenerator.py", "passes_path"), + GenericSites("openmsx/openmsxGenerator.py", "passes_path"), + GenericSites("fpinball/fpinballGenerator.py", "passes_path"), + GenericSites( + "libretro/libretroRetroarchCustom.py", "passes_path", "libretro", emulator="retroarch" + ), + GenericSites("libretro/libretroConfig.py", "passes_path", "libretro"), + GenericSites( + "libretro/libretroMAMEConfig.py", "passes_path", "libretro", + caller=("libretro/libretroConfig.py", "generateMAMEConfigs"), + ), +) + + +@dataclass +class _Site: + """One use of a BIOS path, and the record fields it contributes.""" + + path: str + passes_to: str + kind: str | None + lines: list[tuple[int, int]] + conditions: list[str] + cores: list[str] + expands: dict[str, list[str]] = field(default_factory=dict) + + +class _Reader: + """The BIOS path uses of one generator module.""" + + def __init__(self, cg: Configgen, src: Source) -> None: + self.cg = cg + self.src = src + self.bios = cg.bios.rstrip("/") + self.parents: dict[int, ast.AST] = {} + for parent in ast.walk(src.tree): + for child in ast.iter_child_nodes(parent): + self.parents[id(child)] = parent + self.constants = dict(cg.paths) + folder = src.path.rsplit("/", 1)[0] + for node in src.tree.body: + if ( + isinstance(node, ast.ImportFrom) + and node.level == 1 + and node.module + and node.module.endswith("Paths") + ): + paths = cg.source(f"{folder}/{node.module}.py") + self._define(paths.tree.body) + self._define(src.tree.body) + + def _define(self, body: list[ast.stmt]) -> None: + for node in body: + name = _assigned_name(node) + value = self.resolve(getattr(node, "value", None)) + if name is not None and value is not None: + self.constants[name] = value + + def under_bios(self, value: str | None) -> bool: + return value is not None and ( + value.rstrip("/") == self.bios or f"{self.bios}/" in value + ) + + def resolve(self, node: ast.AST | None) -> str | None: + """The path an expression spells, unknown parts of an f-string as .""" + if node is None: + return None + value = _path_value(node, self.constants) + if value is not None: + return value + if isinstance(node, ast.Constant) and isinstance(node.value, str): + text = node.value.strip('"') + return text if text.startswith("/") else None + if isinstance(node, ast.Call) and ast.unparse(node.func) == "str" and len(node.args) == 1: + return self.resolve(node.args[0]) + if isinstance(node, ast.JoinedStr): + parts = [] + for part in node.values: + if isinstance(part, ast.Constant): + parts.append(str(part.value)) + else: + inner = self.resolve(part.value) # type: ignore[attr-defined] + parts.append(inner if inner is not None else f"<{ast.unparse(part.value)}>") # type: ignore[attr-defined] + return "".join(parts).strip('"') + if ( + isinstance(node, ast.BinOp) + and isinstance(node.op, ast.Div) + and isinstance(node.right, (ast.Constant, ast.Name)) + ): + left = self.resolve(node.left) + right = node.right.value if isinstance(node.right, ast.Constant) else None + if isinstance(node.right, ast.Name): + right = self._placeholder(node.right) + if left is not None and isinstance(right, str): + return f"{left.rstrip('/')}/{right}" + return None + + def _placeholder(self, name: ast.Name) -> str | None: + """{var} for a comprehension variable over a literal list.""" + comprehension = self._comprehension(name) + if comprehension is None: + return None + return "{" + name.id + "}" + + def _comprehension(self, name: ast.Name) -> tuple[str, list[str]] | None: + node: ast.AST | None = name + while node is not None: + node = self.parents.get(id(node)) + if isinstance(node, (ast.GeneratorExp, ast.ListComp)): + for generator in node.generators: + if isinstance(generator.target, ast.Name) and generator.target.id == name.id: + values = self._literal_list(generator.iter, node) + return (name.id, values) if values else None + return None + + def _literal_list(self, node: ast.AST, scope_of: ast.AST) -> list[str] | None: + if isinstance(node, ast.List): + return [str(e.value) for e in node.elts if isinstance(e, ast.Constant)] + if isinstance(node, ast.Name): + scope = self.function_of(scope_of) + bound = [ + n for n in ast.walk(scope or self.src.tree) + if _assigned_name(n) == node.id and isinstance(_bound(n), ast.List) + ] + if len(bound) == 1: + return self._literal_list(_bound(bound[0]), scope_of) # type: ignore[arg-type] + return None + + def function_of(self, node: ast.AST) -> ast.FunctionDef | None: + while node is not None: + node = self.parents.get(id(node)) # type: ignore[assignment] + if isinstance(node, ast.FunctionDef): + return node + return None + + def statement_of(self, node: ast.AST) -> ast.stmt: + while not isinstance(node, ast.stmt): + node = self.parents[id(node)] + return node + + def maximal(self) -> Iterator[tuple[ast.AST, str]]: + """Each expression spelling a BIOS path that no larger one contains.""" + for node in ast.walk(self.src.tree): + if not isinstance(node, ast.expr): + continue + value = self.resolve(node) + if value is None and isinstance(node, ast.Constant) and isinstance(node.value, str): + # A line written to a config file: openmsx's settings.tcl. + written = re.search(rf"{re.escape(self.bios)}(?:/[^\s\"';]*)?", node.value) + parent = self.parents.get(id(node)) + if written and isinstance(parent, ast.Call) and ast.unparse(parent.func).endswith(".write"): + yield node, written.group(0) + continue + if not self.under_bios(value): + continue + parent = self.parents.get(id(node)) + if isinstance(parent, (ast.FormattedValue, ast.JoinedStr)): + continue + if isinstance(parent, ast.expr) and self.under_bios(self.resolve(parent)): + continue + if isinstance(parent, ast.Call) and ast.unparse(parent.func) == "str": + continue + yield node, value # type: ignore[misc] + + def conditions(self, node: ast.AST) -> tuple[list[str], list[tuple[int, int]]]: + """Tests of the ifs a node sits in, outermost first, and their lines. + + A presence test of a BIOS path is a guard, kept for its lines only. + """ + tests: list[str] = [] + spans: list[tuple[int, int]] = [] + child, parent = node, self.parents.get(id(node)) + while parent is not None and not isinstance(parent, ast.FunctionDef): + if isinstance(parent, ast.If) and child is not parent.test: + spans.append(self.src.span(parent.test)) + text = ast.unparse(parent.test) + if not self._is_guard(parent.test): + in_else = any(child is stmt for stmt in parent.orelse) + tests.append(f"not ({text})" if in_else else text) + child, parent = parent, self.parents.get(id(parent)) + return list(reversed(tests)), spans + + def _is_guard(self, test: ast.expr) -> bool: + for n in ast.walk(test): + if not ( + isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "exists" + ): + continue + receiver = n.func.value + if self.under_bios(self.resolve(receiver)): + return True + if isinstance(receiver, ast.Name) and self.local_value(receiver) is not None: + return True + return False + + def cores(self, node: ast.AST) -> list[str]: + child, parent = node, self.parents.get(id(node)) + while parent is not None: + if isinstance(parent, ast.If) and child is not parent.test and _cores_of(parent.test): + return _cores_of(parent.test) + child, parent = parent, self.parents.get(id(parent)) + return [] + + def uses(self, node: ast.AST, value: str) -> Iterator[tuple[ast.AST, str | None, str | None]]: + """(node read, passes_to, kind) for each consumer of a BIOS path expression. + + A path bound to a local name is followed to each read of that name. + A use with no consumer (a presence test, a mkdir) yields passes_to None. + """ + parent = self.parents.get(id(node)) + if isinstance(parent, ast.Assign) and len(parent.targets) == 1: + target = parent.targets[0] + if isinstance(target, ast.Name): + scope = self.function_of(parent) + if scope is None: + return + for read in ast.walk(scope): + if ( + isinstance(read, ast.Name) + and read.id == target.id + and isinstance(read.ctx, ast.Load) + and read.lineno > parent.lineno + ): + yield from self.uses(read, value) + return + if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant): + yield node, str(target.slice.value), None + return + yield node, *self.consumer(node, value) + + def _command_line(self, display: ast.List | ast.Tuple) -> bool: + """Whether a list display is (part of) the command configgen runs. + + It is assigned or added to commandArray (or commandLine for the + libretro MAME cmd file), or it starts with an absolute binary path. + """ + first = display.elts[0] if display.elts else None + if isinstance(first, ast.Constant) and str(first.value).startswith("/"): + return True + holder = self.parents.get(id(display)) + targets: list[ast.expr] = [] + if isinstance(holder, ast.Assign): + targets = holder.targets + elif isinstance(holder, ast.AugAssign): + targets = [holder.target] + elif ( + isinstance(holder, ast.Call) + and isinstance(holder.func, ast.Attribute) + and holder.func.attr in ("extend", "append") + ): + targets = [holder.func.value] + names = { + ast.unparse(t.value if isinstance(t, ast.Subscript) else t) for t in targets + } + return bool(names & COMMAND_NAMES) + + def local_value(self, name: ast.Name) -> str | None: + """The BIOS path a local name is bound to once in its function.""" + scope = self.function_of(name) + if scope is None: + return None + bound = [ + self.resolve(_bound(n)) + for n in ast.walk(scope) if _assigned_name(n) == name.id + ] + return bound[0] if len(bound) == 1 and self.under_bios(bound[0]) else None + + def consumer(self, node: ast.AST, value: str) -> tuple[str | None, str | None]: + parent = self.parents.get(id(node)) + if isinstance(parent, ast.Attribute): + return None, None + if isinstance(parent, (ast.List, ast.Tuple)): + holder = self.parents.get(id(parent)) + if isinstance(holder, (ast.For, ast.comprehension)): + return None, None + if not self._command_line(parent): + return None, None + index = next(i for i, e in enumerate(parent.elts) if e is node) + if isinstance(node, ast.JoinedStr) and isinstance(node.values[0], ast.Constant): + flag = str(node.values[0].value) + if flag.startswith("-") and flag.endswith("=") and value.startswith(flag): + return f"command line {flag[:-1]}", None + previous = parent.elts[index - 1] if index else None + if isinstance(previous, ast.Constant) and str(previous.value).startswith("-"): + return f"command line {previous.value!s}", None + if index: + return f"command line argument {index}", None + return None, None + if isinstance(parent, ast.Call): + func = parent.func + attr = func.attr if isinstance(func, ast.Attribute) else None + consts = [a.value for a in parent.args if isinstance(a, ast.Constant)] + if attr == "save" and len(parent.args) == 2 and parent.args[1] is node and consts: + return str(consts[0]), None + if attr == "set" and len(parent.args) == 3 and parent.args[2] is node and len(consts) >= 2: + return f"[{consts[0]!s}] {consts[1]!s}", None + receiver = func.value if isinstance(func, ast.Attribute) else None + if attr in ("copy", "copyfile", "copy2") and receiver is not None and ast.unparse(receiver) == "shutil": + source, target = parent.args[0], parent.args[1] + if source is node: + return f"copied to {self.resolve(target) or ast.unparse(target)}", "copies" + return f"copied from {self.resolve(source) or ast.unparse(source)}", "copies" + if ( + attr == "append" + and isinstance(receiver, ast.Name) + and receiver.id in COMMAND_NAMES + and isinstance(node, ast.JoinedStr) + and isinstance(node.values[0], ast.Constant) + ): + flag = str(node.values[0].value) + if flag.startswith("-") and flag.endswith("=") and value.startswith(flag): + return f"command line {flag[:-1]}", None + if attr == "write" and isinstance(node, ast.Constant): + return f"written: {str(node.value).strip()}", None + return None, None + if isinstance(parent, ast.Dict): + key = next( + (k for k, v in zip(parent.keys, parent.values) if v is node), None + ) + if isinstance(key, ast.Constant): + holder = self.parents.get(id(parent)) + is_env = ( + isinstance(holder, ast.keyword) and holder.arg == "env" + ) or _assigned_name(holder) == "env" # type: ignore[arg-type] + return (f"environment {key.value!s}" if is_env else str(key.value)), None + return None, None + + def sites(self) -> list[_Site]: + found: list[_Site] = [] + guards: list[tuple[ast.AST, str]] = [] + for node, value in self.maximal(): + if isinstance(self.statement_of(node), (ast.Import, ast.ImportFrom)): + continue + if self.function_of(node) is None and isinstance(self.parents.get(id(node)), ast.Assign): + continue + for read, passes_to, kind in self.uses(node, value): + parent = self.parents.get(id(read)) + if ( + passes_to is None + and isinstance(parent, ast.Attribute) + and parent.attr == "exists" + ): + guards.append((read, value)) + if passes_to is None: + continue + found.append(self._site(read, value, passes_to, kind, node)) + for read, value in guards: + if any(site.path == value for site in found): + site = next(s for s in found if s.path == value) + site.lines.append(self.src.span(self.statement_of(read))) + continue + found.append(self._guard_site(read, value)) + return sorted(found, key=lambda site: site.lines[0]) + + def _site( + self, read: ast.AST, value: str, passes_to: str, kind: str | None, defined: ast.AST + ) -> _Site: + tests, spans = self.conditions(read) + lines = [self.src.span(self.statement_of(read)), *spans] + if defined is not read: + # A path bound in one branch and read after the if: the branch + # that bound it is a condition of the read. + bound_tests, bound_spans = self.conditions(defined) + tests = list(dict.fromkeys([*bound_tests, *tests])) + lines += [self.src.span(self.statement_of(defined)), *bound_spans] + return _Site(value, passes_to, kind, lines, tests, self.cores(read), self._expands(read)) + + def _guard_site(self, read: ast.AST, value: str) -> _Site: + """A presence test whose if body is the only consumer of the path.""" + test = self.statement_of(read) + if not isinstance(test, ast.If): + self.src.fail(f"line {test.lineno} tests a BIOS path outside an if") + first = test.body[0] + described = ast.unparse(first) + call = first.value if isinstance(first, ast.Expr) else None + if isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute) and call.func.attr == "set": + literal = [a.value for a in call.args if isinstance(a, ast.Constant)] + if len(literal) == 3: + described = f"[{literal[0]!s}] {literal[1]!s} = {literal[2]!s}" + tests, spans = self.conditions(test) + return _Site( + value, f"presence sets {described}", None, + [(test.lineno, first.end_lineno), *spans], # type: ignore[list-item] + tests, self.cores(test), self._expands(read), + ) + + def _expands(self, node: ast.AST) -> dict[str, list[str]]: + found: dict[str, list[str]] = {} + for name in ast.walk(node): + if isinstance(name, ast.Name): + bound = self._comprehension(name) + if bound is not None: + found[bound[0]] = bound[1] + return found + + +def _caller_cores(cg: Configgen, caller: tuple[str, str]) -> list[str]: + path, function = caller + src = cg.source(f"{cg.generators}/{path}") + for node in ast.walk(src.tree): + if not isinstance(node, ast.If) or not _cores_of(node.test): + continue + if any( + isinstance(n, ast.Call) and ast.unparse(n.func).endswith(f".{function}") + for stmt in node.body for n in ast.walk(stmt) + ): + return _cores_of(node.test) + src.fail(f"no core test calls {function}") + + +def bios_sites(cg: Configgen, spec: GenericSites) -> list[Launcher]: + """One record per BIOS path and consumer of a generator module.""" + src = cg.source(f"{cg.generators}/{spec.path}") + reader = _Reader(cg, src) + sites = reader.sites() + if not sites: + src.fail("hands over no /userdata/bios path") + module = spec.path.removesuffix(".py").replace("/", ".") + caller_cores = _caller_cores(cg, spec.caller) if spec.caller else [] + grouped: dict[tuple[str, str, str, tuple[str, ...]], list[_Site]] = {} + for site in sites: + flag = site.passes_to.removeprefix("command line ") + if site.path.startswith(f"{flag}="): + site.path = site.path[len(flag) + 1:] + emulator = ( + spec.emulator + or ", ".join(site.cores or caller_cores) + or (cg.emulator(module) if spec.build == "standalone" else "") + ) + if not emulator: + src.fail(f"no core runs the {site.passes_to} use at line {site.lines[0][0]}") + key = (emulator, site.path, site.passes_to, tuple(site.conditions)) + grouped.setdefault(key, []).append(site) + prefix = cg.bios.rstrip("/") + "/" + launchers = [] + for (emulator, path, passes_to, _conditions), group in grouped.items(): + kind = group[0].kind or spec.kind + conditions = list(dict.fromkeys(c for site in group for c in site.conditions)) + expands = {k: v for site in group for k, v in site.expands.items()} + names: list[str] = [] + search_dir = path + if kind in ("fixed_name", "copies"): + if not path.startswith(prefix): + src.fail(f"{path} names no file under {cg.bios}") + names, search_dir = [path[len(prefix):]], cg.bios + launchers.append(Launcher( + emulator=emulator, build=spec.build, kind=kind, names=names, + expands=expands or None, + case_insensitive=False if names else None, + search_dir=search_dir, passes_to=passes_to, fails_when_missing=False, + depends_on_option=" and ".join(conditions) or None, + source_ref=src.ref([span for site in group for span in site.lines]), + )) + return launchers + + +EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = ( + *SHARED, lexaloffle, core_file_options, +) + + +def extract_launchers(read: Callable[[str], str]) -> list[dict[str, Any]]: + """Every launcher record, read through `read(repo-relative path)`.""" + cg = configgen(read) + launchers = [launcher for extractor in EXTRACTORS for launcher in extractor(cg)] + launchers += pico8_installer(read, cg.bios) + for spec in GENERIC_SITES: + launchers += bios_sites(cg, spec) + return [launcher.entry() for launcher in launchers] + + +__all__ = [ + "CONFIGGEN", "GENERIC_SITES", "LauncherPatternMissing", "LauncherShapeError", + "bios_sites", "configgen", "core_file_options", "extract_launchers", "lexaloffle", + "pico8_installer", +] diff --git a/scripts/scraper/knulli_scraper.py b/scripts/scraper/knulli_scraper.py index 7c2936902..47af8e50c 100644 --- a/scripts/scraper/knulli_scraper.py +++ b/scripts/scraper/knulli_scraper.py @@ -19,9 +19,10 @@ Verification (knulli-systems at the release tag): 10834d00, es-app/src/ApiSystem.cpp:764-774) and by knulli-support:69. es_systems.yml and configgen-defaults.yml have Batocera's shape, so the cores -are read with Batocera's code. The configgen generators do not: the hatari, -melonds and gsplus generators and libretroOptions.py are older forms than the -ones batocera_launchers reads, so no launcher layer is written. +are read with Batocera's code. The launcher layer comes from KNULLI's own +configgen package and board scripts at the same tag (knulli_launchers): +Batocera's extractors where a generator is still Batocera's code, KNULLI's +where it is older or its own. """ from __future__ import annotations @@ -44,6 +45,7 @@ from .batocera_scraper import ( requirements_from_systems, ) from .es_emulators import attach_system_cores +from .knulli_launchers import extract_launchers PLATFORM_NAME = "knulli" @@ -124,6 +126,9 @@ class Scraper(BaseScraper): # configgen hands a libretro core /userdata/saves/, as # Batocera's does: the systems each core runs for are its save folders. result["libretro_systems"] = es_systems_libretro_systems(es_systems, dropped) + result["launchers"] = extract_launchers( + lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path) + ) return result diff --git a/tests/test_launchers_knulli.py b/tests/test_launchers_knulli.py new file mode 100644 index 000000000..8ea3070b0 --- /dev/null +++ b/tests/test_launchers_knulli.py @@ -0,0 +1,523 @@ +"""KNULLI's launcher layer, read from its configgen and board scripts. + +The fixtures are cut-down copies of the blocks each extractor reads at +knulli-linux 20260511. Records must carry the names in the order the code +writes or tries them, cite the fixture's own lines, and a construct that is +gone must stop the scrape instead of returning nothing. +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import sys +import textwrap +import unittest +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from scraper import knulli_launchers as kl # noqa: E402 +from scraper.batocera_launchers import hatari, melonds # noqa: E402 +from scraper.configgen_launchers import LauncherShapeError # noqa: E402 + +G = kl.GENERATORS + + +def text(block: str) -> str: + return textwrap.dedent(block).lstrip("\n") + + +PATHS = text( + """ + from pathlib import Path + from typing import Final + + KNULLI_SHARE_DIR: Final = Path('/usr/share/knulli') + DATAINIT_DIR: Final = KNULLI_SHARE_DIR / 'datainit' + USERDATA: Final = Path('/userdata') + HOME: Final = USERDATA / 'system' + CONFIGS: Final = HOME / 'configs' + BIOS: Final = USERDATA / 'bios' + ROMS: Final = USERDATA / 'roms' + """ +) + +IMPORTER = text( + """ + from typing import Final + + _GENERATOR_MAP: Final[dict[str, tuple[str, str]]] = { + 'lexaloffle': ('lexaloffle.lexaloffleGenerator', 'LexaloffleGenerator'), + 'yabasanshiro': ('yabasanshiro.yabasanshiroGenerator', 'YabasanshiroGenerator'), + 'ryujinx': ('ryujinx.ryujinxGenerator', 'RyujinxGenerator'), + 'dolphin': ('dolphin.dolphinGenerator', 'DolphinGenerator'), + 'scummvm': ('scummvm.scummvmGenerator', 'ScummVMGenerator'), + 'openmsx': ('openmsx.openmsxGenerator', 'OpenmsxGenerator'), + 'hatari': ('hatari.hatariGenerator', 'HatariGenerator'), + 'melonds': ('melonds.melondsGenerator', 'MelonDSGenerator'), + } + """ +) + +# lexaloffleGenerator.py: the binary constants, the system branches, the two +# checks that log and return, and the command. +LEXALOFFLE = text( + """ + from ...batoceraPaths import BIOS, HOME, ROMS + + PICO8_BIN_PATH: Final = BIOS / "pico-8" / "pico8" + VOX_BIN_PATH: Final = BIOS / "voxatron" / "vox" + + class LexaloffleGenerator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + if (system.name == "pico8"): + BIN_PATH=PICO8_BIN_PATH + elif (system.name == "voxatron"): + BIN_PATH=VOX_BIN_PATH + else: + return -1 + if not BIN_PATH.exists(): + eslog.error(f"Lexaloffle official binary not found at {BIN_PATH}") + return -1 + if not os.access(BIN_PATH, os.X_OK): + eslog.error(f"File {BIN_PATH} is not set as executable") + return -1 + commandArray = [BIN_PATH] + return Command.Command(array=commandArray, env={}) + """ +) + +# libretroOptions.py: one function of core blocks, the option or a literal. +OPTIONS = text( + """ + def generateCoreSettings(coreSettings, system, rom, guns, wheels): + if (system.config['core'] == 'o2em'): + coreSettings.save('o2em_vkbd_transparency', '"25"') + if system.isOptSet('o2em_bios'): + coreSettings.save('o2em_bios', '"' + system.config['o2em_bios'] + '"') + elif system.name == 'videopacplus': + coreSettings.save('o2em_bios', '"g7400.bin"') + else: + coreSettings.save('o2em_bios', '"o2rom.bin"') + if (system.config['core'] == 'neocd'): + if system.isOptSet('neocd_bios'): + coreSettings.save('neocd_bios', '"' + system.config['neocd_bios'] + '"') + else: + coreSettings.save('neocd_bios', '"neocd_z.rom (CDZ)"') + """ +) + +PICO8 = text( + """ + #!/bin/sh + + TARGET_DIR="/userdata/bios" + PICO_DIR="$TARGET_DIR/pico-8" + ROMS_DIR="/userdata/roms/pico8" + TEMP_INSTALL_DIR="$ROMS_DIR/pico8-install" + PICO_8_64_FILE="$TEMP_INSTALL_DIR/pico-8/pico8_64" + PICO_8_DAT_FILE="$TEMP_INSTALL_DIR/pico-8/pico8.dat" + PICO_8_ZIPFILE=$(ls -d /userdata/roms/pico8/pico-8*raspi*.zip 2>/dev/null | head -1) + + if [ -z $PICO_8_ZIPFILE ] || [ ! -f "$PICO_8_ZIPFILE" ]; then + echo "Please add the Raspberry Pi version of Pico-8 to roms/pico8 and try again." + exit 2 + fi + + mv $PICO_8_64_FILE "$PICO_DIR/pico8" + mv $PICO_8_DAT_FILE "$PICO_DIR/" + """ +) + +YABASANSHIRO = text( + """ + from ...batoceraPaths import BIOS + + YABA_BIOS: Final = BIOS / "saturn_bios.bin" + + class YabasanshiroGenerator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + commandArray = ["yabasanshiro", "-i", rom] + + if not (system.isOptSet('yaba_bios_hle') and system.config['yaba_bios_hle'] == '1'): + if YABA_BIOS.exists(): + commandArray[1:1] = ["-b", str(YABA_BIOS)] + return Command.Command(array=commandArray) + """ +) + +RYUJINX = text( + """ + from ...batoceraPaths import BIOS, CONFIGS + + ryujinxConf: Final = CONFIGS / "Ryujinx" + ryujinxKeys: Final = BIOS / "switch" / "prod.keys" + + class RyujinxGenerator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + if ryujinxKeys.exists(): + shutil.copyfile(ryujinxKeys, ryujinxConf / "system" / "prod.keys") + """ +) + +DOLPHIN_PATHS = text( + """ + from ...batoceraPaths import BIOS + + DOLPHIN_BIOS: Final = BIOS / "GC" + """ +) + +DOLPHIN = text( + """ + from .dolphinPaths import DOLPHIN_BIOS + + class DolphinGenerator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + if system.isOptSet("dolphin_SkipIPL") and system.getOptBoolean("dolphin_SkipIPL"): + ipl_regions = ["USA", "EUR", "JAP"] + if any((DOLPHIN_BIOS / region / "IPL.bin").exists() for region in ipl_regions): + dolphinSettings.set("Core", "SkipIPL", "False") + else: + dolphinSettings.set("Core", "SkipIPL", "True") + """ +) + +SCUMMVM = text( + """ + from ...batoceraPaths import BIOS + + scummExtra: Final = BIOS / "scummvm" / "extra" + + class ScummVMGenerator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + mkdir_if_not_exists(scummExtra) + commandArray = ["/usr/bin/scummvm", "-f"] + commandArray.extend( + [f"--joystick={id}", + f"--extrapath={scummExtra}", + f"{romName}"] + ) + """ +) + +OPENMSX = text( + """ + class OpenmsxGenerator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + with settings_tcl.open("a") as file: + file.write("filepool add -path /userdata/bios/Machines -types system_rom -position 1\\n") + """ +) + +LIBRETRO_CONFIG = text( + """ + def writeLibretroConfigToFile(retroarchConfig, system, rom): + if (system.config['core'] == 'puae') or (system.config['core'] == 'uae4arm'): + retroarchConfig['system_directory'] = '"/userdata/bios/amiga/"' + if system.config['core'] in [ 'mame', 'mess' ]: + libretroMAMEConfig.generateMAMEConfigs(controllers, system, rom, guns) + """ +) + +MAME_CONFIG = text( + """ + from ...batoceraPaths import BIOS + + def generateMAMEConfigs(playersControllers, system, rom, guns): + if system.name in [ 'mame', 'neogeo' ]: + commandLine += [ '-rompath', f'"{rom.parent};/userdata/bios/mame/;/userdata/bios/"' ] + if not system.isOptSet("sharemameart"): + artPath = f"/usr/bin/mame/artwork/;{BIOS / 'mame' / 'artwork'}" + else: + artPath = f"/usr/bin/mame/artwork/" + commandLine += [ '-artpath', artPath ] + mkdir_if_not_exists(BIOS / "mame" / "samples") + """ +) + +# hatariGenerator.py in KNULLI's older form: the language and model read +# with subscripts, the name built bare and joined to BIOS on the command line. +HATARI = text( + """ + from ...batoceraPaths import BIOS + + class HatariGenerator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + model_mapping = {"520st_auto": { "machine": "st", "tos": "auto" }} + commandArray = ["hatari", "--fullscreen"] + machine = "st" + tosversion = "auto" + if system.isOptSet("model") and system.config["model"] in model_mapping: + machine = model_mapping[system.config["model"]]["machine"] + tosversion = model_mapping[system.config["model"]]["tos"] + toslang = "us" + if system.isOptSet("language"): + toslang = system.config["language"] + commandArray += ["--machine", machine] + tos = HatariGenerator.findBestTos(BIOS, machine, tosversion, toslang) + commandArray += [ "--tos", BIOS / tos] + + @staticmethod + def findBestTos(biosdir, machine, tos_version, language) -> str: + all_languages = ["us", "uk", ""] + all_machines_bios = { + "st": ["etos256", "104"], + } + if machine in all_machines_bios: + for v_tos_version in l_tos: + for v_language in l_lang: + if "etos" in v_tos_version: + biosversion = v_tos_version + else: + biosversion = f"tos{v_tos_version}" + filename = f"{biosversion}{v_language}.img" + if os.path.exists(f"{biosdir}/{filename}"): + return filename + raise Exception(f"no bios found for machine {machine}") + """ +) + +MELONDS = text( + """ + from ...batoceraPaths import BIOS + + class MelonDSGenerator(Generator): + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + base_config = { + "DS": { + "FirmwarePath": str(BIOS / "firmware.bin"), + "BIOS7Path": str(BIOS / "bios7.bin"), + }, + "Emu": { + "ExternalBIOSEnable": True + }, + } + if system.isOptSet("melonds_console"): + base_config["Emu"]["ConsoleType"] = int(system.config["melonds_console"]) + else: + base_config["Emu"]["ConsoleType"] = 0 + """ +) + + +def tree(**overrides: str) -> dict[str, str]: + files = { + f"{kl.CONFIGGEN}/batoceraPaths.py": PATHS, + f"{G}/importer.py": IMPORTER, + f"{G}/lexaloffle/lexaloffleGenerator.py": LEXALOFFLE, + kl.LIBRETRO_OPTIONS: OPTIONS, + kl.PICO8_INSTALLER: PICO8, + f"{G}/yabasanshiro/yabasanshiroGenerator.py": YABASANSHIRO, + f"{G}/ryujinx/ryujinxGenerator.py": RYUJINX, + f"{G}/dolphin/dolphinPaths.py": DOLPHIN_PATHS, + f"{G}/dolphin/dolphinGenerator.py": DOLPHIN, + f"{G}/scummvm/scummvmGenerator.py": SCUMMVM, + f"{G}/openmsx/openmsxGenerator.py": OPENMSX, + f"{G}/libretro/libretroConfig.py": LIBRETRO_CONFIG, + f"{G}/libretro/libretroMAMEConfig.py": MAME_CONFIG, + f"{G}/hatari/hatariGenerator.py": HATARI, + f"{G}/melonds/melondsGenerator.py": MELONDS, + } + files.update(overrides) + return files + + +def configgen(**overrides: str) -> kl.Configgen: + files = tree(**overrides) + return kl.configgen(files.__getitem__) + + +def sites(path: str, kind: str, **fields: object) -> list[dict]: + spec = kl.GenericSites(path, kind, **fields) # type: ignore[arg-type] + return [launcher.entry() for launcher in kl.bios_sites(configgen(), spec)] + + +class SharedExtractors(unittest.TestCase): + """Batocera's extractors read KNULLI's older generator forms.""" + + def test_hatari_with_a_bare_name_joined_to_bios(self): + (st,) = hatari(configgen()) + self.assertEqual(st.names, ["etos256{L}.img", "tos104{L}.img"]) + self.assertEqual(st.expands, {"L": ["us", "uk", ""]}) + self.assertEqual(st.passes_to, "--machine st --tos ") + self.assertEqual(st.fails_when_missing, "no bios found for machine st") + self.assertEqual(st.depends_on_option, "model, language") + self.assertTrue(st.source_ref.startswith(f"{G}/hatari/hatariGenerator.py:")) + + def test_hatari_reads_the_knulli_package(self): + (st,) = hatari(configgen()) + self.assertNotIn("package/batocera", st.source_ref) + + def test_hatari_name_built_another_way_raises(self): + broken = HATARI.replace('filename = f"{biosversion}{v_language}.img"', "filename = x") + with self.assertRaises(LauncherShapeError): + hatari(configgen(**{f"{G}/hatari/hatariGenerator.py": broken})) + + def test_melonds_console_read_by_subscript(self): + (ds,) = melonds(configgen()) + self.assertEqual(ds.names, ["firmware.bin", "bios7.bin"]) + self.assertEqual(ds.depends_on_option, "melonds_console") + self.assertEqual(ds.source_ref, f"{G}/melonds/melondsGenerator.py:6-12, 15") + + def test_a_configgen_without_the_plain_map_raises(self): + with self.assertRaises(LauncherShapeError): + configgen(**{f"{G}/importer.py": "X = 1\n"}) + + +class Lexaloffle(unittest.TestCase): + def test_each_system_runs_its_binary(self): + pico8, vox = kl.lexaloffle(configgen()) + self.assertEqual((pico8.slot, pico8.names), ("pico8", ["pico-8/pico8"])) + self.assertEqual((vox.slot, vox.names), ("voxatron", ["voxatron/vox"])) + self.assertEqual(pico8.search_dir, "/userdata/bios") + self.assertEqual(pico8.passes_to, "exec") + + def test_the_two_checks_are_the_failure(self): + pico8, _ = kl.lexaloffle(configgen()) + self.assertEqual( + pico8.fails_when_missing, + "Lexaloffle official binary not found at /userdata/bios/pico-8/pico8, " + "File /userdata/bios/pico-8/pico8 is not set as executable", + ) + + def test_the_lines_cited(self): + pico8, vox = kl.lexaloffle(configgen()) + path = f"{G}/lexaloffle/lexaloffleGenerator.py" + self.assertEqual(pico8.source_ref, f"{path}:3, 8-9, 14-20") + self.assertEqual(vox.source_ref, f"{path}:4, 10-11, 14-20") + + def test_a_raise_in_place_of_the_log_raises(self): + broken = LEXALOFFLE.replace( + 'eslog.error(f"Lexaloffle official binary not found at {BIN_PATH}")\n return -1', + 'raise BatoceraException(f"Lexaloffle official binary not found at {BIN_PATH}")', + ) + with self.assertRaises(LauncherShapeError): + kl.lexaloffle(configgen(**{f"{G}/lexaloffle/lexaloffleGenerator.py": broken})) + + +class PicoInstaller(unittest.TestCase): + def test_the_files_moved_into_bios(self): + (record,) = kl.pico8_installer(tree().__getitem__, "/userdata/bios") + self.assertEqual(record.kind, "moves") + self.assertEqual(record.names, ["pico-8/pico8", "pico-8/pico8.dat"]) + self.assertEqual( + record.passes_to, + "moved from /userdata/roms/pico8/pico-8*raspi*.zip into /userdata/bios/pico-8", + ) + self.assertEqual( + record.fails_when_missing, + "Please add the Raspberry Pi version of Pico-8 to roms/pico8 and try again.", + ) + self.assertEqual(record.source_ref, f"{kl.PICO8_INSTALLER}:3-12, 16-17") + + def test_an_installer_that_moves_nothing_into_bios_raises(self): + broken = PICO8.replace('"$PICO_DIR/', '"/tmp/') + with self.assertRaises(LauncherShapeError): + kl.pico8_installer(tree(**{kl.PICO8_INSTALLER: broken}).__getitem__, "/userdata/bios") + + +class CoreFileOptions(unittest.TestCase): + def test_option_then_literal_defaults(self): + o2em, neocd = kl.core_file_options(configgen()) + self.assertEqual(o2em.emulator, "o2em") + self.assertEqual(o2em.names, ["g7400.bin", "o2rom.bin"]) + self.assertEqual(o2em.depends_on_option, "o2em_bios (default by system.name == 'videopacplus')") + self.assertEqual(o2em.source_ref, f"{kl.LIBRETRO_OPTIONS}:2, 4-9") + self.assertEqual((neocd.names, neocd.depends_on_option), (["neocd_z.rom (CDZ)"], "neocd_bios")) + + def test_a_default_that_is_no_literal_raises(self): + broken = OPTIONS.replace("'\"o2rom.bin\"'", "default_bios") + with self.assertRaises(LauncherShapeError): + kl.core_file_options(configgen(**{kl.LIBRETRO_OPTIONS: broken})) + + +class BiosSites(unittest.TestCase): + def test_a_flag_on_the_command_line_under_a_presence_test(self): + (record,) = sites("yabasanshiro/yabasanshiroGenerator.py", "fixed_name") + self.assertEqual(record["names"], ["saturn_bios.bin"]) + self.assertEqual(record["passes_to"], "command line -b") + self.assertEqual( + record["depends_on_option"], + "not (system.isOptSet('yaba_bios_hle') and system.config['yaba_bios_hle'] == '1')", + ) + self.assertEqual(record["source_ref"], f"{G}/yabasanshiro/yabasanshiroGenerator.py:9-11") + + def test_a_copy_out_of_bios(self): + (record,) = sites("ryujinx/ryujinxGenerator.py", "fixed_name") + self.assertEqual(record["kind"], "copies") + self.assertEqual(record["names"], ["switch/prod.keys"]) + self.assertEqual(record["passes_to"], "copied to /userdata/system/configs/Ryujinx/system/prod.keys") + self.assertNotIn("depends_on_option", record) + + def test_a_presence_test_over_a_literal_list(self): + (record,) = sites("dolphin/dolphinGenerator.py", "fixed_name") + self.assertEqual(record["names"], ["GC/{region}/IPL.bin"]) + self.assertEqual(record["expands"], {"region": ["USA", "EUR", "JAP"]}) + self.assertEqual(record["passes_to"], "presence sets [Core] SkipIPL = False") + self.assertEqual(record["source_ref"], f"{G}/dolphin/dolphinGenerator.py:5, 7-8") + + def test_a_flag_written_into_the_argument(self): + (record,) = sites("scummvm/scummvmGenerator.py", "passes_path") + self.assertEqual(record["search_dir"], "/userdata/bios/scummvm/extra") + self.assertEqual(record["passes_to"], "command line --extrapath") + + def test_a_line_written_to_a_config(self): + (record,) = sites("openmsx/openmsxGenerator.py", "passes_path") + self.assertEqual(record["search_dir"], "/userdata/bios/Machines") + self.assertTrue(record["passes_to"].startswith("written: filepool add -path")) + + def test_a_core_test_names_the_emulator(self): + (record,) = sites("libretro/libretroConfig.py", "passes_path", build="libretro") + self.assertEqual(record["emulator"], "puae, uae4arm") + self.assertEqual(record["passes_to"], "system_directory") + + def test_a_helper_takes_the_cores_of_its_caller(self): + records = sites( + "libretro/libretroMAMEConfig.py", "passes_path", build="libretro", + caller=("libretro/libretroConfig.py", "generateMAMEConfigs"), + ) + self.assertEqual({r["emulator"] for r in records}, {"mame, mess"}) + # The else branch binds an art path outside /userdata/bios: no record. + rompath, art = records + self.assertEqual(rompath["search_dir"], ";/userdata/bios/mame/;/userdata/bios/") + self.assertEqual(rompath["passes_to"], "command line -rompath") + self.assertEqual(art["search_dir"], "/usr/bin/mame/artwork/;/userdata/bios/mame/artwork") + self.assertEqual(art["depends_on_option"], "not system.isOptSet('sharemameart')") + self.assertEqual(art["source_ref"], f"{G}/libretro/libretroMAMEConfig.py:6-7, 10") + + def test_a_generator_without_a_bios_path_raises(self): + files = tree(**{f"{G}/openmsx/openmsxGenerator.py": "def generate():\n return 1\n"}) + spec = kl.GenericSites("openmsx/openmsxGenerator.py", "passes_path") + with self.assertRaises(LauncherShapeError): + kl.bios_sites(kl.configgen(files.__getitem__), spec) + + +class PlatformFile(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.config = yaml.safe_load((REPO_ROOT / "platforms" / "knulli.yml").read_text()) + + def test_the_layer_is_written(self): + self.assertTrue(self.config["launchers"]) + + def test_every_reference_is_in_knulli_code(self): + for record in self.config["launchers"]: + self.assertTrue( + record["source_ref"].startswith((f"{kl.CONFIGGEN}/", kl.PICO8_INSTALLER)), record + ) + + def test_every_emulator_is_one_knulli_names(self): + cores = {*self.config["cores"], *self.config.get("standalone_cores", [])} + named = {n for record in self.config["launchers"] for n in record["emulator"].split(", ")} + # RetroArch itself, and the MAME core names configgen tests that + # KNULLI's es_systems.yml never offers. + self.assertEqual(sorted(named - cores), ["mamevirtual", "mess", "retroarch"]) + + +if __name__ == "__main__": + unittest.main()