mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-14 05:53:47 -06:00
feat: read the knulli launcher layer
knulli_launchers reads KNULLI's configgen and the PICO-8 installer at the tag the scraper pins: Batocera's extractors where the generator is still Batocera's code, KNULLI's own for lexaloffle, libretroOptions and knulli-install-pico8, and one AST reader for the BIOS paths the other generators pass on a command line, in a config key or the environment, copy or test for. 52 records.
This commit is contained in:
1 parent
10e4d925d0
commit
2c58f50a2d
4 files changed
+2008
-3
No files matched your search
@@ -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 <file>
|
||||
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 <file>
|
||||
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 <file>
|
||||
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 <file>
|
||||
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 <file>
|
||||
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: <romDirname>;/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: <romDirname>;/userdata/bios/mame;/userdata/bios;/userdata/roms/mame;<softDir>
|
||||
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: <romDirname>;/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: <rom.parent>;/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: <rom.parent>;/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: <softDir>;/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: <rom.parent>;/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"
|
||||
@@ -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 <test>: 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 <binary>.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 <expr>."""
|
||||
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",
|
||||
]
|
||||
@@ -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/<system>, 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
|
||||
|
||||
|
||||
|
||||
@@ -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 <file>")
|
||||
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"], "<rom.parent>;/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()
|
||||
Reference in new issue
Block a user