From dc337c893cb99b746538a8e33895ffd46e0397d8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:36:26 +0100 Subject: [PATCH] feat: add region and per-game file rules --- games/extras.yml | 278 +++++++++++++++ games/regions.yml | 327 ++++++++++++++++++ schemas/game-extras.schema.json | 59 ++++ schemas/game-regions.schema.json | 85 +++++ scripts/game_rules.py | 269 ++++++++++++++ .../fixtures/game_rules/emulators/fdscore.yml | 9 + .../fixtures/game_rules/emulators/psxcore.yml | 18 + tests/fixtures/game_rules/extras.yml | 8 + tests/fixtures/game_rules/headers.yml | 18 + tests/fixtures/game_rules/regions.yml | 20 ++ tests/test_game_regions.py | 202 +++++++++++ 11 files changed, 1293 insertions(+) create mode 100644 games/extras.yml create mode 100644 games/regions.yml create mode 100644 schemas/game-extras.schema.json create mode 100644 schemas/game-regions.schema.json create mode 100644 scripts/game_rules.py create mode 100644 tests/fixtures/game_rules/emulators/fdscore.yml create mode 100644 tests/fixtures/game_rules/emulators/psxcore.yml create mode 100644 tests/fixtures/game_rules/extras.yml create mode 100644 tests/fixtures/game_rules/headers.yml create mode 100644 tests/fixtures/game_rules/regions.yml create mode 100644 tests/test_game_regions.py diff --git a/games/extras.yml b/games/extras.yml new file mode 100644 index 000000000..a36cab2d0 --- /dev/null +++ b/games/extras.yml @@ -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 .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" diff --git a/games/regions.yml b/games/regions.yml new file mode 100644 index 000000000..f2c9c4c29 --- /dev/null +++ b/games/regions.yml @@ -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" diff --git a/schemas/game-extras.schema.json b/schemas/game-extras.schema.json new file mode 100644 index 000000000..7fa890da1 --- /dev/null +++ b/schemas/game-extras.schema.json @@ -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"] }] + } + } + } +} diff --git a/schemas/game-regions.schema.json b/schemas/game-regions.schema.json new file mode 100644 index 000000000..5d7cfb653 --- /dev/null +++ b/schemas/game-regions.schema.json @@ -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" } + } + } + } +} diff --git a/scripts/game_rules.py b/scripts/game_rules.py new file mode 100644 index 000000000..bd4d6cd39 --- /dev/null +++ b/scripts/game_rules.py @@ -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) diff --git a/tests/fixtures/game_rules/emulators/fdscore.yml b/tests/fixtures/game_rules/emulators/fdscore.yml new file mode 100644 index 000000000..7034fa923 --- /dev/null +++ b/tests/fixtures/game_rules/emulators/fdscore.yml @@ -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 diff --git a/tests/fixtures/game_rules/emulators/psxcore.yml b/tests/fixtures/game_rules/emulators/psxcore.yml new file mode 100644 index 000000000..5920113a7 --- /dev/null +++ b/tests/fixtures/game_rules/emulators/psxcore.yml @@ -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 diff --git a/tests/fixtures/game_rules/extras.yml b/tests/fixtures/game_rules/extras.yml new file mode 100644 index 000000000..460b769b2 --- /dev/null +++ b/tests/fixtures/game_rules/extras.yml @@ -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" diff --git a/tests/fixtures/game_rules/headers.yml b/tests/fixtures/game_rules/headers.yml new file mode 100644 index 000000000..310e988a9 --- /dev/null +++ b/tests/fixtures/game_rules/headers.yml @@ -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 diff --git a/tests/fixtures/game_rules/regions.yml b/tests/fixtures/game_rules/regions.yml new file mode 100644 index 000000000..3c79d36ee --- /dev/null +++ b/tests/fixtures/game_rules/regions.yml @@ -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" diff --git a/tests/test_game_regions.py b/tests/test_game_regions.py new file mode 100644 index 000000000..97f660eae --- /dev/null +++ b/tests/test_game_regions.py @@ -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()