Mednafen (\d+(?:\.\d+)+)'
profiled_date: "2026-08-08"
core_version: "1.32.1"
+version_basis:
+ kind: package
+ url: "https://mednafen.github.io/releases/files/mednafen-1.32.1.tar.xz"
+ pattern: 'mednafen-(\d+(?:\.\d+)+)\.tar\.xz'
+ artifact: "mednafen-1.32.1.tar.xz"
display_name: "Mednafen"
cores: [mednafen]
mode: standalone
diff --git a/emulators/mednafen_gba.yml b/emulators/mednafen_gba.yml
index 619352ea3..d8d3deaee 100644
--- a/emulators/mednafen_gba.yml
+++ b/emulators/mednafen_gba.yml
@@ -3,6 +3,9 @@ type: alias
alias_of: "beetle_gba"
profiled_date: "2026-03-24"
core_version: "v0.9.36"
+version_basis:
+ kind: alias
+ of: "beetle_gba"
display_name: "Nintendo - Game Boy Advance (Beetle GBA)"
cores:
- mednafen_gba
diff --git a/emulators/mednafen_lynx.yml b/emulators/mednafen_lynx.yml
index 8cbe2ac90..b40c5620d 100644
--- a/emulators/mednafen_lynx.yml
+++ b/emulators/mednafen_lynx.yml
@@ -3,6 +3,9 @@ type: alias
alias_of: "beetle_lynx"
profiled_date: "2026-03-24"
core_version: "v1.24.0"
+version_basis:
+ kind: alias
+ of: "beetle_lynx"
display_name: "Atari - Lynx (Beetle Lynx)"
cores:
- mednafen_lynx
diff --git a/emulators/mednafen_ngp.yml b/emulators/mednafen_ngp.yml
index 408ef2e03..0afaf8e37 100644
--- a/emulators/mednafen_ngp.yml
+++ b/emulators/mednafen_ngp.yml
@@ -3,6 +3,9 @@ type: alias
alias_of: "beetle_ngp"
profiled_date: "2026-03-24"
core_version: "v1.29.0.0"
+version_basis:
+ kind: alias
+ of: "beetle_ngp"
display_name: "SNK - Neo Geo Pocket / Color (Beetle NeoPop)"
cores:
- mednafen_ngp
diff --git a/emulators/mednafen_pce.yml b/emulators/mednafen_pce.yml
index 257a68920..d83519043 100644
--- a/emulators/mednafen_pce.yml
+++ b/emulators/mednafen_pce.yml
@@ -3,6 +3,9 @@ type: alias
alias_of: "beetle_pce"
profiled_date: "2026-03-24"
core_version: "v1.29.0"
+version_basis:
+ kind: alias
+ of: "beetle_pce"
display_name: "NEC - PC Engine / SuperGrafx / CD (Beetle PCE)"
cores:
- mednafen_pce
diff --git a/emulators/mednafen_pce_fast.yml b/emulators/mednafen_pce_fast.yml
index 67014859f..91e2e0acc 100644
--- a/emulators/mednafen_pce_fast.yml
+++ b/emulators/mednafen_pce_fast.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-03-24"
source_commit: "4eee99c451f85ebe66d1591de88b892015b23059"
core_version: "v1.31.0.0"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)'
display_name: "NEC - PC Engine / CD (Beetle PCE FAST)"
cores: [mednafen_pce_fast, pce_fast, beetle_pce_fast, "beetle-pce-fast"]
systems: [nec-pc-engine]
diff --git a/emulators/mednafen_pcfx.yml b/emulators/mednafen_pcfx.yml
index 1739c42c3..9db6618cf 100644
--- a/emulators/mednafen_pcfx.yml
+++ b/emulators/mednafen_pcfx.yml
@@ -3,6 +3,9 @@ type: alias
alias_of: "beetle_pcfx"
profiled_date: "2026-03-24"
core_version: "v0.9.36.5"
+version_basis:
+ kind: alias
+ of: "beetle_pcfx"
display_name: "NEC - PC-FX (Beetle PC-FX)"
cores:
- mednafen_pcfx
diff --git a/emulators/mednafen_psx.yml b/emulators/mednafen_psx.yml
index 72928feef..1b5bd75f0 100644
--- a/emulators/mednafen_psx.yml
+++ b/emulators/mednafen_psx.yml
@@ -2,7 +2,10 @@ emulator: "mednafen_psx"
type: alias
alias_of: "beetle_psx"
profiled_date: "2026-03-24"
-core_version: "v0.9.44.1"
+core_version: "0.9.44.1"
+version_basis:
+ kind: alias
+ of: "beetle_psx"
display_name: "Sony - PlayStation (Beetle PSX)"
cores:
- mednafen_psx
diff --git a/emulators/mednafen_psx_hw.yml b/emulators/mednafen_psx_hw.yml
index f0ce68dfc..7b712fa27 100644
--- a/emulators/mednafen_psx_hw.yml
+++ b/emulators/mednafen_psx_hw.yml
@@ -2,7 +2,10 @@ emulator: "mednafen_psx_hw"
type: alias
alias_of: "beetle_psx"
profiled_date: "2026-03-24"
-core_version: "v0.9.44.1"
+core_version: "0.9.44.1"
+version_basis:
+ kind: alias
+ of: "beetle_psx"
display_name: "Sony - PlayStation (Beetle PSX HW)"
cores:
- mednafen_psx_hw
diff --git a/emulators/mednafen_saturn.yml b/emulators/mednafen_saturn.yml
index eef662942..3d13c15f5 100644
--- a/emulators/mednafen_saturn.yml
+++ b/emulators/mednafen_saturn.yml
@@ -2,7 +2,10 @@ emulator: "mednafen_saturn"
type: alias
alias_of: "beetle_saturn"
profiled_date: "2026-03-24"
-core_version: "v0.9.45.1"
+core_version: "v1.32.1"
+version_basis:
+ kind: alias
+ of: "beetle_saturn"
display_name: "Sega - Saturn (Beetle Saturn)"
cores:
- mednafen_saturn
diff --git a/emulators/mednafen_snes.yml b/emulators/mednafen_snes.yml
index 18eaae576..5cc78a905 100644
--- a/emulators/mednafen_snes.yml
+++ b/emulators/mednafen_snes.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-03-24"
source_commit: "5f05e4c785e936c928ac468e129c55b6f08592cb"
core_version: "v0.9.26"
+version_basis:
+ kind: file
+ path: "libretro.cpp"
+ pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)'
display_name: "Nintendo - SNES / SFC (Beetle bsnes)"
cores: [mednafen_snes, beetle_bsnes, beetle-bsnes, "beetle_snes"]
systems: [nintendo-snes]
diff --git a/emulators/mednafen_stv.yml b/emulators/mednafen_stv.yml
index 19fd3bed0..1df2f03fa 100644
--- a/emulators/mednafen_stv.yml
+++ b/emulators/mednafen_stv.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-10-27"
source_commit: "e65713478870dd494afd5c0bcd852fc76c383f14"
core_version: "v1.8.3"
+version_basis:
+ kind: tag
+ tag: "v1.8.3"
+ distance: 0
display_name: "Sega - ST-V (Mednafen STV)"
cores:
- "mednafen_stv"
diff --git a/emulators/mednafen_supafaust.yml b/emulators/mednafen_supafaust.yml
index ed67ba7df..924621992 100644
--- a/emulators/mednafen_supafaust.yml
+++ b/emulators/mednafen_supafaust.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-03-24"
source_commit: "642d1d1b6684aa7e306a02a89885f3f5456a5157"
core_version: "1.29.0"
+version_basis:
+ kind: file
+ path: "mednafen/config.h"
+ pattern: '#define\s+MEDNAFEN_VERSION\s+"([^"]+)"'
display_name: "Nintendo - SNES / SFC (Beetle Supafaust)"
cores: [mednafen_supafaust, supafaust, beetle_supafaust]
systems: [nintendo-snes]
diff --git a/emulators/mednafen_supergrafx.yml b/emulators/mednafen_supergrafx.yml
index 1f4349a3e..c91fd512d 100644
--- a/emulators/mednafen_supergrafx.yml
+++ b/emulators/mednafen_supergrafx.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-10-04"
source_commit: "9c11dcd213a79d57d2c86313971631af801f2cdc"
core_version: "v1.29.0"
+version_basis:
+ kind: file
+ path: "libretro.cpp"
+ pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)'
display_name: "NEC - PC Engine SuperGrafx (Beetle SuperGrafx)"
cores: [mednafen_supergrafx, beetle_supergrafx, "beetle-supergrafx"]
systems: [nec-pc-engine]
diff --git a/emulators/mednafen_vb.yml b/emulators/mednafen_vb.yml
index b432c290a..34ba982ab 100644
--- a/emulators/mednafen_vb.yml
+++ b/emulators/mednafen_vb.yml
@@ -2,7 +2,10 @@ emulator: "mednafen_vb"
type: alias
alias_of: "beetle_vb"
profiled_date: "2026-03-24"
-core_version: "v0.9.36.1"
+core_version: "v1.31.0"
+version_basis:
+ kind: alias
+ of: "beetle_vb"
display_name: "Nintendo - Virtual Boy (Beetle VB)"
cores:
- mednafen_vb
diff --git a/emulators/mednafen_wswan.yml b/emulators/mednafen_wswan.yml
index d82bcc4d8..c51252939 100644
--- a/emulators/mednafen_wswan.yml
+++ b/emulators/mednafen_wswan.yml
@@ -4,6 +4,9 @@ alias_of: "beetle_wswan"
source: "https://github.com/libretro/beetle-wswan-libretro"
profiled_date: "2026-03-24"
core_version: "v0.9.35.1"
+version_basis:
+ kind: alias
+ of: "beetle_wswan"
display_name: "Bandai - WonderSwan/Color (Beetle Wonderswan)"
cores:
- mednafen_wswan
diff --git a/emulators/melonds-nightly.yml b/emulators/melonds-nightly.yml
index 076121ead..40f1df258 100644
--- a/emulators/melonds-nightly.yml
+++ b/emulators/melonds-nightly.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/rafaelvcaetano/melonDS-android-lib"
source_commit: "c42995caff0144f37ad9791dc9bc96d82e8a6f65"
upstream_commit: "431ab4bd0003c4356e25fce89640ae1004579b9b"
profiled_date: "2026-08-12"
-core_version: "2.0.1 (NIGHTLY)"
+core_version: "2.0.1"
+version_basis:
+ kind: file
+ path: "buildSrc/src/main/kotlin/AppConfig.kt"
+ pattern: 'const val versionName = "([^"]+)"'
display_name: "Nintendo - DS (melonDS Nightly)"
cores: ["melonds-nightly"]
systems: [nintendo-ds, nintendo-dsi]
diff --git a/emulators/melonds.yml b/emulators/melonds.yml
index 5915887a0..432e036a9 100644
--- a/emulators/melonds.yml
+++ b/emulators/melonds.yml
@@ -7,7 +7,11 @@ upstream: "https://github.com/melonDS-emu/melonDS"
profiled_date: "2026-03-24"
source_commit: "66b5d2634cd0a79030562811e6e05f5532f800ba"
upstream_commit: "7d8dea42e221b13521270c423b61add41a741b97"
-core_version: "Git"
+core_version: "0.9.3"
+version_basis:
+ kind: file
+ path: "Makefile.common"
+ pattern: '^MELONDS_VERSION\s*=\s*"([^"]+)"'
display_name: "Nintendo - DS (melonDS)"
systems: [nintendo-ds, nintendo-dsi]
data_root:
diff --git a/emulators/melonds_ds.yml b/emulators/melonds_ds.yml
index 95f4ac6ec..d6605bf00 100644
--- a/emulators/melonds_ds.yml
+++ b/emulators/melonds_ds.yml
@@ -7,7 +7,11 @@ upstream: "https://github.com/melonDS-emu/melonDS"
profiled_date: "2026-10-04"
source_commit: "f394adbacb5722ee97c1b37c8064da9a25818310"
upstream_commit: "7d8dea42e221b13521270c423b61add41a741b97"
-core_version: "1.4.0"
+core_version: "v1.4.0"
+version_basis:
+ kind: tag
+ tag: "v1.4.0"
+ distance: 0
display_name: "Nintendo - DS (melonDS DS)"
systems:
- nintendo-ds
diff --git a/emulators/mesen-s.yml b/emulators/mesen-s.yml
index 79fc15434..f964611c9 100644
--- a/emulators/mesen-s.yml
+++ b/emulators/mesen-s.yml
@@ -11,6 +11,11 @@ profiled_date: "2026-03-24"
source_commit: "9e4fdeb9b336470bc96beb8765b2e79c86a2da1e"
upstream_commit: "89199f298a95345a893d814525b6668567a6d490"
core_version: "0.4.0"
+version_basis:
+ kind: file
+ path: "Core/EmuSettings.cpp"
+ pattern: 'uint16_t major = (\d+);\s*\n\s*uint8_t minor = (\d+);\s*\n\s*uint8_t revision = (\d+);'
+ format: "{1}.{2}.{3}"
cores: [mesen-s, mesen_s]
systems: [nintendo-snes, nintendo-gb, nintendo-gbc, nintendo-super-game-boy, nintendo-satellaview]
diff --git a/emulators/mesen.yml b/emulators/mesen.yml
index dc39a92d8..54eb463e8 100644
--- a/emulators/mesen.yml
+++ b/emulators/mesen.yml
@@ -7,6 +7,11 @@ logo: "https://raw.githubusercontent.com/SourMesen/Mesen2/master/UI/Assets/Mesen
profiled_date: "2026-03-24"
source_commit: "0102910c39ad1a62bc3f784466f3f67ca9eae335"
core_version: "0.9.9"
+version_basis:
+ kind: file
+ path: "Core/EmulationSettings.cpp"
+ pattern: '_versionMajor = (\d+);\s*\n[^\n]*_versionMinor = (\d+);\s*\n[^\n]*_versionRevision = (\d+);'
+ format: "{1}.{2}.{3}"
display_name: "Nintendo - NES / Famicom (Mesen)"
cores: [mesen, mesens]
systems: [nintendo-nes, nintendo-fds]
diff --git a/emulators/mesence.yml b/emulators/mesence.yml
index 2aa0fbfc8..88480f7ca 100644
--- a/emulators/mesence.yml
+++ b/emulators/mesence.yml
@@ -12,7 +12,18 @@ source_commit:
upstream_commit: "b9fa69ddc6d0a331fb103fdb5eef6904305703c2"
core_version:
standalone: "2.2.1"
- libretro: "2.0.0"
+ libretro: "2.2.1"
+version_basis:
+ standalone:
+ kind: file
+ path: "Core/Shared/EmuSettings.cpp"
+ pattern: 'uint16_t major = (\d+);\s*\n\s*uint8_t minor = (\d+);\s*\n\s*uint8_t revision = (\d+);'
+ format: "{1}.{2}.{3}"
+ libretro:
+ kind: file
+ path: "Core/Shared/EmuSettings.cpp"
+ pattern: 'uint16_t major = (\d+);\s*\n\s*uint8_t minor = (\d+);\s*\n\s*uint8_t revision = (\d+);'
+ format: "{1}.{2}.{3}"
display_name: "Multi-System (MesenCE)"
cores: [mesence, mesen2]
systems:
diff --git a/emulators/mess2015.yml b/emulators/mess2015.yml
index 9b513f60c..63e8bb184 100644
--- a/emulators/mess2015.yml
+++ b/emulators/mess2015.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/mame2015-libretro"
upstream: "https://github.com/mamedev/mame/tree/mame0160"
profiled_date: "2026-03-24"
source_commit: "7adbf440d5f554097f58f8c6ebb673adc4320a31"
-core_version: "0.160"
+core_version: "0.159"
+version_basis:
+ kind: file
+ path: "src/version.c"
+ pattern: '#define\s+BARE_BUILD_VERSION\s+"([^"]+)"'
display_name: "Multi (MESS 2015)"
mame_version: "0.159"
diff --git a/emulators/meteor.yml b/emulators/meteor.yml
index 274fdc20d..c735484af 100644
--- a/emulators/meteor.yml
+++ b/emulators/meteor.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/blastrock/meteor"
profiled_date: "2026-03-24"
source_commit: "9ea6c358eae6acd1a346647d96b832ccaffad0aa"
core_version: "v1.4"
+version_basis:
+ kind: file
+ path: "libretro/libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Nintendo - Game Boy Advance (Meteor)"
cores:
- meteor
diff --git a/emulators/mfme.yml b/emulators/mfme.yml
index 6e5cabfa4..96d373a11 100644
--- a/emulators/mfme.yml
+++ b/emulators/mfme.yml
@@ -5,6 +5,10 @@ source: "https://www.desertislandfruits.com/"
upstream: closed-source
profiled_date: "2026-08-08"
core_version: "20.1"
+version_basis:
+ kind: package
+ url: "https://www.desertislandfruits.com/"
+ artifact: "MFME v20 Windows installer, MFME.exe FileVersion 20.1.0.0"
display_name: "MFME (Fruit Machines)"
cores:
- mfme
diff --git a/emulators/mgba.yml b/emulators/mgba.yml
index 168c35045..30663a7fd 100644
--- a/emulators/mgba.yml
+++ b/emulators/mgba.yml
@@ -7,7 +7,12 @@ logo: "https://raw.githubusercontent.com/mgba-emu/mgba/master/res/mgba-256.png"
profiled_date: "2026-03-24"
source_commit: "7a12d6d4b9acb14c0ae62c9166b6a2f3d08007f6"
upstream_commit: "669817d03e4858e65d0b992bcd96d3009236cc1e"
-core_version: "0.10-dev"
+core_version: "0.11.0"
+version_basis:
+ kind: file
+ path: "version.cmake"
+ pattern: 'set\(LIB_VERSION_MAJOR (\d+)\)\s*\nset\(LIB_VERSION_MINOR (\d+)\)\s*\nset\(LIB_VERSION_PATCH (\d+)\)'
+ format: "{1}.{2}.{3}"
display_name: "Nintendo - Game Boy Advance (mGBA)"
cores:
- mgba
diff --git a/emulators/mgba_splitscreen.yml b/emulators/mgba_splitscreen.yml
index df26841dc..9ef5e075a 100644
--- a/emulators/mgba_splitscreen.yml
+++ b/emulators/mgba_splitscreen.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/mgba-emu/mgba"
profiled_date: "2026-10-04"
source_commit: "f1d5975d77285f77affbfe64b7050ebd5c4b6a77"
upstream_commit: "afd6f14eaf8bd35214ed3fb9dc69a92bfc3877a9"
-core_version: "v0.3.1"
+core_version: "0.3.1"
+version_basis:
+ kind: file
+ path: "mgba-splitscreen/package.json"
+ pattern: '"version":\s+"([^"]+)'
display_name: "Nintendo - Game Boy Advance (mGBA Splitscreen)"
cores:
- mgba_splitscreen
diff --git a/emulators/minivmac.yml b/emulators/minivmac.yml
index 28685811c..4922ccf27 100644
--- a/emulators/minivmac.yml
+++ b/emulators/minivmac.yml
@@ -6,6 +6,10 @@ upstream: "https://www.gryphel.com/c/minivmac/"
profiled_date: "2026-03-24"
source_commit: "babdf7b53d361a7225858b68a8a64efe8b06f5e5"
core_version: "36.04"
+version_basis:
+ kind: file
+ path: "minivmac/cfg/CNFGRAPI.h"
+ pattern: '#define\s+kAppVariationStr\s+"minivmac-([0-9.]+)-'
display_name: "Mac II (minivmac)"
cores: [minivmac]
systems: [macintosh-ii]
diff --git a/emulators/minivmac37.yml b/emulators/minivmac37.yml
index 089bf95fd..5ba128d50 100644
--- a/emulators/minivmac37.yml
+++ b/emulators/minivmac37.yml
@@ -5,6 +5,11 @@ source: "https://github.com/minivmac/minivmac"
profiled_date: "2026-10-20"
source_commit: "6860a9a7f372c461b25dc42974b191a453ebcfb2"
core_version: "37.03"
+version_basis:
+ kind: file
+ path: "setup/SPBASDEF.i"
+ pattern: '#define MajorVersion (\d+)\s*\n#define MinorVersion (\d+)'
+ format: "{1}.{2}"
display_name: "Apple - Macintosh Plus (Mini vMac 37)"
mode: standalone
cores: ["minivmac"]
diff --git a/emulators/mkxp-z.yml b/emulators/mkxp-z.yml
index fa70b77db..6c2ae571e 100644
--- a/emulators/mkxp-z.yml
+++ b/emulators/mkxp-z.yml
@@ -5,4 +5,8 @@ display_name: "RPG Maker XP/VX/VX Ace (mkxp-z)"
cores:
- mkxp-z
profiled_date: "2026-03-24"
+core_version: "2.4.2"
+version_basis:
+ kind: alias
+ of: "mkxp_z"
files: []
diff --git a/emulators/mkxp_z.yml b/emulators/mkxp_z.yml
index 8502f7178..d86b42710 100644
--- a/emulators/mkxp_z.yml
+++ b/emulators/mkxp_z.yml
@@ -8,7 +8,11 @@ upstream: "https://github.com/mkxp-z/mkxp-z"
profiled_date: "2026-03-24"
source_commit: "650cb0888a07d0b5044e131160ddfa53feaf595b"
upstream_commit: "37a04d14413454551a7b6aadbb4e93e67168accb"
-core_version: "f4bc5aa"
+core_version: "2.4.2"
+version_basis:
+ kind: file
+ path: "meson.build"
+ pattern: '^project\(\s*''mkxp-z''[^)]*?version:\s*''([^'']+)'''
display_name: "RPG Maker XP/VX/VX Ace (mkxp-z)"
cores:
- mkxp-z
diff --git a/emulators/model2.yml b/emulators/model2.yml
index 4f4f69ca1..6c13c2335 100644
--- a/emulators/model2.yml
+++ b/emulators/model2.yml
@@ -7,6 +7,9 @@ version_source:
pattern: 'Model 2 Emulator: (\d+\.\d+[a-z]?)'
profiled_date: "2026-03-26"
core_version: "1.1a"
+version_basis:
+ kind: unverified
+ why: no_artifact
display_name: "Model 2 Emulator (Sega Model 2)"
cores:
- model2
diff --git a/emulators/mojozork.yml b/emulators/mojozork.yml
index 71dc3a689..c960f396f 100644
--- a/emulators/mojozork.yml
+++ b/emulators/mojozork.yml
@@ -5,7 +5,11 @@ source: "https://github.com/icculus/mojozork"
upstream: "https://github.com/icculus/mojozork"
profiled_date: "2026-03-24"
source_commit: "ff7e00742a00acec8e175ddefb97520fb270df2d"
-core_version: "0.1"
+core_version: "0.3"
+version_basis:
+ kind: file
+ path: "mojozork-libretro.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "Infocom Z-Machine (MojoZork)"
cores:
- mojozork
diff --git a/emulators/moonlight.yml b/emulators/moonlight.yml
index 225fe4ff1..78aefe552 100644
--- a/emulators/moonlight.yml
+++ b/emulators/moonlight.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/moonlight-stream/moonlight-embedded"
profiled_date: "2026-03-24"
source_commit: "20d2d77d219613a2809fa9afe9606f6cf3be2aa0"
core_version: "1.0.3"
+version_basis:
+ kind: tag
+ tag: "1.0.3"
+ distance: 0
display_name: "Moonlight"
cores:
- moonlight
diff --git a/emulators/mpv.yml b/emulators/mpv.yml
index d1676222d..ecb0e6546 100644
--- a/emulators/mpv.yml
+++ b/emulators/mpv.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/mpv-player/mpv"
profiled_date: "2026-03-24"
source_commit: "99f7fe4fd43d68da45006301829eaf37c7edbcc2"
core_version: "0.2.alpha"
+version_basis:
+ kind: file
+ path: "version.h"
+ pattern: '#define\s+LIBRETRO_MPV_VERSION\s+"([^"]+)'
display_name: "MPV"
cores:
- mpv
diff --git a/emulators/mrboom.yml b/emulators/mrboom.yml
index 67e604d9d..ca771e6f7 100644
--- a/emulators/mrboom.yml
+++ b/emulators/mrboom.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/mrboom-libretro"
upstream: "https://github.com/libretro/mrboom-libretro"
profiled_date: "2026-03-24"
source_commit: "40ac32020b540c4bb418c7d798a990932a65da04"
-core_version: "5.3"
+core_version: "5.5"
+version_basis:
+ kind: file
+ path: "common.hpp"
+ pattern: '#define\s+GAME_VERSION\s+"([^"]+)"'
display_name: "Mr.Boom (Bomberman)"
cores: [mrboom]
systems: []
diff --git a/emulators/msx-emu.yml b/emulators/msx-emu.yml
index d6b7135db..8a16aa246 100644
--- a/emulators/msx-emu.yml
+++ b/emulators/msx-emu.yml
@@ -6,6 +6,10 @@ upstream: "http://bluemsx.msxblue.com/"
profiled_date: "2026-08-12"
source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943"
core_version: "1.5.85"
+version_basis:
+ kind: file
+ path: "EmuFramework/metadata/conf.mk"
+ pattern: '^metadata_version\s*=\s*(\S+)'
display_name: "MSX/ColecoVision (MSX.emu)"
mode: standalone
cores:
diff --git a/emulators/mu.yml b/emulators/mu.yml
index ffb586708..688d29b71 100644
--- a/emulators/mu.yml
+++ b/emulators/mu.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/meepingsnesroms/Mu"
profiled_date: "2026-03-24"
source_commit: "afaeb157b8ba38a4a9bdf426ba663f7efb2cc6f8"
core_version: "v1.3.3"
+version_basis:
+ kind: file
+ path: "libretroBuildSystem/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Palm OS (Mu)"
cores: [mu]
systems: [palm-os]
diff --git a/emulators/mupen64plus-ae.yml b/emulators/mupen64plus-ae.yml
index 7ec92b775..132c3405c 100644
--- a/emulators/mupen64plus-ae.yml
+++ b/emulators/mupen64plus-ae.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/mupen64plus-ae/mupen64plus-ae"
profiled_date: "2026-08-12"
source_commit: "33bf7021549d7873e976884370fbd1a8e7333bc7"
core_version: "3.0.249 (beta)"
+version_basis:
+ kind: file
+ path: "build_common/version_common.gradle"
+ pattern: 'versionName\s*=\s*"([^"]+)"'
display_name: "Nintendo - Nintendo 64 (Mupen64Plus AE)"
cores:
- mupen64plus-ae
diff --git a/emulators/mupen64plus-libretro.yml b/emulators/mupen64plus-libretro.yml
index 7d334447c..f27adfa16 100644
--- a/emulators/mupen64plus-libretro.yml
+++ b/emulators/mupen64plus-libretro.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/mupen64plus-libretro"
profiled_date: "2026-10-22"
source_commit: "ab8134ac90a567581df6de4fc427dd67bfad1b17"
core_version: "2.5"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Nintendo - Nintendo 64 (Mupen64Plus 2019)"
cores:
- "mupen64plus_32b"
diff --git a/emulators/mupen64plus.yml b/emulators/mupen64plus.yml
index cded5323f..280ae6ec6 100644
--- a/emulators/mupen64plus.yml
+++ b/emulators/mupen64plus.yml
@@ -8,6 +8,11 @@ source_commit: "c8ac4862a019d7885b24927d9b4db5dd3e42a528"
upstream_commit: "6dca4c15370ac3e2171ce7b31426695f8f39b460"
upstream_frozen: "AmberELEC builds the core at this revision with the front end at source_commit. Later core commits are not part of that build."
core_version: "2.6.0"
+version_basis:
+ kind: file
+ path: "src/version.h"
+ pattern: '#define\s+CONSOLE_UI_VERSION\s+0x0?((?<=0)[0-9]|[1-9][0-9])0?((?<=0)[0-9]|[1-9][0-9])0?((?<=0)[0-9]|[1-9][0-9])\b'
+ format: "{1}.{2}.{3}"
display_name: "Nintendo - Nintendo 64 (Mupen64Plus)"
cores:
- "mupen64plus"
diff --git a/emulators/mupen64plus_next.yml b/emulators/mupen64plus_next.yml
index 0a6980a93..60b24e922 100644
--- a/emulators/mupen64plus_next.yml
+++ b/emulators/mupen64plus_next.yml
@@ -7,6 +7,11 @@ profiled_date: "2026-03-24"
source_commit: "15bada85906593376e8fd697f1b0b3af3a20f512"
upstream_commit: "3be6336a1de6f32ccc4f57bf80c988163fe6bf7a"
core_version: "2.6.0"
+version_basis:
+ kind: file
+ path: "mupen64plus-core/src/main/version.h"
+ pattern: '#define\s+MUPEN_CORE_VERSION\s+0x0?((?<=0)[0-9]|[1-9][0-9])0?((?<=0)[0-9]|[1-9][0-9])0?((?<=0)[0-9]|[1-9][0-9])\b'
+ format: "{1}.{2}.{3}"
display_name: "Nintendo - Nintendo 64 (Mupen64Plus-Next)"
systems: [nintendo-64, nintendo-64dd]
cores: [mupen64plus_next, mupen64plus_next_develop, mupen64plus_next_gles3, mupen64plus_next_gles2, mupen64plus-next, mupen64plus_nx, "mupen64plus_next_alt"]
diff --git a/emulators/mupen64plus_next_develop.yml b/emulators/mupen64plus_next_develop.yml
index a4f9476c9..f59ca7cac 100644
--- a/emulators/mupen64plus_next_develop.yml
+++ b/emulators/mupen64plus_next_develop.yml
@@ -2,7 +2,10 @@ emulator: "Mupen64Plus-Next Develop"
type: alias
alias_of: "mupen64plus_next"
profiled_date: "2026-03-24"
-core_version: "2.8"
+core_version: "2.6.0"
+version_basis:
+ kind: alias
+ of: "mupen64plus_next"
display_name: "Nintendo - Nintendo 64 (Mupen64Plus-Next Develop)"
cores:
- mupen64plus_next_develop
diff --git a/emulators/mupen64plus_next_gles2.yml b/emulators/mupen64plus_next_gles2.yml
index 29d872373..01165cb68 100644
--- a/emulators/mupen64plus_next_gles2.yml
+++ b/emulators/mupen64plus_next_gles2.yml
@@ -2,7 +2,10 @@ emulator: "Mupen64Plus-Next GLES2"
type: alias
alias_of: "mupen64plus_next"
profiled_date: "2026-03-24"
-core_version: "2.8"
+core_version: "2.6.0"
+version_basis:
+ kind: alias
+ of: "mupen64plus_next"
display_name: "Nintendo - Nintendo 64 (Mupen64Plus-Next GLES2)"
cores:
- mupen64plus_next_gles2
diff --git a/emulators/mupen64plus_next_gles3.yml b/emulators/mupen64plus_next_gles3.yml
index 661f1ddf7..e80f488e2 100644
--- a/emulators/mupen64plus_next_gles3.yml
+++ b/emulators/mupen64plus_next_gles3.yml
@@ -2,7 +2,10 @@ emulator: "Mupen64Plus-Next GLES3"
type: alias
alias_of: "mupen64plus_next"
profiled_date: "2026-03-24"
-core_version: "2.8"
+core_version: "2.6.0"
+version_basis:
+ kind: alias
+ of: "mupen64plus_next"
display_name: "Nintendo - Nintendo 64 (Mupen64Plus-Next GLES3)"
cores:
- mupen64plus_next_gles3
diff --git a/emulators/my-boy.yml b/emulators/my-boy.yml
index 119663e3c..366c23473 100644
--- a/emulators/my-boy.yml
+++ b/emulators/my-boy.yml
@@ -10,6 +10,10 @@ version_source:
author: "Pixel Respawn LLC"
profiled_date: "2026-10-20"
core_version: "2.0.3"
+version_basis:
+ kind: package
+ url: "https://play.google.com/store/apps/details?id=com.fastemulator.gba"
+ artifact: "com.fastemulator.gba APK versionName (versionCode 90053)"
display_name: "Nintendo - Game Boy Advance (My Boy!)"
mode: standalone
cores:
diff --git a/emulators/my-oldboy.yml b/emulators/my-oldboy.yml
index b49d67645..6754571be 100644
--- a/emulators/my-oldboy.yml
+++ b/emulators/my-oldboy.yml
@@ -10,6 +10,10 @@ version_source:
author: "Pixel Respawn LLC"
profiled_date: "2026-10-20"
core_version: "2.0.0"
+version_basis:
+ kind: package
+ url: "https://play.google.com/store/apps/details?id=com.fastemulator.gbc"
+ artifact: "com.fastemulator.gbc APK versionName (versionCode 90022)"
display_name: "Nintendo - Game Boy / Game Boy Color (My OldBoy!)"
mode: standalone
cores:
diff --git a/emulators/nanoboyadvance.yml b/emulators/nanoboyadvance.yml
index 92546cc99..bbd61b392 100644
--- a/emulators/nanoboyadvance.yml
+++ b/emulators/nanoboyadvance.yml
@@ -5,7 +5,11 @@ source: "https://github.com/nba-emu/NanoBoyAdvance"
profiled_date: "2026-10-17"
source_commit: "3bb6f478f977dbfd3106508536e5fbce90d1898b"
pin_frozen: "ROCKNIX 20261001 builds this revision of the SDL2 frontend (v1.6 plus four commits). Later upstream work is not part of that build."
-core_version: "1.6"
+core_version: "v1.6-4-g3bb6f47"
+version_basis:
+ kind: tag
+ tag: "v1.6"
+ distance: 4
display_name: "Nintendo - Game Boy Advance (NanoBoyAdvance)"
cores:
- "nanoboyadvance"
diff --git a/emulators/native32emu.yml b/emulators/native32emu.yml
index 3e56eafdd..4bd95294a 100644
--- a/emulators/native32emu.yml
+++ b/emulators/native32emu.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/AloysHF/Native32Emu"
profiled_date: "2026-10-26"
source_commit: "059fa33c658caa4dbbad140ebf2af1197f9dd778"
core_version: "1.4.0"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "Sunplus - Native32 (Native32Emu)"
cores:
- native32emu
diff --git a/emulators/nekop2.yml b/emulators/nekop2.yml
index 4f7ed432b..2c913ced7 100644
--- a/emulators/nekop2.yml
+++ b/emulators/nekop2.yml
@@ -6,6 +6,10 @@ upstream: "https://np2.yui.ne.jp"
profiled_date: "2026-03-24"
source_commit: "5fdbb2187f6b14ca7bf668dd52b36c820b989995"
core_version: "0.86"
+version_basis:
+ kind: file
+ path: "x11/configure.ac"
+ pattern: 'AC_INIT\(Xnp2,([0-9][\w.+-]*)'
display_name: "NEC - PC-98 (Neko Project II)"
cores: [nekop2, meowpc98]
systems: [pc-98]
diff --git a/emulators/neo-emu.yml b/emulators/neo-emu.yml
index f56052f3f..14d2e8762 100644
--- a/emulators/neo-emu.yml
+++ b/emulators/neo-emu.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-08-12"
source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943"
upstream_commit: "ed06739a7202a87cd0992006628e4748aacd1ce5"
core_version: "1.5.85"
+version_basis:
+ kind: file
+ path: "EmuFramework/metadata/conf.mk"
+ pattern: '^metadata_version\s*=\s*(\S+)'
display_name: "SNK - Neo Geo (NEO.emu)"
mode: standalone
cores:
diff --git a/emulators/neocd.yml b/emulators/neocd.yml
index 6cc0b0d0c..34b63753e 100644
--- a/emulators/neocd.yml
+++ b/emulators/neocd.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/neocd_libretro"
profiled_date: "2026-03-24"
source_commit: "b1e04c738cb48a1dae0574b8877f6a116d270ca1"
core_version: "2022"
+version_basis:
+ kind: file
+ path: "src/libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "SNK - Neo Geo CD (NeoCD)"
cores:
- neocd
diff --git a/emulators/nes-emu.yml b/emulators/nes-emu.yml
index c115f4c69..e1c9688a2 100644
--- a/emulators/nes-emu.yml
+++ b/emulators/nes-emu.yml
@@ -8,6 +8,10 @@ source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943"
upstream_commit: "d339f1fa0cd8b69cc637a0de3fb3f04cb28748ea"
upstream_frozen: "NES.emu's FCEUX tree is synced at this FCEUX commit of 2025-11-11. Later FCEUX commits are not in the port."
core_version: "1.5.85"
+version_basis:
+ kind: file
+ path: "EmuFramework/metadata/conf.mk"
+ pattern: '^metadata_version\s*=\s*(\S+)'
display_name: "Nintendo - NES / Famicom (NES.emu)"
mode: standalone
cores:
diff --git a/emulators/nes.yml b/emulators/nes.yml
index 0df696d2a..63a1d38cb 100644
--- a/emulators/nes.yml
+++ b/emulators/nes.yml
@@ -6,6 +6,10 @@ upstream: "https://git.9front.org/plan9front/plan9front (sys/src/games/nes/)"
profiled_date: "2026-03-24"
source_commit: "68dfadea5889da5da7c7e171823c9b3ab24fc892"
core_version: "1.0"
+version_basis:
+ kind: file
+ path: "nes.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "Nintendo - NES / Famicom (nes)"
cores: [nes]
systems: [nintendo-nes]
diff --git a/emulators/nesoid.yml b/emulators/nesoid.yml
index e70e17549..73c02e044 100644
--- a/emulators/nesoid.yml
+++ b/emulators/nesoid.yml
@@ -6,7 +6,11 @@ upstream: "https://sourceforge.net/p/nesoid/code"
author: "Yongzh (androidemu.com)"
profiled_date: "2026-08-12"
source_commit: "b2ff53fefa56a668aad4a579af85adc672d7a791"
-core_version: "2.5-4"
+core_version: "2.5"
+version_basis:
+ kind: file
+ path: "app/build.gradle"
+ pattern: 'versionName\s+"([^"]+)"'
display_name: "Nintendo - NES / Famicom (Nesoid)"
mode: standalone
cores:
diff --git a/emulators/nestopia.yml b/emulators/nestopia.yml
index 088e6b326..10a0520ad 100644
--- a/emulators/nestopia.yml
+++ b/emulators/nestopia.yml
@@ -7,7 +7,11 @@ logo: "https://raw.githubusercontent.com/0ldsk00l/nestopia/master/icons/svg/nest
profiled_date: "2026-03-24"
source_commit: "2110d21b7174455ec98948bb474147dea0e3cad0"
upstream_commit: "b3adbeb42482e979ab99e16bcd9a22480f13479f"
-core_version: "1.53.1"
+core_version: "2.0.0"
+version_basis:
+ kind: file
+ path: "libretro/libretro.cpp"
+ pattern: '#define\s+NST_VERSION\s+"([^"]+)"'
display_name: "Nintendo - NES / Famicom (Nestopia)"
cores: [nestopia]
systems: [nintendo-nes, nintendo-fds]
diff --git a/emulators/nethersx2-turnip-classic.yml b/emulators/nethersx2-turnip-classic.yml
index be8691b44..ee33915d6 100644
--- a/emulators/nethersx2-turnip-classic.yml
+++ b/emulators/nethersx2-turnip-classic.yml
@@ -10,6 +10,11 @@ pin_frozen: "the classic build is this revision. v0.7 ships a newer shim, and it
source_commit: "8e17e584884e19b0a912f1988730d576d6d5aa84"
upstream_commit: "1d1f795dfc70e38da67724c7298779260f513889"
core_version: "2.2n-3668-Turnip-v0.7"
+version_basis:
+ kind: package
+ url: "https://github.com/nckstwrt/NetherSX2-Turnip/releases/download/v0.7/NetherSX2-Classic-v2.2n-3668-Turnip-v0.7.apk"
+ pattern: 'NetherSX2-Classic-v(.+)\.apk'
+ artifact: "NetherSX2-Classic-v2.2n-3668-Turnip-v0.7.apk"
display_name: "Sony - PlayStation 2 (NetherSX2-Turnip Classic)"
mode: standalone
cores:
diff --git a/emulators/nethersx2-turnip.yml b/emulators/nethersx2-turnip.yml
index 8762aed6c..5f71386a0 100644
--- a/emulators/nethersx2-turnip.yml
+++ b/emulators/nethersx2-turnip.yml
@@ -8,7 +8,11 @@ author: "nckstwrt"
profiled_date: "2026-08-12"
source_commit: "3b7cf800ccac4ab5af768aee24b04363bedeb711"
upstream_commit: "1d1f795dfc70e38da67724c7298779260f513889"
-core_version: "2.2n-4248-Turnip-v0.7"
+core_version: "v0.7"
+version_basis:
+ kind: tag
+ tag: "v0.7"
+ distance: 0
display_name: "Sony - PlayStation 2 (NetherSX2-Turnip)"
mode: standalone
cores:
diff --git a/emulators/nethersx2.yml b/emulators/nethersx2.yml
index 1d063c154..b4398c0bf 100644
--- a/emulators/nethersx2.yml
+++ b/emulators/nethersx2.yml
@@ -9,6 +9,11 @@ profiled_date: "2026-10-27"
source_commit: "f5f89b3b76e89a8f684da91cf5ad6e542825c0cb"
upstream_commit: "1d1f795dfc70e38da67724c7298779260f513889"
core_version: "2.2n-4248"
+version_basis:
+ kind: package
+ url: "https://github.com/Trixarian/NetherSX2-patch/releases/download/2.2n/NetherSX2-v2.2n-4248.apk"
+ pattern: 'NetherSX2-v([0-9.]+[a-z]?-[0-9]+)\.apk'
+ artifact: "NetherSX2-v2.2n-4248.apk"
display_name: "Sony - PlayStation 2 (NetherSX2)"
mode: standalone
cores:
diff --git a/emulators/ngp-emu.yml b/emulators/ngp-emu.yml
index 9da8dafdb..32ad37027 100644
--- a/emulators/ngp-emu.yml
+++ b/emulators/ngp-emu.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-08-12"
source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943"
core_version: "1.5.85"
+version_basis:
+ kind: file
+ path: "EmuFramework/metadata/conf.mk"
+ pattern: '^metadata_version\s*=\s*(\S+)'
display_name: "SNK - Neo Geo Pocket / Color (NGP.emu)"
mode: standalone
cores:
diff --git a/emulators/nicaiemu.yml b/emulators/nicaiemu.yml
index dbb585e0a..0ff35ac53 100644
--- a/emulators/nicaiemu.yml
+++ b/emulators/nicaiemu.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-10-21"
source_commit: "4c9b73f7213b1537d2361a8b1d438857bdcdc798"
pin_frozen: "the nicaiemu core is this revision, the last one whose .gitlab-ci.yml sets CORENAME: nicaiemu. The next master commit, 7496d513 (PR #78, 2026-10-16), renames the project and its crates to CoolbarEmu and the buildbot recipe to CORENAME: coolbaremu, so later commits build the coolbaremu core, described by coolbaremu.yml."
core_version: "0.1.0"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "MStar - Nicai (NicaiEmu)"
cores:
- nicaiemu
diff --git a/emulators/noods.yml b/emulators/noods.yml
index 593c0294d..f97db9241 100644
--- a/emulators/noods.yml
+++ b/emulators/noods.yml
@@ -6,7 +6,9 @@ upstream: "https://github.com/Hydr8gon/NooDS"
profiled_date: "2026-03-24"
source_commit: "9a2665306927ede64f4b5c109df79f4015607dbc"
upstream_commit: "4cb1439c330248b26cdf5f8dbcfa4e5ac53dcda0"
-core_version: "Git"
+core_version: null
+version_basis:
+ kind: none
display_name: "Nintendo - DS (NooDS)"
cores: [noods]
systems: [nintendo-ds, nintendo-gba]
diff --git a/emulators/nosgba.yml b/emulators/nosgba.yml
index d82f44116..e3d51e83d 100644
--- a/emulators/nosgba.yml
+++ b/emulators/nosgba.yml
@@ -8,6 +8,10 @@ version_source:
pattern: 'no\$gba v(\d+(?:\.\d+)+)'
profiled_date: "2026-10-24"
core_version: "3.06"
+version_basis:
+ kind: package
+ url: "https://problemkaputt.de/gba.htm"
+ artifact: "no$gba-w.zip, release v3.06 of 14 Apr 2025 on the download page"
display_name: "Nintendo - GBA/DS/DSi (no$gba)"
cores:
- nosgba
diff --git a/emulators/np2kai.yml b/emulators/np2kai.yml
index 1f2214abc..2ccbae0a4 100644
--- a/emulators/np2kai.yml
+++ b/emulators/np2kai.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-24"
source_commit: "1ea561cd7fd62b33f4e7b3f1c2e30fc3e6089f28"
upstream_commit: "5d47f3a04b0b8ea78f625eeec354ac132c80df06"
core_version: "0.86"
+version_basis:
+ kind: file
+ path: "np2ver.h"
+ pattern: '#define\s+NP2VER_CORE\s+"([0-9][0-9.]*)"'
display_name: "NEC - PC-98 (Neko Project II Kai)"
cores: [np2kai]
systems: [pc-98]
diff --git a/emulators/np2pi.yml b/emulators/np2pi.yml
index 66d29cefa..a8d3b5267 100644
--- a/emulators/np2pi.yml
+++ b/emulators/np2pi.yml
@@ -6,6 +6,10 @@ upstream: "https://np2.yui.ne.jp"
profiled_date: "2026-10-20"
source_commit: "2e995768b55e97acfaac32d45fbb8351645cba62"
core_version: "0.83"
+version_basis:
+ kind: file
+ path: "np2ver.h"
+ pattern: '#define\s+NP2VER_CORE\s+"ver\.([0-9][^"]*)"'
display_name: "NEC - PC-98 (np2pi)"
mode: standalone
cores:
diff --git a/emulators/nuance.yml b/emulators/nuance.yml
index a07611c3c..6d0dcdd59 100644
--- a/emulators/nuance.yml
+++ b/emulators/nuance.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-09-03"
source_commit: "daf3ca642756a559ecce91e10da0e73cbad180ff"
upstream_commit: "daf3ca642756a559ecce91e10da0e73cbad180ff"
core_version: "0.6.7"
+version_basis:
+ kind: file
+ path: "src/libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "VM Labs - NUON (Nuance)"
cores: [nuance]
systems: [vm-labs-nuon]
diff --git a/emulators/numero.yml b/emulators/numero.yml
index 07621dbef..41727febb 100644
--- a/emulators/numero.yml
+++ b/emulators/numero.yml
@@ -5,7 +5,11 @@ source: "https://github.com/nbarkhina/numero"
upstream: "https://github.com/sputt/wabbitemu"
profiled_date: "2026-03-24"
source_commit: "c0b07a39782feafebcde0c8ccfa91fc92732c999"
-core_version: "v1.0"
+core_version: "1.1"
+version_basis:
+ kind: file
+ path: "libnumero/libretro/libretronew.cpp"
+ pattern: 'library_version\s*=\s*"([^"]+)"'
display_name: "Texas Instruments TI-83 (Numero)"
cores:
- numero
diff --git a/emulators/nxengine.yml b/emulators/nxengine.yml
index c47f4e0b0..661a3e0de 100644
--- a/emulators/nxengine.yml
+++ b/emulators/nxengine.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/Rox64/NXEngine"
profiled_date: "2026-03-24"
source_commit: "fd1c0686f8b4c0aea9b5addbc077e3ad7da23bb7"
core_version: "1.0.0.6"
+version_basis:
+ kind: file
+ path: "nxengine/libretro/libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Cave Story (NXEngine)"
cores: [nxengine, nx]
systems: [cave-story]
diff --git a/emulators/o2em.yml b/emulators/o2em.yml
index 5bc7f467c..586ec8cb9 100644
--- a/emulators/o2em.yml
+++ b/emulators/o2em.yml
@@ -8,6 +8,10 @@ upstream: "https://sourceforge.net/projects/o2em/"
profiled_date: "2026-03-24"
source_commit: "679d6fec04963f6e70a7ec217e3d0ebb1fe472fc"
core_version: "1.18"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Magnavox - Odyssey2 / Philips Videopac+ (O2EM)"
systems:
- odyssey2
diff --git a/emulators/oberon.yml b/emulators/oberon.yml
index 20c000268..c8ca46f1c 100644
--- a/emulators/oberon.yml
+++ b/emulators/oberon.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/oberon-risc-emu"
upstream: "https://github.com/pdewacht/oberon-risc-emu"
profiled_date: "2026-03-24"
source_commit: "5be71d885b87b7cd7c635bf330764fcc4f3905fe"
-core_version: "2026-08-23"
+core_version: "v2016.1-56-g5be71d8"
+version_basis:
+ kind: tag
+ tag: "v2016.1"
+ distance: 56
display_name: "Oberon RISC Emulator"
cores:
- oberon
diff --git a/emulators/omicron.yml b/emulators/omicron.yml
index 356c33709..1f5e68fbf 100644
--- a/emulators/omicron.yml
+++ b/emulators/omicron.yml
@@ -8,6 +8,10 @@ profiled_date: "2026-09-17"
source_commit: "0181fbad079addb1fca895325c711f8f91397be4"
upstream_commit: "aaaa2f1a98b88a6b1e6102417317ced206887076"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "libretro/LibretroOmicronCore/omicron_libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Omicron"
cores:
- omicron
diff --git a/emulators/onscripter.yml b/emulators/onscripter.yml
index f024eb12d..937176f9d 100644
--- a/emulators/onscripter.yml
+++ b/emulators/onscripter.yml
@@ -5,7 +5,11 @@ source: "https://github.com/iyzsong/onscripter-libretro"
upstream: "https://github.com/ogapee/onscripter"
profiled_date: "2026-03-24"
source_commit: "ec4ff1842d498faaa844d570becf561d088deea0"
-core_version: "0.3"
+core_version: "v0.3"
+version_basis:
+ kind: tag
+ tag: "v0.3"
+ distance: 0
display_name: "ONScripter"
cores:
- onscripter
diff --git a/emulators/onsyuri.yml b/emulators/onsyuri.yml
index 773f044ac..f9cb829f1 100644
--- a/emulators/onsyuri.yml
+++ b/emulators/onsyuri.yml
@@ -5,7 +5,11 @@ source: "https://github.com/YuriSizuku/OnscripterYuri"
upstream: "https://github.com/YuriSizuku/OnscripterYuri"
profiled_date: "2026-03-24"
source_commit: "08f744b31cc1907b66a15f0402e62321a131ed81"
-core_version: "0.7.4+2"
+core_version: "v0.7.7beta"
+version_basis:
+ kind: tag
+ tag: "v0.7.7beta"
+ distance: 0
display_name: "ONScripter Yuri"
cores:
- onsyuri
diff --git a/emulators/openbor.yml b/emulators/openbor.yml
index 154161259..5cc4787a5 100644
--- a/emulators/openbor.yml
+++ b/emulators/openbor.yml
@@ -5,7 +5,12 @@ source: "https://github.com/DCurrent/openbor"
upstream: "https://github.com/DCurrent/openbor"
profiled_date: "2026-08-12"
source_commit: "65a7f0b41aee87fe4084e538634b4ed73236f871"
-core_version: "4.0 Build 7949"
+core_version: "4.0"
+version_basis:
+ kind: file
+ path: "engine/version.sh"
+ pattern: '^VERSION_MAJOR=(\d+)\s*\nVERSION_MINOR=(\d+)'
+ format: "{1}.{2}"
display_name: "OpenBOR Game Engine"
cores:
- openbor
diff --git a/emulators/opengoal.yml b/emulators/opengoal.yml
index a05787a7c..47eceb1ab 100644
--- a/emulators/opengoal.yml
+++ b/emulators/opengoal.yml
@@ -7,6 +7,10 @@ source_commit: "6445b4a50a7540df512e3c82c62216521421410c"
pin_frozen: "RetroBat, the only platform that ships it, installs this release: the gk.exe of its opengoal.7z package (10 October 2026) is byte identical to the one in opengoal-windows-v0.3.8.zip. Later releases are not part of that package."
profiled_date: "2026-10-20"
core_version: "v0.3.8"
+version_basis:
+ kind: tag
+ tag: "v0.3.8"
+ distance: 0
display_name: "Jak and Daxter (OpenGOAL)"
cores: [opengoal]
systems: [opengoal]
diff --git a/emulators/openjazz.yml b/emulators/openjazz.yml
index aa7e48aa3..67cab5426 100644
--- a/emulators/openjazz.yml
+++ b/emulators/openjazz.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/AlisterT/openjazz"
profiled_date: "2026-10-04"
source_commit: "a1626f4edd4a7af72c54103021790c56d8ecfced"
core_version: "20260301"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: '^project\(OpenJazz\s+VERSION\s+(\S+)'
display_name: "Jazz Jackrabbit (OpenJazz)"
cores: ["OpenJazz"]
systems: [jazz-jackrabbit]
diff --git a/emulators/openjk.yml b/emulators/openjk.yml
index c04ed1abe..0625ac61d 100644
--- a/emulators/openjk.yml
+++ b/emulators/openjk.yml
@@ -5,6 +5,12 @@ source: "https://github.com/JACoders/OpenJK"
upstream: "https://github.com/JACoders/OpenJK"
profiled_date: "2026-10-04"
source_commit: "260c59c2907187af555a676fe0cc798893bf7757"
+core_version: "v0.0.0-951-g260c59c"
+version_basis:
+ kind: tag
+ match: "v*"
+ tag: "v0.0.0"
+ distance: 951
display_name: "OpenJK"
cores: [openjk]
systems: [jediacademy, jediknight2]
diff --git a/emulators/openjkdf2.yml b/emulators/openjkdf2.yml
index a31e42d78..6b741fb4f 100644
--- a/emulators/openjkdf2.yml
+++ b/emulators/openjkdf2.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/shinyquagsire23/OpenJKDF2"
profiled_date: "2026-10-04"
source_commit: "798f930402525a23b94671d85b97b00390342fa0"
core_version: "0.9.9.0"
+version_basis:
+ kind: file
+ path: "cmake_modules/version.cmake"
+ pattern: 'set\(OPENJKDF2_PROJECT_VERSION\s+([0-9.]+)\)'
display_name: "OpenJKDF2"
cores: []
systems:
diff --git a/emulators/openlara.yml b/emulators/openlara.yml
index ca595a41e..9c24c8a52 100644
--- a/emulators/openlara.yml
+++ b/emulators/openlara.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-24"
source_commit: "e4ce52edec5a9a6ad22b69d08c687bf070e68876"
upstream_commit: "3b268ca4bf986c380f44a26ffd9218bf6d376249"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "src/platform/libretro/main.cpp"
+ pattern: 'library_version\s*=\s*"([^"]+)"'
display_name: "Tomb Raider (OpenLara)"
cores: [openlara]
systems: [tomb-raider]
diff --git a/emulators/openmohaa.yml b/emulators/openmohaa.yml
index cd6b87e27..9cf083712 100644
--- a/emulators/openmohaa.yml
+++ b/emulators/openmohaa.yml
@@ -6,6 +6,11 @@ upstream: "https://github.com/openmoh/openmohaa"
profiled_date: "2026-10-04"
source_commit: "a2f340195975f4f042e28a60b62561dd9a0b2700"
upstream_commit: "a2f340195975f4f042e28a60b62561dd9a0b2700"
+core_version: "0.83.0"
+version_basis:
+ kind: file
+ path: "cmake/identity.cmake"
+ pattern: 'set\(PROJECT_VERSION\s+([0-9][^)\s]*)\)'
display_name: "OpenMoHAA"
cores: []
systems: [mohaa, mohaa-spearhead, mohaa-breakthrough]
diff --git a/emulators/openmsx.yml b/emulators/openmsx.yml
index 26084c8e8..e8cb758fa 100644
--- a/emulators/openmsx.yml
+++ b/emulators/openmsx.yml
@@ -4,7 +4,11 @@ source: "https://github.com/openMSX/openMSX"
upstream: "https://github.com/openMSX/openMSX"
profiled_date: "2026-03-26"
source_commit: "a557de7b37bc48420b63ec10e7ede953babad4f9"
-core_version: "21.0"
+core_version: "RELEASE_22_0_RC2"
+version_basis:
+ kind: tag
+ tag: "RELEASE_22_0_RC2"
+ distance: 0
display_name: "MSX / ColecoVision / Spectravideo (openMSX)"
cores:
- openmsx
diff --git a/emulators/openttd.yml b/emulators/openttd.yml
index 6a1728cf2..e5b1b7dd2 100644
--- a/emulators/openttd.yml
+++ b/emulators/openttd.yml
@@ -5,6 +5,10 @@ source: "https://github.com/OpenTTD/OpenTTD"
profiled_date: "2026-10-20"
source_commit: "61342620bc4c0971b28b3578379b53d04d2f11f1"
core_version: "14.1"
+version_basis:
+ kind: tag
+ tag: "14.1"
+ distance: 0
display_name: "Transport Tycoon Deluxe (OpenTTD)"
mode: standalone
cores:
diff --git a/emulators/opentyrian.yml b/emulators/opentyrian.yml
index afbaf09fc..2790b9f8c 100644
--- a/emulators/opentyrian.yml
+++ b/emulators/opentyrian.yml
@@ -6,7 +6,9 @@ upstream: "https://github.com/opentyrian/opentyrian"
profiled_date: "2026-03-24"
source_commit: "3e9df7a6bc486190ad5085586d0c4e9cb1baf14c"
upstream_commit: "ac5d0e71f4245744350c6116ce24f10624da58f8"
-core_version: "1.0.0.6"
+core_version: null
+version_basis:
+ kind: none
display_name: "Tyrian (OpenTyrian)"
cores: [opentyrian, tyrian]
systems: [tyrian]
diff --git a/emulators/opera.yml b/emulators/opera.yml
index 97d570f8d..9da041b28 100644
--- a/emulators/opera.yml
+++ b/emulators/opera.yml
@@ -8,6 +8,10 @@ upstream: "https://sourceforge.net/projects/freedo/"
profiled_date: "2026-10-18"
source_commit: "370d9a25c030bb9de4aade371b31045691c40a95"
core_version: "1.0.0"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: 'info_->library_version\s+=\s+"([^"]+)'
display_name: "The 3DO Company - 3DO (Opera)"
systems:
- 3do
diff --git a/emulators/oricutron.yml b/emulators/oricutron.yml
index c2ed07844..28cf5119e 100644
--- a/emulators/oricutron.yml
+++ b/emulators/oricutron.yml
@@ -5,7 +5,12 @@ source: "https://github.com/pete-gordon/oricutron"
upstream: "https://www.petergordon.org.uk/oricutron/"
profiled_date: "2026-08-08"
source_commit: "002279fce9fa756d1d63cdc40ae97939eb7de7ed"
-core_version: "V1_2_14"
+core_version: "1.2.0"
+version_basis:
+ kind: file
+ path: "Makefile"
+ pattern: '^VERSION_MAJ\s*=\s*(\d+)\s*\nVERSION_MIN\s*=\s*(\d+)\s*\nVERSION_REV\s*=\s*(\d+)'
+ format: "{1}.{2}.{3}"
display_name: "Tangerine - Oric (Oricutron)"
cores:
- oricutron
diff --git a/emulators/osmose.yml b/emulators/osmose.yml
index bd1164734..b10fb7273 100644
--- a/emulators/osmose.yml
+++ b/emulators/osmose.yml
@@ -4,7 +4,11 @@ core_classification: community_fork
source: "https://github.com/RetroPie/osmose-rpi"
profiled_date: "2026-10-20"
source_commit: "7775add582ad2595e876ca38d4675510f2df866e"
-core_version: "0.8.1+rpi20130302"
+core_version: "0.8.1+rpi20121122"
+version_basis:
+ kind: file
+ path: "Version.h"
+ pattern: '#define\s+__OSMOSE_VERSION__\s+"OsmOse\s+([^"]+)"'
display_name: "Sega - Master System / Game Gear (Osmose)"
mode: standalone
cores:
diff --git a/emulators/panda3ds.yml b/emulators/panda3ds.yml
index 199756601..27295411c 100644
--- a/emulators/panda3ds.yml
+++ b/emulators/panda3ds.yml
@@ -6,7 +6,11 @@ source: "https://github.com/panda3ds-emu/panda3ds"
upstream: "https://github.com/panda3ds-emu/panda3ds"
profiled_date: "2026-03-24"
source_commit: "3787358bdae41d5532ccd00c8998a527ae949949"
-core_version: "Git"
+core_version: "v0.8-615-g3787358"
+version_basis:
+ kind: tag
+ tag: "v0.8"
+ distance: 615
display_name: "Nintendo - 3DS (Panda3DS)"
library_name: "Panda3DS"
cores: [panda3ds]
diff --git a/emulators/parallel-launcher.yml b/emulators/parallel-launcher.yml
index 253a54e30..1f1222cb6 100644
--- a/emulators/parallel-launcher.yml
+++ b/emulators/parallel-launcher.yml
@@ -6,7 +6,12 @@ upstream: "https://gitlab.com/parallel-launcher/parallel-n64"
profiled_date: "2026-08-08"
source_commit: "8dc2a83770c57bc00e1b1fae0aed71f2120cc875"
upstream_commit: "a9028b23bd5ee8b465eefc64320fd7701f3430fa"
-core_version: "v9.0-6"
+core_version: "9.0.6"
+version_basis:
+ kind: file
+ path: "src/core/version.cpp"
+ pattern: 'CurrentVersion::Application\s*=\s*\{\s*(\d+),\s*(\d+),\s*(\d+)\s*\}'
+ format: "{1}.{2}.{3}"
display_name: "Nintendo - Nintendo 64 (Parallel Launcher)"
cores:
- parallel-launcher
diff --git a/emulators/parallel_n64.yml b/emulators/parallel_n64.yml
index 87e3e71da..d103fdc28 100644
--- a/emulators/parallel_n64.yml
+++ b/emulators/parallel_n64.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/mupen64plus/mupen64plus-core"
profiled_date: "2026-03-24"
source_commit: "f87550d9ad70cb320870e2b5fd960235287abb89"
upstream_commit: "3be6336a1de6f32ccc4f57bf80c988163fe6bf7a"
-core_version: "2.0-rc2"
+core_version: "1.0"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: 'library_version\s*=\s*"([^"]+)"'
display_name: "Nintendo - Nintendo 64 (ParaLLEl N64)"
systems: [nintendo-64, nintendo-64dd]
cores: [parallel_n64, parallel_n64_debug, "parallel_n64_32b", "parallel-n64"]
diff --git a/emulators/parallel_n64_debug.yml b/emulators/parallel_n64_debug.yml
index d66ab6b2a..29fc75b63 100644
--- a/emulators/parallel_n64_debug.yml
+++ b/emulators/parallel_n64_debug.yml
@@ -2,7 +2,10 @@ emulator: "parallel_n64_debug"
type: alias
alias_of: "parallel_n64"
profiled_date: "2026-03-24"
-core_version: "2.0-rc2"
+core_version: "1.0"
+version_basis:
+ kind: alias
+ of: "parallel_n64"
display_name: "Nintendo - Nintendo 64 (ParaLLEl) (Debug)"
cores:
- parallel_n64_debug
diff --git a/emulators/pascal_pong.yml b/emulators/pascal_pong.yml
index fa4865d37..de5fe2edf 100644
--- a/emulators/pascal_pong.yml
+++ b/emulators/pascal_pong.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/pascal-pong-libretro"
upstream: "https://github.com/libretro/pascal-pong-libretro"
profiled_date: "2026-03-24"
source_commit: "2a0cfd44652c0b215862de3ef56916d1384ad8a0"
-core_version: "v1.0"
+core_version: "1.0"
+version_basis:
+ kind: file
+ path: "pong.pas"
+ pattern: 'LibraryVersion\s*:=\s*''([^'']+)'''
display_name: "PascalPong"
cores: [pascal_pong]
systems: []
diff --git a/emulators/pce-emu.yml b/emulators/pce-emu.yml
index cfe320bfe..08eadaab3 100644
--- a/emulators/pce-emu.yml
+++ b/emulators/pce-emu.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-08-12"
source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943"
core_version: "1.5.85"
+version_basis:
+ kind: file
+ path: "EmuFramework/metadata/conf.mk"
+ pattern: '^metadata_version\s*=\s*(\S+)'
display_name: "PCE.emu"
cores: ["pce-emu", "PCE.emu", "pceemu", "PCE-EMU", "com.PceEmu"]
systems: [nec-pc-engine, nec-pc-engine-cd, nec-supergrafx]
diff --git a/emulators/pcee2.yml b/emulators/pcee2.yml
index 8e80dc812..11dea57cf 100644
--- a/emulators/pcee2.yml
+++ b/emulators/pcee2.yml
@@ -8,6 +8,11 @@ profiled_date: "2026-09-03"
source_commit: "f52b5a6fe43278dcfc827b85b09fced5a82f863f"
upstream_commit: "ab0b401f99729ef8da3009f9af28d8ee96e0e7ba"
core_version: "v2.9.138"
+version_basis:
+ kind: tag
+ repo: upstream
+ tag: "v2.9.138"
+ distance: 0
display_name: "Sony - PlayStation 2 (PCEE2)"
cores: [pcee2]
systems: [sony-playstation-2]
diff --git a/emulators/pcem.yml b/emulators/pcem.yml
index 98239f60b..a77666867 100644
--- a/emulators/pcem.yml
+++ b/emulators/pcem.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/sarah-walker-pcem/pcem"
profiled_date: "2026-03-24"
source_commit: "e07923e902dcedcf65a502fb3f14e75b9ded5271"
upstream_commit: "25b54e3ff1d51cbf2469b447e55bce7a589697e9"
-core_version: "v11 (PCem-mooch, based on PCem v10.1)"
+core_version: "v10.1"
+version_basis:
+ kind: file
+ path: "configure.ac"
+ pattern: '^AC_INIT\(PCem,\s*([^,\s]+),'
display_name: "PC (PCem)"
cores: [pcem]
systems:
diff --git a/emulators/pcsx1.yml b/emulators/pcsx1.yml
index 288f75c39..4cfba40bf 100644
--- a/emulators/pcsx1.yml
+++ b/emulators/pcsx1.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-24"
source_commit: "35dabb85ff3af6825820f54090d344945f65d24e"
upstream_commit: "8b6c163575e6ce9a0537553e0d4ed4225315955e"
core_version: "r22"
+version_basis:
+ kind: file
+ path: "frontend/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Sony - PlayStation (PCSX1)"
cores: [pcsx1]
systems: [sony-playstation]
diff --git a/emulators/pcsx2-legacy.yml b/emulators/pcsx2-legacy.yml
index 737600eae..74ec65c37 100644
--- a/emulators/pcsx2-legacy.yml
+++ b/emulators/pcsx2-legacy.yml
@@ -6,7 +6,11 @@ source: "https://github.com/PCSX2/pcsx2"
upstream: "https://github.com/PCSX2/pcsx2"
profiled_date: "2026-08-08"
source_commit: "aaaf29557fe63870ede815dd6d2e48a98279ca59"
-core_version: "1.6.0"
+core_version: "v1.6.0"
+version_basis:
+ kind: tag
+ tag: "v1.6.0"
+ distance: 0
display_name: "Sony - PlayStation 2 (PCSX2 Legacy)"
cores:
- pcsx2-legacy
diff --git a/emulators/pcsx2.yml b/emulators/pcsx2.yml
index d428e1188..693e6f9f0 100644
--- a/emulators/pcsx2.yml
+++ b/emulators/pcsx2.yml
@@ -8,7 +8,11 @@ cores:
- pcsx2
profiled_date: "2026-03-30"
source_commit: "f0523f8acb1d3a765ee73b74cffec63093569199"
-core_version: "Git"
+core_version: "v2.9.146-1-gf0523f8"
+version_basis:
+ kind: tag
+ tag: "v2.9.146"
+ distance: 1
display_name: "Sony - PlayStation 2 (PCSX2)"
systems: [sony-playstation-2]
diff --git a/emulators/pcsx2x6.yml b/emulators/pcsx2x6.yml
index 616929928..aeb3ea2cb 100644
--- a/emulators/pcsx2x6.yml
+++ b/emulators/pcsx2x6.yml
@@ -8,7 +8,11 @@ profiled_date: "2026-08-08"
source_commit: "583f0e90907d69bb2db670f570d0865260fcc71b"
upstream_commit: "3df128d9f9cd89cfc47d08d94b3439136ba22b33"
prose_written_at: "583f0e90907d69bb2db670f570d0865260fcc71b"
-core_version: "v0.2.22"
+core_version: "v0.2.22-2-g583f0e9"
+version_basis:
+ kind: tag
+ tag: "v0.2.22"
+ distance: 2
display_name: "Namco - System 246 / 256 (PCSX2x6)"
cores:
- pcsx2x6
diff --git a/emulators/pcsx_rearmed.yml b/emulators/pcsx_rearmed.yml
index f15036ed3..92fd9f7ef 100644
--- a/emulators/pcsx_rearmed.yml
+++ b/emulators/pcsx_rearmed.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/notaz/pcsx_rearmed"
profiled_date: "2026-03-25"
source_commit: "c8816799b50388e61cfe237fe2cdbb7d8175f20a"
upstream_commit: "8b6c163575e6ce9a0537553e0d4ed4225315955e"
-core_version: "r25"
+core_version: "r26"
+version_basis:
+ kind: file
+ path: "frontend/libretro.c"
+ pattern: 'library_version\s*=\s*"([^"]+)"'
display_name: "Sony - PlayStation (PCSX ReARMed)"
cores: [pcsx_rearmed, pcsx_rearmed_neon, pcsx_rearmed_interpreter, "pcsx_rearmed_32b"]
systems: [sony-playstation]
diff --git a/emulators/pcsx_rearmed_standalone.yml b/emulators/pcsx_rearmed_standalone.yml
index 8924ddcfb..fc12264fc 100644
--- a/emulators/pcsx_rearmed_standalone.yml
+++ b/emulators/pcsx_rearmed_standalone.yml
@@ -5,6 +5,10 @@ source: "https://github.com/notaz/pcsx_rearmed"
profiled_date: "2026-10-20"
source_commit: "8b6c163575e6ce9a0537553e0d4ed4225315955e"
core_version: "r26"
+version_basis:
+ kind: file
+ path: "frontend/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Sony - PlayStation (PCSX-ReARMed standalone)"
mode: standalone
cores:
diff --git a/emulators/pd777.yml b/emulators/pd777.yml
index f71a33f65..0944cc773 100644
--- a/emulators/pd777.yml
+++ b/emulators/pd777.yml
@@ -5,7 +5,11 @@ source: "https://github.com/mittonk/PD777"
upstream: "https://github.com/W88DodPECuThLOl/PD777"
profiled_date: "2026-03-24"
source_commit: "331af5f53cc0ffbab90f915681c8dfe3a4cda0a2"
-core_version: "v1.0.0"
+core_version: "1.01.01"
+version_basis:
+ kind: file
+ path: "source/libretro/libretro.cpp"
+ pattern: 'library_version\s*=\s*"([^"]+)"'
display_name: "Epoch - Cassette Vision (PD777)"
cores:
- pd777
diff --git a/emulators/pdark.yml b/emulators/pdark.yml
index c7ac7731b..863e27f03 100644
--- a/emulators/pdark.yml
+++ b/emulators/pdark.yml
@@ -6,7 +6,9 @@ upstream: "https://github.com/perfect-dark-pc-port/perfect_dark"
source_branch: port
source_commit: "32a1cb9f268dd3ac73016801025c6bbbfa20130f"
profiled_date: "2026-10-20"
-core_version: "port 2025-06-14"
+core_version: null
+version_basis:
+ kind: none
display_name: "Perfect Dark (PC port)"
cores: [pdark]
systems: [pdark]
diff --git a/emulators/phoenix.yml b/emulators/phoenix.yml
index 0239cb85c..e56e2dd41 100644
--- a/emulators/phoenix.yml
+++ b/emulators/phoenix.yml
@@ -5,6 +5,10 @@ source: "http://www.arts-union.ru"
upstream: "http://www.arts-union.ru"
profiled_date: "2026-10-20"
core_version: "2.8.JAG"
+version_basis:
+ kind: package
+ url: "https://www.retrobat.ovh/repo/win64/stable/emulators/phoenix.7z"
+ artifact: "phoenix.7z, PhoenixEmuProject.exe version string"
display_name: "3DO, Atari Jaguar (Phoenix)"
mode: standalone
cores:
diff --git a/emulators/picodrive.yml b/emulators/picodrive.yml
index 71ec0610f..027f87c02 100644
--- a/emulators/picodrive.yml
+++ b/emulators/picodrive.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/irixxxx/picodrive"
profiled_date: "2026-03-24"
source_commit: "1890c2932234c9d30f4cd3851d02228baae8f09e"
upstream_commit: "5f9e79f2177443939f21ab965b1c294edbef514c"
-core_version: "1.99"
+core_version: "2.05"
+version_basis:
+ kind: file
+ path: "platform/common/version.h"
+ pattern: '#define\s+VERSION\s+"([^"]+)"'
display_name: "Sega - MS/GG/MD/CD/32X (PicoDrive)"
cores: [picodrive]
systems:
diff --git a/emulators/piece.yml b/emulators/piece.yml
index 4bdeacfdd..84b8feb5e 100644
--- a/emulators/piece.yml
+++ b/emulators/piece.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/autch/piece-emu"
profiled_date: "2026-09-17"
source_commit: "08ec4b45176e8df0d6d6f67e56b87e847cd64a65"
core_version: "0.1"
+version_basis:
+ kind: file
+ path: "src/libretro/piece_libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Aquaplus - P/ECE (piece-emu)"
cores:
- piece
diff --git a/emulators/piemu.yml b/emulators/piemu.yml
index f5bb484a5..f0b5bdef3 100644
--- a/emulators/piemu.yml
+++ b/emulators/piemu.yml
@@ -7,7 +7,11 @@ profiled_date: "2026-10-22"
source_commit: "0785214f13746bdfd709b1909f007e213b470ba1"
upstream_commit: "23208626e16e53b73fd21a2fbd0c429338625dc4"
pin_frozen: "AmberELEC, the only platform that ships it, builds this revision (AmberELEC packages/games/emulators/piemu/package.mk:5). Later commits are not part of that build."
-core_version: "git"
+core_version: "20030430"
+version_basis:
+ kind: file
+ path: "app.h"
+ pattern: '#define\s+VERSION\s+"([^"]+)"'
display_name: "Aquaplus - P/ECE (P/EMU/SDL)"
cores:
- "piemu"
diff --git a/emulators/pisnes.yml b/emulators/pisnes.yml
index 68a7bd6fd..69f70ecd4 100644
--- a/emulators/pisnes.yml
+++ b/emulators/pisnes.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/pisnes/"
profiled_date: "2026-10-20"
source_commit: "a5b427f255f922cfa65e29c4285b3d565a4c9044"
core_version: "1.39"
+version_basis:
+ kind: file
+ path: "snes9x.h"
+ pattern: '#define\s+VERSION\s+"([^"]+)"'
display_name: "Nintendo - SNES (PiSNES)"
mode: standalone
cores:
diff --git a/emulators/pizza-boy-gba.yml b/emulators/pizza-boy-gba.yml
index daf89b967..5c61dd508 100644
--- a/emulators/pizza-boy-gba.yml
+++ b/emulators/pizza-boy-gba.yml
@@ -10,6 +10,10 @@ version_source:
author: "Pizza Emulators"
profiled_date: "2026-10-24"
core_version: "2.8.13"
+version_basis:
+ kind: package
+ url: "https://play.google.com/store/apps/details?id=it.dbtecno.pizzaboygbapro"
+ artifact: "it.dbtecno.pizzaboygbapro APK versionName (versionCode 343)"
display_name: "Nintendo - Game Boy Advance (Pizza Boy GBA)"
mode: standalone
cores:
diff --git a/emulators/pizza-boy-gbc.yml b/emulators/pizza-boy-gbc.yml
index 566895fda..5c74ba3e5 100644
--- a/emulators/pizza-boy-gbc.yml
+++ b/emulators/pizza-boy-gbc.yml
@@ -10,6 +10,10 @@ version_source:
author: "Pizza Emulators"
profiled_date: "2026-10-24"
core_version: "6.0.6"
+version_basis:
+ kind: package
+ url: "https://play.google.com/store/apps/details?id=it.dbtecno.pizzaboypro"
+ artifact: "it.dbtecno.pizzaboypro APK versionName (versionCode 231)"
display_name: "Nintendo - Game Boy / Color (Pizza Boy GBC)"
mode: standalone
cores:
diff --git a/emulators/pizza-boy-sc.yml b/emulators/pizza-boy-sc.yml
index 2658f40ee..b1c7d787b 100644
--- a/emulators/pizza-boy-sc.yml
+++ b/emulators/pizza-boy-sc.yml
@@ -10,6 +10,10 @@ version_source:
author: "Pizza Emulators"
profiled_date: "2026-10-20"
core_version: "1.2.14"
+version_basis:
+ kind: package
+ url: "https://play.google.com/store/apps/details?id=it.dbtecno.pizzaboyscbasic"
+ artifact: "it.dbtecno.pizzaboyscbasic XAPK versionName (versionCode 43)"
display_name: "Sega - MS/GG/MD/CD (Pizza Boy SC)"
mode: standalone
cores:
diff --git a/emulators/plastic.yml b/emulators/plastic.yml
index ba45df4ee..86256d9a9 100644
--- a/emulators/plastic.yml
+++ b/emulators/plastic.yml
@@ -5,7 +5,11 @@ source: "https://github.com/Amjad50/plastic"
upstream: "https://github.com/Amjad50/plastic"
profiled_date: "2026-08-12"
source_commit: "74152adb3acd890d3abee2ff5906408f26a199e2"
-core_version: "0.3.5"
+core_version: "v0.3.5"
+version_basis:
+ kind: tag
+ tag: "v0.3.5"
+ distance: 0
display_name: "Nintendo - NES / Famicom (Plastic)"
cores:
- plastic
diff --git a/emulators/play.yml b/emulators/play.yml
index b58a35f05..a887597e9 100644
--- a/emulators/play.yml
+++ b/emulators/play.yml
@@ -5,7 +5,11 @@ source: "https://github.com/jpd002/Play-"
upstream: "https://github.com/jpd002/Play-"
profiled_date: "2026-03-24"
source_commit: "1f27232772e484bfb79364b751b84f41f4c87481"
-core_version: "Git"
+core_version: "0.77-17-g1f27232"
+version_basis:
+ kind: tag
+ tag: "0.77"
+ distance: 17
display_name: "Sony - PlayStation 2 (Play!)"
cores:
- play
diff --git a/emulators/playdiaemu.yml b/emulators/playdiaemu.yml
index f2fd55d5a..bb7362410 100644
--- a/emulators/playdiaemu.yml
+++ b/emulators/playdiaemu.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/AloysHF/PlaydiaEmu"
profiled_date: "2026-10-23"
source_commit: "b1b990e7f28d6baa58d5fcde9faa155990ee8f3f"
core_version: "0.1.0"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "Bandai - Playdia (PlaydiaEmu)"
cores:
- playdiaemu
diff --git a/emulators/pocketcdg.yml b/emulators/pocketcdg.yml
index 8d776c940..4fb11a200 100644
--- a/emulators/pocketcdg.yml
+++ b/emulators/pocketcdg.yml
@@ -5,7 +5,9 @@ source: "https://github.com/libretro/libretro-pocketcdg"
upstream: "https://github.com/libretro/libretro-pocketcdg"
profiled_date: "2026-03-24"
source_commit: "c6fc08d942753342686cb9494e1d285a14c1893c"
-core_version: "Git"
+core_version: null
+version_basis:
+ kind: none
display_name: "PocketCDG"
cores: [pocketcdg]
systems: []
diff --git a/emulators/pokemini.yml b/emulators/pokemini.yml
index 2d4aa7f5c..aad109124 100644
--- a/emulators/pokemini.yml
+++ b/emulators/pokemini.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/pokemini/"
profiled_date: "2026-03-24"
source_commit: "132111b76343559860532a1ccc094f93f1ed5650"
core_version: "v0.60"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Nintendo - Pokemon Mini (PokeMini)"
cores:
- pokemini
diff --git a/emulators/pokketstation.yml b/emulators/pokketstation.yml
index 9a19ee95e..2560c0ff4 100644
--- a/emulators/pokketstation.yml
+++ b/emulators/pokketstation.yml
@@ -5,7 +5,11 @@ source: "https://github.com/mentalfoundry/Pokket"
upstream: "https://github.com/mentalfoundry/Pokket"
profiled_date: "2026-10-17"
source_commit: "37c022ce46eb578de5b57d7b488ffda9e3b7d9f1"
-core_version: "v1.23"
+core_version: "1.23"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'project\(pokket\s+VERSION\s+([0-9][\w.+-]*)'
display_name: "Sony - PocketStation (pokketstation)"
cores: ["pokketstation", "pokket"]
systems: [sony-pocketstation]
diff --git a/emulators/pom2.yml b/emulators/pom2.yml
index 6c4306833..fa75430d7 100644
--- a/emulators/pom2.yml
+++ b/emulators/pom2.yml
@@ -5,7 +5,11 @@ source: "https://github.com/habib256/pom2"
upstream: "https://github.com/habib256/pom2"
profiled_date: "2026-10-23"
source_commit: "dbf7673d0ca938884419335f3d88e9029c15494f"
-core_version: "v0.9.9"
+core_version: "0.9.9"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'project\(pom2_imgui\s+VERSION\s+([0-9][\w.+-]*)'
display_name: "Apple - Apple II (POM2)"
cores:
- pom2
diff --git a/emulators/potator.yml b/emulators/potator.yml
index f747a2b56..ed0b024e1 100644
--- a/emulators/potator.yml
+++ b/emulators/potator.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/alekmaul/potator"
profiled_date: "2026-03-24"
source_commit: "227c5f6f3ce74d32e9002ce24c1420288559a860"
core_version: "1.0.5"
+version_basis:
+ kind: file
+ path: "platform/libretro/libretro.c"
+ pattern: 'info->library_version\s*=\s*"([^" ]+)'
display_name: "Watara - Supervision (Potator)"
cores:
- potator
diff --git a/emulators/ppsspp-1.5.4.yml b/emulators/ppsspp-1.5.4.yml
index 206016ae3..1ba7ad930 100644
--- a/emulators/ppsspp-1.5.4.yml
+++ b/emulators/ppsspp-1.5.4.yml
@@ -6,6 +6,10 @@ profiled_date: "2026-10-20"
source_commit: "a1e74d0d4f89ba3fa2d4fe64bac7a0fa16fc146f"
pin_frozen: "RetroPie, the only platform that ships it, builds the v1.5.4 tag at this commit (RetroPie scriptmodules/emulators/ppsspp-1.5.4.sh:16). Later releases are packaged separately as ppsspp."
core_version: "v1.5.4"
+version_basis:
+ kind: tag
+ tag: "v1.5.4"
+ distance: 0
display_name: "Sony - PlayStation Portable (PPSSPP 1.5.4)"
mode: standalone
cores:
diff --git a/emulators/ppsspp.yml b/emulators/ppsspp.yml
index d2e07ba3b..4ee72cf14 100644
--- a/emulators/ppsspp.yml
+++ b/emulators/ppsspp.yml
@@ -4,7 +4,11 @@ core_classification: official_port
source: "https://github.com/hrydgard/ppsspp"
profiled_date: "2026-10-21"
source_commit: "43a69b1c84dfe29e82b46bd3797a1106cbd65c5b"
-core_version: "Git"
+core_version: "v1.20.4-3958-g43a69b1"
+version_basis:
+ kind: tag
+ tag: "v1.20.4"
+ distance: 3958
display_name: "Sony - PlayStation Portable (PPSSPP)"
cores:
- ppsspp
diff --git a/emulators/prboom-plus.yml b/emulators/prboom-plus.yml
index 528debff2..912ff69bd 100644
--- a/emulators/prboom-plus.yml
+++ b/emulators/prboom-plus.yml
@@ -5,7 +5,11 @@ source: "https://github.com/coelckers/prboom-plus"
upstream: "https://github.com/coelckers/prboom-plus"
profiled_date: "2026-08-08"
source_commit: "969515162c5aebea4ad7a125ee178dcad3d576ad"
-core_version: "2.6.66"
+core_version: "v2.6.66"
+version_basis:
+ kind: tag
+ tag: "v2.6.66"
+ distance: 0
display_name: "Doom (PrBoom+)"
cores:
- prboom-plus
diff --git a/emulators/prboom.yml b/emulators/prboom.yml
index 15a47166d..c872f169f 100644
--- a/emulators/prboom.yml
+++ b/emulators/prboom.yml
@@ -6,6 +6,10 @@ upstream: "http://prboom.sourceforge.net/"
profiled_date: "2026-10-23"
source_commit: "4849268e7342494193f2bbf2d0128a751cf58da7"
core_version: "v2.5.0"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Doom (PrBoom)"
cores: [prboom]
systems: [doom, heretic]
diff --git a/emulators/primehack.yml b/emulators/primehack.yml
index acf4a39bf..4003bc560 100644
--- a/emulators/primehack.yml
+++ b/emulators/primehack.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-26"
source_commit: "53f53e0f5bad27ad62a807cb93b136d84f68777f"
upstream_commit: "d8558142ead21b3fa39809c4aedb982fbfc85feb"
core_version: "1.0.9"
+version_basis:
+ kind: tag
+ tag: "1.0.9"
+ distance: 0
display_name: "PrimeHack (Dolphin fork for Metroid Prime)"
cores:
- primehack
diff --git a/emulators/project64.yml b/emulators/project64.yml
index dbcd0982d..ce6f6907e 100644
--- a/emulators/project64.yml
+++ b/emulators/project64.yml
@@ -6,6 +6,11 @@ upstream: "https://github.com/project64/project64"
profiled_date: "2026-08-12"
source_commit: "24376e868350792a181d2121fec20d37354559d6"
core_version: "Dev-4.0.0"
+version_basis:
+ kind: file
+ path: "Source/Project64-core/Version.h.in"
+ pattern: '#define VERSION_MAJOR\s+(\d+)\s*\n#define VERSION_MINOR\s+(\d+)\s*\n#define VERSION_REVISION\s+(\d+)[\s\S]*?#define VERSION_PREFIX\s+"([^"]*)"'
+ format: "{4}{1}.{2}.{3}"
display_name: "Nintendo - Nintendo 64 (Project64)"
cores:
- project64
diff --git a/emulators/prosystem.yml b/emulators/prosystem.yml
index 20095165a..316f3834d 100644
--- a/emulators/prosystem.yml
+++ b/emulators/prosystem.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/gstanton/ProSystem1_3"
profiled_date: "2026-03-24"
source_commit: "8a88014287c7a01cd568067e5a557d0a2b2a051f"
core_version: "1.3e"
+version_basis:
+ kind: file
+ path: "core/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Atari - 7800 (ProSystem)"
cores:
- prosystem
diff --git a/emulators/psxmame.yml b/emulators/psxmame.yml
index dcc296205..31a2bb042 100644
--- a/emulators/psxmame.yml
+++ b/emulators/psxmame.yml
@@ -5,7 +5,11 @@ source: "https://github.com/mamedev/mame"
upstream: "https://github.com/mamedev/mame"
profiled_date: "2026-10-20"
source_commit: "0fdce58484202265b0794a77652bb5e3da9c56af"
-core_version: "0.133"
+core_version: "mame0133"
+version_basis:
+ kind: tag
+ tag: "mame0133"
+ distance: 0
display_name: "Arcade PSX boards (pSxMAME)"
mode: standalone
cores:
diff --git a/emulators/puae.yml b/emulators/puae.yml
index 374b4d569..f58d30b8c 100644
--- a/emulators/puae.yml
+++ b/emulators/puae.yml
@@ -7,6 +7,10 @@ upstream: "https://github.com/GnoStiC/PUAE"
profiled_date: "2026-03-24"
source_commit: "7387937066b1b693816c3b4e2b7a0c601d41363d"
core_version: "5.3.1"
+version_basis:
+ kind: file
+ path: "retrodep/sysconfig.h"
+ pattern: '^#define VERSION "([^"]+)"'
display_name: "Commodore - Amiga (PUAE)"
systems:
- commodore-amiga
diff --git a/emulators/puae2021.yml b/emulators/puae2021.yml
index 8bca713fd..13490fe13 100644
--- a/emulators/puae2021.yml
+++ b/emulators/puae2021.yml
@@ -7,6 +7,10 @@ upstream: "https://github.com/GnoStiC/PUAE"
profiled_date: "2026-03-24"
source_commit: "151828f14d43bb2b0651327ee19a23b36a7081b2"
core_version: "2.6.1"
+version_basis:
+ kind: file
+ path: "retrodep/sysconfig.h"
+ pattern: '^#define PACKAGE_VERSION "([^"]+)"'
display_name: "Commodore - Amiga (PUAE 2021)"
systems:
- commodore-amiga
diff --git a/emulators/punes.yml b/emulators/punes.yml
index b8c9e6732..dbad94b43 100644
--- a/emulators/punes.yml
+++ b/emulators/punes.yml
@@ -6,6 +6,11 @@ upstream: "https://github.com/punesemu/puNES"
profiled_date: "2026-08-08"
source_commit: "b1758ec26a3d6da10abf50dc184fa837236c5f61"
core_version: "0.112"
+version_basis:
+ kind: file
+ path: "src/core/version.h"
+ pattern: '#define VER1\s+"(\d+)"[\s\S]*?#define VER2\s+"(\d+)"'
+ format: "{1}.{2}"
display_name: "Nintendo - NES / Famicom (puNES)"
cores:
- punes
diff --git a/emulators/puzzlescript.yml b/emulators/puzzlescript.yml
index 33d4fae70..e8c3551a9 100644
--- a/emulators/puzzlescript.yml
+++ b/emulators/puzzlescript.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/increpare/PuzzleScript"
profiled_date: "2026-03-25"
source_commit: "6d859b47092f585a7ec05804c1d51a1676a06531"
core_version: "0.2.1"
+version_basis:
+ kind: file
+ path: "src/core.cpp"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "PuzzleScript (pzretro)"
cores:
- puzzlescript
diff --git a/emulators/px68k-sdl.yml b/emulators/px68k-sdl.yml
index 87dbbf89c..36c3714bf 100644
--- a/emulators/px68k-sdl.yml
+++ b/emulators/px68k-sdl.yml
@@ -5,6 +5,10 @@ source: "https://github.com/TurtleBazooka/px68k"
profiled_date: "2026-10-23"
source_commit: "bdc3d87efb9f09df88c5c6eb271f9535aaf13210"
core_version: "0.15+K"
+version_basis:
+ kind: file
+ path: "version.txt"
+ pattern: '^PX68K_VERSION=(\S+)'
display_name: "Sharp - X68000 (px68k SDL)"
mode: standalone
cores: ["px68k"]
diff --git a/emulators/px68k.yml b/emulators/px68k.yml
index c42869ed6..479d774b6 100644
--- a/emulators/px68k.yml
+++ b/emulators/px68k.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/hissorii/px68k"
profiled_date: "2026-03-25"
source_commit: "5510637a4c0a08ac0302087ea498093dcc3e9d17"
core_version: "0.15+"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: '#define\s+PX68K_VERSION\s+"([^"]+)'
display_name: "Sharp - X68000 (PX68k)"
cores:
- px68k
diff --git a/emulators/pygame.yml b/emulators/pygame.yml
index 37bf7d8ef..bde9bb6ec 100644
--- a/emulators/pygame.yml
+++ b/emulators/pygame.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/pygame/pygame"
profiled_date: "2026-10-04"
source_commit: "b92c0da9d1b5eba6c813138790fc1ab6cea0252c"
core_version: "2.6.1"
+version_basis:
+ kind: tag
+ tag: "2.6.1"
+ distance: 0
display_name: "Pygame"
cores: [pygame, python-pygame2]
systems: []
diff --git a/emulators/pyxel.yml b/emulators/pyxel.yml
index 6e2206488..e15e203a7 100644
--- a/emulators/pyxel.yml
+++ b/emulators/pyxel.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/kitao/pyxel"
profiled_date: "2026-10-04"
source_commit: "5d8e7b4f37c106684c1e91692772569a32a5e3d4"
core_version: "v2.9.9"
+version_basis:
+ kind: tag
+ tag: "v2.9.9"
+ distance: 0
display_name: "Pyxel"
cores: [pyxel]
systems: []
diff --git a/emulators/qemu.yml b/emulators/qemu.yml
index 00edc9904..b8bce138b 100644
--- a/emulators/qemu.yml
+++ b/emulators/qemu.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "aaba4471880587540314f82419d920431d55b769"
upstream_commit: "d73149283205645625bfa9fe029cfb68e4c4a9ae"
core_version: "0.1.0"
+version_basis:
+ kind: file
+ path: "ui/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "QEMU"
cores: [qemu]
systems: [ibm-pc, x86, x86-64, ppc, ppc64, sparc, sparc64, arm, aarch64, mips, mips64, riscv32, riscv64, s390x, m68k, alpha, hppa]
diff --git a/emulators/quake3.yml b/emulators/quake3.yml
index cb30d9912..496b3fdfc 100644
--- a/emulators/quake3.yml
+++ b/emulators/quake3.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/ioquake/ioq3"
profiled_date: "2026-10-20"
source_commit: "8d89a2a3c1707bf0f75b2ea26645b872e97c0b95"
core_version: "1.35"
+version_basis:
+ kind: file
+ path: "Makefile"
+ pattern: 'VERSION=([0-9][\w.+-]*)'
display_name: "Quake III Arena (Raspberry Pi port)"
cores: [quake3]
systems: [quake3]
diff --git a/emulators/quasi88.yml b/emulators/quasi88.yml
index 649f801e6..c5a15fcd1 100644
--- a/emulators/quasi88.yml
+++ b/emulators/quasi88.yml
@@ -6,6 +6,10 @@ upstream: "https://www.retropc.net/showzoh/quasi88/"
profiled_date: "2026-03-29"
source_commit: "7884be5277776a5d8c4fdc4e574e9b5b589945c9"
core_version: "0.6.4"
+version_basis:
+ kind: file
+ path: "src/version.h"
+ pattern: '#define\s+Q_VERSION\s+"([^"]+)'
display_name: "NEC - PC-8000 / PC-8800 series (QUASI88)"
cores:
- quasi88
diff --git a/emulators/quasi88kai.yml b/emulators/quasi88kai.yml
index 714e22777..5f149dca2 100644
--- a/emulators/quasi88kai.yml
+++ b/emulators/quasi88kai.yml
@@ -5,6 +5,10 @@ source: "https://github.com/winterheart/quasi88"
profiled_date: "2026-10-20"
source_commit: "0d4e171415cad7ac19ae4ff0dcf8cdcfd825947b"
core_version: "0.7.1"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: '^project\(quasi88 VERSION ([0-9.]+)\)'
display_name: "NEC - PC-8800 series (QUASI88kai)"
mode: standalone
cores: ["quasi88"]
diff --git a/emulators/quicknes.yml b/emulators/quicknes.yml
index fa54b3dc5..cc4d670e4 100644
--- a/emulators/quicknes.yml
+++ b/emulators/quicknes.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/kode54/QuickNES"
profiled_date: "2026-03-25"
source_commit: "26bb785c9deddb66a17717b21bb4e328f03ade32"
core_version: "1.0-WIP"
+version_basis:
+ kind: file
+ path: "libretro/libretro.cpp"
+ pattern: '#define\s+CORE_VERSION\s+"([^"]+)'
display_name: "Nintendo - NES / Famicom (QuickNES)"
cores:
- quicknes
diff --git a/emulators/race.yml b/emulators/race.yml
index 8dc142550..c2f9cffff 100644
--- a/emulators/race.yml
+++ b/emulators/race.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-09-17"
source_commit: "c7810dd7f172827bfa2004813bc000b13786636b"
upstream_commit: "065db2eb614d4414ed49a1a9e241022450bb844d"
core_version: "v2.16"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: '#define\s+RACE_VERSION\s+"([^"]+)'
display_name: "SNK - Neo Geo Pocket / Color (RACE)"
cores: [race]
systems: [snk-ngp, snk-ngpc]
diff --git a/emulators/radio.yml b/emulators/radio.yml
index 71fd56f7b..f677ed092 100644
--- a/emulators/radio.yml
+++ b/emulators/radio.yml
@@ -8,6 +8,10 @@ profiled_date: "2026-09-17"
source_commit: "5d7d786ad1d8703a52184ff2ee39712c46ed67c9"
upstream_commit: "5d7d786ad1d8703a52184ff2ee39712c46ed67c9"
core_version: "1.1"
+version_basis:
+ kind: file
+ path: "libretro-radio.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Internet Radio (Radio)"
cores:
- radio
diff --git a/emulators/raine.yml b/emulators/raine.yml
index 7d6d22639..df8fff733 100644
--- a/emulators/raine.yml
+++ b/emulators/raine.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-10-20"
source_commit: "8e25d5d8907ae459181610912b0030c955683015"
pin_frozen: "RetroBat, the only platform that ships it, installs this release: the raine.exe of its raine.7z package (8 August 2026) reports 0.97.5 and was built on 16 March 2025, the bump its changelog records (RetroBat CHANGELOG.md:176). Later commits are not part of that package."
core_version: "0.97.5"
+version_basis:
+ kind: file
+ path: "makefile"
+ pattern: 'VERSION\s+=\s+"([^"]+)'
display_name: "Arcade (RAINE)"
mode: standalone
cores:
diff --git a/emulators/raze.yml b/emulators/raze.yml
index 338a101aa..ae88fc8fc 100644
--- a/emulators/raze.yml
+++ b/emulators/raze.yml
@@ -5,6 +5,11 @@ source: "https://github.com/ZDoom/Raze"
upstream: "https://github.com/ZDoom/Raze"
profiled_date: "2026-10-04"
source_commit: "76fe82f5eb318fdeb30ba9fb27093a9a10bc3821"
+core_version: "1.12pre"
+version_basis:
+ kind: file
+ path: "source/core/version.h"
+ pattern: '^#define VERSIONSTR "([^"]+)"'
display_name: "Raze"
cores: []
systems: [duke3d, blood, shadow-warrior, powerslave, exhumed, redneck, nam, ww2gi]
diff --git a/emulators/rb5000.yml b/emulators/rb5000.yml
index 9277193dc..01d9064db 100644
--- a/emulators/rb5000.yml
+++ b/emulators/rb5000.yml
@@ -13,6 +13,15 @@ pin_frozen: "Recalbox, the only platform that ships the standalone build, builds
core_version:
standalone: "v1.1"
libretro: "v0.9"
+version_basis:
+ standalone:
+ kind: tag
+ tag: "v1.1"
+ distance: 0
+ libretro:
+ kind: tag
+ tag: "v0.9"
+ distance: 0
display_name: "Philips - VG 5000 (RB5000)"
cores:
- "rb5000"
diff --git a/emulators/real3doplayer.yml b/emulators/real3doplayer.yml
index f49bdb826..162002937 100644
--- a/emulators/real3doplayer.yml
+++ b/emulators/real3doplayer.yml
@@ -9,6 +9,11 @@ version_source:
author: "AltmerSoft (Maxim Grishin)"
profiled_date: "2026-08-12"
core_version: "1.0.32"
+version_basis:
+ kind: package
+ url: "http://www.arts-union.ru/node/23"
+ pattern: 'real3DOPlayer_free_(\d+(?:\.\d+)+)\.zip'
+ artifact: "real3DOPlayer_free_1.0.32.zip"
display_name: "The 3DO Company - 3DO (Real3DOPlayer)"
mode: standalone
cores:
diff --git a/emulators/realrtcw.yml b/emulators/realrtcw.yml
index f518b8553..a39081333 100644
--- a/emulators/realrtcw.yml
+++ b/emulators/realrtcw.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-10-20"
source_commit: "ad529d588e4dec717aff0779f9d1555eddc1e2be"
pin_frozen: "RetroBat, the only platform that ships it, installs this release: the RealRTCW.x64.exe of its rtcw.7z package (31 March 2026) reports RealRTCW 5.2. Later releases are not part of that package."
core_version: "5.2"
+version_basis:
+ kind: tag
+ tag: "5.2"
+ distance: 0
display_name: "RealRTCW"
mode: standalone
cores:
diff --git a/emulators/redbook.yml b/emulators/redbook.yml
index 521bae6af..19e872709 100644
--- a/emulators/redbook.yml
+++ b/emulators/redbook.yml
@@ -5,6 +5,10 @@ source: "https://github.com/libretro/redbook"
profiled_date: "2026-03-25"
source_commit: "43544bb988f37a8f3df5af5d2c6f89dd255b8235"
core_version: "1.0"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Redbook Audio Player"
cores:
- redbook
diff --git a/emulators/redream.yml b/emulators/redream.yml
index 3f030d30f..251f8fdab 100644
--- a/emulators/redream.yml
+++ b/emulators/redream.yml
@@ -10,6 +10,10 @@
emulator: Redream
type: libretro
source: "https://github.com/libretro/redream"
+core_version: null
+version_basis:
+ kind: unverified
+ why: no_pin
cores:
- redream
systems:
diff --git a/emulators/reicast.yml b/emulators/reicast.yml
index df036d350..975bcfa4c 100644
--- a/emulators/reicast.yml
+++ b/emulators/reicast.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/skmp/reicast-emulator"
profiled_date: "2026-10-17"
source_commit: "ad61f95dd6b4c6218846a25de6194550311a5d28"
pin_frozen: "Recalbox 10.1.1 builds this revision (package/reicast/reicast.mk REICAST_VERSION). Later commits on reicast-emulator are not part of that build."
-core_version: "Git"
+core_version: "r8.1-243-gad61f95"
+version_basis:
+ kind: tag
+ tag: "r8.1"
+ distance: 243
display_name: "Sega - Dreamcast (Reicast)"
cores:
- "reicast"
diff --git a/emulators/reminiscence.yml b/emulators/reminiscence.yml
index a5a504218..4bf1ff22e 100644
--- a/emulators/reminiscence.yml
+++ b/emulators/reminiscence.yml
@@ -7,6 +7,10 @@ upstream_commit: "947a4dce261aacc4f6ec0c2c90cb9d7e37712c7b"
profiled_date: "2026-03-25"
source_commit: "e6c0b0039258004f8bc377ddb88c0e931db131ce"
core_version: "0.3.6"
+version_basis:
+ kind: file
+ path: "src/libretro.cpp"
+ pattern: '#define\s+RE_VERSION\s+"([^"]+)'
display_name: "Flashback (REminiscence)"
cores:
- reminiscence
diff --git a/emulators/remotejoy.yml b/emulators/remotejoy.yml
index 5db39b9fc..8d204628f 100644
--- a/emulators/remotejoy.yml
+++ b/emulators/remotejoy.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/akiraink/RemoteJoyLite"
profiled_date: "2026-03-25"
source_commit: "745b97ca8d2d825d0bfde4bb321d1bf7b49fd91d"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "PSP RemotePlay (RemoteJoy)"
cores:
- remotejoy
diff --git a/emulators/retro8.yml b/emulators/retro8.yml
index 5354378d7..a27f50183 100644
--- a/emulators/retro8.yml
+++ b/emulators/retro8.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "ddc06a142398ee9755894b3f0bb17c8dc428151d"
upstream_commit: "ecee6594c5b953966ee0515f3edac4032c7ed40f"
core_version: "0.1b"
+version_basis:
+ kind: file
+ path: "src/libretro/libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "PICO-8 (Retro8)"
cores:
- retro8
diff --git a/emulators/retrodream.yml b/emulators/retrodream.yml
index 3154bfe27..0d0c76fba 100644
--- a/emulators/retrodream.yml
+++ b/emulators/retrodream.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/redream-next"
upstream: "https://github.com/inolen/redream"
profiled_date: "2026-03-25"
source_commit: "d826ece7f987c6fed336246e7272e2ded6cefa69"
-core_version: "Git"
+core_version: "0.0"
+version_basis:
+ kind: file
+ path: "src/host/retro_host.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "Sega - Dreamcast (RetroDream)"
cores:
- retrodream
diff --git a/emulators/rigelengine.yml b/emulators/rigelengine.yml
index 6c877abee..880db6916 100644
--- a/emulators/rigelengine.yml
+++ b/emulators/rigelengine.yml
@@ -5,7 +5,11 @@ source: "https://github.com/lethal-guitar/RigelEngine"
upstream: "https://github.com/lethal-guitar/RigelEngine"
profiled_date: "2026-10-22"
source_commit: "30afeff8f0b105eac77ee0a2fbd54b06e1a49b60"
-core_version: "0.9.1-28-g30afeff8"
+core_version: "0.9.1"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: '^project\(RigelEngine VERSION ([0-9.]+)\)'
display_name: "Duke Nukem II (RigelEngine)"
cores: [rigelengine, "RigelEngine"]
systems: [duke-nukem-2]
diff --git a/emulators/romcleaner.yml b/emulators/romcleaner.yml
index 5bc706fdd..2abacb6f0 100644
--- a/emulators/romcleaner.yml
+++ b/emulators/romcleaner.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/JesseTG/rom-cleaner"
profiled_date: "2026-03-25"
source_commit: "f75e2b58733de180f28a73286c6a82505be7bbb2"
core_version: "1.0.0"
+version_basis:
+ kind: file
+ path: "libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "ROM Cleaner"
cores:
- romcleaner
diff --git a/emulators/rosalies-mupen-gui.yml b/emulators/rosalies-mupen-gui.yml
index 8bfab72d7..310b0c6f7 100644
--- a/emulators/rosalies-mupen-gui.yml
+++ b/emulators/rosalies-mupen-gui.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/Rosalie241/RMG"
profiled_date: "2026-10-04"
source_commit: "4777ed3f1fb7f1e4a0e70438a27dc4a5e9fb1927"
core_version: "v0.9.0"
+version_basis:
+ kind: file
+ path: "VERSION"
+ pattern: '^(v?[0-9][^\s]*)'
display_name: "Nintendo - Nintendo 64 (Rosalie's Mupen GUI)"
cores:
- rosalies-mupen-gui
diff --git a/emulators/rp6502.yml b/emulators/rp6502.yml
index e59932a09..65dc4d15c 100644
--- a/emulators/rp6502.yml
+++ b/emulators/rp6502.yml
@@ -6,7 +6,11 @@ source_branch: libretro
upstream: "https://github.com/picocomputer/rp6502"
profiled_date: "2026-09-17"
source_commit: "02bfc3cbc9b2700795ce0a40fb49b413784cb832"
-core_version: "0.33"
+core_version: "v0.33"
+version_basis:
+ kind: tag
+ tag: "v0.33"
+ distance: 0
display_name: "Picocomputer 6502"
cores:
- rp6502
diff --git a/emulators/rpcs3.yml b/emulators/rpcs3.yml
index 5f3a0a146..ea5e70862 100644
--- a/emulators/rpcs3.yml
+++ b/emulators/rpcs3.yml
@@ -8,7 +8,12 @@ cores:
- "rpcs3-sa"
profiled_date: "2026-03-30"
source_commit: "c365ea76a552961ff078ea1a46da9d4b07729674"
-core_version: "v0.0.43"
+core_version: "0.0.43"
+version_basis:
+ kind: file
+ path: "rpcs3/rpcs3_version.cpp"
+ pattern: 'utils::version version\{\s*(\d+),\s*(\d+),\s*(\d+),'
+ format: "{1}.{2}.{3}"
display_name: "Sony - PlayStation 3 (RPCS3)"
systems: [sony-playstation-3]
diff --git a/emulators/rpix86.yml b/emulators/rpix86.yml
index ff3530143..62e7d5737 100644
--- a/emulators/rpix86.yml
+++ b/emulators/rpix86.yml
@@ -5,6 +5,10 @@ source: "http://rpix86.patrickaalto.com/rdown.html"
upstream: ~
profiled_date: "2026-10-20"
core_version: "0.19"
+version_basis:
+ kind: package
+ url: "https://files.retropie.org.uk/archives/rpix86.tar.gz"
+ artifact: "rpix86.tar.gz, rpix86 binary banner"
display_name: "DOS (rpix86)"
cores: [rpix86]
systems: [dos]
diff --git a/emulators/ruffle.yml b/emulators/ruffle.yml
index 8a08c4847..2c91a71db 100644
--- a/emulators/ruffle.yml
+++ b/emulators/ruffle.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/ruffle-rs/ruffle"
profiled_date: "2026-09-17"
source_commit: "fe6cfc545405b888ebb478ce1c211d63147ca49b"
core_version: "0.7.1"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "Adobe Flash (Ruffle)"
cores:
- ruffle
diff --git a/emulators/rust_dos.yml b/emulators/rust_dos.yml
index 4fa4caebc..511114a3d 100644
--- a/emulators/rust_dos.yml
+++ b/emulators/rust_dos.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-10-04"
source_commit: "152d9bca7820f0d9a5d1ca820a2d20855698a211"
upstream_commit: "152d9bca7820f0d9a5d1ca820a2d20855698a211"
core_version: "1.7.5"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "DOS (Rust-DOS)"
cores:
- rust_dos
diff --git a/emulators/rustation.yml b/emulators/rustation.yml
index e2f5238f4..699ba38e5 100644
--- a/emulators/rustation.yml
+++ b/emulators/rustation.yml
@@ -6,7 +6,12 @@ upstream: "https://github.com/simias/rustation"
profiled_date: "2026-03-25"
source_commit: "3e328e3967e312e1f239aeeecd479a7b4bfda850"
upstream_commit: "1daf4faaade3c0fd1e9deace8e309e91303f1b46"
-core_version: "v1"
+core_version: "0.0.1"
+version_basis:
+ kind: file
+ repo: upstream
+ path: "Cargo.toml"
+ pattern: '^version\s*=\s*"([^"]+)"'
display_name: "Sony - PlayStation (Rustation)"
cores: [rustation]
found_by: content
diff --git a/emulators/rustynes.yml b/emulators/rustynes.yml
index 528aca632..aa6999fd5 100644
--- a/emulators/rustynes.yml
+++ b/emulators/rustynes.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/doublegate/RustyNES"
profiled_date: "2026-10-26"
source_commit: "3d02b99dc84fb389c03fc6b1f986ffa658dda0dd"
core_version: "3.5.0"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "Nintendo - NES / Famicom (RustyNES)"
cores:
- rustynes
diff --git a/emulators/rvvm.yml b/emulators/rvvm.yml
index 243610825..21184b7be 100644
--- a/emulators/rvvm.yml
+++ b/emulators/rvvm.yml
@@ -5,7 +5,11 @@ source: "https://github.com/LekKit/RVVM"
upstream: "https://github.com/LekKit/RVVM"
profiled_date: "2026-03-25"
source_commit: "343f6784c8922446083c5d5bd111d7c1eb8590eb"
-core_version: "0.7-git"
+core_version: "0.7"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: '^set\(RVVM_VERSION ([0-9.]+)\)'
display_name: "RVVM"
cores: [rvvm]
systems: [riscv]
diff --git a/emulators/ryujinx.yml b/emulators/ryujinx.yml
index 4d75ef4ea..50fc96065 100644
--- a/emulators/ryujinx.yml
+++ b/emulators/ryujinx.yml
@@ -5,6 +5,10 @@ upstream: "https://github.com/Ryujinx/Ryujinx"
profiled_date: "2026-03-26"
source_commit: "7d158acc3b5826a08941d6e8d50d3a3897021bcd"
core_version: "1.1.1402"
+version_basis:
+ kind: tag
+ tag: "1.1.1402"
+ distance: 0
display_name: "Ryujinx (Nintendo Switch)"
cores:
- ryujinx
diff --git a/emulators/samcoupe.yml b/emulators/samcoupe.yml
index 18eba3027..46958b976 100644
--- a/emulators/samcoupe.yml
+++ b/emulators/samcoupe.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/simonowen/simcoupe"
profiled_date: "2026-10-04"
source_commit: "1f966036543991c05022ce4f95cfbfbb0014b187"
core_version: "v1.2.17"
+version_basis:
+ kind: tag
+ tag: "v1.2.17"
+ distance: 0
display_name: "SAM Coupe (SimCoupe)"
cores:
- samcoupe
diff --git a/emulators/same_cdi.yml b/emulators/same_cdi.yml
index 545108df6..5b5c1914e 100644
--- a/emulators/same_cdi.yml
+++ b/emulators/same_cdi.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/mamedev/mame"
profiled_date: "2026-03-25"
source_commit: "ff9bb99a50d518aef6d2add1c49b7c44447e3e78"
upstream_commit: "39eb9e44c394df9aec8186f2d2ef7517ace232e1"
-core_version: "Git"
+core_version: "0.287"
+version_basis:
+ kind: file
+ path: "gen/version.cpp"
+ pattern: '#define\s+BARE_BUILD_VERSION\s+"([^"]+)"'
display_name: "Philips - CDi (SAME CDi)"
cores: [same_cdi]
systems:
diff --git a/emulators/sameboy.yml b/emulators/sameboy.yml
index 6c0635d4d..fc727a438 100644
--- a/emulators/sameboy.yml
+++ b/emulators/sameboy.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "aa158a889a48b538a0302873704a34577c8eb67d"
upstream_commit: "9bf7e0b4dffc20941e4dfadffc6899ae19463844"
core_version: "0.15.4"
+version_basis:
+ kind: file
+ path: "version.mk"
+ pattern: 'VERSION\s+:=\s+([0-9][\w.+-]*)'
display_name: "Nintendo - Game Boy / Color (SameBoy)"
cores:
- sameboy
diff --git a/emulators/sameduck.yml b/emulators/sameduck.yml
index 07d09653a..2d867fa39 100644
--- a/emulators/sameduck.yml
+++ b/emulators/sameduck.yml
@@ -6,6 +6,9 @@ upstream: "https://github.com/LIJI32/SameBoy"
profiled_date: "2026-03-25"
source_commit: "e7da9386e1533268b7e9de73450e070b1498d1bf"
core_version: "0.13.6"
+version_basis:
+ kind: unverified
+ why: pin_off_branch
display_name: "Mega Duck / Cougar Boy (SameDuck)"
cores:
- sameduck
diff --git a/emulators/saturn-emu.yml b/emulators/saturn-emu.yml
index 5983e977c..bcfb16a67 100644
--- a/emulators/saturn-emu.yml
+++ b/emulators/saturn-emu.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-08-12"
source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943"
core_version: "1.5.85"
+version_basis:
+ kind: file
+ path: "EmuFramework/metadata/conf.mk"
+ pattern: '^metadata_version\s*=\s*(\S+)'
display_name: "Sega - Saturn (Saturn.emu)"
mode: standalone
cores:
diff --git a/emulators/scummvm-sdl1.yml b/emulators/scummvm-sdl1.yml
index 61b43c94c..557b027f5 100644
--- a/emulators/scummvm-sdl1.yml
+++ b/emulators/scummvm-sdl1.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/scummvm/scummvm"
logo: "https://raw.githubusercontent.com/scummvm/scummvm/master/icons/scummvm.svg"
profiled_date: "2026-10-20"
source_commit: "fed42f2068dcafc6aafa1c28c77e4c88def74b66"
-core_version: "2026.3.0"
+core_version: "v2026.3.0"
+version_basis:
+ kind: tag
+ tag: "v2026.3.0"
+ distance: 0
display_name: "ScummVM (SDL1)"
cores: [scummvm-sdl1]
systems: [scummvm]
diff --git a/emulators/scummvm-standalone.yml b/emulators/scummvm-standalone.yml
index e7bc5aef6..468f14c25 100644
--- a/emulators/scummvm-standalone.yml
+++ b/emulators/scummvm-standalone.yml
@@ -6,6 +6,10 @@ logo: "https://raw.githubusercontent.com/scummvm/scummvm/master/icons/scummvm.sv
profiled_date: "2026-10-24"
source_commit: "21955df19b580dad2807ec55f573b5f348690a81"
core_version: "2026.4.1git"
+version_basis:
+ kind: file
+ path: "base/internal_version.h"
+ pattern: '#define\s+SCUMMVM_VERSION\s+"([^"]+)'
display_name: "ScummVM (SDL2)"
mode: standalone
cores:
diff --git a/emulators/scummvm.yml b/emulators/scummvm.yml
index adb0bfcdb..f8241807d 100644
--- a/emulators/scummvm.yml
+++ b/emulators/scummvm.yml
@@ -7,7 +7,11 @@ logo: "https://raw.githubusercontent.com/scummvm/scummvm/master/icons/scummvm.sv
profiled_date: "2026-03-25"
source_commit: "e0ddc60bfabb1efdf1e135006c8914ca59d0596c"
upstream_commit: "e0ddc60bfabb1efdf1e135006c8914ca59d0596c"
-core_version: "2.8.0git"
+core_version: "2026.4.1git"
+version_basis:
+ kind: file
+ path: "base/internal_version.h"
+ pattern: '#define SCUMMVM_VERSION "([^"]+)"'
display_name: "ScummVM"
cores: [scummvm]
systems: [scummvm]
diff --git a/emulators/sdl2trs.yml b/emulators/sdl2trs.yml
index 31618b4a9..cf4a452b2 100644
--- a/emulators/sdl2trs.yml
+++ b/emulators/sdl2trs.yml
@@ -6,6 +6,10 @@ upstream: "http://sdltrs.sourceforge.net"
profiled_date: "2026-10-23"
source_commit: "205ddfd78fd00b32b3f730453175dbd70782ce79"
core_version: "1.2.35"
+version_basis:
+ kind: file
+ path: "configure.ac"
+ pattern: '^AC_INIT\(\[sdltrs\],\s*\[([^\]]+)\]'
display_name: "Tandy - TRS-80 (SDL2TRS)"
cores:
- sdl2trs
diff --git a/emulators/sdlpal.yml b/emulators/sdlpal.yml
index e1aa2234c..d189676c8 100644
--- a/emulators/sdlpal.yml
+++ b/emulators/sdlpal.yml
@@ -5,7 +5,11 @@ source: "https://github.com/sdlpal/sdlpal"
upstream: "https://github.com/sdlpal/sdlpal"
profiled_date: "2026-03-25"
source_commit: "d4bf69b64d335cd680555af9df90e7ec4f3006a3"
-core_version: "v2.0.1"
+core_version: "2.0.1"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "SDLPAL"
cores:
- sdlpal
diff --git a/emulators/sdlpop.yml b/emulators/sdlpop.yml
index 94511bd73..822451fb9 100644
--- a/emulators/sdlpop.yml
+++ b/emulators/sdlpop.yml
@@ -6,6 +6,10 @@ upstream: ~
profiled_date: "2026-10-04"
source_commit: "3c5add5fb7f83d4ceb542823ab66d00146c4271b"
core_version: "1.24 RC"
+version_basis:
+ kind: file
+ path: "src/config.h"
+ pattern: '#define SDLPOP_VERSION "([^"]+)"'
display_name: "Prince of Persia (SDLPoP)"
cores: [sdlpop, "prince"]
systems: [prince-of-persia]
diff --git a/emulators/shadps4.yml b/emulators/shadps4.yml
index d78aa1f34..4256ad93e 100644
--- a/emulators/shadps4.yml
+++ b/emulators/shadps4.yml
@@ -4,7 +4,12 @@ source: "https://github.com/shadps4-emu/shadPS4"
upstream: "https://github.com/shadps4-emu/shadPS4"
profiled_date: "2026-03-26"
source_commit: "3eecea94ddaca360b19f4b800523b609a8b2ffcc"
-core_version: "pre-release 2026-08-08"
+core_version: "0.20.1 WIP"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'set\(EMULATOR_VERSION_MAJOR "(\d+)"\)\s*\nset\(EMULATOR_VERSION_MINOR "(\d+)"\)\s*\nset\(EMULATOR_VERSION_PATCH "(\d+)"\)[\s\S]*?set\(APP_VERSION "\$\{APP_VERSION_NUM\} ?([^"]*)"\)'
+ format: "{1}.{2}.{3} {4}"
display_name: "shadPS4 (PlayStation 4)"
cores:
- shadps4
diff --git a/emulators/sheepshaver.yml b/emulators/sheepshaver.yml
index 916d9b2d2..cd58251e4 100644
--- a/emulators/sheepshaver.yml
+++ b/emulators/sheepshaver.yml
@@ -6,6 +6,11 @@ upstream: "https://github.com/cebix/macemu"
profiled_date: "2026-08-08"
source_commit: "96e512bd6376e78a2869f16dcc8a9028bce5ee72"
core_version: "2.4"
+version_basis:
+ kind: file
+ path: "SheepShaver/src/include/version.h"
+ pattern: 'const int VERSION_MAJOR = (\d+);\s*\nconst int VERSION_MINOR = (\d+);'
+ format: "{1}.{2}"
display_name: "Apple - Macintosh PowerPC (SheepShaver)"
cores:
- sheepshaver
diff --git a/emulators/simcp.yml b/emulators/simcp.yml
index 2c70a88f5..cec35b264 100644
--- a/emulators/simcp.yml
+++ b/emulators/simcp.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/libretro-simcoupe"
upstream: "https://github.com/simonowen/simcoupe"
profiled_date: "2026-03-25"
source_commit: "c28046241ac6a4d79e55326b6e354dc02f92fa34"
-core_version: "Git"
+core_version: "v1.1 alpha"
+version_basis:
+ kind: file
+ path: "SimCoupe/Base/GUIDlg.cpp"
+ pattern: 'szVersion\[128\] = "SimCoupe (v[^"]+)"'
display_name: "SAM Coupe (SimCoupe)"
cores:
- simcp
diff --git a/emulators/simple64.yml b/emulators/simple64.yml
index 8948aad56..f09be5b1d 100644
--- a/emulators/simple64.yml
+++ b/emulators/simple64.yml
@@ -5,7 +5,11 @@ source: "https://github.com/simple64/simple64"
upstream: "https://github.com/simple64/simple64"
profiled_date: "2026-08-08"
source_commit: "d8c969c7b932e3d76e6a25549d76348839dbaefd"
-core_version: "v2024.12.1"
+core_version: "v2024.12.1-5-gd8c969c"
+version_basis:
+ kind: tag
+ tag: "v2024.12.1"
+ distance: 5
display_name: "Nintendo - Nintendo 64 (simple64)"
cores:
- simple64
diff --git a/emulators/singe2.yml b/emulators/singe2.yml
index 3f617744a..0da384e39 100644
--- a/emulators/singe2.yml
+++ b/emulators/singe2.yml
@@ -5,6 +5,11 @@ source: "https://singeengine.com"
upstream: "https://singeengine.com"
profiled_date: "2026-10-20"
core_version: "2.10"
+version_basis:
+ kind: package
+ url: "https://singeengine.com"
+ pattern: 'Singe-v(\d+\.\d+)-Windows'
+ artifact: "Singe-v2.10-Windows-x86_64.exe"
display_name: "Laserdisc (Singe 2)"
mode: standalone
cores:
diff --git a/emulators/sixtyforce.yml b/emulators/sixtyforce.yml
index 153ace7d7..d9d5dc34a 100644
--- a/emulators/sixtyforce.yml
+++ b/emulators/sixtyforce.yml
@@ -8,6 +8,10 @@ version_source:
pattern: 'version (\d+(?:\.\d+)+) '
profiled_date: "2026-08-12"
core_version: "2.0.2"
+version_basis:
+ kind: package
+ url: "https://sixtyforce.com/download/sixtyforce.zip"
+ artifact: "sixtyforce.zip, sixtyforce.app Info.plist CFBundleShortVersionString"
display_name: "Nintendo - Nintendo 64 (sixtyforce)"
cores:
- sixtyforce
diff --git a/emulators/skyemu.yml b/emulators/skyemu.yml
index 56db786ff..da6afa3e7 100644
--- a/emulators/skyemu.yml
+++ b/emulators/skyemu.yml
@@ -5,7 +5,11 @@ source: https://github.com/skylersaleh/SkyEmu/blob/v5/src/libretro.c
upstream: https://github.com/skylersaleh/SkyEmu
profiled_date: "2026-03-25"
source_commit: "f4d3238bf42da483ed1b7cc35649398b2110e4b4"
-core_version: "v5"
+core_version: "v5-22-gf4d3238"
+version_basis:
+ kind: tag
+ tag: "v5"
+ distance: 22
display_name: "Nintendo - Game Boy/GBA/NDS (SkyEmu)"
cores:
diff --git a/emulators/skyline.yml b/emulators/skyline.yml
index ea0b99926..368ac18ed 100644
--- a/emulators/skyline.yml
+++ b/emulators/skyline.yml
@@ -6,6 +6,9 @@ upstream: "https://archive.softwareheritage.org/browse/revision/dc20a615275f66be
profiled_date: "2026-08-12"
source_commit: "dc20a615275f66bee20a4fd851ef0231daca4f14"
core_version: "0.0.3"
+version_basis:
+ kind: unverified
+ why: host_unsupported
display_name: "Nintendo - Switch (Skyline)"
cores:
- "skyline"
diff --git a/emulators/smsplus.yml b/emulators/smsplus.yml
index 1ed5fd3db..a358b3563 100644
--- a/emulators/smsplus.yml
+++ b/emulators/smsplus.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/ekeeke/smsplus-gx"
profiled_date: "2026-03-25"
source_commit: "3844b46caa926b6494987b97da63092818c4ddef"
core_version: "1.8"
+version_basis:
+ kind: file
+ path: "source/system.h"
+ pattern: '#define\s+APP_VERSION\s+"([^"]+)"'
display_name: "Sega - MS/GG (SMS Plus GX)"
cores:
- smsplus
diff --git a/emulators/snes9x-explus.yml b/emulators/snes9x-explus.yml
index fc00ed6e1..19c86b43c 100644
--- a/emulators/snes9x-explus.yml
+++ b/emulators/snes9x-explus.yml
@@ -8,6 +8,10 @@ source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943"
upstream_commit: "b33f2afb33c61d675aaf0319bd3b8cc8d6924d49"
upstream_frozen: "Snes9x is vendored under Snes9x/src/snes9x at this upstream revision, the one the last sync took."
core_version: "1.5.85"
+version_basis:
+ kind: file
+ path: "EmuFramework/metadata/conf.mk"
+ pattern: '^metadata_version\s*=\s*(\S+)'
display_name: "Nintendo - SNES / SFC (Snes9x EX+)"
mode: standalone
cores:
diff --git a/emulators/snes9x.yml b/emulators/snes9x.yml
index d1c0f2018..f79667dc1 100644
--- a/emulators/snes9x.yml
+++ b/emulators/snes9x.yml
@@ -7,7 +7,11 @@ logo: "https://raw.githubusercontent.com/snes9xgit/snes9x/master/gtk/data/snes9x
profiled_date: "2026-03-25"
source_commit: "fae2fea08f74180759ef540ee94259213f503480"
upstream_commit: "85591435763dede7ef111aab8070cdf2d5a7da49"
-core_version: "1.61"
+core_version: "1.63"
+version_basis:
+ kind: file
+ path: "snes9x.h"
+ pattern: '#define\s+VERSION\s+"([^"]+)"'
display_name: "Nintendo - SNES / SFC (Snes9x)"
systems: [nintendo-snes, nintendo-satellaview, nintendo-sufami-turbo]
diff --git a/emulators/snes9x2002.yml b/emulators/snes9x2002.yml
index 4404cdef9..69434ffbf 100644
--- a/emulators/snes9x2002.yml
+++ b/emulators/snes9x2002.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "6ffbf9ef4f0063e1f1b78a40d10c50fc52f2524c"
upstream_commit: "1bcc369e89f08243e0a462882fb1f3e42e51de3a"
core_version: "7.2.0"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Nintendo - SNES / SFC (Snes9x 2002)"
cores:
- snes9x2002
diff --git a/emulators/snes9x2005.yml b/emulators/snes9x2005.yml
index 1f9888dba..238979162 100644
--- a/emulators/snes9x2005.yml
+++ b/emulators/snes9x2005.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/ShadauxCat/CATSFC"
profiled_date: "2026-03-25"
source_commit: "a79dfe9047e7fec58808aefe48ad2bf499c7af11"
core_version: "v1.36"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Nintendo - SNES / SFC (Snes9x 2005)"
cores:
- snes9x2005
diff --git a/emulators/snes9x2005_plus.yml b/emulators/snes9x2005_plus.yml
index 1045e6f08..baa875351 100644
--- a/emulators/snes9x2005_plus.yml
+++ b/emulators/snes9x2005_plus.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/ShadauxCat/CATSFC"
profiled_date: "2026-03-25"
source_commit: "a79dfe9047e7fec58808aefe48ad2bf499c7af11"
core_version: "v1.36"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Nintendo - SNES / SFC (Snes9x 2005 Plus)"
cores:
- snes9x2005_plus
diff --git a/emulators/snes9x2010.yml b/emulators/snes9x2010.yml
index 68598a425..93541ac2d 100644
--- a/emulators/snes9x2010.yml
+++ b/emulators/snes9x2010.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/snes9xgit/snes9x"
profiled_date: "2026-03-25"
source_commit: "fe690dd321fa5a46b5234a2bde089d2518c62b0e"
upstream_commit: "1bcc369e89f08243e0a462882fb1f3e42e51de3a"
-core_version: "v1.52.4"
+core_version: "1.52.4"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: '#define\s+CORE_VERSION\s+"([^"]+)'
display_name: "Nintendo - SNES / SFC (Snes9x 2010)"
cores:
- snes9x2010
diff --git a/emulators/soh.yml b/emulators/soh.yml
index 7b4f087ad..045497315 100644
--- a/emulators/soh.yml
+++ b/emulators/soh.yml
@@ -7,6 +7,10 @@ source_commit: "cb71e22a79bc5d1f688fa881795bbd93094895fc"
pin_frozen: "RetroBat, the only platform that ships it, installs this release: the soh.exe of its soh.7z package (10 October 2026) reports Ackbar Delta (9.2.3). Later releases are not part of that package."
profiled_date: "2026-10-20"
core_version: "9.2.3"
+version_basis:
+ kind: tag
+ tag: "9.2.3"
+ distance: 0
display_name: "The Legend of Zelda: Ocarina of Time (Ship of Harkinian)"
cores: [soh]
systems: [soh]
diff --git a/emulators/solarus-2.0.0.yml b/emulators/solarus-2.0.0.yml
index 62b17785c..e1baae42a 100644
--- a/emulators/solarus-2.0.0.yml
+++ b/emulators/solarus-2.0.0.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-10-26"
source_commit: "49d1d4686f67ec81c86c90ca30279a4ce06b1729"
pin_frozen: "RetroBat, the only platform that ships it, builds this revision: the solarus2 package its emulator repository lists as 0.25.04.16 carries a libsolarus.dll that reports Solarus 2.0.0 (49d1d46), tag v2.0.0+cicd1. Later tags are not part of that build."
core_version: "2.0.0"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'project\(solarus-engine\s+VERSION\s+([0-9][^\s)]*)'
display_name: "Solarus Game Engine (2.0.0)"
cores:
- solarus2
diff --git a/emulators/solarus.yml b/emulators/solarus.yml
index 54b056798..00112eee0 100644
--- a/emulators/solarus.yml
+++ b/emulators/solarus.yml
@@ -6,6 +6,10 @@ upstream: "https://gitlab.com/solarus-games/solarus"
profiled_date: "2026-08-08"
source_commit: "86fa2031e1303aaaf9924a2f03dfdb7b7d1cbf4b"
core_version: "2.2.0"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'project\(solarus-engine\s+VERSION\s+([0-9][^\s)]*)'
display_name: "Solarus Game Engine"
cores:
- solarus
diff --git a/emulators/sonic-mania.yml b/emulators/sonic-mania.yml
index dfff57eee..4b1a93b05 100644
--- a/emulators/sonic-mania.yml
+++ b/emulators/sonic-mania.yml
@@ -5,6 +5,11 @@ source: "https://github.com/RSDKModding/Sonic-Mania-Decompilation"
upstream: "https://github.com/RSDKModding/RSDKv5-Decompilation"
source_commit: "9dc699428420d752af9767bdb13f585ee0881bc0"
upstream_commit: "191d7aea6c262d891244172a8609ac69fbfd7571"
+core_version: "v1.1.1-15-g9dc6994"
+version_basis:
+ kind: tag
+ tag: "v1.1.1"
+ distance: 15
profiled_date: "2026-10-04"
display_name: "Sonic Mania (RSDKv5 Decomp)"
cores: [sonicmania]
diff --git a/emulators/sonic2013.yml b/emulators/sonic2013.yml
index 3fc9f9363..8dfd837fa 100644
--- a/emulators/sonic2013.yml
+++ b/emulators/sonic2013.yml
@@ -5,6 +5,10 @@ source: "https://github.com/RSDKModding/RSDKv4-Decompilation"
upstream: "https://github.com/RSDKModding/RSDKv4-Decompilation"
source_commit: "a7f5195e21fdad7b75e4587e249013feeea9e6f3"
core_version: "1.3.3"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'set\(DECOMP_VERSION\s+([^\s)]+)\)'
profiled_date: "2026-10-04"
display_name: "Sonic 1 & 2 (2013) (RSDKv4 Decomp)"
cores: [sonicretro]
diff --git a/emulators/sonic3-air.yml b/emulators/sonic3-air.yml
index 4df14d223..c1e6220d9 100644
--- a/emulators/sonic3-air.yml
+++ b/emulators/sonic3-air.yml
@@ -4,6 +4,11 @@ core_classification: game_engine
source: "https://github.com/Eukaryot/sonic3air"
upstream: "https://github.com/Eukaryot/sonic3air"
source_commit: "b0063dfb4b3e69d710e131ab83507cdf8c41910f"
+core_version: "26.08.15.0"
+version_basis:
+ kind: file
+ path: "Oxygen/sonic3air/source/sonic3air/version.inc"
+ pattern: '#define\s+BUILD_STRING\s+"([^"]+)"'
profiled_date: "2026-10-04"
display_name: "Sonic 3 - Angel Island Revisited"
cores: [sonic3air]
diff --git a/emulators/soniccd.yml b/emulators/soniccd.yml
index 65fd5654c..d42016b92 100644
--- a/emulators/soniccd.yml
+++ b/emulators/soniccd.yml
@@ -4,6 +4,11 @@ core_classification: game_engine
source: "https://github.com/RSDKModding/RSDKv3-Decompilation"
upstream: "https://github.com/RSDKModding/RSDKv3-Decompilation"
source_commit: "2a7fd8b70f1b33553e0d6c67491d07e8290b80c1"
+core_version: "1.3.3"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'set\(DECOMP_VERSION\s+([^\s)]+)\)'
profiled_date: "2026-10-04"
display_name: "Sonic CD (RSDKv3 Decomp)"
cores: [sonicretrocd]
diff --git a/emulators/speccy.yml b/emulators/speccy.yml
index 72b6e64c3..cfa22dd4b 100644
--- a/emulators/speccy.yml
+++ b/emulators/speccy.yml
@@ -9,6 +9,10 @@ version_source:
author: "Marat Fayzullin (Garage Research)"
profiled_date: "2026-10-20"
core_version: "5.9.13"
+version_basis:
+ kind: package
+ url: "https://play.google.com/store/apps/details?id=com.fms.speccy"
+ artifact: "com.fms.speccy APK versionName (versionCode 5913)"
display_name: "Sinclair - ZX Spectrum and Sam Coupe (Speccy)"
verification: existence
cores:
diff --git a/emulators/splitwolf.yml b/emulators/splitwolf.yml
index 6c002829f..e5e3d699b 100644
--- a/emulators/splitwolf.yml
+++ b/emulators/splitwolf.yml
@@ -6,6 +6,9 @@ source_branch: scrubbed
profiled_date: "2026-10-20"
source_commit: "f6c00044cdef00a31c66307d8b7cb1fb4ddf8c67"
core_version: "V1.01"
+version_basis:
+ kind: unverified
+ why: host_unsupported
display_name: "Wolfenstein 3D (SplitWolf)"
cores: [splitwolf]
systems: [wolfenstein3d]
diff --git a/emulators/spmp8000emu.yml b/emulators/spmp8000emu.yml
index d61d7f961..7244cf773 100644
--- a/emulators/spmp8000emu.yml
+++ b/emulators/spmp8000emu.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/AloysHF/SPMP8000Emu"
profiled_date: "2026-09-03"
source_commit: "65a93528ee33767bb9cf8faef516c8d1ad43fd13"
core_version: "1.0.0"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "Sunplus - SPMP8000 (SPMP8000Emu)"
cores:
- spmp8000emu
diff --git a/emulators/squirreljme.yml b/emulators/squirreljme.yml
index 2d7ef8a25..729338e2f 100644
--- a/emulators/squirreljme.yml
+++ b/emulators/squirreljme.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/SquirrelJME/SquirrelJME"
profiled_date: "2026-03-25"
source_commit: "24d0e97d4297d0bce26a9651f5bce5d326ea690a"
core_version: "0.3.0"
+version_basis:
+ kind: file
+ path: "squirreljme-version"
+ pattern: '^([0-9][^\s]*)'
display_name: "Java ME (SquirrelJME)"
cores: [squirreljme]
systems: [j2me]
diff --git a/emulators/srb2.yml b/emulators/srb2.yml
index 4079f6ea5..83efa5aab 100644
--- a/emulators/srb2.yml
+++ b/emulators/srb2.yml
@@ -6,6 +6,10 @@ upstream: "https://git.do.srb2.org/STJr/SRB2"
profiled_date: "2026-10-20"
source_commit: "639b58c6d718452ef343a0bc927d043bed9e40d6"
core_version: "2.2.15"
+version_basis:
+ kind: file
+ path: "src/version.h"
+ pattern: '#define\s+SRB2VERSION\s+"([^"]+)'
display_name: "Sonic Robo Blast 2"
cores: [srb2]
systems: []
diff --git a/emulators/ssf.yml b/emulators/ssf.yml
index b0dde7a5a..86a83c144 100644
--- a/emulators/ssf.yml
+++ b/emulators/ssf.yml
@@ -7,6 +7,12 @@ author: "Shima"
profiled_date: "2026-08-12"
source_commit: "432e257c312f705d5f85f298710d152240111da8"
core_version: "PreviewVer R38"
+version_basis:
+ kind: package
+ url: "https://github.com/shimazzz/SEGASaturnEmulator-SSF/releases/download/PreviewVer/SSF_PreviewVer_R38.zip"
+ pattern: 'SSF_(PreviewVer)_(R\d+)\.zip'
+ format: "{1} {2}"
+ artifact: "SSF_PreviewVer_R38.zip"
display_name: "Sega - Saturn/ST-V (SSF)"
cores:
- ssf
diff --git a/emulators/starboard.yml b/emulators/starboard.yml
index 3e858b2bd..912f7c94a 100644
--- a/emulators/starboard.yml
+++ b/emulators/starboard.yml
@@ -7,6 +7,11 @@ author: "force9"
profiled_date: "2026-10-27"
source_commit: "d3a21a9aaeabb90e59d7a48241b293dba3ee9309"
core_version: "0.40.0"
+version_basis:
+ kind: package
+ url: "https://github.com/get-starboard/starboard/releases/download/v0.40.0/Starboard-v0.40.0.apk"
+ pattern: 'Starboard-v([0-9][0-9.]*[0-9])\.apk'
+ artifact: "Starboard-v0.40.0.apk"
display_name: "PortMaster library (Starboard)"
verification: existence
cores:
diff --git a/emulators/starship.yml b/emulators/starship.yml
index a8e1c9154..430796830 100644
--- a/emulators/starship.yml
+++ b/emulators/starship.yml
@@ -5,7 +5,11 @@ source: "https://github.com/HarbourMasters/Starship"
upstream: "https://github.com/HarbourMasters/Starship"
source_commit: "cb19785b51698185a688e17ba1a34c7889195bdb"
profiled_date: "2026-10-20"
-core_version: "2.0.0"
+core_version: "v2.0.0"
+version_basis:
+ kind: tag
+ tag: "v2.0.0"
+ distance: 0
display_name: "Star Fox 64 (Starship)"
cores: [starship]
systems: [starship]
diff --git a/emulators/stella.yml b/emulators/stella.yml
index 7d0baebe9..7031022c7 100644
--- a/emulators/stella.yml
+++ b/emulators/stella.yml
@@ -5,6 +5,10 @@ source: "https://github.com/stella-emu/stella"
profiled_date: "2026-10-21"
source_commit: "289a40f1b63022b8bbe5b722061c660009d5de2c"
core_version: "8.0_pre"
+version_basis:
+ kind: file
+ path: "src/common/Version.hxx"
+ pattern: 'STELLA_VERSION\s*=\s*"([^"]+)"'
display_name: "Atari - 2600 (Stella)"
library_name: "Stella"
cores:
diff --git a/emulators/stella2014.yml b/emulators/stella2014.yml
index 1550543f2..bdc60c8e5 100644
--- a/emulators/stella2014.yml
+++ b/emulators/stella2014.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "7d1361e407e63f29e52892655069e5fb4096e691"
upstream_commit: "289a40f1b63022b8bbe5b722061c660009d5de2c"
core_version: "3.9.3"
+version_basis:
+ kind: file
+ path: "stella/src/common/Version.hxx"
+ pattern: '#define\s+STELLA_VERSION\s+"([^"]+)'
display_name: "Atari - 2600 (Stella 2014)"
cores:
- stella2014
diff --git a/emulators/stella2023.yml b/emulators/stella2023.yml
index 95b8d3200..edef189e5 100644
--- a/emulators/stella2023.yml
+++ b/emulators/stella2023.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "ba52c43b9eda950eb0c0eec69cda9b17dee8c39b"
upstream_commit: "408dd6a30cc6fd91decc602403281c7dcfbc1b82"
core_version: "7.0_pre"
+version_basis:
+ kind: file
+ path: "src/common/Version.hxx"
+ pattern: '#define\s+STELLA_VERSION\s+"([^"]+)'
display_name: "Atari - 2600 (Stella 2023)"
cores:
- stella2023
diff --git a/emulators/stonesoup.yml b/emulators/stonesoup.yml
index c51953275..2d4a86ce5 100644
--- a/emulators/stonesoup.yml
+++ b/emulators/stonesoup.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "cc2530f377a3a5d686d4b6a360d88b01de0fbc59"
upstream_commit: "a5ed93406571b9682050cfc318b15b9f637d4e7e"
core_version: "0.16-a0"
+version_basis:
+ kind: file
+ path: "crawl-ref/source/build.h"
+ pattern: '#define\s+CRAWL_VERSION_SHORT\s+"([^"]+)"'
display_name: "Dungeon Crawl Stone Soup"
cores: [stonesoup]
systems: []
diff --git a/emulators/stratagus.yml b/emulators/stratagus.yml
index feba5891d..acf53ab08 100644
--- a/emulators/stratagus.yml
+++ b/emulators/stratagus.yml
@@ -4,7 +4,11 @@ core_classification: game_engine
source: "https://github.com/Wargus/stratagus"
profiled_date: "2026-10-20"
source_commit: "6b309817027618a12e26e4cf314ad09654495e28"
-core_version: "2.4.3"
+core_version: "v2.4.3"
+version_basis:
+ kind: tag
+ tag: "v2.4.3"
+ distance: 0
display_name: "Stratagus"
cores: [stratagus]
systems: []
diff --git a/emulators/sudachi.yml b/emulators/sudachi.yml
index 2604c85a0..5caf7834f 100644
--- a/emulators/sudachi.yml
+++ b/emulators/sudachi.yml
@@ -6,6 +6,9 @@ upstream_gone: "The emuplace GitHub account answers 404 and sudachi.emuplace.app
profiled_date: "2026-10-20"
source_commit: "c7431bd420d66c54df277ba0a0b71de42cf0a5bf"
core_version: "1.0.15"
+version_basis:
+ kind: unverified
+ why: host_gone
display_name: "Sudachi (Nintendo Switch)"
cores:
- sudachi
diff --git a/emulators/sugarbox2.yml b/emulators/sugarbox2.yml
index 7cb7651b1..d0d5db1b6 100644
--- a/emulators/sugarbox2.yml
+++ b/emulators/sugarbox2.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/Tom1975/SugarboxV2"
profiled_date: "2026-10-17"
source_commit: "6b565a43a25e4dfb69867f7e927a707e15ea460f"
core_version: "v0.9"
+version_basis:
+ kind: tag
+ tag: "v0.9"
+ distance: 0
display_name: "Amstrad - CPC / GX4000 (Sugarbox2)"
cores:
- "sugarbox2"
diff --git a/emulators/super3.yml b/emulators/super3.yml
index f2e40d0d4..1d0c01b7b 100644
--- a/emulators/super3.yml
+++ b/emulators/super3.yml
@@ -8,6 +8,10 @@ author: "izzy2lost"
profiled_date: "2026-10-27"
source_commit: "c410f82f771b33094b8c1cdbf96ef7c74ec9cff1"
core_version: "1.2.2"
+version_basis:
+ kind: tag
+ tag: "1.2.2"
+ distance: 0
display_name: "Sega - Model 3 (SUPER3)"
verification: existence
cores:
diff --git a/emulators/superbroswar.yml b/emulators/superbroswar.yml
index 974e3a722..bdefff39e 100644
--- a/emulators/superbroswar.yml
+++ b/emulators/superbroswar.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/superbroswar-libretro"
upstream: "https://github.com/mmatyas/supermariowar"
profiled_date: "2026-03-25"
source_commit: "7f94e03163e1641ecd2a5703eb9ca7e1312b5e8d"
-core_version: "v0.1"
+core_version: "0.1"
+version_basis:
+ kind: file
+ path: "libretro/libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Super Bros War"
cores: [superbroswar]
systems: []
diff --git a/emulators/supermariowar.yml b/emulators/supermariowar.yml
index 223faa533..d2ce65c99 100644
--- a/emulators/supermariowar.yml
+++ b/emulators/supermariowar.yml
@@ -6,6 +6,10 @@ profiled_date: "2026-10-22"
source_commit: "ed60ed45e296ff88a8883cbc51f5bf5aed880636"
pin_frozen: "EmuELEC, the only platform that ships it, builds this revision (EmuELEC packages/sx05re/emuelec-ports/supermariowar/package.mk:5). Later commits are not part of that build."
core_version: "2.0.0"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'project\(smw\s+VERSION\s+([0-9][^\s)]*)'
display_name: "Super Mario War (smw)"
cores:
- "smw"
diff --git a/emulators/supermodel-dojo.yml b/emulators/supermodel-dojo.yml
index 6ace9207c..2a8b3d8a5 100644
--- a/emulators/supermodel-dojo.yml
+++ b/emulators/supermodel-dojo.yml
@@ -9,6 +9,10 @@ source_commit: "113a48d567c5d49a40292df386fee4a0acaea486"
upstream_commit: "98fcbff223e23b1530c8f899aca2fc4fd5663ab4"
upstream_frozen: "The dojo branch merged trzy/Supermodel at this commit through merge ab1ecae. Later upstream commits are not in the port."
core_version: "v0.3a-20261024-git-113a48d"
+version_basis:
+ kind: tag
+ tag: "v0.3a-20261024-git-113a48d"
+ distance: 0
display_name: "Sega - Model 3 (Supermodel Dojo)"
verification: existence
cores:
diff --git a/emulators/supermodel-legacy.yml b/emulators/supermodel-legacy.yml
index 5dde17f5a..a501a3883 100644
--- a/emulators/supermodel-legacy.yml
+++ b/emulators/supermodel-legacy.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-10-17"
source_commit: "0eef09ba2b74e48fd5cfd388ee69a3743fd5f618"
pin_frozen: "Batocera's supermodel-legacy package builds this revision, held before the renderer moved to OpenGL 4.1. Recaling would repoint these refs at code this build never had."
core_version: "0.3a-WIP"
+version_basis:
+ kind: file
+ path: "Src/Version.h"
+ pattern: '#define\s+SUPERMODEL_VERSION\s+"([^"]+)'
display_name: "Sega - Model 3 (Supermodel Legacy)"
cores:
- "supermodel-legacy"
diff --git a/emulators/supermodel.yml b/emulators/supermodel.yml
index 120433931..7c5976c86 100644
--- a/emulators/supermodel.yml
+++ b/emulators/supermodel.yml
@@ -11,8 +11,17 @@ source_commit:
libretro: "84bc106b45b279bf868a53b9232897c8dc10ca17"
upstream_commit: "14815455dc28fca2c8662fb1ed99d447ecf8f364"
core_version:
- standalone: "0.3a-WIP"
- libretro: "v0.3a-libretro"
+ standalone: "v0.3a-20261025-git-1481545"
+ libretro: "0.3a-WIP"
+version_basis:
+ standalone:
+ kind: tag
+ tag: "v0.3a-20261025-git-1481545"
+ distance: 0
+ libretro:
+ kind: file
+ path: "Src/Version.h"
+ pattern: '#define\s+SUPERMODEL_VERSION\s+"([^"]+)"'
display_name: "Sega - Model 3 (Supermodel)"
cores:
- supermodel
diff --git a/emulators/supermodel3.yml b/emulators/supermodel3.yml
index d09fad631..aea86ea3a 100644
--- a/emulators/supermodel3.yml
+++ b/emulators/supermodel3.yml
@@ -10,6 +10,10 @@ prose_written_at: "11726c4bad3696a50a8b834eaba6aee3fa08a590"
upstream_commit: "14815455dc28fca2c8662fb1ed99d447ecf8f364"
upstream_frozen: "The fork is rebased on trzy's tree at 1481545, the revision its version string names. Later trzy commits are not in the fork."
core_version: "0.3a-1481545-mm DirtBagXon (Sinden)"
+version_basis:
+ kind: file
+ path: "Src/Version.h"
+ pattern: '#define\s+SUPERMODEL_VERSION\s+"([^"]+)'
display_name: "Sega - Model 3 (Supermodel, Sinden fork)"
verification: existence
cores:
diff --git a/emulators/supersnes9x.yml b/emulators/supersnes9x.yml
index 9c123eca5..bb8966d61 100644
--- a/emulators/supersnes9x.yml
+++ b/emulators/supersnes9x.yml
@@ -7,6 +7,11 @@ profiled_date: "2026-10-17"
source_commit: "691e47b40fb38fa522a5471462344a2f0206b7a8"
pin_frozen: "ROCKNIX 20261001 builds this revision. Later commits on shanytc/snes9x rework the Super Famicom Box loader and are not part of that build."
core_version: "1.63.29"
+version_basis:
+ kind: file
+ path: "snes9x.h"
+ pattern: '#define\s+VERSION\s+"([^"]+)"\s*\n#define\s+PATCH_VERSION\s+"([^"]+)"'
+ format: "{1}.{2}"
display_name: "Nintendo - SNES / SFC / SGB / GB / GBC (SuperSnes9x)"
cores:
- "supersnes9x"
diff --git a/emulators/supertux.yml b/emulators/supertux.yml
index eb42c76dd..c05f1b9d6 100644
--- a/emulators/supertux.yml
+++ b/emulators/supertux.yml
@@ -5,7 +5,12 @@ source: "https://github.com/SuperTux/supertux"
profiled_date: "2026-10-22"
source_commit: "37cddf24a4abfdec7931a84afd9e87fb8ea18767"
pin_frozen: "EmuELEC, the only platform that ships it, builds this revision, v0.6.3 (EmuELEC packages/sx05re/emuelec-ports/supertux/package.mk:5). Later commits are not part of that build."
-core_version: "0.6.3-1020-g37cddf24a"
+core_version: "v0.6.3-1020-g37cddf2"
+version_basis:
+ kind: tag
+ match: "?[0-9]*.[0-9]*.[0-9]*"
+ tag: "v0.6.3"
+ distance: 1020
display_name: "SuperTux (supertux2)"
cores:
- "supertux2"
diff --git a/emulators/supertuxkart.yml b/emulators/supertuxkart.yml
index 5701311e4..9fe306c0f 100644
--- a/emulators/supertuxkart.yml
+++ b/emulators/supertuxkart.yml
@@ -5,7 +5,11 @@ source: "https://github.com/supertuxkart/stk-code"
profiled_date: "2026-10-22"
source_commit: "fab79150111944c9e33396db68eb5de84f2efeaa"
pin_frozen: "EmuELEC, the only platform that ships it, builds this revision (EmuELEC packages/sx05re/emuelec-ports/supertuxkart/package.mk:5). Later commits are not part of that build."
-core_version: "git"
+core_version: "preview-582-gfab7915"
+version_basis:
+ kind: tag
+ tag: "preview"
+ distance: 582
display_name: "SuperTuxKart"
cores:
- "supertuxkart"
diff --git a/emulators/superzsnes.yml b/emulators/superzsnes.yml
index 0a60fad84..622381f47 100644
--- a/emulators/superzsnes.yml
+++ b/emulators/superzsnes.yml
@@ -8,6 +8,11 @@ version_source:
pattern: 'Latest Release Notes \((v[0-9.]+[a-z]?)\)'
profiled_date: "2026-10-20"
core_version: "v0.320"
+version_basis:
+ kind: package
+ url: "https://www.zsnes.com/files/SuperZSNES_v0.320.zip"
+ pattern: 'SuperZSNES_(v\d+\.\d+)\.zip'
+ artifact: "SuperZSNES_v0.320.zip"
display_name: "Nintendo - SNES / SFC (SUPER ZSNES)"
cores:
- superzsnes
diff --git a/emulators/suyu.yml b/emulators/suyu.yml
index b5fe2f853..e95834d14 100644
--- a/emulators/suyu.yml
+++ b/emulators/suyu.yml
@@ -6,6 +6,9 @@ upstream: "https://github.com/suyu-emu/suyu"
profiled_date: "2026-03-26"
upstream_gone: "GitHub withdrew the repository for legal reasons: the API answers HTTP 451. git.suyu.dev still serves a Forgejo page, but its API answers 403 and its raw paths 404, so it carries no readable tree. Observed 2026-09-04."
core_version: "Yuzu EA 4176 based"
+version_basis:
+ kind: unverified
+ why: host_gone
display_name: "Suyu (Nintendo Switch)"
cores:
- suyu
diff --git a/emulators/swan-emu.yml b/emulators/swan-emu.yml
index 74f8823d6..16c1187ef 100644
--- a/emulators/swan-emu.yml
+++ b/emulators/swan-emu.yml
@@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/"
profiled_date: "2026-08-12"
source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943"
core_version: "1.5.85"
+version_basis:
+ kind: file
+ path: "EmuFramework/metadata/conf.mk"
+ pattern: '^metadata_version\s*=\s*(\S+)'
display_name: "Bandai - WonderSwan / Color (Swan.emu)"
mode: standalone
cores:
diff --git a/emulators/swanstation.yml b/emulators/swanstation.yml
index 5e330f97b..c1cee9c84 100644
--- a/emulators/swanstation.yml
+++ b/emulators/swanstation.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/stenzek/duckstation"
profiled_date: "2026-03-25"
source_commit: "631f0fb31b809e0f889d7c551fe695788e39c1d3"
upstream_commit: "526b4a128827ecaef037134095cd95f431b52d06"
-core_version: "v0.1"
+core_version: "1.0.0"
+version_basis:
+ kind: file
+ path: "src/libretro/libretro_host_interface.cpp"
+ pattern: 'library_version\s*=\s*"([0-9][^" ]*)'
display_name: "Sony - PlayStation (SwanStation)"
cores: [swanstation]
found_by: content
diff --git a/emulators/swf-player.yml b/emulators/swf-player.yml
index 3fcc56951..f71f6d1d2 100644
--- a/emulators/swf-player.yml
+++ b/emulators/swf-player.yml
@@ -9,6 +9,10 @@ version_source:
author: "issess.net"
profiled_date: "2026-08-12"
core_version: "1.90 free (build 507)"
+version_basis:
+ kind: package
+ url: "https://play.google.com/store/apps/details?id=com.issess.flashplayer"
+ artifact: "com.issess.flashplayer APK versionName (versionCode 507)"
display_name: "Adobe Flash (SWF Player)"
mode: standalone
cores:
diff --git a/emulators/syobonaction.yml b/emulators/syobonaction.yml
index 5b64195e9..0df4bdace 100644
--- a/emulators/syobonaction.yml
+++ b/emulators/syobonaction.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "792c40cb590bcc7286cf720b5fb85807082b534e"
upstream_commit: "77ca2c128784512eca10e24a453ca19c270a8af4"
core_version: "0.3"
+version_basis:
+ kind: file
+ path: "libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Syobon Action"
cores: [syobonaction]
systems: []
diff --git a/emulators/tamalibretro.yml b/emulators/tamalibretro.yml
index 3447ff1c9..39d5d7dad 100644
--- a/emulators/tamalibretro.yml
+++ b/emulators/tamalibretro.yml
@@ -6,7 +6,9 @@ upstream: "https://github.com/jcrona/tamalib"
source_ref: "main.c:345-365 (retro_load_game)"
profiled_date: "2026-03-25"
source_commit: "ea8dd612eaab375a04f976149dc693cb5d208b27"
-core_version: "1.0"
+core_version: null
+version_basis:
+ kind: none
display_name: "Bandai - Tamagotchi P1 (TamaLIBretro)"
cores: [tamalibretro]
systems: [tamagotchi-p1]
diff --git a/emulators/taradino.yml b/emulators/taradino.yml
index 58640d580..be8621129 100644
--- a/emulators/taradino.yml
+++ b/emulators/taradino.yml
@@ -5,7 +5,11 @@ source: "https://github.com/fabiangreffrath/taradino"
upstream: "https://github.com/fabiangreffrath/taradino"
profiled_date: "2026-10-04"
source_commit: "2b4ea3c6c18caf60959efee1f2534c2122607716"
-core_version: "0.1"
+core_version: "2025.12.22"
+version_basis:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: 'project\(taradino[^)]*?\bVERSION\s+([0-9][0-9.]*)'
display_name: "Taradino"
cores: [taradino]
systems: [rott]
diff --git a/emulators/teknoparrot.yml b/emulators/teknoparrot.yml
index 7403d1a51..2b2f4c0de 100644
--- a/emulators/teknoparrot.yml
+++ b/emulators/teknoparrot.yml
@@ -7,6 +7,9 @@ author: "TeknoGods"
profiled_date: "2026-10-20"
source_commit: "b75872addc1d5cce81a62f4c1468eab59e2d3cbe"
core_version: "TeknoParrotUI 7ffcdf1"
+version_basis:
+ kind: unverified
+ why: host_gone
display_name: "Arcade - PC-based and Sega/Namco boards (TeknoParrot)"
cores:
- teknoparrot
diff --git a/emulators/tempgba.yml b/emulators/tempgba.yml
index 92386533e..719fdd3cb 100644
--- a/emulators/tempgba.yml
+++ b/emulators/tempgba.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/Nebuleon/ReGBA"
profiled_date: "2026-03-25"
source_commit: "b0c2652797a4c11f3edd30f9e59a77a5b88ce5fb"
core_version: "26731013"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Nintendo - Game Boy Advance (TempGBA)"
cores:
- tempgba
diff --git a/emulators/test.yml b/emulators/test.yml
index 29b8b9fff..7aa106683 100644
--- a/emulators/test.yml
+++ b/emulators/test.yml
@@ -4,7 +4,12 @@ core_classification: pure_libretro
source: "https://github.com/libretro/libretro-samples"
upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
+source_commit: "bce193bc1b8c9a3da43b2ead0158a69e28b37ed8"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "tests/test/libretro-test.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "Test"
cores:
- test
diff --git a/emulators/test_netplay.yml b/emulators/test_netplay.yml
index d671919d7..3319e5084 100644
--- a/emulators/test_netplay.yml
+++ b/emulators/test_netplay.yml
@@ -3,6 +3,9 @@ type: libretro
core_classification: pure_libretro
profiled_date: "2026-03-25"
core_version: "1"
+version_basis:
+ kind: unverified
+ why: no_source
display_name: "Test for netplay"
cores:
- test_netplay
diff --git a/emulators/testaudio_callback.yml b/emulators/testaudio_callback.yml
index 776436799..7cf4184c5 100644
--- a/emulators/testaudio_callback.yml
+++ b/emulators/testaudio_callback.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "audio/audio_callback/libretro-test.c"
+ pattern: 'library_version\s*=\s*"([^"]+)"'
display_name: "TestAudio Callback"
cores:
- testaudio_callback
diff --git a/emulators/testaudio_no_callback.yml b/emulators/testaudio_no_callback.yml
index 1141391a6..dd4796975 100644
--- a/emulators/testaudio_no_callback.yml
+++ b/emulators/testaudio_no_callback.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "audio/audio_no_callback/libretro-test.c"
+ pattern: 'library_version\s*=\s*"([^"]+)"'
display_name: "TestAudio NoCallback"
cores:
- testaudio_no_callback
diff --git a/emulators/testaudio_playback_wav.yml b/emulators/testaudio_playback_wav.yml
index 187ed91e3..e5cfcf963 100644
--- a/emulators/testaudio_playback_wav.yml
+++ b/emulators/testaudio_playback_wav.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "audio/audio_playback_wav/libretro-test.c"
+ pattern: 'retro_system_info\s+myinfo\s*=\s*\{\s*"[^"]*",\s*"([^"]+)"'
display_name: "TestAudio Playback Wav"
cores:
- testaudio_playback_wav
diff --git a/emulators/testgl.yml b/emulators/testgl.yml
index ec10faf45..2c0d9a58c 100644
--- a/emulators/testgl.yml
+++ b/emulators/testgl.yml
@@ -4,7 +4,12 @@ core_classification: pure_libretro
source: "https://github.com/libretro/libretro-samples"
upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
+source_commit: "bce193bc1b8c9a3da43b2ead0158a69e28b37ed8"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "video/opengl/libretro_test_gl_shaders/libretro_gl_test.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "TestGL"
cores:
- testgl
diff --git a/emulators/testgl_compute_shaders.yml b/emulators/testgl_compute_shaders.yml
index 30b823139..60db79d38 100644
--- a/emulators/testgl_compute_shaders.yml
+++ b/emulators/testgl_compute_shaders.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "video/opengl/libretro_test_gl_compute_shaders/app/boxes.cpp"
+ pattern: 'info\.library_version\s*=\s*"([^"]+)"'
display_name: "TestGL ComputeShaders"
cores:
- testgl_compute_shaders
diff --git a/emulators/testgl_ff.yml b/emulators/testgl_ff.yml
index 21ad2d7f9..889d7fc7c 100644
--- a/emulators/testgl_ff.yml
+++ b/emulators/testgl_ff.yml
@@ -7,6 +7,10 @@ source_ref: "video/opengl/libretro_test_gl_fixedfunction/libretro_gl_ff_test.c"
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "video/opengl/libretro_test_gl_fixedfunction/libretro_gl_ff_test.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "TestGL (FF)"
cores:
- testgl_ff
diff --git a/emulators/testinput_buttontest.yml b/emulators/testinput_buttontest.yml
index 2be09c874..5c060eb7e 100644
--- a/emulators/testinput_buttontest.yml
+++ b/emulators/testinput_buttontest.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/libretro-samples"
upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
-core_version: "v1"
+core_version: "0.01"
+version_basis:
+ kind: file
+ path: "input/button_test/libretro.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "Button Test"
cores:
- testinput_buttontest
diff --git a/emulators/testretroluxury.yml b/emulators/testretroluxury.yml
index 99e3da0c7..4a6a91e18 100644
--- a/emulators/testretroluxury.yml
+++ b/emulators/testretroluxury.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/retroluxury"
upstream: "https://github.com/leiradel/retroluxury"
profiled_date: "2026-03-25"
source_commit: "fdcd94fe4099735edcbea638760da3f2297bc520"
-core_version: "v1"
+core_version: "0.1"
+version_basis:
+ kind: file
+ path: "test/libretro.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "Test RetroLuxury"
cores:
- testretroluxury
diff --git a/emulators/testsw.yml b/emulators/testsw.yml
index 672062ada..b80427c05 100644
--- a/emulators/testsw.yml
+++ b/emulators/testsw.yml
@@ -7,6 +7,10 @@ upstream: null
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "video/software/rendering/libretro-test.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "TestSW"
cores:
- testsw
diff --git a/emulators/testsw_vram.yml b/emulators/testsw_vram.yml
index be0836cd2..dd461b1a9 100644
--- a/emulators/testsw_vram.yml
+++ b/emulators/testsw_vram.yml
@@ -7,6 +7,10 @@ source_ref: "video/software/rendering_direct_to_vram/libretro-test.c"
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "video/software/rendering_direct_to_vram/libretro-test.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "TestSW VRAM"
cores:
- testsw_vram
diff --git a/emulators/testvulkan.yml b/emulators/testvulkan.yml
index 66fbcf55e..bdfa9d66c 100644
--- a/emulators/testvulkan.yml
+++ b/emulators/testvulkan.yml
@@ -4,7 +4,12 @@ core_classification: pure_libretro
source: "https://github.com/libretro/libretro-samples"
upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
+source_commit: "bce193bc1b8c9a3da43b2ead0158a69e28b37ed8"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "video/vulkan/vk_rendering/libretro-test.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "TestVulkan"
cores:
- testvulkan
diff --git a/emulators/testvulkan_async_compute.yml b/emulators/testvulkan_async_compute.yml
index bf169fa3a..fcf630031 100644
--- a/emulators/testvulkan_async_compute.yml
+++ b/emulators/testvulkan_async_compute.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/libretro-samples"
profiled_date: "2026-03-25"
source_commit: "7c2ad112d7a5176de2191e473369668da8946b16"
core_version: "v1"
+version_basis:
+ kind: file
+ path: "video/vulkan/vk_async_compute/libretro-test.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "TestVulkan AsyncCompute"
cores:
- testvulkan_async_compute
diff --git a/emulators/tgbdual.yml b/emulators/tgbdual.yml
index 4e1e64299..42c28bc58 100644
--- a/emulators/tgbdual.yml
+++ b/emulators/tgbdual.yml
@@ -6,6 +6,10 @@ upstream: "http://gigo.retrogames.com/download.html#tgb-dual"
profiled_date: "2026-03-25"
source_commit: "0392c9c469e653205e471114c7949c07c83bfce9"
core_version: "v0.8.3"
+version_basis:
+ kind: file
+ path: "libretro/libretro.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Nintendo - Game Boy / Color (TGB Dual)"
cores:
- TGB Dual
diff --git a/emulators/theforceengine.yml b/emulators/theforceengine.yml
index 2b3838da9..073e34f48 100644
--- a/emulators/theforceengine.yml
+++ b/emulators/theforceengine.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/TheForceEngine/TheForceEngine"
profiled_date: "2026-10-04"
source_commit: "cc25205cc94c36a396f6f50295772f6ec83069a2"
core_version: "v1.22.420"
+version_basis:
+ kind: tag
+ tag: "v1.22.420"
+ distance: 0
display_name: "The Force Engine"
cores: [theforceengine, tfe]
systems: [dark-forces, outlaws]
diff --git a/emulators/theodore.yml b/emulators/theodore.yml
index b89062f77..7fa061199 100644
--- a/emulators/theodore.yml
+++ b/emulators/theodore.yml
@@ -5,7 +5,11 @@ source: "https://github.com/Zlika/theodore"
upstream: "http://dcmoto.free.fr/"
profiled_date: "2026-03-25"
source_commit: "4d469ce0f71ee046ceb78cdbf8e9f18364aaa918"
-core_version: "Git (post-3.1)"
+core_version: "v3.2-9-g4d469ce"
+version_basis:
+ kind: tag
+ tag: "v3.2"
+ distance: 9
display_name: "Thomson - MO/TO (Theodore)"
cores: [theodore]
systems: [thomson-to7, thomson-to770, thomson-to8, thomson-to8d, thomson-to9, thomson-to9plus, thomson-mo5, thomson-mo6, olivetti-pc128]
diff --git a/emulators/thepowdertoy.yml b/emulators/thepowdertoy.yml
index b1873d68f..3d61c3356 100644
--- a/emulators/thepowdertoy.yml
+++ b/emulators/thepowdertoy.yml
@@ -5,7 +5,12 @@ source: "https://github.com/libretro/ThePowderToy"
upstream: "https://github.com/The-Powder-Toy/The-Powder-Toy"
profiled_date: "2026-03-25"
source_commit: "bb2d9f6623d2ccf25a0021045af9591c8a0bbaff"
-core_version: "v92.5.336"
+core_version: "92.5.336"
+version_basis:
+ kind: file
+ path: "src/Config.h"
+ pattern: '#define SAVE_VERSION (\d+)[\s\S]*?#define MINOR_VERSION (\d+)[\s\S]*?#define BUILD_NUM (\d+)'
+ format: "{1}.{2}.{3}"
display_name: "The Powder Toy"
cores: [thepowdertoy]
systems: []
diff --git a/emulators/thextech.yml b/emulators/thextech.yml
index 185e99ef1..5a7844b7a 100644
--- a/emulators/thextech.yml
+++ b/emulators/thextech.yml
@@ -5,6 +5,11 @@ source: "https://github.com/TheXTech/TheXTech"
upstream: "https://github.com/TheXTech/TheXTech"
profiled_date: "2026-10-04"
source_commit: "e07c5bfb92377d315d0ab33177092fa7cadeb84e"
+core_version: "1.8-dev"
+version_basis:
+ kind: file
+ path: "version.cmake"
+ pattern: 'set\(THEXTECH_ANDROID_VERSION_NAME "([^"]+)"\)'
display_name: "TheXTech"
cores: []
systems: [smbx]
diff --git a/emulators/ti99sim-sdl1.yml b/emulators/ti99sim-sdl1.yml
index c1219c58d..563f92548 100644
--- a/emulators/ti99sim-sdl1.yml
+++ b/emulators/ti99sim-sdl1.yml
@@ -6,6 +6,11 @@ source: "https://files.retropie.org.uk/archives/ti99sim-0.15.0.src.tar.gz"
upstream: "https://www.mrousseau.org/programs/ti99sim/"
profiled_date: "2026-10-20"
core_version: "0.15.0"
+version_basis:
+ kind: package
+ url: "https://files.retropie.org.uk/archives/ti99sim-0.15.0.src.tar.gz"
+ pattern: 'ti99sim-(\d+\.\d+\.\d+)\.src\.tar\.gz'
+ artifact: "ti99sim-0.15.0.src.tar.gz"
cores:
- ti99sim-sdl1
systems:
diff --git a/emulators/ti99sim.yml b/emulators/ti99sim.yml
index a2f391be6..1d6d11c6b 100644
--- a/emulators/ti99sim.yml
+++ b/emulators/ti99sim.yml
@@ -5,7 +5,9 @@ source: https://github.com/christianhaitian/ti99sim
upstream: https://www.mrousseau.org/programs/ti99sim/
profiled_date: "2026-03-29"
source_commit: "e3608541943b5258afe45b74e3f78e723df1fcbd"
-core_version: "0.16.0"
+core_version: null
+version_basis:
+ kind: none
cores:
- ti99sim
diff --git a/emulators/tia.yml b/emulators/tia.yml
index 04289ad16..04570070a 100644
--- a/emulators/tia.yml
+++ b/emulators/tia.yml
@@ -5,7 +5,9 @@ source: "https://github.com/warmenhoven/tia"
upstream: "https://github.com/warmenhoven/tia"
profiled_date: "2026-09-03"
source_commit: "6ae5bf998fe7a7842294089db17fc86fd73db1f0"
-core_version: "52+6ae5bf9"
+core_version: null
+version_basis:
+ kind: none
display_name: "Atari - 2600 (Tia)"
cores:
- tia
diff --git a/emulators/tic80.yml b/emulators/tic80.yml
index 3f2491049..6252f0505 100644
--- a/emulators/tic80.yml
+++ b/emulators/tic80.yml
@@ -6,7 +6,13 @@ upstream: "https://github.com/nesbox/TIC-80"
profiled_date: "2026-03-25"
source_commit: "14fd99edc29962778bc88d81773288ffc6e8aa53"
upstream_commit: "ae1195a9d0fbda243bf0ebd861d6876d072a979e"
-core_version: "0.80.0"
+core_version: "1.2.0-dev"
+version_basis:
+ kind: file
+ repo: upstream
+ path: "cmake/version.cmake"
+ pattern: 'set\(VERSION_MAJOR (\d+)\)\s*set\(VERSION_MINOR (\d+)\)\s*set\(VERSION_REVISION (\d+)\)\s*set\(VERSION_STATUS "([^"]*)"\)'
+ format: "{1}.{2}.{3}{4}"
display_name: "TIC-80"
cores:
- tic80
diff --git a/emulators/touchhle.yml b/emulators/touchhle.yml
index 9c03a91ba..d588e5190 100644
--- a/emulators/touchhle.yml
+++ b/emulators/touchhle.yml
@@ -6,6 +6,10 @@ profiled_date: "2026-10-17"
source_commit: "3c5850585ba55615b17a58c58331b6d6f52d4a9d"
pin_frozen: "ROCKNIX 20261001 builds this revision. Later upstream commits are not part of that build."
core_version: "0.2.3"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "Apple - iPhone OS (touchHLE)"
cores:
- "touchhle"
diff --git a/emulators/tr1x.yml b/emulators/tr1x.yml
index eb91cfa9d..f39b7237e 100644
--- a/emulators/tr1x.yml
+++ b/emulators/tr1x.yml
@@ -6,6 +6,12 @@ source: "https://github.com/LostArtefacts/TRX"
upstream: "https://github.com/LostArtefacts/TRX"
source_commit: "4d55f7d3130f9d04bdc1fefb85bf4d8fa93a22d3"
upstream_commit: "3d39911a1b839c753b6efad236db67d9fb2b273e"
+core_version: "trx-1.11.1-784-g4d55f7d"
+version_basis:
+ kind: tag
+ match: "trx-*"
+ tag: "trx-1.11.1"
+ distance: 784
profiled_date: "2026-10-04"
cores: []
systems:
diff --git a/emulators/tr2x.yml b/emulators/tr2x.yml
index d11bd7717..93fb7238a 100644
--- a/emulators/tr2x.yml
+++ b/emulators/tr2x.yml
@@ -6,6 +6,12 @@ source: "https://github.com/LostArtefacts/TRX"
upstream: "https://github.com/LostArtefacts/TRX"
source_commit: "4d55f7d3130f9d04bdc1fefb85bf4d8fa93a22d3"
upstream_commit: "3d39911a1b839c753b6efad236db67d9fb2b273e"
+core_version: "trx-1.11.1-784-g4d55f7d"
+version_basis:
+ kind: tag
+ match: "trx-*"
+ tag: "trx-1.11.1"
+ distance: 784
profiled_date: "2026-10-23"
cores: []
systems:
diff --git a/emulators/trident.yml b/emulators/trident.yml
index 8c726b18d..63b6d0e49 100644
--- a/emulators/trident.yml
+++ b/emulators/trident.yml
@@ -7,7 +7,12 @@ upstream: https://github.com/DanAlexMorton/Panda3DS
profiled_date: "2026-03-25"
source_commit: "b97b0befa326105e181838bc6fa11c546de8294d"
upstream_commit: "69095ee6d68d21eacac8d1517f0b7b4ed27884d3"
-core_version: git
+core_version: "v0.9-202-g69095ee"
+version_basis:
+ kind: tag
+ repo: upstream
+ tag: "v0.9"
+ distance: 202
library_name: "Trident"
cores:
diff --git a/emulators/triforce.yml b/emulators/triforce.yml
index 2e1f032a7..f5545d352 100644
--- a/emulators/triforce.yml
+++ b/emulators/triforce.yml
@@ -7,6 +7,11 @@ profiled_date: "2026-08-08"
source_commit: "5c456e8ff0da0235be2cf1d55ba7bb2115b0cae2"
upstream_commit: "b92e354389bb7c0bd114a8631b8af110d3cb3a14"
core_version: "2407"
+version_basis:
+ kind: tag
+ repo: upstream
+ tag: "2407"
+ distance: 0
display_name: "Namco / Sega / Nintendo - Triforce (Dolphin Triforce)"
cores:
- triforce
diff --git a/emulators/tsugaru.yml b/emulators/tsugaru.yml
index f66e7c981..b42da7acf 100644
--- a/emulators/tsugaru.yml
+++ b/emulators/tsugaru.yml
@@ -5,6 +5,11 @@ source: "https://github.com/captainys/TOWNSEMU"
upstream: "https://github.com/captainys/TOWNSEMU"
profiled_date: "2026-03-26"
source_commit: "e27adde120fe06150f3d57a6dc7cba66b5f43a32"
+core_version: "v20260522-179-ge27adde"
+version_basis:
+ kind: tag
+ tag: "v20260522"
+ distance: 179
cores:
- tsugaru
systems:
diff --git a/emulators/tyrquake.yml b/emulators/tyrquake.yml
index 11f05a77d..9d60df008 100644
--- a/emulators/tyrquake.yml
+++ b/emulators/tyrquake.yml
@@ -6,6 +6,11 @@ upstream: "http://disenchant.net/tyrquake/"
profiled_date: "2026-03-25"
source_commit: "67db338ff4d27fdbcead61d4e54938d758ae62cb"
core_version: "v0.62"
+version_basis:
+ kind: file
+ path: "Makefile"
+ pattern: "-DTYR_VERSION=([0-9.]+)"
+ format: "v{1}"
display_name: "Quake (TyrQuake)"
cores: [tyrquake]
systems: [quake]
diff --git a/emulators/uae4all-rpi.yml b/emulators/uae4all-rpi.yml
index 50af2fddd..cae0f03ae 100644
--- a/emulators/uae4all-rpi.yml
+++ b/emulators/uae4all-rpi.yml
@@ -7,6 +7,10 @@ source: "https://github.com/Chips-fr/uae4all-rpi"
profiled_date: "2026-10-20"
source_commit: "ae7eb74cfbe726a45f5b791484948676dcba1aef"
core_version: "0.7"
+version_basis:
+ kind: file
+ path: "libretro/core/libretro-core.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Commodore - Amiga (UAE4ALL)"
systems:
- commodore-amiga
diff --git a/emulators/uae4all.yml b/emulators/uae4all.yml
index dc5adad66..739c6bd9f 100644
--- a/emulators/uae4all.yml
+++ b/emulators/uae4all.yml
@@ -8,6 +8,10 @@ profiled_date: "2026-10-20"
source_commit: "5c514dd6d8bb374bd22e2ee423bad99539207cbd"
upstream_commit: "0f919a32b6d41a04cbc766c0e6076cda9fa7a63b"
core_version: "2.3.6.2"
+version_basis:
+ kind: file
+ path: "AndroidAppSettings.cfg"
+ pattern: '^AppVersionName="([^"]+)"'
display_name: "Commodore - Amiga (UAE4All2)"
cores: [uae4all]
systems: [commodore-amiga]
diff --git a/emulators/uae4arm.yml b/emulators/uae4arm.yml
index bdd5ec8f5..ed7a4b19f 100644
--- a/emulators/uae4arm.yml
+++ b/emulators/uae4arm.yml
@@ -7,7 +7,11 @@ upstream: "https://github.com/PandTomB/uae4arm"
profiled_date: "2026-03-25"
source_commit: "26f444f01771f0dfa2fa652906ff8222e92e476e"
upstream_commit: "161ddbaafe90a9747e9fb9c65ee4f38c163f335e"
-core_version: "v0.5"
+core_version: "0.5"
+version_basis:
+ kind: file
+ path: "libretro/core/libretro-core.cpp"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Commodore - Amiga (UAE4ARM)"
systems:
- commodore-amiga
diff --git a/emulators/ume2015.yml b/emulators/ume2015.yml
index 70c94b7f9..20944e596 100644
--- a/emulators/ume2015.yml
+++ b/emulators/ume2015.yml
@@ -8,6 +8,10 @@ profiled_date: "2026-03-25"
source_commit: "7adbf440d5f554097f58f8c6ebb673adc4320a31"
upstream_commit: "6ae44e46f91aa2bd74d9895e6bfd345baebd3d22"
core_version: "0.159"
+version_basis:
+ kind: file
+ path: "src/version.c"
+ pattern: '#define\s+BARE_BUILD_VERSION\s+"([^"]+)'
display_name: "Multi (UME 2015)"
mame_version: "0.159"
diff --git a/emulators/uqm.yml b/emulators/uqm.yml
index 42de16372..a632b9547 100644
--- a/emulators/uqm.yml
+++ b/emulators/uqm.yml
@@ -6,6 +6,11 @@ upstream: "https://sourceforge.net/projects/sc2/"
profiled_date: "2026-10-04"
source_commit: "ff2444088d42e80612fd28f79d368783e244a606"
core_version: "0.8.0"
+version_basis:
+ kind: file
+ path: "sc2/src/uqmversion.h"
+ pattern: '#define UQM_MAJOR_VERSION_S\s+"(\d+)"[\s\S]*?#define UQM_MINOR_VERSION_S\s+"(\d+)"[\s\S]*?#define UQM_PATCH_VERSION_S\s+"(\d+)"[\s\S]*?#define UQM_EXTRA_VERSION\s+"([^"]*)"'
+ format: "{1}.{2}.{3}{4}"
display_name: "The Ur-Quan Masters"
cores: [uqm]
systems: [ur-quan-masters, star-control-2]
diff --git a/emulators/uw8.yml b/emulators/uw8.yml
index 60f5276c4..f86c9c5b6 100644
--- a/emulators/uw8.yml
+++ b/emulators/uw8.yml
@@ -2,6 +2,10 @@ emulator: MicroW8
profiled_date: "2026-03-25"
source_commit: "1a64f4bde9662cb3dc04214d2b9f8ead19c85f0a"
core_version: "0.2.2"
+version_basis:
+ kind: file
+ path: "uw8.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "MicroW8"
type: libretro
core_classification: pure_libretro
diff --git a/emulators/uxn.yml b/emulators/uxn.yml
index 506d3f3ab..0a4c28fc8 100644
--- a/emulators/uxn.yml
+++ b/emulators/uxn.yml
@@ -5,7 +5,11 @@ source: "https://codeberg.org/iyzsong/uxn-libretro"
upstream: "https://github.com/chmod222/zuxn"
profiled_date: "2026-03-25"
source_commit: "2037b34ee0bf5f3914c38e0aafd145ece170b0f6"
-core_version: "0.0.1"
+core_version: "0.0.2"
+version_basis:
+ kind: file
+ path: "build.zig.zon"
+ pattern: '^\s*\.version\s*=\s*"([^"]+)"'
display_name: "Uxn / Varvara (zuxn)"
cores: [uxn]
systems: []
diff --git a/emulators/uzdoom.yml b/emulators/uzdoom.yml
index 9fce74fa3..3d44c59b1 100644
--- a/emulators/uzdoom.yml
+++ b/emulators/uzdoom.yml
@@ -5,6 +5,10 @@ source: "https://github.com/UZDoom/UZDoom"
profiled_date: "2026-10-20"
source_commit: "7910df7102033fd7754a317f3254f24101135926"
core_version: "5.0.3"
+version_basis:
+ kind: tag
+ tag: "5.0.3"
+ distance: 0
display_name: "UZDoom"
cores:
- uzdoom
diff --git a/emulators/uzem.yml b/emulators/uzem.yml
index 38b5409a2..490c4dce5 100644
--- a/emulators/uzem.yml
+++ b/emulators/uzem.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-03-25"
source_commit: "d991ee94547c8294abc1c4cb73d63116aa58b5bc"
upstream_commit: "eb02e5f69212983e778a2ca87b198f22071303b8"
core_version: "v2.0"
+version_basis:
+ kind: file
+ path: "uzem.h"
+ pattern: '#define\s+VERSION\s+"([^"]+)'
display_name: "Uzebox (Uzem)"
cores:
- uzem
diff --git a/emulators/vaporspec.yml b/emulators/vaporspec.yml
index 7f4599af5..f68f0ca9f 100644
--- a/emulators/vaporspec.yml
+++ b/emulators/vaporspec.yml
@@ -5,7 +5,11 @@ source: "https://github.com/minkcv/vm"
upstream: "https://github.com/minkcv/vm"
profiled_date: "2026-03-25"
source_commit: "11f63848a592644722ea1240b4b96785db9b009f"
-core_version: "GIT"
+core_version: "1.0"
+version_basis:
+ kind: file
+ path: "machine/libretro.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "VaporSpec"
cores: [vaporspec]
systems: [vaporspec]
diff --git a/emulators/vba_m.yml b/emulators/vba_m.yml
index 921b7ebd3..61ee98ff9 100644
--- a/emulators/vba_m.yml
+++ b/emulators/vba_m.yml
@@ -1,5 +1,9 @@
emulator: VBA-M
type: alias
alias_of: vbam
+core_version: "2.1.3"
+version_basis:
+ kind: alias
+ of: "vbam"
note: Legacy profile key retained for platform and buildbot compatibility.
files: []
diff --git a/emulators/vba_next.yml b/emulators/vba_next.yml
index 1c7306f2c..be3b6a07a 100644
--- a/emulators/vba_next.yml
+++ b/emulators/vba_next.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/visualboyadvance-m/visualboyadvance-m"
upstream_commit: "46cc9f6bda114737e99304a4bd8427c038e63084"
profiled_date: "2026-03-25"
source_commit: "788192f215ad0a1413f1625b40ebba3423fa0ade"
-core_version: "SVN"
+core_version: "v1.0.2"
+version_basis:
+ kind: file
+ path: "libretro/libretro.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "Nintendo - Game Boy Advance (VBA Next)"
cores:
- vba_next
diff --git a/emulators/vbam.yml b/emulators/vbam.yml
index 3ff472c34..bc7d0ec5d 100644
--- a/emulators/vbam.yml
+++ b/emulators/vbam.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/visualboyadvance-m/visualboyadvance-m"
profiled_date: "2026-03-25"
source_commit: "115defb3a318258ab84746d45258a1aec19d0b4b"
upstream_commit: "46cc9f6bda114737e99304a4bd8427c038e63084"
-core_version: "2.1.4"
+core_version: "2.1.3"
+version_basis:
+ kind: file
+ path: "CHANGELOG.md"
+ pattern: '^## +\[([0-9][^\]]*)\]'
display_name: "Nintendo - Game Boy Advance (VBA-M)"
cores:
- vbam
diff --git a/emulators/vecx.yml b/emulators/vecx.yml
index c00e7cf7a..baaec255f 100644
--- a/emulators/vecx.yml
+++ b/emulators/vecx.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/libretro-vecx"
upstream: "https://github.com/jhawthorn/vecx"
profiled_date: "2026-03-25"
source_commit: "dccff5402a16d1ea7c112095a8e42632bf149506"
-core_version: "SVN"
+core_version: "1.2"
+version_basis:
+ kind: file
+ path: "libretro.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "GCE - Vectrex (vecx)"
cores:
- vecx
diff --git a/emulators/veesem.yml b/emulators/veesem.yml
index 57abbf5ef..d97a725a5 100644
--- a/emulators/veesem.yml
+++ b/emulators/veesem.yml
@@ -5,7 +5,11 @@ source: "https://github.com/sp1187/veesem"
upstream: "https://github.com/sp1187/veesem"
profiled_date: "2026-08-08"
source_commit: "42fb8f07e7418f16ccfb7364127aef2217dda641"
-core_version: "0.2"
+core_version: "v0.2"
+version_basis:
+ kind: tag
+ tag: "v0.2"
+ distance: 0
display_name: "VTech - V.Smile (veesem)"
cores:
- veesem
diff --git a/emulators/vemulator.yml b/emulators/vemulator.yml
index dee7a02d7..781b6c3cc 100644
--- a/emulators/vemulator.yml
+++ b/emulators/vemulator.yml
@@ -6,6 +6,10 @@ upstream: "closed-source Android app (removed from Google Play ~2020)"
profiled_date: "2026-03-25"
source_commit: "27a062f6ae532e5028e4fb54f523cc689e78146a"
core_version: "0.1"
+version_basis:
+ kind: file
+ path: "main.cpp"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "VeMUlator"
cores:
- vemulator
diff --git a/emulators/vice-standalone.yml b/emulators/vice-standalone.yml
index cacd11daf..612ac8f1f 100644
--- a/emulators/vice-standalone.yml
+++ b/emulators/vice-standalone.yml
@@ -8,6 +8,11 @@ profiled_date: "2026-10-24"
source_commit: "81546e60c222fe4aef750b890b0dbe3a5ac280d8"
pin_frozen: "ROCKNIX, the only platform that ships it, builds this revision (ROCKNIX projects/ROCKNIX/packages/emulators/standalone/vice-sa/package.mk:5). Later commits are not part of that build."
core_version: "3.8"
+version_basis:
+ kind: file
+ path: "vice/configure.ac"
+ pattern: 'm4_define\(vice_version_major, (\d+)\)\s*m4_define\(vice_version_minor, (\d+)\)\s*m4_define\(vice_version_build, 0\)'
+ format: "{1}.{2}"
display_name: "Commodore - 8-bit computers (VICE SDL2)"
mode: standalone
cores:
diff --git a/emulators/vice.yml b/emulators/vice.yml
index 3c13c4c44..891baeef5 100644
--- a/emulators/vice.yml
+++ b/emulators/vice.yml
@@ -15,7 +15,11 @@ source: "https://github.com/libretro/vice-libretro"
logo: "https://raw.githubusercontent.com/VICE-Team/svn-mirror/main/vice/data/common/vice-logo-black.svg"
profiled_date: "2026-03-18"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
-core_version: "3.9"
+core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - C64 (VICE x64, fast)"
systems:
- commodore-c64
diff --git a/emulators/vice_x128.yml b/emulators/vice_x128.yml
index c07198f9d..934f61711 100644
--- a/emulators/vice_x128.yml
+++ b/emulators/vice_x128.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - C128 (VICE x128)"
cores: [vice_x128, x128]
systems: [commodore-c128]
diff --git a/emulators/vice_x64.yml b/emulators/vice_x64.yml
index 0cb97f2a3..efec76790 100644
--- a/emulators/vice_x64.yml
+++ b/emulators/vice_x64.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - C64 (VICE x64, fast)"
cores: [vice_x64, x64]
systems: [commodore-c64]
diff --git a/emulators/vice_x64dtv.yml b/emulators/vice_x64dtv.yml
index b7289af66..f26a72ba8 100644
--- a/emulators/vice_x64dtv.yml
+++ b/emulators/vice_x64dtv.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - C64DTV (VICE x64dtv)"
cores: [vice_x64dtv, x64dtv]
systems: [commodore-c64dtv]
diff --git a/emulators/vice_x64sc.yml b/emulators/vice_x64sc.yml
index 673a159b5..705f53536 100644
--- a/emulators/vice_x64sc.yml
+++ b/emulators/vice_x64sc.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - C64 (VICE x64sc, accurate)"
cores: [vice_x64sc, "x64sx"]
systems: [commodore-c64]
diff --git a/emulators/vice_xcbm2.yml b/emulators/vice_xcbm2.yml
index 2fcd218d6..d88685e16 100644
--- a/emulators/vice_xcbm2.yml
+++ b/emulators/vice_xcbm2.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - CBM-II 6x0/7x0 (VICE xcbm2)"
cores: [vice_xcbm2, xcbm2]
systems: [commodore-cbm-ii]
diff --git a/emulators/vice_xcbm5x0.yml b/emulators/vice_xcbm5x0.yml
index cbbebb045..c4e4fbf61 100644
--- a/emulators/vice_xcbm5x0.yml
+++ b/emulators/vice_xcbm5x0.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - CBM-II 5x0 (VICE xcbm5x0)"
cores: [vice_xcbm5x0, xcbm5x0]
systems: [commodore-cbm-ii]
diff --git a/emulators/vice_xpet.yml b/emulators/vice_xpet.yml
index 34de820f7..71c0c4d81 100644
--- a/emulators/vice_xpet.yml
+++ b/emulators/vice_xpet.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - PET (VICE xpet)"
cores: [vice_xpet, xpet]
systems: [commodore-pet]
diff --git a/emulators/vice_xplus4.yml b/emulators/vice_xplus4.yml
index bbe39f124..8c66c1967 100644
--- a/emulators/vice_xplus4.yml
+++ b/emulators/vice_xplus4.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - PLUS/4 (VICE xplus4)"
cores: [vice_xplus4, xplus4]
systems: [commodore-plus4]
diff --git a/emulators/vice_xscpu64.yml b/emulators/vice_xscpu64.yml
index d82077c93..b260ed2eb 100644
--- a/emulators/vice_xscpu64.yml
+++ b/emulators/vice_xscpu64.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - C64 SuperCPU (VICE xscpu64)"
cores: [vice_xscpu64, xscpu64]
systems: [commodore-c64-supercpu]
diff --git a/emulators/vice_xvic.yml b/emulators/vice_xvic.yml
index 5f50b81a4..c1c80c99b 100644
--- a/emulators/vice_xvic.yml
+++ b/emulators/vice_xvic.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "6cbc44add8b0a03791b343712dc63dc1edad2675"
core_version: "3.10"
+version_basis:
+ kind: file
+ path: "vice/configure"
+ pattern: '^VICE_VERSION=(\S+)'
display_name: "Commodore - VIC-20 (VICE xvic)"
cores: [vice_xvic, xvic]
systems: [commodore-vic20]
diff --git a/emulators/video_processor.yml b/emulators/video_processor.yml
index dd246ddcf..9a3e1f187 100644
--- a/emulators/video_processor.yml
+++ b/emulators/video_processor.yml
@@ -9,6 +9,10 @@ profiled_date: "2026-09-17"
source_commit: "8fe4751f0039a1b645bf750531fcfc234958a7ac"
upstream_commit: "c608ced893583e0a8a8153890f99453fd1647d53"
core_version: "0.0.2"
+version_basis:
+ kind: file
+ path: "video_processor_v4l2.c"
+ pattern: '#define LIBRARY_VERSION\s+"([^"]+)"'
display_name: "Video Processor (V4L2)"
cores:
- video_processor
diff --git a/emulators/vidtest.yml b/emulators/vidtest.yml
index 81193887e..d690e1e5d 100644
--- a/emulators/vidtest.yml
+++ b/emulators/vidtest.yml
@@ -3,7 +3,12 @@ type: test
core_classification: pure_libretro
source: "https://github.com/schellingb/vidtest_libretro"
profiled_date: "2026-03-25"
+source_commit: "edfd32747e5eeb5ce82a5cee686ee1b32f1cc52e"
core_version: "0.1"
+version_basis:
+ kind: file
+ path: "vidtest.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "VidTest"
cores:
- vidtest
diff --git a/emulators/vircon32.yml b/emulators/vircon32.yml
index 8bd710232..4762ea9ed 100644
--- a/emulators/vircon32.yml
+++ b/emulators/vircon32.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/vircon32/ComputerSoftware"
profiled_date: "2026-03-25"
source_commit: "8b4ff35efb0f3a4c2599fa85ec0e588713f83182"
upstream_commit: "e75b5370ff2eaff986057b34df893ad2eba08ea5"
-core_version: "2026.2.18"
+core_version: "v1.7"
+version_basis:
+ kind: tag
+ tag: "v1.7"
+ distance: 0
display_name: "Vircon32"
cores:
- vircon32
diff --git a/emulators/virtual-virtual-boy.yml b/emulators/virtual-virtual-boy.yml
index d12b68939..7950d0cf9 100644
--- a/emulators/virtual-virtual-boy.yml
+++ b/emulators/virtual-virtual-boy.yml
@@ -7,7 +7,11 @@ author: "Simon Gellis"
profiled_date: "2026-08-12"
source_commit: "0bf230bced628d78ff2c149baaf16f6fb8388cec"
upstream_commit: "0bf230bced628d78ff2c149baaf16f6fb8388cec"
-core_version: "1.18.2"
+core_version: "v1.18.2"
+version_basis:
+ kind: tag
+ tag: "v1.18.2"
+ distance: 0
display_name: "Nintendo - Virtual Boy (Virtual Virtual Boy)"
mode: standalone
cores:
diff --git a/emulators/virtualjaguar.yml b/emulators/virtualjaguar.yml
index 882118a0f..22d614b73 100644
--- a/emulators/virtualjaguar.yml
+++ b/emulators/virtualjaguar.yml
@@ -6,6 +6,10 @@ upstream: "http://shamusworld.gotdns.org/git/virtualjaguar"
profiled_date: "2026-09-04"
source_commit: "e7db53678b86e2fceb459e26cf09d2fdaaa4d395"
core_version: "v3.10.0"
+version_basis:
+ kind: tag
+ tag: "v3.10.0"
+ distance: 0
display_name: "Atari - Jaguar (Virtual Jaguar)"
cores:
- virtualjaguar
diff --git a/emulators/virtualxt.yml b/emulators/virtualxt.yml
index 0f511bb1c..bd693d7be 100644
--- a/emulators/virtualxt.yml
+++ b/emulators/virtualxt.yml
@@ -6,6 +6,10 @@ upstream: "https://codeberg.org/virtualxt/virtualxt"
profiled_date: "2026-10-17"
source_commit: "a400adb0abf242c2fecbe926618338a00dc557c5"
core_version: "3.1.0"
+version_basis:
+ kind: file
+ path: "virtualxt_libretro.info"
+ pattern: '^display_version\s*=\s*"([^"]+)"'
display_name: "VirtualXT"
cores: [virtualxt]
systems: [ibm-pc]
diff --git a/emulators/visual-pinball.yml b/emulators/visual-pinball.yml
index 52db2da2b..fe72f75f7 100644
--- a/emulators/visual-pinball.yml
+++ b/emulators/visual-pinball.yml
@@ -7,6 +7,11 @@ profiled_date: "2026-10-27"
source_commit: "63770cd66eee66c84c10ba2f4d2edff918ab85d3"
prose_written_at: "63770cd66eee66c84c10ba2f4d2edff918ab85d3"
core_version: "10.8.1"
+version_basis:
+ kind: file
+ path: "src/core/vpversion.h"
+ pattern: '#define VP_VERSION_MAJOR\s+(\d+)[^\n]*\n#define VP_VERSION_MINOR\s+(\d+)[^\n]*\n#define VP_VERSION_REV\s+(\d+)'
+ format: "{1}.{2}.{3}"
display_name: "Visual Pinball X"
cores:
- vpinball
diff --git a/emulators/vita3k.yml b/emulators/vita3k.yml
index 19be65244..19be9d94f 100644
--- a/emulators/vita3k.yml
+++ b/emulators/vita3k.yml
@@ -4,7 +4,12 @@ source: "https://github.com/Vita3K/Vita3K"
upstream: "https://github.com/Vita3K/Vita3K"
profiled_date: "2026-10-23"
source_commit: "6ec8e6ce31f3d6099b14eb9dd0320c09ef6386b6"
-core_version: "0.2.1"
+core_version: "v0.2.1"
+version_basis:
+ kind: file
+ path: "vita3k/CMakeLists.txt"
+ pattern: 'set\(VITA3K_APP_VER_HI (\d+)\)\nset\(VITA3K_APP_VER_MID (\d+)\)\nset\(VITA3K_APP_VER_LO (\d+)\)\nset\(VITA3K_APP_VERSION "v'
+ format: "v{1}.{2}.{3}"
display_name: "Vita3K (PS Vita)"
cores:
- vita3k
diff --git a/emulators/vitaquake2.yml b/emulators/vitaquake2.yml
index 94d07f1e0..074a20c78 100644
--- a/emulators/vitaquake2.yml
+++ b/emulators/vitaquake2.yml
@@ -6,7 +6,11 @@ upstream: "https://github.com/Rinnegatamante/vitaQuakeII"
profiled_date: "2026-03-25"
source_commit: "4b333fe18e8816974f04432af01f87654ae16e21"
prose_written_at: "4b333fe18e8816974f04432af01f87654ae16e21"
-core_version: "git"
+core_version: "2.3"
+version_basis:
+ kind: file
+ path: "qcommon/qcommon.h"
+ pattern: '^#define\s+VERSION\s+([0-9.]+)'
display_name: "Quake II (vitaQuake 2)"
cores: [vitaquake2, vitaquake2-rogue, vitaquake2-xatrix, vitaquake2-zaero]
systems: [quake2]
diff --git a/emulators/vitaquake3.yml b/emulators/vitaquake3.yml
index 7d75d7287..2077870da 100644
--- a/emulators/vitaquake3.yml
+++ b/emulators/vitaquake3.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/vitaquake3"
upstream: "https://github.com/ioquake/ioq3"
profiled_date: "2026-03-25"
source_commit: "61374f650f42c4f25c215ea1f8db992af162ada6"
-core_version: "git"
+core_version: "1.36_GIT_ba68b99c-2018-01-23"
+version_basis:
+ kind: file
+ path: "Makefile"
+ pattern: '-DPRODUCT_VERSION=\\"([^\\"]+)\\"'
display_name: "Quake III: Arena (vitaQuake 3)"
cores: [vitaquake3]
systems: [quake3]
diff --git a/emulators/vitavoyager.yml b/emulators/vitavoyager.yml
index 28988e2f6..70a24dd62 100644
--- a/emulators/vitavoyager.yml
+++ b/emulators/vitavoyager.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/vitavoyager"
upstream: "https://github.com/clover-moe/lilium-voyager"
profiled_date: "2026-03-25"
source_commit: "58b70dd7ea891c2e8ddc37d03a5d58116b0ea118"
-core_version: "git"
+core_version: "1.36_GIT_ba68b99c-2018-01-23"
+version_basis:
+ kind: file
+ path: "Makefile"
+ pattern: '-DPRODUCT_VERSION=\\"([^\\"]+)\\"'
display_name: "Star Trek: Voyager - Elite Force (vitaVoyager)"
cores: [vitavoyager]
systems: [quake3]
diff --git a/emulators/vkquake.yml b/emulators/vkquake.yml
index 24fbee84a..fd0fd1c16 100644
--- a/emulators/vkquake.yml
+++ b/emulators/vkquake.yml
@@ -6,6 +6,11 @@ upstream: "https://sourceforge.net/projects/quakespasm/"
profiled_date: "2026-10-23"
source_commit: "a6cfba6904e0743dae4235f9dad9711bb5bd27f1"
core_version: "1.37.1"
+version_basis:
+ kind: file
+ path: "Quake/quakever.h"
+ pattern: '#define VKQUAKE_VERSION_MAJOR (\d+)\n#define VKQUAKE_VERSION_MINOR (\d+)\n#define VKQUAKE_VER_PATCH\s+(\d+)'
+ format: "{1}.{2}.{3}"
display_name: "Quake (vkQuake)"
cores: ["vkquake"]
systems: [quake]
diff --git a/emulators/vkquake2.yml b/emulators/vkquake2.yml
index 946317b99..649fb92d6 100644
--- a/emulators/vkquake2.yml
+++ b/emulators/vkquake2.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/id-Software/Quake-2"
profiled_date: "2026-10-23"
source_commit: "6763f207229f97cffabb6fc2da72017a794b139b"
core_version: "1.5.10"
+version_basis:
+ kind: tag
+ tag: "1.5.10"
+ distance: 0
display_name: "Quake II (vkQuake2)"
cores: ["vkquake2"]
systems: [quake2, xatrix, rogue, ctf]
diff --git a/emulators/vkquake3.yml b/emulators/vkquake3.yml
index 2b94f663e..45c527656 100644
--- a/emulators/vkquake3.yml
+++ b/emulators/vkquake3.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/ioquake/ioq3"
profiled_date: "2026-10-17"
source_commit: "650bd625eb725446279c6176804b554e2e1c98f3"
core_version: "1.36"
+version_basis:
+ kind: file
+ path: "Makefile"
+ pattern: 'VERSION=([0-9][\w.+-]*)'
display_name: "Quake III Arena (vkQuake3)"
cores: ["vkquake3"]
systems: [quake3, teamarena]
diff --git a/emulators/vvvvvv.yml b/emulators/vvvvvv.yml
index 4ed530e3a..05590f071 100644
--- a/emulators/vvvvvv.yml
+++ b/emulators/vvvvvv.yml
@@ -6,6 +6,11 @@ upstream: "https://github.com/TerryCavanagh/VVVVVV"
profiled_date: "2026-10-23"
source_commit: "40e1ec3bbf2a990a5c600564fe312c2121178a50"
core_version: "v2.5"
+version_basis:
+ kind: file
+ path: "desktop_version/src/ReleaseVersion.h"
+ pattern: '#define MAJOR_VERSION (\d+)\n#define MINOR_VERSION (\d+)\n#define PATCH_VERSION 0\b'
+ format: "v{1}.{2}"
display_name: "VVVVVV"
cores:
- "vvvvvv"
diff --git a/emulators/wasm4.yml b/emulators/wasm4.yml
index ad49969d5..621246c88 100644
--- a/emulators/wasm4.yml
+++ b/emulators/wasm4.yml
@@ -5,7 +5,11 @@ source: "https://github.com/aduros/wasm4"
upstream: "https://github.com/aduros/wasm4"
profiled_date: "2026-03-25"
source_commit: "9d6c962785cfe3719d0245fd279ecbe98a4dbb63"
-core_version: "v2.7.1"
+core_version: "2.7.1"
+version_basis:
+ kind: file
+ path: "cli/package.json"
+ pattern: '"version":\s+"([^"]+)'
display_name: "WASM-4"
cores: [wasm4]
systems: []
diff --git a/emulators/winuae.yml b/emulators/winuae.yml
index 45daeac6a..602266b95 100644
--- a/emulators/winuae.yml
+++ b/emulators/winuae.yml
@@ -5,6 +5,11 @@ source: "https://github.com/tonioni/WinUAE"
source_commit: "15110cf6606d1188800ea7ad86bf1cbd9c688741"
profiled_date: "2026-10-20"
core_version: "6.1.0 beta 19"
+version_basis:
+ kind: file
+ path: "include/winuae_version.h"
+ pattern: '#define UAEMAJOR (\d+)\n#define UAEMINOR (\d+)\n#define UAESUBREV (\d+)\n\n#define WINUAEPUBLICBETA 1\n\n#if WINUAEPUBLICBETA\n#define WINUAEBETA _T\("(\d+)"\)'
+ format: "{1}.{2}.{3} beta {4}"
display_name: "Commodore - Amiga (WinUAE)"
cores:
- winuae
diff --git a/emulators/wolf4sdl.yml b/emulators/wolf4sdl.yml
index c55166e56..6737502d1 100644
--- a/emulators/wolf4sdl.yml
+++ b/emulators/wolf4sdl.yml
@@ -5,7 +5,11 @@ source: "https://github.com/KS-Presto/Wolf4SDL"
upstream: "https://github.com/KS-Presto/Wolf4SDL"
profiled_date: "2026-10-20"
source_commit: "dc8b250af35fb0ace68db5eb879490b50068c20e"
-core_version: "2.0"
+core_version: "v2.0"
+version_basis:
+ kind: file
+ path: "wl_main.c"
+ pattern: '"Wolf4SDL (v[0-9][0-9.]*[0-9A-Za-z]*)\\n"'
display_name: "Wolfenstein 3D (Wolf4SDL)"
cores: [wolf4sdl]
systems: [wolfenstein3d]
diff --git a/emulators/wqxemu.yml b/emulators/wqxemu.yml
index 226e7b9a5..36ec2a35c 100644
--- a/emulators/wqxemu.yml
+++ b/emulators/wqxemu.yml
@@ -7,6 +7,10 @@ profiled_date: "2026-10-18"
source_commit: "caf0998989446e3d6659f67c1b8aabd708a0379d"
upstream_commit: "caf0998989446e3d6659f67c1b8aabd708a0379d"
core_version: "0.1.0"
+version_basis:
+ kind: file
+ path: "Cargo.toml"
+ pattern: 'version\s+=\s+"([^"]+)'
display_name: "Wenquxing - Electronic Dictionary (WQXEmu)"
cores:
- wqxemu
diff --git a/emulators/x1-box.yml b/emulators/x1-box.yml
index 0bf800383..17220ac2c 100644
--- a/emulators/x1-box.yml
+++ b/emulators/x1-box.yml
@@ -8,6 +8,10 @@ profiled_date: "2026-10-26"
source_commit: "e092175b9196438ee2712bf6f45398fe2970b5fe"
upstream_commit: "26fcbe54f17e496bdf530dad9f237e74e2f943fc"
core_version: "1.3.2"
+version_basis:
+ kind: tag
+ tag: "1.3.2"
+ distance: 0
display_name: "Microsoft - Xbox (X1 BOX)"
cores:
- "x1-box"
diff --git a/emulators/x1.yml b/emulators/x1.yml
index 30e3cd072..380524d9e 100644
--- a/emulators/x1.yml
+++ b/emulators/x1.yml
@@ -6,6 +6,10 @@ upstream: "http://amethyst.yui.ne.jp/svn/x1/xmil/trunk/"
profiled_date: "2026-03-25"
source_commit: "3106aa54ffb244cca4019a6e95dacaa698781bc3"
core_version: "0.60a"
+version_basis:
+ kind: file
+ path: "xmilver.h"
+ pattern: '#define\s+XMILVER_CORE\s+"ver\.([^"]+)"'
display_name: "Sharp X1 (X Millennium)"
cores:
- x1
diff --git a/emulators/x16emu.yml b/emulators/x16emu.yml
index 3d2d52400..d4b011799 100644
--- a/emulators/x16emu.yml
+++ b/emulators/x16emu.yml
@@ -5,6 +5,10 @@ upstream: "https://github.com/X16Community/x16-emulator"
profiled_date: "2026-10-20"
source_commit: "af1024080ced455ea098b007aed43142c99e93da"
core_version: "r49"
+version_basis:
+ kind: tag
+ tag: "r49"
+ distance: 0
display_name: "Commander X16 (x16emu)"
cores:
- x16emu
diff --git a/emulators/x64sdl.yml b/emulators/x64sdl.yml
index cb74df3b6..d9222de86 100644
--- a/emulators/x64sdl.yml
+++ b/emulators/x64sdl.yml
@@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/vice-emu/"
profiled_date: "2026-03-25"
source_commit: "baaaeed97ce1c0a07ed78e3e155cfbd6f508b472"
core_version: "3.1"
+version_basis:
+ kind: file
+ path: "src/version.h"
+ pattern: '#define\s+VERSION\s+"([^"]+)'
display_name: "Commodore - C64 (VICE SDL)"
cores: [x64sdl]
systems: [commodore-c64]
diff --git a/emulators/xash3d_fwgs.yml b/emulators/xash3d_fwgs.yml
index f4e99b9a1..aecc372d1 100644
--- a/emulators/xash3d_fwgs.yml
+++ b/emulators/xash3d_fwgs.yml
@@ -5,7 +5,11 @@ source: "https://github.com/FWGS/xash3d-fwgs"
upstream: "https://github.com/FWGS/xash3d-fwgs"
profiled_date: "2026-10-04"
source_commit: "27092af50d912432887faa43a1cccddfae569b2d"
-core_version: "0.21+git"
+core_version: "0.21"
+version_basis:
+ kind: file
+ path: "engine/common/com_strings.h"
+ pattern: '#define\s+XASH_VERSION\s+"([^"]+)"'
display_name: "Xash3D FWGS"
cores: [xash3d_fwgs, xash3d]
systems: [halflife, cstrike, czero, czeror, dmc, gearbox, bshift, ricochet, tfc]
diff --git a/emulators/xemu-chihiro.yml b/emulators/xemu-chihiro.yml
index ab82e0e92..a7709ddd6 100644
--- a/emulators/xemu-chihiro.yml
+++ b/emulators/xemu-chihiro.yml
@@ -7,7 +7,12 @@ upstream: "https://github.com/xemu-project/xemu"
profiled_date: "2026-10-20"
source_commit: "98fbd75dd0f3088a4988abc1698feb37b844a41a"
upstream_commit: "25e1ee2fd59a451f2ab57e825b4f8a5414220c8b"
-core_version: "0.8.134-406-g0d44d803b2"
+core_version: "v0.8.134-410-g98fbd75"
+version_basis:
+ kind: tag
+ match: "v*"
+ tag: "v0.8.134"
+ distance: 410
display_name: "Sega - Chihiro (xemu Chihiro)"
cores:
- xemu-chihiro
diff --git a/emulators/xemu.yml b/emulators/xemu.yml
index 3c9f47dec..f242bd2f3 100644
--- a/emulators/xemu.yml
+++ b/emulators/xemu.yml
@@ -11,8 +11,18 @@ source_commit:
standalone: "25e1ee2fd59a451f2ab57e825b4f8a5414220c8b"
libretro: "d9804db79f87f6a6a5548b14ca4a8d5164390338"
core_version:
- standalone: "0.8.139-2-g25e1ee2fd5"
+ standalone: "v0.8.139-2-g25e1ee2"
libretro: "libretro-experimental"
+version_basis:
+ standalone:
+ kind: tag
+ match: "v*"
+ tag: "v0.8.139"
+ distance: 2
+ libretro:
+ kind: file
+ path: "ui/xemu-libretro.c"
+ pattern: 'info->library_version\s*=\s*"([^"]+)"'
display_name: "Microsoft - Xbox (xemu)"
cores:
- xemu
diff --git a/emulators/xendroid.yml b/emulators/xendroid.yml
index f712a9d2e..f48b11da5 100644
--- a/emulators/xendroid.yml
+++ b/emulators/xendroid.yml
@@ -7,7 +7,11 @@ author: "rfandango"
profiled_date: "2026-09-17"
source_commit: "779680ade075d2d2a4c589c6a8d57a8e26c116c0"
upstream_commit: "d881a29125978b4eea6bf44f6ed8c33f8aea8c33"
-core_version: "0b11201"
+core_version: "XenDroid-0b11201-2-g779680a"
+version_basis:
+ kind: tag
+ tag: "XenDroid-0b11201"
+ distance: 2
display_name: "Microsoft - Xbox 360 (XenDroid)"
cores:
- "xendroid"
diff --git a/emulators/xenia-edge.yml b/emulators/xenia-edge.yml
index 3fa27d61c..d4d8bd446 100644
--- a/emulators/xenia-edge.yml
+++ b/emulators/xenia-edge.yml
@@ -12,6 +12,15 @@ profiled_date: "2026-10-27"
core_version:
standalone: "7180422"
libretro: "8dc77b0"
+version_basis:
+ standalone:
+ kind: tag
+ tag: "7180422"
+ distance: 0
+ libretro:
+ kind: file
+ path: "upstream.version"
+ pattern: '\A([0-9a-f]{7,40})\s*$'
display_name: "Xenia Edge (Xbox 360)"
cores:
- "xenia-edge"
diff --git a/emulators/xenia.yml b/emulators/xenia.yml
index 57c49bba9..b0ff32c4f 100644
--- a/emulators/xenia.yml
+++ b/emulators/xenia.yml
@@ -5,7 +5,11 @@ source: "https://github.com/xenia-canary/xenia-canary"
upstream: "https://github.com/xenia-canary/xenia-canary"
profiled_date: "2026-03-26"
source_commit: "72c2afa3ba2b1b232f89de71577251d6edafb1bd"
-core_version: "canary"
+core_version: "72c2afa"
+version_basis:
+ kind: tag
+ tag: "72c2afa"
+ distance: 0
display_name: "Xenia Canary (Xbox 360)"
cores:
- xenia
diff --git a/emulators/xenios.yml b/emulators/xenios.yml
index e1f648ef6..cc9440da6 100644
--- a/emulators/xenios.yml
+++ b/emulators/xenios.yml
@@ -6,7 +6,12 @@ upstream: "https://github.com/has207/xenia-edge"
source_commit: "87b176a078c316fde3adf67a217f0c44615b0e0d"
upstream_commit: "07021f019cf55105bc26c0474e0b0835808353e2"
profiled_date: "2026-08-23"
-core_version: "2.0.1"
+core_version: "v2.0.1-2-g87b176a"
+version_basis:
+ kind: tag
+ match: "v[0-9]*.[0-9]*.[0-9]*"
+ tag: "v2.0.1"
+ distance: 2
display_name: "Microsoft - Xbox 360 (XeniOS)"
cores:
- "xenios"
diff --git a/emulators/xm6pro68k.yml b/emulators/xm6pro68k.yml
index 44f605745..4d847f341 100644
--- a/emulators/xm6pro68k.yml
+++ b/emulators/xm6pro68k.yml
@@ -9,6 +9,10 @@ version_source:
based_on: "XM6 v2.06 by PI."
profiled_date: "2026-10-20"
core_version: "260920"
+version_basis:
+ kind: package
+ url: "https://www.retrobat.ovh/repo/win64/stable/emulators/xm6pro.7z"
+ artifact: "xm6pro.7z, Version.txt"
display_name: "XM6 Pro-68k (Sharp X68000)"
mode: standalone
cores:
diff --git a/emulators/xm6typeg.yml b/emulators/xm6typeg.yml
index c44b057ec..666cf9834 100644
--- a/emulators/xm6typeg.yml
+++ b/emulators/xm6typeg.yml
@@ -9,6 +9,12 @@ version_source:
based_on: "XM6 v2.06 by PI."
profiled_date: "2026-08-10"
core_version: "3.38"
+version_basis:
+ kind: package
+ url: "http://retropc.net/pi/xm6/index.html"
+ pattern: 'xm6_typeg_(\d)(\d+)_\d+\.zip'
+ format: "{1}.{2}"
+ artifact: "xm6_typeg_338_20260223.zip"
display_name: "XM6 TypeG (Sharp X68000)"
mode: standalone
cores:
diff --git a/emulators/xm7.yml b/emulators/xm7.yml
index 6a771ae01..e44af9a08 100644
--- a/emulators/xm7.yml
+++ b/emulators/xm7.yml
@@ -7,6 +7,11 @@ upstream: "http://retropc.net/ryu/xm7/"
profiled_date: "2026-10-20"
source_commit: "2a48dceaf8a3a0de92cf369b039ac5fab2bb351a"
core_version: "V3.4 L61a SDL/Agar 0.3.2pre2"
+version_basis:
+ kind: file
+ path: "linux-sdl/vm/xm7.h"
+ pattern: '#if XM7_VER >= 3\n#define\s+VERSION\s+"(V[^"]+)"[\s\S]*?#else\n#define\s+LEVEL\s+"([^"]+)"[\s\S]*?#else\n#define\s+LOCALVER\s+"(SDL/Agar [^"]+)"'
+ format: "{1} {2} {3}"
cores:
- xm7
systems:
diff --git a/emulators/xrick.yml b/emulators/xrick.yml
index 08b56de5a..62ac214d7 100644
--- a/emulators/xrick.yml
+++ b/emulators/xrick.yml
@@ -5,7 +5,11 @@ source: "https://github.com/libretro/xrick-libretro"
upstream: "https://github.com/fabiensanglard/xrick"
profiled_date: "2026-03-30"
source_commit: "fcfde3623a04b4e986548e06d46630fcd0bd1e18"
-core_version: "1.0.0.6"
+core_version: "021212"
+version_basis:
+ kind: file
+ path: "include/config.h"
+ pattern: '#define\s+VERSION\s+"([^"]+)"'
display_name: "Rick Dangerous (XRick)"
cores:
- xrick
diff --git a/emulators/xroar.yml b/emulators/xroar.yml
index 38d7ff49e..4729f716a 100644
--- a/emulators/xroar.yml
+++ b/emulators/xroar.yml
@@ -7,6 +7,9 @@ version_source:
pattern: 'version\s*([0-9.]+), released'
profiled_date: "2026-10-20"
core_version: "1.12.1"
+version_basis:
+ kind: unverified
+ why: host_unsupported
display_name: "Dragon / CoCo / MC-10 (XRoar)"
cores:
- xroar
diff --git a/emulators/yabasanshiro.yml b/emulators/yabasanshiro.yml
index 641f2bdd7..859ed3400 100644
--- a/emulators/yabasanshiro.yml
+++ b/emulators/yabasanshiro.yml
@@ -10,8 +10,18 @@ source_commit:
libretro: "09ed8e5b2e97e7a848ea2514545c34c7b809e399"
standalone: "a40dace1ae0af3ebd45848549fdf396f40e3930f"
core_version:
- libretro: "v2.6.8"
+ libretro: "3.4.2"
standalone: "1.11.beta3"
+version_basis:
+ libretro:
+ kind: file
+ path: "yabause/src/libretro/Makefile.common"
+ pattern: '-DVERSION=\\"([^\\"]+)\\"'
+ standalone:
+ kind: file
+ path: "yabause/CMakeLists.txt"
+ pattern: 'set\(YAB_VERSION_MAJOR (\w+)\)\nset\(YAB_VERSION_MINOR (\w+)\)\nset\(YAB_VERSION_PATCH (\w+)\)'
+ format: "{1}.{2}.{3}"
display_name: "Sega - Saturn (YabaSanshiro)"
cores:
- yabasanshiro
diff --git a/emulators/yabause.yml b/emulators/yabause.yml
index 1d05ae06c..1a83c8fc0 100644
--- a/emulators/yabause.yml
+++ b/emulators/yabause.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/Yabause/yabause"
profiled_date: "2026-03-25"
source_commit: "8926b0c6c347f8c5c755911ddb0ac695420ffbf8"
core_version: "v0.9.15"
+version_basis:
+ kind: file
+ path: "yabause/src/libretro/libretro.c"
+ pattern: 'info->library_version\s+=\s+"([^"]+)'
display_name: "Sega - Saturn (Yabause)"
cores:
- yabause
diff --git a/emulators/yaps2.yml b/emulators/yaps2.yml
index 25111d394..07ed63931 100644
--- a/emulators/yaps2.yml
+++ b/emulators/yaps2.yml
@@ -8,7 +8,13 @@ profiled_date: "2026-09-17"
source_commit: "57ea93c0ce8306063b31fe6658dab6b492bbedfe"
upstream_commit: "342db51523f288c5ced20f5be3c679f46472cc45"
upstream_frozen: "The tree is PCSX2 at this commit plus the fork's own commits. Later PCSX2 commits are not in the port."
-core_version: "Git"
+core_version: "v2.7.421"
+version_basis:
+ kind: tag
+ repo: upstream
+ match: "v*"
+ tag: "v2.7.421"
+ distance: 0
display_name: "Sony - PlayStation 2 (yaps2)"
cores: [yaps2]
systems: [sony-playstation-2]
diff --git a/emulators/ymir.yml b/emulators/ymir.yml
index 284850d0e..45d1b395b 100644
--- a/emulators/ymir.yml
+++ b/emulators/ymir.yml
@@ -10,8 +10,17 @@ source_commit:
standalone: "fe439e98abc555c243a8a197850c71777ff664f4"
libretro: "8174cbbeea4070654841cdd4604361b3eee8fdd1"
core_version:
- standalone: "v0.4.1-84-gfe439e98"
- libretro: "libretro branch"
+ standalone: "0.4.2"
+ libretro: "0.4.0"
+version_basis:
+ standalone:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: '^project\(Ymir VERSION ([0-9][^ )]*)\)'
+ libretro:
+ kind: file
+ path: "CMakeLists.txt"
+ pattern: '^project\(Ymir VERSION ([0-9][^ )]*)\)'
display_name: "Sega - Saturn (Ymir)"
cores:
- ymir
diff --git a/emulators/yquake2.yml b/emulators/yquake2.yml
index 19cf021a9..bdb74943d 100644
--- a/emulators/yquake2.yml
+++ b/emulators/yquake2.yml
@@ -5,7 +5,11 @@ source: "https://github.com/yquake2/yquake2"
upstream: "https://github.com/yquake2/yquake2"
profiled_date: "2026-10-04"
source_commit: "9dbf699273e2546f3ad14cff3dee6a51a518cd2a"
-core_version: "8.51+git"
+core_version: "8.71pre"
+version_basis:
+ kind: file
+ path: "src/common/header/common.h"
+ pattern: '#define\s+YQ2VERSION\s+"([^"]+)"'
display_name: "Yamagi Quake II"
cores: [yquake2]
systems: [quake2, xatrix, rogue, ctf]
diff --git a/emulators/yuzu.yml b/emulators/yuzu.yml
index 529a1cb26..e425668bf 100644
--- a/emulators/yuzu.yml
+++ b/emulators/yuzu.yml
@@ -9,6 +9,9 @@ pin_frozen: "The analysed revision, the one the EA 4176 build documented here co
upstream_gone: "GitHub withdrew the repository for legal reasons: the API answers HTTP 451 for both yuzu-emu/yuzu and the yuzu-emu-mirror copy. Observed 2026-09-04. The fork Zenfone-5-X00QD-4-19/yuzu, pushed 2024-03-05, still serves the analysed commit."
source_mirror: "https://github.com/Zenfone-5-X00QD-4-19/yuzu"
core_version: "EA 4176"
+version_basis:
+ kind: unverified
+ why: host_gone
display_name: "Yuzu (Nintendo Switch)"
cores:
- yuzu
diff --git a/emulators/zc210.yml b/emulators/zc210.yml
index 90cfb3cf4..6a1f502ca 100644
--- a/emulators/zc210.yml
+++ b/emulators/zc210.yml
@@ -3,7 +3,12 @@ type: libretro
core_classification: game_engine
source: https://github.com/netux79/zc210-libretro
upstream: https://www.zeldaclassic.com/
-core_version: "2.10 Beta 5"
+core_version: "2.10"
+version_basis:
+ kind: file
+ path: "zc/zdefs.h"
+ pattern: '#define\s+ZELDA_VERSION\s+0x0*([0-9])([0-9]{2})\b'
+ format: "{1}.{2}"
display_name: Zelda Classic v2.10 (ZC210)
profiled_date: "2026-03-26"
source_commit: "b6426da40b074245fda097fd345fa7c9cdbf152a"
diff --git a/emulators/zc255.yml b/emulators/zc255.yml
index 7ac94e1a9..b9387c9ee 100644
--- a/emulators/zc255.yml
+++ b/emulators/zc255.yml
@@ -5,6 +5,10 @@ source: "https://www.retrobat.ovh/repo/win64/stable/emulators/cores/zc255_libret
upstream: closed-source
profiled_date: "2026-10-20"
core_version: "2.55.16"
+version_basis:
+ kind: package
+ url: "https://www.retrobat.ovh/repo/win64/stable/emulators/cores/zc255_libretro.dll.zip"
+ artifact: "zc255_libretro.dll.zip, RetroBat coreversions.xml entry"
display_name: "Zelda Classic 2.55 (ZC255)"
cores: [zc255]
systems: [zeldaclassic]
diff --git a/emulators/zesarux.yml b/emulators/zesarux.yml
index 728e9d1c3..3017ab21f 100644
--- a/emulators/zesarux.yml
+++ b/emulators/zesarux.yml
@@ -7,6 +7,10 @@ source_commit: "fd354296d851edc24ad1411b8f9342a66d07d460"
prose_written_at: "e1fc8db2957c360c8ff584a2c4d147f70861c3aa"
profiled_date: "2026-10-26"
core_version: "13.1-SN"
+version_basis:
+ kind: file
+ path: "src/cpu.h"
+ pattern: '^#define\s+EMULATOR_VERSION\s+"([^"]+)"'
display_name: "Sinclair - ZX Spectrum and clones (ZEsarUX)"
verification: existence
cores: [zesarux]
diff --git a/emulators/zinc.yml b/emulators/zinc.yml
index 2dbcc21b0..8c45a43af 100644
--- a/emulators/zinc.yml
+++ b/emulators/zinc.yml
@@ -6,6 +6,10 @@ upstream: closed-source
author: "Drunken Muppets"
profiled_date: "2026-10-20"
core_version: "1.1"
+version_basis:
+ kind: package
+ url: "https://www.retrobat.ovh/repo/win64/stable/emulators/zinc.7z"
+ artifact: "zinc.7z, ZiNc.exe banner"
display_name: "Arcade - Sony ZN-1/ZN-2, Namco System 11, Konami GV (ZiNc)"
cores:
- zinc
diff --git a/emulators/zquestclassic.yml b/emulators/zquestclassic.yml
index 10da14d2d..b606049d6 100644
--- a/emulators/zquestclassic.yml
+++ b/emulators/zquestclassic.yml
@@ -6,6 +6,10 @@ upstream: "https://github.com/ZQuestClassic/ZQuestClassic"
source_commit: "4ef771c64666812a1359b8f34fe4d7803868ba13"
profiled_date: "2026-10-20"
core_version: "2.55.17"
+version_basis:
+ kind: tag
+ tag: "2.55.17"
+ distance: 0
display_name: "Zelda Classic (ZQuest Classic)"
cores: [zquestclassic]
systems: [zeldaclassic]
diff --git a/install.ps1 b/install.ps1
index aceaf62d1..615575afc 100644
--- a/install.ps1
+++ b/install.ps1
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
- $defaultInstallSha256 = "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0"
+ $defaultInstallSha256 = "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
diff --git a/install.py b/install.py
index 6c9a3376a..d7b2a6bda 100644
--- a/install.py
+++ b/install.py
@@ -51,7 +51,7 @@ import weakref
import xml.etree.ElementTree as ET
import zipfile
import zlib
-from collections.abc import Callable, Collection, Iterator, Mapping
+from collections.abc import Callable, Collection, Iterable, Iterator, Mapping
from pathlib import Path, PurePosixPath
from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO
@@ -338,12 +338,100 @@ def _enable_ansi() -> bool:
return False
+# The roles text is painted with, from the 16 basic colours only: a
+# handheld's Linux console has no more. The screens and the line questions
+# name a role, never a code. Magenta is the hue that stays legible on black
+# and on white in the xterm, VGA, Tango and Campbell palettes, so it is the
+# accent, bold. Yellow reads poorly on white: it paints a status word, never
+# a sentence. Bold red is bright red on the Linux console, where plain red on
+# black is the weakest pair.
+COLOUR_ROLES = {
+ "accent": "1;35",
+ "success": "32",
+ "warning": "33",
+ "failure": "1;31",
+ "muted": "2",
+ "strong": "1",
+ # The accent reversed: the row in focus, its > kept for no colour.
+ "selected": "7;35",
+}
+
+# The words a status takes its role from, wherever it is said. A count of
+# zero is no news and stays plain: "0 missing" is not a warning.
+_STATUS_WORDS = tuple((re.compile(pattern), role) for pattern, role in (
+ (r"(? list[tuple[int, int, str]]:
+ """The spans in order, none empty, the longer kept where two overlap."""
+ kept: list[tuple[int, int, str]] = []
+ for span in sorted(spans, key=lambda span: (span[0], -span[1])):
+ if span[1] > span[0] and (not kept or span[0] >= kept[-1][1]):
+ kept.append(span)
+ return kept
+
+
+def status_spans(text: str) -> list[tuple[int, int, str]]:
+ """(start, end, role) of the status words in text, in order, apart."""
+ return _apart([(match.start(), match.end(), role) for pattern, role in _STATUS_WORDS
+ for match in pattern.finditer(text)])
+
+
+def _sgr(role: str) -> str:
+ """The SGR sequence of a role, or of roles joined by + ("strong+success")."""
+ return "\033[0;" + ";".join(COLOUR_ROLES[name] for name in role.split("+")) + "m"
+
+
+def painted(text: str, spans: list[tuple[int, int, str]], base: str = "") -> str:
+ """text with each span in its role and the rest in the base role, a code
+ written where the role changes and the colours reset at the end. The
+ characters are those of text, in the same order. A span in "plain"
+ takes the terminal's own colours."""
+ pieces: list[tuple[str, str]] = []
+ at = 0
+ for start, end, role in spans:
+ pieces += [(text[at:start], base), (text[start:end], role)]
+ at = end
+ pieces.append((text[at:], base))
+ out: list[str] = []
+ current = ""
+ for piece, role in pieces:
+ role = "" if role == "plain" else role
+ if not piece:
+ continue
+ if role != current:
+ out.append(_sgr(role) if role else "\033[0m")
+ current = role
+ out.append(piece)
+ if current:
+ out.append("\033[0m")
+ return "".join(out)
+
+
+_SGR = re.compile(r"\x1b\[[0-9;]*m")
+
+
+def unpainted(text: str) -> str:
+ """text without its SGR codes."""
+ return _SGR.sub("", text)
+
+
class Terminal:
"""What the output stream can show: a live line, colour, or neither.
A pipe or a log gets plain lines, and NO_COLOR and TERM=dumb or unset are
honoured, as is a terminal whose description moves no cursor the ANSI way
- (vt52).
+ (vt52), or names fewer than 8 colours (vt100), which keeps the line live
+ and plain.
As no-color.org reads it, an empty NO_COLOR is not set: `NO_COLOR= cmd`
turned colour off. FORCE_COLOR or CLICOLOR_FORCE paints a pipe, for a
log viewer that shows colour, and never makes the line live.
@@ -362,25 +450,30 @@ class Terminal:
forced = any(os.environ.get(name, "0") not in ("", "0")
for name in ("FORCE_COLOR", "CLICOLOR_FORCE"))
self.colour = (
- (self.live or forced) and not os.environ.get("NO_COLOR") and _enable_ansi()
+ ((self.live and not terminal_few_colours(os.environ)) or forced)
+ and not os.environ.get("NO_COLOR") and _enable_ansi()
)
if not self.colour:
self.live = self.live and os.name != "nt"
- def paint(self, text: str, code: str) -> str:
- return f"\033[{code}m{text}\033[0m" if self.colour else text
+ def paint(self, text: str, role: str) -> str:
+ return f"\033[{COLOUR_ROLES[role]}m{text}\033[0m" if self.colour else text
+
+ def status(self, text: str) -> str:
+ """text with its status words in their roles (status_spans)."""
+ return painted(text, status_spans(text)) if self.colour else text
def ok(self, text: str) -> str:
- return self.paint(text, "32")
+ return self.paint(text, "success")
def bad(self, text: str) -> str:
- return self.paint(text, "31")
+ return self.paint(text, "failure")
def dim(self, text: str) -> str:
- return self.paint(text, "2")
+ return self.paint(text, "muted")
def bold(self, text: str) -> str:
- return self.paint(text, "1")
+ return self.paint(text, "strong")
def format_duration(seconds: float) -> str:
@@ -415,6 +508,8 @@ class Progress:
# Files written to their place, which a stopped run counts: the
# queue's length said "118 files installed" over 8 on disk.
self.installed = 0
+ # The entries of those files, which the end of the run counts apart.
+ self.landed: list[dict] = []
self.start = time.monotonic()
self._drawn = 0.0
self._evented = self.start
@@ -546,6 +641,12 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# up to MAX_SCANNED_FILES, truncated past them), and the folders below
# it on another drive, not entered.
"scanned": ("folder", "files", "truncated", "other_drives"),
+ # Before choices, once a list is read: the system here runs another
+ # version than the one the list was built for (list_version, from the
+ # manifest, installed_version, from the system's own version file).
+ # message says it and what it means. Not written when either version
+ # is unknown, and never a refusal.
+ "version": ("platform", "list_version", "installed_version", "message"),
# Before the file events of a check: the systems the run's list lets
# one choose among, --system aside, in id order (other for the files
# no system names). What Choose systems lists, and what the plan counts
@@ -632,7 +733,11 @@ class EventStream:
keys = EVENT_KEYS[kind]
if set(fields) != set(keys):
raise ValueError(f"event {kind}: keys {sorted(fields)} != {sorted(keys)}")
- line = json.dumps({"event": kind, **{key: fields[key] for key in keys}})
+ # Every string a script reads goes through the terminal's filter,
+ # a new line kept: a consumer printing a folder or a name obeyed
+ # the control characters a card label or a list held.
+ line = json.dumps({"event": kind, **{key: _clean_strings(fields[key], "\n")
+ for key in keys}})
with self._lock:
self.stream.write(line + "\n")
self.stream.flush()
@@ -765,6 +870,62 @@ def leave_out_game_data(
)
+class Tally(NamedTuple):
+ """Files and bytes of one list, BIOS files and game data apart. bios
+ and games add up to files, their bytes to size: every count a run
+ prints of a list comes from here."""
+
+ files: int
+ size: int
+ bios: int
+ bios_size: int
+ games: int
+ game_size: int
+
+
+def tally(entries: Iterable[dict]) -> Tally:
+ """The counts of entries, each with its size and the category the list
+ gives it, anything not game data being a BIOS file.
+
+ The run said 8069 files (6.5 GB) while its menu said 8348 (7.1 GB) and
+ 3259 BIOS files and 5089 game data files: three counts of three lists.
+ """
+ bios = bios_size = games = game_size = 0
+ for entry in entries:
+ size = int(entry.get("size") or 0)
+ if entry.get("category") == GAME_DATA:
+ games += 1
+ game_size += size
+ else:
+ bios += 1
+ bios_size += size
+ return Tally(bios + games, bios_size + game_size, bios, bios_size, games, game_size)
+
+
+def sized_words(counts: Tally) -> str:
+ """The count and the size, as in 8358 files, 6.6 GB."""
+ return f"{_plural(counts.files, 'file')}, {format_size(counts.size)}"
+
+
+def kinds_words(bios: int, games: int, data: int = 0) -> str:
+ """BIOS files and game data files, counted apart, and the files of the
+ data trees when some were installed."""
+ parts = [_plural(bios, "BIOS file"), _plural(games, "game data file")]
+ if data:
+ parts.append(_plural(data, "data file"))
+ return f"{', '.join(parts[:-1])} and {parts[-1]}"
+
+
+def split_words(counts: Tally) -> str:
+ """BIOS files and game data apart, in words, "" when there is no game
+ data: the installer tells them apart (--no-game-data), and no count
+ showed it. Both parts come from the count they divide."""
+ if not counts.games:
+ return ""
+ return (f"{_plural(counts.bios, 'BIOS file')} ({format_size(counts.bios_size)}) and "
+ f"{_plural(counts.games, 'game data file')} ({format_size(counts.game_size)})")
+
+
def keep_declared(files: list[dict], omitted: list[dict]) -> tuple[list[dict], list[dict]]:
"""The files and omissions the platform's own list declares (cores: null).
@@ -809,13 +970,19 @@ def declared_copy(entry: dict) -> dict:
def _left_out_lines(left_out: list[dict], every: bool) -> list[str]:
"""What --no-game-data left out, named per category, ten each unless every."""
lines = []
+ # The omitted ones apart: "leave out the 5089 game data files" was
+ # followed by "Left out as game data: 5099 files", the other ten being
+ # files the list cannot install anyway.
for group in _left_out_events(left_out):
dests = group["dests"]
shown = dests if every else dests[:10]
more = (f" ... and {len(dests) - len(shown)} more (--verbose lists them all)"
if len(shown) < len(dests) else "")
+ omitted = sum(1 for entry in left_out if entry.get("reason")
+ and entry.get("category", "") == group["category"])
+ aside = f", {omitted} of them not installable" if omitted else ""
lines.append(f" Left out as {group['label']} (--no-game-data): "
- f"{_plural(len(dests), 'file')}: {', '.join(shown)}{more}")
+ f"{_plural(len(dests), 'file')}{aside}: {', '.join(shown)}{more}")
return lines
@@ -936,13 +1103,16 @@ def _host_path(text: str) -> Path:
def shown_path(path: Path | str) -> str:
"""A path as the person names it: under WSL, /mnt/d/BIOS is D:\\BIOS, the
drive the card row calls D:."""
- text = str(path)
+ text = str(spelled(path)) if os.path.isabs(str(path)) else str(path)
root = _WSL["root"].rstrip("/") + "/"
match = (re.match(rf"^{re.escape(root)}([a-z])(?:/(.*))?$", text)
if _WSL["active"] else None)
if match:
- return f"{match.group(1).upper()}:\\" + (match.group(2) or "").replace("/", "\\")
- return text
+ text = f"{match.group(1).upper()}:\\" + (match.group(2) or "").replace("/", "\\")
+ # A folder's name comes from whoever named it: a card's label, a
+ # retroarch.cfg, a pack. Printed raw, a control character in it is a
+ # terminal command (code_audit_18 P2-1).
+ return screen_text(text)
def _wsl_dest(args: argparse.Namespace) -> None:
@@ -1497,6 +1667,127 @@ _CARD_SYSTEMS = {"MinUI": "/.system/res"}
_DETECTED_OS: dict[tuple[str, Path], str] = {}
+# A version as a list or a system writes it: v1.22.2, 43.1, 8.3.1.1.
+_VERSION_TEXT = re.compile(r"[0-9A-Za-z][0-9A-Za-z._+-]{0,39}")
+# What generate_pack.py wrote as every list's version until it recorded the
+# platform's own: not a version, so a list holding it names none.
+_NO_LIST_VERSION = "1.0"
+_MD5_CHECK = "{name}'s own BIOS check may report some files as wrong though they work."
+
+
+def _version_line(path: Path) -> str:
+ """The first line of a small version file, "" when it cannot be read."""
+ if not _is_file(path):
+ return ""
+ try:
+ with path.open("rb") as fh:
+ head = fh.read(4096)
+ except OSError:
+ return ""
+ lines = head.decode("utf-8", errors="replace").strip().splitlines()
+ return lines[0].strip() if lines else ""
+
+
+def _batocera_version(_bios_path: Path) -> str:
+ # batocera-system.mk:9,100-102 (batocera-43.1): the version, then the
+ # date and time of the build.
+ words = _version_line(_on_device("/usr/share/batocera/batocera.version")).split()
+ return words[0] if words else ""
+
+
+def _recalbox_version(_bios_path: Path) -> str:
+ # Dockerfile:31 (recalbox 10.1.1) writes RECALBOX_VERSION, which
+ # .gitlab-ci.yml:723 sets to the release tag, or for a build of a branch
+ # to its date and name. A build from a working tree says "development"
+ # (projects/configgen/configgen/challenge/RuleScript.py:64). Only a
+ # release is a version a list is built for.
+ text = _version_line(_on_device("/recalbox/recalbox.version"))
+ return text if re.fullmatch(r"\d+(?:\.\d+)+", text) else ""
+
+
+def _retrobat_version(bios_path: Path) -> str:
+ # /system/version.info, which es-update writes and es-checkversion
+ # reads (emulatorlauncher 48cdc616, es-update/RetrobatVersion.cs:66-92).
+ # The 8.3.1 installer holds "8.3.1.1-stable-win64".
+ text = _version_line(bios_path.parent / "system" / "version.info")
+ head = text.split("-", 1)[0]
+ return head if re.fullmatch(r"\d+(?:\.\d+)+", head) else ""
+
+
+def _os_release_version(key: str, value: str) -> Callable[[Path], str]:
+ """VERSION_ID from os-release, on the system that names itself so."""
+ def read(_bios_path: Path) -> str:
+ release = _parse_os_release()
+ if release.get(key, "").lower() != value:
+ return ""
+ return release.get("VERSION_ID", "")
+ return read
+
+
+def _same_release(installed: str, listed: str) -> bool:
+ return installed == listed
+
+
+def _retrobat_release(installed: str, listed: str) -> bool:
+ # The release tagged 8.3.1 ships 8.3.1.1: a fourth number is a build of
+ # the tagged version.
+ return installed == listed or installed.startswith(listed + ".")
+
+
+class _VersionSource(NamedTuple):
+ """Where a system states the version it runs, and what a different one means."""
+
+ read: Callable[[Path], str]
+ meaning: str
+ same: Callable[[str, str], bool] = _same_release
+ # Written by the list (v6.1) and not by the system (6.1).
+ prefix: str = ""
+
+
+# The systems whose version a file states, read without running anything.
+# RetroArch writes its version in no file: its lists come from core info
+# files the online updater replaces, so its version is not compared. KNULLI
+# writes its codename (knulli-system.mk:9,100-102, "scarab") and ROCKNIX the
+# date of the build (scripts/image:82), not the release their lists name.
+# AmberELEC's list names a prerelease tag, and how that tag maps to the
+# VERSION its image writes is not established.
+_VERSION_SOURCES: dict[str, _VersionSource] = {
+ "batocera": _VersionSource(_batocera_version, _MD5_CHECK),
+ "recalbox": _VersionSource(_recalbox_version, _MD5_CHECK),
+ "retrobat": _VersionSource(_retrobat_version, _MD5_CHECK, _retrobat_release),
+ # scripts/image:159-164 (Lakka-LibreELEC v6.1) writes VERSION_ID from
+ # OS_VERSION, "6.1" in distributions/Lakka/version, and ID=lakka.
+ "lakka": _VersionSource(
+ _os_release_version("ID", "lakka"),
+ "Lakka checks only that a file is there, so this matters only where a core "
+ "of the version it runs reads a file this list does not name.", prefix="v"),
+ # scripts/image:172-177 (EmuELEC v4.8): VERSION_ID from OS_VERSION, 4.8 in
+ # distributions/EmuELEC/version, and NAME=EmuELEC.
+ "emuelec": _VersionSource(_os_release_version("NAME", "emuelec"), _MD5_CHECK),
+}
+
+
+def version_note(plat: str, bios_path: Path, manifest: dict) -> tuple[str, str, str] | None:
+ """(list version, installed version, sentence) when the system here runs
+ another version than the one the list was built for, else None: an
+ unknown version on either side says nothing."""
+ source = _VERSION_SOURCES.get(plat)
+ listed = manifest.get("version")
+ if (source is None or not isinstance(listed, str) or listed == _NO_LIST_VERSION
+ or not _VERSION_TEXT.fullmatch(listed)):
+ return None
+ installed = source.read(bios_path)
+ if not _VERSION_TEXT.fullmatch(installed):
+ return None
+ compared = listed[len(source.prefix):] if listed.startswith(source.prefix) else listed
+ if source.same(installed, compared):
+ return None
+ name = display_name(plat, manifest)
+ return listed, installed, (
+ f"This list was built for {name} {listed}, this machine runs {installed}. "
+ + source.meaning.format(name=name))
+
+
def _rule_matches(rule: _OsRule, release: dict[str, str]) -> bool:
if rule.method == "os_release":
# Any of the KEY=value pairs, the value compared without case.
@@ -2009,6 +2300,8 @@ def launchbox_bios_dirs(root: Path) -> dict[str, Path]:
# (folder, why in words) for each folder detection could not look in.
_DETECTION_REFUSED: list[tuple[Path, str]] = []
+# The platforms whose configuration names a folder with a control character.
+_UNNAMEABLE: list[str] = []
def refused_words(folder: Path, why: str, shown: str = "") -> str:
@@ -2360,7 +2653,21 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]:
_detect_by_path(os_type, found)
# An embedded system's config read again as the home folder's one (HOME is
# /storage on the ELEC family) is the same folder.
- return list(dict.fromkeys(found))
+ kept = []
+ for name, path in dict.fromkeys(found):
+ if _CONTROLS.search(str(path)):
+ # A config naming a folder with a control character: printed,
+ # the name is a terminal command, and the folder one nobody can
+ # type (code_audit_18 P2-1). Its default is then no answer: the
+ # setup reads the folder named.
+ if name not in _UNNAMEABLE:
+ _UNNAMEABLE.append(name)
+ print(f" {display_name(name)}: the folder its configuration names holds a "
+ f"control character ({shown_path(path)}). Name its BIOS folder with "
+ "--dest.", file=sys.stderr)
+ continue
+ kept.append((name, path))
+ return kept
def _emudeck_windows_bios() -> Path | None:
@@ -2728,11 +3035,14 @@ def _safe_relative_path(value: object, field: str) -> PurePosixPath:
raise ValueError(f"invalid {field}")
if (
"\\" in value
- or "\x00" in value
+ or _CONTROLS.search(value)
or "//" in value
or value.endswith("/")
or re.match(r"^[A-Za-z]:", value)
):
+ # A control character is refused with NUL: a pack's member named
+ # x]0;PWN.bin set the window title in the progress line,
+ # and the file was written under that name (code_audit_18 P2-1).
raise ValueError(f"unsafe {field}: {value!r}")
path = PurePosixPath(value)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
@@ -2759,7 +3069,7 @@ def _safe_target_dir(value: object, field: str) -> str:
"""
if not isinstance(value, str) or not value or len(value) > 2048:
raise ValueError(f"invalid {field}")
- if "\x00" in value:
+ if _CONTROLS.search(value):
raise ValueError(f"unsafe {field}: {value!r}")
parts = re.split(r"[\\/]", value)
if any(part == ".." for part in parts):
@@ -2794,6 +3104,38 @@ def _record_moved_folders(root: Path, retrodeck_json: Path) -> None:
_MOVED_FOLDERS[root] = moved
+# Resolved folder -> the same folder as the run names it: /sdcard is a link
+# to /storage/emulated/0 on Android, and one result named the BIOS folder
+# /sdcard/RetroArch/system and its backup in /storage/emulated/0/RetroArch.
+_SPELLED: dict[Path, Path] = {}
+
+
+def _note_spelling(root: Path) -> Path:
+ """root resolved, its own spelling recorded for every path shown below
+ the link it goes through (spelled)."""
+ given, resolved = Path(os.path.abspath(root)), root.resolve()
+ named, real = given, resolved
+ while named.name == real.name and named != named.parent and real != real.parent:
+ named, real = named.parent, real.parent
+ if named != real:
+ try:
+ if Path(os.path.realpath(named)) == real:
+ _SPELLED[real] = named
+ except (OSError, ValueError):
+ pass
+ return resolved
+
+
+def spelled(path: Path | str) -> Path:
+ """path in the form the run names its folders: below a link the BIOS
+ folder was named through, the link's name (_note_spelling)."""
+ path = Path(path)
+ for real in sorted(_SPELLED, key=lambda each: len(each.parts), reverse=True):
+ if path == real or real in path.parents:
+ return _SPELLED[real] / path.relative_to(real)
+ return path
+
+
def _destination_path(root: Path, value: object) -> Path:
"""Place a manifest destination below *root*.
@@ -2807,7 +3149,7 @@ def _destination_path(root: Path, value: object) -> Path:
moved = _MOVED_FOLDERS.get(root, {})
if len(relative.parts) > 1 and relative.parts[0] in moved:
return moved[relative.parts[0]].resolve() / Path(*relative.parts[1:])
- return root.resolve() / Path(*relative.parts)
+ return _note_spelling(root) / Path(*relative.parts)
class SavePlace(NamedTuple):
@@ -3126,6 +3468,21 @@ def _check_data_directory(entry: object, where: str) -> None:
raise ValueError(f"{where}.files[{rel!r}] is invalid")
+def _clean_strings(value: object, keep: str = "") -> object:
+ """value with a "?" for each control character of its strings, keys
+ included, those of keep left: the one filter between a list, a card or
+ a config and the terminal, its paths refused before (_safe_relative_path)."""
+ if isinstance(value, str):
+ return _CONTROLS.sub(lambda found: found.group() if found.group() in keep else "?",
+ value)
+ if isinstance(value, dict):
+ return {_clean_strings(key, keep): _clean_strings(item, keep)
+ for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_clean_strings(item, keep) for item in value]
+ return value
+
+
def _validate_manifest(data: object, plat: str) -> dict:
"""Validate the untrusted install-manifest boundary using stdlib only."""
if not isinstance(data, dict):
@@ -3284,7 +3641,9 @@ def _validate_manifest(data: object, plat: str) -> dict:
raise ValueError(f"invalid standalone copy targets at index {index}")
for value in values:
_safe_target_dir(value, f"standalone_copies[{index}].targets")
- return data
+ # Its paths checked above, every other string (a system's title, a
+ # core's name) is shown as the terminal shows text.
+ return _clean_strings(data)
def _validate_targets(data: object) -> dict[str, dict]:
@@ -3590,9 +3949,9 @@ def fetch_manifest(plat: str) -> dict:
message=words if code != "network"
else f"the file list could not be fetched: {words}")
if code == "offline":
- print(f" No file list for {plat}: {OFFLINE_WORDS}. Give it with "
+ print(f" No file list for {plat}: {offline_words()}. Give it with "
"--manifest FILE (a copy of install/.json) or "
- "--from-pack PACK, or run without --offline.", file=sys.stderr)
+ f"--from-pack PACK, or run {offline_undo()}.", file=sys.stderr)
sys.exit(1)
if code != "network":
print(f" Could not fetch the file list for {plat}.", file=sys.stderr)
@@ -3957,9 +4316,31 @@ def _chosen(entry: dict, wanted: set[str]) -> bool:
return bool(wanted & set(systems))
+def recorded_axes(files: list[dict], omitted: list[dict] = ()) -> frozenset:
+ """What a file list lets --system, --core and --region narrow by: its
+ systems, its cores, its regions. A list from an older release records
+ none. The MinUI family's records cores and regions and no system: all
+ three were refused on it, by a remedy that fetched the same list
+ (tui_audit_11 P2-1)."""
+ entries = [*files, *omitted]
+ systems = any("systems" in entry for entry in entries)
+ if not systems and not any("regions" in entry for entry in entries):
+ # Systems and regions came in one release: a list with neither is
+ # older, its cores read by nothing that narrows.
+ return frozenset()
+ # A current list records the regions of the files a region selects, and
+ # none when it has no such file: --region then keeps every file, as the
+ # pack built with it does.
+ axes = {"regions", *(["systems"] if systems else [])}
+ if any(copy.get("cores") for entry in files
+ for copy in [entry, *entry.get("alternatives", []), *entry.get("slot_choices", [])]):
+ axes.add("cores")
+ return frozenset(axes)
+
+
def records_selection(files: list[dict]) -> bool:
- """Whether a manifest carries what narrowing reads. Older ones do not."""
- return any("systems" in entry for entry in files)
+ """Whether a manifest records anything narrowing reads. Older ones do not."""
+ return bool(recorded_axes(files))
def available_choices(files: list[dict], omitted: list[dict]) -> dict[str, list[str]]:
@@ -4264,7 +4645,9 @@ def place_save_entries(
place = save_directory_for(bios_path, manifest, override)
if isinstance(place, SavePlace):
_SAVE_DIRS[bios_path] = place
- where = str(place.directory)
+ # Named through the link the BIOS folder was named through.
+ _note_spelling(bios_path)
+ where = str(spelled(place.directory))
if (manifest.get("save_directory") or {}).get("by_system"):
where += f"{os.sep}"
if place.by_core:
@@ -4671,7 +5054,9 @@ class KeptFiles:
def summary(self) -> list[dict]:
"""[{folder, files, bytes}], one per run folder, for the report and events."""
with self._lock:
- return [{"folder": str(run), "files": files, "bytes": size}
+ # Named as the run names its BIOS folder: /sdcard, not the
+ # /storage/emulated/0 the link resolves to.
+ return [{"folder": str(spelled(run)), "files": files, "bytes": size}
for run, (files, size) in sorted(self.counts.items())]
@@ -4716,7 +5101,7 @@ def _kept_slot(root: Path, value: object) -> tuple[Path, PurePosixPath]:
moved = _MOVED_FOLDERS.get(root, {})
if len(relative.parts) > 1 and relative.parts[0] in moved:
return moved[relative.parts[0]].resolve(), PurePosixPath(*relative.parts[1:])
- return root.resolve(), relative
+ return _note_spelling(root), relative
def plan_kept(entries: list[dict], root: Path) -> dict[Path, list[int]]:
@@ -4863,7 +5248,7 @@ def _download_one(
if LOCAL.pack is not None:
return _extract_from_pack(f, bios_path, dest, progress)
if offline_requested():
- return f["dest"], False, OfflineReason(f"not fetched: {OFFLINE_WORDS}")
+ return f["dest"], False, OfflineReason(f"not fetched: {offline_words()}")
if f.get("release_asset"):
url = RELEASE_URL.format(asset=urllib.parse.quote(f["release_asset"], safe=""))
else:
@@ -5489,6 +5874,17 @@ NETWORK_FAILURE_ADVICE = (
f" More help: {TROUBLESHOOTING_PAGE}")
+def _status_print(term: Terminal | None) -> Callable[..., None]:
+ """print, with the status words of what it prints in their roles when
+ term paints (Terminal.status)."""
+ if term is None or not term.colour:
+ return print
+
+ def say(*parts: object, **kwargs: Any) -> None:
+ print(*(term.status(str(part)) for part in parts), **kwargs)
+ return say
+
+
def _report_outcome(
paths: list[Path],
downloaded: int,
@@ -5506,8 +5902,15 @@ def _report_outcome(
hints: list[str] | None = None,
sources: str = "",
offline: int = 0,
+ kinds: tuple[Tally, int] | None = None,
+ same_choice: str = "",
+ term: Terminal | None = None,
+ notes: list[str] | None = None,
) -> None:
"""Close the run on what happened, and leave with a status that says so.
+ kinds is the count of the files installed (tally) and of the data
+ tree files, given for a list marking its game data, said apart. notes
+ are lines every ending repeats (a list built for another version).
A run that installed nothing used to end on the word Done and a zero exit,
which reads as success to a person and to a script alike. offline counts
@@ -5515,8 +5918,9 @@ def _report_outcome(
the size question: counted with it, 14,696 files missing ended on Done
and exit 0 once 4 came from this machine. They end the run as files
that failed do, 1 when some were installed and 2 when none were, the
- codes the full screen's run gives.
+ codes the full screen's run gives. term paints the status words.
"""
+ say = _status_print(term)
# A file --offline left out is not a failure, whether the run asked or
# not: without questions it was "14698 files failed", and "Run the same
# command again to retry only those", which cannot fetch them.
@@ -5528,22 +5932,24 @@ def _report_outcome(
offline += len(left)
if transferred and seconds > 0:
verb = "copied from the pack" if LOCAL.pack is not None else "downloaded"
- print(f"\n{format_size(transferred)} {verb} in "
- f"{format_duration(seconds)} ({format_size(int(transferred / seconds))}/s).")
+ say(f"\n{format_size(transferred)} {verb} in "
+ f"{format_duration(seconds)} ({format_size(int(transferred / seconds))}/s).")
reasons = {reason for _dest, reason in failures or []}
if failures and len(failures) > 1 and len(reasons) == 1 and reasons != {""}:
# One cause, said once: a read-only folder listed ten of its files
# and "... and 266 more" above the advice that fixes them all.
- print(f" failed: {_plural(len(failures), 'file')} ({reasons.pop()}), "
- f"first {failures[0][0]}")
+ say(f" failed: {_plural(len(failures), 'file')} ({reasons.pop()}), "
+ f"first {failures[0][0]}")
else:
for dest, reason in (failures or [])[:10]:
- print(f" failed: {dest}" + (f" ({reason})" if reason else ""))
+ say(f" failed: {dest}" + (f" ({reason})" if reason else ""))
if failures and len(failures) > 10:
- print(f" ... and {len(failures) - 10} more")
+ say(f" ... and {len(failures) - 10} more")
if declined and not downloaded and not errors and not interrupted and not offline:
- print(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.")
- print(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.")
+ say(f"\nNothing installed: {_plural(declined, 'file')} not downloaded, as answered.")
+ say(f"{_plural(up_to_date, 'file')} already up to date at {_locations(paths)}.")
+ for note in notes or []:
+ say(note)
return
# Left out for an unknown save directory, said with that reason in
# every ending: "the collection does not hold it" blamed the collection.
@@ -5553,11 +5959,12 @@ def _report_outcome(
f"{_plural(len(unplaced), 'file')} read from "
f"{unplaced[0].get('frontend') or 'the frontend'}'s save directory left out: "
"its place is not known. Name it with --save-dir next time."] if unplaced else [])
+ kept += list(notes or [])
if interrupted:
- print(f"\nStopped. {_plural(downloaded, 'file')} installed.")
+ say(f"\nStopped. {_plural(downloaded, 'file')} installed.")
for line in kept:
- print(line)
- print("Run the same command again to carry on from here. Nothing is lost.")
+ say(line)
+ say("Run the same command again to carry on from here. Nothing is lost.")
sys.exit(130)
# A standalone copy fails by absolute path, in an emulator's folder
@@ -5590,77 +5997,86 @@ def _report_outcome(
if offline:
remedy = _offline_remedy()
if not downloaded and not errors:
- print(f"\nNothing installed: {_plural(offline, 'file')} "
- f"{'needs' if offline == 1 else 'need'} the internet, which --offline "
- "does not use.")
- print(f" {remedy}")
+ say(f"\nNothing installed: {_plural(offline, 'file')} "
+ f"{'needs' if offline == 1 else 'need'} the internet, which "
+ f"{offline_named()} does not use.")
+ say(f" {remedy}")
for line in kept:
- print(line)
+ say(line)
if up_to_date:
- print(f"{_plural(up_to_date, 'file')} already up to date at "
- f"{_locations(paths)}.")
+ say(f"{_plural(up_to_date, 'file')} already up to date at "
+ f"{_locations(paths)}.")
sys.exit(2)
failed = f", {errors} failed" if errors else ""
if downloaded:
- print(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, "
- f"{offline} not fetched (--offline){failed}.")
+ say(f"\nFinished with files missing. {_plural(downloaded, 'file')} installed, "
+ f"{offline} not fetched ({offline_named()}){failed}.")
else:
# Nothing written: "Finished with files missing. 0 files installed"
# read as a part done.
- print(f"\nNothing installed: {_plural(offline, 'file')} not fetched "
- f"(--offline){failed}.")
+ say(f"\nNothing installed: {_plural(offline, 'file')} not fetched "
+ f"({offline_named()}){failed}.")
if sources:
- print(f" {sources}")
+ say(f" {sources}")
# Under the files it is about: after the save-directory line, "They"
# read as that one file.
- print(f" {remedy}")
- print(f"Location: {_locations(paths)}")
+ say(f" {remedy}")
+ say(f"Location: {_locations(paths)}")
for line in kept + advice:
- print(line)
+ say(line)
sys.exit(1 if downloaded else 2)
if errors and not downloaded:
- print(f"\nNothing was installed. {_plural(errors, 'file')} failed.")
+ say(f"\nNothing was installed. {_plural(errors, 'file')} failed.")
if failures and all(isinstance(r, (NetworkReason, OfflineReason))
for _d, r in failures):
# The folder was never the problem: naming it as the place being
# written to sent people looking at their disk.
- print(f"Destination: {_locations(paths)}")
+ say(f"Destination: {_locations(paths)}")
else:
- print(f"Location that was being written to: {_locations(paths)}")
+ say(f"Location that was being written to: {_locations(paths)}")
for line in kept + advice:
- print(line)
- print("Run the same command again once that is resolved. "
- "Files already installed are kept.")
+ say(line)
+ say("Run the same command again once that is resolved. "
+ "Files already installed are kept.")
sys.exit(2)
if errors:
- print(f"\nFinished with errors. {_plural(downloaded, 'file')} installed, "
- f"{_plural(errors, 'file')} failed.")
+ say(f"\nFinished with errors. {_plural(downloaded, 'file')} installed, "
+ f"{_plural(errors, 'file')} failed.")
if sources:
- print(f" {sources}")
- print(f"Location: {_locations(paths)}")
+ say(f" {sources}")
+ say(f"Location: {_locations(paths)}")
for line in kept + advice:
- print(line)
+ say(line)
if not all(str(reason).startswith(NOT_KEPT) for _dest, reason in failures or []):
- print("Run the same command again to retry only those.")
+ say("Run the same command again to retry only those.")
sys.exit(1)
- print(f"\nDone. {_plural(downloaded, 'file')} installed, "
- f"{_plural(up_to_date, 'file')} already up to date.")
+ say(f"\nDone. {_plural(downloaded, 'file')} installed, "
+ f"{_plural(up_to_date, 'file')} already up to date.")
+ if kinds and kinds[0].games:
+ say(f" Installed: {kinds_words(kinds[0].bios, kinds[0].games, kinds[1])}.")
if sources:
- print(f" {sources}")
- print(f"Location: {_locations(paths)}")
+ say(f" {sources}")
+ say(f"Location: {_locations(paths)}")
for line in kept:
- print(line)
+ say(line)
if omitted > len(unplaced):
for line in _omission_lines(omitted - len(unplaced),
[entry for entry in omitted_entries or []
- if entry.get("reason") != "no_save_directory"]):
- print(line)
+ if entry.get("reason") != "no_save_directory"],
+ Path(paths[0]) if len(paths) == 1 else None):
+ say(line)
for line in hints or []:
- print(line)
- print("To check this later, run the same command with --check.")
+ say(line)
+ if same_choice:
+ # After the questions, as the screen's result ends: "the same
+ # command" was one that asked them (tui_audit_11 P3-21).
+ say(f"Same choice without questions: {screen_text(same_choice)}")
+ say("To check this later, run that command with --check.")
+ return
+ say("To check this later, run the same command with --check.")
def _offline_remedy(folders: list[str] | None = None) -> str:
@@ -5673,9 +6089,9 @@ def _offline_remedy(folders: list[str] | None = None) -> str:
if labels:
named = ", ".join(screen_text(shown_path(label)) for label in labels)
return (f"They are not in {named}: run the installer again with a pack "
- "(--from-pack) or without --offline.")
+ f"(--from-pack) or {offline_undo()}.")
return ("Copy them from your old card or a folder (--from), or run the installer "
- "again with a pack (--from-pack) or without --offline.")
+ f"again with a pack (--from-pack) or {offline_undo()}.")
def _check_fetch_line(fetch: int, beside: str) -> str:
@@ -5685,7 +6101,7 @@ def _check_fetch_line(fetch: int, beside: str) -> str:
if not fetch:
return " Run the same command without --check to copy them into place."
return (f" {_plural(fetch, 'file')} {'needs' if fetch == 1 else 'need'} the "
- f"internet, which --offline does not use. {_offline_remedy()}")
+ f"internet, which {offline_named()} does not use. {_offline_remedy()}")
return (" Run the same command without --check to fetch them. A file with a wrong "
f"hash is replaced, and the one in place kept in {BACKUP_DIR} beside {beside}.")
@@ -5695,18 +6111,47 @@ def _offline_failure(reason: str) -> bool:
return OFFLINE_WORDS in reason or "makes no network request" in reason
-def _omission_lines(count: int, entries: list[dict]) -> list[str]:
+# What game data is, said wherever it is counted or chosen.
+GAME_DATA_WORDS = "game data: files a game or a game engine reads, of no use to the other cores"
+
+
+def game_data_missing(names: str, count: int, folders: list[Path]) -> str:
+ """The game data a run could not supply, and the folder each goes in:
+ "Copy it from your own copy of the game" named no folder."""
+ one = count == 1
+ places = list(dict.fromkeys(f"{shown_path(folder)}{os.sep}" for folder in folders))
+ where = (f" into {', '.join(places[:3])}" + (f" and {len(places) - 3} more folders"
+ if len(places) > 3 else "")) if places else ""
+ return (f"Not available here, {GAME_DATA_WORDS}, read by "
+ f"{'its game' if one else 'their games'}: {names}. Copy "
+ f"{'it' if one else 'them'} from your own copy of the game{where}.")
+
+
+def _omission_lines(count: int, entries: list[dict], bios_path: Path | None = None) -> list[str]:
"""What was skipped, the files an emulator cannot start without first.
"395 file are not in the collection yet" named nothing and gave one
- reason for all, while the manifest says which file and why.
+ reason for all, while the manifest says which file and why. Game data
+ is said apart, with the folder it goes in, as the screen says it.
"""
one = count == 1
lines = [f"{_plural(count, 'file')} {'was' if one else 'were'} not installed: "
f"the collection does not hold {'it' if one else 'them'}, or its copy "
f"fails {'its' if one else 'their'} check."]
- lines += _needed_lines([e for e in entries if e.get("required")],
+ needed = [e for e in entries if e.get("required")]
+ games = [e for e in needed if e.get("category") == GAME_DATA]
+ lines += _needed_lines([e for e in needed if e.get("category") != GAME_DATA],
"Needed to start an emulator")
+ if games:
+ names = ", ".join(str(e.get("dest") or e.get("name")) for e in games[:3]) + (
+ f" and {len(games) - 3} more" if len(games) > 3 else "")
+ folders = []
+ for entry in games:
+ try:
+ folders.append(_entry_path(bios_path, entry).parent if bios_path else None)
+ except ValueError:
+ continue
+ lines.append(" " + game_data_missing(names, len(games), [f for f in folders if f]))
return lines
@@ -5800,15 +6245,15 @@ def _print_plan(
total out of sight (599 lines for RetroArch): it gives way to the
largest systems, and --verbose lists every file.
"""
- total = sum(f.get("size", 0) for f in to_download)
+ counts = tally(to_download)
trees = [(entry, needed) for entry, needed in trees if needed]
- tree_files = sum(len(needed) for _entry, needed in trees)
- tree_size = sum(meta.get("size", 0) for _entry, needed in trees for meta in needed.values())
+ tree_counts = tally(meta for _entry, needed in trees for meta in needed.values())
+ tree_files = tree_counts.files
parts = []
if to_download:
- parts.append(f"{_plural(len(to_download), 'file')} ({format_size(total)})")
+ parts.append(f"{_plural(counts.files, 'file')} ({format_size(counts.size)})")
if trees:
- parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_size)}, "
+ parts.append(f"{_plural(tree_files, 'data file')} ({format_size(tree_counts.size)}, "
f"extracted from {_plural(len(trees), 'archive')})")
# A pack is copied from, not downloaded: "Would download 23 files" was
# said of a run that makes no network request.
@@ -5822,8 +6267,8 @@ def _print_plan(
# internet".
one = len(to_download) + tree_files == 1
print(f"\n{' and '.join(parts)} {'needs' if one else 'need'} the internet, which "
- "--offline does not use. Without --offline they would go to "
- f"{shown_path(bios_path)}:")
+ f"{offline_named()} does not use. Run {offline_undo()}, and they would go "
+ f"to {shown_path(bios_path)}:")
elif parts:
source = " from the pack" if LOCAL.pack is not None else ""
print(f"\nWould {verb} {' and '.join(parts)}{source} to {shown_path(bios_path)}:")
@@ -5872,7 +6317,8 @@ def _print_plan(
print("\n Nothing was written. Run without --dry-run to install.")
-def _check_free_space(bios_path: Path, needed: int, plat: str = "", code: int = 2) -> None:
+def _check_free_space(bios_path: Path, needed: int, plat: str = "", code: int = 2,
+ card: bool = False) -> None:
"""Stop before writing when the volume cannot hold the download.
Reported against the nearest existing ancestor, since the destination
@@ -5892,6 +6338,12 @@ def _check_free_space(bios_path: Path, needed: int, plat: str = "", code: int =
print(f"\nNot enough space on the drive holding {bios_path}.", file=sys.stderr)
print(f" Needed: {format_size(needed)}. Free: {format_size(free)}.",
file=sys.stderr)
+ if card:
+ # Its system reads this card alone: another drive, with RetroArch's
+ # list, was offered for an Onion card (code_audit_18 P3-2).
+ print(f" Free up {format_size(needed - free)} on the card, then run it again: "
+ "its system reads its BIOS files there only.", file=sys.stderr)
+ sys.exit(code)
if not plat:
print(f" Free up {format_size(needed - free)}, or use --dest to install "
"to another drive.", file=sys.stderr)
@@ -5974,10 +6426,11 @@ def download_files(
print("\n Stopping. Files already installed are kept.")
# The pool has run every future by now: a file that landed after Ctrl+C
# is on disk and counts.
- progress.installed = sum(
- 1 for future in future_map
+ progress.landed = [
+ entry for future, entry in future_map.items()
if not future.cancelled() and future.exception() is None and future.result()[1]
- )
+ ]
+ progress.installed = len(progress.landed)
progress.finish()
progress.emit_transfer()
return failed, interrupted
@@ -6338,6 +6791,7 @@ def _install_standalone_folders(
_report_outcome(
[folder for folder, _entries in plan], downloaded, up_to_date_count, errors,
0, interrupted, failures=failures, transferred=transferred, seconds=seconds,
+ term=term,
)
return True
@@ -6648,12 +7102,66 @@ def share_targets(plat: str) -> list[tuple[str, Path]]:
targets = []
for mount in _mounted_cards():
found = card_system(mount)
- if found is not None and found[0] != plat:
+ doubt = share_doubt(mount)[0]
+ if (found is not None and found[0] != plat) or (doubt and plat not in doubt):
continue
targets.append((f"{_mount_name(mount)}: bios", mount / "bios"))
return targets
+def drive_targets(plat: str) -> list[tuple[str, Path]]:
+ """(row label, BIOS folder) of each drive mounted here offered at the
+ folder question of a setup that runs from no card (RetroArch here): a
+ card of a system that reads plat's list by its own BIOS folder, a drive
+ nothing names by its bios folder. A drive's root is no answer: no setup
+ reads its BIOS files there, and RetroArch's list landed beside the roms/
+ and system/ of a Batocera SHARE drive (code_audit_18 P2-2, tui_audit_11
+ P1-2). Another system's card or drive, and one two systems fit, are
+ reached through their own setup (drives_left_out)."""
+ targets = []
+ for mount in _mounted_cards():
+ found = card_system(mount)
+ if (found is not None and found[0] != plat) or share_doubt(mount)[0]:
+ continue
+ name = _mount_name(mount)
+ if found is not None:
+ below = found[1].relative_to(mount).as_posix() if found[1] != mount else ""
+ what = f"{found[2]} {_card_word(found)}"
+ targets.append((f"{name}: {below} ({what})" if below else f"{name} ({what})",
+ found[1]))
+ else:
+ targets.append((f"{name}: bios (card or drive)", mount / "bios"))
+ return targets
+
+
+def _card_word(system: tuple[str, Path, str]) -> str:
+ """A mounted drive's kind as the first screen writes it: SD card, or
+ SHARE drive."""
+ return "SD card" if drive_kind(system) == "card" else drive_kind(system)
+
+
+def drives_left_out(plat: str) -> str:
+ """Why the drives mounted here that another system reads are not offered
+ at plat's folder question, in words, "" for none."""
+ others = []
+ for mount in _mounted_cards():
+ found = card_system(mount)
+ doubt = share_doubt(mount)[0]
+ # The options that reach it, which work wherever this is asked: the
+ # setup list is not offered once a platform is named.
+ if doubt:
+ names = " or ".join(f"--platform {name}" for name in doubt)
+ others.append(f"{_mount_name(mount)} ({_doubt_kinds(doubt)}: {names} "
+ f"--dest {shown_path(mount)})")
+ elif found is not None and found[0] != plat:
+ word = _name_key(found[2]) or found[0]
+ others.append(f"{_mount_name(mount)} ({found[2]} {_card_word(found)}: "
+ f"--platform {word} --dest {shown_path(mount)})")
+ if not others:
+ return ""
+ return (f"Not offered here, each reads its own system's files: {', '.join(others)}.")
+
+
def ask_platform_folder(os_type: str, plat: str, ask: bool = True,
check: bool = True, found: Path | None = None) -> Path:
"""The BIOS folder for a setup chosen from the list, asked as the
@@ -6675,15 +7183,15 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True,
elif default is not None:
state = "" if _exists(default) else " (it does not exist yet and will be created)"
print(f" {name}'s default BIOS folder: {shown_path(default)}{state}.")
- if shared:
- targets = share_targets(plat)
- for index, (label, folder) in enumerate(targets, 1):
- made = "" if _is_dir(folder) else ", created"
- print(f" {index}) {label} ({shown_path(folder)}{made})")
- else:
- targets = [(card_line(mount), mount) for mount in _mounted_cards()]
- for index, (label, _mount) in enumerate(targets, 1):
- print(f" {index}) {label}")
+ # A drive is offered by the BIOS folder its system reads, the screen's
+ # rows: never its root, and never for a system that reads another list.
+ targets = share_targets(plat) if shared else drive_targets(plat)
+ for index, (label, folder) in enumerate(targets, 1):
+ made = "" if _is_dir(folder) else ", created"
+ print(f" {index}) {label} ({shown_path(folder)}{made})")
+ left_out = "" if shared else drives_left_out(plat)
+ if left_out:
+ print(f" {left_out}")
numbers = "" if not targets else (" or 1" if len(targets) == 1
else f" or 1 to {len(targets)}")
why = ("" if default is not None or shared
@@ -6698,10 +7206,8 @@ def ask_platform_folder(os_type: str, plat: str, ask: bool = True,
if raw is None:
return default
if raw.isdigit() and 1 <= int(raw) <= len(targets):
- folder = targets[int(raw) - 1][1]
- if shared:
- # The screen's row, taken as it takes it.
- return folder
+ # The screen's row, taken as it takes it.
+ return targets[int(raw) - 1][1]
else:
# Named in full on BIOS folder:, Created and Location:, where
# "q" alone said nothing of the folder it was in.
@@ -6771,7 +7277,7 @@ def _default_dest(os_type: str, platform_name: str) -> Path | None:
C:\\userdata\\bios, reported done.
"""
default = _built_in_dest(os_type, platform_name)
- if default is not None and _refused_near(default):
+ if default is not None and (_refused_near(default) or platform_name in _UNNAMEABLE):
# The Flatpak's own folder at 000 hid the retroarch.cfg naming
# ~/Games/bios, and 6.6 GB were offered to a folder it does not read.
return None
@@ -6852,9 +7358,10 @@ def _prompt_platform_choice(
if other is not None:
print(" o) another setup or folder")
print(" q) quit")
- usage = (f" Type a number from 1 to {len(platforms)}"
- + (", o for another setup or folder" if other is not None else "")
- + ", or q to quit.")
+ # "a number from 1 to 1" for a single row (code_audit_18 P3-5).
+ usage = (" Type 1" if len(platforms) == 1 else
+ f" Type a number from 1 to {len(platforms)}") + (
+ ", o for another setup or folder" if other is not None else "") + ", or q to quit."
while True:
choice = _ask(f"> [Enter for {named[0]}] ").lower()
if not choice:
@@ -6891,7 +7398,7 @@ def _quit() -> None:
def _sized(files: list[dict]) -> str:
- return f"{_plural(len(files), 'file')}, {format_size(sum(f.get('size', 0) for f in files))}"
+ return sized_words(tally(files))
# A core whose files are all the platform's own. "pcsx_rearmed 0 files,
@@ -7030,14 +7537,28 @@ def folder_warnings(plat: str, bios_path: Path, files: list[dict], hint: Invocat
a setup that runs from that card, whose own folder is the one it reads."""
lines = []
if not _folder_writable(bios_path):
- lines.append("You cannot write to this folder: choose another one.")
- lines += write_remedy(bios_path, plat, hint, commands)
+ lines += cannot_write_words(bios_path, plat, hint, commands, card)
short = _space_short(bios_path, files)
if short is not None:
lines.append(space_words(short[0], short[1], card))
return lines
+def cannot_write_words(bios_path: Path, plat: str, hint: Invocation, commands: bool,
+ card: bool, screen: bool = False, remedy: bool = True) -> list[str]:
+ """What the plan says of a folder this user cannot write, one remedy
+ for a card: unlock it, never another folder its system does not read.
+ Line mode has no folder row: "choose another one" there offered no way
+ to (code_audit_18 P3-3), so it names --dest, the screen its row."""
+ said = write_remedy(bios_path, plat, hint, commands) if remedy else []
+ if card and _on_read_only_mount(bios_path):
+ return said
+ another = ("choose another one" if screen
+ else "run it again with another --dest" if commands
+ else "run it again and choose another one")
+ return [f"You cannot write to this folder: {another}."] + said
+
+
def space_words(needed: int, free: int, card: bool) -> str:
"""Not enough room, and what to do: on a card setup's own card, room is
made on the card, another folder being one its system does not read."""
@@ -7048,10 +7569,32 @@ def space_words(needed: int, free: int, card: bool) -> str:
"choose another folder.")
+_SAVE_SPLITS = {(True, True): "a folder per system, then one per core, under",
+ (True, False): "a folder per system under",
+ (False, True): "a folder per core under"}
+
+
+def save_where_words(where: str) -> tuple[str, str]:
+ """(words, folder) for where the saves event says files go: the folder
+ without the and it ends with, and those said in words.
+ "go to: /sdcard/RetroArch/saves/" read as a folder named ."""
+ parts = re.split(r"[\\/]", where)
+ by_core = bool(parts) and parts[-1] == ""
+ if by_core:
+ parts = parts[:-1]
+ by_system = bool(parts) and parts[-1] == ""
+ if by_system:
+ parts = parts[:-1]
+ folder = where[:len(os.sep.join(parts))] if (by_core or by_system) else where
+ return _SAVE_SPLITS.get((by_system, by_core), ""), folder
+
+
def saves_line(display: str, count: int, where: str) -> str:
"""Where the files read from the frontend's save directory go."""
+ words, folder = save_where_words(where)
return (f"{_plural(count, 'file')} read from {display}'s save directory "
- f"{'goes' if count == 1 else 'go'} to {where}")
+ f"{'goes' if count == 1 else 'go'} to {words + ' ' if words else ''}"
+ f"{shown_path(folder)}")
def unplaced_line(display: str, count: int, remedy: str) -> str:
@@ -7090,10 +7633,14 @@ def _prompt_custom_selection(
bios_path: Path | None = None, choosable: bool = True,
given: tuple[list[str], list[str], list[str]] | None = None,
manifest: dict | None = None, narrowed: bool = False,
- command: Callable[[object], str] | None = None,
+ command: Callable[[object], str] | None = None, cut: list[bool] | None = None,
) -> tuple[list[str], list[str], list[str]] | str | None:
"""Offer to narrow the install by system, core and region.
+ cut, given for a list that marks its game data, holds whether g left
+ it out: one key, as one row of the screen's plan, toggles it, and the
+ caller applies it as --no-game-data does.
+
Enter installs everything, the default most runs want. Returns None
then, INSTALLED_ONLY for what `installed` (the files the cores found
installed read) holds, or the three choices. Each empty list keeps
@@ -7113,16 +7660,21 @@ def _prompt_custom_selection(
"""
trees = data_trees or []
current = given
+ toggled = False
while True:
- _print_menu_head(plat, files, omitted, installed, name, trees, bios_path, current,
- given, manifest, narrowed)
- if current is not given and command is not None:
+ cutting = bool(cut and cut[0])
+ shown, shown_omitted = ((leave_out_game_data(files, omitted)[:2])
+ if cutting else (files, omitted))
+ _print_menu_head(plat, shown, shown_omitted, installed, name, trees, bios_path,
+ current, object() if toggled else given, manifest, narrowed,
+ menu_choice(files, omitted, installed, current, cutting))
+ if (current is not given or toggled) and command is not None:
print(f" Same choice without questions: {screen_text(command(current))}")
offline = _offline_only()
if offline:
# Said before the menu: "install everything" was offered, then
# nothing came and the run said Done.
- print(" --offline: only the files found on this machine are installed"
+ print(f" {offline_named()}: only the files found on this machine are installed"
+ ("." if LOCAL.folders else ", and no --from names a folder to copy "
"them from."))
# A dry run installs nothing: "install everything" was asked before it.
@@ -7140,17 +7692,30 @@ def _prompt_custom_selection(
print(f" Enter) install {whole}")
if installed is not None and current != INSTALLED_ONLY:
print(f" i) only what the installed cores read: {_sized(installed[0])}")
+ # What c asks, named: "systems, cores and regions" was offered with
+ # a pack that records systems only (tui_audit_11 P3-10).
+ axes = narrowing_words(files, omitted) if choosable else ""
+ choosable = bool(axes)
if choosable:
- print(" c) choose systems, cores and regions"
- + (" again" if current is not given else ""))
+ print(f" c) choose {axes}" + (" again" if current is not given else ""))
# The screen's "Copy the BIOS files from your old card or a folder".
copying = not dry_run
if copying:
print(" f) copy from a folder or an old card")
+ # The game data of the choice the head counts, which g takes out of
+ # it or puts back: counted over the whole list, it said 5089 files
+ # under a head counting 12 for one system. Not offered for a
+ # choice that holds none.
+ games = tally(menu_choice(files, omitted, installed, current, False)[0]
+ if cut is not None else [])
+ if cut is not None and games.games:
+ print(f" g) {'keep' if cut[0] else 'leave out'} the "
+ f"{_plural(games.games, 'game data file')} ({format_size(games.game_size)}), "
+ f"{GAME_DATA_WORDS}")
print(" q) quit")
keys = ["", "q", *(["c"] if choosable else []),
*(["i"] if installed is not None and current != INSTALLED_ONLY else []),
- *(["f"] if copying else [])]
+ *(["f"] if copying else []), *(["g"] if games.games else [])]
answer = _ask("> ").lower()
while answer not in keys:
print(f" Type {', '.join(['Enter', *keys[2:]])} or q.")
@@ -7159,7 +7724,10 @@ def _prompt_custom_selection(
return current
if answer == "q":
_quit()
- if answer == "i":
+ if answer == "g" and cut is not None and games.games:
+ cut[0] = not cut[0]
+ toggled = True
+ elif answer == "i":
current = INSTALLED_ONLY
elif answer == "c":
current = _choose_narrowing(files, omitted, manifest, bios_path)
@@ -7171,15 +7739,13 @@ def _print_menu_head(
plat: str, files: list[dict], omitted: list[dict],
installed: tuple[list[dict], list[dict]] | None, name: str, trees: list[dict],
bios_path: Path | None, current: object, given: object, manifest: dict | None,
- narrowed: bool,
+ narrowed: bool, chosen: tuple[list[dict], list[dict]] | None = None,
) -> None:
"""The plan above the line menu: what the choice holds, its folder, what
- stands in its way and where the save files go, as the screen's plan."""
- shown, kept_omitted = files, omitted
- if current == INSTALLED_ONLY and installed is not None:
- shown, kept_omitted = installed
- elif isinstance(current, tuple):
- shown, kept_omitted = narrow(files, omitted, *current)
+ stands in its way and where the save files go, as the screen's plan.
+ chosen is what the choice keeps (menu_choice), the list every count of
+ the head is taken from."""
+ shown, kept_omitted = chosen or menu_choice(files, omitted, installed, current, False)
# The trees of the systems kept: 2636 data files of blueMSX, Dolphin
# and PPSSPP were counted for every --system tried.
kept_trees = select_data_directories({"data_directories": trees}, shown,
@@ -7202,9 +7768,13 @@ def _print_menu_head(
named = "" if named == ", " else named
if current is not given:
print("\n Counted again for your choices. Nothing is written before Enter.")
- print(f"\n{name or display_name(plat)}: {_sized(shown)}"
+ counts = tally(shown)
+ print(f"\n{name or display_name(plat)}: {sized_words(counts)}"
+ (f", and {_plural(data, 'data file')} ({format_size(data_size)})" if data else "")
+ named + ".")
+ split = split_words(counts) if (manifest or {}).get("categorized") else ""
+ if split:
+ print(f" {split}.")
if bios_path is None:
return
# Named before anything is written: line mode said where only after
@@ -7213,15 +7783,48 @@ def _print_menu_head(
for line in folder_warnings(plat, bios_path, shown, _INVOCATION, True,
on_its_card(plat, bios_path)):
print(f" {line}")
- for line in save_lines(shown, kept_omitted, bios_path, name or display_name(plat)):
+ # The frontend's save directory, named as the end of the run and the
+ # screen name it: "Onion's" before the menu, "RetroArch's" after.
+ for line in save_lines(shown, kept_omitted, bios_path, display_name(plat, manifest)):
print(f" {line}")
+def menu_choice(
+ files: list[dict], omitted: list[dict],
+ installed: tuple[list[dict], list[dict]] | None, current: object, cut: bool,
+) -> tuple[list[dict], list[dict]]:
+ """The files and omissions a choice at the line menu keeps, in the order
+ the run applies it: the systems, cores and regions chosen, or what the
+ installed cores read (i), then the game data left out (g)."""
+ kept, kept_omitted = files, omitted
+ if current == INSTALLED_ONLY and installed is not None:
+ kept, kept_omitted = installed
+ elif isinstance(current, tuple):
+ kept, kept_omitted = narrow(files, omitted, *current)
+ return leave_out_game_data(kept, kept_omitted)[:2] if cut else (kept, kept_omitted)
+
+
+def narrowing_words(files: list[dict], omitted: list[dict]) -> str:
+ """What c chooses among for these files, in words, "" for nothing: a
+ question with one answer is not asked (tui_audit_11 P3-10)."""
+ axes = recorded_axes(files, omitted)
+ choices = available_choices(files, omitted)
+ named = [word for word, axis, enough in (
+ ("systems", "systems", len(listed_systems(files)) > 1),
+ ("cores", "cores", len(choices["cores"]) > 1),
+ ("regions", "regions", bool(choices["regions"]))) if axis in axes and enough]
+ return f"{', '.join(named[:-1])} and {named[-1]}" if len(named) > 1 else "".join(named)
+
+
def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | None,
bios_path: Path | None) -> tuple[list[str], list[str], list[str]]:
"""Systems, cores and regions asked one after the other, the systems
- titled and ordered as the screen's Choose systems lists them."""
- systems_listed = systems_in_order(listed_systems(files), files, manifest, bios_path)
+ titled and ordered as the screen's Choose systems lists them. A question
+ with one answer is skipped: "Cores: 1) azahar" was asked after the 3DS
+ alone was chosen."""
+ listed = listed_systems(files) if "systems" in recorded_axes(files, omitted) else []
+ systems_listed = (systems_in_order(listed, files, manifest, bios_path)
+ if len(listed) > 1 else [])
if OTHER_SYSTEMS in systems_listed:
print(f"\n'{OTHER_TITLE}' holds what cores load for systems this platform")
print("does not list, such as game engines and extra computers.")
@@ -7237,7 +7840,7 @@ def _choose_narrowing(files: list[dict], omitted: list[dict], manifest: dict | N
cores: list[str] = []
core_names = available_choices(kept, kept_omitted)["cores"]
- if core_names:
+ if len(core_names) > 1:
print("\nThe platform's own files for these systems are always kept.")
print("The extra files each core loads can be narrowed.")
cores = _pick("Cores:", [
@@ -7472,6 +8075,28 @@ def _older_list_advice(plat: str = "") -> str:
"installer again to fetch the current one.")
+def _narrowing_advice(plat: str, axis: str, flag: str, recorded: frozenset,
+ pack_alone: bool) -> str:
+ """What to do when a list or a pack records no axis: only a remedy
+ that changes the outcome. The pack's remedy named install/minui.json,
+ refused the same way, and the list's fetched the same list again."""
+ works = [name for name, field in (("--system", "systems"), ("--core", "cores"),
+ ("--region", "regions")) if field in recorded]
+ rest = f"Run it without {flag}" + (f", or narrow with {' and '.join(works)}"
+ if works else "") + "."
+ if pack_alone:
+ listed = LOCAL.pack.list_records if LOCAL.pack is not None else None
+ if listed is not None and axis not in listed:
+ # The pack says its platform's own list lacks the axis too.
+ return f" {display_name(plat)}'s own file list records no {axis} either. {rest}"
+ when = "which records them" if listed is not None else f"when that list records {axis}"
+ return f" Add --manifest FILE, a copy of install/{plat}.json, {when}. {rest}"
+ if recorded:
+ # A current list: the platform has no such axis.
+ return f" {rest}"
+ return _older_list_advice(plat)
+
+
def _narrow_to_emulators(
names: list[str], manifest: dict, plat: str,
files: list[dict], omitted: list[dict],
@@ -7518,25 +8143,26 @@ def _requested_narrowing(
"""The systems, cores and regions named on the command line, checked."""
if not (args.system or args.core or args.region):
return None
- if not records_selection(files):
- # Without the fields, a region filter would match nothing and install
- # everything: refused rather than ignored.
- print(
- f"Error: the {plat} file list records no systems or regions, so "
- "--system, --core and --region cannot narrow it.",
- file=sys.stderr,
- )
- print(_older_list_advice(plat), file=sys.stderr)
- sys.exit(1)
- if LOCAL.pack is not None and LOCAL.manifest is None:
+ pack_alone = LOCAL.pack is not None and LOCAL.manifest is None
+ recorded = recorded_axes(files, omitted)
+ if pack_alone:
# A pack records the systems of its files, not their cores or
# regions: either filter would keep everything, and is refused.
- for flag, given in (("--core", args.core), ("--region", args.region)):
- if given:
- print(f"Error: {LOCAL.pack.name} records the systems of its files, not "
- f"what {flag} narrows by.", file=sys.stderr)
- print(_older_list_advice(plat), file=sys.stderr)
- sys.exit(1)
+ recorded = recorded & {"systems"}
+ asked = [(flag, axis) for flag, given, axis in (
+ ("--system", args.system, "systems"), ("--core", args.core, "cores"),
+ ("--region", args.region, "regions")) if given]
+ for flag, axis in asked:
+ if axis in recorded:
+ continue
+ # Without the field, a filter would match nothing and install
+ # everything: refused rather than ignored, by the axis the list
+ # lacks, with what works on it.
+ source = LOCAL.pack.name if pack_alone else f"the {plat} file list"
+ print(f"Error: {source} records no {axis}, so {flag} cannot narrow it.",
+ file=sys.stderr)
+ print(_narrowing_advice(plat, axis, flag, recorded, pack_alone), file=sys.stderr)
+ sys.exit(1)
choices = available_choices(files, omitted)
try:
systems = resolve_choices(
@@ -7685,6 +8311,13 @@ class PackSource:
self.members: dict[str, tuple[zipfile.ZipFile, zipfile.ZipInfo]] = {}
# The system titles and names the pack's list records, if any.
self.names: dict[str, dict] = {}
+ # What its platform's install list narrows by (systems, cores,
+ # regions), None for a pack that does not say: the remedy for an
+ # option the pack cannot apply names that list only when it can.
+ self.list_records: frozenset | None = None
+ # The version of the platform's list the pack was built from, "" for
+ # a pack that does not record it.
+ self.list_version = ""
try:
for path in self.files:
archive = zipfile.ZipFile(path)
@@ -7772,6 +8405,12 @@ class PackSource:
# A pack is a file anyone can hand over: an id or a name holding a
# control character would reach the terminal as an escape, and is
# dropped.
+ records = data.get("list_records")
+ if isinstance(records, list) and all(isinstance(axis, str) for axis in records):
+ self.list_records = frozenset(records) & {"systems", "cores", "regions"}
+ version = data.get("list_version")
+ if isinstance(version, str) and _VERSION_TEXT.fullmatch(version):
+ self.list_version = version
for key in ("system_titles", "system_names"):
held = data.get(key)
if isinstance(held, dict):
@@ -7814,6 +8453,7 @@ class PackSource:
return {
"manifest_version": 2,
"platform": plat,
+ **({"version": self.list_version} if self.list_version else {}),
**{key: value for key, value in self.names.items() if value},
"files": [
{"dest": f["path"], "sha1": f["sha1"], "size": f["size"],
@@ -7904,7 +8544,30 @@ class _Offline(OSError):
# full-screen interface starts inherits. RETROBIOS_OFFLINE stays the build
# scripts' own: CI and the test suite set it for every process they start.
OFFLINE_VARIABLE = "RETROBIOS_INSTALL_OFFLINE"
-OFFLINE_WORDS = "this run makes no network request (--offline)"
+OFFLINE_WORDS = "this run makes no network request"
+# The value --offline gives the variable: the words then name the option,
+# and the variable itself where it was set (tui_audit_11 P3-12).
+OFFLINE_FLAG_VALUE = "--offline"
+
+
+def offline_named() -> str:
+ """What made this run offline, as the words name it."""
+ value = os.environ.get(OFFLINE_VARIABLE, "")
+ return (f"{OFFLINE_VARIABLE}={value}" if value not in ("", "0", OFFLINE_FLAG_VALUE)
+ else "--offline")
+
+
+def offline_undo() -> str:
+ """How a run gets the network back: without the option, or with the
+ variable unset. "without --offline" told a user who never typed it to
+ remove it."""
+ return ("without --offline" if offline_named() == "--offline"
+ else f"with {OFFLINE_VARIABLE} unset")
+
+
+def offline_words() -> str:
+ """This run makes no network request, and what said so."""
+ return f"{OFFLINE_WORDS} ({offline_named()})"
def offline_requested() -> bool:
@@ -7915,7 +8578,7 @@ def _network_allowed(what: str) -> None:
if LOCAL.pack is not None:
raise _Offline(f"{what}: this run reads a pack and makes no network request")
if offline_requested():
- raise _Offline(f"{what}: {OFFLINE_WORDS}")
+ raise _Offline(f"{what}: {offline_words()}")
def read_local_manifest(path: Path, plat: str) -> dict:
@@ -9919,11 +10582,12 @@ def main() -> None:
if given:
parser.error(reason)
if args.offline:
- # The runs the full-screen interface starts read it from here.
- os.environ[OFFLINE_VARIABLE] = "1"
+ # The runs the full-screen interface starts read it from here, and
+ # the words name the option given.
+ os.environ[OFFLINE_VARIABLE] = OFFLINE_FLAG_VALUE
if offline_requested() and (args.target or args.list_targets):
parser.error("--target and --list-targets read a core list from the network, "
- "which --offline does not use")
+ f"which {offline_named()} does not use")
install_bounded_opener()
if args.from_pack and args.target:
parser.error("--target reads a core list from the network, which --from-pack "
@@ -9942,8 +10606,17 @@ def main() -> None:
"or --list-targets")
# The screens have no choice for --declared-only: the questions ask the
# rest, and the run keeps the platform's own list.
- if args.tui or not (plain_requested(args) or args.declared_only or tui_blocker(args)):
+ blocker = "" if args.tui else tui_blocker(args)
+ if args.tui or not (plain_requested(args) or args.declared_only or blocker):
sys.exit(run_tui(args))
+ small = re.match(r"the terminal is (\d+x\d+), smaller than", blocker or "")
+ # A pseudo-terminal nobody sized (script fed from a pipe) is 0x0: no window.
+ if (small and "0" not in small[1].split("x") and not plain_requested(args)
+ and not args.declared_only):
+ # The questions came line by line with no word of why: the page
+ # says a smaller window says so (tui_audit_11 P3-4).
+ print(f"The window is {small[1]}: questions come line by line "
+ f"({TUI_MIN_COLS}x{TUI_MIN_ROWS} for the full screen).")
if args.events:
# The events own stdout. The text a person reads goes to stderr, so a
# program never has to tell one from the other.
@@ -10034,7 +10707,10 @@ def _open_local_sources(args: argparse.Namespace) -> None:
f"{display_name(plat)}.", file=sys.stderr)
sys.exit(1)
elif len(spelled) == 1:
+ # Named by the pack, not by the user: the setups that read its list
+ # are offered, as for a --manifest.
args.platform = spelled[0]
+ args.platform_from_list = True
else:
among = (" (" + ", ".join(spelled) + ")") if spelled else ""
print(f"Error: name the platform {pack.name} is installed for with "
@@ -10122,6 +10798,12 @@ def _ask_from_folder(files: list[dict] | None = None, bios_path: Path | None = N
_quit()
if raw.isdigit() and 1 <= int(raw) <= len(drives):
label = str(drives[int(raw) - 1])
+ elif raw.isdigit() and drives and not _is_dir(_typed_folder(raw)):
+ # A number out of range, said as one: "2" was read as the folder
+ # ./2 (tui_audit_11 P3-10).
+ count = "1" if len(drives) == 1 else f"a number from 1 to {len(drives)}"
+ print(f" Type {count}, a folder, or Enter.")
+ continue
else:
label = os.path.normpath(str(_typed_folder(raw).absolute()))
refused = copy_source_refusal(Path(label), bios_path)
@@ -10162,9 +10844,9 @@ def _read_from_folder(label: str) -> FolderIndex:
return index
-def _refuse_closed(dest: Path, named: str, then: str = "") -> None:
+def _refuse_closed(dest: Path, named: str) -> None:
"""End the run, with its name, on a folder above dest this user cannot
- open. then is a line said after the error, which it continues."""
+ open."""
closed = closed_folder(dest)
if closed is None:
return
@@ -10173,8 +10855,6 @@ def _refuse_closed(dest: Path, named: str, then: str = "") -> None:
print(f"Error: {screen_text(message)}. Give this user access to it, run as "
f"its owner, or choose another folder than the one {named}.",
file=sys.stderr)
- if then:
- print(f" {then}", file=sys.stderr)
sys.exit(1)
@@ -10262,14 +10942,28 @@ def _card_mount(path: str) -> str:
# Systems whose card a computer sees as their data partition, by the file
# their own build writes there, paths below that partition. KNULLI's is
-# /userdata: S12populateshare:15,105 copies datainit there, and every board's
-# datainit/system/batocera.conf sets system.hostname=KNULLI (knulli-cfw
-# distribution ab3a6809, board/batocera/*/fsoverlay/usr/share/batocera/).
-# ROCKNIX's is /storage: userconfig-setup:12 copies /usr/config into
-# /storage/.config, and its system.cfg sets root.password=rocknix (ROCKNIX
-# distribution 8b70f693, projects/ROCKNIX/packages/rocknix/config/system/
-# configs/system.cfg:152).
+# /userdata, which S12populateshare fills from datainit (knulli-cfw
+# distribution ab3a6809, board/batocera/fsoverlay/etc/init.d/
+# S12populateshare:93). The first rule is system/configs/emulationstation/
+# about.info, which every board's datainit holds with the one line KNULLI
+# (board/batocera/*/fsoverlay/usr/share/batocera/datainit/system/configs/
+# emulationstation/about.info), and which S12populateshare copies back at
+# each start when it is missing or older. EmulationStation reads it as the
+# system's name (knulli-cfw/batocera-emulationstation f7c5ae10,
+# es-app/src/ApiSystem.cpp:190-208) and no menu writes it. The second is
+# system.hostname=KNULLI in system/batocera.conf, which datainit sets
+# (datainit/system/batocera.conf:485) and the HOSTNAME row of the menu
+# rewrites (es-app/src/guis/GuiMenu.cpp:4092): a card renamed there was
+# read as a Batocera SHARE drive. ROCKNIX's is /storage: userconfig-setup:12
+# copies /usr/config into /storage/.config, and its system.cfg sets
+# root.password=rocknix (ROCKNIX distribution 8b70f693, projects/ROCKNIX/
+# packages/rocknix/config/system/configs/system.cfg:153). Nothing else there
+# names ROCKNIX: a password changed with setrootpass
+# (packages/rocknix/sources/scripts/setrootpass:25) leaves the card named
+# by nothing, never by another system.
_SHARE_RULES: tuple[_OsRule, ...] = (
+ _OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_matches",
+ ("system/configs/emulationstation/about.info", r"\AKNULLI\s*$"), "bios"),
_OsRule(_STANDIN_MANIFESTS["KNULLI"], "KNULLI", "file_matches",
("system/batocera.conf", r"^system\.hostname=KNULLI\s*$"), "bios"),
_OsRule("rocknix", "ROCKNIX", "file_matches",
@@ -10342,22 +11036,92 @@ def card_system(root: Path) -> tuple[str, Path, str] | None:
# The SHARE drive of a system a computer reaches over the network, by the
-# file the system keeps on it: Batocera's data part (/userdata) holds
-# system/batocera.conf, Recalbox's (/recalbox/share) system/recalbox.conf,
-# and both read bios/ beside it. KNULLI, built on Batocera, writes the same
-# batocera.conf with its own hostname: its rule (_SHARE_RULES) comes first.
-# Typed at the folder question, the drive was taken as the BIOS folder and
-# filled beside roms/ and system/, where neither reads.
+# file the system keeps on it, and both read bios/ beside it. Batocera's data
+# part (/userdata) holds system/batocera.conf, which S12populateshare copies
+# there (batocera.linux d1966272, board/batocera/fsoverlay/etc/init.d/
+# S12populateshare:104) and which sets system.hostname=BATOCERA
+# (package/batocera/core/batocera-system/batocera.conf:103). Recalbox's
+# (/recalbox/share) holds system/recalbox.conf (recalbox 64b0fd40,
+# board/recalbox/fsoverlay/etc/init.d/S12populateshare:93), a name no menu
+# changes. KNULLI, built on Batocera, writes the same batocera.conf: its
+# rules (_SHARE_RULES) are read first, and a batocera.conf whose hostname
+# is neither system's own, on a drive no about.info names, is one of the
+# two renamed in its menu. That drive is said to be either (share_doubt),
+# never taken for one: a renamed KNULLI card was filled with Batocera's
+# list. Typed at the folder question, a drive was taken as the BIOS folder
+# and filled beside roms/ and system/, where neither reads.
_SHARE_DRIVES: tuple[tuple[str, str], ...] = (
("batocera", "system/batocera.conf"),
("recalbox", "system/recalbox.conf"),
)
+_HOSTNAME_LINE = re.compile(r"^system\.hostname=(.*?)\s*$", re.MULTILINE)
+
+
+def _share_markers(root: Path) -> list[str]:
+ """The platforms whose SHARE drive file is on root's own drive."""
+ return [plat for plat, marker in _SHARE_DRIVES
+ if _is_file(root / marker) and _on_the_drive(root, marker)]
+
+
+def _share_hostname(root: Path) -> str | None:
+ """The hostname system/batocera.conf on root sets, "" when it sets none,
+ None when the file cannot be read."""
+ try:
+ text = (root / "system" / "batocera.conf").read_text(encoding="utf-8",
+ errors="replace")
+ except OSError:
+ return None
+ found = _HOSTNAME_LINE.findall(text)
+ return found[-1] if found else ""
def share_drive(root: Path) -> str | None:
- """The platform whose SHARE drive root is, by its own files, or None."""
- return next((plat for plat, marker in _SHARE_DRIVES
- if _is_file(root / marker) and _on_the_drive(root, marker)), None)
+ """The platform whose SHARE drive root is, by its own files, or None:
+ for none, and for files that name two systems (share_doubt)."""
+ markers = _share_markers(root)
+ if len(markers) != 1:
+ return None
+ if markers == ["batocera"] and _share_hostname(root) not in ("", "BATOCERA"):
+ return None
+ return markers[0]
+
+
+def share_doubt(root: Path) -> tuple[tuple[str, ...], str]:
+ """(platforms, why) for a drive whose files fit more than one system,
+ ((), "") for any other: a batocera.conf renamed in its menu, with no
+ file KNULLI alone writes, or the files of Batocera and Recalbox both."""
+ if card_platform(root) or _card_matches(root):
+ return (), ""
+ markers = _share_markers(root)
+ if len(markers) > 1:
+ return tuple(markers), "it holds system/batocera.conf and system/recalbox.conf"
+ if markers == ["batocera"]:
+ hostname = _share_hostname(root)
+ if hostname not in ("", "BATOCERA"):
+ return ((_STANDIN_MANIFESTS["KNULLI"], "batocera"),
+ f"its system/batocera.conf names the host {screen_text(hostname or '?')}, "
+ "which the menus of KNULLI and Batocera both set")
+ return (), ""
+
+
+def _doubt_kinds(plats: tuple[str, ...]) -> str:
+ """The systems a drive could be, in words: a KNULLI card or a Batocera
+ SHARE drive."""
+ return " or ".join(f"{_a(display_name(plat))} "
+ f"{'SHARE drive' if plat in _SHARE_FOLDERS else 'card'}"
+ for plat in plats)
+
+
+def doubt_words(root: Path) -> str:
+ """What is said of a mounted drive share_doubt cannot name, and how to
+ name it: the same words on the first screen and in line mode."""
+ plats, why = share_doubt(root)
+ if not plats:
+ return ""
+ shown = shown_path(root)
+ kinds = _doubt_kinds(plats)
+ names = " or ".join(f"--platform {plat} --dest {shown}" for plat in plats)
+ return f"{shown} could be {kinds}: {why}. Name it: {names}."
def drive_kind(system: tuple[str, Path, str]) -> str:
@@ -10447,8 +11211,11 @@ def card_setups() -> list[CardSetup]:
rules = _card_rules()
setups = [CardSetup(display_name(plat), plat, "") for plat in AVAILABLE_PLATFORMS
if plat in CARD_PLATFORMS]
- setups += [CardSetup(rule.name, rule.manifest, rule.bios, (rule,), True)
- for rule in _SHARE_RULES]
+ # KNULLI is named by two files: one setup holding both rules.
+ shares = {rule.name: tuple(each for each in _SHARE_RULES if each.name == rule.name)
+ for rule in _SHARE_RULES}
+ setups += [CardSetup(name, rules[0].manifest, rules[0].bios, rules, True)
+ for name, rules in shares.items()]
# AmberELEC and EmuELEC mount their card's games part as /storage/roms,
# the folder their list is written below: AmberELEC 5a3882cb fs-resize
# formats it exFAT as GAMES (packages/sysutils/busybox/scripts/
@@ -10529,9 +11296,12 @@ def _card_is(setup: CardSetup, card: Path, above: bool = False) -> tuple[str, st
names = sorted({rule.name for rule in _card_matches(card, above)})
if setup.name in names:
return "own", setup.name
- if not names and share_drive(card):
- # A Batocera share is no KNULLI card not started yet.
- return "other", display_name(share_drive(card))
+ share = share_drive(card)
+ if not names and share and not (share == "batocera" and setup.name == "KNULLI"):
+ # A Recalbox share is no KNULLI card not started yet. A Batocera
+ # one may be: KNULLI writes the same batocera.conf, and a card
+ # older than its about.info has nothing else (_unknown_card_note).
+ return "other", display_name(share)
return ("other", " and ".join(names)) if names else ("unknown", "")
@@ -10576,11 +11346,17 @@ def _dest_refusal_words(choice: CardChoice, setup: CardSetup | None) -> str:
KNULLI, and for a blank stick named for Onion, both refused the same."""
detail = f" {choice.detail}." if choice.detail else ""
name = setup.name if setup else display_name(choice.plat)
+ if choice.other and choice.plat in _SHARE_FOLDERS and setup is None:
+ return (f"{choice.refused}.{detail} Connect the {name} SHARE drive, or name that "
+ f"drive's own system: --platform {_name_key(choice.other)}")
if choice.other:
return (f"{choice.refused}.{detail} Insert the {name} card, or name that "
f"card's own system: --platform {_name_key(choice.other)}")
if choice.root is not None:
return f"{choice.refused}.{detail} Name the card's root: --dest {shown_path(choice.root)}"
+ if choice.plat in _SHARE_FOLDERS and setup is None:
+ return (f"{choice.refused}.{detail} Connect the {name} SHARE drive and name its "
+ "root with --dest")
return f"{choice.refused}.{detail} Name the top folder of the {name} card with --dest"
@@ -10589,6 +11365,13 @@ def _unknown_card_note(setup: CardSetup, card: Path, folder: Path) -> str:
if setup.name in _GAMES_PART_CARDS:
# Its list writes bios/ below the games part: the plan names that.
folder = folder / "bios"
+ plats, why = share_doubt(card)
+ if setup.name == "KNULLI" and share_drive(card) == "batocera":
+ plats, why = ((setup.plat,), "it holds the system/batocera.conf Batocera and KNULLI "
+ "both write, and no about.info naming KNULLI")
+ if setup.plat in plats:
+ return (f"{shown_path(card)} could be another system's: {why}. Taken as "
+ f"{setup.name}'s, as named: the files go to {shown_path(folder)}")
if setup.fresh:
return (f"Nothing on {shown_path(card)} says it is {_a(setup.name)} card yet (not started "
f"once?): the files go to {shown_path(folder)}")
@@ -10682,10 +11465,24 @@ def _list_card_choice(plat: str, dest: Path) -> CardChoice:
short=f"That is {_a(found[2])} {kind}.", other=found[2])
if found and plat and found[0] != plat:
shown = shown_path(dest)
- return CardChoice(plat, dest, f"Note: {shown} is {_a(found[2])} card, which reads "
+ return CardChoice(plat, dest, f"Note: {shown} is {_a(found[2])} {drive_kind(found)}, "
+ "which reads "
f"the {display_name(found[0])} file list in {shown_path(found[1])}: "
f"--platform {_name_key(found[2])} --dest {shown}. The "
f"{display_name(plat)} list goes into it as named.")
+ doubt, why = ((), "") if found else share_doubt(dest)
+ if plat in doubt:
+ # Named by the user, a drive its files cannot name is taken for the
+ # system named, and the doubt is said.
+ folder = dest / "bios"
+ return CardChoice(plat, folder, f"Note: {shown_path(dest)} could be another system's: "
+ f"{why}. Taken as {display_name(plat)}'s, as named: the files go "
+ f"to {shown_path(folder)}")
+ if doubt and plat in _SHARE_FOLDERS:
+ name = display_name(plat)
+ return CardChoice(plat, dest, refused=f"{shown_path(dest)} could be "
+ f"{_doubt_kinds(doubt)}, not {_a(name)} SHARE drive", detail=why,
+ short=f"That could be {_doubt_kinds(doubt)}.")
matched = [] if found else _card_matches(dest)
if len({(match.manifest, match.bios) for match in matched}) > 1:
names = " and ".join(sorted({match.name for match in matched}))
@@ -10963,7 +11760,8 @@ def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> tuple[str
print(f" File list: {display_name(plat)}. Same choice without questions: "
f"--platform {_name_key(named) if named != plat else plat}")
return plat, choice.folder
- found = list(dict.fromkeys(name for name, _path in detect_platforms(os_type)))
+ # The setups the screen and line mode find, cards mounted here included.
+ found = list(dict.fromkeys(name for name, _path, _label in found_setups(os_type)[0]))
default = found[0] if len(found) == 1 else "" if found else "retroarch"
if not default:
print(f"Error: {' and '.join(display_name(n) for n in found)} found here. "
@@ -10975,6 +11773,24 @@ def _platform_for_dest(os_type: str, dest: Path, interactive: bool) -> tuple[str
return default, dest
+def _several_found(platforms: list[tuple[str, Path]], setups: list) -> None:
+ """Stop a run without questions that found more than one setup, each
+ named with the options that choose it."""
+ labels = {(name, path): label for name, path, label in setups}
+ shown = [(labels.get((name, path)) or _setup_label(name, path), name, path)
+ for name, path in platforms]
+ conflict = os_conflict(platforms)
+ lead = (f"{' and '.join(conflict)} are both detected here, and a machine runs one"
+ if conflict else f"{len(shown)} setups were found here")
+ message = (f"{lead}, and a run without questions installs for one: "
+ + ", ".join(label for label, _n, _p in shown))
+ EVENTS.emit("error", stage="detect", reason="conflict", platform="", message=message)
+ print(f"Error: {message}. Name the one to install for:", file=sys.stderr)
+ for label, name, path in shown:
+ print(f" {label}: --platform {name} --dest {shown_path(path)}", file=sys.stderr)
+ sys.exit(1)
+
+
def _unknown_target(target: str, plat: str, targets: dict) -> None:
print(f"Error: unknown target '{target}' for {plat}.", file=sys.stderr)
if targets:
@@ -11068,10 +11884,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
platforms = [(args.platform, args.dest)]
elif args.platform:
print("Looking for setups here...")
- if interactive and not listing:
- # The cards mounted here too, as the screen finds them: a pack's
- # RetroArch list went to this computer's folder past the Onion
- # card it was downloaded for.
+ if not listing:
+ # The cards mounted here too, as the screen finds them, with or
+ # without questions: a pack's RetroArch list went to this
+ # computer's folder past the Onion card it was downloaded for,
+ # and --yes did so after the screen had found the card.
setups, mounted = found_setups(os_type)
detected = [(name, path) for name, path, _label in setups]
else:
@@ -11097,6 +11914,15 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
platforms = [(args.platform, card_install_dest(
named, args.platform, interactive,
not (args.check or args.dry_run or args.yes)))]
+ elif interactive and getattr(args, "platform_from_list", False) and len(
+ _line_setups(setups, mounted, os_type)) > 1:
+ # A list or a pack that card setups read too: they are offered,
+ # RetroArch first, as the screen offers them. Its folder question
+ # alone left ArkOS, muOS and JELOS out of reach (tui_audit_11 P1-2).
+ print(f" {display_name(args.platform)} was not found here.")
+ platforms = _prompt_manual_platform(
+ os_type, not (args.check or args.dry_run or args.yes), True,
+ f"Which setup is the {display_name(args.platform)} list for?", setups, mounted)
elif interactive:
# Asked as the screen asks, never taken: the default folder was
# filled with a card mounted in the reader, and with nothing
@@ -11161,12 +11987,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
print(" Found ES-DE (BIOS files go to each emulator, not to ES-DE itself)")
if "launchbox" in frontends:
print(" Found LaunchBox")
- if interactive and not listing:
- # A card mounted here is found as the screen finds it: line mode
- # said "No supported platform detected" beside an Onion card.
+ if not listing:
+ # A card mounted here is found as the screen finds it, in every
+ # mode: line mode said "No supported platform detected" beside
+ # an Onion card, and --yes filled this computer's folder.
setups, mounted = found_setups(os_type)
platforms = [(name, path) for name, path, _label in setups]
- offer = True
+ offer = interactive
else:
platforms = detect_platforms(os_type)
if not platforms:
@@ -11201,18 +12028,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
for name, path in platforms:
print(f" Found {found_label(name, path)}")
- conflict = os_conflict(platforms)
- if conflict and not interactive and not listing:
- # A machine runs one system: installing to each folder would write
- # where nothing reads, and picking one would be a guess.
- message = (f"{' and '.join(conflict)} are both detected here, and a "
- "machine runs one")
- EVENTS.emit("error", stage="detect", reason="conflict", platform="",
- message=message)
- print(f"Error: {message}. Name the folder with --dest (and --platform for "
- "its file list).",
- file=sys.stderr)
- sys.exit(1)
+ if len(platforms) > 1 and not interactive and not listing:
+ # A run without questions installs for one setup, the one a person
+ # would have chosen: --yes beside two setups filled both, 14 GB in
+ # two folders, where its help says it stops on what to name.
+ _several_found(platforms, setups)
if platforms:
EVENTS.emit("detect", os=os_type, os_name=_os_name(), platforms=[
{"platform": name, "name": display_name(name), "bios_dir": str(path)}
@@ -11240,9 +12060,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
named = (f"used by default, since {display_name(name)} was not found"
if path in assumed_dests
else f"{found_in[0]} names" if found_in else "detected here")
- _refuse_closed(path, f"{named} (name another with --dest)",
- "Nothing was installed. Name one platform with --platform "
- "to install it alone." if len(platforms) > 1 else "")
+ # One setup by now: two found without questions stopped above.
+ _refuse_closed(path, f"{named} (name another with --dest)")
total_downloaded = 0
total_up_to_date = 0
@@ -11250,6 +12069,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
total_omitted = 0
omitted_seen: list[dict] = []
left_out_seen: list[dict] = []
+ version_notes: list[str] = []
total_interrupted = False
total_bytes = 0
total_seconds = 0.0
@@ -11262,6 +12082,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
failures: list[tuple[str, str]] = []
installed_paths: list[Path] = []
bulk_lines: list[str] = []
+ # The entries installed and the data tree files: the end line counts
+ # BIOS files and game data among them, the data files apart.
+ landed: list[dict] = []
+ data_landed = 0
+ categorized = False
# The BIOS folders read for misplaced files, one per platform.
bios_indexes: list[FolderIndex] = []
@@ -11321,6 +12146,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
else:
print(f"\nFetching file index for {plat_name}...")
manifest = fetch_manifest(plat_name)
+ categorized = categorized or bool(manifest.get("categorized"))
files = manifest.get("files", [])
omitted_files = manifest.get("omitted_files", [])
variants = manifest.get("case_variants", [])
@@ -11451,6 +12277,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
args.emulator, manifest, plat_name, files, omitted_files
)
+ # Said before the install and again with the result.
+ note = None if listing else version_note(plat_name, bios_path, manifest)
+ if note is not None:
+ print(f" {note[2]}")
+ version_notes.append(note[2])
+ EVENTS.emit("version", platform=plat_name, list_version=note[0],
+ installed_version=note[1], message=note[2])
+
# Before the menu: it offered 23 files, then 22 were checked, the
# one read from an unknown save directory left out. Said once the
# choice is made, over the files it keeps (say_saves).
@@ -11468,6 +12302,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# What declining the size question points back at, when it was asked.
smaller = ""
given = selection
+ # g at the menu: whether it left the game data out.
+ cut: list[bool] | None = None
menu = interactive and not args.check and not listing
if menu:
installed = (
@@ -11476,11 +12312,18 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
listed = len(manifest.get("files", [])) + len(variants if case_kept else [])
+ # g, the plan's game data row: offered where the list marks its
+ # game data and --no-game-data did not already leave it out.
+ cut = ([False] if manifest.get("categorized") and not args.no_game_data
+ and any(entry.get("category") == GAME_DATA for entry in files) else None)
+
def command_for(choice: object, plat: str = plat_name,
folder: Path = bios_path) -> str:
# The choice made at the questions, every other option of the
# run kept: --dest, --from-pack and --manifest were dropped.
- _INVOCATION.chosen[plat] = chosen_flags(choice)
+ _INVOCATION.chosen[plat] = (
+ (chosen_flags(choice) if choice is not None else [])
+ + ([("--no-game-data", None)] if cut and cut[0] else []))
return _INVOCATION.command(plat, folder)
selection = _prompt_custom_selection(
@@ -11490,7 +12333,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
records_selection(files), given, manifest,
bool(target or args.installed_cores or args.declared_only
or len(files) + game_data_cut + len(unplaced) != listed),
- command_for,
+ command_for, cut,
)
if selection is None and LOCAL.pack is not None:
smaller = " Nothing copied."
@@ -11514,6 +12357,16 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
files, omitted_files = narrow(files, omitted_files, *selection)
unplaced = narrow([], unplaced, *selection)[1]
print(f" Narrowed {before} to {len(files)} files")
+ if cut and cut[0]:
+ # Left out at g, as --no-game-data leaves it out, from what the
+ # choice kept: g offered the game data of the systems chosen,
+ # and the line below counted the whole list's.
+ files, omitted_files, left_out = leave_out_game_data(files, omitted_files)
+ left_out_seen.extend(left_out)
+ _INVOCATION.chosen[plat_name] = (_INVOCATION.chosen.get(plat_name, [])
+ + [("--no-game-data", None)])
+ for line in _left_out_lines(left_out, args.verbose):
+ print(line)
if unplaced:
total_omitted += len(unplaced)
omitted_seen.extend(unplaced)
@@ -11523,8 +12376,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
_print_choices(plat_name, files, omitted_files, args.list_systems, manifest)
continue
- total_size = sum(f.get("size", 0) for f in files)
- print(f" {len(files)} files ({format_size(total_size)})")
+ # The count every later line of the platform divides: the files
+ # checked, planned, offered and installed are these.
+ run_counts = tally(files)
+ print(f" {_plural(run_counts.files, 'file')} ({format_size(run_counts.size)})")
+ split = split_words(run_counts) if manifest.get("categorized") else ""
+ if split:
+ print(f" {split}.")
if omitted_files:
required_omitted = sum(
1 for entry in omitted_files if entry.get("required", True)
@@ -11569,7 +12427,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
present = len(up_to_date) + len(mismatched)
print(
- f" {present}/{len(files)} present "
+ f" {present}/{run_counts.files} present "
f"({len(up_to_date)} verified, {len(mismatched)} wrong hash)"
)
@@ -11732,7 +12590,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
rest = (f" and {'copying' if LOCAL.pack is not None else 'downloading'} "
f"{len(fetched)}" if fetched else "")
where = f" from {LOCAL.pack.name}" if fetched and LOCAL.pack is not None else ""
- print(f"\nCopying {_plural(len(copies), 'file')} found on this machine"
+ # Named by the folder they come from, as the line above
+ # names it: "found on this machine" followed "from E:\".
+ sources = list(dict.fromkeys(copy.index.label for copy in copies.values()))
+ named = ", ".join(screen_text(shown_path(label)) for label in sources)
+ origin = (f"copied from {named}" if all(
+ copy.index is not bios_index for copy in copies.values())
+ else "found on this machine")
+ print(f"\nCopying {_plural(len(copies), 'file')} {origin}"
f"{rest}{where} ({format_size(dl_size)})...")
elif LOCAL.pack is not None:
print(f"\nCopying {len(to_download)} files ({format_size(dl_size)}) "
@@ -11743,7 +12608,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
"carries on from here.")
# Checked before the folder is created: a run stopped for space
# left an empty BIOS folder behind.
- _check_free_space(bios_path, dl_size, plat_name, 1 if total_downloaded else 2)
+ _check_free_space(bios_path, dl_size, plat_name, 1 if total_downloaded else 2,
+ on_its_card(plat_name, bios_path))
created = not bios_path.exists()
try:
bios_path.mkdir(parents=True, exist_ok=True)
@@ -11777,6 +12643,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
)
bulk_lines += hint
total_downloaded += progress.installed
+ # The end line tells BIOS files from game data, as the menu
+ # counted them, over the files that landed: matched by the end
+ # of a failed path, a.bin was dropped when x/a.bin failed.
+ landed.extend(progress.landed)
files_pending += len(to_download) - progress.installed if interrupted \
else len(failed)
total_errors += len(failed)
@@ -11799,12 +12669,13 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
for entry in data_dirs:
counts = install_data_directory(entry, bios_path)
total_downloaded += counts["installed"]
+ data_landed += counts["installed"]
total_up_to_date += counts["present"]
if isinstance(counts["error"], OfflineReason):
# Left out as the BIOS files above are: counted missing,
# not as one failure per tree.
print(f" {entry['ref']}: {counts['present']} already up to date, "
- f"{counts['absent']} need the internet (--offline)")
+ f"{counts['absent']} need the internet ({offline_named()})")
total_offline += counts["absent"]
files_pending += counts["absent"]
continue
@@ -11890,9 +12761,14 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
omitted_entries=omitted_seen,
platform_of={path: plat for plat, path in platforms},
hints=bulk_lines,
+ kinds=((tally(landed), data_landed) if categorized else None),
+ same_choice=(_INVOCATION.command(*platforms[0])
+ if interactive and len(platforms) == 1 else ""),
sources=_run_sources(bios_indexes, total_downloaded,
total_errors + total_offline),
offline=total_offline,
+ term=term,
+ notes=version_notes,
)
finish_app(args, platforms, app_cards, interactive and not total_declined)
@@ -11933,8 +12809,26 @@ _PASTE_END = "\x1b[201~"
# Turned on by the interface, so a pasted path arrives as one piece and its
# newline does not submit it half typed.
_SCREEN_ON = "\x1b[?1049h\x1b[?25l\x1b[?2004h\x1b[2J"
-_SCREEN_OFF = "\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l"
-_STYLES = {"title": "\x1b[1m", "focus": "\x1b[7m", "dim": "\x1b[2m"}
+# The mouse modes are turned off on every way out, turned on or not: a
+# shell left with them on reads each click as a line of garbage.
+_SCREEN_OFF = "\x1b[?1006l\x1b[?1000l\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l"
+# Presses and releases (1000), reported in SGR form (1006): a column past
+# 223 does not fit the legacy form's byte. Never 1003 (every motion): a
+# touch screen has no hover, and nothing here depends on one.
+_MOUSE_ON = "\x1b[?1000h\x1b[?1006h"
+_MOUSE_OFF = "\x1b[?1006l\x1b[?1000l"
+TUI_MOUSE_ENV = "RETROBIOS_NO_MOUSE"
+# How a line is drawn without colour: the screen's title and a headline
+# bold, the row in focus reversed, the bar and secondary text dim.
+_STYLES = {"title": "\x1b[1m", "head": "\x1b[1m", "focus": "\x1b[7m", "dim": "\x1b[2m",
+ "hint": "\x1b[2m"}
+# The role each style is drawn in with colour (tui_paint).
+_COLOUR_BASES = {"title": "strong", "head": "strong", "focus": "selected", "dim": "muted",
+ "hint": "muted", "refusal": "failure"}
+# A count or a size on a headline: "4 files to download, 1.0 MB".
+_KEY_NUMBER = re.compile(r"\b\d[\d,.]*(?: (?:B|KB|MB|GB|TB)\b)?")
+_PROGRESS_BAR = re.compile(r"^ *\[(#*)(-*)\]")
+_FOLDER_LABEL = "BIOS folder: "
class TuiKey(NamedTuple):
@@ -11944,6 +12838,40 @@ class TuiKey(NamedTuple):
text: str = ""
+class TuiMouse(NamedTuple):
+ """One mouse report: the button (0 left, 1 middle, 2 right, 3 a legacy
+ release, 64 and 65 the wheel, plus 4, 8 and 16 for Shift, Meta and
+ Ctrl), the cell counted from 1, and whether the button went up."""
+
+ button: int
+ col: int
+ row: int
+ release: bool = False
+ name: str = "mouse"
+ text: str = ""
+
+
+def _sgr_mouse(params: str, final: str) -> TuiMouse | None:
+ """An SGR report: ESC [ < button ; column ; row, M pressed, m released."""
+ parts = params[1:].split(";")
+ if len(parts) != 3 or not all(part.isdigit() for part in parts):
+ return None
+ button, col, row = (int(part) for part in parts)
+ return TuiMouse(button, col, row, final == "m")
+
+
+def _x10_mouse(data: str) -> TuiMouse | None:
+ """A legacy report: three characters, each 32 plus its value. A
+ position past 95 is a byte UTF-8 cannot read, and the report is dropped."""
+ codes = [ord(char) - 32 for char in data]
+ if any(not 0 <= code <= 223 for code in codes):
+ return None
+ button, col, row = codes
+ if col < 1 or row < 1:
+ return None
+ return TuiMouse(button, col, row, button & 3 == 3 and not button & 64)
+
+
_CSI_KEYS = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end",
"Z": "shift-tab"}
_TILDE_KEYS = {"1": "home", "7": "home", "4": "end", "8": "end", "5": "pgup", "6": "pgdn",
@@ -11967,7 +12895,9 @@ def _plain_key(char: str) -> TuiKey | None:
return TuiKey("char", char)
-def _csi_key(params: str, final: str) -> TuiKey | None:
+def _csi_key(params: str, final: str) -> TuiKey | TuiMouse | None:
+ if params.startswith("<"):
+ return _sgr_mouse(params, final) if final in "Mm" else None
if final == "~":
if params == "200":
return TuiKey("paste-start")
@@ -11983,15 +12913,16 @@ def _csi_key(params: str, final: str) -> TuiKey | None:
return None
-def _escape(buffer: str) -> tuple[int, TuiKey | None]:
+def _escape(buffer: str) -> tuple[int, TuiKey | TuiMouse | None]:
"""The sequence at the start of buffer: the characters it takes and its
key (None for one nobody bound), or 0 while more are needed to tell."""
if len(buffer) < 2:
return 0, None
if buffer[1] == "[":
if buffer[2:3] == "M":
- # An X10 mouse report: three bytes of position follow.
- return (6, None) if len(buffer) >= 6 else (0, None)
+ # A legacy mouse report: three bytes of button and position
+ # follow, read as a report and never as keys.
+ return (6, _x10_mouse(buffer[3:6])) if len(buffer) >= 6 else (0, None)
for end in range(2, len(buffer)):
code = ord(buffer[end])
if 0x40 <= code <= 0x7E:
@@ -12010,7 +12941,8 @@ def _escape(buffer: str) -> tuple[int, TuiKey | None]:
class KeyDecoder:
"""Keys from what a terminal sends, however the reads split it.
- Unknown sequences (F13, mouse reports) are dropped, never taken as text.
+ Unknown sequences (F13) are dropped, never taken as text. A mouse
+ report, SGR or legacy, comes out as a TuiMouse.
"""
def __init__(self) -> None:
@@ -12171,15 +13103,27 @@ def _glued_words(text: str) -> list[str]:
return words
-def _wrap(text: str, width: int, indent: str = " ") -> list[str]:
- """Lines of at most width cells, the ones after the first indented."""
+# A shell word: quoted parts kept with what they quote.
+_SHELL_WORD = re.compile(r"""(?:'[^']*'|"[^"]*"|[^\s'"]+|['"])+""")
+
+
+def _wrap(text: str, width: int, indent: str = " ", whole: str = "") -> list[str]:
+ """Lines of at most width cells, the ones after the first indented.
+
+ whole keeps every word whole, a longer one alone on a line wider than
+ width, which the terminal folds and a copy keeps in one piece: "text"
+ for prose and paths, "command" for a command, whose words are shell
+ words. A restore command was drawn split inside its URL and its folder,
+ and a backup folder's dated name fell on a line of its own."""
if cell_width(text) <= width:
return [text]
lead = text[:len(text) - len(text.lstrip(" "))]
lines: list[str] = []
line = ""
- for word in _glued_words(text):
- for index, piece in enumerate(_split_word(word, width - len(lead + indent))):
+ words = (_SHELL_WORD.findall(text) if whole == "command" else _glued_words(text))
+ for word in words:
+ pieces = [word] if whole else _split_word(word, width - len(lead + indent))
+ for index, piece in enumerate(pieces):
prefix = lead + indent if lines else lead
# The rest of a word broken at a slash joins it without a space:
# a path was drawn "retroarch/ system", and copied wrong.
@@ -12194,9 +13138,15 @@ def _wrap(text: str, width: int, indent: str = " ") -> list[str]:
class TuiLine(str):
- """A screen line and how it is drawn: "title", "focus", "dim" or plain."""
+ """A screen line and how it is drawn: "head" (the screen's title),
+ "title", "focus", "dim", "hint" (the bar), "refusal" or plain.
+
+ mark is what a click on the line reaches (tui_hits): ("row", index,
+ (action, value)) for a row of the list, ("scroll", kind, rows) for the
+ line saying which lines are in view, ("bar",) for the bar."""
style = ""
+ mark: tuple | None = None
def _styled(text: str, style: str = "") -> TuiLine:
@@ -12205,6 +13155,59 @@ def _styled(text: str, style: str = "") -> TuiLine:
return line
+def _bar_keys() -> set[str]:
+ """Every name a key or a button takes on the bar, on every route."""
+ keys = {"Enter", "Esc", "Space", "Start", "Select", "Ctrl-C", "?", "/"}
+ for route in TUI_ROUTES.values():
+ keys.update(route.bar)
+ keys.update(name for name in route.bar.values() if name)
+ keys.update(short.strip() for _long, short in _SHORT_KEYS)
+ return keys
+
+
+def tui_paint(text: str, style: str, colour: bool) -> str:
+ """A drawn line as the terminal gets it: its style, and with colour the
+ roles of its words (COLOUR_ROLES). The characters are those of text.
+
+ The title names RetroBIOS in the accent, a headline its counts and
+ sizes, the bar the key it names first, a line its status words, the
+ plan and the result the BIOS folder in bold.
+ """
+ if not colour:
+ return _STYLES.get(style, "") + text
+ base = _COLOUR_BASES.get(style, "")
+ if style not in ("", "head", "title", "hint", "refusal"):
+ return painted(text, [], base)
+ spans: list[tuple[int, int, str]] = []
+ if style == "hint":
+ start = len(text) - len(text.lstrip(" "))
+ key = text[start:].split(" ", 1)[0]
+ if key in _bar_keys():
+ end = text.find(" ", start)
+ end = len(text) if end < 0 else end
+ spans = [(start, start + len(key), "accent"), (start + len(key), end, "plain")]
+ return painted(text, spans, base)
+ if style == "head":
+ at = text.find("RetroBIOS")
+ if at >= 0:
+ spans.append((at, at + len("RetroBIOS"), "accent"))
+ if style == "title":
+ spans += [(match.start(), match.end(), "accent")
+ for match in _KEY_NUMBER.finditer(text)]
+ lead = text.lstrip(" ")
+ if lead.startswith(_FOLDER_LABEL):
+ start = len(text) - len(lead) + len(_FOLDER_LABEL)
+ spans.append((start, len(text.rstrip(" ")), "strong"))
+ bar = _PROGRESS_BAR.match(text)
+ if bar:
+ spans += [(bar.start(1), bar.end(1), "accent"), (bar.start(2), bar.end(2), "muted")]
+ # On a bold line a status word stays bold.
+ spans += [(start, end, f"strong+{role}" if base == "strong"
+ and not COLOUR_ROLES[role].startswith("1;") else role)
+ for start, end, role in status_spans(text)]
+ return painted(text, _apart(spans), base)
+
+
def _short_path(path: Path | None, home: Path) -> str:
if path is None:
return ""
@@ -12277,6 +13280,12 @@ class TuiState:
self.card_note: str = ""
# The rows the last view drew in its list, what a page moves by.
self.page_rows: int = 0
+ # The targets of the frame on screen (tui_hits), the target a
+ # button went down on, and per screen the first row of a list the
+ # wheel scrolled.
+ self.hits: TuiHits | None = None
+ self.pressed: tuple | None = None
+ self.list_top: dict = {}
# The mounted cards of that system, as last looked for.
self.cards: list | None = None
# (width and text, lines) of the last pager wrapped.
@@ -12353,6 +13362,8 @@ class TuiState:
self.cli_regions: list = []
self.cores: list = []
self.no_game_data: bool = False
+ # A quit the user chose, said as one (tui_quit).
+ self.quit_asked: bool = False
self.installed_only: bool = False
self.jobs: int = 8
self.passthrough: list = []
@@ -12542,6 +13553,8 @@ _TERMINFO_DIRS = ("/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo",
"/usr/lib/terminfo", "/usr/share/lib/terminfo")
# cursor_address, string capability 10 of term(5).
_TERMINFO_CUP = 10
+# max_colors, numeric capability 13.
+_TERMINFO_COLORS = 13
def _terminfo_folders(env: Mapping[str, str]) -> list[Path]:
@@ -12553,19 +13566,47 @@ def _terminfo_folders(env: Mapping[str, str]) -> list[Path]:
return folders + [Path(folder) for folder in _TERMINFO_DIRS]
+def _terminfo_header(data: bytes) -> tuple[int, int, int, int, int] | None:
+ """Of a compiled term(5) entry: the width of a number, the counts of
+ names bytes, booleans, numbers and strings. None when data is none."""
+ if len(data) < 12:
+ return None
+ # The legacy format stores numbers in 2 bytes, the extended one in 4.
+ width = {0o432: 2, 0o1036: 4}.get(int.from_bytes(data[0:2], "little", signed=True))
+ if width is None:
+ return None
+ names, bools, numbers, strings = (int.from_bytes(data[at:at + 2], "little", signed=True)
+ for at in (2, 4, 6, 8))
+ return width, names, bools, numbers, strings
+
+
+def terminfo_number(data: bytes, index: int) -> int | None:
+ """Numeric capability index of a compiled term(5) entry, -1 when the
+ entry lacks it, None when data is no compiled entry."""
+ header = _terminfo_header(data)
+ if header is None:
+ return None
+ width, names, bools, numbers, _strings = header
+ if index >= numbers:
+ return -1
+ start = 12 + names + bools
+ start += start % 2 + index * width
+ if start + width > len(data):
+ return None
+ value = int.from_bytes(data[start:start + width], "little", signed=True)
+ return value if value >= 0 else -1
+
+
def terminfo_string(data: bytes, index: int) -> str | None:
"""String capability index of a compiled term(5) entry, "" when the
entry lacks it, None when data is no compiled entry."""
def short(at: int) -> int:
return int.from_bytes(data[at:at + 2], "little", signed=True)
- if len(data) < 12:
+ header = _terminfo_header(data)
+ if header is None:
return None
- # The legacy format stores numbers in 2 bytes, the extended one in 4.
- width = {0o432: 2, 0o1036: 4}.get(short(0))
- if width is None:
- return None
- names, bools, numbers, strings = short(2), short(4), short(6), short(8)
+ width, names, bools, numbers, strings = header
offsets = 12 + names + bools
offsets += offsets % 2 + numbers * width
if index >= strings:
@@ -12580,23 +13621,64 @@ def terminfo_string(data: bytes, index: int) -> str | None:
return data[start:end].decode("latin-1")
-def terminal_cursor_address(term: str, env: Mapping[str, str]) -> str | None:
- """How TERM's description here moves the cursor, "" when it cannot,
- None when no description is found: Windows, Android and many handheld
- images carry none, and the screen is not held back for it."""
+def _terminfo_data(term: str, env: Mapping[str, str]) -> bytes | None:
+ """TERM's compiled description, the first found, None when none is:
+ Windows, Android and many handheld images carry none."""
if not term or "/" in term or term.startswith("."):
return None
for folder in _terminfo_folders(env):
# Linux files xterm under x/, macOS under 78/.
for sub in (term[0], f"{ord(term[0]):02x}"):
try:
- data = (folder / sub / term).read_bytes()
+ return (folder / sub / term).read_bytes()
except (OSError, ValueError):
continue
- return terminfo_string(data, _TERMINFO_CUP)
return None
+def terminal_cursor_address(term: str, env: Mapping[str, str]) -> str | None:
+ """How TERM's description here moves the cursor, "" when it cannot,
+ None when no description is found, and the screen is not held back
+ for it."""
+ data = _terminfo_data(term, env)
+ return None if data is None else terminfo_string(data, _TERMINFO_CUP)
+
+
+def terminal_colours(term: str, env: Mapping[str, str]) -> int | None:
+ """The colours TERM's description here names, 0 when it names none
+ (vt100), None when no description is found or it cannot be read."""
+ data = _terminfo_data(term, env)
+ if data is None:
+ return None
+ count = terminfo_number(data, _TERMINFO_COLORS)
+ return None if count is None else max(count, 0)
+
+
+def terminal_few_colours(env: Mapping[str, str]) -> str:
+ """Why TERM's description here leaves colour off, "" when it does not:
+ no description is no refusal, as for the cursor."""
+ if os.name == "nt":
+ return ""
+ term = env.get("TERM", "")
+ count = terminal_colours(term, env)
+ if count is not None and count < 8:
+ return f"TERM={term} names {count} colours, fewer than 8"
+ return ""
+
+
+def colour_blocker(env: Mapping[str, str], stream: TextIO) -> str:
+ """Why the full-screen interface draws without colour, "" when it paints
+ with the roles of COLOUR_ROLES. The text is the same either way."""
+ if env.get("NO_COLOR"):
+ return "NO_COLOR is set"
+ isatty = getattr(stream, "isatty", None)
+ if not (isatty and isatty()):
+ return "the output is not a terminal"
+ if os.name != "nt" and env.get("TERM", "") in ("", "dumb"):
+ return "TERM is dumb or not set"
+ return terminal_few_colours(env)
+
+
def terminal_without_ansi(env: Mapping[str, str]) -> str:
"""Why TERM's description here says the ANSI sequences the screen and
the progress line write would print as text, "" when it does not."""
@@ -12666,6 +13748,52 @@ def tui_blocker(
return ""
+# TERM values of terminals that report no mouse the xterm way: the Linux
+# console (gpm is its own protocol), the DEC VTs and BSD consoles.
+_NO_MOUSE_TERMS = ("linux", "vt", "cons", "ansi")
+
+
+def tui_mouse_blocker(
+ stdin: TextIO | None = None, stdout: TextIO | None = None,
+ env: Mapping[str, str] | None = None,
+) -> str:
+ """Why the full screen leaves the mouse to the terminal, "" when it
+ asks for clicks. A pad route has no pointer and its mapper may send
+ anything a report would be confused with, and RETROBIOS_NO_MOUSE keeps
+ the terminal's own selection everywhere."""
+ env = os.environ if env is None else env
+ stdin = stdin or sys.stdin
+ stdout = stdout or sys.stdout
+ if _env_on(env, TUI_MOUSE_ENV):
+ return f"{TUI_MOUSE_ENV} is set"
+ if env.get(TUI_PAD_ENV, "").strip():
+ return f"{TUI_PAD_ENV} names a controller route"
+ if os.name == "nt":
+ # msvcrt.getwch reads keys, never a console mouse record.
+ return "a Windows console reports no clicks as keys"
+ for name, stream in (("input", stdin), ("output", stdout)):
+ isatty = getattr(stream, "isatty", None)
+ if not (isatty and isatty()):
+ return f"the {name} is not a terminal"
+ term = env.get("TERM", "").strip().lower()
+ if term in ("", "dumb", "unknown") or term.startswith(_NO_MOUSE_TERMS):
+ return f"TERM={term} reports no clicks" if term else "TERM is not set"
+ return ""
+
+
+def tui_wants_mouse(state: TuiState, lines: list[str]) -> bool:
+ """Whether the frame takes clicks: not where a command or the result is
+ shown, so the terminal's own selection copies it as it always has, nor
+ where a path is typed, which a middle or right click pastes into."""
+ if state.help:
+ return True
+ if state.screen in ("result", "command", "path"):
+ return False
+ hint = tui_hint(state)
+ text = " ".join(" ".join(str(line).split()) for line in lines)
+ return not any(launcher in text for launcher in (hint.launcher(), hint.launcher(True)))
+
+
def tui_flags(state: TuiState) -> list[tuple[str, str | None]]:
"""The options that carry the choices made on screen, CLI ones included."""
flags = list(state.passthrough)
@@ -12685,7 +13813,10 @@ def tui_flags(state: TuiState) -> list[tuple[str, str | None]]:
if state.jobs != 8:
flags.append(("--jobs", str(state.jobs)))
flags += [("--from", str(folder)) for folder in state.from_folders]
- if offline_requested() and not any(flag == "--offline" for flag, _v in flags):
+ if (os.environ.get(OFFLINE_VARIABLE) == OFFLINE_FLAG_VALUE
+ and not any(flag == "--offline" for flag, _v in flags)):
+ # Given as --offline: said so in the command. Set as the variable,
+ # the runs inherit it and the words name it.
flags.append(("--offline", None))
return flags
@@ -12890,10 +14021,10 @@ def tui_rows(state: TuiState) -> list[TuiRow]:
for label, folder in share_targets(state.platform)]
if not bound:
# The drives mounted here, as line mode numbers them at the same
- # question: a stick in the reader was offered there and not here.
+ # question, each by the BIOS folder its system reads.
drives = [] if state.folder_kind == "share" else [
- TuiRow("drive", f"{_mount_name(mount)} ({_card_what(mount)})", value=mount)
- for mount in _mounted_cards()]
+ TuiRow("drive", label, value=folder)
+ for label, folder in drive_targets(state.platform)]
return rows + [TuiRow("browse", "Browse for a folder"),
TuiRow("type", "Type a path")] + drives
# Only the cards whose own files say they run the system: A on the
@@ -12976,17 +14107,35 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
# Nothing here to copy, and the network refused: Install would
# only fail every file, in this folder or another.
rows = []
- if state.facts.get("writable") is False or _short_of_space(state, check):
+ if _on_its_card(state) and (state.facts.get("writable") is False
+ or _short_of_space(state, check)):
+ # Its system reads this card alone: room is made on it, or it is
+ # unlocked, then counted again, first in focus. Install anyway on
+ # a mount nobody can write would only fail (tui_audit_11 P3-1).
+ locked = state.facts.get("writable") is False
+ rows = [TuiRow("recheck", "Check again"), TuiRow("folder", "Choose another folder"),
+ *([TuiRow("install", "Install anyway")] if rows and not locked else [])]
+ elif state.facts.get("writable") is False or _short_of_space(state, check):
# Every file would fail alike, or the run would stop before the
# first one: the way out is another folder.
rows = [TuiRow("folder", "Choose another folder"),
*([TuiRow("install", "Install anyway")] if rows else [])]
# --system given: the choice stays offered, the list widened when it is
# opened. With one system named it was neither shown nor changeable.
- if records_selection((state.manifest or {}).get("files", [])) and (
+ copy = TuiRow("fromcard", "Copy the BIOS files from your old card or a folder")
+ if not rows:
+ # Nothing can be installed: the remedy the plan names comes first,
+ # in focus. Choose systems was, whose systems need the internet
+ # too (tui_audit_11 P3-8).
+ rows.append(copy)
+ if "systems" in recorded_axes((state.manifest or {}).get("files", [])) and (
len(state.systems) > 1 or state.cli_systems):
rows.append(TuiRow("systems", "Choose systems"))
- rows.append(TuiRow("fromcard", "Copy the BIOS files from your old card or a folder"))
+ game_data = _game_data_row(state)
+ if game_data is not None:
+ rows.append(game_data)
+ if copy not in rows:
+ rows.append(copy)
if _tui_misplaced(state):
rows.append(TuiRow("misplaced", "Show the files in the wrong place"))
if state.unreadable:
@@ -12994,6 +14143,68 @@ def _plan_rows(state: TuiState) -> list[TuiRow]:
return rows + [TuiRow("options", "Options"), TuiRow("quit", "Quit")]
+def _tui_categories(state: TuiState) -> dict[str, str]:
+ """Each destination's category, from the list the screen holds, for a
+ list that marks its game data. The second spellings a folder that keeps
+ case installs are in it too: counted as nothing, the result's BIOS and
+ game data fell short of its total."""
+ manifest = state.manifest or {}
+ if not manifest.get("categorized"):
+ return {}
+ return {str(entry.get("dest")): str(entry.get("category") or "")
+ for entry in [*manifest.get("files", []), *manifest.get("case_variants", [])]}
+
+
+def _tui_tally(state: TuiState, events: Iterable[dict]) -> Tally:
+ """The count of file events (dest and size), each with the category the
+ list gives its destination: the one count the plan, its game data row
+ and the result divide."""
+ categories = _tui_categories(state)
+ return tally({"size": event["size"], "category": categories.get(event["dest"], "")}
+ for event in events)
+
+
+def _tui_split(state: TuiState, events: Iterable[dict]) -> str:
+ """BIOS files and game data apart, for file events, "" when the list
+ marks none or holds no game data among them."""
+ if not _tui_categories(state):
+ return ""
+ return split_words(_tui_tally(state, events))
+
+
+def _tui_estimating(state: TuiState) -> bool:
+ """Whether the plan counts the systems ticked since the check, not the
+ check itself."""
+ return bool(_choice_line(state) and state.systems and state.check is not None
+ and not _offline_only())
+
+
+def _plan_files(state: TuiState) -> list[dict]:
+ """The file events the plan's headline counts: the systems ticked when
+ it estimates them, the check's own otherwise."""
+ return _ticked_files(state) if _tui_estimating(state) else state.files
+
+
+def _game_data_row(state: TuiState) -> TuiRow | None:
+ """The plan's game data row, one press to leave it out or keep it, for
+ a list that marks game data: inside Options alone, it went unseen.
+
+ Counted over the files the plan counts, as g at the line menu: over
+ the whole list, it said 5089 files beside a plan of 12. Once left out
+ the check holds none, and the list's own game data is counted."""
+ if not _tui_categories(state):
+ return None
+ games = _tui_tally(state, _plan_files(state))
+ if not games.games:
+ games = tally(entry for entry in (state.manifest or {}).get("files", [])
+ if entry.get("category") == GAME_DATA)
+ if not games.games:
+ return None
+ sized = f"{_plural(games.games, 'file')}, {format_size(games.game_size)}"
+ return TuiRow("gamedata", f"Game data ({sized}): "
+ + ("left out, change" if state.no_game_data else "kept, leave out"))
+
+
def _offline_only() -> bool:
"""--offline without a pack: what is not on this machine cannot come."""
return offline_requested() and LOCAL.pack is None
@@ -13103,8 +14314,10 @@ def _option_rows(state: TuiState) -> list[TuiRow]:
# The interface said "Name it with --save-dir" and had no row for it.
rows.append(TuiRow("savedir", f"Save folder: {_save_folder_words(state)}",
"--save-dir", style="path"))
+ if not LOCAL.offline:
+ # From a pack, or offline, nothing is downloaded (tui_audit_11 P3-16).
+ rows.append(TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"))
rows += [
- TuiRow("jobs", f"Parallel downloads: {state.jobs}", "--jobs"),
TuiRow("folder", f"BIOS folder: {_short_path(state.dest, state.home)}", "--dest",
style="path"),
TuiRow("from", f"Copy from: {copied}", "--from", style="path"),
@@ -13127,7 +14340,7 @@ def _save_folder_words(state: TuiState) -> str:
if given is not None:
return _short_path(given, state.home)
if state.saves and state.saves.get("folder"):
- return _short_path(Path(state.saves["folder"]), state.home)
+ return _short_path(Path(save_where_words(state.saves["folder"])[1]), state.home)
return "not known"
@@ -13186,8 +14399,11 @@ def _back(state: TuiState) -> list[tuple]:
return effects
-def tui_quit(state: TuiState, code: int = 130) -> list[tuple]:
- """Leave now: with the install's own code once it ran, else 130."""
+def tui_quit(state: TuiState, code: int = 130, asked: bool = True) -> list[tuple]:
+ """Leave now: with the install's own code once it ran, else 130. asked
+ is a quit the user chose (q, Quit), said as one: it read "stopped", the
+ word of an interrupted run (tui_audit_11 P3-19)."""
+ state.quit_asked = asked
if state.screen == "result":
code = state.code if state.code is not None else 1
elif state.screen == "error":
@@ -13203,7 +14419,7 @@ def tui_interrupt(state: TuiState) -> list[tuple]:
return [("kill",)]
state.stopping = True
return [("stop",)]
- return tui_quit(state)
+ return tui_quit(state, asked=False)
def _start_run(state: TuiState, run: str) -> list[tuple]:
@@ -13246,9 +14462,10 @@ def _start_check(state: TuiState) -> list[tuple]:
return [("fetch", state.platform)]
-def _closed_status(state: TuiState, closed: tuple[Path, str]) -> str:
- return (f"This user cannot open {_short_path(closed[0], state.home)} ({closed[1]}): "
- "choose another folder, or give this user access to it.")
+def _closed_status(state: TuiState, closed: tuple[Path, str]) -> TuiLine:
+ folder = _short_path(closed[0], state.home)
+ return _styled(f"This user cannot open {folder} ({closed[1]}): choose another folder, "
+ "or give this user access to it.", "refusal")
def _choose(state: TuiState, plat: str, dest: Path, proposed: bool = False) -> list[tuple]:
@@ -13349,7 +14566,7 @@ def _card_status(state: TuiState, choice: CardChoice, given: bool = False) -> No
else:
then = "Choose the card's top folder."
short = choice.short or choice.refused + "."
- state.status = f"{short} {then}"
+ state.status = _styled(f"{short} {then}", "refusal")
state.status_more = f"{choice.refused}." + (f" {choice.detail}." if choice.detail else "")
@@ -13453,7 +14670,8 @@ def _mount_name(mount: Path) -> str:
if (_WSL["active"] and mount.parent == Path(_WSL["root"]) and len(mount.name) == 1
and mount.name.isalpha()):
return f"{mount.name.upper()}:"
- return mount.name or str(mount)
+ # The label is whoever formatted the card's: shown, never obeyed.
+ return screen_text(mount.name or str(mount))
def _mounted_cards() -> list[Path]:
@@ -13716,10 +14934,8 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
if screen == "folder":
if action == "default":
return _choose(state, state.platform, state.folder_default, proposed=True)
- if action == "share":
+ if action in ("share", "drive"):
return _choose(state, state.platform, row.value, proposed=True)
- if action == "drive":
- return _choose_card(state, row.value)
if action in ("card", "rescan"):
if action == "rescan":
state.cards = mounted_cards_of(_tui_card_bound(state)[0], state.platform)
@@ -13771,7 +14987,7 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
chosen = state.browse_dir if action == "use" else row.value
refused = copy_source_refusal(chosen, state.dest)
if refused:
- state.status = refused
+ state.status = _styled(refused, "refusal")
return []
if chosen not in state.from_folders:
state.from_folders.append(chosen)
@@ -13812,6 +15028,12 @@ def _activate(state: TuiState, row: TuiRow) -> list[tuple]:
return _open_from_browser(state)
if action in ("misplaced", "unreadable"):
return _push(state, action)
+ if action == "recheck":
+ return _start_check(state)
+ if action == "gamedata":
+ # One press, then counted again, as Back from Options counts.
+ state.no_game_data = not state.no_game_data
+ return _start_check(state)
if action == "systems" and state.cli_systems:
# The check read only what --system named: it is read again for
# every system, those named ticked. Its exact plan is kept, to
@@ -14262,7 +15484,7 @@ _DESKTOP_LEAD: dict[str, tuple[str, ...]] = {
# and a Linux one (SteamOS), RetroDECK is a Linux Flatpak, BizHawk publishes
# Windows and Linux builds, RetroPie runs on Debian and Ubuntu.
_COMPUTER_SYSTEMS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
- "Windows": (("windows",), ("retroarch", "retrobat", "emudeck", "bizhawk")),
+ "Windows": (("windows", "pc"), ("retroarch", "retrobat", "emudeck", "bizhawk")),
"macOS": (("macos", "mac", "osx", "macbook"), ("retroarch",)),
"Linux": (("linux", "ubuntu", "debian", "steamos"),
("retroarch", "emudeck", "retrodeck", "bizhawk", "retropie")),
@@ -14272,7 +15494,9 @@ _COMPUTER_SYSTEMS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
def _computer_named(plat: str, needle: str) -> list[str]:
"""The computer systems the filter names that run plat."""
key = _device_key(needle)
- if len(key) < 3:
+ # Two letters are a word on their way: "wi" said "No setup matches"
+ # between "w" and "win" (tui_audit_11 P3-17). "pc" is one of the words.
+ if len(key) < 2:
return []
return [name for name, (words, plats) in _COMPUTER_SYSTEMS.items()
if plat in plats and any(word.startswith(key) for word in words)]
@@ -14540,6 +15764,8 @@ def _pager_last(state: TuiState, items: list) -> int:
def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
"""What a key does on the current screen, and the work it starts."""
+ if key.name == "mouse":
+ return tui_mouse(state, key) # type: ignore[arg-type]
if key.name in ("ctrl-c", "ctrl-d"):
return tui_interrupt(state)
if state.cols < TUI_MIN_COLS or state.rows < TUI_MIN_ROWS:
@@ -14594,6 +15820,12 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
if char == "q":
return tui_quit(state)
rows = tui_rows(state)
+ if screen == "error" and key.name in ("pgup", "pgdn"):
+ # The message scrolls, the rows stay (_view_error).
+ step = max(1, state.rows - len(rows) - 5)
+ state.focus["error_text"] = max(0, state.focus.get("error_text", 0)
+ + (step if key.name == "pgdn" else -step))
+ return []
if screen in _PAGERS and not rows:
return _pager_key(state, key)
if not rows:
@@ -14664,6 +15896,103 @@ def tui_update(state: TuiState, key: TuiKey) -> list[tuple]:
return []
+# Lines one notch of the wheel moves.
+TUI_WHEEL_LINES = 3
+
+
+def tui_mouse(state: TuiState, event: TuiMouse) -> list[tuple]:
+ """What a click, a tap or the wheel does, read against the frame on
+ screen when it came (state.hits): a frame drawn for another screen, or
+ a row no longer where it was drawn, does nothing.
+
+ A tap acts when the button goes up, on the target it went down on: a
+ swipe that ends elsewhere does nothing. On a row it focuses the row,
+ and on the row in focus it does what Enter does: one terminal row is
+ far smaller than a finger, and a single tap acting on it would act on
+ a near miss. On a bar entry it presses that entry's key. The right and
+ middle buttons do nothing, nor a click with Shift, Meta or Ctrl held,
+ which the terminal keeps for its own selection and links.
+ """
+ hits = state.hits
+ if (event.button & 32 or hits is None or hits.screen != state.screen
+ or hits.help != state.help or hits.size != (state.cols, state.rows)
+ or state.cols < TUI_MIN_COLS or state.rows < TUI_MIN_ROWS or state.app_running):
+ state.pressed = None
+ return []
+ target = hits.at(event.col - 1, event.row - 1)
+ if event.button & 64:
+ if event.release or event.button & 3 > 1 or state.help:
+ return []
+ return _wheel(state, hits, 1 if event.button & 1 else -1)
+ if event.button & 28:
+ state.pressed = None
+ return []
+ button = event.button & 3
+ if not event.release:
+ state.pressed = (state.screen, state.help, target) if button == 0 else None
+ return []
+ pressed, state.pressed = state.pressed, None
+ if button not in (0, 3) or pressed != (state.screen, state.help, target):
+ return []
+ if state.help:
+ # Any key closes the help, a tap too.
+ state.help = False
+ return []
+ if target is None:
+ return []
+ if target[0] == "key":
+ return tui_update(state, target[1])
+ if target[0] == "scroll":
+ return _scroll_tap(state, target[1])
+ rows = tui_rows(state)
+ index = target[1]
+ if index >= len(rows) or (rows[index].action, rows[index].value) != target[2]:
+ return []
+ screen = state.screen
+ if state.editing:
+ state.editing = ""
+ elif max(0, min(state.focus.get(screen, 0), len(rows) - 1)) == index:
+ return tui_update(state, TuiKey("enter"))
+ state.status = state.status_more = ""
+ state.focus[screen] = index
+ return []
+
+
+def _wheel(state: TuiState, hits: TuiHits, step: int) -> list[tuple]:
+ """A notch of the wheel: the list, the message or the text moves, and
+ the focus moves only when it would leave the rows in view."""
+ screen = state.screen
+ lines = step * TUI_WHEEL_LINES
+ if hits.listing is not None:
+ first, shown, total = hits.listing
+ top = max(0, min(first + lines, total - shown))
+ state.list_top[screen] = top
+ rows = tui_rows(state)
+ focus = max(0, min(state.focus.get(screen, 0), len(rows) - 1))
+ state.focus[screen] = max(top, min(focus, top + shown - 1))
+ elif hits.scroll == "error":
+ state.focus["error_text"] = max(0, state.focus.get("error_text", 0) + lines)
+ elif hits.scroll == "pager":
+ last = _pager_last(state, _pager_items(state))
+ state.focus[screen] = max(0, min(state.focus.get(screen, 0) + lines, last))
+ return []
+
+
+def _scroll_tap(state: TuiState, kind: str) -> list[tuple]:
+ """A tap on the line saying which lines are in view: the next page,
+ and from the last one the first."""
+ before = (dict(state.focus), dict(state.list_top))
+ effects = tui_update(state, TuiKey("pgdn"))
+ # The view keeps a scroll inside the text: measured, not guessed.
+ tui_view(state, state.cols, state.rows)
+ if (dict(state.focus), dict(state.list_top)) != before:
+ return effects
+ if kind == "error":
+ state.focus["error_text"] = 0
+ return effects
+ return effects + tui_update(state, TuiKey("home"))
+
+
# The routes inside muxterm, where no button sends PgUp or PgDn.
_NO_PAGE_PADS = ("muxterm", "gptokeyb_muxterm")
@@ -14894,11 +16223,17 @@ def _route_hint(state: TuiState, parts: tuple) -> tuple:
_SHORT_VERBS = ((" Only this system", " Only this"), (" First system", " First"))
+# Below this many columns of text the bar always uses the short key names.
+TUI_LONG_BAR_WIDTH = 52
+
+
def _hint(width: int, *parts: str) -> str:
"""The keys that matter now, short names first given up, then whole
entries from the right."""
full = " ".join(parts)
- if cell_width(full) <= width:
+ # One rule for every screen of a width: at 40 columns the plan read
+ # "A Install" and the next screen "A/Enter Install" (tui_audit_11 P3-5).
+ if cell_width(full) <= width and width >= TUI_LONG_BAR_WIDTH:
return full
short = []
for part in parts:
@@ -14933,11 +16268,19 @@ _ROW_VERBS = {
"fromcard": "Choose", "from": "Add a folder", "misplaced": "Show", "zip": "Copy from it",
"unreadable": "Show", "exit": "Exit", "addapp": "Put it on the card",
"cardsystem": "Choose", "rescan": "Look again", "elsewhere": "Choose", "drive": "Choose",
+ "recheck": "Check again", "gamedata": "Change", "share": "Choose",
}
+# The widest a row's two columns spread: at 120 columns a system's size sat
+# 85 columns from its name (tui_audit_11 P3-20).
+TUI_ROW_SPREAD = 78
+
+
def _row_line(row: TuiRow, focused: bool, width: int) -> TuiLine:
left = ("> " if focused else " ") + row.label
+ if row.right:
+ width = min(width, max(TUI_ROW_SPREAD, cell_width(left) + 2 + cell_width(row.right)))
if row.keep_right and row.right:
whole = width - cell_width(left) - 2
forms = (row.right, *row.narrow)
@@ -14970,19 +16313,40 @@ def _row_line(row: TuiRow, focused: bool, width: int) -> TuiLine:
return _styled(text, "focus" if focused else row.style)
-def _text_lines(items: list, width: int) -> list[TuiLine]:
+def _text_lines(items: list, width: int, whole: bool = False) -> list[TuiLine]:
+ """Items as screen lines. whole keeps every word, path and command
+ whole (_wrap): a line wider than width is drawn as it is, the terminal
+ folding it, with the rows it takes after it left to it ("cont")."""
lines: list[TuiLine] = []
for item in items:
style = getattr(item, "style", "")
- if style == "path":
+ if getattr(item, "mark", None) is not None:
+ # A line saying which lines are in view keeps what a click on it does.
+ line = _styled(fit(str(item), width), style)
+ line.mark = item.mark
+ lines.append(line)
+ continue
+ if style == "path" and not whole:
# One line, cut in its middle: the end of a path tells it apart.
lines.append(_styled(middle(str(item), width)))
continue
- for piece in _wrap(str(item), width):
- lines.append(_styled(fit(piece, width), style))
+ kind = ("command" if style == "command" else "text") if whole else ""
+ for piece in _wrap(str(item), width, whole=kind):
+ if cell_width(piece) <= width:
+ lines.append(_styled(fit(piece, width), "" if style in ("path", "command")
+ else style))
+ continue
+ lines.append(_styled(piece, "long"))
+ lines += [_styled("", "cont")] * (-(-cell_width(piece) // width) - 1)
return lines
+def _whole_words(state: TuiState) -> bool:
+ """Whether a pager keeps paths and commands whole: the result and the
+ command line, the screens a person copies from."""
+ return state.screen in ("result", "command")
+
+
def _scrolled(lines: list[TuiLine], first: int, height: int, lead: int = 0) -> list[TuiLine]:
"""height lines of a longer list from first, the last one saying where.
The lead rows (All, None, Filter) are not counted among the entries."""
@@ -15020,14 +16384,25 @@ def _compose(
return top_lines + _compose(state, width, room, before, rows, after) + footer_lines
focus = max(0, min(state.focus.get(state.screen, 0), len(rows) - 1)) if rows else 0
row_lines = [_row_line(row, index == focus, width) for index, row in enumerate(rows)]
+ for index, (row, line) in enumerate(zip(rows, row_lines)):
+ line.mark = ("row", index, (row.action, row.value))
# What L1/R1 move by: the rows drawn. Six fewer than the window's height
# skipped rows the intro left no room for at 40x12, muOS among them.
state.page_rows = max(1, min(len(row_lines), room - 1))
if len(row_lines) >= room:
first = focus - (room - 1) // 2
+ # Scrolled by the wheel, the list stays where it was put while the
+ # focus is in view: centred again, the row under the pointer moved.
+ top = state.list_top.get(state.screen)
+ if top is not None and top <= focus < top + max(1, room - 1):
+ first = top
+ else:
+ state.list_top.pop(state.screen, None)
lead = next((i for i, row in enumerate(rows)
if row.action not in ("all", "none", "filter")), 0)
body = _scrolled(row_lines, first, room, lead)
+ if len(row_lines) > room:
+ body[-1].mark = ("scroll", "list", len(rows))
else:
budget = room - len(row_lines)
top: list[TuiLine] = []
@@ -15060,33 +16435,38 @@ def _pager(
"""
starts = _pager_starts(state, items, width)
total = starts[-1]
+ whole = _whole_words(state)
if total <= height:
- return _text_lines(items, width)
+ return _text_lines(items, width, whole)
room = max(1, height - 1)
first = max(0, min(state.focus.get(state.screen, 0), total - room))
index = bisect.bisect_right(starts, first) - 1
offset = first - starts[index]
lines: list[TuiLine] = []
while index < len(items) and starts[index] < first + room:
- lines += _text_lines([items[index]], width)
+ lines += _text_lines([items[index]], width, whole)
index += 1
skip = starts[lead] if lead else 0
start = max(1, first + 1 - skip)
end = max(start, first + room - skip)
- return lines[offset:offset + room] + [_styled(f"{start}-{end} of {total - skip}", "dim")]
+ where = _styled(f"{start}-{end} of {total - skip}", "dim")
+ where.mark = ("scroll", "pager", total)
+ return lines[offset:offset + room] + [where]
def _pager_starts(state: TuiState, items: list, width: int) -> list[int]:
"""The line each item starts on once wrapped at width, and the total
last, kept while the items stay the same. An item that fits is one line
without being wrapped."""
- key = (width, [(str(item), getattr(item, "style", "")) for item in items])
+ whole = _whole_words(state)
+ key = (width, whole, [(str(item), getattr(item, "style", "")) for item in items])
if state.pager_cache is not None and state.pager_cache[0] == key:
return state.pager_cache[1]
starts = [0]
for item in items:
- fits = getattr(item, "style", "") == "path" or cell_width(str(item)) <= width
- starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width))))
+ fits = ((getattr(item, "style", "") == "path" and not whole)
+ or cell_width(str(item)) <= width)
+ starts.append(starts[-1] + (1 if fits else len(_text_lines([item], width, whole))))
state.pager_cache = (key, starts)
return starts
@@ -15149,7 +16529,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
# folder off a 40x15 screen. The Options row spells it out.
where = ", ".join(f"{len(events)} found in {_last_name(folder)}"
for folder, events in elsewhere.items())
- headline = (f"{where}, {fetch} need the internet (--offline)" if fetch and offline
+ headline = (f"{where}, {fetch} need the internet ({offline_named()})"
+ if fetch and offline
else f"{where}, {fetch} to download ({format_size(fetch_bytes)})"
if fetch else f"{where}, nothing to download")
elif need and fetch and offline:
@@ -15176,8 +16557,8 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
# they read as if they were on hand.
both = fetch + data > 1
headline += ((f" and {_plural(data, 'data file')}{data_size}" if data else "")
- + f" {'need' if both else 'needs'} the internet, which --offline does "
- "not use")
+ + f" {'need' if both else 'needs'} the internet, which "
+ f"{offline_named()} does not use")
elif need:
if data:
headline += f", and {_plural(data, 'data file')}{data_size}"
@@ -15194,8 +16575,16 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
_styled(headline, "title"),
_styled(f"BIOS folder: {_short_path(_plan_folder(state, dest), state.home)}", "path"),
]
+ found_in = _retroarch_config_at(dest) if dest is not None else None
+ if found_in:
+ # Right under the folder it names: after the save lines it read as
+ # naming them (tui_audit_11 P3-6).
+ lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path"))
if state.facts.get("writable") is False:
- lines.append("You cannot write to this folder: choose another one.")
+ # The remedy itself comes below, after the counts.
+ lines += cannot_write_words(dest, state.platform, tui_hint(state),
+ state.pad not in TUI_PAD_ROUTES, _on_its_card(state),
+ screen=True, remedy=False)
# Said again where it is the BIOS folder: the browser said it, and the
# plan named ~ alone.
lines += filter(None, [_no_setup_reads(state, dest)])
@@ -15219,9 +16608,6 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
else state.dry_saves if check is state.dry else None)
saves_lines = _saves_lines(state, saves)
lines += [_styled(line, "path" if index else "") for index, line in enumerate(saves_lines)]
- found_in = _retroarch_config_at(dest) if dest is not None else None
- if found_in:
- lines.append(_styled(f"Named by {_short_path(Path(found_in[0]), state.home)}", "path"))
free = state.facts.get("free")
if free is not None:
lines.append(f"Free space: {format_size(free)}")
@@ -15264,7 +16650,7 @@ def _plan_lines(check: dict, dest: Path | None, state: TuiState) -> list:
# by "They are not in USBSTICK".
offline_lines = [f"{', '.join(empty)} {'holds' if len(empty) == 1 else 'hold'} "
"none of these files: run the installer again with a pack "
- "(--from-pack) or without --offline."]
+ f"(--from-pack) or {offline_undo()}."]
else:
offline_lines = [f"{name} holds none of these files." for name in empty] \
+ offline_lines
@@ -15289,11 +16675,12 @@ def _saves_lines(state: TuiState, saves: dict | None) -> list[str]:
return []
name = display_name(state.platform) if state.platform else "the frontend"
count = saves["files"]
+ words, folder = save_where_words(saves["folder"])
# The folder alone is cut in the middle: cut with the sentence, it
# lost "directory goes to" even at 80 columns.
return [f"{_plural(count, 'file')} read from {name}'s save directory "
- f"{'goes' if count == 1 else 'go'} to:",
- _short_path(Path(saves['folder']), state.home)]
+ f"{'goes' if count == 1 else 'go'} to{' ' + words if words else ''}:",
+ _short_path(Path(folder), state.home)]
def _unplaced_lines(summary: dict | None, state: TuiState,
@@ -15396,6 +16783,9 @@ def _view_folder(state: TuiState, width: int, height: int) -> tuple:
else:
title = "Which folder?"
before = [f"No usual {name} folder here: browse for it or type it."]
+ if not bound and state.folder_kind != "share":
+ left_out = drives_left_out(state.platform)
+ before += [left_out] if left_out else []
return (title, _compose(state, width, height, before, tui_rows(state), _status_after(state),
pinned=state.refused),
("A/Enter Choose", "B/Esc Back", "? Help"))
@@ -15410,12 +16800,15 @@ def _status_after(state: TuiState) -> list[str]:
# Said once: the full form repeated the short one under it. The help
# holds it, and a pad, with no button for the help, reads it here.
if state.pad in ("both", "keys"):
- return [f"{state.status} {'F1' if state.screen == 'path' else '?'} says more."]
+ return [_styled(f"{state.status} {'F1' if state.screen == 'path' else '?'} says more.",
+ getattr(state.status, "style", ""))]
return [state.status, state.status_more]
def _view_browse(state: TuiState, width: int, height: int) -> tuple:
- where = "Drives" if state.browse_dir is None else middle(str(state.browse_dir), width)
+ # Under WSL a drive is E:\ on every screen: the title said /mnt/e
+ # (tui_audit_11 P3-13).
+ where = "Drives" if state.browse_dir is None else middle(shown_path(state.browse_dir), width)
hint: list = []
if state.browse_for == "from":
# Two lines at 40 columns: the long form was cut after "copied,".
@@ -15513,7 +16906,7 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple:
check = state.check or {}
lines = _plan_lines(check, state.dest, state)
choice = _choice_line(state)
- if choice and state.systems and check is state.check and not _offline_only():
+ if _tui_estimating(state):
# The headline counts the systems ticked, as the list just did: at
# 40x12 the line below it was dropped, and the plan showed the
# total of all of them.
@@ -15526,17 +16919,41 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple:
whole = (f"{check['present']} in place, {check['missing']} missing, "
f"{check['wrong_hash']} wrong ")
lines = [line for line in lines if not str(line).startswith(whole)]
- lines[2:2] = choice
- if state.cli_systems and state.systems:
- # Named on the command line: said, and widened by Choose systems.
- lines[2:2] = [f"For {len(state.systems)} of {len(tui_systems(state))} "
- "systems (--system)."]
- if state.card_note and state.card_note != state.status:
- # Under the folder it is about: a stick no file names, taken for
- # KNULLI, read as a KNULLI card.
- lines.insert(2, state.card_note)
+ # Under the folder and what named it, in this order: the card's note,
+ # the choice named on the command line, the choice made here.
+ at = next((index + 1 for index, line in enumerate(lines)
+ if str(line).startswith("BIOS folder:")), 1)
+ if at < len(lines) and str(lines[at]).startswith("Named by "):
+ at += 1
+ named = [*([f"for {len(state.systems)} of {len(tui_systems(state))} systems (--system)"]
+ if state.cli_systems and state.systems else []),
+ *(["--core"] if state.cores else []), *(["--region"] if state.regions else [])]
+ said = ", ".join(named)
+ # Named on the command line: said in line mode's words, and widened by
+ # Choose systems. --core and --region were said in line mode alone
+ # (tui_audit_11 P3-11).
+ lines[at:at] = [*([state.card_note] if state.card_note
+ and state.card_note != state.status else []),
+ *([f"{said[0].upper()}{said[1:]}."] if said else []), *choice]
+ # Of the files the headline counts: those ticked when it estimates
+ # them, those to install otherwise.
+ split = _tui_split(state, _ticked_files(state) if _tui_estimating(state) else [
+ event for event in state.files if event["state"] != "present"])
+ if split or (state.no_game_data and _game_data_row(state) is not None):
+ # BIOS files and game data apart, and what game data is, beside the
+ # row that leaves it out.
+ lines[1:1] = [f"{split or 'No game data'}. {GAME_DATA_WORDS[0].upper()}"
+ f"{GAME_DATA_WORDS[1:]}."]
if state.status:
lines.append(state.status)
+ if width > 38:
+ # A folder that does not fit is wrapped, as the first screen wraps
+ # it: cut in its middle, the plan hid the part being checked before
+ # Install. Cut only on a 40-column screen (tui_audit_11 P3-7).
+ lines = [piece for line in lines for piece in (
+ [_styled(part) for part in _wrap(str(line), width)]
+ if getattr(line, "style", "") == "path" and cell_width(str(line)) > width
+ else [line])]
rows = tui_rows(state)
if width < 54:
rows = [row._replace(label="Copy from your old card or a folder")
@@ -15585,10 +17002,10 @@ def _view_systems(state: TuiState, width: int, height: int) -> tuple:
and cell_width(f"> {row.label} {row.value} {row.right}") + 2 <= width
else row for row in rows]
chosen = _ticked_files(state)
- footer = [f"{len(state.ticked)} of {len(state.systems)} systems: "
- f"{_plural(len(chosen), 'file')}, "
- f"{format_size(sum(e['size'] for e in chosen))}"
- + (" before your options" if _counts_predate_options(state) else "")]
+ total = (f"{len(state.ticked)} of {len(state.systems)} systems: "
+ f"{_plural(len(chosen), 'file')}, {format_size(sum(e['size'] for e in chosen))}"
+ + (" before your options" if _counts_predate_options(state) else ""))
+ footer = [_styled(total, "title")]
focused = rows[min(state.focus.get("systems", 0), len(rows) - 1)]
drawn = str(_row_line(focused, True, width))
if focused.action == "system" and not (
@@ -15674,7 +17091,7 @@ def _pager_items(state: TuiState) -> list:
if state.screen == "result":
return tui_outcome(state)[1]
if state.screen == "command":
- return ["The same choice without questions:", "", tui_command(state)]
+ return ["The same choice without questions:", "", _styled(tui_command(state), "command")]
if state.running:
items = ["Counting the files for these choices..."]
elif state.dry is None:
@@ -15888,10 +17305,8 @@ def _unavailable_lines(state: TuiState) -> list[str]:
f"{'its emulator' if len(firmware) == 1 else 'their emulators'}: "
f"{named(firmware)}. Your own copy goes in the BIOS folder.")
if games:
- lines.append(f"Game data not available here, read by "
- f"{'its game' if len(games) == 1 else 'their games'}: "
- f"{named(games)}. Copy {'it' if len(games) == 1 else 'them'} from "
- f"your own copy of the game.")
+ lines.append(game_data_missing(named(games), len(games), [
+ (state.dest / entry["dest"]).parent for entry in games] if state.dest else []))
return lines
@@ -15919,11 +17334,11 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
if total else "Stopped. Run again to carry on: what is done stays done.")
elif left and installed:
title = "Finished with files missing"
- head = f"{installed} installed, {left} not fetched (--offline){errors}."
+ head = f"{installed} installed, {left} not fetched ({offline_named()}){errors}."
elif left:
title = "Nothing installed"
head = (f"Nothing was installed: {_plural(left, 'file')} not fetched "
- f"(--offline){errors}.")
+ f"({offline_named()}){errors}.")
elif failed and installed:
title = "Finished with errors"
head = f"{installed} installed, {len(failed)} failed."
@@ -15949,12 +17364,25 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
title = "Done"
head = "Everything is in place."
lines = [head]
+ if _tui_categories(state) and title != "Stopped":
+ # As the plan counted them, and as line mode ends: the data tree
+ # files are the installed ones no file event names.
+ done = _tui_tally(state, [{"dest": dest, "size": 0}
+ for dest, good, _why in state.finished if good])
+ if done.games:
+ lines.append("Installed: " + kinds_words(
+ done.bios, done.games, max(0, installed - done.files)) + ".")
+ sources = _tui_sources(state, installed, len(failed) + left)
+ if left and installed and sources:
+ # One count, then the remedy, as line mode says it: the count came
+ # twice, the remedy between them (tui_audit_11 P3-18).
+ lines[0] = sources
+ sources = ""
+ if sources:
+ lines.append(sources)
if left:
lines.append(_offline_remedy([_short_path(Path(folder), state.home)
for folder in state.from_folders]))
- sources = _tui_sources(state, installed, len(failed) + left)
- if sources:
- lines.append(sources)
ok = {dest for dest, good, _why in state.finished if good}
if any(reason and dest in ok for dest, (_folder, reason) in state.local_dests.items()):
# A fix copies: the misplaced files are the user's, never moved.
@@ -15999,8 +17427,9 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
for kept in summary.get("kept") or []:
lines.append(f"The files it replaced are kept in {kept['folder']}")
if typed:
- lines.append(f"To put them back: {run.launcher()} --restore "
- f"{run.word(str(kept['folder']))}")
+ # Broken between its words only, never inside one (_wrap).
+ lines.append(_styled(f"To put them back: {run.launcher()} --restore "
+ f"{run.word(str(kept['folder']))}", "command"))
if summary and not summary.get("interrupted") and installed + up_to_date:
lines += _unavailable_lines(state)
for group in summary.get("left_out") or []:
@@ -16008,7 +17437,7 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]:
lines += _unplaced_lines(summary, state)
lines += _tui_app_lines(state)
if typed:
- lines += ["Same choice without questions:", f" {tui_command(state)}"]
+ lines += ["Same choice without questions:", _styled(f" {tui_command(state)}", "command")]
if tui_keeps_log(state):
lines.append(f"Details: {state.log}")
return title, lines
@@ -16040,6 +17469,9 @@ def tui_keeps_log(state: TuiState) -> bool:
def _view_result(state: TuiState, width: int, height: int) -> tuple:
title, lines = tui_outcome(state)
+ # The outcome in bold, its counts in their roles: it led a list of
+ # lines drawn alike.
+ lines[0] = _styled(lines[0], "title")
rows = tui_rows(state)
if rows:
return (title, _compose(state, width, height, lines, rows),
@@ -16051,9 +17483,23 @@ def _view_result(state: TuiState, width: int, height: int) -> tuple:
def _view_error(state: TuiState, width: int, height: int) -> tuple:
+ """The message and its rows. A message longer than the room the rows
+ leave scrolls, with a line saying which lines are in view: at 40x12 the
+ offline screen dropped its remedy without a word (tui_audit_11 P3-2)."""
+ rows = tui_rows(state)
+ text = _text_lines(list(state.error), width)
+ room = height - len(rows) - (1 if rows else 0)
+ hint: tuple = ("A/Enter Choose", "B/Esc Back")
+ if len(text) > room >= 2:
+ first = max(0, min(state.focus.get("error_text", 0), len(text) - (room - 1)))
+ state.focus["error_text"] = first
+ text = _scrolled(text, first, room)
+ text[-1].mark = ("scroll", "error", 0)
+ page = (TUI_ROUTES.get(state.pad, TUI_ROUTES["both"]).help["page"]
+ or "PgUp/PgDn").split(", ")[0]
+ hint += (f"{page} Scroll",)
return (state.error_title or "Something went wrong",
- _compose(state, width, height, state.error, tui_rows(state)),
- ("A/Enter Choose", "B/Esc Back"))
+ _compose(state, width, height, text, rows), hint)
def _view_quit(state: TuiState, width: int, height: int) -> tuple:
@@ -16094,6 +17540,12 @@ def _help_lines(state: TuiState) -> list[tuple[str, str]]:
return [(names["stop"], "Stop: what is done stays done"),
*([(names["kill"], "Stop")] if names["kill"] else []),
*_route_extras(state), *_help_key(state)]
+ if state.editing:
+ # Typing a filter: Space, a, n and q type there, and the help listed
+ # them as ticks, all, none and quit (tui_audit_11 P3-9).
+ return [("Enter", "Done"), ("Esc", "Clear the filter"),
+ ("Backspace", "Delete a character"), ("Ctrl-U", "Clear"), ("Ctrl-C", "Quit"),
+ *_route_extras(state)]
if screen == "path":
return [(names["enter"], "Use this folder"), ("Backspace", "Delete a character"),
("Ctrl-U", "Clear"), (names["esc"], "Back"), ("Ctrl-C", "Quit"),
@@ -16216,14 +17668,97 @@ def tui_view(state: TuiState, cols: int, rows: int) -> list[str]:
# keys that end it, not Tick and Help.
hint = ("Enter Done", "Esc Clear")
hint = _route_hint(state, _back_hint(state, hint))
- lines = [_styled(fit(f" RetroBIOS: {title}", cols), "title"), _styled("")]
- lines += [_styled(fit(" " + line, cols), line.style) for line in body[:height]]
+ lines = [_styled(fit(f" RetroBIOS: {title}", cols), "head"), _styled("")]
+ # A long line is drawn whole, the terminal folding it into the rows
+ # after it (_text_lines): cut to the width, a copied path lost its end.
+ for line in body[:height]:
+ drawn = _styled(" " + line if line.style == "long" else fit(" " + line, cols),
+ line.style)
+ drawn.mark = line.mark
+ lines.append(drawn)
lines += [_styled("")] * (rows - 1 - len(lines))
# Never the last cell of the screen: a terminal may scroll when it is written.
- lines.append(_styled(fit(" " + _hint(width, *hint), cols - 1), "dim"))
+ bar = _styled(fit(" " + _hint(width, *hint), cols - 1), "hint")
+ bar.mark = ("bar",)
+ lines.append(bar)
return lines
+class TuiHits(NamedTuple):
+ """What a click reaches on one drawn frame (tui_hits): each target is
+ (line, first column, column past the last, what it does), lines and
+ columns counted from 0. listing is (first row shown, rows shown, rows)
+ for a list taller than its room, scroll "pager" or "error" for a text
+ that scrolls."""
+
+ screen: str
+ help: bool
+ size: tuple[int, int]
+ targets: tuple
+ listing: tuple | None = None
+ scroll: str = ""
+
+ def at(self, col: int, line: int) -> tuple | None:
+ return next((target for row, first, end, target in self.targets
+ if row == line and first <= col < end), None)
+
+
+# The keys the bar's entries name, as a click on the entry presses them. A
+# pair of keys (Left/Right, PgUp/PgDn) is no button: which one is meant
+# cannot be told.
+_BAR_CLICKS = {
+ "A/Enter": TuiKey("enter"), "A": TuiKey("enter"), "Enter": TuiKey("enter"),
+ "Start": TuiKey("enter"), "B/Esc": TuiKey("esc"), "B": TuiKey("esc"),
+ "Esc": TuiKey("esc"), "X/Space": TuiKey("char", " "), "X": TuiKey("char", " "),
+ "Space": TuiKey("char", " "), "Y/o": TuiKey("char", "o"), "Y": TuiKey("char", "o"),
+ "o": TuiKey("char", "o"), "/": TuiKey("char", "/"), "Ctrl-U": TuiKey("ctrl-u"),
+ "Ctrl-C": TuiKey("ctrl-c"),
+ # F1 and not ?, which the path screen types into the path.
+ "?": TuiKey("f1"),
+}
+_BAR_ENTRY = re.compile(r"\S+(?: \S+)*")
+
+
+def _bar_click(state: TuiState, name: str) -> TuiKey | None:
+ """The key a bar entry's name stands for, on the route the bar names."""
+ route = TUI_ROUTES.get(state.pad)
+ if route is not None and name not in _BAR_CLICKS:
+ name = next((key for key, mine in route.bar.items() if mine == name), name)
+ return _BAR_CLICKS.get(name)
+
+
+def tui_hits(state: TuiState, lines: list[str]) -> TuiHits:
+ """The targets of the frame tui_view drew: a row of the list across
+ the whole width, its text or not, each entry of the bar that names a
+ key, and the line saying which lines are in view."""
+ cols = max([state.cols, *(cell_width(str(line)) for line in lines)])
+ targets: list[tuple] = []
+ shown: list[int] = []
+ listed = None
+ scroll = ""
+ for number, line in enumerate(lines):
+ mark = getattr(line, "mark", None)
+ if not mark:
+ continue
+ if mark[0] == "row":
+ targets.append((number, 0, cols, mark))
+ shown.append(mark[1])
+ elif mark[0] == "scroll":
+ targets.append((number, 0, cols, mark))
+ if mark[1] == "list":
+ listed = mark[2]
+ else:
+ scroll = mark[1]
+ elif mark[0] == "bar" and not state.help:
+ for entry in _BAR_ENTRY.finditer(str(line)):
+ key = _bar_click(state, entry.group().split(" ", 1)[0])
+ if key is not None:
+ targets.append((number, entry.start(), entry.end(), ("key", key)))
+ rows = (min(shown), len(shown), listed) if listed is not None and shown else None
+ return TuiHits(state.screen, state.help, (state.cols, state.rows), tuple(targets),
+ rows, scroll)
+
+
def _setup_label(plat: str, path: Path) -> str:
system = detected_os(plat, path)
name = display_name(plat)
@@ -16291,15 +17826,46 @@ def found_setups(os_type: str) -> tuple[list[tuple[str, Path, str]], set]:
# On the device itself its own card is the system found, by its own
# path (muOS reads /run/muos/storage/bios, a bind of MUOS/bios).
here = {detected_os(name, path) for name, path in found}
+ taken = []
for card, mount in cards:
- if card and (card[0], card[1]) not in found and card[2] not in here:
+ if card is None:
+ # A drive whose files fit two systems is said, never guessed.
+ words = doubt_words(mount)
+ if words:
+ print(f" {words}")
+ continue
+ if (card[0], card[1]) not in found and card[2] not in here:
_DETECTED_OS.setdefault((card[0], card[1]), card[2])
mounted.add((card[0], card[1]))
- kind = "SHARE drive" if drive_kind(card) != "card" else "SD card"
- setups.append((card[0], card[1], f"{card[2]} {kind} ({_mount_name(mount)})"))
+ taken.append((card, mount))
+ systems = [card[2] for card, _mount in taken]
+ counts = {mount: _bios_count(card[1]) for card, mount in taken
+ if systems.count(card[2]) > 1}
+ # Two cards of one system read alike: each says what its BIOS folder
+ # holds, and the one with fewer files, the card being set up rather
+ # than the old one, comes first (tui_audit_11 P3-15).
+ taken.sort(key=lambda item: (systems.index(item[0][2]), counts.get(item[1], -1)))
+ for card, mount in taken:
+ kind = _card_word(card)
+ held = counts.get(mount)
+ what = ("" if held is None else ", no BIOS folder" if held < 0
+ else f", {_plural(held, 'BIOS file')}")
+ setups.append((card[0], card[1], f"{card[2]} {kind} ({_mount_name(mount)}{what})"))
return tell_apart(setups), mounted
+def _bios_count(folder: Path, cap: int = 99999) -> int:
+ """How many files a card's BIOS folder holds, -1 when it has none."""
+ if not _is_dir(folder):
+ return -1
+ count = 0
+ for _top, _dirs, files in os.walk(folder):
+ count += len(files)
+ if count >= cap:
+ break
+ return count
+
+
def tui_start(
args: argparse.Namespace, os_type: str, detect: bool = True,
) -> tuple[TuiState, list[tuple]]:
@@ -16366,6 +17932,14 @@ def tui_start(
if matched:
state.setups = matched
return state, []
+ if (getattr(args, "platform_from_list", False) and not bound and len(setup_list(
+ state.setups, state.mounted_setups, "", os_type, LOCAL.serves())) > 1):
+ # A list or a pack card setups read too: the setup list, filtered
+ # to them and RetroArch first, as line mode asks (tui_audit_11
+ # P1-2). The folder question alone left them out of reach.
+ state.stack = ["platforms"]
+ state.focus["platforms"] = _platform_focus(state, plat)
+ return state, []
state.platform = plat
state.card_bound = bound
state.stack = []
@@ -16527,11 +18101,11 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None:
# Asked for, not a failure: no Try again, and the two ways a
# run without the network gets its list.
inbox.put(("manifest_failed", plat, TUI_OFFLINE_TITLE, [
- "This run makes no network request (--offline), and the list of "
- "BIOS files comes from the network.",
+ f"This run makes no network request ({offline_named()}), and the list "
+ "of BIOS files comes from the network.",
"Run the installer again with the list saved as a file "
"(--manifest FILE, a copy of install/.json) or with a pack "
- "(--from-pack PACK), or without --offline."], 1))
+ f"(--from-pack PACK), or {offline_undo()}."], 1))
return
title = {"clock": "The clock is wrong",
"no_ca": "No trusted certificates"}.get(code, "No connection")
@@ -16540,7 +18114,11 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None:
", then choose Try again") + "."]
if code == "network":
lines = [f"The list of BIOS files could not be downloaded: {words}.",
- "Check the Wi-Fi or the cable, then choose Try again."]
+ "Check the Wi-Fi or the cable, then choose Try again.",
+ # A handheld with no Wi-Fi has the pack as its way
+ # (tui_audit_11 P3-3).
+ "Or copy a pack to the card and run the installer with --from-pack "
+ "PACK: no network is needed then."]
proxy = next((name for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY",
"all_proxy") if os.environ.get(name)), "")
if proxy:
@@ -16571,10 +18149,25 @@ class _TuiTerminal:
# Python turns a C locale into UTF-8 for itself, not for the
# terminal: a console font there has no CJK, often no accents.
self.ascii = not utf8 or locale_name in ("C", "POSIX")
+ # The same text without colour: NO_COLOR, a dumb or unset TERM, a
+ # description naming fewer than 8 colours, an output no terminal.
+ self.colour = not colour_blocker(os.environ, stdout) and _enable_ansi()
self.ended = False
self.heard = 0.0
self.frame: list[tuple[str, str]] = []
self.entered = False
+ # Whether clicks may be asked for here (tui_mouse_blocker), and
+ # whether they are now: off on a screen whose text is copied.
+ self.mouse_allowed = not tui_mouse_blocker(stdin, stdout)
+ self.mouse = False
+
+ def set_mouse(self, on: bool) -> None:
+ """Ask for clicks or give them back to the terminal, written only
+ when that changes."""
+ on = on and getattr(self, "mouse_allowed", False) and self.entered
+ if on != getattr(self, "mouse", False):
+ self.mouse = on
+ self.write(_MOUSE_ON if on else _MOUSE_OFF)
def enter(self) -> None:
if os.name != "nt":
@@ -16602,6 +18195,9 @@ class _TuiTerminal:
# the cursor are as they were, nothing is written over them.
if getattr(self, "entered", True):
self.entered = False
+ # The mouse modes go with the screen (_SCREEN_OFF), and come
+ # back with the next frame once entered again.
+ self.mouse = False
self.write(_SCREEN_OFF)
finally:
if self.saved is not None:
@@ -16621,7 +18217,14 @@ class _TuiTerminal:
"""Rewrite the lines that changed since the last frame, and only those."""
out = [] if self.frame else ["\x1b[H\x1b[2J"]
frame = [(str(line), getattr(line, "style", "")) for line in lines]
+ covered = 0
for index, (text, style) in enumerate(frame):
+ if style == "cont" and covered:
+ # A row the long line above folds into.
+ covered -= 1
+ continue
+ covered = (max(0, -(-cell_width(text) // max(1, cols)) - 1)
+ if style == "long" else 0)
if index < len(self.frame) and self.frame[index] == (text, style):
continue
text = screen_text(text)
@@ -16629,7 +18232,8 @@ class _TuiTerminal:
text = ascii_text(text)
if style == "focus":
text = _pad(text, min(cols, TUI_MAX_WIDTH) - (index == len(frame) - 1))
- out.append(f"\x1b[{index + 1};1H{_STYLES.get(style, '')}{text}\x1b[0m\x1b[K")
+ out.append(f"\x1b[{index + 1};1H"
+ f"{tui_paint(text, style, getattr(self, 'colour', False))}\x1b[0m\x1b[K")
self.frame = frame
if out:
self.write("".join(out))
@@ -16786,9 +18390,29 @@ def _closing_lines(state: TuiState) -> list[str]:
for line in state.error)]
if state.exit_code == 0:
return [f"RetroBIOS: everything is in place in {state.dest}."]
+ if state.quit_asked:
+ return ["RetroBIOS: quit. Nothing was installed."]
return ["RetroBIOS: stopped. Nothing was installed."]
+CTRL_Z_NO_SHELL = ("Ctrl-Z is ignored: no shell started this installer, and none could "
+ "bring it back.")
+
+
+def tui_ctrl_z(state: TuiState) -> bool:
+ """Whether a Ctrl-Z typed on the screen stops it: only where a shell
+ with job control can resume it, the installer's process group being one
+ of its jobs, not its session's own. Started from a terminal app's -e,
+ a desktop entry or a launcher's sh -c, nothing ever sent SIGCONT, and
+ the screen and its launcher stayed stopped for good (code_audit_18
+ P3-1). Otherwise the key is ignored and the bar says why."""
+ if (hasattr(signal, "SIGSTOP") and hasattr(os, "getsid")
+ and os.getpgrp() != os.getsid(0)):
+ return True
+ state.status, state.status_more = CTRL_Z_NO_SHELL, ""
+ return False
+
+
def tui_suspend(runs: list[_EngineRun], whole: bool) -> None:
"""Stop the screen and the runs it started until fg continues them.
@@ -16956,12 +18580,17 @@ def run_tui(args: argparse.Namespace) -> int:
state.cols, state.rows = cols, rows
terminal.frame = []
state.now = time.monotonic()
- terminal.draw(tui_view(state, cols, rows), cols)
+ lines = tui_view(state, cols, rows)
+ # A click is read against the frame it was made on.
+ state.hits = tui_hits(state, lines)
+ # Before the frame: a frame on screen takes clicks as it shows.
+ terminal.set_mouse(tui_wants_mouse(state, lines))
+ terminal.draw(lines, cols)
for key in terminal.keys(TUI_FRAME):
if key.name == "ctrl-z":
# ISIG is off, so Ctrl-Z comes as a key: it stops what
- # the terminal's would, where the system has job control.
- if hasattr(signal, "SIGSTOP"):
+ # the terminal's would, where a shell can resume it.
+ if tui_ctrl_z(state):
flags["suspend"] = "key"
break
continue
diff --git a/install.sh b/install.sh
index a99ddc501..629d48f74 100755
--- a/install.sh
+++ b/install.sh
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
-DEFAULT_INSTALL_SHA256="71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0"
+DEFAULT_INSTALL_SHA256="7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af"
MAX_INSTALLER_BYTES=2097152
cleanup() {
diff --git a/install/amberelec.json b/install/amberelec.json
index dce7e2a88..2ff1d9e66 100644
--- a/install/amberelec.json
+++ b/install/amberelec.json
@@ -5,8 +5,8 @@
"categorized": true,
"platform": "amberelec",
"display_name": "AmberELEC",
- "version": "1.0",
- "generated": "2026-10-26T22:11:28Z",
+ "version": "prerelease-20261019_1835",
+ "generated": "2026-10-27T10:27:58Z",
"base_destination": "",
"detect": [
{
diff --git a/install/batocera.json b/install/batocera.json
index 1fd86f48b..9f37d8eba 100644
--- a/install/batocera.json
+++ b/install/batocera.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "batocera",
"display_name": "Batocera",
- "version": "1.0",
+ "version": "43.1",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "bios",
"detect": [
diff --git a/install/bizhawk.json b/install/bizhawk.json
index 04c533eec..9e28995bf 100644
--- a/install/bizhawk.json
+++ b/install/bizhawk.json
@@ -5,8 +5,8 @@
"categorized": true,
"platform": "bizhawk",
"display_name": "BizHawk",
- "version": "1.0",
- "generated": "2026-10-26T22:11:28Z",
+ "version": "2.11.1",
+ "generated": "2026-10-27T10:27:58Z",
"base_destination": "Firmware",
"detect": [
{
diff --git a/install/emudeck.json b/install/emudeck.json
index 136cca83d..3503de2d0 100644
--- a/install/emudeck.json
+++ b/install/emudeck.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "emudeck",
"display_name": "EmuDeck",
- "version": "1.0",
+ "version": "2.7EA",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "bios",
"detect": [
diff --git a/install/emuelec.json b/install/emuelec.json
index 716017acd..15e6db113 100644
--- a/install/emuelec.json
+++ b/install/emuelec.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "emuelec",
"display_name": "EmuELEC",
- "version": "1.0",
+ "version": "4.8",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "",
"detect": [
diff --git a/install/knulli.json b/install/knulli.json
index 1e9d35a2e..4580eea4f 100644
--- a/install/knulli.json
+++ b/install/knulli.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "knulli",
"display_name": "KNULLI",
- "version": "1.0",
+ "version": "20260511",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "bios",
"detect": [
diff --git a/install/lakka.json b/install/lakka.json
index 253ca1a2c..2b73f1bfa 100644
--- a/install/lakka.json
+++ b/install/lakka.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "lakka",
"display_name": "Lakka",
- "version": "1.0",
+ "version": "v6.1",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "system",
"detect": [
diff --git a/install/launchers/index.json b/install/launchers/index.json
index 825839450..0f5e5493c 100644
--- a/install/launchers/index.json
+++ b/install/launchers/index.json
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
- "size": 775811,
- "sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0",
+ "size": 854609,
+ "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
- "size": 828360,
- "sha256": "dc3204c1dab8db00fb10e47cb9d2cad8ad6320b9d5c7d8fff9168c238620d5ef"
+ "size": 907158,
+ "sha256": "15c404a5dfb8d084caa11843d3969e7f0222f434c9b2320b04a0d89ca7336f21"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
- "size": 775811,
- "sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0",
+ "size": 854609,
+ "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
- "size": 775811,
- "sha256": "71fa422aa38f9710c9aab81ba038cad71df66f574a527dc62e0307c69c30d1b0",
+ "size": 854609,
+ "sha256": "7757b36db5beb3cbeb3717e0736db630237e87da5189a14d5092e679b69156af",
"executable": false
},
{
diff --git a/install/minui.json b/install/minui.json
index 6bf174174..c38414f3b 100644
--- a/install/minui.json
+++ b/install/minui.json
@@ -5,8 +5,8 @@
"categorized": true,
"platform": "minui",
"display_name": "MinUI",
- "version": "1.0",
- "generated": "2026-10-26T22:11:28Z",
+ "version": "2025-11-27",
+ "generated": "2026-10-27T10:27:58Z",
"base_destination": "",
"detect": [
{
diff --git a/install/misterfpga.json b/install/misterfpga.json
index e5ca2efb9..fccaab825 100644
--- a/install/misterfpga.json
+++ b/install/misterfpga.json
@@ -5,8 +5,8 @@
"categorized": true,
"platform": "misterfpga",
"display_name": "MiSTer FPGA",
- "version": "1.0",
- "generated": "2026-10-26T22:11:28Z",
+ "version": "2026-09-29",
+ "generated": "2026-10-27T10:27:58Z",
"base_destination": "games",
"detect": [
{
diff --git a/install/myminui.json b/install/myminui.json
index c7603dafc..5f3b557b9 100644
--- a/install/myminui.json
+++ b/install/myminui.json
@@ -5,8 +5,8 @@
"categorized": true,
"platform": "myminui",
"display_name": "MyMinUI",
- "version": "1.0",
- "generated": "2026-10-26T22:11:28Z",
+ "version": "2026-08-09",
+ "generated": "2026-10-27T10:27:58Z",
"base_destination": "",
"detect": [
{
diff --git a/install/nextui.json b/install/nextui.json
index d4bbdfe88..75149d901 100644
--- a/install/nextui.json
+++ b/install/nextui.json
@@ -5,8 +5,8 @@
"categorized": true,
"platform": "nextui",
"display_name": "NextUI",
- "version": "1.0",
- "generated": "2026-10-26T22:11:28Z",
+ "version": "2026-10-20",
+ "generated": "2026-10-27T10:27:58Z",
"base_destination": "",
"detect": [
{
diff --git a/install/recalbox.json b/install/recalbox.json
index d3af8a54a..b756658ec 100644
--- a/install/recalbox.json
+++ b/install/recalbox.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "recalbox",
"display_name": "Recalbox",
- "version": "1.0",
+ "version": "10.1.1",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "bios",
"detect": [
diff --git a/install/retroarch.json b/install/retroarch.json
index b13add54a..32d0d2737 100644
--- a/install/retroarch.json
+++ b/install/retroarch.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "retroarch",
"display_name": "RetroArch",
- "version": "1.0",
+ "version": "v1.22.2",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "system",
"detect": [
diff --git a/install/retrobat.json b/install/retrobat.json
index edc759ace..be67ba157 100644
--- a/install/retrobat.json
+++ b/install/retrobat.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "retrobat",
"display_name": "RetroBat",
- "version": "1.0",
+ "version": "8.3.1",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "bios",
"detect": [
diff --git a/install/retrodeck.json b/install/retrodeck.json
index 0d6d1be7a..87a0a01c7 100644
--- a/install/retrodeck.json
+++ b/install/retrodeck.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "retrodeck",
"display_name": "RetroDECK",
- "version": "1.0",
+ "version": "0.10.10b",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "",
"detect": [
diff --git a/install/retropie.json b/install/retropie.json
index 1c0b23a83..0348a1156 100644
--- a/install/retropie.json
+++ b/install/retropie.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "retropie",
"display_name": "RetroPie",
- "version": "1.0",
+ "version": "4.8.12",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "BIOS",
"detect": [
diff --git a/install/rocknix.json b/install/rocknix.json
index 21f573f2a..839470c2a 100644
--- a/install/rocknix.json
+++ b/install/rocknix.json
@@ -5,7 +5,7 @@
"categorized": true,
"platform": "rocknix",
"display_name": "ROCKNIX",
- "version": "1.0",
+ "version": "20261001",
"generated": "2026-10-27T10:27:58Z",
"base_destination": "bios",
"detect": [
diff --git a/install/romm.json b/install/romm.json
index 45381043f..a812488c9 100644
--- a/install/romm.json
+++ b/install/romm.json
@@ -5,8 +5,8 @@
"categorized": true,
"platform": "romm",
"display_name": "RomM",
- "version": "1.0",
- "generated": "2026-10-26T22:11:28Z",
+ "version": "5.4.0",
+ "generated": "2026-10-27T10:27:58Z",
"base_destination": "bios",
"detect": [
{
diff --git a/platforms/lakka.yml b/platforms/lakka.yml
index 87dba4158..b5dfabda4 100644
--- a/platforms/lakka.yml
+++ b/platforms/lakka.yml
@@ -1,5 +1,7 @@
inherits: retroarch
platform: Lakka
homepage: "https://www.lakka.tv"
-# Version is inherited from retroarch.yml (fetched dynamically by scraper)
+# Lakka-LibreELEC's release tag, the revision platforms/targets/lakka.yml
+# reads its cores at. A version is never inherited from retroarch.yml.
+version: v6.1
# Lakka uses the same BIOS requirements as RetroArch
diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json
index bd8cdf9a4..a8dc392e0 100644
--- a/schemas/emulator.schema.json
+++ b/schemas/emulator.schema.json
@@ -63,6 +63,33 @@
}
}
}
+ },
+ {
+ "comment": "A profile whose code states no version says so with version_basis kind none and a null core_version, never a placeholder.",
+ "if": {
+ "properties": {
+ "version_basis": {
+ "properties": {
+ "kind": {
+ "const": "none"
+ }
+ },
+ "required": [
+ "kind"
+ ]
+ }
+ },
+ "required": [
+ "version_basis"
+ ]
+ },
+ "then": {
+ "properties": {
+ "core_version": {
+ "type": "null"
+ }
+ }
+ }
}
],
"properties": {
@@ -155,11 +182,50 @@
]
},
"core_version": {
- "type": [
- "string",
- "number",
- "object",
- "null"
+ "description": "The version of the code at the pin, read where version_basis says: the string the program states, git describe's form of the nearest tag, or the package read. Never a branch or VCS name (Git, SVN, master, latest, dev, unknown). null when version_basis is kind none. An object keys it by build mode, like source.",
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^(?!([Gg][Ii][Tt]|[Ss][Vv][Nn]|[Uu][Nn][Kk][Nn][Oo][Ww][Nn]|[Mm][Aa][Ss][Tt][Ee][Rr]|[Mm][Aa][Ii][Nn]|[Ll][Aa][Tt][Ee][Ss][Tt]|[Dd][Ee][Vv]|[Hh][Ee][Aa][Dd])([\\s(]|$)).*\\S"
+ },
+ {
+ "type": "null"
+ },
+ {
+ "type": "object",
+ "minProperties": 1,
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "type": "string",
+ "pattern": "^(?!([Gg][Ii][Tt]|[Ss][Vv][Nn]|[Uu][Nn][Kk][Nn][Oo][Ww][Nn]|[Mm][Aa][Ss][Tt][Ee][Rr]|[Mm][Aa][Ii][Nn]|[Ll][Aa][Tt][Ee][Ss][Tt]|[Dd][Ee][Vv]|[Hh][Ee][Aa][Dd])([\\s(]|$)).*\\S"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "version_basis": {
+ "description": "Where core_version is read, so a tool reads it again at any pin. An object keyed by build mode when core_version is.",
+ "oneOf": [
+ {
+ "$ref": "#/$defs/version_basis"
+ },
+ {
+ "type": "object",
+ "minProperties": 1,
+ "not": {
+ "required": [
+ "kind"
+ ]
+ },
+ "additionalProperties": {
+ "$ref": "#/$defs/version_basis"
+ }
+ }
]
},
"alias_of": {
@@ -964,5 +1030,188 @@
"type": "string",
"description": "Repository carrying the same tree, consulted after source and upstream. The only thing that keeps a profile checkable once its own forge is withdrawn or its host stops resolving."
}
+ },
+ "$defs": {
+ "version_basis": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "kind"
+ ],
+ "properties": {
+ "kind": {
+ "enum": [
+ "file",
+ "tag",
+ "alias",
+ "package",
+ "none",
+ "unverified"
+ ],
+ "description": "file: a pattern in one file at the pin. tag: the nearest tag in the pin's history. alias: the version of the profile it aliases. package: an artefact read outside any source tree. none: the code at the pin states no version and no tag reaches it. unverified: no reading could be made, for the reason why gives. core_version stays as written, unconfirmed."
+ },
+ "why": {
+ "enum": [
+ "no_pin",
+ "host_unsupported",
+ "host_gone",
+ "host_unreachable",
+ "pin_off_branch",
+ "no_artifact",
+ "no_source"
+ ],
+ "description": "Why a basis is unverified: no pinned commit, a host the tools cannot read, a forge gone, a forge that did not answer, a pin on another branch than the build the profile describes, no build or package named as read, no source declared."
+ },
+ "repo": {
+ "enum": [
+ "source",
+ "upstream",
+ "source_mirror"
+ ],
+ "description": "The declared repository the version describes, pinned by source_commit (source, source_mirror) or upstream_commit. Default source."
+ },
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "description": "kind file: a repository outside the profile's own, read at commit (the package file of a build a platform ships). kind package: where the artefact was obtained."
+ },
+ "commit": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{7,40}$",
+ "description": "The revision of url the file is read at."
+ },
+ "path": {
+ "type": "string",
+ "minLength": 1,
+ "description": "File read at the pin."
+ },
+ "pattern": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Regular expression, multiline, whose groups hold the version."
+ },
+ "format": {
+ "type": "string",
+ "pattern": "\\{\\d+",
+ "description": "How the groups are laid out, {1}.{2} or {4:02d}. Default: group 1."
+ },
+ "match": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Glob a tag must fit to count, as git describe --match takes it."
+ },
+ "tag": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The nearest tag in the pin's history."
+ },
+ "distance": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Commits the pin has that the tag lacks."
+ },
+ "of": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The profile this one aliases."
+ },
+ "artifact": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The package, binary or archive the version was read from."
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "kind": {
+ "const": "file"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "path",
+ "pattern"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kind": {
+ "const": "tag"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "tag",
+ "distance"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kind": {
+ "const": "alias"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "of"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kind": {
+ "const": "package"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "artifact"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kind": {
+ "const": "file"
+ }
+ },
+ "required": [
+ "url"
+ ]
+ },
+ "then": {
+ "required": [
+ "commit"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "kind": {
+ "const": "unverified"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "why"
+ ]
+ }
+ }
+ ]
+ }
}
}
diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json
index 2ffaefdfe..62db8c06b 100644
--- a/schemas/install-events.schema.json
+++ b/schemas/install-events.schema.json
@@ -15,6 +15,7 @@
"detect",
"error",
"scanned",
+ "version",
"choices",
"file",
"system",
@@ -51,6 +52,9 @@
{
"$ref": "#/$defs/scanned"
},
+ {
+ "$ref": "#/$defs/version"
+ },
{
"$ref": "#/$defs/choices"
},
@@ -244,6 +248,34 @@
}
}
},
+ "version": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "event",
+ "platform",
+ "list_version",
+ "installed_version",
+ "message"
+ ],
+ "properties": {
+ "event": {
+ "const": "version"
+ },
+ "platform": {
+ "type": "string"
+ },
+ "list_version": {
+ "type": "string"
+ },
+ "installed_version": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ },
"choices": {
"type": "object",
"additionalProperties": false,
diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json
index 38ef18588..4e7d1ec20 100644
--- a/schemas/install-manifest.schema.json
+++ b/schemas/install-manifest.schema.json
@@ -67,7 +67,8 @@
},
"version": {
"type": "string",
- "description": "Version of the platform's own list this manifest was built from."
+ "minLength": 1,
+ "description": "Version of the platform's own list this manifest was built from, as the platform YAML records it (inherits: resolved): the release tag the scraper read (Batocera 43.1, Recalbox 10.1.1). A list kept on a branch with no release records what the scraper pinned instead: MinUI, MyMinUI and NextUI the date of the pinned commit, MiSTer the timestamp of the BiosDB it read. RetroArch and Lakka name the RetroArch release current when System.dat was read at its pinned commit, RetroPie the __version of RetroPie-Setup at its pinned commit."
},
"generated": {
"type": "string",
diff --git a/schemas/pack-manifest.schema.json b/schemas/pack-manifest.schema.json
index e836ccd33..4b2a9910f 100644
--- a/schemas/pack-manifest.schema.json
+++ b/schemas/pack-manifest.schema.json
@@ -43,6 +43,17 @@
"system_names": {
"type": "object",
"additionalProperties": { "type": "array", "items": { "type": "string" } }
+ },
+ "list_version": {
+ "description": "Version of the platform's list the pack was built from, the version of its install manifest. The installer compares it with the version the machine runs when it installs from the pack alone. The version key above numbers this file's format.",
+ "type": "string",
+ "minLength": 1
+ },
+ "list_records": {
+ "description": "What the platform's install list narrows by: the installer names that list as the remedy for --core or --region given with the pack alone only when it records them.",
+ "type": "array",
+ "uniqueItems": true,
+ "items": { "enum": ["systems", "cores", "regions"] }
}
}
}
diff --git a/scripts/common.py b/scripts/common.py
index eed59b068..9da469fc3 100644
--- a/scripts/common.py
+++ b/scripts/common.py
@@ -235,6 +235,10 @@ def _inherited_yaml(platform_name: str, platforms_dir: str) -> tuple[dict, list[
return config, [platform_name]
merged, chain = _inherited_yaml(config["inherits"], platforms_dir)
+ # A version names one platform's release, never its parent's: Lakka
+ # read as RetroArch v1.22.2 in its manifest, pack name and site page.
+ # dat_version stays, it is the version of the inherited list itself.
+ merged.pop("version", None)
merged.update(
{k: v for k, v in config.items() if k not in ("inherits", "overrides")}
)
diff --git a/scripts/core_probe.py b/scripts/core_probe.py
index 1f979430e..70b6b3052 100644
--- a/scripts/core_probe.py
+++ b/scripts/core_probe.py
@@ -766,6 +766,30 @@ def _bwrap_base(bwrap: str, network: bool) -> list[str]:
return argv
+def interpreter_paths() -> list[Path]:
+ """The trees the worker's Python lives in, when the sandbox's system
+ binds do not hold them: CI's setup-python under /opt/hostedtoolcache, a
+ venv, pyenv or uv. Each link on the way to the interpreter is a path too:
+ a venv's python points at uv's unversioned name, itself a link to the
+ versioned tree. Without them the worker could not start in the sandbox,
+ and every run crashed."""
+ system = ("/usr/", "/bin/", "/lib")
+ found: list[Path] = []
+ candidates = [Path(sys.prefix), Path(sys.base_prefix)]
+ hop = Path(sys.executable)
+ for _ in range(16):
+ candidates.append(hop.parent.parent)
+ if not hop.is_symlink():
+ break
+ target = Path(os.readlink(hop))
+ hop = target if target.is_absolute() else hop.parent / target
+ for path in candidates:
+ path = Path(os.path.abspath(path))
+ if not str(path).startswith(system) and path not in found and path.exists():
+ found.append(path)
+ return found
+
+
def sandbox_argv(writable: Sequence[Path], readonly: Sequence[Path] = (),
network: bool = False) -> tuple[list[str], str]:
"""The bubblewrap prefix a third-party program runs under, and its name.
@@ -788,8 +812,10 @@ def sandbox_argv(writable: Sequence[Path], readonly: Sequence[Path] = (),
return [], "environment only: bwrap cannot create a sandbox here"
argv = _bwrap_base(bwrap, network)
for path in readonly:
- resolved = Path(path).resolve()
- argv += ["--ro-bind", str(resolved), str(resolved)]
+ # Mounted at the path as the caller names it: a link's own path stays
+ # usable inside, its target's bytes behind it.
+ resolved, named = Path(path).resolve(), Path(os.path.abspath(path))
+ argv += ["--ro-bind", str(resolved), str(named)]
for path in writable:
resolved = Path(path).resolve()
resolved.mkdir(parents=True, exist_ok=True)
@@ -880,7 +906,7 @@ def run_once(args: argparse.Namespace, system_dir: Path, scratch: Path, state_ou
if strace:
command = [strace, "-f", "-qq", "-s", "4096", "-e", "trace=openat,open", "-o", str(trace_path)] + command
writable = [scratch] + [p for p in (png_dir, state_out.parent if state_out else None) if p]
- readonly = [Path(__file__), Path(args.core), system_dir]
+ readonly = [Path(__file__), Path(args.core), system_dir, *interpreter_paths()]
if args.content:
readonly.append(Path(args.content).resolve().parent)
readonly += [Path(value) for _, value in args.env if Path(value).is_absolute() and Path(value).exists()]
diff --git a/scripts/generate_event_schema.py b/scripts/generate_event_schema.py
index 7fb77bbd1..468e33bac 100644
--- a/scripts/generate_event_schema.py
+++ b/scripts/generate_event_schema.py
@@ -35,6 +35,7 @@ KEY_TYPES: dict[str, dict] = {
**dict.fromkeys((
"installer", "os", "os_name", "stage", "reason", "platform", "message",
"folder", "dest", "system", "name", "bios_dir", "path", "member",
+ "list_version", "installed_version",
), _TEXT),
"mode": {"enum": ["install", "check", "dry-run", "list"]},
"state": {"enum": ["present", "wrong_hash", "missing"]},
diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py
index f96ec9c4d..ef443d2d2 100644
--- a/scripts/generate_installer_doc.py
+++ b/scripts/generate_installer_doc.py
@@ -22,7 +22,7 @@ import ast
import importlib.util
import re
import sys
-from pathlib import Path
+from pathlib import Path, PurePosixPath
ROOT = Path(__file__).resolve().parent.parent
PAGE = ROOT / "wiki" / "installer.md"
@@ -154,6 +154,40 @@ def route_keys() -> str:
return _table(heading, rows)
+def _marker(rule: _install._OsRule) -> str:
+ """A card rule's evidence in words: the file or folder, and the line it
+ holds for a rule that reads one."""
+ if rule.method == "file_matches":
+ path, pattern = rule.values
+ line = re.sub(r"\\s\*\$$", "", re.sub(r"^(\^|\\A)", "", pattern)).replace("\\", "")
+ return f"`{path}` holding the line `{line}`"
+ return ", ".join(f"`{value}`" if PurePosixPath(value).suffix else f"`{value}/`"
+ for value in rule.values)
+
+
+def _list_words(plat: str) -> str:
+ name = _install.display_name(plat)
+ return f"{name}'{'' if name.endswith('s') else 's'} list"
+
+
+def card_marker_rows() -> list[list[str]]:
+ """The files that name a card or a SHARE drive, in the order they are
+ read: install.py's card rules, then its SHARE drive files."""
+ rows = [[_marker(rule), rule.name, f"`{rule.bios}`, {_list_words(rule.manifest)}"]
+ for rule in _install._card_rules()]
+ hostname = "system.hostname=BATOCERA"
+ for plat, marker in _install._SHARE_DRIVES:
+ name = _install.display_name(plat)
+ evidence = (f"`{marker}` holding `{hostname}` or no hostname line, and no KNULLI file "
+ "above" if plat == "batocera" else f"`{marker}`")
+ rows.append([evidence, f"{name} SHARE drive", f"`bios`, {_list_words(plat)}"])
+ rows.append([f"`{_install._SHARE_DRIVES[0][1]}` with any other hostname, and no KNULLI "
+ "file above, or the files of two SHARE drives",
+ "named by neither: the run says which systems it could be",
+ "the folder of the system named with `--platform`"])
+ return rows
+
+
def render(page: str) -> str:
"""The page with each marked table written from install.py."""
writers = {
@@ -162,6 +196,8 @@ def render(page: str) -> str:
"events": lambda body: _table(["Kind", "Keys", "When"], event_rows(body)),
"pad-routes": lambda body: _table(["Value", "Input", "Set by"], pad_rows(body)),
"route-keys": lambda _body: route_keys(),
+ "card-markers": lambda _body: _table(["On the card", "System", "Files go to"],
+ card_marker_rows()),
}
seen = []
diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py
index 69b057a27..07dc00ecc 100644
--- a/scripts/generate_pack.py
+++ b/scripts/generate_pack.py
@@ -351,6 +351,23 @@ def _platform_pack_stem(
return "_".join(_name_part(n) for n in names) + version_tag
+def platform_list_version(config: dict, platform: str) -> str:
+ """The version of the list a platform's YAML transcribes: the release
+ tag the scraper read, or for a list kept on a branch with no release,
+ the date of the commit it pinned. load_platform_config never passes a
+ parent's version to a child, Lakka is not RetroArch v1.22.2.
+
+ The installer compares it with the version a machine runs, so a missing
+ value is refused rather than written as a stand-in.
+ """
+ version = str(config.get("version") or "").strip()
+ if not version:
+ raise ValueError(
+ f"{platform}: the platform YAML records no version of its own"
+ )
+ return version
+
+
# What the builder writes into a platform pack beside the files it carries.
PACK_DOCUMENTS = ("README.txt", "manifest.json")
@@ -2568,6 +2585,9 @@ def _run_manifest_mode(
alias_manifest["display_name"] = alias_cfg.get(
"platform", alias_plat
)
+ alias_manifest["version"] = platform_list_version(
+ alias_cfg, alias_plat
+ )
alias_manifest["base_destination"] = alias_cfg.get(
"base_destination", ""
)
@@ -4600,7 +4620,7 @@ def generate_manifest(
**({"no_game_data": True} if no_game_data else {}),
"platform": platform_name,
"display_name": platform_display,
- "version": "1.0",
+ "version": platform_list_version(config, platform_name),
"generated": now,
"base_destination": base_dest,
"detect": detect,
@@ -4759,12 +4779,31 @@ def pack_systems(install_manifest: dict) -> dict:
("the file list comes from an older release") and offered no Choose
systems, the route of a handheld without Wi-Fi.
"""
- return {
+ files = install_manifest.get("files", [])
+ entries = [*files, *install_manifest.get("omitted_files", [])]
+ # What the install list narrows by, the installer's recorded_axes: with
+ # the pack alone it names that list as the way to --core and --region
+ # only when the list records them. It named install/misterfpga.json for
+ # --core, a list that records no core.
+ records = {"systems"} if any("systems" in entry for entry in entries) else set()
+ if any(copy.get("cores") for entry in files
+ for copy in [entry, *entry.get("alternatives", []),
+ *entry.get("slot_choices", [])]):
+ records.add("cores")
+ if records or any("regions" in entry for entry in entries):
+ records.add("regions")
+ systems = {
"files": {entry["dest"]: list(entry.get("systems") or [])
- for entry in install_manifest.get("files", []) if "systems" in entry},
+ for entry in files if "systems" in entry},
"system_titles": dict(install_manifest.get("system_titles") or {}),
"system_names": dict(install_manifest.get("system_names") or {}),
+ "list_records": sorted(records),
}
+ # The version of the platform's list, which the installer compares with
+ # the one the machine runs when it has the pack alone.
+ if install_manifest.get("version"):
+ systems["list_version"] = install_manifest["version"]
+ return systems
def annotate_pack_systems(manifest: dict, systems: dict | None) -> None:
@@ -4779,6 +4818,9 @@ def annotate_pack_systems(manifest: dict, systems: dict | None) -> None:
for key in ("system_titles", "system_names"):
if systems.get(key):
manifest[key] = systems[key]
+ for key in ("list_records", "list_version"):
+ if key in systems:
+ manifest[key] = systems[key]
def inject_manifest(zip_path: str, manifest: dict) -> None:
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index dd04f5697..a94048f90 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -32,6 +32,7 @@ import urllib.error
import urllib.parse
import urllib.request
import zipfile
+from collections import Counter
from collections.abc import Callable, Iterator
from datetime import datetime, timezone
from pathlib import Path
@@ -83,6 +84,7 @@ from generate_readme import (
)
from provenance_report import build_report, distinct_missing
import sitemarkup as markup
+import siteentries
import sitestarlight
from siteexports import generate_exports_page, load_export_reports
from siteguides import (
@@ -2510,7 +2512,9 @@ def _emulator_description(markdown: str, key: str, title: str, intro: str) -> st
row = {name: _plain_markdown(value) for name, value in re.findall(
r"^\| (Type|Classification|Version) \| (.+?) \|$", markdown, re.M)}
kind = row.get("Classification") or row.get("Type") or "emulator"
- version = row.get("Version", "")
+ # The cell goes on to say where the version is read: the intro
+ # keeps the version alone.
+ version = row.get("Version", "").split(", ")[0]
known = f", version {version}" if version and version[0].isdigit() else ""
intro = f"{title}: {kind.lower()}{known}."
elif title not in intro:
@@ -3238,41 +3242,35 @@ def with_setup_table(page: str, coverages: dict, registry: dict | None = None) -
return f"{page.rstrip()}\n\n{table}"
-# A platform system whose file list passes this much Markdown gets a page
-# of its own: RetroDECK's arcade list was 750 KB of a 1.1 MB page.
-PLATFORM_SECTION_BUDGET = 200_000
-# A system's own page in runs of at most this much Markdown: RetroDECK's
-# arcade list, each file with its served copy and pinned readers, rendered
-# 1.4 MB of HTML on one page.
-PLATFORM_PART_BUDGET = 350_000
-# The Markdown of a platform page's file lists, past which the largest
+# A platform's file lists are split by the share of a page they render to,
+# in thousandths: their elements against validate_site's MAX_PAGE_ELEMENTS,
+# their bytes against the 600,000 of test_site_r2_gate, whichever is
+# larger. Measured in Markdown characters, the lists split anew whenever
+# their markup changed: rows written as tables moved 38 more systems to
+# pages of their own.
+PAGE_ELEMENTS = 10_000
+PAGE_BYTES = 600_000
+# A system whose list passes this share gets a page of its own.
+PLATFORM_SECTION_BUDGET = 400
+# A system's own page in runs of at most this share.
+PLATFORM_PART_BUDGET = 700
+# The share of a platform page's file lists, past which the largest
# systems move to pages of their own.
-PLATFORM_REFERENCE_BUDGET = 250_000
+PLATFORM_REFERENCE_BUDGET = 600
-def _file_runs(body: list[str], budget: int) -> list[list[str]]:
- """A system's file list in runs of at most ``budget`` characters.
+# Every part page a platform list has had, with the system it held:
+# platforms/batocera-atari-st.md -> platforms/batocera.md#atari-st. When a
+# budget or a list changes, a part can fold back into its platform page,
+# and its address then redirects there instead of ending on the 404 page.
+PLATFORM_PARTS_FILE = (Path(__file__).resolve().parent.parent
+ / "website" / "src" / "data" / "platform-parts.json")
- The lines before the first file (counts, emulators) open every run. A
- file is never cut from its hash and evidence lines.
- """
- first = next((i for i, line in enumerate(body) if line.startswith("- ")), len(body))
- head, items = body[:first], []
- for line in body[first:]:
- if line.startswith("- ") or not items:
- items.append([line])
- else:
- items[-1].append(line)
- runs: list[list[str]] = [[]]
- size = 0
- for item in items:
- weight = sum(len(line) + 1 for line in item)
- if runs[-1] and size + weight > budget:
- runs.append([])
- size = 0
- runs[-1].extend(item)
- size += weight
- return [head + run for run in runs]
+
+def _published_platform_parts() -> dict[str, str]:
+ if not PLATFORM_PARTS_FILE.is_file():
+ return {}
+ return json.loads(PLATFORM_PARTS_FILE.read_text(encoding="utf-8"))
# A list moved off a reference page (gaps, provenance) is cut in parts of
@@ -3280,13 +3278,18 @@ def _file_runs(body: list[str], budget: int) -> list[list[str]]:
LIST_PART_BUDGET = 300_000
-def _blocks_in_runs(blocks: list[list[str]], budget: int) -> list[list[str]]:
- """Blocks (table rows, folded lists) in runs of at most ``budget``
- characters, a block never cut."""
+def _characters(lines: list[str]) -> int:
+ return sum(len(line) + 1 for line in lines)
+
+
+def _blocks_in_runs(blocks: list[list[str]], budget: int,
+ weigh: Callable[[list[str]], int] = _characters) -> list[list[str]]:
+ """Blocks (table rows, folded lists) in runs of at most ``budget``, by
+ default in characters, a block never cut."""
runs: list[list[str]] = [[]]
size = 0
for block in blocks:
- weight = sum(len(line) + 1 for line in block)
+ weight = weigh(block)
if runs[-1] and size + weight > budget:
runs.append([])
size = 0
@@ -3423,6 +3426,186 @@ def _launcher_refs(platform: str, config: dict) -> dict[str, list[str]]:
return found
+# The hash a row shows first, when the list does not say which it checks.
+_ROW_HASHES = ("sha1", "md5", "sha256", "crc32")
+# An untested entry whose reason is a hash the bytes do not match.
+_WRONG_HASH = re.compile(r"hash|mismatch|expected|must match", re.IGNORECASE)
+# A list this long gets a filter box above it, as a table of RECORD_ROWS
+# rows does (website/src/plugins/rehype-stack-tables.mjs).
+FILTER_ROWS = 20
+
+
+def _status_chip(f: dict) -> str:
+ """The row's status as a chip, its word and its colour saying the same."""
+ status = f.get("status")
+ if status == "ok":
+ word, tone = "OK", "success"
+ elif status == "missing":
+ word, tone = "Missing", "danger"
+ elif status == "untested":
+ word = "Wrong hash" if _WRONG_HASH.search(str(f.get("reason") or "")) else "Untested"
+ tone = "warning"
+ else:
+ word, tone = str(status), "muted"
+ return f'{html.escape(word)}'
+
+
+def _copy_hash(algorithm: str, value: str | list) -> str:
+ """A declared hash cut to SHORT_HASH characters on a button that copies
+ it whole. A list of accepted values (Recalbox) copies its first. Outside
+ a form a button submits nothing, so it carries no type."""
+ from hashing import parse_hash_list # noqa: PLC0415
+
+ values = parse_hash_list(value)
+ if not values:
+ return ""
+ first = html.escape(values[0])
+ more = f" +{len(values) - 1}" if len(values) > 1 else ""
+ return (f'')
+
+
+# Characters Markdown or MDX would read inside the raw HTML of a row: an
+# MDX page parses the text of its HTML as Markdown, a .md page does not.
+_INERT = str.maketrans({"*": "*", "_": "_", "`": "`", "[": "[", "]": "]"})
+_INLINE_MD = re.compile(
+ r"\[(?P
", ""])
return lines
+# What a system card shows at a glance: who lists the file and who reads it.
+# Its fingerprints and the rest open on demand, so a page of 250 cards reads
+# as a list of files, not a wall of hashes.
+_CARD_KEY_FACTS = ("On the setup's list", "Packed for its emulators by", "Emulators")
+_CARD_HASHES = ("SHA1", "MD5", "CRC32", "SHA256")
+# The words a card's fold names its other facts by.
+_CARD_MORE_WORDS = {"Romset": "romset", "Collection path": "collection path",
+ "Catalogued": "catalogues"}
+# A hash shown short, in a card, a platform row or an emulator table: as long
+# as the card's own address (sha1-<12 hex>), one length across the site.
+SHORT_HASH = 12
+
+
+def _listed_words(words: list[str]) -> str:
+ """"a", "a and b", "a, b and c", first letter capital."""
+ text = words[0] if len(words) == 1 else f"{', '.join(words[:-1])} and {words[-1]}"
+ return text[:1].upper() + text[1:]
+
+
+def _fact_list(facts: list[tuple[str, list[str], bool]], skip: str,
+ copy: dict[str, str] | None = None) -> list[str]:
+ """Facts as a grid, label then value, one value cell per label.
+
+ A label in *copy* holds one hash: its cell takes the hash style, and
+ the site script a copy button (site.ts). *copy* gives the cell's
+ classes. One element, where a span in the cell was one more per hash.
+ """
+ if not facts:
+ return []
+ copy = copy or {}
+
+ def cell(label: str, values: list[str]) -> str:
+ if label in copy:
+ return f'
{values[0]}
'
+ return "
" + " ".join(_inline_card_html(v) for v in values) + "
"
+
+ return [f'
',
+ *(f"
{label}
{cell(label, values)}" for label, values, _searched in facts),
+ "
"]
+
+
+def card_fold(summary: str, body: list[str], skip: bool = True) -> list[str]:
+ """A fold of a file card: closed, its summary naming what it holds.
+
+ Native details: no script needed, and the text inside stays in the page
+ (and in the search, unless *skip*). No class: the card styles its folds,
+ and a class was 21 bytes on each of 250 cards."""
+ attribute = f" {markup.search_exclude()}" if skip else ""
+ summary_line = f"{html.escape(summary, quote=False)}"
+ # Each tag on a line of its own, or MDX reads the fold as a paragraph
+ # left open. Markdown inside also takes a blank line around it.
+ gap = [] if body and body[0].startswith("<") else [""]
+ return [f"", summary_line, *gap, *body, *gap, ""]
+
+
+def _card_fact_groups(facts: list[tuple[str, list[str], bool]], sha1: str,
+ searchable: bool) -> list[str]:
+ """A system card's facts under Starlight: the key ones open, a short
+ SHA1 first, then the hashes and the rest in one closed fold.
+
+ The search skips all of them, as it did when they were one list: on
+ each row the attribute was 77 KB of the Sony page. Hashes are found by
+ files/?hash=, which the search box offers for a pasted hash.
+ """
+ skip = f" {markup.search_exclude()}"
+ key = [fact for fact in facts if fact[0] in _CARD_KEY_FACTS]
+ hashes = [fact for fact in facts if fact[0] in _CARD_HASHES]
+ more = [fact for fact in facts if fact not in key and fact not in hashes]
+ searched = [fact for fact in facts if fact[2] and searchable]
+ lines: list[str] = []
+ if searched:
+ # No fact is searched today. One that is keeps its label out: the
+ # excerpt ran the two together, "On the setup's listAmberELEC".
+ lines += ['
',
+ *(f"
{label}
"
+ + " ".join(_inline_card_html(v) for v in values) + "
"
+ for label, values, _searched in searched),
+ "
"]
+ key = [fact for fact in key if fact not in searched]
+ more = [fact for fact in more if fact not in searched]
+ glance = []
+ if sha1 and sha1 != "unknown":
+ # The first characters, enough to tell two dumps apart at a glance.
+ # Its button copies the whole value, read from the fold below: the
+ # value twice was 54 bytes more on each card.
+ glance.append(("SHA1", [html.escape(sha1[:SHORT_HASH])], False))
+ lines += _fact_list(glance + key, skip, {"SHA1": "rb-hash"})
+ # One fold: two were four more elements on each of 250 cards.
+ folded = hashes + more
+ if folded:
+ words = (["hashes"] if hashes else []) + [
+ _CARD_MORE_WORDS.get(label, label.lower()) for label, _values, _searched in more]
+ # On one line: nothing reads it as Markdown, and a page cut by size
+ # holds its cards in the same bytes.
+ lines += card_fold(_listed_words(words),
+ ["".join(_fact_list(folded, "", dict.fromkeys(_CARD_HASHES, "rb-hash")))])
+ return lines
+
+
SEARCH_TABLE_BYTES = 3000
@@ -7462,12 +7787,15 @@ def _render_emulator_file(
owner: str = "",
alias: str = "",
data_href: str = "#data-folder",
+ badges_apart: bool = False,
) -> list[str]:
"""Render one row of an emulator profile's file table.
`anchor` is the card's id on the page, so one file can be cited by URL,
and `alias` a second id it answers to. `data_href` links the data
folder an app reads the entry under, from the page the card lands on.
+ `badges_apart`: the title holds the name alone, for a table whose
+ Status column shows the badges (siteentries.py).
Split out of generate_emulator_page, where it was a 228-line loop body
carrying most of that function's branching: thirty-odd optional fields,
@@ -7516,7 +7844,8 @@ def _render_emulator_file(
system = system_name((profile.get("systems") or [""])[0])
label = (f"a {system} BIOS (example: {_code(fname)})" if _named_example(str(fname))
else f"a {system} BIOS, any file name")
- lines.append(_card_title(f"{marker}{label} {badge_str}", anchor, weight=5))
+ title = f"{marker}{label}" if badges_apart else f"{marker}{label} {badge_str}"
+ lines.append(_card_title(title, anchor, weight=5))
lines.append("")
if desc:
lines.extend([data_root_words(_escape_tags(str(desc)), data_href), ""])
@@ -7564,15 +7893,18 @@ def _render_emulator_file(
if fmax:
bounds.append(f"max {_fmt_size(fmax)}")
details.append(f"Size: {', '.join(bounds)}")
+ # Starlight folds the hashes and gives each a copy button.
+ copyable = markup.starlight()
if fsha1:
- details.append(f"SHA1: {_hash_spans(fsha1)}")
+ details.append(f"SHA1: {_hash_spans(fsha1, copyable)}")
if fmd5:
- details.append(f"MD5: {_hash_spans(fmd5)}")
+ details.append(f"MD5: {_hash_spans(fmd5, copyable)}")
if fcrc32:
crcs = fcrc32 if isinstance(fcrc32, list) else [fcrc32]
- details.append("CRC32: " + ", ".join(f"`{crc}`" for crc in crcs))
+ details.append("CRC32: " + ", ".join(
+ markup.hash_value(crc, True) if copyable else f"`{crc}`" for crc in crcs))
if fsha256:
- details.append(f"SHA256: {_hash_spans(fsha256)}")
+ details.append(f"SHA256: {_hash_spans(fsha256, copyable)}")
if fadler32:
# Printed as the downloads spell a hash, one per code span: the list
# repr read `['0x66f334fe', ...]`. Dolphin hashes its DSP ROMs over
@@ -7639,7 +7971,14 @@ def _render_emulator_file(
if copy:
details.append(_held_copy_line(copy, consumers))
plat_links = _platform_links(sha1, consumers) if sha1 else []
- if plat_links:
+ if plat_links and markup.starlight():
+ # Grouped by where the setups put the file, as the system card
+ # says it: twelve setups repeating scph5502.bin took twelve
+ # names and twelve paths where a few say it. One sentence a
+ # place: a list or a line break was one more element a place.
+ places = _places_markdown(_platform_places(sha1, consumers))
+ details.append("Platforms: " + ". ".join(places))
+ elif plat_links:
details.append(f"Platforms: {', '.join(plat_links)}")
if dest and dest != fname and dest != fpath:
@@ -7655,6 +7994,11 @@ def _render_emulator_file(
if size_range:
details.append(f"Size range: {size_range}")
+ if markup.starlight():
+ lines.extend(_emulator_card_folds(f, details, fnote, profile, in_repo, contents,
+ data_href))
+ lines.extend(["", "", ""])
+ return lines
if details:
for d in details:
lines.append(f"- {d}")
@@ -7667,26 +8011,7 @@ def _render_emulator_file(
f"- Not collectable: {_escape_tags(str(f['unsourceable']))} "
"([acknowledged gaps](../gaps.md#acknowledged-gaps))"
)
- if contents:
- # Every member with the hashes the emulator checks: MAME and FBNeo
- # verify the members, never the archive, so a member's crc32 is the
- # value a reader compares a set against.
- lines.append(f"- Contents ({len(contents)} members):")
- for c in contents:
- if isinstance(c, dict):
- parts = []
- if c.get("description"):
- parts.append(_escape_tags(str(c["description"])))
- if c.get("size"):
- parts.append(_fmt_size(c["size"]))
- if c.get("crc32"):
- parts.append(f"CRC32 `{c['crc32']}`")
- if c.get("sha1"):
- parts.append(f"SHA1 `{c['sha1']}`")
- extra = f": {', '.join(parts)}" if parts else ""
- lines.append(f" - `{c.get('name', '')}`{extra}")
- else:
- lines.append(f" - {c}")
+ lines.extend(_contents_lines(contents))
lines[body_start:] = _search_skipped_apart(lines[body_start:])
lines.append("")
lines.append("")
@@ -7694,6 +8019,148 @@ def _render_emulator_file(
return lines
+def _contents_lines(contents: list) -> list[str]:
+ """Every member with the hashes the emulator checks: MAME and FBNeo
+ verify the members, never the archive, so a member's crc32 is the
+ value a reader compares a set against."""
+ if not contents:
+ return []
+ lines = [f"- Contents ({len(contents)} members):"]
+ for c in contents:
+ if isinstance(c, dict):
+ parts = []
+ if c.get("description"):
+ parts.append(_escape_tags(str(c["description"])))
+ if c.get("size"):
+ parts.append(_fmt_size(c["size"]))
+ if c.get("crc32"):
+ parts.append(f"CRC32 `{c['crc32']}`")
+ if c.get("sha1"):
+ parts.append(f"SHA1 `{c['sha1']}`")
+ extra = f": {', '.join(parts)}" if parts else ""
+ lines.append(f" - `{c.get('name', '')}`{extra}")
+ else:
+ lines.append(f" - {c}")
+ return lines
+
+
+# What an emulator card shows at a glance: where the file goes, how big it
+# is, which setups list it and which copy answers it. Fingerprints and the
+# reading details open on demand.
+_ENTRY_KEY_FACTS = ("Path:", "Standalone path:", "Read from:", "Found by:", "System:",
+ "Size:", "Aliases:", "Collection copy:", "Collection copies:",
+ "Platforms", "Destination:")
+_ENTRY_HASHES = ("SHA1:", "MD5:", "CRC32:", "SHA256:", "Adler32")
+# The words the fold of an emulator card names its facts by.
+_ENTRY_MORE_WORDS = (
+ ("Priority", "priority"), ("Fast boot", "fast boot"), ("Validation", "validation"),
+ ("On a failed", "failed check"), ("Installed by the platform's own package", "packages"),
+ ("Not in the platform's package", "packages"), ("Never opened", "unread setups"),
+ ("Asked for by", "source"), ("Source", "source"), ("Config key", "config key"),
+ ("Pattern", "pattern"), ("Region check", "region check"), ("Size note", "size note"),
+ ("Size range", "size range"),
+)
+
+
+def _more_words(lines: list[str]) -> list[str]:
+ """The words a fold names its facts by, once each, in their order."""
+ words: list[str] = []
+ for line in lines:
+ text = re.sub(r"<[^>]+>", "", line.removeprefix("- "))
+ word = next((w for prefix, w in _ENTRY_MORE_WORDS if text.startswith(prefix)),
+ text.split(":", 1)[0].lower())
+ if word not in words:
+ words.append(word)
+ return words
+
+
+def _short_hash_line(f: dict) -> str:
+ """The entry's strongest declared hash, cut to SHORT_HASH characters,
+ with a copy button for the whole value. "" when it declares none."""
+ from hashing import parse_hash_list # noqa: PLC0415
+
+ for field, label in (("sha1", "SHA1"), ("md5", "MD5"), ("sha256", "SHA256"), ("crc32", "CRC32")):
+ values = parse_hash_list(f.get(field) or "")
+ if values:
+ value = str(values[0])
+ more = f" and {len(values) - 1} more" if len(values) > 1 else ""
+ return (f'{label}: '
+ f"{html.escape(value[:SHORT_HASH])}{more}")
+ return ""
+
+
+def _emulator_card_folds(
+ f: dict, details: list[str], fnote: object, profile: dict, in_repo: bool,
+ contents: list, data_href: str,
+) -> list[str]:
+ """An emulator card's body under Starlight: the key facts and the notes
+ open, the hashes and the rest in one closed fold.
+
+ Lines the search reads and lines it skips are kept in two runs: mixed,
+ each change of side was one more list and one more block, five lists
+ on a card of a dozen lines.
+ """
+ def skipped(line: str) -> bool:
+ return f"- {line}".startswith(SEARCH_SKIPPED)
+
+ key = [d for d in details if d.startswith(_ENTRY_KEY_FACTS)]
+ hashes = [d for d in details if d.startswith(_ENTRY_HASHES)]
+ more = [d for d in details if d not in key and d not in hashes]
+ short = _short_hash_line(f)
+ shut_open = [d for d in key if skipped(d)]
+ sized = next((i for i, d in enumerate(shut_open) if d.startswith("Size:")), None)
+ if short and sized is not None:
+ # On the size's line: what tells this dump from another, one list
+ # item fewer on each card.
+ shut_open[sized] += ", " + short.replace(": ", " ", 1)
+ elif short:
+ shut_open.insert(0, short)
+ # What the search reads first, the notes included, then what it skips:
+ # one list each.
+ open_lines = [d for d in key if not skipped(d)]
+ if fnote:
+ open_lines.append(data_root_words(
+ _escape_tags(link_prose_citations(profile, str(fnote))), data_href))
+ if not in_repo and f.get("unsourceable"):
+ # The badge alone sent readers after a dump that cannot exist, and the
+ # reason was only on the gaps page, which the card did not link.
+ open_lines.append(
+ f"Not collectable: {_escape_tags(str(f['unsourceable']))} "
+ "([acknowledged gaps](../gaps.md#acknowledged-gaps))"
+ )
+ lines = [f"- {d}" for d in open_lines]
+ shut_fold = [f"- {d}" for d in hashes + [m for m in more if skipped(m)]]
+ read_fold = [f"- {m}" for m in more if not skipped(m)]
+ # The members of an archive, a list of their own under the fold's
+ # summary, which counts them: a "Contents" item holding them was two
+ # more elements and 44 bytes on each of 200 MAME cards.
+ members = [f"* {line.removeprefix(' - ')}" for line in _contents_lines(contents)[1:]]
+ fold: list[str] = []
+ if shut_fold or read_fold or members:
+ words = (["hashes"] if hashes else []) + _more_words(
+ [line for line in shut_fold + read_fold if not line.startswith(
+ tuple(f"- {h}" for h in _ENTRY_HASHES))])
+ words = words if len(words) <= 3 else [*words[:3], "more"]
+ if members:
+ words.append(_count(len(members), "member"))
+ hidden_part = [*shut_fold, "", *members] if members else shut_fold
+ body = (hidden_part if not read_fold else [
+ "", markup.block_open("div", f'class="rb-search-skip" {markup.search_exclude()}'),
+ "", *hidden_part, "", "", "", *read_fold])
+ fold = card_fold(_listed_words(words), body, skip=False)
+ # A fold the search skips whole sits in the skipped block before it, with
+ # no attribute of its own: on a page of 200 MAME cards the attribute, the
+ # class and a block each were 12 KB.
+ hidden = bool(fold) and not read_fold
+ shut = [f"- {d}" for d in shut_open] + (["", *fold] if hidden else [])
+ if shut:
+ lines += ["", markup.block_open("div", f'class="rb-search-skip" {markup.search_exclude()}'),
+ "", *shut, "", "", ""]
+ if fold and not hidden:
+ lines += ["", *fold, ""]
+ return lines
+
+
def _held_copy_line(copy: tuple[str, str], consumers: FileConsumers) -> str:
"""Where a copy the database does not index sits: a ZIP member under
bios/, or a file or tree of a data directory the packs fetch."""
@@ -7757,7 +8224,8 @@ def emulator_romset(profile: dict) -> str:
version = str(profile.get("core_version") or "")
if profile.get("mame_version"):
return f"MAME {profile['mame_version']}"
- if "MAME" in emulator and _MAME_RELEASE.match(version):
+ # HBMAME's sets are its own homebrew sets, whatever MAME it is based on.
+ if "MAME" in emulator and "HBMAME" not in emulator and _MAME_RELEASE.match(version):
return f"MAME {version}"
if emulator.startswith("FinalBurn Neo") and version:
return f"FinalBurn Neo {version}"
@@ -7766,6 +8234,114 @@ def emulator_romset(profile: dict) -> str:
return ""
+def _version_said(value: object, basis: dict) -> str:
+ """One build's version and where the profile reads it."""
+ kind = basis.get("kind")
+ if kind == "none":
+ return "none stated in the source"
+ if value is None:
+ if kind == "unverified":
+ return f"unknown: {_UNVERIFIED.get(str(basis.get('why')), 'unread')}"
+ return "none published"
+ value = str(value)
+ if kind == "file":
+ return f"{value}, stated in `{basis.get('path')}`"
+ if kind == "tag":
+ distance = int(basis.get("distance") or 0)
+ if not distance:
+ return f"{value}, the tag at this revision"
+ return f"{value}, {distance} commit{'s' if distance != 1 else ''} after tag `{basis.get('tag')}`"
+ if kind == "package":
+ return f"{value}, read from {basis.get('artifact')}"
+ if kind == "unverified":
+ return f"{value}, not confirmed: {_UNVERIFIED.get(str(basis.get('why')), 'unread')}"
+ return value
+
+
+# Why a profile's version could not be read, said to a reader.
+_UNVERIFIED = {
+ "no_pin": "no revision recorded",
+ "host_unsupported": "its host cannot be read",
+ "host_gone": "its source is gone",
+ "host_unreachable": "its host did not answer",
+ "pin_off_branch": "its recorded revision is not the build described",
+ "no_artifact": "no build recorded as read",
+ "no_source": "no source declared",
+}
+
+
+def version_markdown(profile: dict) -> str:
+ """The Version cell: core_version and where version_basis reads it.
+
+ A null version said "none published" whether the code states none or
+ nobody looked. Keyed by build mode like source, each build is named,
+ where the page printed the dict's Python repr.
+ """
+ if "core_version" not in profile and not profile.get("version_basis"):
+ return "unknown"
+ value = profile.get("core_version")
+ basis = profile.get("version_basis") if isinstance(profile.get("version_basis"), dict) else {}
+ if isinstance(value, dict) or (basis and "kind" not in basis):
+ values = value if isinstance(value, dict) else {}
+ modes = list(dict.fromkeys([*values, *basis]))
+ return ", ".join(
+ f"{_version_said(values.get(mode), basis.get(mode) or {})} ({mode})"
+ for mode in modes
+ )
+ return _version_said(value, basis)
+
+
+ROMSET_FAQ_PAGE = Path(WIKI_SRC_DIR) / "technical-faq.md"
+_ROMSET_BLOCK = re.compile(
+ r"(\n)(.*?)()", re.S)
+
+
+def _retroarch_label(profile: dict) -> str:
+ """"Arcade (MAME 2003)" gives "MAME 2003", "Arcade (FinalBurn Neo)
+ (Neo-Geo)" gives "FinalBurn Neo (Neo-Geo)"."""
+ inner = str(profile.get("display_name") or profile.get("emulator") or "").split("(", 1)[-1]
+ return inner.replace(")", "", 1).strip()
+
+
+def romset_table(profiles: dict) -> list[str]:
+ """The FAQ's arcade table: each MAME and FinalBurn libretro core and the
+ romset its profile reads at its pin.
+
+ Written by hand, it told readers to collect MAME 0.160 sets for a core
+ whose src/version.c states 0.159, and FinalBurn Neo 1.0.0.2 for a source
+ at 1.0.0.03. A core whose name gives its version (Recalbox's MAME 0.258)
+ needs no row.
+ """
+ rows: dict[str, list[str]] = {}
+ for key, profile in sorted(profiles.items()):
+ if not re.match(r"(mame|fbneo|fbalpha)", key) or \
+ "libretro" not in str(profile.get("type", "")):
+ continue
+ romset = emulator_romset(profile)
+ label = _retroarch_label(profile)
+ if not romset or romset.split(" ", 1)[1].lstrip("v") in label:
+ continue
+ rows.setdefault(romset, []).append(label)
+
+ def order(romset: str) -> tuple:
+ family = 0 if romset.startswith("MAME") else 1 if romset.startswith("FB Alpha") else 2
+ numbers = tuple(int(n) for n in re.findall(r"\d+", romset.split(" (", 1)[0]))
+ return family, numbers
+
+ lines = ["| Emulator, as RetroArch names it | Sets from |", "|---|---|"]
+ for romset in sorted(rows, key=order):
+ lines.append(f"| {', '.join(sorted(rows[romset]))} | {romset} |")
+ return lines
+
+
+def with_romset_table(page: str, profiles: dict) -> str:
+ """The FAQ page with its romset table read from the profiles."""
+ table = "\n".join(romset_table(profiles)) + "\n"
+ if not _ROMSET_BLOCK.search(page):
+ raise ValueError(f"{ROMSET_FAQ_PAGE}: no romsets block to write the table in")
+ return _ROMSET_BLOCK.sub(lambda m: f"{m.group(1)}{table}{m.group(3)}", page, count=1)
+
+
def _render_metadata_rows(profile: dict) -> list[str]:
"""Scalar profile fields, one table row each.
@@ -8170,6 +8746,85 @@ def _ps_quote(value: str) -> str:
return value if re.fullmatch(r"[\w.+-]+", value) else "'" + value.replace("'", "''") + "'"
+# The OS tabs of a system's install box: labels the site's other command
+# tabs use, so the reader's choice carries from page to page.
+INSTALL_PICKER_OSES = ("windows", "macos", "linux", "steamdeck")
+
+
+def _install_args(platform: str, system: str, shell: str) -> str:
+ quoted = _ps_quote(system) if shell == "powershell" else shlex.quote(system)
+ return f"--platform {platform} --system {quoted}"
+
+
+def _install_picker(declared: list[tuple[str, str]]) -> list[str]:
+ """One command, for the setup the reader picks, under the OS tabs.
+
+ Every setup's command for a console was written out, twice: 12 long
+ lines for the PSP, about 360 page elements once highlighted. The
+ picker shows one and the script (site.ts) rewrites it for the setup
+ chosen, remembered from page to page. Without the script the picker
+ stays hidden and the plain list of every command shows, open.
+ """
+ def attribute(text: str) -> str:
+ # Quotes kept as written: a system id quoted for the shell ('Neo Geo').
+ return html.escape(text, quote=False).replace('"', """)
+
+ names = Counter(platform for platform, _system in declared)
+ options = "".join(
+ f'"
+ for platform, system in declared)
+ lead = declared[0]
+ args = {key: _install_args(*lead, TERMINALS[key][2]) for key in INSTALL_PICKER_OSES}
+ return [
+ "Each platform names the system its own way.",
+ "",
+ '
",
+ "",
+ # Each command as inline code, no highlighting: the list is the
+ # fallback and the reference, and highlighted it was most of the
+ # box's elements.
+ '',
+ "Every setup's command",
+ "",
+ *_command_list("Terminal (Linux, macOS, Steam Deck):", declared, "sh"),
+ *_command_list("PowerShell (Windows):", declared, "powershell"),
+ "",
+ ]
+
+
+def _command_list(label: str, declared: list[tuple[str, str]], shell: str) -> list[str]:
+ """Every setup's command for one shell, a line each, with a copy button.
+
+ Inline HTML code, so no break element is added at each slash. A break
+ point follows the URL's scheme: the Markdown around the code made the
+ bare URL a link inside the command, and a scheme with nothing after it
+ is no link. The break adds no character to a copy."""
+ def command(platform: str, system: str) -> str:
+ text = html.escape(install_command(shell, _install_args(platform, system, shell)), quote=False)
+ return text.replace("://", "://")
+
+ # One HTML line: the glossary leaves HTML alone, and it marked a word
+ # inside a command.
+ names = Counter(platform for platform, _system in declared)
+ items = "".join(
+ f"
'
+ for platform, system in declared)
+ return [label, "", f"
{items}
", ""]
+
+
def _console_install_block(console: str, declared: list[tuple[str, str]]) -> list[str]:
"""Each platform's command for one console, by the system id it gives it,
for a terminal and for PowerShell, each under its setup's name: RetroBat
@@ -8187,6 +8842,8 @@ def _console_install_block(console: str, declared: list[tuple[str, str]]) -> lis
# the files of Other arcade files".
summary = ("Install these files" if console.casefold().endswith(" files")
else f"Install the files of {console}")
+ if markup.starlight() and len(declared) > 1:
+ return search_excluded(markup.details("tip", summary, _install_picker(declared)))
return search_excluded(markup.details("tip", summary, [
"Each platform names the system its own way.",
"",
@@ -8305,8 +8962,9 @@ def _render_structured_blocks(profile: dict) -> list[str]:
return lines
-def _hash_spans(value: str | list) -> str:
- """Each hash a profile accepts, in full.
+def _hash_spans(value: str | list, copy: bool = False) -> str:
+ """Each hash a profile accepts, in full, each with a copy button when
+ *copy*.
Cut to 12 characters with the rest in a title attribute, 208 hashes
that only a profile declares were out of reach of the site search: it
@@ -8315,7 +8973,7 @@ def _hash_spans(value: str | list) -> str:
from hashing import parse_hash_list # noqa: PLC0415
return ", ".join(
- markup.hash_value(v) for v in parse_hash_list(value)
+ markup.hash_value(v, copy) for v in parse_hash_list(value)
)
@@ -8522,6 +9180,10 @@ def _data_tree_page(ref: str, entry: dict, pages: list[tuple[str, list[str]]],
# anchor. A count, not a size: system cards link to these cards and are
# written before them. At 400 a part of MAME's cards was 805 KB of HTML.
EMULATOR_CARDS_PER_PAGE = 220
+# The most cards a part of a split page holds. Each card is a table row
+# since siteentries.py, about five elements and 60 bytes more: at 220 a
+# part, MAME's first was 612 KB and 9,777 elements.
+EMULATOR_CARDS_PER_PART = 180
# The headings an emulator's cards go under, in page order: what it needs
@@ -8556,12 +9218,59 @@ def emulator_parts(name: str, files: list) -> list[tuple[str, list[int]]]:
if len(files) <= EMULATOR_CARDS_PER_PAGE:
return []
order = _display_order(files)
- count = -(-len(files) // EMULATOR_CARDS_PER_PAGE)
+ count = -(-len(files) // EMULATOR_CARDS_PER_PART)
size = -(-len(files) // count)
return [(f"{name}-files-{index + 1}", order[start:start + size])
for index, start in enumerate(range(0, len(files), size))]
+# From this many entries an unsplit emulator page lists them as one table,
+# each row opening to its card (siteentries.py), in place of a summary
+# table above the cards: GroovyMAME's 171 cards read 14,400 words open.
+# A split emulator's card pages always do.
+EMULATOR_TABLE_MIN = 150
+ENTRY_HEADS = ["File", "Contents", "Status", "Hash"]
+# The end of the badge _file_badges writes for an entry of each section.
+_SECTION_BADGE = {"required": ">required", "optional": ">optional",
+ "with_app": ">comes with the app"}
+
+
+def _entry_rows(files: list[dict], cards: list[list[str]], profile: dict,
+ available: Callable[[dict], bool]) -> list[siteentries.Row]:
+ """Each entry as a table row: its card's title and body, the number of
+ members an archive holds, its badges and its hash."""
+ many = len(profile.get("systems") or []) > 1
+ rows = []
+ for f, card in zip(files, cards):
+ title, body = siteentries.card_title_and_body(card)
+ for_system = system_name(str(f["system"])) if f.get("system") and many else ""
+ contents = f.get("contents") or []
+ badges = _file_badges(f, available(f), for_system)
+ # The first badge says whether the entry is needed: under Required,
+ # Optional or Comes with the app it repeats the section heading.
+ # One element and two words fewer on each of MAME's 1,563 rows.
+ if badges and badges[0].endswith(_SECTION_BADGE.get(_card_section(f), "\0")):
+ badges = badges[1:]
+ rows.append(siteentries.Row(title, body, [
+ f"{len(contents):,}" if contents else "",
+ " ".join(badges),
+ _hash_short(f),
+ ]))
+ return rows
+
+
+def _entries_table(files: list[dict], group: list[int], rows: list[siteentries.Row]) -> list[str]:
+ """The rows of ``group``, one section's entries, as one table."""
+ key = _card_section(files[group[0]])
+ heading = dict(CARD_SECTIONS)[key]
+ return siteentries.entry_table([rows[i] for i in group], ENTRY_HEADS,
+ f"rb-entry-filter-{key.replace('_', '-')}", heading,
+ # The badges hide themselves from the search
+ # but for the archive's name ("in pgm.zip"),
+ # how a ZIP name finds the cores reading it.
+ searched=frozenset({"Status"}))
+
+
def _sectioned(files: list, indexes: list[int], render: Callable[[list[int]], list[str]],
fold: bool = False) -> list[str]:
"""Each section's heading, then ``render`` of its entries among ``indexes``.
@@ -8856,15 +9565,7 @@ def emulator_pages(
upstream_raw = profile.get("upstream", "")
upstream_none = "upstream" in profile and upstream_raw is None
upstream = str(upstream_raw) if isinstance(upstream_raw, str) else ""
- version_raw = profile.get("core_version", "unknown")
- if version_raw is None:
- version = "none published"
- elif isinstance(version_raw, dict):
- # Keyed by build mode like source: each build's version, named,
- # where the page printed the dict's Python repr.
- version = ", ".join(f"{v} ({mode})" for mode, v in version_raw.items() if v)
- else:
- version = version_raw
+ version = version_markdown(profile)
profiled = profile.get("profiled_date", "unknown")
systems = profile.get("systems", [])
cores = profile.get("cores", [name])
@@ -8999,12 +9700,18 @@ def emulator_pages(
aliases = emulator_file_aliases(files, anchors)
parts = emulator_parts(name, files)
data_href = f"{name}.md#data-folder" if parts else "#data-folder"
+ table_form = markup.starlight() and (bool(parts) or len(files) >= EMULATOR_TABLE_MIN)
cards = [
_render_emulator_file(f, profile, consumers, files, _file_available, anchor, name,
- alias, data_href)
+ alias, data_href, badges_apart=table_form)
for f, anchor, alias in zip(files, anchors, aliases)
]
- if not parts:
+ rows = (_entry_rows(files, cards, profile, _file_available) if table_form else [])
+ if not parts and table_form:
+ lines.extend(held)
+ lines.extend(_sectioned(files, _display_order(files),
+ lambda group: _entries_table(files, group, rows)))
+ elif not parts:
order = _display_order(files)
indexed = len(files) >= EMULATOR_SUMMARY_MIN
if indexed:
@@ -9026,7 +9733,8 @@ def emulator_pages(
for number, (stem, entries) in enumerate(parts, 1):
part_pages[stem] = _emulator_part_page(
name, emu_name, number, parts, [cards[i] for i in entries],
- [files[i] for i in entries])
+ [files[i] for i in entries],
+ [rows[i] for i in entries] if table_form else None)
lines.extend(sitemeasurements.tested_section(name, _MEASUREMENT_ITEMS.get(name, [])))
@@ -9156,9 +9864,16 @@ def _part_front_matter(parent: tuple[str, str], siblings: list[tuple[str, str]],
def _emulator_part_page(
name: str, emu_name: str, number: int, parts: list[tuple[str, range]],
- cards: list[list[str]], files: list[dict],
+ cards: list[list[str]], files: list[dict], rows: list[siteentries.Row] | None = None,
) -> str:
- """One page of a split emulator's cards."""
+ """One page of a split emulator's cards: with ``rows``, one table row
+ each, the card under its name."""
+ if rows is not None:
+ def render(group: list[int]) -> list[str]:
+ return _entries_table(files, group, rows)
+ else:
+ def render(group: list[int]) -> list[str]:
+ return [line for i in group for line in cards[i]]
first = str(files[0].get("path") or files[0].get("name", ""))
last = str(files[-1].get("path") or files[-1].get("name", ""))
others = ", ".join(
@@ -9173,8 +9888,7 @@ def _emulator_part_page(
f"This part holds {_count(len(files), 'entry', 'entries')}, "
f"{_code(first)} to {_code(last)}. The other parts: {others}.",
"",
- *_sectioned(files, list(range(len(files))),
- lambda group: [line for i in group for line in cards[i]]),
+ *_sectioned(files, list(range(len(files))), render),
]
lines.extend(_next_block([f"[{emu_name}]({name}.md), the profile and every entry"]))
lines.extend(["", _date_line()])
@@ -11195,6 +11909,7 @@ def _generate(args: argparse.Namespace) -> None:
)
if HANDHELD_GUIDE:
write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE))
+ published_parts = _published_platform_parts()
for name, cov in coverages.items():
# The emulators this platform runs, not every one of the system.
runs = resolve_platform_cores(cov["config"], profiles)
@@ -11209,6 +11924,9 @@ def _generate(args: argparse.Namespace) -> None:
if stem in coverages:
raise RuntimeError(f"a system page takes the platform {stem}'s address")
PART_PAGES.add(f"platforms/{stem}.md")
+ anchor = re.search(rf"\]\({re.escape(name)}\.md#([^)\s]+)\)", page)
+ if anchor:
+ published_parts[f"platforms/{stem}.md"] = f"platforms/{name}.md#{anchor[1]}"
# One console of one setup's list, a reference: "playstation
# bios" found five setups' lists before the console's page.
# Weighed down, they still won on their title, which the page
@@ -11334,6 +12052,11 @@ def _generate(args: argparse.Namespace) -> None:
wiki_dest = docs / "wiki"
wiki_dest.mkdir(parents=True, exist_ok=True)
wiki_src = Path(WIKI_SRC_DIR)
+ # The romset table is the profiles' versions, never typed: the source
+ # page is rewritten before it is copied, as the README is.
+ faq = _REPO_ROOT / ROMSET_FAQ_PAGE
+ if faq.is_file():
+ write_if_changed(str(faq), with_romset_table(faq.read_text(encoding="utf-8"), profiles))
if wiki_src.is_dir():
for src_file in wiki_src.glob("*.md"):
shutil.copy2(src_file, wiki_dest / src_file.name)
@@ -11375,6 +12098,8 @@ def _generate(args: argparse.Namespace) -> None:
stale += _sweep_top_pages(docs)
print("Adding page metadata and structured data...")
+ write_if_changed(str(PLATFORM_PARTS_FILE),
+ json.dumps(dict(sorted(published_parts.items())), indent=1) + "\n")
lastmod = decorate_markdown_pages(docs)
nav = generate_nav(coverages, manufacturers, profiles, registry,
@@ -11391,7 +12116,7 @@ def _generate(args: argparse.Namespace) -> None:
json.dumps(sitestarlight.sidebar(nav, present, _overview_anchors(nav, docs)),
indent=1) + "\n")
write_if_changed(str(generated / "redirects.json"),
- json.dumps(sitestarlight.redirects(profiles, present), indent=1,
+ json.dumps(sitestarlight.redirects(profiles, present, published_parts), indent=1,
sort_keys=True) + "\n")
write_if_changed(str(generated / "lastmod.json"),
json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n")
diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py
index e42d99b61..741ac22b7 100644
--- a/scripts/profile_sync.py
+++ b/scripts/profile_sync.py
@@ -31,6 +31,7 @@ import yaml
sys.path.insert(0, os.path.dirname(__file__))
import upstream
+import version_basis
from common import (
_norm_system_id,
github_token,
@@ -2670,6 +2671,129 @@ def apply_edit(path: Path, new_text: str, expected: dict) -> None:
raise YamlWriteError(f"{path}: structure changed beyond the intended field")
+# Key order of a written version_basis, as a reader expects it.
+_BASIS_ORDER = ("kind", "why", "repo", "url", "commit", "path", "pattern", "format",
+ "match", "tag", "distance", "of", "artifact")
+
+
+def _top_block(lines: list[str], key: str) -> tuple[int, int] | None:
+ """Line span of a top-level key and the indented lines under it."""
+ indices = _top_level_indices(lines, key)
+ if not indices:
+ return None
+ if len(indices) > 1:
+ raise YamlWriteError(f"{key}: expected one top-level line, found {len(indices)}")
+ end = indices[0] + 1
+ while end < len(lines) and (lines[end].startswith((" ", "\t")) or not lines[end].strip()):
+ end += 1
+ # Blank lines closing the block belong to the document, not to it.
+ while end > indices[0] + 1 and not lines[end - 1].strip():
+ end -= 1
+ return indices[0], end
+
+
+def _render_version(value: object) -> list[str]:
+ if isinstance(value, dict):
+ return ["core_version:"] + [
+ f" {mode}: {'null' if v is None else _double_quoted(str(v))}"
+ for mode, v in value.items()
+ ]
+ return [f"core_version: {'null' if value is None else _double_quoted(str(value))}"]
+
+
+def _render_basis(basis: dict) -> list[str]:
+ def ordered(entry: dict) -> dict:
+ rank = {key: i for i, key in enumerate(_BASIS_ORDER)}
+ return dict(sorted(entry.items(), key=lambda item: rank.get(item[0], len(rank))))
+
+ def scalar(value: object) -> str:
+ # Every string quoted: a tag 2407 or 0.10 read back as a number. A
+ # pattern keeps its backslashes readable in single quotes.
+ if isinstance(value, bool) or not isinstance(value, str):
+ return str(value).lower() if isinstance(value, bool) else str(value)
+ if "\\" in value or '"' in value:
+ return "'" + value.replace("'", "''") + "'"
+ return _double_quoted(value)
+
+ def block(entry: dict, indent: str) -> list[str]:
+ # kind, why and repo are words of the schema's enums, written bare.
+ return [f"{indent}{key}: {value if key in ('kind', 'why', 'repo') else scalar(value)}"
+ for key, value in ordered(entry).items()]
+
+ if "kind" in basis:
+ return ["version_basis:", *block(basis, " ")]
+ lines = ["version_basis:"]
+ for mode, entry in basis.items():
+ lines += [f" {mode}:", *block(entry, " ")]
+ return lines
+
+
+def set_version_fields(text: str, version: object, basis: dict | None) -> str:
+ """Rewrite core_version and version_basis, version_basis right after it."""
+ lines = text.splitlines()
+ span = _top_block(lines, "core_version")
+ rendered = _render_version(version)
+ if span is None:
+ anchor = _top_block(lines, "upstream_commit") or _top_block(lines, "source_commit") \
+ or _top_block(lines, "profiled_date") or _top_block(lines, "upstream") \
+ or _top_block(lines, "source") or _top_block(lines, "alias_of") \
+ or _top_block(lines, "type")
+ if anchor is None:
+ raise YamlWriteError("core_version: no line to write it after")
+ lines[anchor[1]:anchor[1]] = rendered
+ else:
+ lines[span[0]:span[1]] = rendered
+ if basis is not None:
+ old = _top_block(lines, "version_basis")
+ if old is not None:
+ del lines[old[0]:old[1]]
+ after = _top_block(lines, "core_version")
+ lines[after[1]:after[1]] = _render_basis(basis)
+ return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
+
+
+def apply_readings(document: dict, readings: list) -> tuple[object, dict | None]:
+ """core_version and version_basis as the readings state them.
+
+ A reading that could not be made leaves its build mode as written.
+ """
+ version = document.get("core_version")
+ basis = document.get("version_basis")
+ basis = dict(basis) if isinstance(basis, dict) else None
+ if isinstance(version, dict):
+ version = dict(version)
+ for reading in readings:
+ if reading.status not in (version_basis.MATCH, version_basis.DRIFT):
+ continue
+ if reading.mode:
+ if not isinstance(version, dict):
+ version = {}
+ version[reading.mode] = reading.read
+ else:
+ version = reading.read
+ if reading.updates and basis is not None:
+ target = basis if not reading.mode else dict(basis.get(reading.mode) or {})
+ target.update(dict(reading.updates))
+ if reading.mode:
+ basis[reading.mode] = target
+ return version, basis
+
+
+def write_version(path: Path, readings: list) -> bool:
+ """Set core_version (and a tag basis' tag and distance) from the readings."""
+ text = path.read_text(encoding="utf-8")
+ document = yaml.safe_load(text)
+ version, basis = apply_readings(document, readings)
+ if version == document.get("core_version") and basis == document.get("version_basis"):
+ return False
+ expected = dict(document)
+ expected["core_version"] = version
+ if basis is not None:
+ expected["version_basis"] = basis
+ apply_edit(path, set_version_fields(text, version, basis), expected)
+ return True
+
+
def backfill_commit(path: Path, sha: str) -> bool:
"""Insert source_commit after profiled_date. False when already present."""
text = path.read_text(encoding="utf-8")
@@ -3313,9 +3437,15 @@ def _needs_review(report: ProfileReport, accept_changed: bool) -> bool:
def bump_commit(
- path: Path, report: ProfileReport, accept_changed: bool = False
+ path: Path, report: ProfileReport, accept_changed: bool = False,
+ cache_dir: str = DEFAULT_CACHE, offline: bool = False,
) -> bool:
- """Advance source_commit to HEAD when nothing needs a read again."""
+ """Advance source_commit to HEAD when nothing needs a read again.
+
+ core_version moves with the pin, read again from version_basis at the
+ new revision. Four bulk advances moved 295 pins and no version: an
+ advance whose version cannot be read is refused.
+ """
if _needs_review(report, accept_changed):
return False
text = path.read_text(encoding="utf-8")
@@ -3375,10 +3505,33 @@ def bump_commit(
new_text = _set_pin(
new_text, document, "upstream_commit", report.upstream_head, "source_commit"
)
+ readings = version_basis.check(expected, cache_dir, offline, recompute=True)
+ refused = version_refusal(readings)
+ if refused:
+ print(f"{path.stem}: {refused}, its pin is not advanced", file=sys.stderr)
+ return False
+ version, basis = apply_readings(expected, readings)
+ if version != expected.get("core_version") or basis != expected.get("version_basis"):
+ new_text = set_version_fields(new_text, version, basis)
+ expected["core_version"] = version
+ if basis is not None:
+ expected["version_basis"] = basis
apply_edit(path, new_text, expected)
return True
+def version_refusal(readings: list) -> str | None:
+ """Why the version at a new pin cannot be stated, or None."""
+ for reading in readings:
+ if reading.status == version_basis.NO_BASIS:
+ return "no version_basis says where its version is read"
+ if reading.status == version_basis.UNREADABLE:
+ return f"core_version cannot be read at the new pin: {reading.where}"
+ if reading.status == version_basis.UNCHECKED:
+ return f"core_version is {reading.where}, not read from the code at a pin"
+ return None
+
+
def pins_changed(before: dict, after: dict) -> list[str]:
"""The pin fields whose value a write changed."""
return [
@@ -3996,6 +4149,16 @@ def build_parser() -> argparse.ArgumentParser:
"--check-version", action="store_true",
help="compare core_version with the latest tag and release",
)
+ parser.add_argument(
+ "--verify-version", action="store_true",
+ help="read core_version again from version_basis at the pin and "
+ "exit 1 when the code states another",
+ )
+ parser.add_argument(
+ "--set-version", action="store_true",
+ help="write core_version from version_basis at the pin, the nearest "
+ "tag asked of git again",
+ )
parser.add_argument(
"--detect-new-files", action="store_true",
help="name files upstream loads that the profile does not list",
@@ -4284,7 +4447,8 @@ def _apply_writes(
applied = []
if args.rebase_refs and not report.skipped:
applied = rebase_refs(target, report, args.accept_changed)
- moved = bump_commit(target, report, args.accept_changed)
+ moved = bump_commit(target, report, args.accept_changed,
+ args.cache_dir, args.offline)
if args.rebase_refs and applied and not moved:
keyed = any(isinstance(profile.get(f), dict) for f in ("source_commit", "upstream_commit"))
reason = (
@@ -4723,6 +4887,32 @@ def _print_triage(
def _refuse_flag_combinations(args: argparse.Namespace) -> None:
"""Exit on a flag the chosen mode would not apply, before any request."""
+ if args.verify_version or args.set_version:
+ ignored = [
+ flag for flag, on in (
+ ("--verify-version", args.verify_version and args.set_version),
+ ("--markdown", args.markdown),
+ ("--fetch-plan", args.fetch_plan),
+ ("--triage", args.triage),
+ ("--strict", args.strict),
+ ("--check-version", args.check_version),
+ ("--detect-new-files", args.detect_new_files),
+ ("--watch-hashes", args.watch_hashes),
+ ("--full-diff", args.full_diff),
+ ("--tree-diff", args.tree_diff),
+ ("--backfill-commits", args.backfill_commits),
+ ("--rebase-refs", args.rebase_refs),
+ ("--attribute-refs", args.attribute_refs),
+ ("--bump-commit", args.bump_commit),
+ ("--realign-prose", args.realign_prose),
+ ("--accept-changed", args.accept_changed),
+ ("--dry-run", args.dry_run and not args.set_version),
+ ) if on
+ ]
+ if ignored:
+ mode = "--set-version" if args.set_version else "--verify-version"
+ print(f"{mode} does not apply {', '.join(ignored)}", file=sys.stderr)
+ raise SystemExit(1)
if args.fresh and args.offline:
print("--fresh asks the forges again, which --offline forbids.", file=sys.stderr)
raise SystemExit(1)
@@ -4809,6 +4999,63 @@ def _refuse_flag_combinations(args: argparse.Namespace) -> None:
raise SystemExit(1)
+def _version_selection(args: argparse.Namespace, profiles: dict) -> dict:
+ """--all reaches the alias and test profiles too: each states a version."""
+ if args.all and not (args.system or args.type_ or args.stale_before or args.limit):
+ return dict(sorted(profiles.items()))
+ return select_profiles(profiles, args)
+
+
+def format_reading(name: str, reading: version_basis.Reading) -> str:
+ mode = f"[{reading.mode}] " if reading.mode else ""
+ kind = f"{reading.kind} " if reading.kind else ""
+ if reading.status == version_basis.DRIFT:
+ said = f"declared {reading.declared}, code {reading.read}"
+ elif reading.status == version_basis.MATCH:
+ said = str(reading.read)
+ else:
+ said = f"declared {reading.declared}"
+ return f"{name}: {mode}{reading.status} {said} ({kind}{reading.where})"
+
+
+def run_versions(args: argparse.Namespace, profiles: dict) -> None:
+ """--verify-version and --set-version: core_version against the code."""
+ selected = _version_selection(args, profiles)
+ if args.set_version and not args.dry_run and not args.force:
+ _refuse_dirty_tree(args.emulators_dir, list(selected))
+ counts: Counter = Counter()
+ rows = []
+ failed = False
+ for name, profile in selected.items():
+ readings = version_basis.check(
+ profile, args.cache_dir, args.offline, profiles, recompute=args.set_version
+ )
+ for reading in readings:
+ counts[reading.status] += 1
+ rows.append({"profile": name, **asdict(reading)})
+ if not args.as_json and (not args.changed_only or reading.status != version_basis.MATCH):
+ print(format_reading(name, reading))
+ if args.set_version:
+ path = Path(args.emulators_dir) / f"{name}.yml"
+ if args.dry_run:
+ continue
+ try:
+ if write_version(path, readings):
+ print(f"{name}: core_version written")
+ except YamlWriteError as exc:
+ print(f"{name}: write refused: {exc}", file=sys.stderr)
+ failed = True
+ elif any(r.status in version_basis.FAILING for r in readings):
+ failed = True
+ if args.as_json:
+ print(json.dumps(rows, indent=2))
+ else:
+ print(", ".join(f"{count} {status}" for status, count in sorted(counts.items())),
+ file=sys.stderr)
+ if failed:
+ raise SystemExit(1)
+
+
def main() -> None:
"""Build one report per selected profile, then apply writes and output."""
args = build_parser().parse_args()
@@ -4818,6 +5065,9 @@ def main() -> None:
# trzy/Supermodel and VICE read as behind their real HEAD.
upstream.MOVING_TTL = 0
profiles = load_emulator_profiles(args.emulators_dir, skip_aliases=False)
+ if args.verify_version or args.set_version:
+ run_versions(args, profiles)
+ return
selected = select_profiles(profiles, args)
_check_quota(len(selected), args.offline)
diff --git a/scripts/scraper/_hash_merge.py b/scripts/scraper/_hash_merge.py
index 1642357f5..4fe3cc93d 100644
--- a/scripts/scraper/_hash_merge.py
+++ b/scripts/scraper/_hash_merge.py
@@ -158,7 +158,12 @@ def merge_fbneo_profile(
profile = _load_yaml(profile_path)
hashes = _load_json(hashes_path)
- profile["core_version"] = hashes.get("version", profile.get("core_version"))
+ # core_version stays as written: it is the version of the code at the
+ # profile's own pin, read through version_basis (profile_sync
+ # --set-version). The hashes come from finalburnneo/FBNeo's tip, a
+ # revision the libretro/FBNeo pin does not name: writing its version
+ # left fbneo, fbneo_cps12 and fbneo_neogeo on a release their pin
+ # never was.
files = profile.get("files", [])
archive_files, non_archive = _split_files(files, lambda f: "archive" in f)
@@ -1060,17 +1065,16 @@ def _backup_and_write_fbneo(path: str, data: dict, hashes: dict) -> None:
"""Write merged FBNeo profile using text-based patching.
FBNeo profiles have individual ROM entries with archive: field.
- Patches core_version, the content values of existing entries that
- changed, and appends new ROM entries. Advancing core_version alone left
- a profile claiming a release whose crc32 it did not carry. Comments and
- curated source_refs stay as written.
+ Patches the content values of existing entries that changed and
+ appends new ROM entries. core_version is the pin's, never written here.
+ Comments and curated source_refs stay as written.
"""
p = Path(path)
backup = p.with_suffix(PROFILE_BACKUP_SUFFIX)
shutil.copy2(p, backup)
original = p.read_text(encoding="utf-8")
- patched = _patch_core_version(original, data.get("core_version", ""))
+ patched = original
# Identify new ROM entries by comparing parsed data keys, not text search
existing_data = yaml_load(original) or {}
diff --git a/scripts/scraper/fbneo_hash_scraper.py b/scripts/scraper/fbneo_hash_scraper.py
index b641be10c..17ac95a43 100644
--- a/scripts/scraper/fbneo_hash_scraper.py
+++ b/scripts/scraper/fbneo_hash_scraper.py
@@ -11,6 +11,7 @@ import argparse
import json
import logging
import os
+import re
import shutil
import subprocess
import sys
@@ -20,7 +21,7 @@ from typing import Any
import yaml
-from common import github_token, source_yaml_paths, write_text_atomic, yaml_load
+from common import source_yaml_paths, write_text_atomic, yaml_load
from scripts.scraper._hash_merge import compute_diff, merge_fbneo_profile
from scripts.scraper.fbneo_parser import parse_fbneo_source_tree
@@ -51,7 +52,7 @@ def _is_cache_fresh() -> bool:
def _sparse_clone() -> None:
- """Sparse clone FBNeo repo, checking out only src/burn/drv."""
+ """Sparse clone FBNeo repo: src/burn/drv, and in cone mode src/burn/version.h."""
if CLONE_DIR.exists():
shutil.rmtree(CLONE_DIR)
@@ -74,7 +75,7 @@ def _sparse_clone() -> None:
)
subprocess.run(
- ["git", "sparse-checkout", "set", "src/burn/drv", "src/burner/resource.h"],
+ ["git", "sparse-checkout", "set", "src/burn/drv"],
cwd=CLONE_DIR,
check=True,
capture_output=True,
@@ -82,31 +83,28 @@ def _sparse_clone() -> None:
)
+# src/burn/version.h, as the libretro core prints it: "v%x.%x.%x.%02x" of
+# the BCD BURN_VERSION, so VER_ALPHA 3 reads 1.0.0.03. FBNeo bumps this file
+# without tagging: the last tag (v1.0.0.02) was 9 337 commits behind it.
+_VERSION_H = re.compile(
+ r"#define\s+VER_MAJOR\s+(\d+)\s+#define\s+VER_MINOR\s+(\d+)\s+"
+ r"#define\s+VER_BETA\s+(\d+)\s+#define\s+VER_ALPHA\s+(\d+)"
+)
+
+
+def version_from_header(text: str) -> str:
+ """The version src/burn/version.h states."""
+ found = _VERSION_H.search(text)
+ if not found:
+ raise RuntimeError("src/burn/version.h states no VER_MAJOR..VER_ALPHA")
+ major, minor, beta, alpha = (int(group) for group in found.groups())
+ return f"v{major}.{minor}.{beta}.{alpha:02d}"
+
+
def _extract_version() -> tuple[str, str]:
- """Extract version tag and commit SHA from the cloned repo.
-
- Returns (version, commit_sha). Falls back to resource.h if no tag.
- """
- result = subprocess.run(
- ["git", "describe", "--tags", "--abbrev=0"],
- cwd=CLONE_DIR,
- capture_output=True,
- text=True,
- )
-
- # Prefer real version tags over pseudo-tags like "latest"
- version = "unknown"
- if result.returncode == 0:
- tag = result.stdout.strip()
- if tag and tag != "latest":
- version = tag
- # Fallback: resource.h
- if version == "unknown":
- version = _version_from_resource_h()
- # Last resort: use GitHub API for latest real release tag
- if version == "unknown":
- version = _version_from_api()
-
+ """The version the cloned source states, and its commit."""
+ header = CLONE_DIR / "src" / "burn" / "version.h"
+ version = version_from_header(header.read_text(encoding="utf-8", errors="replace"))
sha_result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=CLONE_DIR,
@@ -114,49 +112,7 @@ def _extract_version() -> tuple[str, str]:
text=True,
check=True,
)
- commit = sha_result.stdout.strip()
-
- return version, commit
-
-
-def _version_from_api() -> str:
- """The latest real release tag, or an error: "unknown" was written as
- core_version and cached for a day."""
- import json as json_mod
- import urllib.error
- import urllib.request
-
- headers = {"User-Agent": "retrobios-scraper/1.0"}
- token = github_token()
- if token:
- headers["Authorization"] = f"Bearer {token}"
- req = urllib.request.Request(
- "https://api.github.com/repos/finalburnneo/FBNeo/tags?per_page=10", headers=headers
- )
- try:
- with urllib.request.urlopen(req, timeout=10) as resp:
- tags = json_mod.loads(resp.read())
- except (urllib.error.URLError, OSError, ValueError) as exc:
- raise RuntimeError(f"cannot determine the FBNeo version: {exc}") from exc
- for tag in tags:
- if tag["name"] != "latest" and tag["name"].startswith("v"):
- return tag["name"]
- raise RuntimeError("cannot determine the FBNeo version: no tag, resource.h or release")
-
-
-def _version_from_resource_h() -> str:
- """Fallback: parse VER_FULL_VERSION_STR from resource.h."""
- resource_h = CLONE_DIR / "src" / "burner" / "resource.h"
- if not resource_h.exists():
- return "unknown"
-
- text = resource_h.read_text(encoding="utf-8", errors="replace")
- for line in text.splitlines():
- if "VER_FULL_VERSION_STR" in line:
- parts = line.split('"')
- if len(parts) >= 2:
- return parts[1]
- return "unknown"
+ return version, sha_result.stdout.strip()
def _cleanup() -> None:
diff --git a/scripts/scraper/mame_hash_scraper.py b/scripts/scraper/mame_hash_scraper.py
index 0d2e8411d..bbdc38d08 100644
--- a/scripts/scraper/mame_hash_scraper.py
+++ b/scripts/scraper/mame_hash_scraper.py
@@ -467,6 +467,13 @@ def release_profile(
"source_commit": source_commit,
"upstream_commit": upstream_commit,
"core_version": version,
+ # Where profile_sync reads it again: the makefile of the libretro/mame
+ # tree the platform builds, at source_commit.
+ "version_basis": {
+ "kind": "file",
+ "path": "makefile",
+ "pattern": '#define BARE_BUILD_VERSION "([^"]+)"',
+ },
"display_name": f"Arcade (MAME {version})",
"cores": cores,
"built_by": built_by,
diff --git a/scripts/scraper/misterfpga_scraper.py b/scripts/scraper/misterfpga_scraper.py
index 78585f6e8..90c4888fb 100644
--- a/scripts/scraper/misterfpga_scraper.py
+++ b/scripts/scraper/misterfpga_scraper.py
@@ -215,12 +215,12 @@ class Scraper(BaseScraper):
if not entry["docs"]:
del entry["docs"]
+ # The BiosDB has no release: the date it was built is the only
+ # version the list carries, and the manifest needs one.
timestamp = data.get("timestamp")
- released = (
- datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y-%m-%d")
- if isinstance(timestamp, (int, float))
- else ""
- )
+ if not isinstance(timestamp, (int, float)):
+ raise RuntimeError(f"{self.url}: the BiosDB records no timestamp")
+ released = datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y-%m-%d")
return {
"platform": "MiSTer FPGA",
diff --git a/scripts/siteentries.py b/scripts/siteentries.py
new file mode 100644
index 000000000..7fc5dc31c
--- /dev/null
+++ b/scripts/siteentries.py
@@ -0,0 +1,112 @@
+"""Long lists as one filterable table, each row opening to its details.
+
+MAME's card pages read 13,000 to 16,000 words, one card after another,
+before a reader found the archive they came for. Here each entry is one
+row: its name, the facts that tell rows apart, and the full card in a
+closed block under the name. The table is written as HTML so the block can
+hold the card's Markdown. It carries what rehype-stack-tables.mjs gives a
+Markdown table: `data-rb-stack` (one block per row on a phone) and the
+filter box above, which site.ts wires. Without JavaScript the rows show and
+every block opens. A closed block stays in the page, so the browser's find
+and the site search still reach every name it holds.
+"""
+
+from __future__ import annotations
+
+import html
+from dataclasses import dataclass, field
+
+import sitemarkup as markup
+
+# rehype-stack-tables.mjs RECORD_ROWS: from this many rows a table has a
+# filter box, and its stacked labels are written by site.ts, not the build.
+RECORD_ROWS = 20
+# filterPlaceholder in rehype-stack-tables.mjs: the column names offered.
+PLACEHOLDER_COLUMNS = 4
+
+
+@dataclass
+class Row:
+ """One entry: ``title`` is the inline HTML of its name (a heading, so
+ the site search lands on it), ``body`` the Markdown lines shown when the
+ row opens, ``cells`` the HTML of the other columns."""
+
+ title: str
+ body: list[str]
+ cells: list[str] = field(default_factory=list)
+
+
+def filter_placeholder(labels: list[str]) -> str:
+ """"File, contents, status..." as rehype-stack-tables.mjs words it."""
+ words = [label for label in labels if label][:PLACEHOLDER_COLUMNS]
+ if not words:
+ return "Type to filter..."
+ lowered = [word if index == 0 or not (word[:1].isupper() and word[1:2].islower())
+ else word[0].lower() + word[1:] for index, word in enumerate(words)]
+ return f"{', '.join(lowered)}..."
+
+
+def filter_box(ident: str, heading: str, labels: list[str], rows: int, noun: str = "rows") -> str:
+ """The filter box rehype-stack-tables.mjs writes above a long table."""
+ return (
+ f'
'
+ )
+
+
+def card_title_and_body(card: list[str]) -> tuple[str, list[str]]:
+ """A card's heading line and the lines under it, its wrapping block
+ (the first line and the last closing tag) taken off."""
+ title_at = next(index for index, line in enumerate(card) if line.startswith("
")
+ body = card[title_at + 1:closing]
+ while body and not body[0].strip():
+ body.pop(0)
+ while body and not body[-1].strip():
+ body.pop()
+ return card[title_at], body
+
+
+def entry_table(rows: list[Row], heads: list[str], ident: str, heading: str,
+ stacked: list[str] | None = None,
+ searched: frozenset[str] = frozenset()) -> list[str]:
+ """``rows`` under ``heads`` (the first names the column of titles).
+
+ ``stacked`` gives a column's short label on a phone, as a header's
+ `title` does in a Markdown table. A column every row leaves empty
+ (``""``) is not written. The site search skips every column but the
+ titles and those ``searched`` names: a count or a short hash is no word
+ anyone types.
+ """
+ keep = [0] + [column for column in range(1, len(heads))
+ if any(row.cells[column - 1] for row in rows)]
+ labels = [heads[c] for c in keep]
+ short = [(stacked or heads)[c] for c in keep]
+ skip = markup.search_exclude()
+ # The block holds the box and the table: an open card spans its width.
+ lines = ["", '
')
+ head_cells = ""
+ for label, name in zip(labels, short):
+ title = f' title="{html.escape(name)}"' if name != label else ""
+ head_cells += f'
{html.escape(label)}
'
+ lines.extend(["", f"
{head_cells}
", "", ""])
+ for row in rows:
+ lines.append("
")
+ if row.body:
+ lines.extend(["
", '', f"{row.title}",
+ "", *row.body, "", "", "
"])
+ else:
+ lines.append(f"
{row.title}
")
+ for column in keep[1:]:
+ label = (f' data-label="{html.escape(short[keep.index(column)])}"'
+ if len(rows) < RECORD_ROWS else "")
+ ignored = "" if heads[column] in searched else f" {skip}"
+ lines.append(f"
{row.cells[column - 1] or '-'}
")
+ lines.append("
")
+ lines.extend(["", "
", "
", ""])
+ return lines
diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py
index a2a24f2d1..2660f04b9 100644
--- a/scripts/sitemarkup.py
+++ b/scripts/sitemarkup.py
@@ -156,13 +156,15 @@ def image(alt: str, src: str, **attributes: str) -> str:
return f''
-def hash_value(value: object) -> str:
+def hash_value(value: object, copy: bool = False) -> str:
"""A hash printed whole, in the hash style. One element under Starlight:
- a span around a code span was 1,400 extra elements on a page of cards."""
+ a span around a code span was 1,400 extra elements on a page of cards.
+ *copy* marks it for the copy button the site script adds after it."""
text = str(value).replace("`", "'")
if _engine == "mkdocs":
return f'`{text}`'
- return f'{html.escape(text, quote=False)}'
+ marker = ' data-copy=""' if copy else ""
+ return f'{html.escape(text, quote=False)}'
def block_open(tag: str, attributes: str = "") -> str:
diff --git a/scripts/sitesources.py b/scripts/sitesources.py
index c0270a5e9..55dc2f5e9 100644
--- a/scripts/sitesources.py
+++ b/scripts/sitesources.py
@@ -425,6 +425,24 @@ def _two_repositories(profile: dict) -> bool:
def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
"""Render source_ref values as pinned links when their forge is known.
+ Each part is followed by whose code it cites, in words, when the profile
+ reads two repositories. source_ref_items() gives the same parts with
+ that role apart, for a page that names the roles once in a legend.
+ """
+ rendered_groups = []
+ for label, items in source_ref_items(profile, value):
+ text = ", ".join(f"{part} ({role})" if role else part for part, role in items)
+ if label:
+ text = f"**{label}:** {text}"
+ rendered_groups.append(text)
+ return ", ".join(rendered_groups)
+
+
+def source_ref_items(profile: dict, value: str | dict | None) -> list[tuple[str, list[tuple[str, str]]]]:
+ """Each group of source_ref values: its label and its parts, each part
+ as Markdown with the role_label() of the code it cites ("" when the
+ profile reads one repository).
+
A profile can declare two repositories: the libretro port in ``source`` and
the original emulator in ``upstream``. A path links into the one whose
tree SOURCE_PATHS says holds it. Any other path is rendered as plain
@@ -432,18 +450,18 @@ def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
link to the wrong repository is a false citation. An address inside a
shipped binary reads as one (offset 0x8094-0x80ac in libnes.so).
"""
- rendered_groups: list[str] = []
+ groups: list[tuple[str, list[tuple[str, str]]]] = []
path_re = re.compile(r"^[A-Za-z0-9_.@/+~-]+$")
for label, refs in source_ref_values(value):
candidates = _forge_sources(profile, label)
by_role = role_sources(profile, label)
- rendered: list[str] = []
+ rendered: list[tuple[str, str]] = []
current_role = ""
binary = ""
for part in split_source_ref(refs):
if is_binary_citation(part.path):
text, binary = _binary_citation(part.path, binary)
- rendered.append(text)
+ rendered.append((text, ""))
continue
binary = ""
role, bare = ref_role(part.path)
@@ -473,22 +491,19 @@ def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
else:
selected = _holder(candidates, part.path)
- tag = (f" ({role_label(profile, current_role, part.path)})"
- if current_role and _two_repositories(profile) else "")
+ role = (role_label(profile, current_role, part.path)
+ if current_role and _two_repositories(profile) else "")
if selected is None:
- rendered.append(f"`{display}`{tag}")
+ rendered.append((f"`{display}`", role))
else:
repo, pin = selected
url = _source_permalink(
repo, pin, real_path, part.start, part.end
)
- rendered.append(f"[`{display}`]({url}){tag}")
+ rendered.append((f"[`{display}`]({url})", role))
- text = ", ".join(rendered) if rendered else f"`{refs}`"
- if label:
- text = f"**{label}:** {text}"
- rendered_groups.append(text)
- return ", ".join(rendered_groups)
+ groups.append((label, rendered or [(f"`{refs}`", "")]))
+ return groups
def _binary_citation(path: str, previous: str) -> tuple[str, str]:
diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py
index c766af591..dad8fa63f 100644
--- a/scripts/sitestarlight.py
+++ b/scripts/sitestarlight.py
@@ -809,19 +809,23 @@ MOVED_PAGES = {"wiki/getting-started.md": "which-pack.md"}
_CORE_NAME = re.compile(r"[a-z0-9_-]+")
-def redirects(profiles: dict, present: set[str]) -> dict[str, str]:
+def redirects(profiles: dict, present: set[str],
+ moved: dict[str, str] | None = None) -> dict[str, str]:
"""Old and alternative addresses, for src/generated/redirects.json.
A page that moved, once its source is gone, and each name a profile's
`cores:` gives the emulator (the buildbot's `mednafen_psx` for
beetle_psx), to the profile's page. A name two profiles claim, or one
that is already a page's address, is left out. Addresses are written
- under the site root, without its base."""
+ under the site root, without its base. ``moved`` adds pages that moved
+ by generation, old page to new page, the new one with an optional
+ #fragment."""
def address(rel: str) -> str:
- return "/" + page_url(rel)[len(BASE):]
+ page, _, fragment = rel.partition("#")
+ return "/" + page_url(page)[len(BASE):] + (f"#{fragment}" if fragment else "")
- out = {address(old): address(new) for old, new in MOVED_PAGES.items()
- if old not in present and new in present}
+ out = {address(old): address(new) for old, new in {**MOVED_PAGES, **(moved or {})}.items()
+ if old not in present and new.partition("#")[0] in present}
pages = {rel.casefold() for rel in present}
claims: dict[str, set[str]] = {}
for key, profile in profiles.items():
diff --git a/scripts/upstream.py b/scripts/upstream.py
index c2cb95a0d..9892cded2 100644
--- a/scripts/upstream.py
+++ b/scripts/upstream.py
@@ -12,6 +12,7 @@ import http.client
import json
import os
import socket
+import subprocess
import tempfile
import time
import urllib.error
@@ -1086,3 +1087,103 @@ def commits_touching(
1 for entry in payload
if isinstance(entry, dict) and (entry.get("sha") or entry.get("id")) != base
)
+
+
+# Commit graphs kept to count the commits between a tag and a pin. A
+# treeless bare clone holds commits and tags only: the files a profile reads
+# come from fetch_file, by sha.
+GIT_DIR = "_git"
+
+
+def clone_dir(cache_dir: str, repo: Repo) -> Path:
+ """Where the commit graph of a repository is kept."""
+ return Path(cache_dir) / GIT_DIR / repo.host / repo.owner / f"{repo.name}.git"
+
+
+def _git(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess:
+ env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
+ return subprocess.run(
+ ["git", *args], cwd=cwd, capture_output=True, text=True, env=env, check=False
+ )
+
+
+def _has_commit(clone: Path, sha: str) -> bool:
+ return _git(["cat-file", "-e", f"{sha}^{{commit}}"], clone).returncode == 0
+
+
+def _graph(repo: Repo, sha: str, cache_dir: str, offline: bool) -> Path:
+ """A clone holding sha and every tag, fetched again once tags may have moved."""
+ clone = clone_dir(cache_dir, repo)
+ url = f"https://{repo.host}/{repo.slug}.git"
+ if not (clone / "HEAD").is_file():
+ if offline:
+ raise NotCachedError(f"{repo.slug}: no commit graph in the offline cache")
+ clone.parent.mkdir(parents=True, exist_ok=True)
+ result = _git(["clone", "--quiet", "--bare", "--filter=tree:0", url, str(clone)])
+ if result.returncode:
+ raise UpstreamError(f"{url}: clone failed: {result.stderr.strip()}")
+ _git(["fetch", "--quiet", "--filter=tree:0", "--tags", "origin"], clone)
+ stamp = clone / "FETCH_HEAD"
+ fresh = stamp.is_file() and time.time() - stamp.stat().st_mtime <= MOVING_TTL
+ if offline or (fresh and _has_commit(clone, sha)):
+ if not _has_commit(clone, sha):
+ raise NotCachedError(f"{repo.slug}@{sha[:8]}: not in the offline commit graph")
+ return clone
+ # Tags are fetched again: a tag pushed since the clone can be nearer.
+ result = _git(
+ ["fetch", "--quiet", "--filter=tree:0", "--force", "--tags", "origin",
+ "+refs/heads/*:refs/heads/*"], clone,
+ )
+ if result.returncode:
+ raise UpstreamError(f"{url}: fetch failed: {result.stderr.strip()}")
+ if not _has_commit(clone, sha):
+ # A commit no branch reaches any more: a rewritten fork, a PR head.
+ _git(["fetch", "--quiet", "--filter=tree:0", "origin", sha], clone)
+ if not _has_commit(clone, sha):
+ raise UpstreamError(f"{url}: {sha[:8]} is on no branch the forge serves")
+ return clone
+
+
+def describe(
+ repo: Repo, sha: str, cache_dir: str, offline: bool = False,
+ match: str | None = None,
+) -> tuple[str, int] | None:
+ """The tag nearest to sha in its history, and the commits after it.
+
+ git describe counts the commits sha has that the tag lacks, the count a
+ forge compare reports as ahead. ``match`` is a glob the tag must fit.
+ None when no tag is reachable from sha.
+ """
+ clone = _graph(repo, sha, cache_dir, offline)
+ argv = ["describe", "--tags", "--long", "--abbrev=40"]
+ if match:
+ argv += ["--match", match]
+ result = _git([*argv, sha], clone)
+ if result.returncode:
+ if any(text in result.stderr for text in (
+ "No names found", "No tags can describe", "cannot describe",
+ )):
+ return None
+ raise UpstreamError(f"{repo.slug}: describe {sha[:8]}: {result.stderr.strip()}")
+ tag, _distance, _commit = result.stdout.strip().rsplit("-", 2)
+ # describe's own count is an estimate across merges (ares: 149 where the
+ # pin holds 143 commits the tag lacks): the exact count is rev-list's.
+ counted = _git(["rev-list", "--count", f"refs/tags/{tag}..{sha}"], clone)
+ if counted.returncode:
+ raise UpstreamError(f"{repo.slug}: {tag}..{sha[:8]}: {counted.stderr.strip()}")
+ return tag, int(counted.stdout.strip())
+
+
+def graph_ancestry(
+ repo: Repo, base: str, head: str, cache_dir: str, offline: bool = False
+) -> tuple[int, int]:
+ """(ahead, behind) counted in the commit graph, for a pair a forge
+ refuses to compare (GitHub answers 422 past a diff size)."""
+ clone = _graph(repo, head, cache_dir, offline)
+ counts = []
+ for spec in (f"{base}..{head}", f"{head}..{base}"):
+ result = _git(["rev-list", "--count", spec], clone)
+ if result.returncode:
+ raise UpstreamError(f"{repo.slug}: {spec}: {result.stderr.strip()}")
+ counts.append(int(result.stdout.strip()))
+ return counts[0], counts[1]
diff --git a/scripts/version_basis.py b/scripts/version_basis.py
new file mode 100644
index 000000000..c4e169d2a
--- /dev/null
+++ b/scripts/version_basis.py
@@ -0,0 +1,280 @@
+"""Read a profile's core_version from the source its version_basis records.
+
+A profile states the version of the code it was read at. Copied from a
+libretro .info or left behind by a pin advance, it named another release:
+snes9x said 1.61 at a pin whose snes9x.h states 1.63, and four bulk advances
+moved 295 pins without touching one version. version_basis records where the
+version is read, so a tool can read it again at any pin:
+
+- ``file``: a pattern in one file of a declared repository at its pin, the
+ version the program states (a macro, a build file, a package file).
+- ``tag``: the nearest tag in the pin's history and the commits after it,
+ for code that takes its version from git at build time. core_version is
+ the tag at distance 0, else ``--g``, git describe's
+ form.
+- ``alias``: the same binary as another profile, whose version it carries.
+- ``package``: an artefact read outside any source tree (an Android package,
+ a closed binary, a release archive): a version no pin can be asked for.
+ A pattern reads it from the artefact's name when the name states it.
+- ``none``: the code at the pin states no version and no tag reaches it.
+ core_version is then null.
+- ``unverified``: no reading could be made (``why``: no pin, a host the
+ tools cannot read, a forge gone, no build named as read, no source).
+ core_version stays as written and is reported unconfirmed.
+
+A profile whose builds live in different repositories keys version_basis by
+build mode, the way it keys core_version.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+import upstream
+
+KINDS = ("file", "tag", "alias", "package", "none", "unverified")
+# Values copied from a .info or a branch name, which name no version.
+PLACEHOLDERS = frozenset({"git", "svn", "unknown", "master", "main", "latest", "dev", "head"})
+# Pin field of each repository field a basis may name. A mirror carries the
+# source's history, so source_commit pins it too.
+PIN_FIELDS = {"source": "source_commit", "source_mirror": "source_commit",
+ "upstream": "upstream_commit"}
+
+MATCH = "MATCH"
+DRIFT = "DRIFT"
+UNREADABLE = "UNREADABLE"
+NO_BASIS = "NO_BASIS"
+UNCHECKED = "UNCHECKED"
+FAILING = (DRIFT, UNREADABLE, NO_BASIS)
+
+_FIELD = re.compile(r"\{(\d+)(?::([^}]*))?\}")
+
+
+class VersionUnreadable(Exception):
+ """The basis yields no version at the pin it is asked about."""
+
+
+@dataclass(frozen=True)
+class Reading:
+ mode: str
+ kind: str
+ declared: str | None
+ read: str | None
+ where: str
+ status: str
+ # What a write sets beside core_version: the tag and distance of a tag basis.
+ updates: tuple[tuple[str, object], ...] = ()
+
+
+def bases(profile: dict) -> dict[str, dict]:
+ """The basis of each build mode, "" when one covers every build."""
+ basis = profile.get("version_basis")
+ if not isinstance(basis, dict):
+ return {}
+ if "kind" in basis:
+ return {"": basis}
+ return {str(mode): value for mode, value in basis.items() if isinstance(value, dict)}
+
+
+def declared(profile: dict, mode: str) -> str | None:
+ """core_version for a build mode, as written."""
+ value = profile.get("core_version")
+ if isinstance(value, dict):
+ value = value.get(mode)
+ return None if value is None else str(value)
+
+
+def _by_mode(profile: dict, field: str, mode: str) -> str | None:
+ value = profile.get(field)
+ if isinstance(value, dict):
+ value = value.get(mode)
+ return str(value) if value else None
+
+
+def is_placeholder(value: object) -> bool:
+ """A VCS or branch word standing for a version, alone or annotated
+ ("SVN (2015 snapshot)"). The schema refuses the same strings."""
+ if not isinstance(value, str):
+ return False
+ return re.split(r"[\s(]", value.strip(), maxsplit=1)[0].casefold() in PLACEHOLDERS
+
+
+def format_version(match: re.Match, template: str | None) -> str:
+ """The groups of a match laid out by template, `{1}.{2}` or `{4:02d}`.
+
+ A format spec applies to the group read as an integer: FBNeo writes its
+ alpha number on two digits (VER_ALPHA 3 is 1.0.0.03).
+ """
+ if not template:
+ return match.group(1).strip()
+
+ def field(found: re.Match) -> str:
+ text = match.group(int(found.group(1))) or ""
+ spec = found.group(2)
+ return format(int(text, 0), spec) if spec else text.strip()
+
+ return _FIELD.sub(field, template)
+
+
+def read_text_version(text: str, pattern: str, template: str | None) -> tuple[str, int] | None:
+ """The version a pattern reads in a file's text, and its line number."""
+ found = re.search(pattern, text, re.MULTILINE)
+ if not found:
+ return None
+ return format_version(found, template), text.count("\n", 0, found.start()) + 1
+
+
+def describe_form(tag: str, distance: int, pin: str) -> str:
+ """core_version of a tag basis: the tag, or git describe's long form."""
+ return tag if distance == 0 else f"{tag}-{distance}-g{pin[:7]}"
+
+
+def _repo_and_pin(
+ profile: dict, basis: dict, mode: str, cache_dir: str, offline: bool,
+) -> tuple[upstream.Repo, str]:
+ """The repository a basis reads and the commit it is read at."""
+ if basis.get("url"):
+ repo = upstream.parse_repo(str(basis["url"]))
+ pin = str(basis.get("commit") or "")
+ where = str(basis["url"])
+ else:
+ field = str(basis.get("repo") or "source")
+ url = _by_mode(profile, field, mode) or ""
+ repo = upstream.parse_repo(url)
+ pin = _by_mode(profile, PIN_FIELDS.get(field, ""), mode) or ""
+ where = url or field
+ if repo is not None and not pin and field == "upstream" and profile.get("profiled_date"):
+ # The pin of a second repository falls back on profiled_date,
+ # as profile_sync resolves it.
+ pin = upstream.resolve_commit_at(
+ repo, str(profile["profiled_date"]), cache_dir, offline) or ""
+ if repo is None:
+ raise VersionUnreadable(f"{where}: not a forge repository")
+ if not pin:
+ raise VersionUnreadable(f"{repo.slug}: no pinned commit")
+ return repo, pin
+
+
+def _ancestry(
+ repo: upstream.Repo, base: str, head: str, cache_dir: str, offline: bool,
+) -> tuple[int, int] | None:
+ """The forge's compare, else the commit graph: GitHub refuses with 422
+ a compare whose diff is too large (aps3e's tag and pin)."""
+ try:
+ relation = upstream.ancestry(repo, base, head, cache_dir, offline)
+ except upstream.RateLimitError:
+ raise
+ except upstream.UpstreamError:
+ relation = None
+ if relation is None:
+ try:
+ relation = upstream.graph_ancestry(repo, base, head, cache_dir, offline)
+ except upstream.NotCachedError:
+ return None
+ return relation
+
+
+def read(
+ profile: dict, mode: str, basis: dict, cache_dir: str, offline: bool,
+ profiles: dict | None = None, recompute: bool = False,
+) -> Reading:
+ """The version the basis yields at the profile's pin, against core_version.
+
+ ``recompute`` asks git for the nearest tag again, the way a pin advance
+ has to: a recorded tag only proves its own distance, and a tag released
+ between the two pins would go unseen.
+ """
+ stated = declared(profile, mode)
+ kind = str(basis.get("kind") or "")
+
+ def reading(value: str | None, where: str, updates: tuple = ()) -> Reading:
+ status = MATCH if value == stated else DRIFT
+ return Reading(mode, kind, stated, value, where, status, updates)
+
+ def unreadable(reason: str) -> Reading:
+ return Reading(mode, kind, stated, None, reason, UNREADABLE)
+
+ try:
+ if kind == "none":
+ if recompute:
+ # A release tagged since the basis was written gives the
+ # code a version: the advance has to name it.
+ repo, pin = _repo_and_pin(profile, basis, mode, cache_dir, offline)
+ described = upstream.describe(repo, pin, cache_dir, offline)
+ if described is not None:
+ return Reading(mode, kind, stated, describe_form(*described, pin),
+ f"{repo.slug}: tag {described[0]} is now in "
+ f"{pin[:7]}'s history", DRIFT)
+ return reading(None, "the code states no version")
+ if kind == "unverified":
+ return Reading(mode, kind, stated, None, f"not verified: {basis.get('why')}",
+ UNCHECKED)
+ if kind == "package":
+ artifact = str(basis.get("artifact") or "")
+ if basis.get("pattern"):
+ # An artefact named after its version says it in its name:
+ # advancemame-0.94.0.tar.gz.
+ found = read_text_version(artifact, str(basis["pattern"]), basis.get("format"))
+ if found is None:
+ return unreadable(f"{artifact}: pattern not found")
+ return reading(found[0], f"named by {artifact}")
+ return Reading(mode, kind, stated, None, f"read from {artifact}", UNCHECKED)
+ if kind == "alias":
+ other = (profiles or {}).get(str(basis.get("of") or ""))
+ if other is None:
+ return unreadable(f"alias of {basis.get('of')}: no such profile")
+ return reading(declared(other, mode) if isinstance(other.get("core_version"), dict)
+ else declared(other, ""), f"alias of {basis['of']}")
+ repo, pin = _repo_and_pin(profile, basis, mode, cache_dir, offline)
+ if kind == "file":
+ path = str(basis.get("path") or "")
+ lines = upstream.fetch_file(repo, pin, path, cache_dir, offline)
+ if lines is None:
+ return unreadable(f"{repo.slug}@{pin[:7]}:{path}: absent")
+ found = read_text_version("\n".join(lines), str(basis.get("pattern") or ""),
+ basis.get("format"))
+ if found is None:
+ return unreadable(f"{repo.slug}@{pin[:7]}:{path}: pattern not found")
+ value, line = found
+ return reading(value, f"{repo.slug}@{pin[:7]}:{path}:{line}")
+ if kind == "tag":
+ if recompute:
+ described = upstream.describe(repo, pin, cache_dir, offline, basis.get("match"))
+ if described is None:
+ return unreadable(f"{repo.slug}@{pin[:7]}: no tag in its history")
+ tag, distance = described
+ else:
+ tag, distance = str(basis.get("tag") or ""), basis.get("distance")
+ commit = upstream.tag_commit(repo, tag, cache_dir, offline)
+ if commit is None:
+ return unreadable(f"{repo.slug}: no tag {tag}")
+ relation = (0, 0) if commit == pin else _ancestry(
+ repo, commit, pin, cache_dir, offline)
+ if relation is None:
+ return unreadable(f"{repo.slug}: {tag}...{pin[:7]} not comparable")
+ if relation[1]:
+ return unreadable(f"{repo.slug}: tag {tag} is not in {pin[:7]}'s history")
+ if relation[0] != distance:
+ return Reading(mode, kind, stated, describe_form(tag, relation[0], pin),
+ f"{repo.slug}: {pin[:7]} is {relation[0]} commits after "
+ f"{tag}, the basis says {distance}", DRIFT,
+ (("distance", relation[0]),))
+ return reading(describe_form(tag, distance, pin),
+ f"{repo.slug}: tag {tag} + {distance}",
+ (("tag", tag), ("distance", distance)))
+ except (upstream.UpstreamError, VersionUnreadable) as exc:
+ return unreadable(str(exc))
+ return unreadable(f"unknown kind {kind!r}")
+
+
+def check(
+ profile: dict, cache_dir: str, offline: bool, profiles: dict | None = None,
+ recompute: bool = False,
+) -> list[Reading]:
+ """One reading per build mode, or a NO_BASIS reading."""
+ found = bases(profile)
+ if not found:
+ return [Reading("", "", declared(profile, ""), None, "no version_basis", NO_BASIS)]
+ return [read(profile, mode, basis, cache_dir, offline, profiles, recompute)
+ for mode, basis in found.items()]
diff --git a/tests/fixtures/tui/checking-40x12.txt b/tests/fixtures/tui/checking-40x12.txt
index f49a3e15a..599dd3dc1 100644
--- a/tests/fixtures/tui/checking-40x12.txt
+++ b/tests/fixtures/tui/checking-40x12.txt
@@ -9,4 +9,4 @@
- Ctrl-C Quit B/Esc Quit
+ Ctrl-C Quit B Quit
diff --git a/tests/fixtures/tui/checking-40x15.txt b/tests/fixtures/tui/checking-40x15.txt
index 5c131dfe1..5345d7513 100644
--- a/tests/fixtures/tui/checking-40x15.txt
+++ b/tests/fixtures/tui/checking-40x15.txt
@@ -12,4 +12,4 @@
- Ctrl-C Quit B/Esc Quit
+ Ctrl-C Quit B Quit
diff --git a/tests/fixtures/tui/colour-120x40.txt b/tests/fixtures/tui/colour-120x40.txt
new file mode 100644
index 000000000..7a89fbf7f
--- /dev/null
+++ b/tests/fixtures/tui/colour-120x40.txt
@@ -0,0 +1,902 @@
+=== setup
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where do you play?^[[0m
+
+ Found on this machine:
+
+^[[0;7;35m > RetroArch^[[0m
+ KNULLI (Batocera files)
+ Another setup or folder
+
+ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m
+ ES-DE found: its BIOS files go to each emulator, not to ES-DE itself.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Continue^[[0;2m B/Esc Quit ? Help^[[0m
+=== platforms
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m
+
+ Choose the setup that will use the BIOS files.
+
+^[[0;2m Filter: setup or device (/)^[[0m
+^[[0;7;35m > RetroArch^[[0m
+ Batocera
+ Recalbox
+ RetroBat
+ EmuDeck
+ Lakka
+ RetroDECK
+ RomM
+ BizHawk
+ MiSTer FPGA
+ RetroPie
+ Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip
+ AmberELEC, SD card RG351, RG552
+ Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX
+ ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, Odroid Go Super, RG351V, +6
+ ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10
+ CrossMix-OS, SD card TrimUI Smart Pro
+ dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350
+ EmuELEC, SD card Odroid Go
+ JELOS, SD card RG351M, RG351V, RG353M, RG353P, RG353V, RG353VS, RG503, RG552, R33S, R36S, +24
+ KNULLI, SD card RG ARC-S, RG28XX, RG34XX, RG35XX, RG35XX Plus, RG35XX H, RG35XX 2024, +15
+ KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, RK2023, RGB30, RGB10 Max 3
+ Koriki, SD card Miyoo Mini
+ MinUI, SD card RG28XX, RG34XX, RG34XX SP, RG35XX, RG35XX Plus, RG35XX H, RG35XX SP, +16
+ MiyooCFW, SD card BittBoy, Pocket Go, PowKiddy Q90, PowKiddy V90, PowKiddy Q20 Mini, Sup M3, +1
+ muOS, SD card RG35XX H, RG35XX Plus, RG35XX SP, RG35XX 2024, RG34XX, RG40XX H, RG40XX V, +4
+ MyMinUI, SD card RG35XX, Miyoo Mini, Miyoo Mini Flip, Miyoo Mini Plus, Miyoo A30, +16
+ NextUI, SD card TrimUI Brick, TrimUI Smart Pro
+ Onion, SD card Miyoo Mini, Miyoo Mini Plus
+ plumOS, SD card MagicX XU20 V32
+ RetroOZ, SD card Odroid Go Super, Odroid Go Advance, RGB10 Max, RGB10 Max 2
+ ROCKNIX, SD card RG351, RG353, RG503, RG552, RG 55G1, RG ARC, RG35XX 2024, RG35XX Plus, +37
+ spruceOS, SD card Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, +5
+ UnofficialOS, SD card R35S, R36S, RG351M, RG351P, RG351V, RG353M, RG353P, RG353V, RG503, +5
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back / Filter ? Help^[[0m
+=== folder
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m
+
+ RetroArch usually reads its BIOS files from:
+ /home/user/.config/retroarch/system
+
+^[[0;7;35m > Use this folder^[[0m
+ Browse for a folder
+ Type a path
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m
+=== folder-card
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m
+
+ NextUI keeps its BIOS files at the root of its SD card. Choose the card's top folder, the one
+ holding .system.
+ No NextUI card is mounted. Insert it, then choose Look again.
+
+^[[0;7;35m > Browse for a folder^[[0m
+ Type a path
+ Look again
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Browse^[[0;2m B/Esc Back ? Help^[[0m
+=== browse
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m
+
+ /home/user/.config/retroarch
+
+^[[0;7;35m > Use this folder^[[0m
+ ..
+ /media/user/SDCARD (NextUI card)
+ cores/
+ system/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m
+=== path
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m
+
+ The folder for RetroArch:
+^[[0;7;35m > ~/bios_^[[0m
+
+ Ctrl-U clears. Quotes are removed, ~ and variables are expanded.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back Ctrl-U Clear^[[0m
+=== create
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m
+
+ /home/user/bios/new does not exist.
+ The nearest folder that does: /home/user
+
+^[[0;7;35m > Create it and continue^[[0m
+ Choose another folder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Create^[[0;2m B/Esc Quit^[[0m
+=== checking
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m
+
+ RetroArch: ~/.config/retroarch/system
+
+ File list received.
+ Reading the files already in the folder... 0:04
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B/Esc Quit^[[0m
+=== plan
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
+
+^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m
+ BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
+ ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m
+ The ^[[0;33m1 wrong copy^[[0m is replaced, the old one kept in a backup folder.
+ Free space: 50.0 GB
+
+^[[0;7;35m > Install^[[0m
+ Choose systems
+ Copy the BIOS files from your old card or a folder
+ Options
+ Quit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back Y/o Options ? Help^[[0m
+=== plan-in-place
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
+
+^[[0;1m ^[[0;1;32mEverything is in place^[[0m
+ BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
+ ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies
+ Free space: 50.0 GB
+
+^[[0;7;35m > Close^[[0m
+ Options
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Close^[[0;2m B/Esc Back Y/o Options ? Help^[[0m
+=== systems
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m
+
+ All
+ None
+^[[0;2m Filter (/ to type)^[[0m
+^[[0;7;35m > [x] nintendo-gba (gba) 1 file, 16.0 KB^[[0m
+ [x] sony-playstation 3 files, 1.0 MB
+^[[0;2m [ ] nintendo-famicom-disk (ファミコン) in place^[[0m
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Continue^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m
+=== options
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m
+
+ Each row is an option of the command line, named on the right.
+
+^[[0;7;35m > Preferred regions: North America, Japan --region^[[0m
+ Game data: kept --no-game-data
+ Installed cores only: no (1 found) --installed-cores
+ Cores: all --core
+ Parallel downloads: 8 --jobs
+ BIOS folder: ^[[0;1m~/.config/retroarch/system --dest^[[0m
+ Copy from: none --from
+ Preview the file list --dry-run
+ Show the command line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Open^[[0;2m B/Esc Back Left/Right Set ? Help^[[0m
+=== regions
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m
+
+ Tick the regions to keep, best first. Where a system has one BIOS per region, the best one found
+ is installed.
+
+^[[0;7;35m > [1] North America^[[0m
+ [ ] Japan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Tick^[[0;2m B/Esc Done Left/Right Order ? Help^[[0m
+=== cores
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m
+
+ Tick the cores you use. With none ticked, every core's files are kept.
+
+^[[0;7;35m > Filter (/ to type)^[[0m
+ [ ] mesen
+ [x] pcsx_rearmed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Filter^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m
+=== preview
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m
+
+ 4 files to download, 1.0 MB. Nothing is written.
+
+ missing 512.0 KB scph5500.bin
+ wrong copy 512.0 KB scph5501.bin
+ missing 4 B pcsx/extra.bin
+ missing 16.0 KB gba_bios.bin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Back
+=== command
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m
+
+ The same choice without questions:
+
+ python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system --system
+ nintendo-gba
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Back
+=== confirm
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m
+
+ Counted again for your choices. Nothing is written before Install.
+^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
+ BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
+ 0 in place, ^[[0;33m1 missing^[[0m, 0 wrong copies
+ Free space: 50.0 GB
+
+^[[0;7;35m > Install^[[0m
+ Back
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back ? Help^[[0m
+=== installing
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m
+
+ [^[[0;1;35m##############################################^[[0;2m---------------------------------------------^[[0m] 50%
+ 528.0 KB of 1.0 MB
+ 128.9 KB/s, 0:04 left
+ 2 of 4 files
+^[[0;2m B, Ctrl-C or Select+Start stops. What is done stays done.^[[0m
+
+ ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404)
+^[[0;32m ok^[[0m gba_bios.bin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mB/Esc^[[0m Stop^[[0;2m ? Help^[[0m
+=== result
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mFinished with errors^[[0m
+
+^[[0;1m ^[[0;1;32m3 installed^[[0;1m, ^[[0;1;31m1 failed^[[0;1m.^[[0m
+ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m
+ ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404)
+ The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+ To put them back: python3 install.py --restore
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+ Same choice without questions:
+ python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system
+ Details: /tmp/retrobios-x/install.log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Exit
+=== error
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m
+
+ Could not fetch the file list for retroarch after 3 attempts: [Errno -2] Name or service not
+ known
+ Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/retroarch.json
+
+^[[0;7;35m > Choose another setup or folder^[[0m
+ Quit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back^[[0m
+=== quit
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m
+
+ Quit without installing? Nothing was written.
+
+^[[0;7;35m > Quit^[[0m
+ Stay
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Quit^[[0;2m B/Esc Stay^[[0m
+=== help
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m
+
+ Up/Down, k/j Move
+ A/Enter, Start Install
+ Y, o, Tab Options
+ B/Esc, Backspace Back
+ q, Ctrl-C Quit
+ ?, F1 This help
+
+ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m Any key closes this help^[[0m
diff --git a/tests/fixtures/tui/colour-40x12.txt b/tests/fixtures/tui/colour-40x12.txt
new file mode 100644
index 000000000..396127fd0
--- /dev/null
+++ b/tests/fixtures/tui/colour-40x12.txt
@@ -0,0 +1,286 @@
+=== setup
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where do you play?^[[0m
+
+ Found on this machine:
+
+^[[0;7;35m > RetroArch^[[0m
+ KNULLI (Batocera files)
+ Another setup or folder
+
+ BIOS folder:
+ /home/user/.config/retroarch/system
+ ES-DE found: its BIOS files go to each
+^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Quit ? Help^[[0m
+=== platforms
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m
+
+^[[0;2m Filter: setup or device (/)^[[0m
+^[[0;7;35m > RetroArch^[[0m
+ Batocera
+ Recalbox
+ RetroBat
+ EmuDeck
+ Lakka
+ RetroDECK
+^[[0;2m 1-7 of 34^[[0m
+^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back / Filter ? Help^[[0m
+=== folder
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m
+
+ RetroArch usually reads its BIOS files
+ from:
+ /home/user/.config/retroarch/system
+
+^[[0;7;35m > Use this folder^[[0m
+ Browse for a folder
+ Type a path
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m
+=== folder-card
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m
+
+ NextUI keeps its BIOS files at the
+ root of its SD card. Choose the
+ card's top folder, the one holding
+ .system.
+ No NextUI card is mounted. Insert it,
+ then choose Look again.
+^[[0;7;35m > Browse for a folder^[[0m
+ Type a path
+ Look again
+^[[0;2m ^[[0;1;35mA^[[0m Browse^[[0;2m B Back ? Help^[[0m
+=== browse
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m
+
+ /home/user/.config/retroarch
+
+^[[0;7;35m > Use this folder^[[0m
+ ..
+ /media/user/SDCARD (NextUI card)
+ cores/
+ system/
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m
+=== path
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m
+
+ The folder for RetroArch:
+^[[0;7;35m > ~/bios_^[[0m
+
+ Ctrl-U clears. Quotes are removed, ~
+ and variables are expanded.
+
+
+
+
+^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back^[[0m
+=== create
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m
+
+ /home/user/bios/new does not exist.
+ The nearest folder that does:
+ /home/user
+
+^[[0;7;35m > Create it and continue^[[0m
+ Choose another folder
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Create^[[0;2m B Quit^[[0m
+=== checking
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m
+
+ RetroArch: ~/.config/retroarch/system
+
+ File list received.
+ Reading the files already in the
+ folder... 0:04
+
+
+
+
+^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B Quit^[[0m
+=== plan
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
+
+^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m
+ BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m
+ ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m
+ ^[[0;33m1 wrong copy^[[0m: backed up, replaced.
+^[[0;7;35m > Install^[[0m
+ Choose systems
+ Copy from your old card or a folder
+ Options
+ Quit
+^[[0;2m ^[[0;1;35mA^[[0m Install^[[0;2m B Back Y Options ? Help^[[0m
+=== plan-in-place
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
+
+^[[0;1m ^[[0;1;32mEverything is in place^[[0m
+ BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m
+ ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies
+ Free space: 50.0 GB
+
+^[[0;7;35m > Close^[[0m
+ Options
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Close^[[0;2m B Back Y Options ? Help^[[0m
+=== systems
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m
+
+ All
+ None
+^[[0;2m Filter (/ to type)^[[0m
+^[[0;7;35m > [x] nintendo-gba (gba) 16.0 KB^[[0m
+ [x] sony-playstation 1.0 MB
+^[[0;2m [ ] nintendo-famicom-disk in place^[[0m
+
+
+^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m
+^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Back X Tick ? Help^[[0m
+=== options
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m
+
+^[[0;7;35m > Preferred regions: North America, Ja^[[0m
+ Game data: kept
+ Installed cores only: no (1 found)
+ Cores: all
+ Parallel downloads: 8
+ BIOS folder: ^[[0;1m~/.config...ch/system^[[0m
+ Copy from: none
+ Preview the file list
+ Show the command line
+^[[0;2m ^[[0;1;35mA^[[0m Open^[[0;2m B Back <> Set ? Help^[[0m
+=== regions
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m
+
+ Tick the regions to keep, best first.
+ Where a system has one BIOS per
+ region, the best one found is
+ installed.
+
+^[[0;7;35m > [1] North America^[[0m
+ [ ] Japan
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Tick^[[0;2m B Done <> Order ? Help^[[0m
+=== cores
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m
+
+ Tick the cores you use. With none
+ ticked, every core's files are kept.
+
+^[[0;7;35m > Filter (/ to type)^[[0m
+ [ ] mesen
+ [x] pcsx_rearmed
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Filter^[[0;2m B Back X Tick ? Help^[[0m
+=== preview
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m
+
+ 4 files to download, 1.0 MB. Nothing
+ is written.
+
+ missing 512.0 KB scph5500.bin
+ wrong copy 512.0 KB scph5501.bin
+ missing 4 B pcsx/extra.bin
+ missing 16.0 KB gba_bios.bin
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Back
+=== command
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m
+
+ The same choice without questions:
+
+ python3 install.py --platform
+ retroarch --dest
+ /home/user/.config/retroarch/system
+ --system nintendo-gba
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Back
+=== confirm
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m
+
+ Counted again for your choices.
+ Nothing is written before Install.
+^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
+ BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m
+ 0 in place, ^[[0;33m1 missing^[[0m, 0 wrong copies
+ Free space: 50.0 GB
+
+^[[0;7;35m > Install^[[0m
+ Back
+^[[0;2m ^[[0;1;35mA^[[0m Install^[[0;2m B Back ? Help^[[0m
+=== installing
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m
+
+ [^[[0;1;35m###############^[[0;2m----------------^[[0m] 50%
+ 528.0 KB of 1.0 MB
+ 128.9 KB/s, 0:04 left
+ 2 of 4 files
+^[[0;2m B, Ctrl-C or Select+Start stops. What^[[0m
+^[[0;2m is done stays done.^[[0m
+
+ ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404)
+^[[0;32m ok^[[0m gba_bios.bin
+^[[0;2m ^[[0;1;35mB^[[0m Stop^[[0;2m ? Help^[[0m
+=== result
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mFinished with errors^[[0m
+
+^[[0;1m ^[[0;1;32m3 installed^[[0;1m, ^[[0;1;31m1 failed^[[0;1m.^[[0m
+ BIOS folder:
+ /home/user/.config/retroarch/system
+ ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404)
+ The files it replaced are kept in
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
+ To put them back: python3 install.py
+^[[0;2m 1-8 of 17^[[0m
+^[[0;2m ^[[0;1;35mA^[[0m Exit^[[0;2m ^v Scroll^[[0m
+=== error
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m
+
+ Could not fetch the file list for
+ retroarch after 3 attempts: [Errno
+ -2] Name or service not known
+ Address:
+ https://raw.githubusercontent.com/
+^[[0;2m 1-5 of 7^[[0m
+
+^[[0;7;35m > Choose another setup or folder^[[0m
+ Quit
+^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back PgUp/PgDn Scroll^[[0m
+=== quit
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m
+
+ Quit without installing? Nothing was
+ written.
+
+^[[0;7;35m > Quit^[[0m
+ Stay
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Quit^[[0;2m B Stay^[[0m
+=== help
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m
+
+ Up/Down, k/j Move
+ A/Enter, Start Install
+ Y, o, Tab Options
+ B/Esc, Backspace Back
+ q, Ctrl-C Quit
+ ?, F1 This help
+
+ BIOS folder:
+ /home/user/.config/retroarch/system
+^[[0;2m Any key closes this help^[[0m
diff --git a/tests/fixtures/tui/colour-40x15.txt b/tests/fixtures/tui/colour-40x15.txt
new file mode 100644
index 000000000..05721261e
--- /dev/null
+++ b/tests/fixtures/tui/colour-40x15.txt
@@ -0,0 +1,352 @@
+=== setup
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where do you play?^[[0m
+
+ Found on this machine:
+
+^[[0;7;35m > RetroArch^[[0m
+ KNULLI (Batocera files)
+ Another setup or folder
+
+ BIOS folder:
+ /home/user/.config/retroarch/system
+ ES-DE found: its BIOS files go to each
+ emulator, not to ES-DE itself.
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Quit ? Help^[[0m
+=== platforms
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m
+
+^[[0;2m Filter: setup or device (/)^[[0m
+^[[0;7;35m > RetroArch^[[0m
+ Batocera
+ Recalbox
+ RetroBat
+ EmuDeck
+ Lakka
+ RetroDECK
+ RomM
+ BizHawk
+ MiSTer FPGA
+^[[0;2m 1-10 of 34^[[0m
+^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back / Filter ? Help^[[0m
+=== folder
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m
+
+ RetroArch usually reads its BIOS files
+ from:
+ /home/user/.config/retroarch/system
+
+^[[0;7;35m > Use this folder^[[0m
+ Browse for a folder
+ Type a path
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m
+=== folder-card
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m
+
+ NextUI keeps its BIOS files at the
+ root of its SD card. Choose the
+ card's top folder, the one holding
+ .system.
+ No NextUI card is mounted. Insert it,
+ then choose Look again.
+
+^[[0;7;35m > Browse for a folder^[[0m
+ Type a path
+ Look again
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Browse^[[0;2m B Back ? Help^[[0m
+=== browse
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m
+
+ /home/user/.config/retroarch
+
+^[[0;7;35m > Use this folder^[[0m
+ ..
+ /media/user/SDCARD (NextUI card)
+ cores/
+ system/
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Use this folder^[[0;2m B Back ? Help^[[0m
+=== path
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m
+
+ The folder for RetroArch:
+^[[0;7;35m > ~/bios_^[[0m
+
+ Ctrl-U clears. Quotes are removed, ~
+ and variables are expanded.
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back^[[0m
+=== create
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m
+
+ /home/user/bios/new does not exist.
+ The nearest folder that does:
+ /home/user
+
+^[[0;7;35m > Create it and continue^[[0m
+ Choose another folder
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Create^[[0;2m B Quit^[[0m
+=== checking
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m
+
+ RetroArch: ~/.config/retroarch/system
+
+ File list received.
+ Reading the files already in the
+ folder... 0:04
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B Quit^[[0m
+=== plan
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
+
+^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m
+ BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m
+ ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m
+ ^[[0;33m1 wrong copy^[[0m: backed up, replaced.
+ Free space: 50.0 GB
+
+^[[0;7;35m > Install^[[0m
+ Choose systems
+ Copy from your old card or a folder
+ Options
+ Quit
+
+^[[0;2m ^[[0;1;35mA^[[0m Install^[[0;2m B Back Y Options ? Help^[[0m
+=== plan-in-place
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
+
+^[[0;1m ^[[0;1;32mEverything is in place^[[0m
+ BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m
+ ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies
+ Free space: 50.0 GB
+
+^[[0;7;35m > Close^[[0m
+ Options
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Close^[[0;2m B Back Y Options ? Help^[[0m
+=== systems
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m
+
+ All
+ None
+^[[0;2m Filter (/ to type)^[[0m
+^[[0;7;35m > [x] nintendo-gba (gba) 16.0 KB^[[0m
+ [x] sony-playstation 1.0 MB
+^[[0;2m [ ] nintendo-famicom-disk in place^[[0m
+
+
+
+
+
+^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m
+^[[0;2m ^[[0;1;35mA^[[0m Continue^[[0;2m B Back X Tick ? Help^[[0m
+=== options
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m
+
+^[[0;7;35m > Preferred regions: North America, Ja^[[0m
+ Game data: kept
+ Installed cores only: no (1 found)
+ Cores: all
+ Parallel downloads: 8
+ BIOS folder: ^[[0;1m~/.config...ch/system^[[0m
+ Copy from: none
+ Preview the file list
+ Show the command line
+
+ Sets --region
+
+^[[0;2m ^[[0;1;35mA^[[0m Open^[[0;2m B Back <> Set ? Help^[[0m
+=== regions
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m
+
+ Tick the regions to keep, best first.
+ Where a system has one BIOS per
+ region, the best one found is
+ installed.
+
+^[[0;7;35m > [1] North America^[[0m
+ [ ] Japan
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Tick^[[0;2m B Done <> Order ? Help^[[0m
+=== cores
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m
+
+ Tick the cores you use. With none
+ ticked, every core's files are kept.
+
+^[[0;7;35m > Filter (/ to type)^[[0m
+ [ ] mesen
+ [x] pcsx_rearmed
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Filter^[[0;2m B Back X Tick ? Help^[[0m
+=== preview
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m
+
+ 4 files to download, 1.0 MB. Nothing
+ is written.
+
+ missing 512.0 KB scph5500.bin
+ wrong copy 512.0 KB scph5501.bin
+ missing 4 B pcsx/extra.bin
+ missing 16.0 KB gba_bios.bin
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Back
+=== command
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m
+
+ The same choice without questions:
+
+ python3 install.py --platform
+ retroarch --dest
+ /home/user/.config/retroarch/system
+ --system nintendo-gba
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Back
+=== confirm
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m
+
+ Counted again for your choices.
+ Nothing is written before Install.
+^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
+ BIOS folder: ^[[0;1m~/.co.../retroarch/system^[[0m
+ 0 in place, ^[[0;33m1 missing^[[0m, 0 wrong copies
+ Free space: 50.0 GB
+
+^[[0;7;35m > Install^[[0m
+ Back
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Install^[[0;2m B Back ? Help^[[0m
+=== installing
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m
+
+ [^[[0;1;35m###############^[[0;2m----------------^[[0m] 50%
+ 528.0 KB of 1.0 MB
+ 128.9 KB/s, 0:04 left
+ 2 of 4 files
+^[[0;2m B, Ctrl-C or Select+Start stops. What^[[0m
+^[[0;2m is done stays done.^[[0m
+
+ ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404)
+^[[0;32m ok^[[0m gba_bios.bin
+
+
+
+^[[0;2m ^[[0;1;35mB^[[0m Stop^[[0;2m ? Help^[[0m
+=== result
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mFinished with errors^[[0m
+
+^[[0;1m ^[[0;1;32m3 installed^[[0;1m, ^[[0;1;31m1 failed^[[0;1m.^[[0m
+ BIOS folder:
+ /home/user/.config/retroarch/system
+ ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404)
+ The files it replaced are kept in
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
+ To put them back: python3 install.py
+ --restore
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
+^[[0;2m 1-11 of 17^[[0m
+^[[0;2m ^[[0;1;35mA^[[0m Exit^[[0;2m ^v Scroll^[[0m
+=== error
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m
+
+ Could not fetch the file list for
+ retroarch after 3 attempts: [Errno
+ -2] Name or service not known
+ Address:
+ https://raw.githubusercontent.com/
+ Abdess/retrobios/main/install/
+ retroarch.json
+
+^[[0;7;35m > Choose another setup or folder^[[0m
+ Quit
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Choose^[[0;2m B Back^[[0m
+=== quit
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m
+
+ Quit without installing? Nothing was
+ written.
+
+^[[0;7;35m > Quit^[[0m
+ Stay
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA^[[0m Quit^[[0;2m B Stay^[[0m
+=== help
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m
+
+ Up/Down, k/j Move
+ A/Enter, Start Install
+ Y, o, Tab Options
+ B/Esc, Backspace Back
+ q, Ctrl-C Quit
+ ?, F1 This help
+
+ BIOS folder:
+ /home/user/.config/retroarch/system
+
+
+
+^[[0;2m Any key closes this help^[[0m
diff --git a/tests/fixtures/tui/colour-80x24.txt b/tests/fixtures/tui/colour-80x24.txt
new file mode 100644
index 000000000..fd8f432c9
--- /dev/null
+++ b/tests/fixtures/tui/colour-80x24.txt
@@ -0,0 +1,550 @@
+=== setup
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where do you play?^[[0m
+
+ Found on this machine:
+
+^[[0;7;35m > RetroArch^[[0m
+ KNULLI (Batocera files)
+ Another setup or folder
+
+ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m
+ ES-DE found: its BIOS files go to each emulator, not to ES-DE itself.
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Continue^[[0;2m B/Esc Quit ? Help^[[0m
+=== platforms
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which setup?^[[0m
+
+^[[0;2m Filter: setup or device (/)^[[0m
+^[[0;7;35m > RetroArch^[[0m
+ Batocera
+ Recalbox
+ RetroBat
+ EmuDeck
+ Lakka
+ RetroDECK
+ RomM
+ BizHawk
+ MiSTer FPGA
+ RetroPie
+ Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip
+ AmberELEC, SD card RG351, RG552
+ Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX
+ ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, +8
+ ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, +2
+ CrossMix-OS, SD card TrimUI Smart Pro
+ dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350
+ EmuELEC, SD card Odroid Go
+^[[0;2m 1-19 of 34^[[0m
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back / Filter ? Help^[[0m
+=== folder
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Which folder?^[[0m
+
+ RetroArch usually reads its BIOS files from:
+ /home/user/.config/retroarch/system
+
+^[[0;7;35m > Use this folder^[[0m
+ Browse for a folder
+ Type a path
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m
+=== folder-card
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Where is the SD card?^[[0m
+
+ NextUI keeps its BIOS files at the root of its SD card. Choose the card's top
+ folder, the one holding .system.
+ No NextUI card is mounted. Insert it, then choose Look again.
+
+^[[0;7;35m > Browse for a folder^[[0m
+ Type a path
+ Look again
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Browse^[[0;2m B/Esc Back ? Help^[[0m
+=== browse
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Browse for a folder^[[0m
+
+ /home/user/.config/retroarch
+
+^[[0;7;35m > Use this folder^[[0m
+ ..
+ /media/user/SDCARD (NextUI card)
+ cores/
+ system/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Use this folder^[[0;2m B/Esc Back ? Help^[[0m
+=== path
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Type a path^[[0m
+
+ The folder for RetroArch:
+^[[0;7;35m > ~/bios_^[[0m
+
+ Ctrl-U clears. Quotes are removed, ~ and variables are expanded.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mEnter^[[0m Use this folder^[[0;2m Esc Back Ctrl-U Clear^[[0m
+=== create
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Create the folder?^[[0m
+
+ /home/user/bios/new does not exist.
+ The nearest folder that does: /home/user
+
+^[[0;7;35m > Create it and continue^[[0m
+ Choose another folder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Create^[[0;2m B/Esc Quit^[[0m
+=== checking
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Checking^[[0m
+
+ RetroArch: ~/.config/retroarch/system
+
+ File list received.
+ Reading the files already in the folder... 0:04
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mCtrl-C^[[0m Quit^[[0;2m B/Esc Quit^[[0m
+=== plan
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
+
+^[[0;1m ^[[0;1;35m4^[[0;1m files to download, ^[[0;1;35m1.0 MB^[[0m
+ BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
+ ^[[0;32m1 in place^[[0m, ^[[0;33m3 missing^[[0m, ^[[0;33m1 wrong copy^[[0m
+ The ^[[0;33m1 wrong copy^[[0m is replaced, the old one kept in a backup folder.
+ Free space: 50.0 GB
+
+^[[0;7;35m > Install^[[0m
+ Choose systems
+ Copy the BIOS files from your old card or a folder
+ Options
+ Quit
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back Y/o Options ? Help^[[0m
+=== plan-in-place
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: RetroArch^[[0m
+
+^[[0;1m ^[[0;1;32mEverything is in place^[[0m
+ BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
+ ^[[0;32m5 in place^[[0m, 0 missing, 0 wrong copies
+ Free space: 50.0 GB
+
+^[[0;7;35m > Close^[[0m
+ Options
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Close^[[0;2m B/Esc Back Y/o Options ? Help^[[0m
+=== systems
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Choose systems^[[0m
+
+ All
+ None
+^[[0;2m Filter (/ to type)^[[0m
+^[[0;7;35m > [x] nintendo-gba (gba) 1 file, 16.0 KB^[[0m
+ [x] sony-playstation 3 files, 1.0 MB
+^[[0;2m [ ] nintendo-famicom-disk (ファミコン) in place^[[0m
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;1m ^[[0;1;35m2^[[0;1m of ^[[0;1;35m3^[[0;1m systems: ^[[0;1;35m4^[[0;1m files, ^[[0;1;35m1.0 MB^[[0m
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Continue^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m
+=== options
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Options^[[0m
+
+ Each row is an option of the command line, named on the right.
+
+^[[0;7;35m > Preferred regions: North America, Japan --region^[[0m
+ Game data: kept --no-game-data
+ Installed cores only: no (1 found) --installed-cores
+ Cores: all --core
+ Parallel downloads: 8 --jobs
+ BIOS folder: ^[[0;1m~/.config/retroarch/system --dest^[[0m
+ Copy from: none --from
+ Preview the file list --dry-run
+ Show the command line
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Open^[[0;2m B/Esc Back Left/Right Set ? Help^[[0m
+=== regions
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preferred regions^[[0m
+
+ Tick the regions to keep, best first. Where a system has one BIOS per region,
+ the best one found is installed.
+
+^[[0;7;35m > [1] North America^[[0m
+ [ ] Japan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Tick^[[0;2m B/Esc Done Left/Right Order ? Help^[[0m
+=== cores
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Cores^[[0m
+
+ Tick the cores you use. With none ticked, every core's files are kept.
+
+^[[0;7;35m > Filter (/ to type)^[[0m
+ [ ] mesen
+ [x] pcsx_rearmed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Filter^[[0;2m B/Esc Back X/Space Tick ? Help^[[0m
+=== preview
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Preview^[[0m
+
+ 4 files to download, 1.0 MB. Nothing is written.
+
+ missing 512.0 KB scph5500.bin
+ wrong copy 512.0 KB scph5501.bin
+ missing 4 B pcsx/extra.bin
+ missing 16.0 KB gba_bios.bin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Back
+=== command
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Command line^[[0m
+
+ The same choice without questions:
+
+ python3 install.py --platform retroarch --dest
+ /home/user/.config/retroarch/system --system nintendo-gba
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Back
+=== confirm
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Confirm^[[0m
+
+ Counted again for your choices. Nothing is written before Install.
+^[[0;1m ^[[0;1;35m1^[[0;1m file to download, ^[[0;1;35m16.0 KB^[[0m
+ BIOS folder: ^[[0;1m~/.config/retroarch/system^[[0m
+ 0 in place, ^[[0;33m1 missing^[[0m, 0 wrong copies
+ Free space: 50.0 GB
+
+^[[0;7;35m > Install^[[0m
+ Back
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Install^[[0;2m B/Esc Back ? Help^[[0m
+=== installing
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Installing^[[0m
+
+ [^[[0;1;35m####################################^[[0;2m-----------------------------------^[[0m] 50%
+ 528.0 KB of 1.0 MB
+ 128.9 KB/s, 0:04 left
+ 2 of 4 files
+^[[0;2m B, Ctrl-C or Select+Start stops. What is done stays done.^[[0m
+
+ ^[[0;1;31mFAILED^[[0m scph5500.bin (HTTP 404)
+^[[0;32m ok^[[0m gba_bios.bin
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mB/Esc^[[0m Stop^[[0;2m ? Help^[[0m
+=== result
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mFinished with errors^[[0m
+
+^[[0;1m ^[[0;1;32m3 installed^[[0;1m, ^[[0;1;31m1 failed^[[0;1m.^[[0m
+ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m
+ ^[[0;1;31mfailed:^[[0m scph5500.bin (HTTP 404)
+ The files it replaced are kept in
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+ To put them back: python3 install.py --restore
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+ Same choice without questions:
+ python3 install.py --platform retroarch --dest
+ /home/user/.config/retroarch/system
+ Details: /tmp/retrobios-x/install.log
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Exit
+=== error
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: ^[[0;1;31mSomething went wrong^[[0m
+
+ Could not fetch the file list for retroarch after 3 attempts: [Errno -2]
+ Name or service not known
+ Address: https://raw.githubusercontent.com/Abdess/retrobios/main/install/
+ retroarch.json
+
+^[[0;7;35m > Choose another setup or folder^[[0m
+ Quit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Choose^[[0;2m B/Esc Back^[[0m
+=== quit
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Quit?^[[0m
+
+ Quit without installing? Nothing was written.
+
+^[[0;7;35m > Quit^[[0m
+ Stay
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m ^[[0;1;35mA/Enter^[[0m Quit^[[0;2m B/Esc Stay^[[0m
+=== help
+^[[0;1m ^[[0;1;35mRetroBIOS^[[0;1m: Keys^[[0m
+
+ Up/Down, k/j Move
+ A/Enter, Start Install
+ Y, o, Tab Options
+ B/Esc, Backspace Back
+ q, Ctrl-C Quit
+ ?, F1 This help
+
+ BIOS folder: ^[[0;1m/home/user/.config/retroarch/system^[[0m
+
+
+
+
+
+
+
+
+
+
+
+
+
+^[[0;2m Any key closes this help^[[0m
diff --git a/tests/fixtures/tui/command-40x12.txt b/tests/fixtures/tui/command-40x12.txt
index f05700025..1b955d059 100644
--- a/tests/fixtures/tui/command-40x12.txt
+++ b/tests/fixtures/tui/command-40x12.txt
@@ -9,4 +9,4 @@
- A/Enter Back
+ A Back
diff --git a/tests/fixtures/tui/command-40x15.txt b/tests/fixtures/tui/command-40x15.txt
index 6fcca70a1..3e8530cf0 100644
--- a/tests/fixtures/tui/command-40x15.txt
+++ b/tests/fixtures/tui/command-40x15.txt
@@ -12,4 +12,4 @@
- A/Enter Back
+ A Back
diff --git a/tests/fixtures/tui/confirm-40x12.txt b/tests/fixtures/tui/confirm-40x12.txt
index a6829f6b0..704d86a7a 100644
--- a/tests/fixtures/tui/confirm-40x12.txt
+++ b/tests/fixtures/tui/confirm-40x12.txt
@@ -9,4 +9,4 @@
> Install
Back
- A/Enter Install B/Esc Back ? Help
+ A Install B Back ? Help
diff --git a/tests/fixtures/tui/confirm-40x15.txt b/tests/fixtures/tui/confirm-40x15.txt
index 250844ab7..6cb81ee9f 100644
--- a/tests/fixtures/tui/confirm-40x15.txt
+++ b/tests/fixtures/tui/confirm-40x15.txt
@@ -12,4 +12,4 @@
- A/Enter Install B/Esc Back ? Help
+ A Install B Back ? Help
diff --git a/tests/fixtures/tui/create-40x12.txt b/tests/fixtures/tui/create-40x12.txt
index c5ef8083c..e767d4777 100644
--- a/tests/fixtures/tui/create-40x12.txt
+++ b/tests/fixtures/tui/create-40x12.txt
@@ -9,4 +9,4 @@
- A/Enter Create B/Esc Quit
+ A Create B Quit
diff --git a/tests/fixtures/tui/create-40x15.txt b/tests/fixtures/tui/create-40x15.txt
index 600629ca8..b62b0cd1a 100644
--- a/tests/fixtures/tui/create-40x15.txt
+++ b/tests/fixtures/tui/create-40x15.txt
@@ -12,4 +12,4 @@
- A/Enter Create B/Esc Quit
+ A Create B Quit
diff --git a/tests/fixtures/tui/error-40x12.txt b/tests/fixtures/tui/error-40x12.txt
index 224cf89d2..d3c843856 100644
--- a/tests/fixtures/tui/error-40x12.txt
+++ b/tests/fixtures/tui/error-40x12.txt
@@ -5,8 +5,8 @@
-2] Name or service not known
Address:
https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
+ 1-5 of 7
+
> Choose another setup or folder
Quit
- A/Enter Choose B/Esc Back
+ A Choose B Back PgUp/PgDn Scroll
diff --git a/tests/fixtures/tui/error-40x15.txt b/tests/fixtures/tui/error-40x15.txt
index df4e6f1c3..a7d362f90 100644
--- a/tests/fixtures/tui/error-40x15.txt
+++ b/tests/fixtures/tui/error-40x15.txt
@@ -12,4 +12,4 @@
Quit
- A/Enter Choose B/Esc Back
+ A Choose B Back
diff --git a/tests/fixtures/tui/folder-card-40x12.txt b/tests/fixtures/tui/folder-card-40x12.txt
index 8a348dfe2..ef783de21 100644
--- a/tests/fixtures/tui/folder-card-40x12.txt
+++ b/tests/fixtures/tui/folder-card-40x12.txt
@@ -9,4 +9,4 @@
> Browse for a folder
Type a path
Look again
- A/Enter Browse B/Esc Back ? Help
+ A Browse B Back ? Help
diff --git a/tests/fixtures/tui/folder-card-40x15.txt b/tests/fixtures/tui/folder-card-40x15.txt
index c791e4241..03aba349e 100644
--- a/tests/fixtures/tui/folder-card-40x15.txt
+++ b/tests/fixtures/tui/folder-card-40x15.txt
@@ -12,4 +12,4 @@
Look again
- A/Enter Browse B/Esc Back ? Help
+ A Browse B Back ? Help
diff --git a/tests/fixtures/tui/installing-40x12.txt b/tests/fixtures/tui/installing-40x12.txt
index 5750b4633..d56a30bbd 100644
--- a/tests/fixtures/tui/installing-40x12.txt
+++ b/tests/fixtures/tui/installing-40x12.txt
@@ -9,4 +9,4 @@
FAILED scph5500.bin (HTTP 404)
ok gba_bios.bin
- B/Esc Stop ? Help
+ B Stop ? Help
diff --git a/tests/fixtures/tui/installing-40x15.txt b/tests/fixtures/tui/installing-40x15.txt
index 51aeca7c0..950ad6515 100644
--- a/tests/fixtures/tui/installing-40x15.txt
+++ b/tests/fixtures/tui/installing-40x15.txt
@@ -12,4 +12,4 @@
- B/Esc Stop ? Help
+ B Stop ? Help
diff --git a/tests/fixtures/tui/options-120x40.txt b/tests/fixtures/tui/options-120x40.txt
index 21aae2729..eac6c418f 100644
--- a/tests/fixtures/tui/options-120x40.txt
+++ b/tests/fixtures/tui/options-120x40.txt
@@ -2,14 +2,14 @@
Each row is an option of the command line, named on the right.
- > Preferred regions: North America, Japan --region
- Game data: kept --no-game-data
- Installed cores only: no (1 found) --installed-cores
- Cores: all --core
- Parallel downloads: 8 --jobs
- BIOS folder: ~/.config/retroarch/system --dest
- Copy from: none --from
- Preview the file list --dry-run
+ > Preferred regions: North America, Japan --region
+ Game data: kept --no-game-data
+ Installed cores only: no (1 found) --installed-cores
+ Cores: all --core
+ Parallel downloads: 8 --jobs
+ BIOS folder: ~/.config/retroarch/system --dest
+ Copy from: none --from
+ Preview the file list --dry-run
Show the command line
diff --git a/tests/fixtures/tui/platforms-120x40.txt b/tests/fixtures/tui/platforms-120x40.txt
index c4f74e812..0389c94a8 100644
--- a/tests/fixtures/tui/platforms-120x40.txt
+++ b/tests/fixtures/tui/platforms-120x40.txt
@@ -14,26 +14,26 @@
BizHawk
MiSTer FPGA
RetroPie
- Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip
- AmberELEC, SD card RG351, RG552
- Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX
+ Allium, SD card Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip
+ AmberELEC, SD card RG351, RG552
+ Anbernic stock firmware, SD card RG35XX Plus, RG35XX H, RG35XX SP, RG28XX
ArchR, SD card R36S, R33S, R36S Soysauce, Odroid Go Advance, Odroid Go Super, RG351V, +6
- ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10
- CrossMix-OS, SD card TrimUI Smart Pro
- dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350
- EmuELEC, SD card Odroid Go
+ ArkOS, SD card RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10
+ CrossMix-OS, SD card TrimUI Smart Pro
+ dArkOS, SD card Odroid Go Advance, RGB10, RG351MP, G350
+ EmuELEC, SD card Odroid Go
JELOS, SD card RG351M, RG351V, RG353M, RG353P, RG353V, RG353VS, RG503, RG552, R33S, R36S, +24
KNULLI, SD card RG ARC-S, RG28XX, RG34XX, RG35XX, RG35XX Plus, RG35XX H, RG35XX 2024, +15
- KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, RK2023, RGB30, RGB10 Max 3
- Koriki, SD card Miyoo Mini
+ KOLOS, SD card RG353P, RG353M, RG353V, RG353VS, RG503, RK2023, RGB30, RGB10 Max 3
+ Koriki, SD card Miyoo Mini
MinUI, SD card RG28XX, RG34XX, RG34XX SP, RG35XX, RG35XX Plus, RG35XX H, RG35XX SP, +16
MiyooCFW, SD card BittBoy, Pocket Go, PowKiddy Q90, PowKiddy V90, PowKiddy Q20 Mini, Sup M3, +1
muOS, SD card RG35XX H, RG35XX Plus, RG35XX SP, RG35XX 2024, RG34XX, RG40XX H, RG40XX V, +4
MyMinUI, SD card RG35XX, Miyoo Mini, Miyoo Mini Flip, Miyoo Mini Plus, Miyoo A30, +16
- NextUI, SD card TrimUI Brick, TrimUI Smart Pro
- Onion, SD card Miyoo Mini, Miyoo Mini Plus
- plumOS, SD card MagicX XU20 V32
- RetroOZ, SD card Odroid Go Super, Odroid Go Advance, RGB10 Max, RGB10 Max 2
+ NextUI, SD card TrimUI Brick, TrimUI Smart Pro
+ Onion, SD card Miyoo Mini, Miyoo Mini Plus
+ plumOS, SD card MagicX XU20 V32
+ RetroOZ, SD card Odroid Go Super, Odroid Go Advance, RGB10 Max, RGB10 Max 2
ROCKNIX, SD card RG351, RG353, RG503, RG552, RG 55G1, RG ARC, RG35XX 2024, RG35XX Plus, +37
spruceOS, SD card Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, +5
UnofficialOS, SD card R35S, R36S, RG351M, RG351P, RG351V, RG353M, RG353P, RG353V, RG503, +5
diff --git a/tests/fixtures/tui/preview-40x12.txt b/tests/fixtures/tui/preview-40x12.txt
index 507cf8b5d..55499e667 100644
--- a/tests/fixtures/tui/preview-40x12.txt
+++ b/tests/fixtures/tui/preview-40x12.txt
@@ -9,4 +9,4 @@
missing 16.0 KB gba_bios.bin
- A/Enter Back
+ A Back
diff --git a/tests/fixtures/tui/preview-40x15.txt b/tests/fixtures/tui/preview-40x15.txt
index 110e70660..873f126d8 100644
--- a/tests/fixtures/tui/preview-40x15.txt
+++ b/tests/fixtures/tui/preview-40x15.txt
@@ -12,4 +12,4 @@
- A/Enter Back
+ A Back
diff --git a/tests/fixtures/tui/quit-40x12.txt b/tests/fixtures/tui/quit-40x12.txt
index 8d91077c0..fce65bb05 100644
--- a/tests/fixtures/tui/quit-40x12.txt
+++ b/tests/fixtures/tui/quit-40x12.txt
@@ -9,4 +9,4 @@
- A/Enter Quit B/Esc Stay
+ A Quit B Stay
diff --git a/tests/fixtures/tui/quit-40x15.txt b/tests/fixtures/tui/quit-40x15.txt
index 675109b69..1f2fd0f1c 100644
--- a/tests/fixtures/tui/quit-40x15.txt
+++ b/tests/fixtures/tui/quit-40x15.txt
@@ -12,4 +12,4 @@
- A/Enter Quit B/Esc Stay
+ A Quit B Stay
diff --git a/tests/fixtures/tui/result-40x12.txt b/tests/fixtures/tui/result-40x12.txt
index b7b5d8c6b..568b9dce0 100644
--- a/tests/fixtures/tui/result-40x12.txt
+++ b/tests/fixtures/tui/result-40x12.txt
@@ -5,8 +5,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
To put them back: python3 install.py
1-8 of 17
- A/Enter Exit Up/Down Scroll
+ A Exit ^v Scroll
diff --git a/tests/fixtures/tui/result-40x15.txt b/tests/fixtures/tui/result-40x15.txt
index 60138766a..45e55c93a 100644
--- a/tests/fixtures/tui/result-40x15.txt
+++ b/tests/fixtures/tui/result-40x15.txt
@@ -5,11 +5,11 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
To put them back: python3 install.py
--restore
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
1-11 of 17
- A/Enter Exit Up/Down Scroll
+ A Exit ^v Scroll
diff --git a/tests/fixtures/tui/route-evmapy.txt b/tests/fixtures/tui/route-evmapy.txt
index 21813258f..89b845d3a 100644
--- a/tests/fixtures/tui/route-evmapy.txt
+++ b/tests/fixtures/tui/route-evmapy.txt
@@ -322,35 +322,6 @@
A Use this folder B Back
-=== create 40x12
- RetroBIOS: Create the folder?
-
- /home/user/bios/new does not exist.
- The nearest folder that does:
- /home/user
-
- > Create it and continue
- Choose another folder
-
-
-
- A Create B Quit
-=== create 40x15
- RetroBIOS: Create the folder?
-
- /home/user/bios/new does not exist.
- The nearest folder that does:
- /home/user
-
- > Create it and continue
- Choose another folder
-
-
-
-
-
-
- A Create B Quit
=== create 80x24
RetroBIOS: Create the folder?
@@ -604,7 +575,7 @@
Copy from: none
Preview the file list
Show the command line
- A Open B Back Left/Right Set
+ A Open B Back <> Set
=== options 40x15
RetroBIOS: Options
@@ -620,7 +591,7 @@
Sets --region
- A Open B Back Left/Right Set
+ A Open B Back <> Set
=== options 80x24
RetroBIOS: Options
@@ -658,7 +629,7 @@
[ ] Japan
- A Tick B Done Left/Right Order
+ A Tick B Done <> Order
=== regions 40x15
RetroBIOS: Preferred regions
@@ -674,7 +645,7 @@
- A Tick B Done Left/Right Order
+ A Tick B Done <> Order
=== regions 80x24
RetroBIOS: Preferred regions
@@ -754,35 +725,6 @@
A Filter B Back X Tick
-=== preview 40x12
- RetroBIOS: Preview
-
- 4 files to download, 1.0 MB. Nothing
- is written.
-
- missing 512.0 KB scph5500.bin
- wrong copy 512.0 KB scph5501.bin
- missing 4 B pcsx/extra.bin
- missing 16.0 KB gba_bios.bin
-
-
- A Back
-=== preview 40x15
- RetroBIOS: Preview
-
- 4 files to download, 1.0 MB. Nothing
- is written.
-
- missing 512.0 KB scph5500.bin
- wrong copy 512.0 KB scph5501.bin
- missing 4 B pcsx/extra.bin
- missing 16.0 KB gba_bios.bin
-
-
-
-
-
- A Back
=== preview 80x24
RetroBIOS: Preview
@@ -807,35 +749,6 @@
- A Back
-=== command 40x12
- RetroBIOS: Command line
-
- The same choice without questions:
-
- python3 install.py --platform
- retroarch --dest
- /home/user/.config/retroarch/system
- --system nintendo-gba
-
-
-
- A Back
-=== command 40x15
- RetroBIOS: Command line
-
- The same choice without questions:
-
- python3 install.py --platform
- retroarch --dest
- /home/user/.config/retroarch/system
- --system nintendo-gba
-
-
-
-
-
-
A Back
=== command 80x24
RetroBIOS: Command line
@@ -978,8 +891,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
A Exit
@@ -991,8 +904,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
@@ -1032,27 +945,11 @@
-2] Name or service not known
Address:
https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
- > Choose another setup or folder
- Quit
- A Choose B Back
-=== error 40x15
- RetroBIOS: Something went wrong
-
- Could not fetch the file list for
- retroarch after 3 attempts: [Errno
- -2] Name or service not known
- Address:
- https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
+ 1-5 of 7
> Choose another setup or folder
Quit
-
-
- A Choose B Back
+ A Choose B Back L1/R1 Scroll
=== error 80x24
RetroBIOS: Something went wrong
@@ -1078,35 +975,6 @@
A Choose B Back
-=== quit 40x12
- RetroBIOS: Quit?
-
- Quit without installing? Nothing was
- written.
-
- > Quit
- Stay
-
-
-
-
- A Quit B Stay
-=== quit 40x15
- RetroBIOS: Quit?
-
- Quit without installing? Nothing was
- written.
-
- > Quit
- Stay
-
-
-
-
-
-
-
- A Quit B Stay
=== quit 80x24
RetroBIOS: Quit?
diff --git a/tests/fixtures/tui/route-gptokeyb.txt b/tests/fixtures/tui/route-gptokeyb.txt
index cf6839b66..7ae640cbc 100644
--- a/tests/fixtures/tui/route-gptokeyb.txt
+++ b/tests/fixtures/tui/route-gptokeyb.txt
@@ -322,35 +322,6 @@
A Use this folder B Back
-=== create 40x12
- RetroBIOS: Create the folder?
-
- /home/user/bios/new does not exist.
- The nearest folder that does:
- /home/user
-
- > Create it and continue
- Choose another folder
-
-
-
- A Create B Quit
-=== create 40x15
- RetroBIOS: Create the folder?
-
- /home/user/bios/new does not exist.
- The nearest folder that does:
- /home/user
-
- > Create it and continue
- Choose another folder
-
-
-
-
-
-
- A Create B Quit
=== create 80x24
RetroBIOS: Create the folder?
@@ -604,7 +575,7 @@
Copy from: none
Preview the file list
Show the command line
- A Open B Back Left/Right Set
+ A Open B Back <> Set
=== options 40x15
RetroBIOS: Options
@@ -620,7 +591,7 @@
Sets --region
- A Open B Back Left/Right Set
+ A Open B Back <> Set
=== options 80x24
RetroBIOS: Options
@@ -658,7 +629,7 @@
[ ] Japan
- A Tick B Done Left/Right Order
+ A Tick B Done <> Order
=== regions 40x15
RetroBIOS: Preferred regions
@@ -674,7 +645,7 @@
- A Tick B Done Left/Right Order
+ A Tick B Done <> Order
=== regions 80x24
RetroBIOS: Preferred regions
@@ -754,35 +725,6 @@
A Filter B Back X Tick
-=== preview 40x12
- RetroBIOS: Preview
-
- 4 files to download, 1.0 MB. Nothing
- is written.
-
- missing 512.0 KB scph5500.bin
- wrong copy 512.0 KB scph5501.bin
- missing 4 B pcsx/extra.bin
- missing 16.0 KB gba_bios.bin
-
-
- A Back
-=== preview 40x15
- RetroBIOS: Preview
-
- 4 files to download, 1.0 MB. Nothing
- is written.
-
- missing 512.0 KB scph5500.bin
- wrong copy 512.0 KB scph5501.bin
- missing 4 B pcsx/extra.bin
- missing 16.0 KB gba_bios.bin
-
-
-
-
-
- A Back
=== preview 80x24
RetroBIOS: Preview
@@ -807,35 +749,6 @@
- A Back
-=== command 40x12
- RetroBIOS: Command line
-
- The same choice without questions:
-
- python3 install.py --platform
- retroarch --dest
- /home/user/.config/retroarch/system
- --system nintendo-gba
-
-
-
- A Back
-=== command 40x15
- RetroBIOS: Command line
-
- The same choice without questions:
-
- python3 install.py --platform
- retroarch --dest
- /home/user/.config/retroarch/system
- --system nintendo-gba
-
-
-
-
-
-
A Back
=== command 80x24
RetroBIOS: Command line
@@ -978,8 +891,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
A Exit
@@ -991,8 +904,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
@@ -1032,27 +945,11 @@
-2] Name or service not known
Address:
https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
- > Choose another setup or folder
- Quit
- A Choose B Back
-=== error 40x15
- RetroBIOS: Something went wrong
-
- Could not fetch the file list for
- retroarch after 3 attempts: [Errno
- -2] Name or service not known
- Address:
- https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
+ 1-5 of 7
> Choose another setup or folder
Quit
-
-
- A Choose B Back
+ A Choose B Back L1/R1 Scroll
=== error 80x24
RetroBIOS: Something went wrong
@@ -1078,35 +975,6 @@
A Choose B Back
-=== quit 40x12
- RetroBIOS: Quit?
-
- Quit without installing? Nothing was
- written.
-
- > Quit
- Stay
-
-
-
-
- A Quit B Stay
-=== quit 40x15
- RetroBIOS: Quit?
-
- Quit without installing? Nothing was
- written.
-
- > Quit
- Stay
-
-
-
-
-
-
-
- A Quit B Stay
=== quit 80x24
RetroBIOS: Quit?
diff --git a/tests/fixtures/tui/route-gptokeyb_muxterm.txt b/tests/fixtures/tui/route-gptokeyb_muxterm.txt
index cf6839b66..5a8efe5a0 100644
--- a/tests/fixtures/tui/route-gptokeyb_muxterm.txt
+++ b/tests/fixtures/tui/route-gptokeyb_muxterm.txt
@@ -322,35 +322,6 @@
A Use this folder B Back
-=== create 40x12
- RetroBIOS: Create the folder?
-
- /home/user/bios/new does not exist.
- The nearest folder that does:
- /home/user
-
- > Create it and continue
- Choose another folder
-
-
-
- A Create B Quit
-=== create 40x15
- RetroBIOS: Create the folder?
-
- /home/user/bios/new does not exist.
- The nearest folder that does:
- /home/user
-
- > Create it and continue
- Choose another folder
-
-
-
-
-
-
- A Create B Quit
=== create 80x24
RetroBIOS: Create the folder?
@@ -604,7 +575,7 @@
Copy from: none
Preview the file list
Show the command line
- A Open B Back Left/Right Set
+ A Open B Back <> Set
=== options 40x15
RetroBIOS: Options
@@ -620,7 +591,7 @@
Sets --region
- A Open B Back Left/Right Set
+ A Open B Back <> Set
=== options 80x24
RetroBIOS: Options
@@ -658,7 +629,7 @@
[ ] Japan
- A Tick B Done Left/Right Order
+ A Tick B Done <> Order
=== regions 40x15
RetroBIOS: Preferred regions
@@ -674,7 +645,7 @@
- A Tick B Done Left/Right Order
+ A Tick B Done <> Order
=== regions 80x24
RetroBIOS: Preferred regions
@@ -754,35 +725,6 @@
A Filter B Back X Tick
-=== preview 40x12
- RetroBIOS: Preview
-
- 4 files to download, 1.0 MB. Nothing
- is written.
-
- missing 512.0 KB scph5500.bin
- wrong copy 512.0 KB scph5501.bin
- missing 4 B pcsx/extra.bin
- missing 16.0 KB gba_bios.bin
-
-
- A Back
-=== preview 40x15
- RetroBIOS: Preview
-
- 4 files to download, 1.0 MB. Nothing
- is written.
-
- missing 512.0 KB scph5500.bin
- wrong copy 512.0 KB scph5501.bin
- missing 4 B pcsx/extra.bin
- missing 16.0 KB gba_bios.bin
-
-
-
-
-
- A Back
=== preview 80x24
RetroBIOS: Preview
@@ -807,35 +749,6 @@
- A Back
-=== command 40x12
- RetroBIOS: Command line
-
- The same choice without questions:
-
- python3 install.py --platform
- retroarch --dest
- /home/user/.config/retroarch/system
- --system nintendo-gba
-
-
-
- A Back
-=== command 40x15
- RetroBIOS: Command line
-
- The same choice without questions:
-
- python3 install.py --platform
- retroarch --dest
- /home/user/.config/retroarch/system
- --system nintendo-gba
-
-
-
-
-
-
A Back
=== command 80x24
RetroBIOS: Command line
@@ -978,8 +891,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
A Exit
@@ -991,8 +904,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
@@ -1032,26 +945,10 @@
-2] Name or service not known
Address:
https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
- > Choose another setup or folder
- Quit
- A Choose B Back
-=== error 40x15
- RetroBIOS: Something went wrong
-
- Could not fetch the file list for
- retroarch after 3 attempts: [Errno
- -2] Name or service not known
- Address:
- https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
+ 1-5 of 7
> Choose another setup or folder
Quit
-
-
A Choose B Back
=== error 80x24
RetroBIOS: Something went wrong
@@ -1078,35 +975,6 @@
A Choose B Back
-=== quit 40x12
- RetroBIOS: Quit?
-
- Quit without installing? Nothing was
- written.
-
- > Quit
- Stay
-
-
-
-
- A Quit B Stay
-=== quit 40x15
- RetroBIOS: Quit?
-
- Quit without installing? Nothing was
- written.
-
- > Quit
- Stay
-
-
-
-
-
-
-
- A Quit B Stay
=== quit 80x24
RetroBIOS: Quit?
diff --git a/tests/fixtures/tui/route-keys.txt b/tests/fixtures/tui/route-keys.txt
index 6ec4b4720..e26ac816f 100644
--- a/tests/fixtures/tui/route-keys.txt
+++ b/tests/fixtures/tui/route-keys.txt
@@ -924,11 +924,11 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
To put them back: python3 install.py
1-8 of 17
- Enter Exit Up/Down Scroll
+ Enter Exit ^v Scroll
=== result 40x15
RetroBIOS: Finished with errors
@@ -937,14 +937,14 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
To put them back: python3 install.py
--restore
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
1-11 of 17
- Enter Exit Up/Down Scroll
+ Enter Exit ^v Scroll
=== result 80x24
RetroBIOS: Finished with errors
@@ -978,8 +978,8 @@
-2] Name or service not known
Address:
https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
+ 1-5 of 7
+
> Choose another setup or folder
Quit
Enter Choose Esc Back
diff --git a/tests/fixtures/tui/route-miyoo.txt b/tests/fixtures/tui/route-miyoo.txt
index 1b33c6920..77041c30d 100644
--- a/tests/fixtures/tui/route-miyoo.txt
+++ b/tests/fixtures/tui/route-miyoo.txt
@@ -604,7 +604,7 @@
Copy from: none
Preview the file list
Show the command line
- A Open R2 Back Left/Right Set
+ A Open R2 Back <> Set
=== options 40x15
RetroBIOS: Options
@@ -620,7 +620,7 @@
Sets --region
- A Open R2 Back Left/Right Set
+ A Open R2 Back <> Set
=== options 80x24
RetroBIOS: Options
@@ -658,7 +658,7 @@
[ ] Japan
- A Tick R2 Done Left/Right Order
+ A Tick R2 Done <> Order
=== regions 40x15
RetroBIOS: Preferred regions
@@ -674,7 +674,7 @@
- A Tick R2 Done Left/Right Order
+ A Tick R2 Done <> Order
=== regions 80x24
RetroBIOS: Preferred regions
@@ -754,35 +754,6 @@
A Filter R2 Back
-=== preview 40x12
- RetroBIOS: Preview
-
- 4 files to download, 1.0 MB. Nothing
- is written.
-
- missing 512.0 KB scph5500.bin
- wrong copy 512.0 KB scph5501.bin
- missing 4 B pcsx/extra.bin
- missing 16.0 KB gba_bios.bin
-
-
- A Back
-=== preview 40x15
- RetroBIOS: Preview
-
- 4 files to download, 1.0 MB. Nothing
- is written.
-
- missing 512.0 KB scph5500.bin
- wrong copy 512.0 KB scph5501.bin
- missing 4 B pcsx/extra.bin
- missing 16.0 KB gba_bios.bin
-
-
-
-
-
- A Back
=== preview 80x24
RetroBIOS: Preview
@@ -807,35 +778,6 @@
- A Back
-=== command 40x12
- RetroBIOS: Command line
-
- The same choice without questions:
-
- python3 install.py --platform
- retroarch --dest
- /home/user/.config/retroarch/system
- --system nintendo-gba
-
-
-
- A Back
-=== command 40x15
- RetroBIOS: Command line
-
- The same choice without questions:
-
- python3 install.py --platform
- retroarch --dest
- /home/user/.config/retroarch/system
- --system nintendo-gba
-
-
-
-
-
-
A Back
=== command 80x24
RetroBIOS: Command line
@@ -978,8 +920,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
A Exit
@@ -991,8 +933,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
@@ -1032,11 +974,11 @@
-2] Name or service not known
Address:
https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
+ 1-5 of 7
+
> Choose another setup or folder
Quit
- A Choose R2 Back
+ A Choose R2 Back L1/R1 Scroll
=== error 40x15
RetroBIOS: Something went wrong
diff --git a/tests/fixtures/tui/route-muxterm.txt b/tests/fixtures/tui/route-muxterm.txt
index 5e414ed37..1e8af124d 100644
--- a/tests/fixtures/tui/route-muxterm.txt
+++ b/tests/fixtures/tui/route-muxterm.txt
@@ -604,7 +604,7 @@
Copy from: none
Preview the file list
Show the command line
- Start Open B Back Left/Right Set
+ Start Open B Back <> Set
=== options 40x15
RetroBIOS: Options
@@ -620,7 +620,7 @@
Sets --region
- Start Open B Back Left/Right Set
+ Start Open B Back <> Set
=== options 80x24
RetroBIOS: Options
@@ -658,7 +658,7 @@
[ ] Japan
- Start Tick B Done Left/Right Order
+ Start Tick B Done <> Order
=== regions 40x15
RetroBIOS: Preferred regions
@@ -674,7 +674,7 @@
- Start Tick B Done Left/Right Order
+ Start Tick B Done <> Order
=== regions 80x24
RetroBIOS: Preferred regions
@@ -978,8 +978,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
Start Exit
@@ -991,8 +991,8 @@
/home/user/.config/retroarch/system
failed: scph5500.bin (HTTP 404)
The files it replaced are kept in
- /home/user/.config/retroarch/
- retrobios-backup/2026-10-23_1200
+ /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
+
Details: /tmp/retrobios-x/install.log
@@ -1032,8 +1032,8 @@
-2] Name or service not known
Address:
https://raw.githubusercontent.com/
- Abdess/retrobios/main/install/
- retroarch.json
+ 1-5 of 7
+
> Choose another setup or folder
Quit
Start Choose B Back
diff --git a/tests/fixtures/tui/setup-40x12.txt b/tests/fixtures/tui/setup-40x12.txt
index 04e73733b..2d87a3413 100644
--- a/tests/fixtures/tui/setup-40x12.txt
+++ b/tests/fixtures/tui/setup-40x12.txt
@@ -9,4 +9,4 @@
BIOS folder:
/home/user/.config/retroarch/system
ES-DE found: its BIOS files go to each
- A/Enter Continue B/Esc Quit ? Help
+ A Continue B Quit ? Help
diff --git a/tests/fixtures/tui/setup-40x15.txt b/tests/fixtures/tui/setup-40x15.txt
index d2d99ab29..faa6cbd38 100644
--- a/tests/fixtures/tui/setup-40x15.txt
+++ b/tests/fixtures/tui/setup-40x15.txt
@@ -12,4 +12,4 @@
emulator, not to ES-DE itself.
- A/Enter Continue B/Esc Quit ? Help
+ A Continue B Quit ? Help
diff --git a/tests/fixtures/tui/systems-120x40.txt b/tests/fixtures/tui/systems-120x40.txt
index 2b859a315..0580bbaa7 100644
--- a/tests/fixtures/tui/systems-120x40.txt
+++ b/tests/fixtures/tui/systems-120x40.txt
@@ -3,9 +3,9 @@
All
None
Filter (/ to type)
- > [x] nintendo-gba (gba) 1 file, 16.0 KB
- [x] sony-playstation 3 files, 1.0 MB
- [ ] nintendo-famicom-disk (ファミコン) in place
+ > [x] nintendo-gba (gba) 1 file, 16.0 KB
+ [x] sony-playstation 3 files, 1.0 MB
+ [ ] nintendo-famicom-disk (ファミコン) in place
diff --git a/tests/fixtures/version_basis.json b/tests/fixtures/version_basis.json
new file mode 100644
index 000000000..51c47f76f
--- /dev/null
+++ b/tests/fixtures/version_basis.json
@@ -0,0 +1,94 @@
+{
+ "api": {
+ "https://api.github.com/repos/libretro/dolphin/compare/f84df02055ab9610feec48e65648cac5a3c098fa...3aac3f052f733f8df2f1616843a4d2e14002ed11": {
+ "ahead_by": 437,
+ "behind_by": 0,
+ "status": "ahead"
+ },
+ "https://api.github.com/repos/libretro/dolphin/git/ref/tags/2609": {
+ "object": {
+ "sha": "b134d026ab4e71df13b3657ea6787d499962664b",
+ "type": "tag"
+ }
+ },
+ "https://api.github.com/repos/libretro/dolphin/git/tags/b134d026ab4e71df13b3657ea6787d499962664b": {
+ "object": {
+ "sha": "f84df02055ab9610feec48e65648cac5a3c098fa",
+ "type": "commit"
+ }
+ }
+ },
+ "files": {
+ "github.com/libretro/FBNeo/32958a89abd645ed20387c8d31d21ede3bc48161/src/burn/version.h": "// Version number, written as vV.V.BB or vV.V.BBaa\n// (0xVVBBaa, in BCD notation)\n\n#define VER_MAJOR 1\n#define VER_MINOR 0\n#define VER_BETA 0\n#define VER_ALPHA 3\n\n#define BURN_VERSION (VER_MAJOR * 0x100000) + (VER_MINOR * 0x010000) + (((VER_BETA / 10) * 0x001000) + ((VER_BETA % 10) * 0x000100)) + (((VER_ALPHA / 10) * 0x000010) + (VER_ALPHA % 10))\n\n\n\n\n",
+ "github.com/libretro/snes9x/fae2fea08f74180759ef540ee94259213f503480/snes9x.h": "/*****************************************************************************\\\n Snes9x - Portable Super Nintendo Entertainment System (TM) emulator.\n This file is licensed under the Snes9x License.\n For further information, consult the LICENSE file in the root directory.\n\\*****************************************************************************/\n\n#ifndef _SNES9X_H_\n#define _SNES9X_H_\n\n#ifndef VERSION\n#define VERSION\t\"1.63\"\n#endif\n\n#include \"port.h\"\n#include \"65c816.h\"\n#include \"messages.h\"\n\n/* All core file I/O goes through the libretro VFS via the\n file_stream_transforms wrappers (RFILE). The frontend supplies the\n VFS interface when available (see retro_set_environment);\n filestream falls back to its local implementation otherwise.\n Compressed (.gz) stream support from the old ZLIB build is gone --\n under libretro the frontend handles compressed content itself. */\n#include \n#define FSTREAM\t\t\t\t\tRFILE *\n#define READ_FSTREAM(p, l, s)\trfread(p, 1, l, s)\n#define WRITE_FSTREAM(p, l, s)\trfwrite(p, 1, l, s)\n#define GETS_FSTREAM(p, l, s)\trfgets(p, l, s)\n#define GETC_FSTREAM(s)\t\t\trfgetc(s)\n#define OPEN_FSTREAM(f, m)\t\trfopen(f, m)\n#define FIND_FSTREAM(s)\t\t\trftell(s)\n#define REVERT_FSTREAM(s, o, p)\trfseek(s, o, p)\n#define CLOSE_FSTREAM(s)\t\t\trfclose(s)\n\n\n#define SNES_WIDTH\t\t\t\t\t256\n#define SNES_HEIGHT\t\t\t\t\t224\n#define SNES_HEIGHT_EXTENDED\t\t239\n#define MAX_SNES_WIDTH\t\t\t\t(SNES_WIDTH * 2)\n#define MAX_SNES_HEIGHT\t\t\t\t(SNES_HEIGHT_EXTENDED * 2)\n\n#define\tNTSC_MASTER_CLOCK\t\t\t21477272.727272 // 21477272 + 8/11 exact\n#define\tPAL_MASTER_CLOCK\t\t\t21281370.0\n#define NTSC_PROGRESSIVE_FRAME_RATE\t60.09881389744051\n#define NTSC_INTERLACED_FRAME_RATE\t59.94005994\n#define PAL_PROGRESSIVE_FRAME_RATE\t50.006977968\n\n\n#define SNES_MAX_NTSC_VCOUNTER\t\t262\n#define SNES_MAX_PAL_VCOUNTER\t\t312\n#define SNES_HCOUNTER_MAX\t\t\t341\n\n#ifndef ALLOW_CPU_OVERCLOCK\n#define ONE_CYCLE\t\t\t\t\t6\n#define SLOW_ONE_CYCLE\t\t\t\t8\n#define TWO_CYCLES\t\t\t\t\t12\n#else\n#define ONE_CYCLE (Settings.OneClockCycle)\n#define SLOW_ONE_CYCLE (Settings.OneSlowClockCycle)\n#define TWO_CYCLES (Settings.TwoClockCycles)\n#endif\n#define\tONE_DOT_CYCLE\t\t\t\t4\n\n#define SNES_CYCLES_PER_SCANLINE\t(SNES_HCOUNTER_MAX * ONE_DOT_CYCLE)\n#define SNES_SCANLINE_TIME\t\t\t(SNES_CYCLES_PER_SCANLINE / NTSC_MASTER_CLOCK)\n\n#define SNES_WRAM_REFRESH_HC_v1\t\t530\n#define SNES_WRAM_REFRESH_HC_v2\t\t538\n#define SNES_WRAM_REFRESH_CYCLES\t40\n\n#define SNES_HBLANK_START_HC\t\t1096\t\t\t\t\t// H=274\n#define\tSNES_HDMA_START_HC\t\t\t1106\t\t\t\t\t// FIXME: not true\n#define\tSNES_HBLANK_END_HC\t\t\t4\t\t\t\t\t\t// H=1\n#define\tSNES_HDMA_INIT_HC\t\t\t20\t\t\t\t\t\t// FIXME: not true\n#define\tSNES_RENDER_START_HC\t\t(128 * ONE_DOT_CYCLE)\t// FIXME: Snes9x renders a line at a time.\n\n#define SNES_TR_MASK\t\t(1 << 4)\n#define SNES_TL_MASK\t\t(1 << 5)\n#define SNES_X_MASK\t\t\t(1 << 6)\n#define SNES_A_MASK\t\t\t(1 << 7)\n#define SNES_RIGHT_MASK\t\t(1 << 8)\n#define SNES_LEFT_MASK\t\t(1 << 9)\n#define SNES_DOWN_MASK\t\t(1 << 10)\n#define SNES_UP_MASK\t\t(1 << 11)\n#define SNES_START_MASK\t\t(1 << 12)\n#define SNES_SELECT_MASK\t(1 << 13)\n#define SNES_Y_MASK\t\t\t(1 << 14)\n#define SNES_B_MASK\t\t\t(1 << 15)\n\n#define DEBUG_MODE_FLAG\t\t(1 << 0)\t// debugger\n#define TRACE_FLAG\t\t\t(1 << 1)\t// debugger\n#define SINGLE_STEP_FLAG\t(1 << 2)\t// debugger\n#define BREAK_FLAG\t\t\t(1 << 3)\t// debugger\n#define SCAN_KEYS_FLAG\t\t(1 << 4)\t// CPU\n#define HALTED_FLAG\t\t\t(1 << 12)\t// APU\n#define FRAME_ADVANCE_FLAG\t(1 << 9)\n\n#define ROM_NAME_LEN\t23\n#define AUTO_FRAMERATE\t200\n\nstruct SCPUState\n{\n\tuint32\tFlags;\n\tint32\tCycles;\n\tint32\tPrevCycles;\n\tint32\tV_Counter;\n\tuint8\t*PCBase;\n\tbool8\tNMIPending;\n\tbool8\tIRQLine;\n\tbool8\tIRQTransition;\n\tbool8\tIRQLastState;\n\tbool8\tIRQExternal;\n\tint32\tIRQPending;\n\tint32\tMemSpeed;\n\tint32\tMemSpeedx2;\n\tint32\tFastROMSpeed;\n\tbool8\tInDMA;\n\tbool8\tInHDMA;\n\tbool8\tInDMAorHDMA;\n\tbool8\tInWRAMDMAorHDMA;\n\tuint8\tHDMARanInDMA;\n\tint32\tCurrentDMAorHDMAChannel;\n\tuint8\tWhichEvent;\n\tint32\tNextEvent;\n\tbool8\tWaitingForInterrupt;\n\tuint32\tAutoSaveTimer;\n\tbool8\tSRAMModified;\n};\n\nenum\n{\n\tHC_HBLANK_START_EVENT = 1,\n\tHC_HDMA_START_EVENT = 2,\n\tHC_HCOUNTER_MAX_EVENT = 3,\n\tHC_HDMA_INIT_EVENT = 4,\n\tHC_RENDER_EVENT = 5,\n\tHC_WRAM_REFRESH_EVENT = 6\n};\n\nenum\n{\n\tIRQ_NONE = 0x0,\n\tIRQ_SET_FLAG = 0x1,\n\tIRQ_CLEAR_FLAG = 0x2,\n\tIRQ_TRIGGER_NMI = 0x4\n};\n\nstruct STimings\n{\n\tint32\tH_Max_Master;\n\tint32\tH_Max;\n\tint32\tV_Max_Master;\n\tint32\tV_Max;\n\tint32\tHBlankStart;\n\tint32\tHBlankEnd;\n\tint32\tHDMAInit;\n\tint32\tHDMAStart;\n\tint32\tNMITriggerPos;\n\tint32\tNextIRQTimer;\n\tint32\tIRQTriggerCycles;\n\tint32\tWRAMRefreshPos;\n\tint32\tRenderPos;\n\tbool8\tInterlaceField;\n\tint32\tDMACPUSync;\t\t// The cycles to synchronize DMA and CPU. Snes9x cannot emulate correctly.\n\tint32\tNMIDMADelay;\t// The delay of NMI trigger after DMA transfers. Snes9x cannot emulate correctly.\n\tint32\tIRQFlagChanging;\t// This value is just a hack.\n\tint32\tAPUSpeedup;\n\tbool8\tAPUAllowTimeOverflow;\n};\n\nstruct SSettings\n{\n\tbool8\tTraceDMA;\n\tbool8\tTraceHDMA;\n\tbool8\tTraceVRAM;\n\tbool8\tTraceUnknownRegisters;\n\tbool8\tTraceDSP;\n\tbool8\tTraceHCEvent;\n\tbool8\tTraceSMP;\n\n\tbool8\tSuperFX;\n\tuint8\tDSP;\n\tbool8\tSA1;\n\tbool8\tC4;\n\tbool8\tSDD1;\n\tbool8\tSPC7110;\n\tbool8\tSPC7110RTC;\n\tbool8\tOBC1;\n\tuint8\tSETA;\n\tbool8\tSRTC;\n\tbool8\tBS;\n\tbool8\tBSXItself;\n\tbool8\tBSXBootup;\n\tbool8\tMSU1;\n\tbool8\tMouseMaster;\n\tbool8\tSuperScopeMaster;\n\tbool8\tJustifierMaster;\n\tbool8\tMultiPlayer5Master;\n\tbool8\tMacsRifleMaster;\n\n\tbool8\tForceLoROM;\n\tbool8\tForceHiROM;\n\tbool8\tForceHeader;\n\tbool8\tForceNoHeader;\n\tbool8\tForceInterleaved;\n\tbool8\tForceInterleaved2;\n\tbool8\tForceInterleaveGD24;\n\tbool8\tForceNotInterleaved;\n\tbool8\tForcePAL;\n\tbool8\tForceNTSC;\n\tbool8\tPAL;\n\tuint32\tFrameTimePAL;\n\tuint32\tFrameTimeNTSC;\n\tuint32\tFrameTime;\n\n\tbool8\tStereo;\n\tbool8\tReverseStereo;\n\tbool8\tMute;\n\t/* The frontend has stated it will never need this frame's audio and that\n\t the state left behind will be discarded or restored. The DSP declines to\n\t run at all while this is set - see SPC_DSP::run. Distinct from Mute,\n\t which only says the output is not wanted. */\n\tbool8\tHardDisableAudio;\n\tint32\tInterpolationMethod;\n\n\tbool8\tTransparency;\n\tint32\tMode7Hires;\t\t\t\t// 0 off, 2 = 2x, 4 = 4x Mode 7 hires\n\tint32\tMode7HiresVertical;\t\t// 2x vertical resample post-pass\n\tint32\tMode7HiresBilinear;\t\t// 0 nearest, 1 stable, 2 smooth\n\tuint8\tBG_Forced;\n\tuint16 ForcedBackdrop;\n\n\tuint32\tInitialInfoStringTimeout;\n\tuint16\tDisplayColor;\n\tbool8\tBilinearFilter;\n\tbool\tShowOverscan;\n\n\tbool8\tMulti;\n\tchar\tCartAName[PATH_MAX + 1];\n\tchar\tCartBName[PATH_MAX + 1];\n\n\tbool8\tDisableGameSpecificHacks;\n\tbool8\tBlockInvalidVRAMAccessMaster;\n\tbool8\tBlockInvalidVRAMAccess;\n\tint32\tHDMATimingHack;\n\n\tuint32\tSkipFrames;\n\tuint32\tTurboSkipFrames;\n\tuint32\tAutoMaxSkipFrames;\n\tbool8\tTurboMode;\n\tuint32\tHighSpeedSeek;\n\n\tbool8\tDumpStreams;\n\tint\t\tDumpStreamsMaxFrames;\n\n\tchar InitialSnapshotFilename[PATH_MAX + 1];\n\tbool8\tFastSavestates;\n\n\tbool8\tApplyCheats;\n\tbool8\tNoPatch;\n\tbool8\tIgnorePatchChecksum;\n\tbool8\tIsPatched;\n\tint32\tAutoSaveDelay;\n\tbool8\tDontSaveOopsSnapshot;\n\tbool8\tUpAndDown;\n\n bool8 SeparateEchoBuffer;\n\tuint32\tSuperFXClockMultiplier;\n int OverclockMode;\n\tint\tOneClockCycle;\n\tint\tOneSlowClockCycle;\n\tint\tTwoClockCycles;\n\tint\tMaxSpriteTilesPerLine;\n\tint\tChannelsVolumePercent[9];\n};\n\nstruct SSNESGameFixes\n{\n\tuint8\tSRAMInitialValue;\n\tuint8\tUniracers;\n};\n\nvoid S9xMessage(int, int, const char *);\n\nextern struct SSettings\t\t\tSettings;\nextern struct SCPUState\t\t\tCPU;\nextern struct STimings\t\t\tTimings;\nextern struct SSNESGameFixes\tSNESGameFixes;\nextern char\t\t\t\t\t\tString[513];\n\n#endif\n"
+ },
+ "profiles": {
+ "anarch": {
+ "core_version": null,
+ "emulator": "Anarch",
+ "type": "libretro",
+ "version_basis": {
+ "kind": "none"
+ }
+ },
+ "beetle_psx": {
+ "core_version": "v0.9.44.1",
+ "emulator": "Beetle PSX",
+ "type": "libretro",
+ "version_basis": {
+ "artifact": "mednafen_psx_libretro v0.9.44.1.zip",
+ "kind": "package",
+ "pattern": "libretro (v[0-9.]+)\\.zip"
+ }
+ },
+ "dolphin": {
+ "core_version": "2609-437-g3aac3f0",
+ "emulator": "Dolphin",
+ "source": "https://github.com/libretro/dolphin",
+ "source_commit": "3aac3f052f733f8df2f1616843a4d2e14002ed11",
+ "type": "standalone + libretro",
+ "upstream": "https://github.com/dolphin-emu/dolphin",
+ "version_basis": {
+ "distance": 437,
+ "kind": "tag",
+ "tag": "2609"
+ }
+ },
+ "fbneo": {
+ "core_version": "v1.0.0.03",
+ "emulator": "FinalBurn Neo",
+ "source": "https://github.com/libretro/FBNeo",
+ "source_commit": "32958a89abd645ed20387c8d31d21ede3bc48161",
+ "type": "libretro",
+ "version_basis": {
+ "format": "v{1}.{2}.{3}.{4:02d}",
+ "kind": "file",
+ "path": "src/burn/version.h",
+ "pattern": "VER_MAJOR\\s+(\\d+)\\s+#define\\s+VER_MINOR\\s+(\\d+)\\s+#define\\s+VER_BETA\\s+(\\d+)\\s+#define\\s+VER_ALPHA\\s+(\\d+)"
+ }
+ },
+ "mednafen_psx": {
+ "alias_of": "beetle_psx",
+ "core_version": "v0.9.44.1",
+ "emulator": "Beetle PSX",
+ "type": "alias",
+ "version_basis": {
+ "kind": "alias",
+ "of": "beetle_psx"
+ }
+ },
+ "snes9x": {
+ "core_version": "1.63",
+ "emulator": "Snes9x",
+ "source": "https://github.com/libretro/snes9x",
+ "source_commit": "fae2fea08f74180759ef540ee94259213f503480",
+ "type": "libretro",
+ "upstream": "https://github.com/snes9xgit/snes9x",
+ "version_basis": {
+ "kind": "file",
+ "path": "snes9x.h",
+ "pattern": "#define\\s+VERSION\\s+\"([^\"]+)\""
+ }
+ }
+ }
+}
diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py
index 9aad3b0e5..7e068a04d 100644
--- a/tests/test_audit_regressions.py
+++ b/tests/test_audit_regressions.py
@@ -790,16 +790,6 @@ class FaqRegressions(unittest.TestCase):
existence platform. Readers act on all three.
"""
- _MAME_GENERATIONS = {
- "MAME 2000": "mame2000",
- "MAME 2003": "mame2003",
- "MAME 2009": "mame2009",
- "MAME 2010": "mame2010",
- "MAME 2015": "mame2015",
- "MAME 2016": "mame2016",
- "current MAME": "mame",
- }
-
@staticmethod
def _faq() -> str:
# The questions about hashes and checks moved to the technical FAQ.
@@ -813,17 +803,23 @@ class FaqRegressions(unittest.TestCase):
with (ROOT / "emulators" / f"{key}.yml").open(encoding="utf-8") as handle:
return yaml.safe_load(handle)
- def test_mame_versions_match_their_profiles(self):
- # The list is wrapped, so a label and its version can straddle a line.
- faq = " ".join(self._faq().split())
- for label, key in self._MAME_GENERATIONS.items():
- match = re.search(rf"{re.escape(label)} \(([^)]+)\)", faq)
- self.assertIsNotNone(match, f"FAQ no longer states a version for {label}")
- self.assertEqual(
- match.group(1),
- self._profile(key)["core_version"],
- f"FAQ version for {label} has drifted from emulators/{key}.yml",
- )
+ def test_the_romset_table_is_written_from_the_profiles(self):
+ # Typed by hand, the table and the prose under it kept MAME 0.160
+ # and FinalBurn Neo 1.0.0.2, versions no source at the pins states,
+ # and the test holding them to the profiles kept the errors in step.
+ sys.path.insert(0, str(ROOT / "scripts"))
+ import generate_site # noqa: PLC0415
+ from common import load_emulator_profiles # noqa: PLC0415
+
+ profiles = load_emulator_profiles(str(ROOT / "emulators"), skip_aliases=False)
+ page = (ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8")
+ self.assertEqual(
+ page, generate_site.with_romset_table(page, profiles),
+ "wiki/technical-faq.md romset table is stale: run scripts/generate_site.py",
+ )
+ prose = page.split("### How this is known", 1)[1].split("\n## ", 1)[0]
+ self.assertIsNone(re.search(r"\(0\.\d", prose),
+ "the prose under the table types a version again")
def test_adler32_is_attributed_to_the_files_that_carry_it(self):
dolphin = self._profile("dolphin")
@@ -2080,6 +2076,59 @@ def _git_paths(*args: str, stdin: list[str] | None = None) -> set[str] | None:
return {p for p in result.stdout.split("\0") if p}
+class ManifestVersionIsThePlatformsOwn(unittest.TestCase):
+ """Every install manifest named its list "1.0", and an inheriting YAML
+ took its parent's version: Lakka was published as RetroArch v1.22.2.
+ The installer compares the list's version with the one a machine runs,
+ so each manifest states the version its own platform YAML records."""
+
+ def setUp(self):
+ from scripts.common import _platform_config_cache
+ _platform_config_cache.clear()
+
+ def test_committed_manifests_state_their_platform_version(self):
+ from scripts.common import load_platform_config
+ manifests = sorted((ROOT / "install").glob("*.json"))
+ self.assertTrue(manifests)
+ for path in manifests:
+ with self.subTest(manifest=path.name):
+ data = json.loads(path.read_text())
+ config = load_platform_config(data["platform"], str(ROOT / "platforms"))
+ self.assertTrue(config.get("version"))
+ self.assertEqual(data["version"], str(config["version"]))
+
+ def test_no_child_shows_its_parents_version(self):
+ from scripts.common import load_platform_config
+ platforms = ROOT / "platforms"
+ children = {}
+ for path in sorted(platforms.glob("*.yml")):
+ raw = yaml.safe_load(path.read_text()) or {}
+ if isinstance(raw, dict) and raw.get("inherits"):
+ children[path.stem] = raw
+ self.assertIn("lakka", children)
+ for name, raw in children.items():
+ with self.subTest(platform=name):
+ parent = yaml.safe_load(
+ (platforms / f"{raw['inherits']}.yml").read_text())
+ version = load_platform_config(name, str(platforms)).get("version")
+ self.assertEqual(version, raw.get("version"))
+ self.assertNotEqual(version, parent.get("version"))
+
+ def test_an_unversioned_child_has_no_version(self):
+ from scripts.common import load_platform_config
+ with tempfile.TemporaryDirectory(dir=TMP_ROOT) as tmp:
+ Path(tmp, "parent.yml").write_text("platform: P\nversion: '2.0'\nsystems: {}\n")
+ Path(tmp, "child.yml").write_text("inherits: parent\nplatform: C\n")
+ self.assertNotIn("version", load_platform_config("child", tmp))
+
+ def test_lakka_names_the_release_its_cores_are_read_at(self):
+ platforms = ROOT / "platforms"
+ lakka = yaml.safe_load((platforms / "lakka.yml").read_text())
+ targets = yaml.safe_load((platforms / "targets" / "lakka.yml").read_text())
+ tag = re.search(r"/blob/([^/]+)/", targets["source"]).group(1)
+ self.assertEqual(lakka["version"], tag)
+
+
class EveryManifestEntryIsFetchable(unittest.TestCase):
"""An install manifest may not list a file the installer cannot get.
@@ -5000,7 +5049,7 @@ class CollectionCardsCarryFullHashes(unittest.TestCase):
"crc32": "504086c1", "sha256": "b" * 64}
text = "\n".join(_system_file_card(entry, FileConsumers({}, {})))
for value in (entry["sha1"], entry["md5"], entry["crc32"], entry["sha256"]):
- self.assertIn(f'{value}', text)
+ self.assertIn(f'
{value}
', text)
diff --git a/tests/test_campaign.py b/tests/test_campaign.py
index 5a5d21343..fee154c1c 100644
--- a/tests/test_campaign.py
+++ b/tests/test_campaign.py
@@ -1030,6 +1030,11 @@ class FakeCorePasses(Workspace):
@classmethod
def setUpClass(cls) -> None:
+ # These passes prove the cores run sandboxed. Without a working bwrap
+ # the campaign refuses, and the cause is named here instead of a KeyError.
+ _prefix, isolation = campaign.core_probe.sandbox_argv([Path(tempfile.gettempdir())])
+ if isolation != campaign.core_probe.SANDBOX:
+ raise unittest.SkipTest(f"no sandbox here ({isolation}): install bubblewrap")
TMP.mkdir(parents=True, exist_ok=True)
cls.build = Path(tempfile.mkdtemp(dir=TMP))
source = cls.build / "fake.c"
diff --git a/tests/test_core_file_fills_declaration.py b/tests/test_core_file_fills_declaration.py
index 1f4ee4309..2f03e248f 100644
--- a/tests/test_core_file_fills_declaration.py
+++ b/tests/test_core_file_fills_declaration.py
@@ -58,6 +58,7 @@ class ACoreFileFillsAFreeDeclaration(unittest.TestCase):
self.emulators.mkdir()
(self.platforms / "fakeplat.yml").write_text(yaml.safe_dump({
"platform": "FakePlat",
+ "version": "2.1",
"verification_mode": "existence",
"base_destination": "system",
"cores": ["fakecore"],
@@ -118,7 +119,7 @@ class AFileDeclaredUnderAnotherName(unittest.TestCase):
(root / "platforms").mkdir()
(root / "emulators").mkdir()
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
- "platform": "FakePlat", "verification_mode": "existence",
+ "platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
"base_destination": "system", "cores": ["gs"],
"systems": {"apple-iigs": {"files": [
{"name": "ROM1", "destination": "ROM1", "md5": md5},
@@ -156,7 +157,7 @@ class AMissingDataDirectoryFailsTheRun(unittest.TestCase):
(root / "emulators").mkdir()
(root / "bios").mkdir()
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
- "platform": "FakePlat", "verification_mode": "existence",
+ "platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
"base_destination": "system", "cores": [],
"systems": {"console-a": {"files": [], "data_directories": [
{"ref": "assets", "destination": "assets"},
@@ -197,7 +198,7 @@ class AFileNobodyHoldsIsAnOmission(unittest.TestCase):
for folder in ("platforms", "emulators", "bios"):
(root / folder).mkdir()
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
- "platform": "FakePlat", "verification_mode": "existence",
+ "platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
"base_destination": "system", "cores": ["devil"],
"systems": {"diablo": {"files": []}},
}))
@@ -238,7 +239,7 @@ class AMemberNameIsNotALooseFile(unittest.TestCase):
(root / "platforms").mkdir()
(root / "emulators").mkdir()
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
- "platform": "FakePlat", "verification_mode": "existence",
+ "platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
"base_destination": "system", "cores": ["naomi2core"],
"systems": {"sega-naomi2": {"files": []}},
}))
@@ -283,7 +284,7 @@ class TwoDeclarationsOfOnePath(unittest.TestCase):
for folder in ("platforms", "emulators"):
(root / folder).mkdir()
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
- "platform": "FakePlat", "verification_mode": "existence",
+ "platform": "FakePlat", "version": "2.1", "verification_mode": "existence",
"base_destination": "system", "cores": [],
"systems": {
"a-system": {"files": [{"name": first, "destination": "shared.bin"}]},
@@ -335,7 +336,7 @@ class ANamelessWhitelistEntry(unittest.TestCase):
(root / folder).mkdir()
values = [md5.upper(), f"{'0' * 32},{md5}", "f" * 32]
(root / "platforms" / "fakeplat.yml").write_text(yaml.safe_dump({
- "platform": "FakePlat", "verification_mode": "md5",
+ "platform": "FakePlat", "version": "2.1", "verification_mode": "md5",
"base_destination": "bios", "cores": [],
"systems": {"sony-playstation": {"files": [
{"name": f"sony-playstation:{v}", "destination": "", "md5": v}
diff --git a/tests/test_core_probe.py b/tests/test_core_probe.py
index ead050b66..f9b3babb1 100644
--- a/tests/test_core_probe.py
+++ b/tests/test_core_probe.py
@@ -658,5 +658,45 @@ class Sandbox(unittest.TestCase):
self.assertIn("--require-sandbox: environment only: bwrap not found", problems)
+
+class TheInterpreterIsInTheSandbox(unittest.TestCase):
+ """CI's setup-python, a venv and uv put Python outside /usr. The sandbox
+ bound only the system trees, so the worker could not start and every run
+ crashed on the runner."""
+
+ def test_each_link_to_the_interpreter_is_bound(self):
+ from unittest import mock # noqa: PLC0415
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ real = root / "uv" / "cpython-3.13.12" / "bin"
+ real.mkdir(parents=True)
+ (real / "python3.13").write_text("")
+ alias = root / "uv" / "cpython-3.13"
+ alias.symlink_to(root / "uv" / "cpython-3.13.12")
+ venv = root / "venv" / "bin"
+ venv.mkdir(parents=True)
+ (venv / "python").symlink_to(alias / "bin" / "python3.13")
+ with mock.patch.object(core_probe.sys, "executable", str(venv / "python")), \
+ mock.patch.object(core_probe.sys, "prefix", str(root / "venv")), \
+ mock.patch.object(core_probe.sys, "base_prefix", str(root / "uv" / "cpython-3.13.12")):
+ paths = core_probe.interpreter_paths()
+ self.assertIn(root / "venv", paths)
+ self.assertIn(alias, paths)
+ self.assertIn(root / "uv" / "cpython-3.13.12", paths)
+
+ def test_a_link_is_mounted_at_its_own_path(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ target = Path(tmp) / "real"
+ target.mkdir()
+ link = Path(tmp) / "link"
+ link.symlink_to(target)
+ prefix, isolation = core_probe.sandbox_argv([], [link])
+ if isolation != core_probe.SANDBOX:
+ self.skipTest(f"no sandbox here ({isolation})")
+ self.assertIn(["--ro-bind", str(target.resolve()), str(link)],
+ [prefix[i:i + 3] for i in range(len(prefix) - 2)])
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_core_size_refusal.py b/tests/test_core_size_refusal.py
index b0a3c09c3..7ff272fed 100644
--- a/tests/test_core_size_refusal.py
+++ b/tests/test_core_size_refusal.py
@@ -76,7 +76,7 @@ class _Collection(unittest.TestCase):
}}
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
self.config = {
- "platform": "Exist", "verification_mode": "existence",
+ "platform": "Exist", "version": "2.1", "verification_mode": "existence",
"cores": ["sized", "unsized"],
"systems": {"mac": {"files": []}, "iigs": {"files": []}},
}
diff --git a/tests/test_directory_entries.py b/tests/test_directory_entries.py
index ef8175634..6d63e0e7f 100644
--- a/tests/test_directory_entries.py
+++ b/tests/test_directory_entries.py
@@ -56,6 +56,7 @@ files:
PLATFORM = {
"platform": "Demo",
+ "version": "2.1",
"verification_mode": "existence",
"base_destination": "",
"cores": ["demo"],
diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py
index da7dfc7d6..e6bf0a222 100644
--- a/tests/test_doc_pages.py
+++ b/tests/test_doc_pages.py
@@ -98,8 +98,30 @@ class InstallerOutputExample(unittest.TestCase):
self.assertIn(part.rsplit(":", 1)[0], install.OMISSION_REASONS.values())
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", run).groups()
self.assertIn(f"0/{count} present", run)
- menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)\.", text)
- self.assertEqual(menu.groups(), (count, size))
+ self.assertSplitAddsUp(run, count, size)
+ menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)[.,]([^`]*)", text)
+ self.assertEqual(menu.groups()[:2], (count, size))
+ self.assertSplitAddsUp(menu.group(3), count, size)
+ games = re.search(r"g\) leave out the (\d+) game data files \(([\d.]+ [KMG]B)\)",
+ menu.group(3))
+ self.assertIsNotNone(games, "the menu offers to leave the game data out")
+ split = re.search(r"(\d+) BIOS files \(([\d.]+ [KMG]B)\) and (\d+) game data files "
+ r"\(([\d.]+ [KMG]B)\)", menu.group(3))
+ self.assertEqual(games.groups(), split.groups()[2:])
+
+ def assertSplitAddsUp(self, text: str, count: str, size: str) -> None:
+ """The run counted 8069 files where its menu counted 3259 BIOS files
+ and 5089 game data files: the parts add up to the total they divide,
+ the sizes within the rounding of their three figures."""
+ split = re.search(r"(\d+) BIOS files \(([\d.]+) ([KMG]B)\) and (\d+) game data "
+ r"files \(([\d.]+) ([KMG]B)\)", text)
+ self.assertIsNotNone(split, "a categorized list says its BIOS files and game data")
+ bios, bios_size, bios_unit, games, game_size, game_unit = split.groups()
+ self.assertEqual(int(bios) + int(games), int(count))
+ units = {"KB": 1, "MB": 1024, "GB": 1024 ** 2}
+ total, unit = size.split()
+ added = (float(bios_size) * units[bios_unit] + float(game_size) * units[game_unit])
+ self.assertLessEqual(abs(added - float(total) * units[unit]), 0.15 * units[unit])
class OneResolutionChain(unittest.TestCase):
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
index 4a92f198a..2e0b4ed4c 100644
--- a/tests/test_e2e.py
+++ b/tests/test_e2e.py
@@ -224,6 +224,7 @@ class TestE2E(unittest.TestCase):
def _create_existence_platform(self):
config = {
"platform": "TestExistence",
+ "version": "9.1",
"verification_mode": "existence",
"base_destination": "system",
"systems": {
@@ -3676,6 +3677,51 @@ class TestE2E(unittest.TestCase):
self.assertIsInstance(f["size"], int)
self.assertGreater(len(f["sha1"]), 0)
+ def test_91_manifest_version_is_the_platform_list_version(self):
+ """The manifest names the version of the list it was built from.
+
+ It was the constant "1.0" for every platform, so the installer could
+ not tell which Batocera or Recalbox release a list described. Two
+ platforms of different versions show the value comes from each YAML.
+ A YAML without one, an inheriting child included, is refused rather
+ than written with a stand-in or its parent's version.
+ """
+ from common import _platform_config_cache
+ from generate_pack import generate_manifest
+
+ registry_path = os.path.join(self.platforms_dir, "_test_registry_version.yml")
+ with open(registry_path, "w") as fh:
+ yaml.dump({"platforms": {}}, fh)
+ with open(os.path.join(self.platforms_dir, "test_md5.yml")) as fh:
+ config = yaml.safe_load(fh)
+ config["version"] = "41"
+ with open(os.path.join(self.platforms_dir, "versioned_md5.yml"), "w") as fh:
+ yaml.dump(config, fh)
+ del config["version"]
+ with open(os.path.join(self.platforms_dir, "unversioned.yml"), "w") as fh:
+ yaml.dump(config, fh)
+ _platform_config_cache.clear()
+
+ def version_of(name: str) -> str:
+ return generate_manifest(
+ name, self.platforms_dir, self.db, self.bios_dir, registry_path,
+ emulators_dir=self.emulators_dir, offline=True,
+ )["version"]
+
+ self.assertEqual(version_of("test_existence"), "9.1")
+ self.assertEqual(version_of("versioned_md5"), "41")
+ for name in ("unversioned", "test_inherited"):
+ with self.subTest(platform=name), \
+ self.assertRaisesRegex(ValueError, "records no version"):
+ version_of(name)
+ with open(os.path.join(self.platforms_dir, "test_inherited.yml")) as fh:
+ child = yaml.safe_load(fh)
+ child["version"] = "2"
+ with open(os.path.join(self.platforms_dir, "test_inherited.yml"), "w") as fh:
+ yaml.dump(child, fh)
+ _platform_config_cache.clear()
+ self.assertEqual(version_of("test_inherited"), "2")
+
def test_91b_manifest_hash_points_at_a_fetchable_file(self):
"""A declared hash no local file carries must not blank repo_path.
@@ -4905,7 +4951,8 @@ struct BurnDriver BurnDrvneogeo = {
rom = next(f for f in result["files"] if f["name"] == "sp-s2.sp1")
self.assertEqual(rom["crc32"], "newcrc")
self.assertEqual(rom["system"], "snk-neogeo-mvs")
- self.assertEqual(result["core_version"], "v1.0.0.03")
+ # The version is the pin's, read through version_basis.
+ self.assertEqual(result["core_version"], "v1.0.0.02")
def _load_config(self, platform_name: str) -> dict:
@@ -5726,6 +5773,7 @@ struct BurnDriver BurnDrvneogeo = {
self.db = self._build_db()
config = {
"platform": "RegionPlat",
+ "version": "3.0",
"verification_mode": "existence",
"systems": {
"region-console": {
diff --git a/tests/test_frontend_digest.py b/tests/test_frontend_digest.py
index 4067fb464..bf070938b 100644
--- a/tests/test_frontend_digest.py
+++ b/tests/test_frontend_digest.py
@@ -47,6 +47,7 @@ class FrontendDigestDecides(unittest.TestCase):
)
(self.platforms / "digestplat.yml").write_text(yaml.safe_dump({
"platform": "DigestPlat",
+ "version": "2.1",
"verification_mode": "md5",
"base_destination": "bios",
"systems": {"psx": {"files": [{
diff --git a/tests/test_hash_merge.py b/tests/test_hash_merge.py
index 3b28b37ce..079dfe4e5 100644
--- a/tests/test_hash_merge.py
+++ b/tests/test_hash_merge.py
@@ -592,7 +592,10 @@ class TestFbneoMerge(unittest.TestCase):
self.assertEqual(len(archive_files), 1)
self.assertNotIn("_upstream_removed", archive_files[0])
- def test_merge_updates_core_version(self) -> None:
+ def test_merge_leaves_core_version_to_the_pin(self) -> None:
+ # The hashes are read at finalburnneo/FBNeo's tip, which the
+ # profile's libretro/FBNeo pin does not name: the version stays the
+ # one version_basis reads at that pin.
with tempfile.TemporaryDirectory() as td:
p = Path(td)
profile_path = _write_yaml(p / "fbneo.yml", _make_fbneo_profile())
@@ -600,7 +603,7 @@ class TestFbneoMerge(unittest.TestCase):
result = merge_fbneo_profile(profile_path, hashes_path)
- self.assertEqual(result["core_version"], "v1.0.0.03")
+ self.assertEqual(result["core_version"], "v1.0.0.02")
class TestDiff(unittest.TestCase):
@@ -849,7 +852,7 @@ class WritersPutTheValuesOnDisk(unittest.TestCase):
merge_fbneo_profile(str(profile), hashes, write=True, add_new=False)
written = yaml.safe_load(profile.read_text(encoding="utf-8"))
entry = written["files"][0]
- self.assertEqual(written["core_version"], "v1.0.0.03")
+ self.assertEqual(written["core_version"], "v1.0.0.02")
self.assertEqual(entry["crc32"], "9036d879")
self.assertEqual(entry["note"], "MVS Asia/Europe ver. 5")
self.assertEqual(entry["source_ref"], "src/burn/drv/neogeo/d_neogeo.cpp:1625")
diff --git a/tests/test_install.py b/tests/test_install.py
index ce3389aa5..42fb10a39 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -23,6 +23,7 @@ import unittest
from types import SimpleNamespace
import unittest.mock
import urllib.error
+import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -5852,5 +5853,157 @@ class KeptFilesEndToEnd(unittest.TestCase, _SyntheticTree):
self.assertEqual(len(list((self.home / install.BACKUP_DIR).iterdir())), 2)
+
+class TheListVersionIsComparedWithTheSystems(unittest.TestCase):
+ """The list says which version of the platform it was built for, and the
+ installer reads the version the system runs from the file the system
+ writes. A difference is said, never refused."""
+
+ def note(self, plat: str, listed: str, bios: Path = Path("/userdata/bios")):
+ return install.version_note(plat, bios, {"platform": plat, "version": listed})
+
+ def test_batocera_reads_the_first_word_of_its_version_file(self):
+ _device(self, {"/usr/share/batocera/batocera.version": "41 2025/01/17 10:12\n"})
+ listed, installed, text = self.note("batocera", "43.1")
+ self.assertEqual((listed, installed), ("43.1", "41"))
+ self.assertEqual(
+ text, "This list was built for Batocera 43.1, this machine runs 41. "
+ "Batocera's own BIOS check may report some files as wrong though they work.")
+ self.assertIsNone(self.note("batocera", "41"))
+
+ def test_recalbox_reads_only_a_release(self):
+ _device(self, {"/recalbox/recalbox.version": "10.0\n"})
+ self.assertEqual(self.note("recalbox", "10.1.1")[:2], ("10.1.1", "10.0"))
+ self.assertIsNone(self.note("recalbox", "10.0"))
+ for built in ("development", "2026/10/01 main (4242)"):
+ with self.subTest(built=built):
+ _device(self, {"/recalbox/recalbox.version": built})
+ self.assertIsNone(self.note("recalbox", "10.1.1"))
+
+ def test_retrobat_reads_the_folder_beside_its_bios_folder(self):
+ root = Path(_tempdir(self))
+ version = root / "system" / "version.info"
+ version.parent.mkdir()
+ version.write_text("8.3.1.1-stable-win64")
+ # The release tagged 8.3.1 ships 8.3.1.1.
+ self.assertIsNone(self.note("retrobat", "8.3.1", root / "bios"))
+ version.write_text("8.2.0-stable-win64")
+ self.assertEqual(self.note("retrobat", "8.3.1", root / "bios")[:2],
+ ("8.3.1", "8.2.0"))
+
+ def test_lakka_and_emuelec_read_os_release(self):
+ _device(self, {}, release='NAME="Lakka"\nID="lakka"\nVERSION_ID="6.0"\n')
+ listed, installed, text = self.note("lakka", "v6.1")
+ self.assertEqual((listed, installed), ("v6.1", "6.0"))
+ self.assertIn("Lakka checks only that a file is there", text)
+ _device(self, {}, release='NAME="Lakka"\nID="lakka"\nVERSION_ID="6.1"\n')
+ self.assertIsNone(self.note("lakka", "v6.1"))
+ _device(self, {}, release='NAME="EmuELEC"\nVERSION_ID="4.7"\n')
+ self.assertEqual(self.note("emuelec", "4.8")[:2], ("4.8", "4.7"))
+ # The file of another system says nothing about this one.
+ self.assertIsNone(self.note("lakka", "v6.1"))
+
+ def test_an_unknown_version_says_nothing(self):
+ _device(self, {"/usr/share/batocera/batocera.version": "41 2025/01/17 10:12"})
+ self.assertIsNone(self.note("batocera", "1.0"))
+ self.assertIsNone(install.version_note("batocera", Path("/userdata/bios"),
+ {"platform": "batocera"}))
+ self.assertIsNone(self.note("retroarch", "v1.22.2"))
+ _device(self, {"/usr/share/knulli/knulli.version": "scarab 2026/05/11 10:00"})
+ self.assertIsNone(self.note("knulli", "20260511"))
+ _device(self, {})
+ self.assertIsNone(self.note("batocera", "43.1"))
+ _device(self, {"/usr/share/batocera/batocera.version": "\x1b[2J 2025/01/17"})
+ self.assertIsNone(self.note("batocera", "43.1"))
+
+ def test_a_pack_alone_gives_the_version_of_its_list(self):
+ """A handheld without network installs from the pack: its manifest.json
+ records the list's version, which generate_pack takes from the
+ install list built with the same choices."""
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
+ import generate_pack
+ data = b"bios"
+ install_list = {"version": "43.1", "files": [
+ {"dest": "a.bin", "sha1": hashlib.sha1(data).hexdigest(), "size": 4}]}
+ pack_list = {"files": [{"path": "a.bin", "sha1": hashlib.sha1(data).hexdigest(),
+ "size": 4}]}
+ generate_pack.annotate_pack_systems(pack_list, generate_pack.pack_systems(install_list))
+ self.assertEqual(pack_list["list_version"], "43.1")
+ pack = Path(_tempdir(self)) / "Batocera_43.1_BIOS_Pack.zip"
+ with zipfile.ZipFile(pack, "w") as archive:
+ archive.writestr("manifest.json", json.dumps(pack_list))
+ archive.writestr("a.bin", data)
+ source = install.PackSource([pack])
+ self.addCleanup(source.close)
+ self.assertEqual(source.manifest("batocera")["version"], "43.1")
+
+ def test_the_result_repeats_it_in_every_ending(self):
+ for kw in ({"downloaded": 3}, {"errors": 2}, {"downloaded": 1, "errors": 1}):
+ with self.subTest(**kw):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf), contextlib.suppress(SystemExit):
+ install._report_outcome([Path("/userdata/bios")], omitted=0,
+ interrupted=False, notes=["Built for 43.1."],
+ **{"downloaded": 0, "up_to_date": 0,
+ "errors": 0, **kw})
+ self.assertIn("Built for 43.1.", buf.getvalue())
+
+
+class TheListVersionIsSaidBeforeAndAfter(unittest.TestCase):
+ """A RetroBat folder holding 8.2.0 and a list built for 8.3.1, through
+ the installer as a person and as the screen run it."""
+
+ DATA = b"retrobat bios" * 8
+
+ def setUp(self):
+ self.root = Path(_tempdir(self))
+ (self.root / "system").mkdir()
+ (self.root / "system" / "version.info").write_text("8.2.0-stable-win64")
+ self.bios = self.root / "bios"
+ self.bios.mkdir()
+ (self.bios / "a.bin").write_bytes(self.DATA)
+ self.list = self.root / "retrobat.json"
+ self.list.write_text(json.dumps({
+ "manifest_version": 2, "platform": "retrobat", "version": "8.3.1",
+ "files": [{"dest": "a.bin", "sha1": hashlib.sha1(self.DATA).hexdigest(),
+ "size": len(self.DATA), "repo_path": "bios/a.bin"}],
+ }))
+
+ def run_installer(self, *options: str) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [sys.executable, str(REPO_ROOT / "install.py"), "--platform", "retrobat",
+ "--dest", str(self.bios), "--manifest", str(self.list), "--offline",
+ "--yes", *options],
+ capture_output=True, text=True, timeout=120, check=False,
+ stdin=subprocess.DEVNULL,
+ )
+
+ SAID = ("This list was built for RetroBat 8.3.1, this machine runs 8.2.0. "
+ "RetroBat's own BIOS check may report some files as wrong though they work.")
+
+ def test_line_mode_says_it_before_the_install_and_with_the_result(self):
+ proc = self.run_installer()
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+ self.assertEqual(proc.stdout.count(self.SAID), 2, proc.stdout)
+ self.assertLess(proc.stdout.index(self.SAID), proc.stdout.index("Checking existing"))
+ self.assertGreater(proc.stdout.rindex(self.SAID), proc.stdout.index("Done."))
+
+ def test_the_screen_reads_an_event(self):
+ proc = self.run_installer("--check", "--events")
+ events = [json.loads(line) for line in proc.stdout.splitlines()]
+ said = [event for event in events if event["event"] == "version"]
+ self.assertEqual(said, [{"event": "version", "platform": "retrobat",
+ "list_version": "8.3.1", "installed_version": "8.2.0",
+ "message": self.SAID}])
+ kinds = [event["event"] for event in events]
+ self.assertLess(kinds.index("version"), kinds.index("check"))
+
+ def test_the_same_version_says_nothing(self):
+ (self.root / "system" / "version.info").write_text("8.3.1.1-stable-win64")
+ proc = self.run_installer()
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+ self.assertNotIn("This list was built for", proc.stdout)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py
index edd7c07df..beb738b11 100644
--- a/tests/test_install_cores.py
+++ b/tests/test_install_cores.py
@@ -370,18 +370,28 @@ class RefusedBeforeAnyInstall(unittest.TestCase):
(tmp / "retroarch.cfg").write_text("")
(tmp / "cores").mkdir()
(tmp / "cores" / "mgba_libretro.so").write_bytes(b"")
- with mock.patch.object(install, "detect_os", return_value="linux"), \
- mock.patch.object(install, "detect_frontends", return_value=[]), \
- mock.patch.object(install, "detect_platforms", return_value=detected), \
- mock.patch.object(install, "fetch_manifest") as fetch, \
- mock.patch.object(sys, "argv", ["install.py", "--installed-cores", "--no-input"]), \
- contextlib.redirect_stdout(io.StringIO()), \
- contextlib.redirect_stderr(io.StringIO()) as err:
- with self.assertRaises(SystemExit) as stop:
- install.main()
- self.assertEqual(stop.exception.code, 1)
- self.assertIn("batocera ships its cores", err.getvalue())
- fetch.assert_not_called()
+ # Two setups found stop a run without questions before either is
+ # read (code_audit_18 P2-3): the refusal is checked with Batocera
+ # named, and with both found nothing is fetched either.
+ for argv, said in (
+ (["--platform", "batocera", "--dest", str(tmp / "bios")],
+ "batocera ships its cores"),
+ ([], "2 setups were found here")):
+ with self.subTest(argv=argv), \
+ mock.patch.object(install, "detect_os", return_value="linux"), \
+ mock.patch.object(install, "detect_frontends", return_value=[]), \
+ mock.patch.object(install, "detect_platforms", return_value=detected), \
+ mock.patch.object(install, "_mounted_cards", return_value=[]), \
+ mock.patch.object(install, "fetch_manifest") as fetch, \
+ mock.patch.object(sys, "argv", ["install.py", "--installed-cores",
+ "--no-input", *argv]), \
+ contextlib.redirect_stdout(io.StringIO()), \
+ contextlib.redirect_stderr(io.StringIO()) as err:
+ with self.assertRaises(SystemExit) as stop:
+ install.main()
+ self.assertEqual(stop.exception.code, 1)
+ self.assertIn(said, err.getvalue())
+ fetch.assert_not_called()
class AnUnknownTargetStopsTheRunFirst(unittest.TestCase):
@@ -393,12 +403,14 @@ class AnUnknownTargetStopsTheRunFirst(unittest.TestCase):
self.addCleanup(shutil.rmtree, tmp, True)
detected = [("emudeck", tmp / "bios"), ("retroarch", tmp / "system")]
lists = {"emudeck": {"steamos": {"cores": []}}, "retroarch": {"x86_64": {"cores": []}}}
+ # Listed for both platforms: a listing reads every list found.
with mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install, "detect_platforms", return_value=detected), \
mock.patch.object(install, "fetch_targets", side_effect=lists.get), \
mock.patch.object(install, "fetch_manifest") as fetch, \
- mock.patch.object(sys, "argv", ["install.py", "--target", "steamos", "--no-input"]), \
+ mock.patch.object(sys, "argv", ["install.py", "--target", "steamos",
+ "--list-systems", "--no-input"]), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()) as err:
with self.assertRaises(SystemExit) as stop:
diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py
index 34d5a9af8..bbcf0fb0e 100644
--- a/tests/test_install_experience.py
+++ b/tests/test_install_experience.py
@@ -918,6 +918,37 @@ class ColourFollowsNoColorOrg(unittest.TestCase):
self.assertFalse(term.live)
+class TheEndOfARunPaintsItsStatusWords(unittest.TestCase):
+ """Line mode ends on the words the full screen paints, in the same
+ roles, and on the same text without colour."""
+
+ def _end(self, colour: bool, *counts: int, **given) -> str:
+ term = install.Terminal(io.StringIO())
+ term.colour = colour
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.suppress(SystemExit):
+ install._report_outcome([Path("bios")], *counts, term=term, **given)
+ return out.getvalue()
+
+ def test_the_same_words_with_and_without_colour(self):
+ for counts, given, painted in (
+ ((3, 1, 1, 0, False), {"failures": [("a.bin", "HTTP 404")]},
+ (("failure", "Finished with errors"), ("success", "3 files installed"),
+ ("failure", "1 file failed"), ("failure", "failed:"))),
+ ((2, 1, 0, 0, False), {},
+ (("success", "Done"), ("success", "2 files installed"),
+ ("success", "1 file already up to date"))),
+ ((1, 0, 0, 0, False), {"offline": 2},
+ (("warning", "Finished with files missing"), ("warning", "2 not fetched")))):
+ with self.subTest(counts=counts):
+ plain, coloured = self._end(False, *counts, **given), \
+ self._end(True, *counts, **given)
+ self.assertNotIn("\x1b", plain)
+ self.assertEqual(install.unpainted(coloured), plain)
+ for role, word in painted:
+ self.assertIn(install._sgr(role) + word, coloured)
+
+
class SmallWordings(unittest.TestCase):
def test_check_asks_which_to_check(self):
"""--check with several platforms asked "Install for:", and
@@ -1174,11 +1205,17 @@ class ADetectedClosedFolderSaysWhereItCameFrom(unittest.TestCase):
said = " ".join(proc.stderr.split())
self.assertIn(f"than the one {self.cfg} names (name another with --dest)", said)
- def test_nothing_installed_comes_after_the_error(self):
+ def test_two_setups_stop_before_either_folder_is_read(self):
+ """code_audit_18 P2-3: a run without questions installs for one
+ setup, so two found stop on what to name before either folder is
+ read, the closed one included."""
proc = self.run_detected(True)
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
- error = proc.stderr.index("Error: ")
- self.assertGreater(proc.stderr.index("Nothing was installed."), error, proc.stderr)
+ said = " ".join(proc.stderr.split())
+ self.assertIn("2 setups were found here, and a run without questions installs for "
+ "one", said)
+ self.assertIn("--platform emudeck --dest", said)
+ self.assertNotIn("Downloading", proc.stdout)
def test_an_assumed_default_reads_plainly(self):
self.assertIn("used by default, since {display_name(name)} was not found",
diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py
index e836309df..23285cb55 100644
--- a/tests/test_install_offline.py
+++ b/tests/test_install_offline.py
@@ -828,8 +828,8 @@ class OfflineLeavesFilesMissing(Scratch):
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertNotIn("Would download", proc.stdout)
self.assertIn("4 files (", proc.stdout)
- self.assertIn("need the internet, which --offline does not use. Without --offline "
- "they would go to", proc.stdout)
+ self.assertIn("need the internet, which --offline does not use. Run without "
+ "--offline, and they would go to", proc.stdout)
def test_an_archive_not_read_is_only_its_error(self):
listed = manifest()
@@ -967,7 +967,8 @@ class AuditFifteenOffline(Scratch):
# The screen reads that summary as missing files, not failures.
state = install.TuiState()
state.summary = summary
- with unittest.mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
+ with unittest.mock.patch.dict(os.environ,
+ {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}):
title, lines = install.tui_outcome(state)
self.assertEqual(title, "Finished with files missing")
self.assertIn("1 installed, 3 not fetched (--offline).", lines[0])
diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py
index 142ed304a..882b196b0 100644
--- a/tests/test_install_selection.py
+++ b/tests/test_install_selection.py
@@ -612,7 +612,8 @@ class TheOneLinerNarrows(unittest.TestCase):
with FakeRepository(selection=False) as repo:
proc = repo.run("--region", "us")
self.assertEqual(proc.returncode, 1)
- self.assertIn("records no systems or regions", proc.stderr)
+ self.assertIn("records no regions, so --region cannot narrow it", proc.stderr)
+ self.assertIn("older release", proc.stderr)
self.assertEqual(repo.installed(), [])
def test_a_pinned_ref_is_named_as_the_way_out(self):
@@ -675,18 +676,19 @@ class TheOneLinerNarrows(unittest.TestCase):
@unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal")
def test_the_custom_mode_at_a_terminal(self):
- """Typed answers: customise, PlayStation only, no core, US first."""
+ """Typed answers: customise, PlayStation only, US first. Its one
+ core is not asked about (tui_audit_11 P3-10)."""
with FakeRepository() as repo:
command = " ".join(shlex.quote(part) for part in repo.command())
proc = subprocess.run(
["script", "-qec", command, "/dev/null"],
- input="c\n2\n\n2\n\n", env=repo.env(), capture_output=True,
+ input="c\n2\n2\n\n", env=repo.env(), capture_output=True,
text=True, timeout=60, check=False,
)
out = proc.stdout
self.assertEqual(proc.returncode, 0, out + proc.stderr)
self.assertIn("choose systems, cores and regions", out)
- self.assertIn("pcsx_rearmed", out)
+ self.assertNotIn("Cores:", out)
self.assertIn("--system sony-playstation --region north-america", out)
self.assertEqual(repo.installed(), ["pcsx/extra.bin", "scph5501.bin"])
@@ -1019,7 +1021,7 @@ class AHeldFileTheInstallerCannotFetch(unittest.TestCase):
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
Path("platforms").mkdir()
Path("platforms/p.yml").write_text(json.dumps({
- "platform": "P", "verification_mode": "existence", "cores": ["core"],
+ "platform": "P", "version": "2.1", "verification_mode": "existence", "cores": ["core"],
"systems": {"sys": {"files": []}},
}))
Path("platforms/_registry.yml").write_text(
@@ -1094,7 +1096,7 @@ class AStandaloneLayoutBelowTheBiosFolder(unittest.TestCase):
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
Path("platforms").mkdir()
Path("platforms/p.yml").write_text(json.dumps({
- "platform": "P", "verification_mode": "existence",
+ "platform": "P", "version": "2.1", "verification_mode": "existence",
"base_destination": "bios", "cores": ["dsa", "other"],
"standalone_cores": ["dsa"], "systems": {"sys": {"files": []}},
}))
diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py
index 824a0da2f..b53c1bf31 100644
--- a/tests/test_install_tui.py
+++ b/tests/test_install_tui.py
@@ -144,7 +144,9 @@ class KeyDecoding(unittest.TestCase):
for sequence in ("\x1b[25~", "\x1b[1;5A", "\x1b[M !!", "\x1b[<0;10;5M", "\x1b[?1;2c",
"\x1bOZ"):
with self.subTest(sequence=sequence):
- self.assertEqual(self.feed(sequence + "x"), keys("char:x"))
+ # A mouse report is a report (MouseReports), never typed.
+ self.assertEqual([key for key in self.feed(sequence + "x")
+ if key.name != "mouse"], keys("char:x"))
def test_an_incomplete_sequence_is_dropped_at_the_timeout(self):
self.assertEqual(self.feed("\x1b[1"), [])
@@ -951,6 +953,29 @@ def golden_states() -> dict:
return states
+def result_states() -> dict:
+ """The result of a run that installed everything, of one with a file
+ failed, of one --offline left files out of, and of one stopped."""
+ def ended(code: int, **summary) -> install.TuiState:
+ state = checked_state()
+ state.stack.append("result")
+ state.code = code
+ state.download = {"event": "download", "files": 4, "bytes": 1064964}
+ state.summary = {"event": "summary", "mode": "install", "installed": 4,
+ "up_to_date": 1, "pending": 0, "failed": [], "skipped": [],
+ "left_out": [], "kept": [], "interrupted": False, **summary}
+ return state
+
+ return {
+ "done": ended(0),
+ "partial": golden_states()["result"],
+ "offline": ended(1, installed=1, pending=3, failed=[
+ {"dest": name, "reason": install.OFFLINE_WORDS}
+ for name in ("scph5500.bin", "scph5501.bin", "pcsx/extra.bin")]),
+ "stopped": ended(130, installed=2, interrupted=True),
+ }
+
+
class Views(unittest.TestCase):
"""Every screen as text, at the sizes a handheld and a desktop show."""
@@ -970,7 +995,9 @@ class Views(unittest.TestCase):
lines = install.tui_view(state, cols, rows)
self.assertEqual(len(lines), rows)
for line in lines:
- self.assertLessEqual(install.cell_width(line), cols, line)
+ # A path or a command kept whole folds in the terminal.
+ if line.style != "long":
+ self.assertLessEqual(install.cell_width(line), cols, line)
self.assertTrue(all(ord(c) < 128 or c in "ファミコン" for c in line),
line)
self.assertLess(install.cell_width(lines[-1]), cols)
@@ -993,7 +1020,8 @@ class Views(unittest.TestCase):
state.cols, state.rows = cols, rows
lines = install.tui_view(state, cols, rows)
for line in lines:
- self.assertLessEqual(install.cell_width(line), cols, line)
+ if line.style != "long":
+ self.assertLessEqual(install.cell_width(line), cols, line)
self.assertLess(install.cell_width(lines[-1]), cols)
text = "\n".join(line.rstrip() for line in lines) + "\n"
both = (GOLDEN / f"{name}-{cols}x{rows}.txt").read_text(encoding="utf-8")
@@ -1005,12 +1033,84 @@ class Views(unittest.TestCase):
with self.subTest(route=route):
self.assertEqual("".join(sections), path.read_text(encoding="utf-8"))
+ def test_colour_goldens(self):
+ """Every screen as the terminal gets it with colour, the escape
+ codes kept (ESC written ^[), one file per size."""
+ update = os.environ.get("RETROBIOS_UPDATE_GOLDENS") == "1"
+ for cols, rows in SIZES:
+ sections = []
+ for name, state in golden_states().items():
+ state.cols, state.rows = cols, rows
+ painted = [install.tui_paint(str(line), line.style, True)
+ for line in install.tui_view(state, cols, rows)]
+ sections.append(f"=== {name}\n" + "\n".join(painted).replace("\x1b", "^[")
+ + "\n")
+ path = GOLDEN / f"colour-{cols}x{rows}.txt"
+ if update:
+ path.write_text("".join(sections), encoding="utf-8")
+ with self.subTest(size=f"{cols}x{rows}"):
+ self.assertEqual("".join(sections), path.read_text(encoding="utf-8"))
+
+ def test_colour_adds_codes_and_never_text(self):
+ """With colour off a line is drawn as before, its style alone, and
+ with colour on, stripped of its codes, it is the same text: the
+ golden screen, character for character."""
+ for cols, rows in SIZES:
+ colour = (GOLDEN / f"colour-{cols}x{rows}.txt").read_text(encoding="utf-8")
+ screens = dict(re.findall(r"=== (\S+)\n(.*?)(?=\n=== |\n\Z)", colour, re.S))
+ for name, state in golden_states().items():
+ with self.subTest(screen=name, size=f"{cols}x{rows}"):
+ state.cols, state.rows = cols, rows
+ lines = install.tui_view(state, cols, rows)
+ for line in lines:
+ self.assertEqual(install.tui_paint(str(line), line.style, False),
+ install._STYLES.get(line.style, "") + line)
+ stripped = [install.unpainted(painted.replace("^[", "\x1b")).rstrip()
+ for painted in screens[name].split("\n")]
+ golden = (GOLDEN / f"{name}-{cols}x{rows}.txt").read_text(encoding="utf-8")
+ self.assertEqual("\n".join(stripped) + "\n", golden)
+
+ def test_each_role_paints_a_status_word_on_the_result_screens(self):
+ """Done, a part done, files left offline, a stop: between them the
+ result screens use every role, each on a word that says it."""
+ drawn = "\n".join(install.tui_paint(str(line), line.style, True)
+ for state in result_states().values()
+ for line in install.tui_view(state, 80, 24))
+ for role, word in (("accent", "RetroBIOS"), ("strong+success", "4 files installed"),
+ ("strong+success", "Done"), ("failure", "1 failed"),
+ ("failure", "failed:"), ("strong+warning", "3 not fetched"),
+ ("strong+warning", "Stopped"), ("muted", " "),
+ ("strong", "/home/user/.config/retroarch/system")):
+ with self.subTest(role=role, word=word):
+ self.assertIn(install._sgr(role) + word, drawn)
+ # "2 of 4 installed" is no count of files installed.
+ self.assertNotIn(install._sgr("strong+success") + "4 installed", drawn)
+
+ def test_the_status_words_take_their_role_and_a_zero_none(self):
+ spans = install.status_spans("1 in place, 0 missing, 3 missing, 1 wrong copy, 2 failed")
+ self.assertEqual([("1 in place", "success"), ("3 missing", "warning"),
+ ("1 wrong copy", "warning"), ("2 failed", "failure")],
+ [("1 in place, 0 missing, 3 missing, 1 wrong copy, 2 failed"[a:b], r)
+ for a, b, r in spans])
+ self.assertEqual(install.status_spans("0 wrong copies, 0 missing"), [])
+ self.assertEqual(install.status_spans("python3 install.py --platform retroarch"), [])
+
+ def test_the_bar_paints_a_key_only(self):
+ """The first entry's key in the accent, never a word: "Writing to the
+ card..." and "Any key closes this help" name no key."""
+ for text in (" Writing to the card...", " Any key closes this help"):
+ self.assertEqual(install.tui_paint(text, "hint", True),
+ install._sgr("muted") + text + "\x1b[0m")
+ painted = install.tui_paint(" A/Enter Exit B/Esc Back", "hint", True)
+ self.assertTrue(painted.startswith(install._sgr("muted") + " "
+ + install._sgr("accent") + "A/Enter"), painted)
+
def test_the_hint_bar_names_the_primary_action_first(self):
for name, state in golden_states().items():
with self.subTest(screen=name):
hint = install.tui_view(state, 40, 15)[-1].strip()
self.assertTrue(hint.startswith(("A/Enter", "A ", "Enter", "Ctrl-C", "Any key",
- "B/Esc Stop")), hint)
+ "B/Esc Stop", "B Stop")), hint)
def test_focus_is_marked_without_colour(self):
lines = install.tui_view(fresh_state(), 80, 24)
@@ -1027,12 +1127,12 @@ def _bar_keys(line: str) -> set:
# The keys each route has, as the bar may name them. "Any" opens "Any key
# closes this help", <> and ^v are the narrow forms of Left/Right, Up/Down.
ROUTE_KEYS = {
- "keys": {"Enter", "Esc", "Space", "o", "?", "/", "Ctrl-C", "Ctrl-U"},
- "gptokeyb": {"A", "B", "X", "Y", "Select+Start"},
- "gptokeyb_muxterm": {"A", "B", "X", "Y", "Select+Start"},
- "evmapy": {"A", "B", "X", "Y"},
- "muxterm": {"Start", "B", "Y", "Menu", "Select"},
- "miyoo": {"A", "Start", "R2", "L2"},
+ "keys": {"Enter", "Esc", "Space", "o", "?", "/", "Ctrl-C", "Ctrl-U", "PgUp/PgDn"},
+ "gptokeyb": {"A", "B", "X", "Y", "Select+Start", "L1/R1"},
+ "gptokeyb_muxterm": {"A", "B", "X", "Y", "Select+Start", "D-pad"},
+ "evmapy": {"A", "B", "X", "Y", "L1/R1"},
+ "muxterm": {"Start", "B", "Y", "Menu", "Select", "D-pad"},
+ "miyoo": {"A", "Start", "R2", "L2", "L1/R1"},
}
EVERY_ROUTE_HAS = {"Left/Right", "Up/Down", "<>", "^v", "Any"}
@@ -1625,7 +1725,11 @@ class AuditRoundTwo(unittest.TestCase):
kind, plat, title, lines, code = inbox.get_nowait()
self.assertEqual((kind, title, code), ("manifest_failed", "No connection", 1))
self.assertIn("could not be downloaded: the server refused the connection", lines[0])
- self.assertNotIn("--", " ".join(lines))
+ # The pack is the one option named: the way of a handheld with no
+ # Wi-Fi (tui_audit_11 P3-3).
+ self.assertIn("Or copy a pack to the card and run the installer with --from-pack",
+ " ".join(lines))
+ self.assertNotIn("--", " ".join(lines).replace("--from-pack", ""))
state = fresh_state()
with mock.patch.object(install.Path, "exists", return_value=True):
press(state, "enter")
@@ -1763,9 +1867,9 @@ class AuditRoundTwo(unittest.TestCase):
real = install._text_lines
calls = []
- def counted(items, width):
+ def counted(items, width, whole=False):
calls.append(len(items))
- return real(items, width)
+ return real(items, width, whole)
with mock.patch.object(install, "_text_lines", counted):
press(state, "end")
@@ -1833,6 +1937,9 @@ class WorkFolders(unittest.TestCase):
def draw(self, lines, cols) -> None:
pass
+ def set_mouse(self, on: bool) -> None:
+ pass
+
def keys(self, timeout):
WorkFolders.Terminal.state_to(self)
return [Key("enter")]
@@ -3438,11 +3545,14 @@ class ThePagerCountsLines(unittest.TestCase):
with self.subTest(size=f"{cols}x{rows}"):
state = self.result_state(cols, rows)
width = min(cols, install.TUI_MAX_WIDTH) - 2
- wrapped = install._text_lines(install._pager_items(state), width)
+ # The result keeps a path whole (whole=True): its rows after
+ # it are left to the terminal's folding ("cont").
+ wrapped = install._text_lines(install._pager_items(state), width, True)
+ last = [line for line in wrapped if line.style != "cont"][-1]
press(state, "end")
screen = install.tui_view(state, cols, rows)
body = [line.rstrip() for line in screen[2:-1]]
- self.assertIn(" " + str(wrapped[-1]).rstrip(), body)
+ self.assertIn(" " + str(last).rstrip(), body)
if len(wrapped) > rows - 3:
self.assertTrue(any(re.fullmatch(rf" \d+-{len(wrapped)} of {len(wrapped)}",
line) for line in body), body)
@@ -3462,10 +3572,26 @@ class OfflineInTheInterface(unittest.TestCase):
forbids."""
def setUp(self):
- patcher = mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"})
+ # The value --offline gives the variable.
+ patcher = mock.patch.dict(os.environ,
+ {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE})
patcher.start()
self.addCleanup(patcher.stop)
+ def test_the_variable_is_named_when_it_made_the_run_offline(self):
+ """tui_audit_11 P3-12: "(--offline)" and "without --offline" with
+ RETROBIOS_INSTALL_OFFLINE=1 and no --offline: nothing to remove."""
+ with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
+ inbox = queue.Queue()
+ install._tui_fetch("retroarch", inbox, io.StringIO())
+ text = " ".join(inbox.get_nowait()[3])
+ self.assertIn("(RETROBIOS_INSTALL_OFFLINE=1)", text)
+ self.assertIn("with RETROBIOS_INSTALL_OFFLINE unset", text)
+ self.assertNotIn("--offline", text)
+ plan = " ".join(" ".join(install.tui_view(checked_state(), 120, 40)).split())
+ self.assertIn("which RETROBIOS_INSTALL_OFFLINE=1 does not use", plan)
+ self.assertNotIn("without --offline", plan)
+
def test_no_list_names_the_ways_that_work(self):
inbox = queue.Queue()
install._tui_fetch("retroarch", inbox, io.StringIO())
@@ -3581,8 +3707,9 @@ class AuditFive(unittest.TestCase):
state = checked_state()
saves = {"files": 1, "folder": str(HOME / ".config" / "retroarch" / "saves" / "")}
lines = install._saves_lines(state, saves)
- self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to:",
- "~/.config/retroarch/saves/"])
+ # In words: "saves/" read as a folder named .
+ self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to a "
+ "folder per core under:", "~/.config/retroarch/saves"])
def test_the_shared_note_is_one_line(self):
state = self.long_list()
@@ -3641,7 +3768,8 @@ class LineModeOffline(unittest.TestCase):
term.wait("q) quit")
term.send("\r")
code = term.close()
- out = term.raw.decode("utf-8", "replace")
+ # Read as words: a terminal gets the status words painted.
+ out = install.unpainted(term.raw.decode("utf-8", "replace"))
self.assertIn("--offline: only the files found on this machine are installed, and no "
"--from names a folder to copy them from.", out)
# tui_audit_7 P3-4: nothing to copy from, nothing to install.
@@ -3768,6 +3896,9 @@ class Crash(unittest.TestCase):
def draw(self, lines, cols) -> None:
pass
+ def set_mouse(self, on: bool) -> None:
+ pass
+
def keys(self, timeout):
raise RuntimeError("a defect")
@@ -3798,7 +3929,8 @@ class TheSaveFolderLineFollowsTheChoice(unittest.TestCase):
SAVES = {"event": "saves", "platform": "retroarch", "files": 1,
"folder": "/home/user/MySaves/"}
- LINE = "1 file read from RetroArch's save directory goes to: ~/MySaves/"
+ LINE = ("1 file read from RetroArch's save directory goes to a folder per core under: "
+ "~/MySaves")
def said(self, lines) -> str:
return " ".join(" ".join(lines).split())
@@ -4608,11 +4740,11 @@ class EndToEnd(unittest.TestCase):
self.assertIn("6 files to download", screen)
# The second folder the run writes to, named before
# anything is written, and again on the result.
- self.assertIn("1 file read from RetroArch's save directory goes to:\n"
- " ~/MySaves", screen)
+ self.assertIn("1 file read from RetroArch's save directory goes to a "
+ "folder per core under:\n ~/MySaves", screen)
term.send("\r")
screen = term.wait("6 files installed.")
- self.assertIn("save directory goes to:\n ~/MySaves", screen)
+ self.assertIn("folder per core under:\n ~/MySaves", screen)
term.send("\r")
self.assertEqual(term.close(), 0)
results.append(written(repo, saves))
@@ -4960,52 +5092,77 @@ class EndToEnd(unittest.TestCase):
def test_no_color_draws_no_colour(self):
"""The screen marks the row in focus with > as well as reverse video,
- and draws no colour at all: NO_COLOR has nothing left to remove."""
+ so NO_COLOR, or a terminal of fewer than 8 colours, takes the colour
+ away and leaves the same screen."""
self.repo()
- self.env["NO_COLOR"] = "1"
- term = self.start()
- screen = term.wait("Where do you play?")
- self.assertIn("> RetroArch", screen)
- term.send("q")
- self.assertEqual(term.close(), 130)
- for params in re.findall(rb"\x1b\[([0-9;]*)m", term.raw):
- codes = {int(code) for code in params.split(b";") if code}
- self.assertFalse(codes & {*range(30, 50), *range(90, 108)}, params)
+ screens = {}
+ for name, env in (("colour", {}), ("no_color", {"NO_COLOR": "1"}),
+ ("mono", {"TERM": "rbtest-mono",
+ "TERMINFO": str(_terminfo_folder(self))})):
+ with self.subTest(env=name):
+ self.env.pop("NO_COLOR", None)
+ self.env.update({"TERM": "xterm", **env})
+ term = self.start()
+ screen = term.wait("Where do you play?")
+ self.assertIn("> RetroArch", screen)
+ screens[name] = screen
+ # pyte reads the colours drawn: RetroBIOS in the accent.
+ title = term.screen.buffer[0]
+ at = screen.splitlines()[0].index("RetroBIOS")
+ self.assertEqual(title[at].fg, "magenta" if name == "colour" else "default")
+ term.send("q")
+ self.assertEqual(term.close(), 130)
+ colours = set()
+ for params in re.findall(rb"\x1b\[([0-9;]*)m", term.raw):
+ colours |= {int(code) for code in params.split(b";") if code} & {
+ *range(30, 50), *range(90, 108)}
+ self.assertEqual(bool(colours), name == "colour", colours)
+ self.assertEqual(screens["colour"], screens["no_color"])
+ self.assertEqual(screens["colour"], screens["mono"])
-def _terminfo_entry(name: str, cup: str | None, extended: bool = False) -> bytes:
+def _terminfo_entry(name: str, cup: str | None, extended: bool = False,
+ colours: int | None = 8) -> bytes:
"""A compiled term(5) entry holding the strings up to cursor_address,
- only that one set."""
+ only that one set, and the numbers up to max_colors when colours is
+ given, columns alone when it is not."""
def short(value: int) -> bytes:
return value.to_bytes(2, "little", signed=True)
+ width = 4 if extended else 2
names = name.encode() + b"|test terminal\0"
table = (cup.encode("latin-1") + b"\0") if cup is not None else b""
offsets = [-1] * (install._TERMINFO_CUP + 1)
if cup is not None:
offsets[install._TERMINFO_CUP] = 0
+ numbers = [80] + ([-1] * (install._TERMINFO_COLORS - 1) + [colours]
+ if colours is not None else [])
body = names + (b"\0" if len(names) % 2 else b"")
- # One number, columns, in the width of the format.
- body += (80).to_bytes(4 if extended else 2, "little")
+ body += b"".join(number.to_bytes(width, "little", signed=True) for number in numbers)
body += b"".join(short(offset) for offset in offsets) + table
header = b"".join(short(value) for value in (
- 0o1036 if extended else 0o432, len(names), 0, 1, len(offsets), len(table)))
+ 0o1036 if extended else 0o432, len(names), 0, len(numbers), len(offsets),
+ len(table)))
return header + body
def _terminfo_folder(test: unittest.TestCase) -> Path:
- """A $TERMINFO with a glass terminal, a VT52 and two ANSI terminals,
- one in the extended format."""
+ """A $TERMINFO with a glass terminal, a VT52, two ANSI terminals of 8
+ colours, one in the extended format, and two ANSI terminals without
+ colour: one naming 2, one naming none (vt100)."""
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
folder = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
test.addCleanup(shutil.rmtree, folder, True)
- for name, cup, extended in (
- ("rbtest-glass", None, False),
- ("rbtest-vt52", "\x1bY%p1%' '%+%c%p2%' '%+%c", False),
- ("rbtest-ansi", "\x1b[%i%p1%d;%p2%dH", False),
- ("rbtest-ansi2", "\x1b[%i%p1%d;%p2%dH", True)):
+ ansi = "\x1b[%i%p1%d;%p2%dH"
+ for name, cup, extended, colours in (
+ ("rbtest-glass", None, False, None),
+ ("rbtest-vt52", "\x1bY%p1%' '%+%c%p2%' '%+%c", False, None),
+ ("rbtest-ansi", ansi, False, 8),
+ ("rbtest-ansi2", ansi, True, 256),
+ ("rbtest-two", ansi, True, 2),
+ ("rbtest-mono", ansi, False, None)):
(folder / "r").mkdir(exist_ok=True)
- (folder / "r" / name).write_bytes(_terminfo_entry(name, cup, extended))
+ (folder / "r" / name).write_bytes(_terminfo_entry(name, cup, extended, colours))
return folder
@@ -5074,6 +5231,55 @@ class TerminalsTheScreenCannotDraw(unittest.TestCase):
self.assertEqual((shown.live, shown.colour), (live, live))
self.assertEqual(shown.ok("ok") == "ok", not live)
+ def test_the_colours_are_read(self):
+ self.assertEqual(install.terminal_colours("rbtest-ansi", self.env), 8)
+ self.assertEqual(install.terminal_colours("rbtest-ansi2", self.env), 256)
+ self.assertEqual(install.terminal_colours("rbtest-two", self.env), 2)
+ self.assertEqual(install.terminal_colours("rbtest-mono", self.env), 0)
+ self.assertIsNone(install.terminal_colours("rbtest-none", self.env))
+ for data in (b"", b"\x00" * 12, _terminfo_entry("x", None, colours=8)[:40]):
+ with self.subTest(data=data):
+ self.assertIsNone(install.terminfo_number(data, install._TERMINFO_COLORS))
+
+ def test_fewer_than_8_colours_draw_the_line_live_and_plain(self):
+ """A vt100 moves the cursor the ANSI way and has no colour: the
+ progress line still redraws, its words unpainted."""
+ for term, colour in (("rbtest-ansi", True), ("rbtest-ansi2", True),
+ ("rbtest-two", False), ("rbtest-mono", False),
+ ("rbtest-none", True)):
+ with self.subTest(term=term), mock.patch.dict(
+ os.environ, {**self.env, "TERM": term}), \
+ mock.patch.object(install, "_enable_ansi", return_value=True):
+ os.environ.pop("NO_COLOR", None)
+ shown = install.Terminal(self.tty)
+ self.assertEqual((shown.live, shown.colour), (True, colour))
+ self.assertEqual(shown.status("1 failed") != "1 failed", colour)
+
+ def test_the_screen_draws_without_colour_for_each_reason(self):
+ """NO_COLOR, TERM dumb or unset, fewer than 8 colours, an output no
+ terminal: each turns the colour off, and says which."""
+ for env, said in (({"TERM": "rbtest-ansi"}, ""),
+ ({"TERM": "rbtest-none"}, ""),
+ ({"TERM": "rbtest-ansi", "NO_COLOR": "1"}, "NO_COLOR is set"),
+ ({"TERM": "rbtest-ansi", "NO_COLOR": ""}, ""),
+ ({"TERM": "dumb"}, "TERM is dumb or not set"),
+ ({}, "TERM is dumb or not set"),
+ ({"TERM": "rbtest-two"},
+ "TERM=rbtest-two names 2 colours, fewer than 8"),
+ ({"TERM": "rbtest-mono"},
+ "TERM=rbtest-mono names 0 colours, fewer than 8")):
+ with self.subTest(env=env):
+ self.assertEqual(install.colour_blocker({**self.env, **env}, self.tty), said)
+ self.assertEqual(install.colour_blocker({**self.env, "TERM": "rbtest-ansi"},
+ io.StringIO()),
+ "the output is not a terminal")
+ with mock.patch.dict(os.environ, {**self.env, "TERM": "rbtest-mono"}), \
+ mock.patch.object(install, "_enable_ansi", return_value=True):
+ self.assertFalse(install._TuiTerminal(self.tty, self.tty).colour)
+ os.environ["TERM"] = "rbtest-ansi"
+ os.environ.pop("NO_COLOR", None)
+ self.assertTrue(install._TuiTerminal(self.tty, self.tty).colour)
+
def test_an_unset_term_is_a_dumb_one(self):
with mock.patch.dict(os.environ, {"FORCE_COLOR": ""}), \
mock.patch.object(install, "_enable_ansi", return_value=True):
@@ -5113,6 +5319,12 @@ class TheTerminalComesBack(unittest.TestCase):
raw = term.raw[since:]
self.assertGreater(raw.rfind(b"\x1b[?1049l"), raw.rfind(b"\x1b[?1049h"), raw[-200:])
self.assertGreater(raw.rfind(b"\x1b[?25h"), raw.rfind(b"\x1b[?25l"), raw[-200:])
+ # Clicks are given back too: a shell left with them on reads each
+ # one as a line of escape bytes.
+ for mode in (b"1000", b"1006"):
+ self.assertGreater(raw.rfind(b"\x1b[?" + mode + b"l"),
+ raw.rfind(b"\x1b[?" + mode + b"h"), raw[-200:])
+ self.assertNotIn(b"\x1b[?1003h", raw)
def assertModesBack(self, modes: list) -> None:
fresh = self.fresh_modes()
@@ -5125,6 +5337,10 @@ class TheTerminalComesBack(unittest.TestCase):
self.repo()
term = self.start()
term.wait("Where do you play?")
+ # Every way out below starts with the clicks asked for, written
+ # right after the frame.
+ term.settle()
+ self.assertIn(install._MOUSE_ON.encode(), term.raw)
how(term)
return term
@@ -5159,35 +5375,85 @@ class TheTerminalComesBack(unittest.TestCase):
self.assertGivenBack(term)
self.assertModesBack(term.modes)
+ def shell(self) -> Pty:
+ """An interactive bash on a pseudo-terminal, a shell with job control
+ as a person's terminal runs one."""
+ self.repo()
+ env = {**self.env, "PS1": "PROMPT$ ",
+ "PATH": f"{Path(sys.executable).parent}{os.pathsep}{self.env.get('PATH', '')}"}
+ term = Pty(["--norc", "--noprofile", "-i"], env, program=["/bin/bash"])
+ self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9))
+ term.wait("PROMPT$")
+ return term
+
+ def typed(self, term: Pty, line: str) -> None:
+ os.write(term.fd, line.encode() + b"\r")
+
+ @unittest.skipUnless(Path("/bin/bash").exists(), "a shell with job control")
def test_ctrl_z_then_fg(self):
"""ISIG is off on the screen, so Ctrl-Z reached it as a byte and did
- nothing: the shell never got its terminal back."""
- import signal
-
- term = self.ended(lambda term: None)
+ nothing: the shell never got its terminal back. From a shell with
+ job control it stops, and fg brings the screen back."""
+ term = self.shell()
+ self.typed(term, f"{shlex.quote(sys.executable)} {REPO_ROOT / 'install.py'} --tui")
+ term.wait("Where do you play?")
mark = len(term.raw)
term.send("\x1a")
- end, stopped = time.monotonic() + 10, False
- while time.monotonic() < end and not stopped:
+ end = time.monotonic() + 10
+ while b"PROMPT$" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
- pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED)
- stopped = bool(pid) and os.WIFSTOPPED(status)
- self.assertTrue(stopped, term.text())
+ self.assertIn(b"Stopped", term.raw[mark:], term.raw[mark:][-300:])
self.assertGivenBack(term, mark)
- self.assertModesBack(termios.tcgetattr(term.fd))
# fg: the shell's SIGCONT, and the screen is back, raw again.
mark = len(term.raw)
- os.kill(term.pid, signal.SIGCONT)
+ self.typed(term, "fg")
end = time.monotonic() + 5
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
self.assertIn(b"\x1b[?1049h", term.raw[mark:])
term.wait("Where do you play?")
self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO)
+ # The clicks come back with the screen.
+ term.settle()
+ self.assertIn(install._MOUSE_ON.encode(), term.raw[mark:])
term.send("q")
+ term.wait("PROMPT$")
+ self.typed(term, "exit")
self.assertEqual(term.close(), 130)
self.assertGivenBack(term)
- self.assertModesBack(term.modes)
+
+ def test_ctrl_z_with_no_shell_to_resume_it_is_ignored(self):
+ """code_audit_18 P3-1: started straight on a terminal (a terminal
+ app's -e, a desktop entry) or under a launcher's sh -c, nothing ever
+ resumed the screen a Ctrl-Z stopped. It now goes on, and says why."""
+ self.repo()
+ for program in (None, ["/bin/sh", "-c", f"{shlex.quote(sys.executable)} "
+ f"{REPO_ROOT / 'install.py'} \"$@\"", "sh"]):
+ with self.subTest(program=program):
+ term = Pty(["--tui"], self.env, program=program)
+ self.addCleanup(lambda term=term: term.status is None
+ and os.killpg(term.pid, 9))
+ term.wait("Where do you play?")
+ term.send("\x1a")
+ screen = term.wait("Ctrl-Z is ignored")
+ self.assertIn("no shell started this installer", " ".join(screen.split()))
+ pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED)
+ self.assertEqual(pid, 0, "stopped")
+ term.send("q")
+ self.assertEqual(term.close(), 130)
+ self.assertGivenBack(term)
+
+ def test_ctrl_z_needs_a_shell_that_can_resume(self):
+ state = install.TuiState(os_type="linux", home=HOME)
+ with mock.patch.object(install.os, "getpgrp", return_value=10), \
+ mock.patch.object(install.os, "getsid", return_value=10):
+ self.assertFalse(install.tui_ctrl_z(state))
+ self.assertEqual(state.status, install.CTRL_Z_NO_SHELL)
+ state.status = ""
+ with mock.patch.object(install.os, "getpgrp", return_value=12), \
+ mock.patch.object(install.os, "getsid", return_value=10):
+ self.assertTrue(install.tui_ctrl_z(state))
+ self.assertEqual(state.status, "")
@staticmethod
def proc_state(pid: int) -> str:
@@ -5214,30 +5480,35 @@ class TheTerminalComesBack(unittest.TestCase):
"""code_audit_17 P2-1: Ctrl-Z stopped the installer alone. install.sh,
which runs it as a child, kept waiting, the shell never got its prompt
back, and the install run behind the screen kept writing."""
- import signal
-
repo = self.repo(GameDataRepository, slow=True)
- self.env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{self.env['PATH']}"
- term = Pty(["--jobs", "1"], self.env, program=["/bin/sh", str(REPO_ROOT / "install.sh")])
+ env = {**self.env, "PS1": "PROMPT$ ",
+ "PATH": f"{Path(sys.executable).parent}{os.pathsep}{self.env['PATH']}"}
+ # Typed at an interactive bash, as a person runs it: install.sh is a
+ # job of a shell that can resume it (code_audit_18 P3-1).
+ term = Pty(["--norc", "--noprofile", "-i"], env, program=["/bin/bash"])
self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9))
+ term.wait("PROMPT$")
+ self.typed(term, f"sh {REPO_ROOT / 'install.sh'} --jobs 1")
term.wait("Where do you play?")
term.send("\r")
term.wait("to download")
term.send("\r")
term.wait("Installing")
time.sleep(0.8)
- installer = self.children(term.pid)
+ wrapper = self.children(term.pid)
+ self.assertEqual(len(wrapper), 1, wrapper)
+ installer = self.children(wrapper[0])
self.assertEqual(len(installer), 1, installer)
runs = self.children(installer[0])
self.assertTrue(runs, "no install run")
+ mark = len(term.raw)
term.send("\x1a")
- end, stopped = time.monotonic() + 10, False
- while time.monotonic() < end and not stopped:
+ end = time.monotonic() + 10
+ while b"PROMPT$" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
- pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED)
- stopped = bool(pid) and os.WIFSTOPPED(status)
- # The wrapper is stopped: a shell's job of it is reported stopped.
- self.assertTrue(stopped, term.text())
+ # The wrapper is stopped: the shell reports its job stopped.
+ self.assertIn(b"Stopped", term.raw[mark:], term.raw[mark:][-300:])
+ self.assertEqual(self.proc_state(wrapper[0]), "T")
self.assertEqual(self.proc_state(installer[0]), "T")
self.assertEqual([self.proc_state(run) for run in runs], ["T"] * len(runs))
written = sorted(repo.dest.rglob("*"))
@@ -5246,13 +5517,15 @@ class TheTerminalComesBack(unittest.TestCase):
# fg continues the job's group: the screen comes back and the install
# carries on to its end.
mark = len(term.raw)
- os.killpg(term.pid, signal.SIGCONT)
+ self.typed(term, "fg")
end = time.monotonic() + 5
while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end:
term._pump(0.05)
self.assertIn(b"\x1b[?1049h", term.raw[mark:])
term.wait("files installed", timeout=30)
term.send("\r")
+ term.wait("PROMPT$")
+ self.typed(term, "exit")
self.assertEqual(term.close(), 0)
@@ -5347,7 +5620,11 @@ class GameDataIsNoFirmwareAlarm(unittest.TestCase):
self.assertEqual(len(lines), 2, lines)
self.assertIn("needed to start its emulator: missing.bin", lines[0])
self.assertNotIn("Anims.j2a", lines[0])
- self.assertIn("Game data not available here", lines[1])
+ # The folder it goes in, and what game data is (the Android run of
+ # 2026-10-27 said neither).
+ self.assertIn("Not available here, game data: files a game or a game engine reads",
+ lines[1])
+ self.assertIn(f"into {SYSTEM_DIR / 'jazz2' / 'Source'}{os.sep}", lines[1])
self.assertIn("Anims.j2a (Jazz Jackrabbit 2)", lines[1])
@@ -5719,7 +5996,8 @@ class AuditSix(unittest.TestCase):
self.assertNotIn("before Install", text)
self.assertNotIn("backup folder", text)
self.assertIn("Copy them from your old card", text)
- self.assertEqual(view[-1].strip(), "A/Enter Back ? Help")
+ # The short names at 40 columns, as every screen of that width.
+ self.assertEqual(view[-1].strip(), "A Back ? Help")
def test_a_refusal_shows_at_40x12_on_type_a_path(self):
"""P2-2: Enter seemed to do nothing, the refusal drawn below the
@@ -5894,7 +6172,7 @@ class AuditSix(unittest.TestCase):
state.found = [{"event": "found", "platform": "retroarch", "dest": FILES[0]["dest"],
"folder": "/media/old", "path": "/media/old/x", "member": "",
"reason": ""}]
- with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
+ with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}):
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
self.assertNotIn("downloaded", text)
state.found = []
@@ -6533,7 +6811,375 @@ def screen_plan(args: argparse.Namespace, manifest: dict, events: list[dict],
return state, " ".join(" ".join(install.tui_view(state, 120, 40)).split())
-class LineModeAndScreenDecideAlike(unittest.TestCase):
+class _CardFixtures:
+ """Drives, lists and packs of code_audit_18 and tui_audit_11, for the
+ classes that test them on the screen and in line mode."""
+
+ ABOUT = "system/configs/emulationstation/about.info"
+
+ def drive(self, name: str, files: dict) -> Path:
+ root = self.root / "media" / name
+ root.mkdir(parents=True)
+ for path, text in files.items():
+ (root / path).parent.mkdir(parents=True, exist_ok=True)
+ (root / path).write_text(text, encoding="utf-8")
+ return root
+
+ def renamed(self) -> dict:
+ """The drives code_audit_18 P1-1 names: KNULLI and Batocera each
+ renamed in its own menu, with and without the file KNULLI alone
+ writes, and Recalbox's share."""
+ conf = "system/batocera.conf"
+ return {
+ "KNULLI renamed": self.drive("K1", {self.ABOUT: "KNULLI\n",
+ conf: "system.hostname=RG40XXH\n"}),
+ "KNULLI as shipped": self.drive("K2", {self.ABOUT: "KNULLI\n",
+ conf: "system.hostname=KNULLI\n"}),
+ "renamed, no about.info": self.drive("K3", {conf: "system.hostname=RG40XXH\n"}),
+ "Batocera as shipped": self.drive("B1", {conf: "system.hostname=BATOCERA\n"}),
+ "Batocera, no hostname": self.drive("B2", {conf: "# system.hostname=X\n"}),
+ "Recalbox": self.drive("R1", {"system/recalbox.conf": "a=1\n"}),
+ "two shares": self.drive("T1", {conf: "system.hostname=BATOCERA\n",
+ "system/recalbox.conf": ""}),
+ }
+
+ def minui(self, plat: str = "minui", systems: bool = False, cores: bool = True,
+ regions: bool = True) -> tuple[Path, Path, dict]:
+ """A list of plat's shape (the MinUI family records cores and regions,
+ no system), the pack built from it and the files' payloads."""
+ from test_install_offline import GBA, JP, US # noqa: PLC0415
+
+ payloads = {"Bios/MGBA/gba_bios.bin": (GBA, "nintendo-gba", "mgba", None),
+ "Bios/PS/scph5501.bin": (US, "sony-playstation", "pcsx_rearmed",
+ "north-america"),
+ "Bios/PS/scph5500.bin": (JP, "sony-playstation", "pcsx_rearmed", "japan")}
+ files = []
+ for dest, (data, system, core, region) in payloads.items():
+ entry = {"dest": dest, "sha1": hashlib.sha1(data).hexdigest(), "size": len(data),
+ "repo_path": f"bios/{dest}", "cores": [core] if cores else None}
+ if systems:
+ entry["systems"] = [system]
+ if regions and region:
+ entry.update(regions=[region], region_groups=["ps"])
+ files.append(entry)
+ listing = self.root / f"{plat}.json"
+ install_list = {"manifest_version": 2, "platform": plat, "files": files}
+ listing.write_text(json.dumps(install_list))
+ if sys.version_info < (3, 9):
+ # generate_pack.py's context managers need Python 3.9.
+ return listing, None, {}
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
+ self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts"))
+ import generate_pack # noqa: PLC0415
+
+ pack_list = {"files": [{"path": dest, "sha1": entry["sha1"], "size": entry["size"]}
+ for dest, entry in zip(payloads, files)]}
+ generate_pack.annotate_pack_systems(pack_list, generate_pack.pack_systems(install_list))
+ name = {"minui": "MinUI", "misterfpga": "MiSTer_FPGA"}[plat]
+ pack = self.root / f"{name}_v1_BIOS_Pack.zip"
+ with zipfile.ZipFile(pack, "w") as archive:
+ for dest, (data, *_rest) in payloads.items():
+ archive.writestr(dest, data)
+ archive.writestr("manifest.json", json.dumps(pack_list))
+ return listing, pack, pack_list
+
+ def narrowed(self, plat: str, **given) -> tuple[int, str, list]:
+ """(exit code, what was said, the files kept) of a --dry-run narrowed
+ as given."""
+ dest = self.root / "dest"
+ if not dest.exists():
+ make_card(dest, "MinUI")
+ stream = io.StringIO()
+ code = 0
+ with mock.patch.object(install.EVENTS, "stream", stream), \
+ mock.patch.object(install, "detect_os", return_value="linux"), \
+ mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \
+ contextlib.redirect_stdout(io.StringIO()) as out, \
+ contextlib.redirect_stderr(io.StringIO()) as err:
+ try:
+ install._install(_args(platform=plat, dest=dest, dry_run=True, events=True,
+ no_input=True, declared_only=False, **given),
+ io.StringIO())
+ except SystemExit as stop:
+ code = stop.code
+ finally:
+ install.LOCAL.reset()
+ kept = [json.loads(line)["dest"] for line in stream.getvalue().splitlines()
+ if '"event": "file"' in line]
+ return code, out.getvalue() + err.getvalue(), kept
+
+
+
+class _LineModeFindings(_CardFixtures):
+ """The line-mode findings of code_audit_18 and tui_audit_11, run by
+ LineModeAndScreenDecideAlike."""
+
+ def test_a_drive_two_systems_fit_is_taken_as_named_and_said(self):
+ """code_audit_18 P1-1: --platform knulli is never refused on a
+ drive whose only evidence is a batocera.conf, --platform batocera
+ takes its bios folder, and the doubt is said. Recalbox refuses it
+ with words for a SHARE drive (P3-4)."""
+ drives = self.renamed()
+ for name in ("renamed, no about.info", "Batocera as shipped"):
+ drive = drives[name]
+ with self.subTest(drive=name):
+ choice = install.card_choice("knulli", "knulli", drive)
+ self.assertEqual((choice.plat, choice.folder, choice.refused),
+ ("knulli", drive / "bios", ""))
+ self.assertIn("Taken as KNULLI's, as named", choice.note)
+ drive = drives["renamed, no about.info"]
+ choice = install.card_choice("batocera", "batocera", drive)
+ self.assertEqual((choice.plat, choice.folder), ("batocera", drive / "bios"))
+ self.assertIn("names the host RG40XXH", choice.note)
+ refused = install.card_choice("recalbox", "recalbox", drive)
+ self.assertIn("could be a KNULLI card or a Batocera SHARE drive", refused.refused)
+ words = install._dest_refusal_words(refused, None)
+ self.assertIn("Connect the Recalbox SHARE drive", words)
+ self.assertNotIn("Insert", words)
+ other = install.card_choice("recalbox", "recalbox", drives["Batocera as shipped"])
+ words = install._dest_refusal_words(other, None)
+ self.assertIn("Connect the Recalbox SHARE drive, or name that drive's own system: "
+ "--platform batocera", words)
+
+ def test_the_first_screen_and_line_mode_say_the_doubt(self):
+ """code_audit_18 P1-1: the screen's first row was "Batocera SHARE
+ drive (KNULLI2)". A renamed card with about.info is KNULLI's, one
+ without is listed by neither and said."""
+ drives = self.renamed()
+ knulli, doubt = drives["KNULLI renamed"], drives["renamed, no about.info"]
+ with mock.patch.object(install, "_mounted_cards", return_value=[knulli, doubt]), \
+ mock.patch.object(install, "detect_platforms", return_value=[]), \
+ contextlib.redirect_stdout(io.StringIO()) as out:
+ setups, _mounted = install.found_setups("linux")
+ self.assertEqual([(plat, path) for plat, path, _label in setups],
+ [("knulli", knulli / "bios")])
+ self.assertIn("KNULLI SD card", setups[0][2])
+ text = out.getvalue()
+ self.assertIn(f"{doubt} could be a KNULLI card or a Batocera SHARE drive", text)
+ self.assertIn(f"--platform knulli --dest {doubt} or --platform batocera --dest {doubt}",
+ text)
+ with mock.patch.object(install, "_mounted_cards", return_value=[knulli, doubt]), \
+ mock.patch.object(install, "detect_platforms", return_value=[]), \
+ mock.patch.object(install, "detect_frontends", return_value=[]), \
+ contextlib.redirect_stdout(io.StringIO()):
+ state, _effects = install.tui_start(_args(), "linux")
+ self.assertTrue(any("could be a KNULLI card" in note for note in state.notes))
+ self.assertEqual([setup[0] for setup in state.setups], ["knulli"])
+
+ def test_a_list_without_systems_narrows_by_what_it_records(self):
+ """tui_audit_11 P2-1: install/minui.json records cores and regions,
+ no system, and all three options were refused with a remedy that
+ fetched the same list."""
+ listing, _pack, _listed = self.minui()
+ code, text, kept = self.narrowed("minui", manifest=listing, core=["mgba"])
+ self.assertEqual(code, 0, text)
+ self.assertEqual(kept, ["Bios/MGBA/gba_bios.bin"])
+ code, text, kept = self.narrowed("minui", manifest=listing, region=["us"])
+ self.assertEqual(code, 0, text)
+ self.assertNotIn("Bios/PS/scph5500.bin", kept)
+ code, text, _kept = self.narrowed("minui", manifest=listing,
+ system=["nintendo-gba"])
+ self.assertEqual(code, 1)
+ self.assertIn("records no systems, so --system cannot narrow it", text)
+ self.assertIn("Run it without --system, or narrow with --core and --region", text)
+ self.assertNotIn("one-line installer", text)
+ # The remedy followed: what it names works.
+ code, text, _kept = self.narrowed("minui", manifest=listing, core=["mgba"],
+ region=["us"])
+ self.assertEqual(code, 0, text)
+
+ @unittest.skipIf(sys.version_info < (3, 9),
+ "generate_pack.py's context managers need Python 3.9")
+ def test_a_pack_names_its_list_only_when_that_list_records_it(self):
+ """tui_audit_11 P2-1: a MinUI pack sent --core to install/minui.json,
+ refused the same way, and a MiSTer pack to install/misterfpga.json,
+ which records no core. The pack now records what its list narrows
+ by, and each remedy changes the outcome."""
+ listing, pack, listed = self.minui()
+ self.assertEqual(listed["list_records"], ["cores", "regions"])
+ code, text, _kept = self.narrowed("minui", from_pack=[pack], core=["mgba"])
+ self.assertEqual(code, 1)
+ self.assertIn("Add --manifest FILE, a copy of install/minui.json, which records them",
+ text)
+ code, text, kept = self.narrowed("minui", from_pack=[pack], manifest=listing,
+ core=["mgba"])
+ self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text)
+ mister_list, mister, listed = self.minui("misterfpga", systems=True, cores=False,
+ regions=False)
+ self.assertEqual(listed["list_records"], ["regions", "systems"])
+ code, text, _kept = self.narrowed("misterfpga", from_pack=[mister], core=["x"])
+ self.assertEqual(code, 1)
+ self.assertIn("MiSTer FPGA's own file list records no cores either. Run it without "
+ "--core, or narrow with --system.", text)
+ self.assertNotIn("--manifest", text)
+ code, text, kept = self.narrowed("misterfpga", from_pack=[mister],
+ system=["nintendo-gba"])
+ self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text)
+ self.assertTrue(mister_list.exists())
+
+ def test_line_mode_offers_what_can_be_chosen(self):
+ """tui_audit_11 P3-10: "c) choose systems, cores and regions" with a
+ pack that records systems only, and "Cores: 1) azahar" asked with
+ one answer."""
+ mister, _pack, _listed = self.minui("misterfpga", systems=True, cores=False,
+ regions=False)
+ files = json.loads(mister.read_text())["files"]
+ self.assertEqual(install.narrowing_words(files, []), "systems")
+ listing, _pack, _listed = self.minui()
+ minui = json.loads(listing.read_text())["files"]
+ self.assertEqual(install.narrowing_words(minui, []), "cores and regions")
+ one_core = [{**entry, "systems": ["nintendo-3ds"], "cores": ["azahar"]}
+ for entry in minui[:1]]
+ with mock.patch.object(install, "_ask", side_effect=["", ""]) as asked, \
+ contextlib.redirect_stdout(io.StringIO()) as out:
+ install._choose_narrowing(one_core, [], {}, None)
+ self.assertNotIn("Cores:", out.getvalue())
+ self.assertEqual(asked.call_count, 0)
+
+ # code_audit_18 P3-2, P3-3, tui_audit_11 P3-1: the remedies of a card.
+
+ def test_a_full_card_is_freed_never_left_for_another_drive(self):
+ with mock.patch.object(install.shutil, "disk_usage",
+ return_value=mock.Mock(free=10)), \
+ contextlib.redirect_stderr(io.StringIO()) as err, \
+ self.assertRaises(SystemExit):
+ install._check_free_space(self.root, 1000, "retroarch", card=True)
+ said = err.getvalue()
+ self.assertIn("on the card, then run it again", said)
+ self.assertNotIn("--platform", said)
+ self.assertNotIn("another drive", said)
+
+ def test_line_mode_names_the_way_to_another_folder(self):
+ hint = install.Invocation()
+ with mock.patch.object(install, "_folder_writable", return_value=False), \
+ mock.patch.object(install, "_on_read_only_mount", return_value=False):
+ lines = install.folder_warnings("retroarch", self.root, [], hint, True, False)
+ self.assertIn("You cannot write to this folder: run it again with another --dest.",
+ lines)
+ self.assertNotIn("choose another one", " ".join(lines))
+ with mock.patch.object(install, "_folder_writable", return_value=False), \
+ mock.patch.object(install, "_on_read_only_mount", return_value=True):
+ lines = install.folder_warnings("retroarch", self.root, [], hint, True, True)
+ self.assertEqual(lines, [install.read_only_words(self.root)])
+
+
+
+class _OutsideTextFindings:
+ """code_audit_18 P2-1: a pack's member path, a --manifest dest, the
+ names a list records, a card's label and a retroarch.cfg folder reached
+ line mode, the events and the disk with their control characters. Each
+ source is fed escape sequences, and none comes out raw."""
+
+ OSC = "\x1b]0;PWNED\x07"
+ CSI = "\x1b[41m"
+
+ @property
+ def dest(self) -> Path:
+ dest = self.root / "dest"
+ dest.mkdir(exist_ok=True)
+ return dest
+
+ def assertClean(self, text: str, where: str) -> None:
+ self.assertNotIn("\x1b", text, where)
+ self.assertNotIn("\x07", text, where)
+
+ def assertNothingRawOnDisk(self) -> None:
+ for path in self.root.rglob("*"):
+ self.assertIsNone(install._CONTROLS.search(path.name), path)
+
+ def run_install(self, args: argparse.Namespace, mounts: list = (),
+ env: dict | None = None) -> str:
+ events = io.StringIO()
+ install.LOCAL.reset()
+ self.addCleanup(install.LOCAL.reset)
+ with mock.patch.object(install, "_UNNAMEABLE", []), \
+ mock.patch.object(install, "_mounted_cards", return_value=list(mounts)), \
+ mock.patch.object(install, "_mount_points", return_value=frozenset()), \
+ mock.patch.object(install, "detect_frontends", return_value=[]), \
+ mock.patch.object(install, "detect_os", return_value="linux"), \
+ mock.patch.object(install.EVENTS, "stream", events), \
+ mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1", **(env or {})}), \
+ contextlib.redirect_stdout(io.StringIO()) as out, \
+ contextlib.redirect_stderr(io.StringIO()) as err, \
+ contextlib.suppress(SystemExit):
+ install._install(args, io.StringIO())
+ return out.getvalue() + err.getvalue() + events.getvalue()
+
+ def test_a_pack_member_path_is_refused(self):
+ from test_install_offline import _pack, GBA # noqa: PLC0415
+
+ pack = _pack(self.root, "RetroArch_v1_BIOS_Pack.zip", {f"x{self.OSC}.bin": GBA})
+ text = self.run_install(_args(platform="retroarch", dest=self.dest,
+ from_pack=[pack], no_input=True, declared_only=False))
+ self.assertClean(text, "pack")
+ self.assertIn("is not valid: unsafe", text)
+ self.assertEqual(list(self.dest.iterdir()), [])
+ self.assertNothingRawOnDisk()
+
+ def test_a_list_dest_and_its_names_are_refused_or_shown(self):
+ files = [{"dest": f"x{self.CSI}BAD.bin", "sha1": "0" * 40, "size": 1,
+ "repo_path": "bios/x", "cores": None, "systems": ["a"]}]
+ listing = self.root / "list.json"
+ listing.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch",
+ "files": files}))
+ text = self.run_install(_args(platform="retroarch", dest=self.dest, manifest=listing,
+ no_input=True, dry_run=True, verbose=True,
+ declared_only=False))
+ self.assertClean(text, "--manifest dest")
+ self.assertIn("unsafe", text)
+ named = {"manifest_version": 2, "platform": "retroarch", "files": [
+ {**files[0], "dest": "a.bin", "systems": [f"a{self.CSI}"],
+ "cores": [f"core{self.OSC}"]}],
+ "system_titles": {f"a{self.CSI}": f"Title{self.OSC}"}}
+ cleaned = install._validate_manifest(named, "retroarch")
+ self.assertClean(json.dumps(cleaned, ensure_ascii=False), "names")
+ self.assertNothingRawOnDisk()
+
+ def test_a_card_label_is_shown_and_never_obeyed(self):
+ card = self.root / "media" / f"S{self.CSI}RED"
+ (card / "system").mkdir(parents=True)
+ (card / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n")
+ listing = self.root / "batocera.json"
+ listing.write_text(json.dumps({"manifest_version": 2, "platform": "batocera",
+ "files": []}))
+ with mock.patch.object(install, "detect_platforms", return_value=[]):
+ text = self.run_install(_args(platform="batocera", manifest=listing,
+ no_input=True, dry_run=True, events=True,
+ declared_only=False), [card])
+ self.assertIn("S?[41mRED", text)
+ self.assertClean(text, "card label")
+ self.assertClean(install.card_line(card), "card row")
+
+ def test_a_folder_a_config_names_is_refused(self):
+ home = self.root / "home"
+ weird = home / f"sys{self.OSC}x"
+ weird.mkdir(parents=True)
+ cfg = home / ".config" / "retroarch" / "retroarch.cfg"
+ cfg.parent.mkdir(parents=True)
+ cfg.write_text(f'system_directory = "{weird}"\n')
+ listing = self.root / "retroarch.json"
+ listing.write_text(json.dumps({"manifest_version": 2, "platform": "retroarch",
+ "files": []}))
+ text = self.run_install(
+ _args(manifest=listing, no_input=True, dry_run=True, events=True,
+ declared_only=False),
+ env={"HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"),
+ "RETROBIOS_OS": "linux"})
+ self.assertClean(text, "retroarch.cfg")
+ self.assertIn("holds a control character", text)
+ self.assertNotIn("using default path", text)
+ self.assertNotIn(str(home / ".config" / "retroarch" / "system"), text)
+
+ def test_a_path_with_a_control_character_is_no_relative_path(self):
+ for value in ("a\x1bb.bin", "a\x07.bin", "a\x9bb.bin", "a\nb.bin"):
+ with self.subTest(value=value), self.assertRaises(ValueError):
+ install._safe_relative_path(value, "dest")
+ self.assertEqual(install.shown_path(Path("/a/b\x1b[2Jc")), "/a/b?[2Jc")
+
+
+
+class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
+ unittest.TestCase):
"""tui_audit_8 P1-1, P2-1 and code_audit_15 P2-2: line mode looked at
this machine alone, took this computer's default folder with a card in
the reader or nothing found, and the screen put RetroArch under A for a
@@ -6629,6 +7275,405 @@ class LineModeAndScreenDecideAlike(unittest.TestCase):
self.assertEqual(screen, expected)
self.assertNotIn("using default path", text)
+ def unasked(self, mounts: list, found: list, **given) -> tuple:
+ """(platforms or exit code, text, events) of a run without
+ questions: --no-input, or --yes, which implies it."""
+ def stop(_args, platforms):
+ raise _Decided(platforms)
+
+ install.LOCAL.reset()
+ self.addCleanup(install.LOCAL.reset)
+ args = _args(declared_only=False, offline=False, no_input=True, **given)
+ events = []
+ with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
+ mock.patch.object(install, "_mount_points", return_value=frozenset()), \
+ mock.patch.object(install, "detect_platforms", return_value=found), \
+ mock.patch.object(install, "detect_frontends", return_value=[]), \
+ mock.patch.object(install, "app_refusal", side_effect=stop), \
+ mock.patch.object(install, "detect_os", return_value="linux"), \
+ mock.patch.object(install.EVENTS, "emit",
+ side_effect=lambda kind, **keys: events.append((kind, keys))), \
+ mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \
+ contextlib.redirect_stdout(io.StringIO()) as out, \
+ contextlib.redirect_stderr(io.StringIO()) as err:
+ try:
+ install._install(args, io.StringIO())
+ except _Decided as decided:
+ return [tuple(each) for each in decided.args[0]], out.getvalue(), events
+ except SystemExit as exit_:
+ return exit_.code, out.getvalue() + err.getvalue(), events
+ raise AssertionError(out.getvalue())
+
+ def test_a_run_without_questions_sees_the_cards_the_screen_sees(self):
+ """tui_audit_11 P1-1: --yes and --no-input looked at this machine
+ alone, beside the Onion card the screen and line mode open on: the
+ files went to the home folder, or the run said nothing is here."""
+ onion = self.card("ONION", "Onion")
+ default = install._default_dest("linux", "retroarch")
+ for flag in ({"yes": True}, {}):
+ for name, (mounts, given) in {
+ "an Onion card, a list": ([onion], {"manifest": self.list}),
+ "an Onion card, nothing named": ([onion], {}),
+ }.items():
+ with self.subTest(flag=flag, scenario=name):
+ chosen, text, _events = self.unasked(mounts, [], **flag, **given)
+ self.assertEqual(chosen, [("retroarch", onion / "BIOS")], text)
+ self.assertNotIn(str(default), text)
+ install._DETECTED_OS.clear()
+ with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
+ mock.patch.object(install, "_mount_points",
+ return_value=frozenset()), \
+ mock.patch.object(install, "detect_platforms", return_value=[]), \
+ mock.patch.object(install, "detect_frontends", return_value=[]):
+ screen = screen_decision(_args(declared_only=False, **given))
+ self.assertEqual(chosen, [screen])
+
+ def test_a_run_without_questions_stops_on_two_setups(self):
+ """code_audit_18 P2-3, tui_audit_11 P1-1: --yes beside two setups
+ installed for both, where its help says it stops on what to name.
+ It now stops with an error event naming each and its --dest."""
+ onion, muos = self.card("ONION", "Onion"), self.card("MUOS", "muOS")
+ pc = [("retroarch", self.here)]
+ for flag in ({"yes": True}, {}):
+ for name, (mounts, found, given) in {
+ "two cards": ([onion, muos], [], {}),
+ "a card and RetroArch here, a list": ([onion], pc, {"manifest": self.list}),
+ "two setups here": ([], [("retroarch", self.here),
+ ("emudeck", self.root / "Emulation" / "bios")], {}),
+ }.items():
+ with self.subTest(flag=flag, scenario=name):
+ code, text, events = self.unasked(mounts, found, **flag, **given)
+ self.assertEqual(code, 1, text)
+ errors = [keys for kind, keys in events if kind == "error"]
+ self.assertEqual([(e["stage"], e["reason"]) for e in errors],
+ [("detect", "conflict")])
+ self.assertIn("a run without questions installs for one", text)
+ self.assertRegex(text, r"--platform \w+ --dest ")
+
+ def share(self, name: str = "SHARE") -> Path:
+ share = self.root / "media" / name
+ (share / "system").mkdir(parents=True)
+ (share / "roms").mkdir()
+ (share / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n")
+ return share
+
+ def test_a_drive_is_offered_by_the_folder_its_system_reads(self):
+ """code_audit_18 P2-2, tui_audit_11 P1-2: RetroArch's folder question
+ numbered a Batocera SHARE drive and a KNULLI card, and one press put
+ RetroArch's list at their root, beside roms/ and system/. A drive
+ nothing names went to its root too. Now another system's drive is
+ left out and said, in both modes, and a drive is its bios folder."""
+ share, knulli = self.share(), self.card("KNULLI", "KNULLI")
+ stick = self.root / "media" / "EASYROMS"
+ (stick / "bios").mkdir(parents=True)
+ onion = self.card("ONION", "Onion")
+ mounts = [share, knulli, stick, onion]
+ with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
+ mock.patch.object(install, "_mount_points", return_value=frozenset()), \
+ contextlib.redirect_stdout(io.StringIO()) as out, \
+ mock.patch.object(install, "_ask", side_effect=["q"]), \
+ self.assertRaises(SystemExit):
+ install.ask_platform_folder("linux", "retroarch")
+ text = out.getvalue()
+ self.assertIn(f"1) EASYROMS: bios (card or drive) ({stick / 'bios'})", text)
+ self.assertIn(f"2) ONION: BIOS (Onion SD card) ({onion / 'BIOS'})", text)
+ self.assertNotIn("3)", text)
+ self.assertIn("Not offered here, each reads its own system's files: SHARE (Batocera "
+ f"SHARE drive: --platform batocera --dest {share}), KNULLI (KNULLI SD "
+ f"card: --platform knulli --dest {knulli}).", text)
+ state = install.TuiState(os_type="linux", home=HOME)
+ state.platform, state.stack = "retroarch", ["folder"]
+ state.folder_default, state.folder_kind = SYSTEM_DIR, "usual"
+ with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
+ mock.patch.object(install, "_mount_points", return_value=frozenset()):
+ rows = install.tui_rows(state)
+ screen = " ".join(install.tui_view(state, 120, 40))
+ state.focus["folder"] = next(index for index, row in enumerate(rows)
+ if row.action == "drive")
+ install.tui_update(state, Key("enter"))
+ self.assertEqual([row.value for row in rows if row.action == "drive"],
+ [stick / "bios", onion / "BIOS"])
+ self.assertIn("Not offered here", screen)
+ self.assertEqual(state.dest, stick / "bios")
+
+ def test_a_list_card_setups_read_offers_them_first(self):
+ """tui_audit_11 P1-2: with a RetroArch pack or list and nothing found,
+ the first screen was RetroArch's folder question: ArkOS, muOS and
+ JELOS, which read the same list, were out of reach. Both modes now
+ open on the setups that read it, RetroArch first."""
+ stick = self.root / "media" / "EASYROMS"
+ (stick / "bios").mkdir(parents=True)
+ args = dict(manifest=self.list, declared_only=False, offline=False)
+ text, asked = self.line_run(_args(**args), [], mounts=[stick])
+ self.assertIn("Which setup is the RetroArch list for?", text)
+ self.assertIn("ArkOS", text)
+ self.assertNotIn("Batocera", text)
+ self.assertIn("[Enter for RetroArch]", asked[0])
+ with mock.patch.object(install, "_mounted_cards", return_value=[stick]), \
+ mock.patch.object(install, "_mount_points", return_value=frozenset()), \
+ mock.patch.object(install, "detect_platforms", return_value=[]), \
+ mock.patch.object(install, "detect_frontends", return_value=[]):
+ install._DETECTED_OS.clear()
+ state = screen_keys(_args(**args), [])
+ with contextlib.redirect_stdout(io.StringIO()):
+ install._open_local_sources(_args(**args))
+ rows = install.tui_rows(state)
+ labels = [row.label for row in rows]
+ focused = rows[state.focus["platforms"]]
+ install._DETECTED_OS.clear()
+ ark = screen_keys(_args(**args), ["end", Key("char", "/"), *[
+ Key("char", c) for c in "arkos"], "enter", "enter"])
+ self.assertEqual(state.screen, "platforms")
+ self.assertEqual(focused.value, "retroarch")
+ self.assertTrue(any(label.startswith("ArkOS") for label in labels), labels)
+ self.assertFalse(any(label.startswith("Batocera") for label in labels), labels)
+ self.assertEqual(ark.screen, "folder")
+ self.assertEqual(install._tui_card_bound(ark)[0], "ArkOS")
+
+ # The owner's request of 2026-10-27: BIOS files and game data counted
+ # apart, the game data choice on the plan itself and in the menu.
+
+ def game_list(self) -> tuple[Path, list]:
+ files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]),
+ self.entry("doom/doom1.wad", b"wad" * 10, ["doom"], category="game_data")]
+ return self.listing("retroarch", files, categorized=True), files
+
+ def test_line_mode_counts_game_data_apart_and_g_leaves_it_out(self):
+ listing, _files = self.game_list()
+ args = dict(manifest=listing, dest=self.here, declared_only=False, dry_run=True)
+ text, asked = self.line_run(_args(**args), ["g", ""])
+ self.assertIn("1 BIOS file (3 B) and 1 game data file (30 B).", text)
+ self.assertIn("g) leave out the 1 game data file (30 B), game data: files a game or "
+ "a game engine reads, of no use to the other cores", text)
+ after = text[text.index("Counted again"):]
+ self.assertIn("RetroArch: 1 file, 3 B.", after)
+ self.assertIn("g) keep the 1 game data file", after)
+ self.assertIn("--no-game-data", after)
+ self.assertIn("Left out as game data", after)
+ self.assertNotIn("doom1.wad would", after)
+
+ def test_the_plan_counts_game_data_apart_and_one_press_leaves_it_out(self):
+ state = checked_state()
+ manifest = json.loads(json.dumps(state.manifest))
+ for entry in manifest["files"]:
+ if entry["dest"] == "pcsx/extra.bin":
+ entry["category"] = "game_data"
+ state.manifest = manifest
+ text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
+ self.assertIn("3 BIOS files (1.0 MB) and 1 game data file (4 B). Game data: files a "
+ "game or a game engine reads, of no use to the other cores.", text)
+ rows = install.tui_rows(state)
+ row = next(row for row in rows if row.action == "gamedata")
+ self.assertIn("kept, leave out", row.label)
+ state.focus["plan"] = rows.index(row)
+ effects = install.tui_update(state, Key("enter"))
+ self.assertTrue(state.no_game_data)
+ self.assertEqual(effects, [("run", "check")])
+ self.assertIn(("--no-game-data", None), install.tui_flags(state))
+
+ def test_the_result_counts_game_data_apart_in_both_modes(self):
+ state = _installed_state(Path("/x/system"))
+ state.manifest = {**state.manifest, "files": [
+ *state.manifest["files"],
+ {"dest": "doom/doom1.wad", "size": 30, "category": "game_data", "cores": None}]}
+ state.finished = [("scph5500.bin", True, ""), ("doom/doom1.wad", True, "")]
+ state.summary = {**state.summary, "installed": 2}
+ _title, lines = install.tui_outcome(state)
+ self.assertIn("Installed: 1 BIOS file and 1 game data file.", lines)
+ # The data tree files are installed without a file event: the parts
+ # still add up to the head's count.
+ state.summary = {**state.summary, "installed": 5}
+ _title, lines = install.tui_outcome(state)
+ self.assertIn("5 files installed.", lines[0])
+ self.assertIn("Installed: 1 BIOS file, 1 game data file and 3 data files.", lines)
+ listing, _files = self.game_list()
+ backup = self.root / "old"
+ (backup / "doom").mkdir(parents=True)
+ (backup / "gba_bios.bin").write_bytes(b"gba")
+ (backup / "doom" / "doom1.wad").write_bytes(b"wad" * 10)
+ args = _args(manifest=listing, dest=self.here, offline=True, declared_only=False,
+ no_input=True)
+ setattr(args, "from", [backup])
+ real = io.StringIO()
+ install.LOCAL.reset()
+ self.addCleanup(install.LOCAL.reset)
+ with mock.patch.object(install, "detect_os", return_value="linux"), \
+ contextlib.redirect_stdout(io.StringIO()), \
+ contextlib.redirect_stderr(io.StringIO()):
+ install._install(args, real)
+ self.assertIn("Done. 2 files installed", real.getvalue())
+ self.assertIn("Installed: 1 BIOS file and 1 game data file.", real.getvalue())
+
+ # 2026-10-27, CI run 40541886959: the run said 8069 files, the menu
+ # 8348 and 3259 BIOS files and 5089 game data files, the real dry run
+ # 8358. Every count of a run is one tally of one list.
+
+ def counted_list(self) -> Path:
+ files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]),
+ self.entry("doom/doom1.wad", b"wad" * 10, ["doom"], category="game_data"),
+ self.entry("doom/doom2.wad", b"two" * 20, ["doom"], category="game_data")]
+ variant = {**self.entry("DOOM/DOOM1.WAD", b"WAD" * 10, ["doom"]),
+ "category": "game_data"}
+ omitted = [{"dest": "doom/doom3.wad", "name": "doom3.wad", "system": "doom",
+ "systems": ["doom"], "required": False, "reason": "not_found",
+ "category": "game_data"}]
+ return self.listing("retroarch", files, categorized=True, case_variants=[variant],
+ omitted_files=omitted)
+
+ def test_every_count_of_a_line_run_is_one_list(self):
+ args = dict(manifest=self.counted_list(), dest=self.here, declared_only=False,
+ dry_run=True)
+ text, _asked = self.line_run(_args(**args), [""])
+ # The second spelling a folder keeping case installs is counted
+ # everywhere or nowhere: here, everywhere.
+ self.assertIn("RetroArch: 4 files, 123 B.\n 1 BIOS file (3 B) and 3 game data files "
+ "(120 B).", text)
+ self.assertIn("g) leave out the 3 game data files (120 B)", text)
+ self.assertIn(" 4 files (123 B)\n 1 BIOS file (3 B) and 3 game data files (120 B).",
+ text)
+ self.assertIn("0/4 present", text)
+ self.assertIn("4 files (123 B)", text[text.index("Checking existing files"):])
+
+ def test_g_counts_the_systems_chosen_and_leaves_out_what_it_said(self):
+ args = dict(manifest=self.counted_list(), dest=self.here, declared_only=False,
+ dry_run=True)
+ text, _asked = self.line_run(_args(**args), ["c", "2", ""])
+ after = text[text.index("Counted again"):]
+ self.assertIn("RetroArch: 1 file, 3 B, for 1 of 2 systems.", after)
+ # No game data among the systems chosen: nothing for g to leave out.
+ self.assertNotIn("g) leave out", after)
+ text, _asked = self.line_run(_args(**args), ["c", "1", "g", ""])
+ after = text[text.index("Counted again"):]
+ self.assertIn("g) leave out the 3 game data files (120 B)", after)
+ last = after[after.rindex("Counted again"):]
+ self.assertIn("g) keep the 3 game data files (120 B)", last)
+ self.assertIn("Left out as game data (--no-game-data): 4 files, 1 of them not "
+ "installable", text)
+
+ def test_the_parts_add_up_on_every_committed_list(self):
+ for path in sorted((REPO_ROOT / "install").glob("*.json")):
+ manifest = json.loads(path.read_text(encoding="utf-8"))
+ if not manifest.get("categorized"):
+ continue
+ with self.subTest(path.name):
+ for files in (manifest.get("files", []),
+ manifest.get("files", []) + manifest.get("case_variants", [])):
+ counts = install.tally(files)
+ self.assertEqual(counts.bios + counts.games, counts.files)
+ self.assertEqual(counts.files, len(files))
+ self.assertEqual(counts.bios_size + counts.game_size, counts.size)
+ self.assertEqual(counts.size, sum(e.get("size") or 0 for e in files))
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ install._print_menu_head(
+ manifest["platform"], manifest["files"], [], None, "", [], None,
+ None, None, manifest, False)
+ head = re.search(r"(\d+) files?, ", out.getvalue())
+ split = re.search(r"(\d+) BIOS files? \(.*?\) and (\d+) game data files?",
+ out.getvalue())
+ if split:
+ self.assertEqual(int(split[1]) + int(split[2]), int(head[1]))
+
+ def test_the_screen_divides_the_count_it_shows(self):
+ state = checked_state()
+ manifest = json.loads(json.dumps(state.manifest))
+ manifest["case_variants"] = [{"dest": "PCSX/EXTRA.BIN", "size": 4,
+ "category": "game_data", "cores": ["pcsx_rearmed"],
+ "systems": ["sony-playstation"]}]
+ for entry in manifest["files"]:
+ if entry["dest"] == "pcsx/extra.bin":
+ entry["category"] = "game_data"
+ state.manifest = manifest
+ state.files = [*state.files, {
+ "event": "file", "platform": "retroarch", "dest": "PCSX/EXTRA.BIN",
+ "systems": ["sony-playstation"], "size": 4, "state": "missing"}]
+ state.check = {**state.check, "missing": state.check["missing"] + 1,
+ "files": state.check["files"] + 1,
+ "bytes_needed": state.check["bytes_needed"] + 4}
+ text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
+ self.assertIn("5 files to download", text)
+ self.assertIn("3 BIOS files (1.0 MB) and 2 game data files (8 B).", text)
+ row = next(row for row in install.tui_rows(state) if row.action == "gamedata")
+ self.assertIn("Game data (2 files, 8 B)", row.label)
+
+ # code_audit_18 P3-5, tui_audit_11 P3-10 and P3-21: line mode wording.
+
+ def test_one_row_says_type_1(self):
+ with mock.patch.object(install, "_ask", side_effect=["2", "q"]), \
+ contextlib.redirect_stdout(io.StringIO()) as out, \
+ self.assertRaises(SystemExit):
+ install._prompt_platform_choice([("retroarch", self.here)], other=lambda: [])
+ self.assertIn("Type 1, o for another setup or folder, or q to quit.", out.getvalue())
+ self.assertNotIn("1 to 1", out.getvalue())
+
+ def test_a_number_out_of_range_at_copy_from_is_said_as_one(self):
+ stick = self.root / "media" / "STICK"
+ stick.mkdir(parents=True)
+ with mock.patch.object(install, "_mounted_cards", return_value=[stick]), \
+ mock.patch.object(install, "_ask", side_effect=["2", ""]), \
+ contextlib.redirect_stdout(io.StringIO()) as out:
+ install._ask_from_folder([], self.here)
+ self.assertIn("Type 1, a folder, or Enter.", out.getvalue())
+ self.assertNotIn("does not exist", out.getvalue())
+
+ def test_the_copy_line_names_the_folder_it_copies_from(self):
+ payload = b"gba"
+ files = [self.entry("gba_bios.bin", payload, ["nintendo-gba"])]
+ listing = self.listing("retroarch", files)
+ old = self.root / "OLD"
+ old.mkdir()
+ (old / "gba_bios.bin").write_bytes(payload)
+ args = _args(manifest=listing, dest=self.here, declared_only=False, offline=True)
+ setattr(args, "from", [old])
+ text, _asked = self.line_run(args, [""])
+ self.assertIn(f"Copying 1 file copied from {old}", text)
+ self.assertNotIn("found on this machine (", text)
+
+ def test_the_save_lines_name_the_frontend_both_times(self):
+ onion = self.card("ONION", "Onion")
+ files = [self.entry("gba_bios.bin", b"g", ["nintendo-gba"]),
+ self.entry("x/keys.txt", b"k", ["nintendo-3ds"], root="save")]
+ listing = self.listing("retroarch", files, base_destination="RetroArch",
+ save_directory={"folder": "saves", "by_core": False})
+ text, _asked = self.line_run(_args(manifest=listing, declared_only=False,
+ offline=False), [""], mounts=[onion])
+ self.assertIn("Install for:", text)
+ self.assertIn("save directory left out", text)
+ self.assertNotIn("Onion's save directory", text)
+ self.assertIn("RetroArch's save directory", text)
+
+ def test_line_mode_repeats_the_command_after_enter(self):
+ payload = b"gba"
+ files = [self.entry("gba_bios.bin", payload, ["nintendo-gba"])]
+ listing = self.listing("retroarch", files)
+ old = self.root / "OLD"
+ old.mkdir()
+ (old / "gba_bios.bin").write_bytes(payload)
+ args = _args(manifest=listing, declared_only=False, offline=True)
+ setattr(args, "from", [old])
+ real = io.StringIO()
+ install.LOCAL.reset()
+ self.addCleanup(install.LOCAL.reset)
+ invocation = install.Invocation()
+ invocation.os_type = "linux"
+ invocation.record(args)
+ with mock.patch.object(install, "_INVOCATION", invocation), \
+ mock.patch.object(install, "_mounted_cards", return_value=[]), \
+ mock.patch.object(install, "detect_platforms",
+ return_value=[("retroarch", self.here)]), \
+ mock.patch.object(install, "detect_frontends", return_value=[]), \
+ mock.patch.object(install, "detect_os", return_value="linux"), \
+ mock.patch.object(install.sys.stdin, "isatty", return_value=True), \
+ mock.patch.object(install, "_is_terminal", return_value=True), \
+ mock.patch.object(install, "_ask", return_value=""), \
+ contextlib.redirect_stdout(io.StringIO()), \
+ contextlib.redirect_stderr(io.StringIO()):
+ install._install(args, real)
+ said = real.getvalue()
+ self.assertIn("Same choice without questions: python3 install.py --platform retroarch "
+ f"--dest {self.here}", said)
+ self.assertIn("To check this later, run that command with --check.", said)
+
def test_line_mode_says_the_card_it_found_and_names_its_folder(self):
onion = self.card("ONION", "Onion")
with mock.patch.object(install, "_mounted_cards", return_value=[onion]), \
@@ -6666,7 +7711,8 @@ class LineModeAndScreenDecideAlike(unittest.TestCase):
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(install.sys.stdin, "isatty", return_value=True), \
mock.patch.object(install, "_is_terminal", return_value=True), \
- mock.patch.object(install, "_ask", return_value=str(self.here)), \
+ mock.patch.object(install, "_ask", side_effect=lambda prompt: (
+ "" if prompt.startswith(">") else str(self.here))), \
mock.patch.object(install, "_prompt_custom_selection", menu), \
contextlib.redirect_stdout(io.StringIO()) as out, \
self.assertRaises(SystemExit):
@@ -6759,7 +7805,8 @@ class LineModeAndScreenDecideAlike(unittest.TestCase):
share = self.root / "media" / f"SHARE-{plat}"
(share / "system").mkdir(parents=True)
(share / "roms").mkdir()
- (share / marker).write_text("system.hostname=SHARE\n")
+ # Batocera's own hostname: another would fit KNULLI renamed too.
+ (share / marker).write_text("system.hostname=BATOCERA\n")
shares[plat] = share
for plat, share in shares.items():
other = shares["recalbox" if plat == "batocera" else "batocera"]
@@ -7013,13 +8060,16 @@ class LineModeAndScreenDecideAlike(unittest.TestCase):
mock.patch.object(install, "_ask", side_effect=["1"]), \
mock.patch.object(install, "_missing_dest", return_value=True):
folder = install.ask_platform_folder("linux", "retroarch")
- self.assertEqual(folder, stick)
+ # tui_audit_11 P1-2: a drive is offered by its bios folder, never its
+ # root, which no setup reads.
+ self.assertEqual(folder, stick / "bios")
state = install.TuiState(os_type="linux", home=HOME)
state.platform, state.stack = "retroarch", ["folder"]
state.folder_default, state.folder_kind = SYSTEM_DIR, "usual"
with mock.patch.object(install, "_mounted_cards", return_value=mounts):
rows = install.tui_rows(state)
- self.assertEqual([row.value for row in rows if row.action == "drive"], [stick])
+ self.assertEqual([row.value for row in rows if row.action == "drive"],
+ [stick / "bios"])
def test_the_systems_are_listed_by_title_in_the_screen_order(self):
"""tui_audit_10 P3-7: line mode listed ids, nintendo-3ds before
@@ -7186,7 +8236,9 @@ class AuditSeventeenAndTen(unittest.TestCase):
"--manifest", str(listing)])
proc = subprocess.run(
["script", "-qec", command, "/dev/null"],
- input=f"{repo.dest}\nc\n2\n\n2\n\n", env=env, capture_output=True,
+ # Enter on RetroArch, first of the setups that read its list
+ # (tui_audit_11 P1-2), then its folder.
+ input=f"\n{repo.dest}\nc\n2\n2\n\n", env=env, capture_output=True,
text=True, timeout=120, check=False)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
installed = repo.installed()
@@ -7272,7 +8324,7 @@ class AuditSeventeenAndTen(unittest.TestCase):
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit):
install._requested_narrowing(_args(system=["nintendo-gba"]), "retroarch",
install.LOCAL.pack.manifest("retroarch")["files"], [])
- self.assertIn("add --manifest FILE, a copy of install/retroarch.json", err.getvalue())
+ self.assertIn("Add --manifest FILE, a copy of install/retroarch.json", err.getvalue())
self.assertNotIn("older release", err.getvalue())
def test_a_pack_cannot_write_escapes_through_its_system_names(self):
@@ -7511,10 +8563,14 @@ class AuditSeventeenAndTen(unittest.TestCase):
def test_the_docs_say_what_the_installer_does(self):
"""code_audit_17 P3-7."""
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
- menu = page[page.index("RetroArch: 8069 files, 6.5 GB."):]
+ menu = page[re.search(r"RetroArch: \d+ files, [\d.]+ [KMG]B[.,]", page).start():]
menu = menu[:menu.index("```")]
self.assertIn("BIOS folder:", menu)
self.assertIn("f) copy from a folder or an old card", menu)
+ # The owner's request of 2026-10-27: game data counted apart, g.
+ # test_doc_pages checks that the figures add up.
+ self.assertRegex(menu, r"\d+ BIOS files \([\d.]+ [KMG]B\) and \d+ game data files")
+ self.assertRegex(menu, r"g\) leave out the \d+ game data files")
env = page[page.index("## Environment overrides"):]
self.assertIn("`TERMINFO`", env)
self.assertIn("`TERMINFO_DIRS`", env)
@@ -7766,7 +8822,7 @@ class AuditFifteenAndEightScreens(unittest.TestCase):
old.mkdir()
state = checked_state()
state.from_folders = [old]
- with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
+ with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: install.OFFLINE_FLAG_VALUE}):
lines = [str(line) for line in install._plan_lines(state.check, state.dest,
state)]
said = [line for line in lines if "USBSTICK" in line]
@@ -8350,5 +9406,834 @@ class EdgesTheMutantsFound(unittest.TestCase):
["Odroid"])
+
+class AuditEighteenAndEleven(_CardFixtures, unittest.TestCase):
+ """code_audit_18 and tui_audit_11: what the cards, the screens and the
+ docs answer, line mode's parity being in LineModeAndScreenDecideAlike."""
+
+ def setUp(self):
+ (REPO_ROOT / "tmp").mkdir(exist_ok=True)
+ self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
+ self.addCleanup(shutil.rmtree, self.root, True)
+ install.LOCAL.reset()
+ self.addCleanup(install.LOCAL.reset)
+ for patcher in (mock.patch.dict(install._DETECTED_OS, {}),
+ mock.patch.object(install, "_mount_points", return_value=frozenset())):
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_a_renamed_card_is_named_by_the_file_no_menu_writes(self):
+ """code_audit_18 P1-1: a KNULLI card whose hostname was changed in
+ its menu was a Batocera SHARE drive, Enter filled it with Batocera's
+ list and --platform knulli was refused."""
+ drives = self.renamed()
+ expected = {
+ "KNULLI renamed": ("knulli", "bios", "KNULLI"),
+ "KNULLI as shipped": ("knulli", "bios", "KNULLI"),
+ "renamed, no about.info": None,
+ "Batocera as shipped": ("batocera", "bios", "Batocera"),
+ "Batocera, no hostname": ("batocera", "bios", "Batocera"),
+ "Recalbox": ("recalbox", "bios", "Recalbox"),
+ "two shares": None,
+ }
+ for name, drive in drives.items():
+ with self.subTest(drive=name):
+ found = install.card_system(drive)
+ want = expected[name]
+ self.assertEqual(found, want and (want[0], drive / want[1], want[2]))
+ doubt, _why = install.share_doubt(drive)
+ self.assertEqual(bool(doubt), want is None)
+ self.assertEqual(install.share_doubt(drives["renamed, no about.info"])[0],
+ ("knulli", "batocera"))
+ self.assertEqual(install.share_doubt(drives["two shares"])[0],
+ ("batocera", "recalbox"))
+
+ def test_a_share_drive_is_never_called_a_card(self):
+ """code_audit_18 P3-4, tui_audit_11 P3-14: "is a Batocera card"
+ and "is a Recalbox card" for their SHARE drives."""
+ drives = self.renamed()
+ for name in ("Batocera as shipped", "Recalbox"):
+ with self.subTest(drive=name):
+ choice = install.card_choice("retroarch", "retroarch", drives[name])
+ self.assertIn("SHARE drive, which reads", choice.note)
+ self.assertNotIn(" card, which reads", choice.note)
+
+ def test_two_cards_of_one_system_say_what_they_hold(self):
+ """tui_audit_11 P3-15: an old Onion card with 6 BIOS files and a new
+ empty one looked the same, and Enter took the first by name."""
+ old = self.root / "media" / "OLDONION"
+ make_card(old, "Onion")
+ for index in range(6):
+ (old / "BIOS" / f"f{index}.bin").write_bytes(b"x")
+ new = self.root / "media" / "ONION"
+ make_card(new, "Onion")
+ shutil.rmtree(new / "BIOS")
+ with mock.patch.object(install, "_mounted_cards", return_value=[old, new]), \
+ mock.patch.object(install, "detect_platforms", return_value=[]), \
+ contextlib.redirect_stdout(io.StringIO()):
+ setups, _mounted = install.found_setups("linux")
+ self.assertEqual([label for _plat, _path, label in setups],
+ ["Onion SD card (ONION, no BIOS folder)",
+ "Onion SD card (OLDONION, 6 BIOS files)"])
+
+ # tui_audit_11 P2-1: a list or a pack that records some axes only.
+
+ def test_a_card_plan_offers_one_remedy_first(self):
+ for facts, locked in (({"free": 50 * 1024 ** 3, "writable": False, "cores": []}, True),
+ ({"free": 10, "writable": True, "cores": []}, False)):
+ with self.subTest(locked=locked):
+ state = checked_state(facts)
+ with mock.patch.object(install, "_on_its_card", return_value=True), \
+ mock.patch.object(install, "_on_read_only_mount", return_value=locked):
+ rows = install.tui_rows(state)
+ text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
+ effects = install.tui_update(state, Key("enter"))
+ self.assertEqual(rows[0].action, "recheck")
+ self.assertEqual("install" in [row.action for row in rows], not locked)
+ self.assertNotIn("choose another one", text)
+ self.assertEqual(effects, [("run", "check")])
+
+ def test_options_offer_no_downloads_when_nothing_is_downloaded(self):
+ """P3-16: Parallel downloads offered for an install from a pack."""
+ state = checked_state()
+ state.stack.append("options")
+ with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: ""}):
+ self.assertIn("jobs", [row.action for row in install.tui_rows(state)])
+ with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
+ self.assertNotIn("jobs", [row.action for row in install.tui_rows(state)])
+
+ def test_nothing_installable_focuses_the_copy(self):
+ """tui_audit_11 P3-8: offline with nothing to copy, the plan focused
+ Choose systems, whose systems need the internet too."""
+ with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
+ state = checked_state()
+ rows = install.tui_rows(state)
+ self.assertEqual(rows[state.focus.get("plan", 0)].action, "fromcard")
+
+ # tui_audit_11 P3-2 to P3-20: the screens' polish.
+
+ def test_a_long_message_scrolls_with_its_position(self):
+ """P3-2: at 40x12 the offline screen dropped its remedy unsaid."""
+ state = fresh_state()
+ state.stack = ["error"]
+ state.error_title = install.TUI_OFFLINE_TITLE
+ state.error = ["This run makes no network request (--offline), and the list of BIOS "
+ "files comes from the network.",
+ "Run the installer again with the list saved as a file (--manifest "
+ "FILE, a copy of install/.json) or with a pack (--from-pack "
+ "PACK), or without --offline."]
+ state.cols, state.rows = 40, 12
+ view = [str(line) for line in install.tui_view(state, 40, 12)]
+ self.assertTrue(any(re.fullmatch(r" 1-\d+ of \d+", line) for line in view), view)
+ self.assertIn("Scroll", view[-1])
+ press(state, "pgdn")
+ end = " ".join(" ".join(str(line) for line in install.tui_view(state, 40, 12)).split())
+ self.assertIn("--offline.", end)
+
+ def test_a_small_window_says_why_the_questions_come_line_by_line(self):
+ """P3-4."""
+ with mock.patch.object(install, "tui_blocker",
+ return_value="the terminal is 39x12, smaller than 40x12"), \
+ mock.patch.object(install, "_run", side_effect=SystemExit(0)), \
+ mock.patch.object(install, "detect_os", return_value="linux"), \
+ mock.patch.dict(install._WSL), \
+ mock.patch.object(sys, "argv", ["install.py"]), \
+ contextlib.redirect_stdout(io.StringIO()) as out, \
+ self.assertRaises(SystemExit):
+ install.main()
+ self.assertIn("The window is 39x12: questions come line by line (40x12 for the full "
+ "screen).", out.getvalue())
+
+ def test_one_bar_rule_per_width(self):
+ """P3-5: at 40 columns the plan read "A Install", Confirm "A/Enter"."""
+ states = golden_states()
+ for name in ("preview", "confirm", "result", "quit"):
+ with self.subTest(screen=name):
+ bar = str(install.tui_view(states[name], 40, 12)[-1])
+ self.assertNotIn("A/Enter", bar)
+ self.assertNotIn("B/Esc", bar)
+
+ def test_the_folder_its_config_named_is_said_under_it(self):
+ """P3-6, P3-7."""
+ state = checked_state()
+ long_dest = Path("/home/user/" + "a-long-folder-name/" * 5 + "system")
+ state.dest = long_dest
+ with mock.patch.object(install, "_retroarch_config_at",
+ return_value=(HOME / ".config/retroarch/retroarch.cfg", HOME)):
+ lines = [str(line).strip() for line in install.tui_view(state, 80, 30)]
+ at = next(index for index, line in enumerate(lines) if line.startswith("BIOS folder:"))
+ tail = next(index for index, line in enumerate(lines) if line.startswith("Named by"))
+ self.assertNotIn("...", " ".join(lines[at:tail]))
+ self.assertIn(str(long_dest).replace("/home/user", "~")[-20:], " ".join(lines[at:tail]))
+ self.assertLessEqual(tail - at, 3)
+
+ def test_the_help_while_typing_lists_what_does_not_type(self):
+ """P3-9."""
+ state = checked_state()
+ state.stack.append("systems")
+ state.editing = "systems"
+ state.help = True
+ help_text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
+ self.assertIn("Esc Clear the filter", help_text)
+ self.assertIn("Ctrl-U Clear", help_text)
+ self.assertNotIn("Tick, untick", help_text)
+
+ def test_the_plan_names_core_and_region_as_line_mode(self):
+ """P3-11."""
+ state = checked_state()
+ state.cli_systems, state.cores, state.regions = ["sony-playstation"], ["x"], ["us"]
+ text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
+ self.assertIn("(--system), --core, --region.", text)
+
+ def test_pc_and_short_prefixes_find_the_computer_setups(self):
+ """P3-17."""
+ for typed in ("pc", "wi", "win"):
+ with self.subTest(typed=typed):
+ found = {row.value for row in install.setup_list([], set(), typed, "linux")
+ if row.action == "platform"}
+ self.assertIn("retrobat", found)
+
+ def test_a_partial_result_counts_once_then_says_the_remedy(self):
+ """P3-18."""
+ state = _installed_state(Path("/x/system"))
+ state.summary.update(installed=6, pending=16, failed=[])
+ state.from_folders = [Path("/media/me/OLD")]
+ with mock.patch.object(install, "_offline_only", return_value=True), \
+ mock.patch.object(install, "_tui_sources",
+ return_value="6 files copied from OLD, 16 still missing."):
+ _title, lines = install.tui_outcome(state)
+ self.assertEqual(lines[0], "6 files copied from OLD, 16 still missing.")
+ self.assertIn("They are not in", lines[1])
+
+ def test_q_is_said_as_a_quit(self):
+ """P3-19: q reported "stopped", the word of an interrupted run."""
+ state = checked_state()
+ press(state, "q")
+ self.assertEqual(state.exit_code, 130)
+ self.assertEqual(install._closing_lines(state),
+ ["RetroBIOS: quit. Nothing was installed."])
+ other = checked_state()
+ install.tui_interrupt(other)
+ self.assertEqual(install._closing_lines(other),
+ ["RetroBIOS: stopped. Nothing was installed."])
+
+ def test_wide_rows_keep_the_size_near_the_name(self):
+ """P3-20."""
+ row = install.TuiRow("system", "[x] 3DO 3do", "2 files, 4.9 KB")
+ line = str(install._row_line(row, False, 118))
+ self.assertLessEqual(len(line.rstrip()), install.TUI_ROW_SPREAD)
+
+ def test_the_wsl_browser_titles_a_drive_by_its_letter(self):
+ """P3-13."""
+ state = fresh_state()
+ state.stack = ["browse"]
+ state.browse_dir = Path("/mnt/e")
+ with mock.patch.dict(install._WSL, {"active": True, "root": "/mnt"}), \
+ mock.patch.object(install, "_browse_rows", return_value=([], "")):
+ state.browse_rows = []
+ title = " ".join(str(line) for line in install.tui_view(state, 80, 24)[:4])
+ self.assertIn("E:\\", title)
+ self.assertNotIn("/mnt/e", title)
+
+ def test_the_card_marker_table_is_written_from_the_rules(self):
+ """code_audit_18 P3-6, tui_audit_11 P2-2: the table left out the
+ SHARE drives. It is generated: every card rule and SHARE drive file
+ is a row, about.info among them."""
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
+ self.addCleanup(sys.path.remove, str(REPO_ROOT / "scripts"))
+ import generate_installer_doc # noqa: PLC0415
+
+ page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
+ block = page[page.index(""):
+ page.index("")]
+ for rule in install._card_rules():
+ self.assertIn(f"`{rule.values[0]}", block)
+ for _plat, marker in install._SHARE_DRIVES:
+ self.assertIn(f"`{marker}`", block)
+ self.assertIn(self.ABOUT, block)
+ self.assertIn("Recalbox SHARE drive", block)
+ self.assertEqual(generate_installer_doc.render(page), page)
+
+class TheAndroidResultReadsWhole(unittest.TestCase):
+ """A run on Android 17 under Termux: the result named /sdcard and
+ /storage/emulated/0 for one storage, saves/ read as a folder named
+ , a backup path and the restore command were split inside a word,
+ and the game data line named no folder."""
+
+ def setUp(self):
+ (REPO_ROOT / "tmp").mkdir(exist_ok=True)
+ self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
+ self.addCleanup(shutil.rmtree, self.root, True)
+ for patcher in (mock.patch.dict(install._SPELLED, {}, clear=True),):
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_one_storage_is_named_one_way(self):
+ real = self.root / "storage" / "emulated" / "0"
+ (real / "RetroArch" / "system").mkdir(parents=True)
+ link = self.root / "sdcard"
+ link.symlink_to(real)
+ bios = link / "RetroArch" / "system"
+ self.assertEqual(install._destination_path(bios, "a.bin"),
+ real.resolve() / "RetroArch" / "system" / "a.bin")
+ kept = install.KeptFiles()
+ run = kept.plan(install._kept_slot(bios, "a.bin")[0], 3)
+ folder = kept.summary()[0]["folder"]
+ self.assertTrue(folder.startswith(str(link / "RetroArch" / "retrobios-backup")),
+ folder)
+ self.assertEqual(install.shown_path(run), folder)
+ self.assertNotIn(str(real), install.shown_path(real.resolve() / "RetroArch" / "saves"))
+
+ def test_a_folder_per_core_is_said_in_words(self):
+ where = "/sdcard/RetroArch/saves/"
+ line = install.saves_line("RetroArch", 106, where)
+ self.assertEqual(line, "106 files read from RetroArch's save directory go to a "
+ "folder per core under /sdcard/RetroArch/saves")
+ self.assertEqual(install.save_where_words("/s/saves/"),
+ ("a folder per system under", "/s/saves"))
+ state = checked_state()
+ screen = install._saves_lines(state, {"files": 106, "folder": where})
+ self.assertNotIn("", " ".join(screen))
+ self.assertIn("a folder per core under:", screen[0])
+
+ def result(self, cols: int) -> list:
+ state = _installed_state(Path("/sdcard/RetroArch/system"))
+ state.summary["kept"] = [{"folder": "/sdcard/RetroArch/retrobios-backup/"
+ "20261027-223524", "files": 3, "bytes": 10}]
+ state.cols, state.rows = cols, 40
+ return install.tui_view(state, cols, 40)
+
+ def test_a_path_or_a_command_is_never_split_inside_a_word(self):
+ for cols in (40, 50, 80):
+ with self.subTest(cols=cols):
+ lines = [str(line).strip() for line in self.result(cols)]
+ self.assertIn("/sdcard/RetroArch/retrobios-backup/20261027-223524", lines)
+ words = [word for line in lines for word in line.split()]
+ self.assertIn("--restore", words)
+ self.assertTrue(all(not word.endswith("/") for word in words), words)
+ state = _installed_state(Path("/sdcard/RetroArch/system"))
+ command = install._styled("curl -fsSL https://raw.githubusercontent.com/Abdess/"
+ "retrobios/main/install.sh | sh -s -- --restore "
+ "'/storage/emulated/0/a b/20261027-223524'", "command")
+ state.stack.append("result")
+ pieces = install._text_lines([command], 38, whole=True)
+ joined = [str(piece).strip() for piece in pieces if piece.style != "cont"]
+ self.assertIn("https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh",
+ joined)
+ self.assertIn("'/storage/emulated/0/a b/20261027-223524'", joined)
+
+ def test_a_long_line_folds_in_the_terminal(self):
+ terminal = install._TuiTerminal.__new__(install._TuiTerminal)
+ terminal.frame, written = [], []
+ terminal.write = written.append
+ long = install._styled("x" * 50, "long")
+ terminal.draw([long, install._styled("", "cont"), install._styled("next")], 40)
+ out = "".join(written)
+ self.assertIn("x" * 50, out)
+ self.assertNotIn("\x1b[2;1H", out)
+ self.assertIn("\x1b[3;1H", out)
+
+ def test_game_data_names_the_folder_it_goes_in(self):
+ entry = {"dest": "jazz2/Source/Anims.j2a", "required": True, "category": "game_data",
+ "system": "jazz-jackrabbit-2"}
+ bios = Path("/sdcard/RetroArch/system")
+ lines = install._omission_lines(1, [entry], bios)
+ said = " ".join(lines)
+ self.assertIn(f"into {bios / 'jazz2' / 'Source'}{os.sep}", said)
+ self.assertIn("files a game or a game engine reads", said)
+ self.assertNotIn("Needed to start an emulator", said)
+
+
+MOUSE_SIZES = ((40, 12), (80, 24), (120, 40))
+
+
+def sgr(col: int, row: int, button: int = 0, release: bool = False) -> str:
+ """An SGR mouse report, the cell counted from 1."""
+ return f"\x1b[<{button};{col};{row}{'m' if release else 'M'}"
+
+
+def x10(col: int, row: int, button: int = 0) -> str:
+ """A legacy report: 32 plus each value."""
+ return "\x1b[M" + "".join(chr(32 + value) for value in (button, col, row))
+
+
+def drawn(state: install.TuiState, cols: int, rows: int) -> list:
+ """The frame as the loop draws it, its targets kept as the loop keeps them."""
+ state.cols, state.rows = cols, rows
+ lines = install.tui_view(state, cols, rows)
+ state.hits = install.tui_hits(state, lines)
+ return lines
+
+
+def tap(state: install.TuiState, col: int, row: int, button: int = 0) -> list:
+ """Press and release on one cell, columns and lines counted from 0."""
+ effects = install.tui_update(state, install.TuiMouse(button, col + 1, row + 1))
+ return effects + install.tui_update(state, install.TuiMouse(button, col + 1, row + 1,
+ True))
+
+
+class MouseReports(unittest.TestCase):
+ """Mouse reports come out of the decoder as reports, never as keys."""
+
+ def feed(self, *chunks: str) -> list:
+ decoder = install.KeyDecoder()
+ found = []
+ for chunk in chunks:
+ found += decoder.feed(chunk)
+ return found + decoder.flush()
+
+ def test_sgr_press_release_and_wheel(self):
+ Mouse = install.TuiMouse
+ self.assertEqual(self.feed(sgr(10, 5), sgr(10, 5, release=True)),
+ [Mouse(0, 10, 5), Mouse(0, 10, 5, True)])
+ self.assertEqual(self.feed(sgr(3, 4, 64), sgr(3, 4, 65)),
+ [Mouse(64, 3, 4), Mouse(65, 3, 4)])
+ self.assertEqual(self.feed(sgr(1, 1, 2)), [Mouse(2, 1, 1)])
+
+ def test_sgr_past_the_legacy_limit(self):
+ self.assertEqual(self.feed(sgr(300, 250)), [install.TuiMouse(0, 300, 250)])
+
+ def test_sgr_split_across_reads(self):
+ report = sgr(12, 7)
+ for cut in range(1, len(report)):
+ with self.subTest(cut=cut):
+ decoder = install.KeyDecoder()
+ found = decoder.feed(report[:cut])
+ found += decoder.feed(report[cut:] + "j")
+ self.assertEqual(found, [install.TuiMouse(0, 12, 7), Key("char", "j")])
+
+ def test_legacy_reports(self):
+ Mouse = install.TuiMouse
+ self.assertEqual(self.feed(x10(5, 6), x10(5, 6, 3), x10(5, 6, 64)),
+ [Mouse(0, 5, 6), Mouse(3, 5, 6, True), Mouse(64, 5, 6)])
+
+ def test_legacy_bytes_are_never_keys(self):
+ """A position past 95 is a byte UTF-8 replaces: the report is
+ dropped, and none of its bytes is read as a key press."""
+ for tail in ("�!!", "!�!", " qj"):
+ with self.subTest(tail=tail):
+ found = self.feed("\x1b[M" + tail, "k")
+ self.assertEqual([key for key in found if key.name != "mouse"],
+ [Key("char", "k")])
+
+ def test_a_cut_report_is_dropped_whole(self):
+ self.assertEqual(self.feed("\x1b[<0;10"), [])
+ self.assertEqual(self.feed("\x1b[<0;10;1;7M", "j"), [Key("char", "j")])
+
+
+class MouseEnabled(unittest.TestCase):
+ """Clicks are asked for only where they can come and are wanted."""
+
+ class Tty(io.StringIO):
+ def isatty(self) -> bool:
+ return True
+
+ def blocker(self, env: dict, interactive: bool = True) -> str:
+ stream = self.Tty() if interactive else io.StringIO()
+ return install.tui_mouse_blocker(stream, stream, env)
+
+ def test_an_xterm_like_terminal_takes_clicks(self):
+ for term in ("xterm", "xterm-256color", "screen-256color", "tmux-256color",
+ "alacritty", "foot", "xterm-kitty", "rxvt-unicode-256color"):
+ with self.subTest(term=term):
+ self.assertEqual(self.blocker({"TERM": term}), "")
+
+ def test_what_keeps_them_off(self):
+ for env, interactive in (
+ ({"TERM": "linux"}, True), ({"TERM": "dumb"}, True), ({}, True),
+ ({"TERM": "vt100"}, True), ({"TERM": "xterm"}, False),
+ ({"TERM": "xterm", install.TUI_PAD_ENV: "gptokeyb"}, True),
+ ({"TERM": "xterm", install.TUI_MOUSE_ENV: "1"}, True)):
+ with self.subTest(env=env, interactive=interactive):
+ self.assertNotEqual(self.blocker(env, interactive), "")
+ self.assertEqual(self.blocker({"TERM": "xterm", install.TUI_MOUSE_ENV: "0"}), "")
+
+ def test_a_windows_console_reads_no_clicks(self):
+ with mock.patch.object(install.os, "name", "nt"):
+ self.assertIn("Windows", self.blocker({"TERM": "xterm"}))
+
+ def test_the_terminal_writes_a_change_only(self):
+ terminal = install._TuiTerminal.__new__(install._TuiTerminal)
+ written = []
+ terminal.write = written.append
+ terminal.mouse_allowed, terminal.mouse, terminal.entered = True, False, True
+ terminal.set_mouse(True)
+ terminal.set_mouse(True)
+ terminal.set_mouse(False)
+ self.assertEqual(written, [install._MOUSE_ON, install._MOUSE_OFF])
+ terminal.mouse_allowed = False
+ terminal.set_mouse(True)
+ self.assertEqual(len(written), 2)
+
+ def test_modes_asked_for(self):
+ self.assertEqual(install._MOUSE_ON, "\x1b[?1000h\x1b[?1006h")
+ self.assertIn(install._MOUSE_OFF, install._SCREEN_OFF)
+ source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
+ for mode in ("1002h", "1003h", "1005h", "1015h"):
+ self.assertNotIn("?" + mode, source)
+
+ def test_the_text_to_copy_keeps_the_terminal_selection(self):
+ states = golden_states()
+ for name in ("result", "command", "path"):
+ with self.subTest(screen=name):
+ state = states[name]
+ self.assertFalse(install.tui_wants_mouse(state, drawn(state, 80, 24)))
+ for name in ("setup", "platforms", "plan", "systems", "options", "error", "quit"):
+ with self.subTest(screen=name):
+ state = states[name]
+ self.assertTrue(install.tui_wants_mouse(state, drawn(state, 80, 24)))
+
+ def test_a_plan_naming_a_command_keeps_the_selection(self):
+ state = checked_state({"free": 50 * 1024 ** 3, "writable": False,
+ "cores": ["pcsx_rearmed"]})
+ with mock.patch.object(install, "_on_read_only_mount", return_value=False):
+ lines = drawn(state, 120, 40)
+ self.assertIn("install.py", " ".join(lines))
+ self.assertFalse(install.tui_wants_mouse(state, lines))
+
+ def test_the_view_adds_no_text(self):
+ """A frame drawn with its targets is the frame drawn without them."""
+ patcher = mock.patch.object(install, "_mounted_cards", return_value=[])
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ for name, state in golden_states().items():
+ for cols, rows in MOUSE_SIZES:
+ with self.subTest(screen=name, size=f"{cols}x{rows}"):
+ path = GOLDEN / f"{name}-{cols}x{rows}.txt"
+ if not path.exists():
+ continue
+ lines = drawn(state, cols, rows)
+ text = "\n".join(line.rstrip() for line in lines) + "\n"
+ self.assertEqual(text, path.read_text(encoding="utf-8"))
+
+
+class MouseOnEveryScreen(unittest.TestCase):
+ """A tap does on each screen what its key does, at each size."""
+
+ def setUp(self):
+ patcher = mock.patch.object(install, "_mounted_cards", return_value=[])
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ @staticmethod
+ def twin(state: install.TuiState) -> install.TuiState:
+ import copy # noqa: PLC0415
+
+ return copy.deepcopy(state)
+
+ def test_rows_and_bar_buttons(self):
+ checked = 0
+ for name, base in golden_states().items():
+ for cols, rows in MOUSE_SIZES:
+ lines = drawn(base, cols, rows)
+ hits = base.hits
+ for line, first, end, target in hits.targets:
+ with self.subTest(screen=name, size=f"{cols}x{rows}", target=target):
+ state, keyed = self.twin(base), self.twin(base)
+ if target[0] == "row":
+ index = target[1]
+ row_list = install.tui_rows(state)
+ focus = max(0, min(state.focus.get(state.screen, 0),
+ len(row_list) - 1))
+ self.assertEqual(lines[line].style == "focus", index == focus)
+ # The whole width, the text's end or not.
+ effects = tap(state, cols - 1, line)
+ if index == focus:
+ self.assertEqual(effects,
+ install.tui_update(keyed, Key("enter")))
+ self.assertEqual(state.stack, keyed.stack)
+ else:
+ self.assertEqual(effects, [])
+ self.assertEqual(state.focus[state.screen], index)
+ self.assertEqual(state.stack, base.stack)
+ elif target[0] == "key":
+ label = str(lines[line])[first:end].split(" ", 1)[0]
+ self.assertIn(label, install._BAR_CLICKS)
+ effects = tap(state, first, line)
+ self.assertEqual(effects, install.tui_update(keyed, target[1]))
+ self.assertEqual((state.stack, state.help),
+ (keyed.stack, keyed.help))
+ checked += 1
+ self.assertGreater(checked, 200)
+
+ def test_two_taps_activate_a_row(self):
+ state = fresh_state()
+ lines = drawn(state, 80, 24)
+ another = next(index for index, line in enumerate(lines)
+ if "Choose another" in line or "another" in line.lower())
+ self.assertEqual(tap(state, 30, another), [])
+ self.assertEqual(state.screen, "setup")
+ drawn(state, 80, 24)
+ tap(state, 30, another)
+ self.assertEqual(state.screen, "platforms")
+
+ def test_release_acts_not_press(self):
+ state = fresh_state()
+ lines = drawn(state, 80, 24)
+ row = next(i for i, line in enumerate(lines) if line.style != "focus"
+ and getattr(line, "mark", None) and line.mark[0] == "row")
+ install.tui_update(state, install.TuiMouse(0, 5, row + 1))
+ self.assertEqual(state.focus.get("setup", 0), 0)
+ # Released on another row: a swipe, which does nothing.
+ focused = next(i for i, line in enumerate(lines) if line.style == "focus")
+ install.tui_update(state, install.TuiMouse(0, 5, focused + 1, True))
+ self.assertEqual(state.focus.get("setup", 0), 0)
+ self.assertIsNone(state.pressed)
+ # A release with no press before it does nothing either.
+ install.tui_update(state, install.TuiMouse(0, 5, row + 1, True))
+ self.assertEqual(state.focus.get("setup", 0), 0)
+
+ def test_legacy_release_names_no_button(self):
+ state = fresh_state()
+ lines = drawn(state, 80, 24)
+ row = next(i for i, line in enumerate(lines) if line.style != "focus"
+ and getattr(line, "mark", None) and line.mark[0] == "row")
+ decoder = install.KeyDecoder()
+ for event in decoder.feed(x10(5, row + 1) + x10(5, row + 1, 3)):
+ install.tui_update(state, event)
+ self.assertEqual(state.focus["setup"], lines[row].mark[1])
+
+ def test_right_middle_and_modified_clicks_do_nothing(self):
+ for button in (1, 2, 4, 8, 16, 32):
+ with self.subTest(button=button):
+ state = fresh_state()
+ lines = drawn(state, 80, 24)
+ row = next(i for i, line in enumerate(lines) if line.style != "focus"
+ and getattr(line, "mark", None) and line.mark[0] == "row")
+ self.assertEqual(tap(state, 5, row, button), [])
+ self.assertEqual(state.focus.get("setup", 0), 0)
+
+ def test_a_click_on_another_frame_does_nothing(self):
+ """Read against the frame on screen when it came: Enter, typed in
+ the same read, opened the next screen before the click was read."""
+ state = fresh_state()
+ lines = drawn(state, 80, 24)
+ row = next(i for i, line in enumerate(lines) if line.style != "focus"
+ and getattr(line, "mark", None) and line.mark[0] == "row")
+ install.tui_update(state, Key("char", "?"))
+ self.assertEqual(tap(state, 5, row), [])
+ self.assertTrue(state.help)
+ state = fresh_state()
+ drawn(state, 80, 24)
+ state.stack.append("platforms")
+ self.assertEqual(tap(state, 5, row), [])
+ self.assertEqual(state.focus.get("platforms", 0), 0)
+ # The same screen with its rows changed under the frame.
+ state = fresh_state()
+ drawn(state, 80, 24)
+ state.setups = state.setups[1:]
+ tap(state, 5, row)
+ self.assertEqual(state.focus.get("setup", 0), 0)
+ # A frame of another size.
+ state = fresh_state()
+ drawn(state, 80, 24)
+ state.cols = 100
+ self.assertEqual(tap(state, 5, row), [])
+ self.assertEqual(state.focus.get("setup", 0), 0)
+
+ def test_a_tap_closes_the_help(self):
+ state = golden_states()["help"]
+ drawn(state, 80, 24)
+ tap(state, 3, 5)
+ self.assertFalse(state.help)
+
+ def long_list(self, cols: int = 40, rows: int = 12) -> install.TuiState:
+ state = fresh_state()
+ state.stack = ["setup", "platforms"]
+ drawn(state, cols, rows)
+ self.assertIsNotNone(state.hits.listing)
+ return state
+
+ def test_the_wheel_scrolls_a_list_and_keeps_the_focus_in_view(self):
+ for cols, rows in MOUSE_SIZES[:2]:
+ with self.subTest(size=f"{cols}x{rows}"):
+ state = self.long_list(cols, rows)
+ first, shown, total = state.hits.listing
+ self.assertEqual(state.focus.get("platforms", 0), 0)
+ install.tui_update(state, install.TuiMouse(65, 5, 4))
+ lines = drawn(state, cols, rows)
+ newfirst, _shown, _total = state.hits.listing
+ self.assertEqual(newfirst, first + install.TUI_WHEEL_LINES)
+ # Out of view, the focus came to the first row in view.
+ self.assertEqual(state.focus["platforms"], newfirst)
+ focused = [line.mark[1] for line in lines if line.style == "focus"]
+ self.assertEqual(focused, [newfirst])
+ # Down again: the row in focus is still in view, and stays.
+ state.focus["platforms"] = newfirst + shown - 1
+ drawn(state, cols, rows)
+ install.tui_update(state, install.TuiMouse(64, 5, 4))
+ drawn(state, cols, rows)
+ self.assertEqual(state.hits.listing[0], first)
+ self.assertEqual(state.focus["platforms"], first + shown - 1)
+ # To the end and no further.
+ for _notch in range(total):
+ install.tui_update(state, install.TuiMouse(65, 5, 4))
+ drawn(state, cols, rows)
+ self.assertEqual(state.hits.listing[0], total - shown)
+ # A key afterwards moves the focus from where the wheel put it.
+ at = state.focus["platforms"]
+ install.tui_update(state, Key("down"))
+ self.assertEqual(state.focus["platforms"], at + 1)
+
+ def test_the_wheel_scrolls_a_text(self):
+ state = golden_states()["preview"]
+ state.dry_files = [{**FILES[0], "dest": f"file{n}.bin"} for n in range(40)]
+ drawn(state, 40, 12)
+ self.assertEqual(state.hits.scroll, "pager")
+ install.tui_update(state, install.TuiMouse(65, 5, 4))
+ self.assertEqual(state.focus["preview"], install.TUI_WHEEL_LINES)
+ install.tui_update(state, install.TuiMouse(64, 5, 4))
+ self.assertEqual(state.focus["preview"], 0)
+ install.tui_update(state, install.TuiMouse(64, 5, 4))
+ self.assertEqual(state.focus["preview"], 0)
+
+ def test_the_wheel_scrolls_a_long_message(self):
+ state = golden_states()["error"]
+ state.error = [f"Line {n} of a long message." for n in range(30)]
+ drawn(state, 40, 12)
+ self.assertEqual(state.hits.scroll, "error")
+ focus = state.focus.get("error", 0)
+ install.tui_update(state, install.TuiMouse(65, 5, 4))
+ self.assertEqual(state.focus["error_text"], install.TUI_WHEEL_LINES)
+ self.assertEqual(state.focus.get("error", 0), focus)
+
+ def test_a_tap_on_the_count_shows_the_next_page(self):
+ state = self.long_list()
+ lines = drawn(state, 40, 12)
+ where = next(i for i, line in enumerate(lines)
+ if getattr(line, "mark", None) and line.mark[0] == "scroll")
+ before = state.hits.listing[0]
+ tap(state, 2, where)
+ drawn(state, 40, 12)
+ self.assertGreater(state.hits.listing[0], before)
+ last = len(install.tui_rows(state)) - 1
+ for _page in range(40):
+ if state.focus["platforms"] == last:
+ break
+ tap(state, 2, where)
+ drawn(state, 40, 12)
+ self.assertEqual(state.focus["platforms"], last)
+ # From the last page it goes back to the first.
+ tap(state, 2, where)
+ drawn(state, 40, 12)
+ self.assertEqual((state.focus["platforms"], state.hits.listing[0]), (0, 0))
+
+ def test_a_tap_on_a_row_ends_the_typing_of_a_filter(self):
+ state = self.long_list(80, 24)
+ install.tui_update(state, Key("char", "/"))
+ install.tui_update(state, Key("char", "r"))
+ lines = drawn(state, 80, 24)
+ row = next(i for i, line in enumerate(lines) if line.style != "focus"
+ and getattr(line, "mark", None) and line.mark[0] == "row"
+ and line.mark[2][0] == "platform")
+ tap(state, 5, row)
+ self.assertEqual(state.editing, "")
+ self.assertEqual(state.focus["platforms"], lines[row].mark[1])
+ self.assertEqual(state.filters["platforms"], "r")
+
+
+@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
+ "screen tests need pyte (pip install pyte==0.8.2) and a pty")
+class MouseOnAPty(unittest.TestCase):
+ """The real install.py --tui, clicked through a pseudo-terminal."""
+
+ repo = EndToEnd.repo
+ start = EndToEnd.start
+
+ @staticmethod
+ def line_of(term: Pty, needle: str) -> int:
+ return next(index for index, line in enumerate(term.screen.display) if needle in line)
+
+ def click(self, term: Pty, col: int, row: int) -> None:
+ term.send(sgr(col + 1, row + 1), sgr(col + 1, row + 1, release=True))
+
+ def test_taps_choose_a_row_and_press_the_bar(self):
+ self.repo()
+ term = self.start()
+ term.wait("Where do you play?")
+ term.settle()
+ self.assertIn(install._MOUSE_ON.encode(), term.raw)
+ row = self.line_of(term, "Another setup")
+ self.click(term, 60, row)
+ term.settle()
+ self.assertTrue(term.screen.buffer[row][3].reverse, term.text())
+ self.assertIn("Where do you play?", term.text())
+ self.click(term, 60, row)
+ term.wait("Which setup?")
+ # The bar's ? Help, pressed by a click.
+ bar = term.screen.display[-1]
+ self.click(term, bar.index("? Help") + 1, len(term.screen.display) - 1)
+ term.wait("Any key closes this help")
+ self.click(term, 10, 5)
+ term.wait("Which setup?")
+ # Legacy reports move the focus the same way.
+ row = self.line_of(term, " > ") + 2
+ term.send(x10(20, row + 1), x10(20, row + 1, 3))
+ term.settle()
+ self.assertTrue(term.screen.buffer[row][3].reverse, term.text())
+ term.send("q")
+ self.assertEqual(term.close(), 130)
+ raw = term.raw
+ self.assertGreater(raw.rfind(b"\x1b[?1000l"), raw.rfind(b"\x1b[?1000h"))
+
+ def test_the_result_gives_the_mouse_back(self):
+ self.repo()
+ term = self.start()
+ term.wait("Where do you play?")
+ term.send("\r")
+ term.wait("files to download")
+ self.click(term, 20, self.line_of(term, "> Install"))
+ term.wait("files installed.")
+ term.settle()
+ mark = term.raw.rfind(b"files installed")
+ self.assertGreater(term.raw.rfind(b"\x1b[?1000l"), term.raw.rfind(b"\x1b[?1000h"))
+ self.assertNotIn(install._MOUSE_ON.encode(), term.raw[mark:])
+ term.send("\r")
+ self.assertEqual(term.close(), 0)
+
+ def test_no_clicks_asked_when_turned_off(self):
+ for name, value in ((install.TUI_MOUSE_ENV, "1"), (install.TUI_PAD_ENV, "gptokeyb")):
+ with self.subTest(env=name):
+ self.repo()
+ self.env[name] = value
+ term = self.start()
+ term.wait("RetroBIOS")
+ term.settle()
+ term.send("\x03")
+ term.close()
+ self.assertNotIn(b"\x1b[?1000h", term.raw)
+
+ def test_line_mode_asks_for_no_clicks(self):
+ self.repo()
+ term = self.start("--plain")
+ term.settle(quiet=1.0)
+ term.send("\x03")
+ term.close()
+ self.assertNotIn(b"\x1b[?1000h", term.raw)
+ self.assertNotIn(b"\x1b[?1006h", term.raw)
+
+ def test_a_crash_gives_the_clicks_back(self):
+ """The crash path ends in _tui_cleanup, as every other way out."""
+ master, slave = os.openpty()
+ self.addCleanup(os.close, master)
+ self.addCleanup(os.close, slave)
+ given = open(slave, encoding="utf-8", closefd=False) # noqa: SIM115
+ shown = open(slave, "w", encoding="utf-8", closefd=False) # noqa: SIM115
+ self.addCleanup(given.close)
+ self.addCleanup(shown.close)
+ terminal = install._TuiTerminal(given, shown)
+ terminal.mouse_allowed = True
+ terminal.enter()
+ terminal.set_mouse(True)
+ problems = install._tui_cleanup(terminal, [], {}, fresh_state())
+ self.assertEqual(problems, [])
+ raw = b""
+ while select.select([master], [], [], 0.2)[0]:
+ raw += os.read(master, 65536)
+ for mode in (b"1000", b"1006"):
+ self.assertGreater(raw.rfind(b"\x1b[?" + mode + b"l"),
+ raw.rfind(b"\x1b[?" + mode + b"h"))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_minui_platform.py b/tests/test_minui_platform.py
index c75d47b1e..efccfc258 100644
--- a/tests/test_minui_platform.py
+++ b/tests/test_minui_platform.py
@@ -193,6 +193,7 @@ class PackPlacesCoreFilesUnderTheTag(unittest.TestCase):
platform = {
"platform": "MinUI Test",
+ "version": "2025-11-27",
"base_destination": "",
"verification_mode": "existence",
"cores": ["gambatte", "arcadecore", "unlisted"],
diff --git a/tests/test_no_game_data.py b/tests/test_no_game_data.py
index 5e348c0b5..79f2d6b73 100644
--- a/tests/test_no_game_data.py
+++ b/tests/test_no_game_data.py
@@ -92,7 +92,7 @@ class _Fixture(unittest.TestCase):
for key, profile in PROFILES.items():
(self.emulators / f"{key}.yml").write_text(yaml.dump(profile))
platform = {
- "platform": "Handheld", "verification_mode": "existence",
+ "platform": "Handheld", "version": "2.1", "verification_mode": "existence",
"cores": ["emu", "doomcore"],
"systems": {
"console": {"files": [{"name": "firmware.rom", "destination": "firmware.rom"}]},
@@ -289,7 +289,8 @@ class InstallerLeavesGameDataOut(unittest.TestCase):
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertTrue((self.bios / "a.bin").exists())
self.assertFalse((self.bios / "game.wad").exists())
- self.assertIn("Left out as game data (--no-game-data): 2 files: game.wad, level.dat",
+ self.assertIn("Left out as game data (--no-game-data): 2 files, 1 of them not installable: "
+ "game.wad, level.dat",
proc.stdout)
def test_check_and_dry_run_leave_it_out(self):
@@ -299,7 +300,8 @@ class InstallerLeavesGameDataOut(unittest.TestCase):
with self.subTest(mode=mode):
proc = self.run_installer("--no-game-data", mode)
self.assertIn(
- "Left out as game data (--no-game-data): 2 files: game.wad, level.dat",
+ "Left out as game data (--no-game-data): 2 files, 1 of them not installable: "
+ "game.wad, level.dat",
proc.stdout,
)
self.assertIn(listed, proc.stdout)
diff --git a/tests/test_pack_counts.py b/tests/test_pack_counts.py
index 85fbead0a..461ed4942 100644
--- a/tests/test_pack_counts.py
+++ b/tests/test_pack_counts.py
@@ -52,6 +52,7 @@ files:
PLATFORM = {
"platform": "Demo",
+ "version": "2.1",
"verification_mode": "existence",
"base_destination": "",
"cores": ["demo"],
@@ -494,7 +495,7 @@ class RefusedFilesAreNotPresent(unittest.TestCase):
}
db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
config = {
- "platform": "Hashy", "verification_mode": "md5", "base_destination": "bios",
+ "platform": "Hashy", "version": "2.1", "verification_mode": "md5", "base_destination": "bios",
"systems": {"sys": {"files": [
{"name": "good.bin", "destination": "good.bin",
"md5": hashlib.md5(b"good.bin").hexdigest()},
diff --git a/tests/test_profile_sync.py b/tests/test_profile_sync.py
index 1c78c1f94..a807a9bc6 100644
--- a/tests/test_profile_sync.py
+++ b/tests/test_profile_sync.py
@@ -60,9 +60,29 @@ from profile_sync import (
worst_status,
)
import upstream
+import version_basis
from upstream import CompareResult, FileChange
+def _version_read_as_written(profile, cache_dir, offline, profiles=None, recompute=False):
+ """bump_commit reads core_version again at the new pin. These fixtures
+ have no forge behind them, so the version reads as written here:
+ tests/test_version_basis.py holds the reading and the refusals."""
+ stated = version_basis.declared(profile, "")
+ return [version_basis.Reading("", "file", stated, stated, "fixture", version_basis.MATCH)]
+
+
+_VERSION_AS_WRITTEN = mock.patch.object(version_basis, "check", _version_read_as_written)
+
+
+def setUpModule():
+ _VERSION_AS_WRITTEN.start()
+
+
+def tearDownModule():
+ _VERSION_AS_WRITTEN.stop()
+
+
class TestParseSourceRef(unittest.TestCase):
def test_path_with_single_line(self):
self.assertEqual(
diff --git a/tests/test_required_only_twins.py b/tests/test_required_only_twins.py
index 8c06a9dff..c8cabe4c3 100644
--- a/tests/test_required_only_twins.py
+++ b/tests/test_required_only_twins.py
@@ -51,6 +51,7 @@ class RequiredTwins(unittest.TestCase):
self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})}
platform = {
"platform": "Twins",
+ "version": "2.1",
"verification_mode": "md5",
"base_destination": "bios",
"cores": [],
diff --git a/tests/test_save_root.py b/tests/test_save_root.py
index 0b1ae378a..e0a58c5ea 100644
--- a/tests/test_save_root.py
+++ b/tests/test_save_root.py
@@ -243,7 +243,7 @@ class _Fixture(unittest.TestCase):
"required": False}]}
for name in ("testra", "testnosave"):
(self.platforms / f"{name}.yml").write_text(yaml.safe_dump({
- "platform": name, "verification_mode": "existence",
+ "platform": name, "version": "2.1", "verification_mode": "existence",
"base_destination": "system", "cores": ["corex"],
"systems": {"test-console": system},
}))
@@ -399,7 +399,7 @@ class AFolderPerSystem(_Fixture):
"required": False}]}
for name in ("testbato", "testrb"):
(self.platforms / f"{name}.yml").write_text(yaml.safe_dump({
- "platform": name, "verification_mode": "existence",
+ "platform": name, "version": "2.1", "verification_mode": "existence",
"base_destination": "bios", "cores": ["corex", "corey"],
"systems": {"test-console": system},
# Two systems run CoreX, none runs CoreY.
diff --git a/tests/test_site_density_cards.py b/tests/test_site_density_cards.py
new file mode 100644
index 000000000..afe64f11d
--- /dev/null
+++ b/tests/test_site_density_cards.py
@@ -0,0 +1,304 @@
+"""File cards and install boxes read at a glance (density round, 2026-10-27).
+
+A system page wrote every setup's install command for each console, twice:
+12 long lines for the PSP. A file card showed four full hashes, the
+collection path and the catalogue records before the next file. Now a box
+shows one command for the setup picked, and a card shows the name, the
+size, the download, a short SHA1, the setups and the emulators. The rest
+sits in closed folds, every fact still on the page.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+import types
+import unittest
+from html.parser import HTMLParser
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+
+import generate_site as gs # noqa: E402
+import sitemarkup as markup # noqa: E402
+from cross_reference import FileConsumers # noqa: E402
+
+DIST = REPO_ROOT / "website" / "dist"
+SITE_TS = REPO_ROOT / "website" / "src" / "scripts" / "site.ts"
+
+SHA1 = "30a33f3651f8e1b0967a5dfe6ff714baebcce41a"
+MD5 = "866855cc330b9b95cc69135fb7b41d38"
+CRC32 = "7b57fa78"
+SHA256 = "3ab7fcf6956d0281ada94d501fc6d1cfa8fc034f139e25fed6c829f723d92a95"
+
+
+class _Visible(HTMLParser):
+ """Words shown before any fold is opened: text outside closed details
+ (their summary counts), hidden elements, scripts and styles."""
+
+ VOID = {"br", "img", "input", "wbr", "hr", "meta", "link", "source"}
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.stack: list[tuple[str, object]] = []
+ self.hidden = 0
+ self.words: list[str] = []
+
+ def handle_starttag(self, tag: str, attrs: list) -> None:
+ if tag in self.VOID:
+ return
+ names = dict(attrs)
+ if tag == "summary" and self.stack and self.stack[-1] == ("details", True):
+ self.hidden -= 1
+ self.stack.append((tag, "shown"))
+ return
+ hides = tag in ("script", "style") or "hidden" in names or (
+ tag == "details" and "open" not in names)
+ self.hidden += hides
+ self.stack.append((tag, hides))
+
+ def handle_endtag(self, tag: str) -> None:
+ while self.stack:
+ name, hides = self.stack.pop()
+ if hides == "shown":
+ self.hidden += 1
+ elif hides:
+ self.hidden -= 1
+ if name == tag:
+ break
+
+ def handle_data(self, data: str) -> None:
+ if not self.hidden:
+ self.words += data.split()
+
+
+def visible_words(html_text: str) -> list[str]:
+ parser = _Visible()
+ parser.feed(html_text)
+ return parser.words
+
+
+def _main(page: str) -> str:
+ text = (DIST / page / "index.html").read_text(encoding="utf-8")
+ return text[text.index("")]
+
+
+def _card_consumers(sha1: str) -> types.SimpleNamespace:
+ return types.SimpleNamespace(
+ platforms={sha1: {"batocera", "retroarch"}}, emulators={sha1: {"ppsspp"}},
+ platform_systems={sha1: {"batocera": {"sony-psp"}, "retroarch": {"sony-psp"}}},
+ platform_destinations={sha1: {"batocera": {"psp/ppge_atlas.zim"},
+ "retroarch": {"PPSSPP/ppge_atlas.zim"}}},
+ installed={sha1: {"knulli": {"PPSSPP/ppge_atlas.zim"}}},
+ emulator_entries={}, emulator_files={},
+ )
+
+
+def _card() -> dict:
+ return {"name": "ppge_atlas.zim", "sha1": SHA1, "md5": MD5, "crc32": CRC32,
+ "sha256": SHA256, "size": 666539,
+ "path": "bios/Sony/PlayStation Portable/ppge_atlas.zim",
+ "provenance": {"tosec": {"name": "ppge_atlas (PPSSPP).zim"}}}
+
+
+class ASystemCardShowsTheAnswerFirst(unittest.TestCase):
+ def setUp(self) -> None:
+ self.lines = gs._system_file_card(_card(), _card_consumers(SHA1))
+ self.text = "\n".join(self.lines)
+ self.fold = self.text[self.text.index("")]
+ self.shown = self.text.replace(self.fold, "")
+
+ def test_the_open_part(self) -> None:
+ for fact in ("ppge_atlas.zim", ">650.9 KB<", ">Download",
+ "
On the setup's list
", "
Packed for its emulators by
",
+ "
Emulators
", f'
SHA1
{SHA1[:12]}
'):
+ self.assertIn(fact, self.shown)
+
+ def test_every_hash_whole_in_one_fold_with_the_rest(self) -> None:
+ for label, value in (("SHA1", SHA1), ("MD5", MD5), ("CRC32", CRC32), ("SHA256", SHA256)):
+ self.assertIn(f'
{label}
{value}
', self.fold)
+ shown = " ".join(visible_words(self.text))
+ for value in (MD5, CRC32, SHA256, SHA1):
+ self.assertNotIn(value, shown)
+ self.assertIn("
Collection path
", self.fold)
+ self.assertIn("
Catalogued
", self.fold)
+ self.assertEqual(self.text.count("Hashes, collection path and catalogues", self.fold)
+
+ def test_the_fold_is_out_of_the_search(self) -> None:
+ self.assertTrue(self.fold.startswith("\n"))
+
+ def test_no_fact_is_lost(self) -> None:
+ # The yardstick list a page is cut by holds every fact: the
+ # Starlight card holds each of its values.
+ with markup.using("mkdocs"):
+ yardstick = "\n".join(gs._system_file_card(_card(), _card_consumers(SHA1)))
+ for label, values in re.findall(r"(?m)^- ([^:]+): (.*)$", yardstick):
+ self.assertIn(f"