feat: add region and per-game file rules

This commit is contained in:
Abdessamad Derraz committed 2026-11-03 16:36:26 +01:00
1 parent 8d8689c2e4
commit dc337c893c
11 files changed
+1293

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
# Files a game needs beyond its system's region BIOS, decided by the game.
#
# Each entry names the core (profile), the system, what in the game
# decides it (`trigger`), and the files by the names the profile declares.
# `required: true` means the code refuses or fails the game without the
# file. A trigger is one of:
# extensions the file's lowercased extension
# header bytes of the game: `reader` names a reader of games/headers.yml and
# `values` the labels or values it must return (`field` for one of its fields)
# every_game every game of the system, with `one_of` when any single file serves
# `filename_override: true` marks a choice the core also takes from tags in
# the file name: a tag is a guess, so the installer keeps every file listed
# for that system and core.
#
# Every source_ref names the profile's own pin.
extras:
- system: nintendo-fds
profile: fceumm
trigger:
header:
reader: fds-image
values: [fds]
files: [disksys.rom]
required: true
source_ref: "libretro/libretro-fceumm@ac0d00a7 src/fds.c:624-625, src/fds.c:680-690, src/fceu.c:275-281"
- system: nintendo-fds
profile: mesen
trigger:
header:
reader: fds-image
values: [fds]
files: [disksys.rom]
required: true
source_ref: "libretro/Mesen@0102910c Core/RomLoader.cpp:40, Core/FdsLoader.cpp:44-58"
- system: nintendo-fds
profile: nestopia
trigger:
extensions: [.fds]
files: [disksys.rom]
required: true
source_ref: "libretro/nestopia@2110d21b libretro/libretro.cpp:2171, libretro/libretro.cpp:2182"
- system: nintendo-satellaview
profile: snes9x
trigger:
header:
reader: snes-bsx-header
values: [bsx]
files: [BS-X.bin]
required: true
source_ref: "libretro/snes9x@fae2fea0 libretro/libretro.cpp:1417, libretro/libretro.cpp:1511-1514"
- system: nintendo-satellaview
profile: bsnes
trigger:
extensions: [.bs]
files: [BS-X.bin]
required: true
source_ref: "libretro/bsnes-libretro@05439f96 bsnes/target-libretro/libretro.cpp:955-966"
- system: nintendo-sufami-turbo
profile: snes9x
trigger:
header:
reader: sufami-magic
values: [sufami-turbo]
files: [STBIOS.bin]
required: true
source_ref: "libretro/snes9x@fae2fea0 libretro/libretro.cpp:1458-1462, libretro/libretro.cpp:1505-1507"
- system: nintendo-sgb
profile: bsnes
trigger:
extensions: [.gb, .gbc]
files: [SGB1.sfc]
required: true
note: SGB1.sfc is the default of the bsnes_sgb_bios option, SGB2.sfc when the user picks it.
source_ref: "libretro/bsnes-libretro@05439f96 bsnes/target-libretro/libretro.cpp:937-952, bsnes/target-libretro/libretro.cpp:395-400, bsnes/target-libretro/libretro_core_options.h:689-693"
- system: nintendo-sgb
profile: mgba
trigger:
header:
reader: gb-sgb-flag
values: [sgb]
files: [sgb_bios.bin]
required: false
source_ref: "libretro/mgba@7a12d6d4 src/gb/gb.c:938-946, src/platform/libretro/libretro.c:2066-2116"
- system: nintendo-64dd
profile: mupen64plus_next
trigger:
extensions: [.ndd]
files: [IPL.n64]
required: true
note: The disk reaches the core through the N64 Disk Drive subsystem or a <game>.ndd beside the cartridge, never as content of its own.
source_ref: "libretro/mupen64plus-libretro-nx@15bada85 mupen64plus-core/src/main/main.c:953-985, libretro/libretro.c:643, libretro/libretro.c:1890-1904, libretro/libretro.c:658"
- system: nintendo-64dd
profile: parallel_n64
trigger:
extensions: [.ndd]
files: [64DD_IPL.bin]
required: false
source_ref: "libretro/parallel-n64@f87550d9 libretro/libretro.c:1370, libretro/libretro.c:951"
- system: nintendo-64dd
profile: ares
trigger:
extensions: [.ndd]
region_files: true
required: true
note: The IPL follows the disk's region word, see games/regions.yml.
source_ref: "ares-emulator/ares@d400eee9 mia/medium/nintendo-64dd.cpp:57-77, desktop-ui/emulator/nintendo-64dd.cpp:64-80"
- system: nintendo-64dd
profile: project64
trigger:
extensions: [.ndd]
region_files: true
required: true
source_ref: "project64/project64@24376e86 Source/Project64-core/N64System/N64Disk.cpp:828-840, Source/Project64-core/N64System/N64System.cpp:641-662"
- system: nintendo-64dd
profile: rosalies-mupen-gui
trigger:
extensions: [.ndd]
region_files: true
required: true
source_ref: "Rosalie241/RMG@4777ed3f Source/3rdParty/mupen64plus-core/src/device/dd/disk.c:405-480, Source/RMG-Core/MediaLoader.cpp:47-63"
- system: msx
profile: fmsx
trigger:
extensions: [.dsk, .fdi]
files: [DISK.ROM]
required: true
source_ref: "libretro/fmsx-libretro@4de11755 libretro.c:1220-1225, libretro.c:1303-1304, fMSX/MSX.c:514, fMSX/MSX.c:778-791"
- system: msx2plus
profile: bluemsx
trigger:
extensions: [.dsk]
files: [MSX2P.rom, MSX2PEXT.rom, MSX2PMUS.rom, PANASONICDISK.rom]
required: true
note: With the Auto machine a disk picks MSX2+ and never falls back to C-BIOS. The machine's ROM list is the profile's, from Machines/MSX2+/config.ini.
source_ref: "libretro/blueMSX-libretro@e3086eb5 libretro.c:156-235, libretro.c:1410-1446"
- system: snk-neogeo-cd
profile: neocd
trigger:
every_game: true
one_of: true
files: [neocd_f.rom, front-sp1.bin, neocd_sf.rom, neocd_t.rom, top-sp1.bin, neocd_st.rom, neocd_z.rom, neocd.bin, neocd_sz.rom, uni-bioscd.rom]
required: true
note: Drive models, not regions. The core option picks among the files found, the first listed by default.
source_ref: "libretro/neocd_libretro@b1e04c73 src/libretro_variables.cpp:143-176, src/libretro_variables.cpp:244-273, src/memory_cdintf.cpp:111-116"
- system: nintendo-ds
profile: melonds_ds
trigger:
header:
reader: nds-secure-area
values: [encrypted]
files: [bios7.bin, bios9.bin]
required: true
source_ref: "JesseTG/melonds-ds@f394adba src/libretro/config/console.cpp:379-392, src/libretro/config/console.cpp:419-424"
- system: nintendo-dsi
profile: melonds_ds
trigger:
header:
reader: nds-unit-code
values: [dsi]
files: [dsi_bios7.bin, dsi_bios9.bin, dsi_firmware.bin, dsi_nand.bin]
required: true
source_ref: "JesseTG/melonds-ds@f394adba src/libretro/config/console.cpp:238-256"
- system: nec-pc-engine
profile: mednafen_pce_fast
trigger:
extensions: [.cue, .ccd, .chd, .toc, .m3u]
files: [syscard3.pce]
required: true
note: syscard3.pce is the default of the pce_fast_cdbios option. HuCards need no file.
source_ref: "libretro/beetle-pce-fast-libretro@4eee99c4 libretro.c:1620-1628, libretro.c:65, libretro.c:1283-1290, libretro.c:1172-1180"
- system: nec-pc-engine
profile: beetle_pce
trigger:
extensions: [.cue, .ccd, .chd, .toc, .m3u]
files: [syscard3.pce]
required: true
note: syscard3.pce is the default of the pce_cdbios option. HuCards need no file.
source_ref: "libretro/beetle-pce-libretro@b96c11e0 libretro.cpp:1047-1063, libretro_core_options.h:659-674"
- system: nec-pc-engine
profile: beetle_pce
trigger:
header:
reader: pce-games-express
values: [games-express]
files: [gexpress.pce]
required: true
source_ref: "libretro/beetle-pce-libretro@b96c11e0 mednafen/pce/pce.cpp:374-410, mednafen/pce/pce.cpp:447-455"
- system: nec-pc-engine-cd
profile: geargrafx
trigger:
every_game: true
files: [syscard3.pce]
required: true
note: syscard3.pce is the default of the geargrafx_cdrom_bios option.
source_ref: "drhelius/Geargrafx@7c447b0b platforms/libretro/libretro.cpp:761-802, platforms/libretro/libretro.cpp:1645-1668"
- system: nec-pc-engine-cd
profile: geargrafx
trigger:
every_game: true
files: [gexpress.pce]
required: false
note: Loaded beside the System Card whenever present.
source_ref: "drhelius/Geargrafx@7c447b0b platforms/libretro/libretro.cpp:761-802, platforms/libretro/libretro.cpp:1645-1668"
- system: sega-dreamcast
profile: flycast
trigger:
header:
reader: dc-product-id
values: ["T7001D 50", "T40505D 50", T18702M, T0019M, T0020M, HDR-0187, "T15104D 50", MK-51152]
files: [dc_boot.bin]
required: true
note: For these product ids the core asks for the real BIOS and warns "This game requires a real BIOS" when it boots HLE.
source_ref: "flyinghead/flycast@e88b0ced core/emulator.cpp:267-279, core/emulator.cpp:613-622"
- system: sega-dreamcast
profile: flycast
trigger:
header:
reader: dc-product-id
field: disk_type
values: [MIL CD]
files: [dc_boot.bin]
required: true
note: Every MIL-CD disc asks for the real BIOS, as the product ids above do.
source_ref: "flyinghead/flycast@e88b0ced core/emulator.cpp:275-279, core/emulator.cpp:613-622"
- system: commodore-amiga
profile: puae
trigger:
extensions: [.adf, .adz, .fdi, .dms, .raw, .ipf]
files: [kick34005.A500]
required: true
filename_override: true
source_ref: "libretro/libretro-uae@73879370 libretro/libretro-dc.c:847-882, libretro/libretro-core.c:865-917, libretro/libretro-core.c:6526-6601, libretro/libretro-core.c:5609-5679"
- system: commodore-amiga
profile: puae
trigger:
extensions: [.hdf, .hdz, .lha, .slave, .info]
files: [kick40068.A1200]
required: true
filename_override: true
source_ref: "libretro/libretro-uae@73879370 libretro/libretro-dc.c:847-882, libretro/libretro-core.c:865-917, libretro/libretro-core.c:6526-6601, libretro/libretro-core.c:5609-5679"
- system: commodore-amiga-cd32
profile: puae
trigger:
extensions: [.cue, .ccd, .nrg, .mds, .iso, .chd]
files: [kick40060.CD32, kick40060.CD32.ext]
required: true
filename_override: true
source_ref: "libretro/libretro-uae@73879370 libretro/libretro-dc.c:847-882, libretro/libretro-core.c:865-917, libretro/libretro-core.c:6526-6601, libretro/libretro-core.c:5609-5679"
- system: atari-jaguar
profile: virtualjaguar
trigger:
extensions: [.cue, .cdi, .chd]
files: ["[BIOS] Atari Jaguar CD (World).j64"]
required: false
note: HLE boot by default, and an embedded CD BIOS when the real one is absent.
source_ref: "libretro/virtualjaguar-libretro@e7db5367 libretro.c:113, libretro.c:7081, libretro.c:6587-6604"
- system: atari-lynx
profile: handy
trigger:
header:
reader: lynx-header
values: [headerless]
files: [howard.o]
required: false
source_ref: "libretro/libretro-handy@bc55d462 lynx/cart.cpp:283-296, lynx/system.cpp:197-238"
+327
View File
@@ -0,0 +1,327 @@
# Which BIOS region a core opens for a game, beyond what the profiles say.
#
# The files of each region come from the profiles' `region:` fields
# (scripts/region.py build_region_index), never from this file. What is
# recorded here is what the code does around them: the game regions it
# sends to another region's file (`routes`), the region it falls back to
# when the game names none it knows (`default_region`), the order it picks
# from a multi-region disc (`preference`), the files it opens whatever the
# region (`region_free`), whether a missing region file is replaced by
# another region's (`fallback_across_regions`), and the cases where even a
# proven game region does not settle the file (`keep_all_regions`).
#
# Only cores whose region code was read at the profile's pin are listed.
# Every source_ref names the profile's own pin.
cases:
user-region-option:
title: A core option forces the console region
reason: The forced region decides the file, and the option is device state the installer does not read.
psx-override-bios:
title: Region-free override BIOS
reason: An option swaps in a region-free image for every region.
duckstation-custom-region:
title: Per-game custom region
reason: A region the user set for one game in the game list replaces the region read from the disc.
cross-region-fallback:
title: Missing region file replaced by another region's image
reason: The game still starts without the precise file, on another region's BIOS with a warning.
first-disc-region:
title: Only the first disc sets the region
reason: For a multi-disc game the region is read from disc 1 alone.
multi-area-disc:
title: Disc listing several areas
reason: The core picks among the disc's areas by its own preference order.
saturn-kr-ipl:
title: Asia NTSC and Korea discs ask for an undeclared IPL
reason: The KR IPL is not declared by the profile, so the core falls back to a region-free image, then a mismatched one.
saturn-disc-database:
title: A disc or cartridge database overrides the header
reason: The core's own database decides the area, so a header read is not enough without that table.
sms-crc-list:
title: CRC32 list overrides the cartridge header
reason: A list of whole-ROM CRC32s forces Europe or Japan before the header is read.
megacd-region-list:
title: Mega CD region from the disc's region list
reason: The file follows the disc's list of regions and the preferred-region option, not one header byte.
gamecube-unknown-region:
title: GameCube disc with an unknown region field
reason: An unknown value at 0x458 falls back to the host locale, which differs per device.
naomi-one-archive:
title: One BIOS archive serves every region
reason: The region filters blobs inside one archive, so the archive is the unit.
nds-header-decides:
title: NDS BIOS decided by header bytes, not region
reason: An encrypted secure area or a DSi unit code decides the files, not the game region.
optional-pif:
title: Optional boot image
reason: The core runs HLE without the file, and dropping it saves under 4 KB.
systems:
sony-playstation:
beetle_psx:
routes:
south-korea: japan
default_region: north-america
region_free: [psxonpsp660.bin, ps1_rom.bin, openbios.bin]
fallback_across_regions: false
source_ref: "libretro/beetle-psx-libretro@7fea57a5 libretro.c:2426-2446, libretro.c:2497-2540, libretro.c:768-821, libretro.c:3425-3458, mednafen/settings.c:42"
keep_all_regions:
- case: psx-override-bios
condition: option
option: beetle_psx_override_bios
default: disabled
keeps: region-free-files
files: [psxonpsp660.bin, ps1_rom.bin, openbios.bin]
source_ref: "libretro/beetle-psx-libretro@7fea57a5 libretro.c:694-765, libretro_core_options.h:259-274"
- case: first-disc-region
condition: disc
when: multi-disc game (.m3u)
keeps: first-disc
source_ref: "libretro/beetle-psx-libretro@7fea57a5 libretro.c:2426-2446"
swanstation:
default_region: north-america
region_free: [psxonpsp660.bin, ps1_rom.bin]
fallback_across_regions: true
source_ref: "libretro/swanstation@631f0fb3 src/core/system.cpp:400-456, src/core/system.cpp:621-640, src/core/host_interface.cpp:100-196, src/core/bios.cpp:28-71"
keep_all_regions:
- case: user-region-option
condition: option
option: swanstation_Console_Region
default: Auto
keeps: all-regions
source_ref: "libretro/swanstation@631f0fb3 src/libretro/libretro_core_options.h:82-136"
- case: cross-region-fallback
condition: always
keeps: precise
source_ref: "libretro/swanstation@631f0fb3 src/core/host_interface.cpp:100-196"
duckstation:
default_region: north-america
region_free: [openbios.bin]
fallback_across_regions: true
source_ref: "stenzek/duckstation@526b4a12 src/core/system.cpp:939-1009, src/core/system.cpp:664-680, src/core/bios.cpp:34-139, src/core/bios.cpp:397-460"
keep_all_regions:
- case: user-region-option
condition: option
option: Console/Region
default: Auto
keeps: all-regions
source_ref: "stenzek/duckstation@526b4a12 src/core/system.cpp:1739-1771, src/core/settings.h:608"
- case: duckstation-custom-region
condition: user-state
when: the user set a custom region for the game in the game list
keeps: all-regions
source_ref: "stenzek/duckstation@526b4a12 src/core/bios.cpp:397-460"
pcsx_rearmed:
default_region: north-america
fallback_across_regions: true
source_ref: "libretro/pcsx_rearmed@c8816799 libpcsxcore/misc.c:415-455, frontend/libretro.c:3604-3680, frontend/libretro.c:3864-3885, libpcsxcore/psxmem.c:291-327"
keep_all_regions:
- case: cross-region-fallback
condition: always
keeps: precise
source_ref: "libretro/pcsx_rearmed@c8816799 libpcsxcore/psxmem.c:291-327"
sega-megacd:
genesis_plus_gx:
default_region: north-america
fallback_across_regions: false
source_ref: "libretro/Genesis-Plus-GX@60bee4c7 core/loadrom.c:1056-1078, core/loadrom.c:394-418, core/loadrom.c:724-734, core/cd_hw/cdd.c:556-561"
keep_all_regions:
- case: user-region-option
condition: option
option: genesis_plus_gx_region_detect
default: auto
keeps: all-regions
source_ref: "libretro/Genesis-Plus-GX@60bee4c7 libretro/libretro_core_options.h:120-134, libretro/libretro.c:1574-1585, core/loadrom.c:1160-1164"
genesis_plus_gx_wide:
default_region: north-america
fallback_across_regions: false
source_ref: "libretro/Genesis-Plus-GX-Wide@b7ad0054 core/loadrom.c:393-414, core/loadrom.c:1050-1070"
genesis_plus_gx_expanded:
default_region: north-america
fallback_across_regions: false
source_ref: "BillyTimeGames/Genesis-Plus-GX-Expanded-Rom-Size@dea3528c core/loadrom.c:393-414, core/loadrom.c:1048-1068"
picodrive:
default_region: north-america
fallback_across_regions: false
source_ref: "libretro/picodrive@1890c293 pico/media.c:185-258, pico/media.c:288-312, platform/libretro/libretro.c:1273-1335"
blastem:
preference: [north-america, europe, japan]
fallback_across_regions: false
source_ref: "libretro/blastem@ab743653 genesis.c:3245-3268, segacd.c:1573-1593, romdb.c:175-216, libblastem.c:501-503"
keep_all_regions:
- case: megacd-region-list
condition: option
option: blastem_region
default: U
keeps: listed-areas
source_ref: "libretro/blastem@ab743653 segacd.c:1573-1593, libblastem.c:85-90, libblastem.c:195-198"
mega-cd:
ares:
preference: [north-america]
source_ref: "ares-emulator/ares@d400eee9 mia/medium/mega-cd.cpp:40-58, desktop-ui/emulator/mega-cd.cpp:17-19, desktop-ui/emulator/mega-cd.cpp:70-81, desktop-ui/emulator/emulator.cpp:86-106"
keep_all_regions:
- case: multi-area-disc
condition: disc
when: the security code matches none of the three known CRC32s, so the disc lists every region
keeps: listed-areas
source_ref: "ares-emulator/ares@d400eee9 mia/medium/mega-cd.cpp:40-58, desktop-ui/settings/settings.hpp:71"
sega-mastersystem:
genesis_plus_gx:
default_region: north-america
source_ref: "libretro/Genesis-Plus-GX@60bee4c7 core/cart_hw/sms_cart.c:831-874, core/loadrom.c:300-347, core/loadrom.c:498-520"
keep_all_regions:
- case: sms-crc-list
condition: always
keeps: all-regions
source_ref: "libretro/Genesis-Plus-GX@60bee4c7 core/cart_hw/sms_cart.c:160-264, core/cart_hw/sms_cart.c:831-851"
- case: user-region-option
condition: option
option: genesis_plus_gx_region_detect
default: auto
keeps: all-regions
source_ref: "libretro/Genesis-Plus-GX@60bee4c7 libretro/libretro_core_options.h:120-162, core/cart_hw/sms_cart.c:618-623"
master-system:
ares:
source_ref: "ares-emulator/ares@d400eee9 mia/medium/master-system.cpp:50-104, desktop-ui/emulator/master-system.cpp:14-16, desktop-ui/emulator/master-system.cpp:100-107"
sega-saturn:
beetle_saturn:
preference: [north-america, japan, south-korea, europe, brazil, asia-ntsc, asia-pal, latin-america]
source_ref: "libretro/beetle-saturn-libretro@30f4c120 disc.c:155-172, disc.c:686-734, mednafen/ss/ss.c:1846-1849"
keep_all_regions:
- case: saturn-disc-database
condition: always
keeps: all-regions
source_ref: "libretro/beetle-saturn-libretro@30f4c120 mednafen/ss/db.c:36-55, mednafen/ss/db.c:1731-1745"
- case: user-region-option
condition: option
option: beetle_saturn_region
default: Auto Detect
keeps: all-regions
source_ref: "libretro/beetle-saturn-libretro@30f4c120 libretro_core_options.h:79-97, libretro.c:454-475"
mednafen_stv:
source_ref: "beudbeud/libretro-stv@e6571347 mednafen/ss/ss.cpp:1532-1537, mednafen/ss/ss.cpp:2599-2600, mednafen/ss/ss.cpp:1596-1630, mednafen/ss/db.cpp:48"
keep_all_regions:
- case: user-region-option
condition: option
option: mednafen_stv_region
default: auto
keeps: all-regions
source_ref: "beudbeud/libretro-stv@e6571347 libretro/libretro.cpp:428-437, libretro/libretro_core_options.h:36-37"
ymir:
fallback_across_regions: true
source_ref: "ymir-emu/Ymir@fe439e98 libs/ymir-core/src/ymir/media/saturn_header.cpp:29-40, libs/ymir-core/src/ymir/sys/saturn.cpp:308-350, apps/ymir-sdl3/src/app/services/rom_service.cpp:127-200"
keep_all_regions:
- case: multi-area-disc
condition: disc
when: the disc lists more than one area, picked by a user-configurable order
keeps: listed-areas
source_ref: "ymir-emu/Ymir@fe439e98 apps/ymir-sdl3/src/app/settings.cpp:1052-1055, libs/ymir-core/src/ymir/sys/saturn.cpp:308-350"
- case: saturn-kr-ipl
condition: disc
when: area T (Asia NTSC) or K (Korea)
keeps: all-regions
source_ref: "ymir-emu/Ymir@fe439e98 apps/ymir-sdl3/src/app/services/rom_service.cpp:127-200"
kronos:
region_free: [saturn_bios.bin, sega_101.bin, mpr-17933.bin]
source_ref: "libretro/yabause@3791ffb2 yabause/src/libretro/libretro.c:1618-1636"
sega-stv:
beetle_saturn:
source_ref: "libretro/beetle-saturn-libretro@30f4c120 libretro.c:1175-1195, libretro.c:1230-1255, mednafen/ss/ss.c:1830-1845"
keep_all_regions:
- case: saturn-disc-database
condition: always
keeps: all-regions
source_ref: "libretro/beetle-saturn-libretro@30f4c120 mednafen/ss/db.c:382-1640, libretro.c:1175-1195"
mednafen_stv:
source_ref: "beudbeud/libretro-stv@e6571347 mednafen/ss/ss.cpp:1800-1820, mednafen/ss/ss.cpp:1521-1531, mednafen/ss/ss.cpp:2602-2605"
keep_all_regions:
- case: saturn-disc-database
condition: always
keeps: all-regions
source_ref: "beudbeud/libretro-stv@e6571347 mednafen/ss/ss.cpp:1800-1820, mednafen/ss/db.cpp:169-1633"
- case: user-region-option
condition: option
option: mednafen_stv_region
default: auto
keeps: all-regions
source_ref: "beudbeud/libretro-stv@e6571347 libretro/libretro.cpp:428-437"
kronos:
region_free: [stvbios.zip]
source_ref: "libretro/yabause@3791ffb2 yabause/src/ctrl/src/stv.c:112-136, yabause/src/ctrl/src/stv.c:2560-2570, yabause/src/libretro/libretro.c:1039-1051"
nintendo-gamecube:
dolphin:
source_ref: "libretro/dolphin@3aac3f05 Source/Core/DiscIO/VolumeGC.cpp:91-109, Source/Core/DiscIO/Enums.h:45-53, Source/Core/Core/Config/MainSettings.cpp:721-768"
keep_all_regions:
- case: gamecube-unknown-region
condition: disc
when: the u32 at disc offset 0x458 is not 0, 1, 2 or 4
keeps: all-regions
source_ref: "libretro/dolphin@3aac3f05 Source/Core/Core/ConfigManager.cpp:489-490, Source/Core/Core/Config/DefaultLocale.cpp:148-191, Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:100-131"
primehack:
source_ref: "shiiion/dolphin@53f53e0f Source/Core/DiscIO/VolumeGC.cpp:108, Source/Core/Core/ConfigManager.cpp:458"
dolphin-mmjr2:
source_ref: "nachoverdon/Dolphin-MMJR2@4d0fdb8e Source/Core/DiscIO/VolumeGC.cpp:82, Source/Core/Core/ConfigManager.cpp:337"
triforce:
source_ref: "REG-Linux/dolphin-triforce@5c456e8f Source/Core/DiscIO/VolumeGC.cpp:89, Source/Core/Core/ConfigManager.cpp:355"
nintendo-64:
simple64:
default_region: north-america
source_ref: "simple64/simple64@d8c969c7 mupen64plus-core/src/main/rom.c:190, mupen64plus-core/src/main/rom.c:502-526, mupen64plus-core/src/main/main.c:1565-1580"
keep_all_regions:
- case: optional-pif
condition: always
keeps: all-regions
source_ref: "simple64/simple64@d8c969c7 mupen64plus-core/src/main/main.c:1624-1633"
nintendo-64dd:
ares:
default_region: north-america
source_ref: "ares-emulator/ares@d400eee9 mia/medium/nintendo-64dd.cpp:57-77, desktop-ui/emulator/nintendo-64dd.cpp:17-19, desktop-ui/emulator/nintendo-64dd.cpp:64-80"
project64:
source_ref: "project64/project64@24376e86 Source/Project64-core/N64System/N64Disk.cpp:828-840, Source/Project64-core/N64System/N64System.cpp:641-662"
rosalies-mupen-gui:
source_ref: "Rosalie241/RMG@4777ed3f Source/3rdParty/mupen64plus-core/src/device/dd/disk.c:405-480, Source/RMG-Core/MediaLoader.cpp:47-63"
mupen64plus_next:
region_free: [IPL.n64]
source_ref: "libretro/mupen64plus-libretro-nx@15bada85 mupen64plus-core/src/main/main.c:953-973"
parallel_n64:
region_free: [64DD_IPL.bin]
source_ref: "libretro/parallel-n64@f87550d9 libretro/libretro.c:951"
laseractive:
geargrafx:
source_ref: "drhelius/Geargrafx@7c447b0b platforms/libretro/libretro.cpp:830-850, platforms/libretro/libretro.cpp:513-529"
keep_all_regions:
- case: user-region-option
condition: option
option: geargrafx_laseractive_region
default: Auto
keeps: all-regions
source_ref: "drhelius/Geargrafx@7c447b0b platforms/libretro/libretro.cpp:830-850"
sega-naomi:
flycast:
source_ref: "flyinghead/flycast@e88b0ced core/hw/naomi/naomi_cart.cpp:61-133"
keep_all_regions:
- case: naomi-one-archive
condition: always
keeps: one-archive
source_ref: "flyinghead/flycast@e88b0ced core/hw/naomi/naomi_cart.cpp:61-133"
nintendo-ds:
melonds_ds:
source_ref: "JesseTG/melonds-ds@f394adba src/libretro/config/console.cpp:238-256, src/libretro/config/console.cpp:379-424"
keep_all_regions:
- case: nds-header-decides
condition: always
keeps: by-header
source_ref: "JesseTG/melonds-ds@f394adba src/libretro/config/console.cpp:238-256, src/libretro/config/console.cpp:419-424"
+59
View File
@@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://abdess.github.io/retrobios/api/v1/schemas/game-extras.schema.json",
"title": "RetroBIOS per-game extra files",
"description": "games/extras.yml: files a game needs beyond its system's region BIOS, decided by the game's extension, header bytes or presence, with the core that reads them. A header trigger names a reader of games/headers.yml and the labels or values it returns.",
"type": "object",
"additionalProperties": false,
"required": ["extras"],
"properties": {
"extras": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["system", "profile", "trigger", "required", "source_ref"],
"properties": {
"system": { "type": "string", "minLength": 1 },
"profile": { "type": "string", "minLength": 1 },
"trigger": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"maxProperties": 1,
"properties": {
"extensions": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "pattern": "^\\.[a-z0-9]+$" },
"uniqueItems": true
},
"header": {
"type": "object",
"additionalProperties": false,
"required": ["reader", "values"],
"properties": {
"reader": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
"field": { "type": "string", "pattern": "^[a-z0-9_]+$", "description": "A field of the reader, when the value is read there rather than from the reader's own location." },
"values": { "type": "array", "minItems": 1, "items": { "type": ["string", "integer"] } }
}
},
"every_game": { "const": true }
}
},
"files": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"region_files": { "const": true, "description": "The files are the system's region files for the game's region (games/regions.yml)." },
"one_of": { "type": "boolean", "description": "Any single file of the list serves." },
"filename_override": { "type": "boolean", "description": "Tags in the file name can change the choice: the installer keeps every file listed for this system and core." },
"required": { "type": "boolean" },
"note": { "type": "string", "minLength": 1 },
"source_ref": {
"type": "string",
"pattern": "^[\\w.-]+/[\\w.-]+@[0-9a-f]{8,40} \\S.*$"
}
},
"oneOf": [{ "required": ["files"] }, { "required": ["region_files"] }]
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://abdess.github.io/retrobios/api/v1/schemas/game-regions.schema.json",
"title": "RetroBIOS region rules per system and core",
"description": "games/regions.yml: what a core does around its region files (routes, default region, preference order, region-free files, cross-region fallback) and the cases where a proven game region does not settle the file. Files per region come from the profiles' region: fields, never from here.",
"type": "object",
"additionalProperties": false,
"required": ["cases", "systems"],
"$defs": {
"region": { "type": "string", "pattern": "^[a-z]+(?:-[a-z]+)*$" },
"ref": {
"type": "string",
"description": "'owner/repo@sha path:line[, path:line]', several repositories joined by '; '. The sha is a prefix of the profile's source_commit or upstream_commit.",
"pattern": "^[\\w.-]+/[\\w.-]+@[0-9a-f]{8,40} \\S.*$"
},
"keep": {
"type": "object",
"additionalProperties": false,
"required": ["case", "condition", "keeps", "source_ref"],
"properties": {
"case": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
"condition": {
"enum": ["always", "option", "disc", "user-state"],
"description": "always: whatever the game. option: when a core option leaves its default. disc: for the games 'when' describes. user-state: per-game state the user set on the device."
},
"option": { "type": "string", "minLength": 1 },
"default": { "type": "string", "minLength": 1 },
"when": { "type": "string", "minLength": 1 },
"keeps": {
"enum": ["all-regions", "listed-areas", "region-free-files", "first-disc", "precise", "by-header", "one-archive"],
"description": "all-regions: every region file of the system. listed-areas: every region the disc lists. region-free-files: the files named, never dropped. first-disc: read disc 1 of a multi-disc set. precise: the precise file is enough, the game still starts without it. by-header: decided by header bytes (games/extras.yml). one-archive: one archive serves every region."
},
"files": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1 },
"source_ref": { "$ref": "#/$defs/ref" }
},
"allOf": [
{ "if": { "properties": { "condition": { "const": "option" } } }, "then": { "required": ["option", "default"] } },
{ "if": { "properties": { "condition": { "enum": ["disc", "user-state"] } } }, "then": { "required": ["when"] } },
{ "if": { "properties": { "keeps": { "const": "region-free-files" } } }, "then": { "required": ["files"] } }
]
},
"core": {
"type": "object",
"additionalProperties": false,
"required": ["source_ref"],
"properties": {
"routes": {
"type": "object",
"description": "Game region the core computes -> the region whose files it opens, where the profile's tags do not already say so.",
"propertyNames": { "$ref": "#/$defs/region" },
"additionalProperties": { "$ref": "#/$defs/region" }
},
"default_region": { "$ref": "#/$defs/region" },
"preference": { "type": "array", "items": { "$ref": "#/$defs/region" }, "uniqueItems": true, "minItems": 1 },
"region_free": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"fallback_across_regions": { "type": "boolean" },
"source_ref": { "$ref": "#/$defs/ref" },
"keep_all_regions": { "type": "array", "items": { "$ref": "#/$defs/keep" } }
}
}
},
"properties": {
"cases": {
"type": "object",
"propertyNames": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["title", "reason"],
"properties": {
"title": { "type": "string", "minLength": 1 },
"reason": { "type": "string", "minLength": 1 }
}
}
},
"systems": {
"type": "object",
"additionalProperties": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/$defs/core" }
}
}
}
}
+269
View File
@@ -0,0 +1,269 @@
"""Load and check games/regions.yml and games/extras.yml against the profiles.
python scripts/game_rules.py # check, print a summary
python scripts/game_rules.py --json # the summary as JSON
Both files name profiles and the files those profiles declare. A rule is
only as good as that link: a profile renamed, a file dropped from it, a
region slug the vocabulary does not know, a citation at a revision the
profile is not pinned to, or a header trigger asking a reader of
games/headers.yml for a value it never returns, and the installer would act
on a rule nothing backs. Each of those is an error here.
"""
from __future__ import annotations
import argparse
import json
import re
from dataclasses import dataclass
from pathlib import Path
from common import load_emulator_profiles
from region import REGIONS, normalize_declared
from safeparse import yaml_load
REGIONS_FILE = Path("games") / "regions.yml"
EXTRAS_FILE = Path("games") / "extras.yml"
HEADERS_FILE = Path("games") / "headers.yml"
_REF_REPO = re.compile(r"(?:^|; )([\w.-]+/[\w.-]+)@([0-9a-f]{8,40}) ")
def load_yaml(path: Path) -> dict:
with Path(path).open(encoding="utf-8") as handle:
return yaml_load(handle) or {}
def profile_pins(profile: dict) -> set[str]:
"""Every commit a profile is pinned to, a build-mode dict included."""
pins: set[str] = set()
for key in ("source_commit", "upstream_commit"):
value = profile.get(key)
values = value.values() if isinstance(value, dict) else [value]
pins.update(str(v) for v in values if v)
return pins
def declared_names(profile: dict) -> set[str]:
"""The names and aliases a profile's files answer to."""
names: set[str] = set()
for entry in profile.get("files") or []:
if isinstance(entry, dict) and entry.get("name"):
names.add(str(entry["name"]))
names.update(str(a) for a in entry.get("aliases") or [])
return names
def tagged_regions(profile: dict, name: str) -> set[str]:
"""The regions a profile tags a file with, over all its declarations."""
out: set[str] = set()
for entry in profile.get("files") or []:
if isinstance(entry, dict) and (
entry.get("name") == name or name in (entry.get("aliases") or [])
):
out |= normalize_declared(entry.get("region"))
return out
def every_tag(profile: dict) -> set[str]:
return {
region
for entry in profile.get("files") or []
if isinstance(entry, dict)
for region in normalize_declared(entry.get("region"))
}
def header_readers(headers: dict) -> dict[str, dict]:
"""Every reader of games/headers.yml by id, over all its systems."""
readers: dict[str, dict] = {}
for system in (headers.get("systems") or {}).values():
readers.update(system.get("readers") or {})
return readers
def reader_answers(reader: dict, field: str | None = None) -> set[object] | None:
"""The labels and values a reader can return, None when any string can
be (a serial or product id read as it stands)."""
if field:
spec = (reader.get("fields") or {}).get(field) or {}
return {v for k, v in spec.items() if k not in ("encoding",) and isinstance(v, str)}
decode = reader.get("decode") or {}
if decode.get("kind") == "serial":
return None
answers: set[object] = set()
for row in decode.get("values") or []:
answers.update(row[k] for k in ("value", "label") if k in row)
for key in ("then", "otherwise"):
branch = decode.get(key)
if isinstance(branch, dict) and "label" in branch:
answers.add(branch["label"])
elif isinstance(branch, str):
answers.add(branch)
return answers
@dataclass
class Checker:
profiles: dict[str, dict]
readers: dict[str, dict] | None = None
def __post_init__(self) -> None:
self.errors: list[str] = []
def _profile(self, where: str, name: str, system: str) -> dict | None:
profile = self.profiles.get(name)
if profile is None:
self.errors.append(f"{where}: no profile {name}")
return None
if system not in (profile.get("systems") or []):
self.errors.append(f"{where}: {name} does not declare system {system}")
return profile
def _files(self, where: str, name: str, profile: dict, files: list[str]) -> None:
known = declared_names(profile)
for file in files:
if file not in known:
self.errors.append(f"{where}: {name} declares no file {file}")
def _region(self, where: str, value: str) -> None:
if value not in REGIONS:
self.errors.append(f"{where}: unknown region {value}")
def _ref(self, where: str, profile: dict, ref: str | None) -> None:
if not ref:
self.errors.append(f"{where}: no source_ref")
return
pins = profile_pins(profile)
cited = _REF_REPO.findall(ref)
if not cited:
self.errors.append(f"{where}: source_ref names no owner/repo@sha")
for repo, sha in cited:
if not any(pin.startswith(sha) for pin in pins):
self.errors.append(f"{where}: {repo}@{sha} is not a pin of the profile")
def regions(self, doc: dict) -> None:
cases = doc.get("cases") or {}
for system, cores in (doc.get("systems") or {}).items():
for name, rule in cores.items():
where = f"regions {system}/{name}"
profile = self._profile(where, name, system)
if profile is None:
continue
self._ref(where, profile, rule.get("source_ref"))
tags = every_tag(profile)
for src, dst in (rule.get("routes") or {}).items():
self._region(where, src)
self._region(where, dst)
if dst not in tags:
self.errors.append(f"{where}: route {src} -> {dst}, but no file is tagged {dst}")
for value in [rule.get("default_region"), *(rule.get("preference") or [])]:
if value:
self._region(where, value)
free = rule.get("region_free") or []
self._files(where, name, profile, free)
for file in free:
specific = tagged_regions(profile, file) - {"world"}
if specific:
self.errors.append(
f"{where}: region_free {file} is tagged {sorted(specific)} in the profile"
)
for keep in rule.get("keep_all_regions") or []:
kwhere = f"{where} case {keep.get('case')}"
if keep.get("case") not in cases:
self.errors.append(f"{kwhere}: case not described under cases")
self._ref(kwhere, profile, keep.get("source_ref"))
self._files(kwhere, name, profile, keep.get("files") or [])
def extras(self, doc: dict) -> None:
for index, entry in enumerate(doc.get("extras") or []):
where = f"extras[{index}] {entry.get('system')}/{entry.get('profile')}"
profile = self._profile(where, entry["profile"], entry["system"])
if profile is None:
continue
self._ref(where, profile, entry.get("source_ref"))
self._files(where, entry["profile"], profile, entry.get("files") or [])
header = (entry.get("trigger") or {}).get("header")
if header and self.readers is not None:
self._header(where, header)
def _header(self, where: str, header: dict) -> None:
reader = self.readers.get(header["reader"])
if reader is None:
self.errors.append(f"{where}: games/headers.yml has no reader {header['reader']}")
return
field = header.get("field")
if field and field not in (reader.get("fields") or {}):
self.errors.append(f"{where}: reader {header['reader']} has no field {field}")
return
answers = reader_answers(reader, field)
if answers is None:
return
for value in header.get("values") or []:
if value not in answers:
self.errors.append(
f"{where}: reader {header['reader']} never returns {value!r}"
)
def check(
regions: dict, extras: dict, profiles: dict[str, dict], headers: dict | None = None
) -> list[str]:
"""Errors over both files. Without headers, header triggers go unchecked."""
checker = Checker(profiles, header_readers(headers) if headers is not None else None)
checker.regions(regions)
checker.extras(extras)
return checker.errors
def summary(regions: dict, extras: dict) -> dict:
keeps = [
{"case": keep["case"], "system": system, "profile": name, "condition": keep["condition"]}
for system, cores in sorted((regions.get("systems") or {}).items())
for name, rule in sorted(cores.items())
for keep in rule.get("keep_all_regions") or []
]
systems = regions.get("systems") or {}
return {
"region_systems": len(systems),
"region_cores": sum(len(c) for c in systems.values()),
"cases": sorted(regions.get("cases") or {}),
"case_entries": keeps,
"extras": len(extras.get("extras") or []),
"extra_systems": sorted({e["system"] for e in extras.get("extras") or []}),
}
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.splitlines()[0])
parser.add_argument("--regions", type=Path, default=REGIONS_FILE)
parser.add_argument("--extras", type=Path, default=EXTRAS_FILE)
parser.add_argument("--headers", type=Path, default=HEADERS_FILE)
parser.add_argument("--emulators-dir", default="emulators")
parser.add_argument("--json", action="store_true", help="print the summary as JSON")
args = parser.parse_args()
regions = load_yaml(args.regions)
extras = load_yaml(args.extras)
errors = check(regions, extras, load_emulator_profiles(args.emulators_dir), load_yaml(args.headers))
info = summary(regions, extras)
if args.json:
print(json.dumps({**info, "errors": errors}, indent=2))
else:
print(
f"Region rules: {info['region_cores']} cores over {info['region_systems']} systems, "
f"{len(info['cases'])} cases in {len(info['case_entries'])} entries"
)
for row in info["case_entries"]:
print(f" {row['case']:26} {row['system']:20} {row['profile']:22} {row['condition']}")
print(f"Per-game extras: {info['extras']} entries over {len(info['extra_systems'])} systems")
for error in errors:
print(f"ERROR {error}")
return 1 if errors else 0
if __name__ == "__main__":
from common import run_cli
run_cli(main)
+9
View File
@@ -0,0 +1,9 @@
emulator: FDS Core
type: libretro
source: https://github.com/example/fdscore
source_commit: 2222222222222222222222222222222222222222
systems: [nintendo-nes, nintendo-fds]
files:
- name: disksys.rom
system: nintendo-fds
required: true
+18
View File
@@ -0,0 +1,18 @@
emulator: PSX Core
type: libretro
source: https://github.com/example/psxcore
source_commit: 1111111111111111111111111111111111111111
systems: [sony-playstation]
files:
- name: scph5500.bin
region: [japan]
required: false
- name: scph5501.bin
aliases: [scph7003.bin]
region: [north-america]
required: false
- name: scph5502.bin
region: [europe]
required: false
- name: openbios.bin
required: false
+8
View File
@@ -0,0 +1,8 @@
extras:
- system: nintendo-fds
profile: fdscore
trigger:
extensions: [.fds]
files: [disksys.rom]
required: true
source_ref: "example/fdscore@22222222 fds.c:5"
+18
View File
@@ -0,0 +1,18 @@
systems:
nintendo-fds:
readers:
fds-image:
location: {offset: 0, length: 14, encoding: ascii}
decode:
kind: exact-prefix
values:
- {value: "FDS\x1a", label: fds}
otherwise: unknown
sega-dreamcast:
readers:
dc-product-id:
location: {offset: 64, length: 10, encoding: ascii}
fields:
disk_type: {offset: 37, length: 6, encoding: ascii, mil_cd: MIL CD}
decode:
kind: serial
+20
View File
@@ -0,0 +1,20 @@
cases:
psx-override-bios:
title: Region-free override BIOS
reason: An option swaps in a region-free image for every region.
systems:
sony-playstation:
psxcore:
routes:
south-korea: japan
default_region: north-america
region_free: [openbios.bin]
source_ref: "example/psxcore@11111111 core.c:10-20"
keep_all_regions:
- case: psx-override-bios
condition: option
option: psxcore_override
default: disabled
keeps: region-free-files
files: [openbios.bin]
source_ref: "example/psxcore@11111111 core.c:30"
+202
View File
@@ -0,0 +1,202 @@
"""games/regions.yml and games/extras.yml: the rules the games-on-card mode reads.
Every rule names a profile and files that profile declares, cites the code
at the profile's own pin, and speaks the region vocabulary of region.py.
The checker is run on small fixtures, once clean and once per way a rule
can lose its backing, then on the repository's own files.
"""
from __future__ import annotations
import copy
import json
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import game_rules # noqa: E402
from common import load_emulator_profiles # noqa: E402
FIXTURES = REPO_ROOT / "tests" / "fixtures" / "game_rules"
# The cases the research names where a proven game region does not settle
# the file (games_on_card_research.md section 2).
EXPECTED_CASES = {
"user-region-option", "psx-override-bios", "duckstation-custom-region",
"cross-region-fallback", "first-disc-region", "multi-area-disc",
"saturn-kr-ipl", "saturn-disc-database", "sms-crc-list",
"megacd-region-list", "gamecube-unknown-region", "naomi-one-archive",
"nds-header-decides", "optional-pif",
}
def _schema_validator(name: str):
try:
from jsonschema import Draft202012Validator
except ImportError as exc:
raise unittest.SkipTest(f"schema check needs jsonschema: {exc}") from exc
schema = json.loads((REPO_ROOT / "schemas" / name).read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
return Draft202012Validator(schema)
class FixtureChecks(unittest.TestCase):
def setUp(self) -> None:
self.profiles = load_emulator_profiles(str(FIXTURES / "emulators"))
self.regions = game_rules.load_yaml(FIXTURES / "regions.yml")
self.extras = game_rules.load_yaml(FIXTURES / "extras.yml")
def errors(self, regions=None, extras=None) -> list[str]:
return game_rules.check(
regions if regions is not None else self.regions,
extras if extras is not None else self.extras,
self.profiles,
)
def psx(self, regions: dict) -> dict:
return regions["systems"]["sony-playstation"]["psxcore"]
def test_clean_fixture_passes(self) -> None:
self.assertEqual(self.errors(), [])
def test_unknown_profile(self) -> None:
extras = copy.deepcopy(self.extras)
extras["extras"][0]["profile"] = "nocore"
self.assertTrue(any("no profile nocore" in e for e in self.errors(extras=extras)))
def test_system_the_profile_does_not_run(self) -> None:
extras = copy.deepcopy(self.extras)
extras["extras"][0]["system"] = "sega-saturn"
self.assertTrue(any("does not declare system" in e for e in self.errors(extras=extras)))
def test_undeclared_file(self) -> None:
extras = copy.deepcopy(self.extras)
extras["extras"][0]["files"] = ["FdsBios.bin"]
self.assertTrue(any("declares no file FdsBios.bin" in e for e in self.errors(extras=extras)))
def test_alias_counts_as_declared(self) -> None:
regions = copy.deepcopy(self.regions)
self.psx(regions)["keep_all_regions"][0]["files"] = ["scph7003.bin"]
self.assertFalse(any("declares no file" in e for e in self.errors(regions=regions)))
def test_unknown_region(self) -> None:
regions = copy.deepcopy(self.regions)
self.psx(regions)["default_region"] = "usa-canada"
self.assertTrue(any("unknown region usa-canada" in e for e in self.errors(regions=regions)))
def test_route_to_a_region_no_file_carries(self) -> None:
regions = copy.deepcopy(self.regions)
self.psx(regions)["routes"] = {"south-korea": "oceania"}
self.assertTrue(any("no file is tagged oceania" in e for e in self.errors(regions=regions)))
def test_citation_at_another_revision(self) -> None:
regions = copy.deepcopy(self.regions)
self.psx(regions)["source_ref"] = "example/psxcore@deadbeef core.c:10"
self.assertTrue(any("not a pin of the profile" in e for e in self.errors(regions=regions)))
def test_missing_citation(self) -> None:
regions = copy.deepcopy(self.regions)
del self.psx(regions)["keep_all_regions"][0]["source_ref"]
self.assertTrue(any("no source_ref" in e for e in self.errors(regions=regions)))
def test_case_without_description(self) -> None:
regions = copy.deepcopy(self.regions)
self.psx(regions)["keep_all_regions"][0]["case"] = "made-up"
self.assertTrue(any("not described" in e for e in self.errors(regions=regions)))
def test_region_free_file_tagged_with_a_region(self) -> None:
regions = copy.deepcopy(self.regions)
self.psx(regions)["region_free"] = ["scph5500.bin"]
self.assertTrue(any("tagged ['japan']" in e for e in self.errors(regions=regions)))
def header_errors(self, header: dict) -> list[str]:
extras = copy.deepcopy(self.extras)
extras["extras"][0]["trigger"] = {"header": header}
headers = game_rules.load_yaml(FIXTURES / "headers.yml")
return game_rules.check(self.regions, extras, self.profiles, headers)
def test_header_trigger_takes_a_label_the_reader_returns(self) -> None:
self.assertEqual(self.header_errors({"reader": "fds-image", "values": ["fds"]}), [])
def test_header_trigger_with_free_text(self) -> None:
errors = self.header_errors({"reader": "fds-image", "values": ["FDS header at 0"]})
self.assertTrue(any("never returns 'FDS header at 0'" in e for e in errors))
def test_header_trigger_naming_no_reader(self) -> None:
errors = self.header_errors({"reader": "fds-magic", "values": ["fds"]})
self.assertTrue(any("no reader fds-magic" in e for e in errors))
def test_serial_reader_takes_any_value(self) -> None:
self.assertEqual(self.header_errors({"reader": "dc-product-id", "values": ["T7001D 50"]}), [])
def test_header_field_value(self) -> None:
self.assertEqual(
self.header_errors({"reader": "dc-product-id", "field": "disk_type", "values": ["MIL CD"]}), [])
errors = self.header_errors({"reader": "dc-product-id", "field": "area", "values": ["J"]})
self.assertTrue(any("has no field area" in e for e in errors))
def test_summary_is_stable(self) -> None:
first = game_rules.summary(self.regions, self.extras)
second = game_rules.summary(copy.deepcopy(self.regions), copy.deepcopy(self.extras))
self.assertEqual(json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True))
self.assertEqual(first["case_entries"][0]["case"], "psx-override-bios")
class FixtureSchemas(unittest.TestCase):
def test_fixtures_match_the_schemas(self) -> None:
for name, schema in (("regions.yml", "game-regions.schema.json"),
("extras.yml", "game-extras.schema.json")):
errors = list(_schema_validator(schema).iter_errors(
game_rules.load_yaml(FIXTURES / name)))
self.assertEqual(errors, [], name)
def test_schema_refuses_an_option_case_without_its_option(self) -> None:
regions = game_rules.load_yaml(FIXTURES / "regions.yml")
keep = regions["systems"]["sony-playstation"]["psxcore"]["keep_all_regions"][0]
del keep["option"]
self.assertTrue(list(_schema_validator("game-regions.schema.json").iter_errors(regions)))
def test_schema_refuses_two_triggers(self) -> None:
extras = game_rules.load_yaml(FIXTURES / "extras.yml")
extras["extras"][0]["trigger"]["every_game"] = True
self.assertTrue(list(_schema_validator("game-extras.schema.json").iter_errors(extras)))
class RepositoryRules(unittest.TestCase):
"""The committed files, against the committed profiles."""
@classmethod
def setUpClass(cls) -> None:
cls.regions = game_rules.load_yaml(REPO_ROOT / game_rules.REGIONS_FILE)
cls.extras = game_rules.load_yaml(REPO_ROOT / game_rules.EXTRAS_FILE)
def test_every_rule_is_backed_by_its_profile(self) -> None:
profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
headers = game_rules.load_yaml(REPO_ROOT / game_rules.HEADERS_FILE)
self.assertEqual(game_rules.check(self.regions, self.extras, profiles, headers), [])
def test_files_match_the_schemas(self) -> None:
self.assertEqual(list(_schema_validator("game-regions.schema.json").iter_errors(self.regions)), [])
self.assertEqual(list(_schema_validator("game-extras.schema.json").iter_errors(self.extras)), [])
def test_the_fourteen_cases_are_encoded_and_used(self) -> None:
self.assertEqual(set(self.regions["cases"]), EXPECTED_CASES)
used = {row["case"] for row in game_rules.summary(self.regions, self.extras)["case_entries"]}
self.assertEqual(used, EXPECTED_CASES)
def test_region_free_files_never_carry_a_region(self) -> None:
# The installer keeps them for every region: a profile tag would let
# a region filter drop what the code opens for every game.
for system, cores in self.regions["systems"].items():
for core, rule in cores.items():
for keep in rule.get("keep_all_regions") or []:
if keep["keeps"] == "region-free-files":
self.assertTrue(set(keep["files"]) <= set(rule.get("region_free") or []),
f"{system}/{core}")
if __name__ == "__main__":
unittest.main()