diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index f73a52a08..9a84a5c55 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -45,6 +45,7 @@ on: - "scripts/safeparse.py" - "scripts/sect233r1.py" - "scripts/sitediagrams.py" + - "scripts/siteentries.py" - "scripts/siteguides.py" - "scripts/sitemarkup.py" - "scripts/sitemeasurements.py" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2821345e9..75fb53dbd 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -239,6 +239,16 @@ jobs: - name: Check that shellcheck is installed run: shellcheck --version + # core_probe and the campaign run third-party cores only inside a + # bubblewrap sandbox and refuse without one. Ubuntu 24.04 blocks the + # unprivileged user namespaces bwrap needs, so the campaign tests saw + # "refused". The last command proves a sandbox starts. + - name: Give the sandbox tests a working bubblewrap + run: | + sudo apt-get install -y --no-install-recommends bubblewrap + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + bwrap --ro-bind /usr /usr --symlink usr/lib /lib --symlink usr/lib64 /lib64 --symlink usr/bin /bin --proc /proc --dev /dev --unshare-all /bin/true + # The site is built as deploy-site.yml builds it, from the lockfile. - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: diff --git a/emulators/00_example.yml b/emulators/00_example.yml index b7e3a7100..6f43c3a15 100644 --- a/emulators/00_example.yml +++ b/emulators/00_example.yml @@ -4,7 +4,11 @@ core_classification: pure_libretro source: "https://github.com/libretro/libretro-core-info" profiled_date: "2026-03-22" source_commit: "bd81a0b73b50449c0862b65bd755d87860925f65" -core_version: ~ +core_version: "v1.22.2-3-gbd81a0b" +version_basis: + kind: tag + tag: "v1.22.2" + distance: 3 display_name: "Example Core (.info template)" cores: - 00_example diff --git a/emulators/2048.yml b/emulators/2048.yml index 149ba2d96..b979db5af 100644 --- a/emulators/2048.yml +++ b/emulators/2048.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/libretro-2048" upstream: "https://github.com/gabrielecirulli/2048" profiled_date: "2026-03-22" source_commit: "39333f7b13dc4daea7c151d9c38d22b961246343" -core_version: "1.0" +core_version: "v1.0" +version_basis: + kind: file + path: "libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "2048" cores: ["2048"] systems: [] diff --git a/emulators/2600-emu.yml b/emulators/2600-emu.yml index 21eb79ad0..4a889f7a3 100644 --- a/emulators/2600-emu.yml +++ b/emulators/2600-emu.yml @@ -7,6 +7,10 @@ profiled_date: "2026-08-11" source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943" upstream_commit: "289a40f1b63022b8bbe5b722061c660009d5de2c" core_version: "1.5.85" +version_basis: + kind: file + path: "EmuFramework/metadata/conf.mk" + pattern: '^metadata_version\s*=\s*(\S+)' display_name: "Atari - 2600 (2600.emu)" mode: standalone cores: diff --git a/emulators/2ship.yml b/emulators/2ship.yml index 8b8c6e53a..dfe5b4dac 100644 --- a/emulators/2ship.yml +++ b/emulators/2ship.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/2ship2harkinian/2ship2harkinian" source_commit: "8a24047fbce8915993804e7819f4df4fa591551f" profiled_date: "2026-10-20" core_version: "5.0.1" +version_basis: + kind: tag + tag: "5.0.1" + distance: 0 display_name: "The Legend of Zelda: Majora's Mask (2 Ship 2 Harkinian)" cores: [2ship] systems: [2ship] diff --git a/emulators/3dengine.yml b/emulators/3dengine.yml index 14f091617..c311bacf4 100644 --- a/emulators/3dengine.yml +++ b/emulators/3dengine.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/libretro-3dengine" profiled_date: "2026-03-22" source_commit: "efa2bf6f32f930721fe9ae2361d7ec858077c483" core_version: "v1" +version_basis: + kind: file + path: "libretro.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Test Core - 3D Engine" cores: [3dengine] systems: [] diff --git a/emulators/3dsen.yml b/emulators/3dsen.yml index 01eb8faa0..50d413574 100644 --- a/emulators/3dsen.yml +++ b/emulators/3dsen.yml @@ -8,6 +8,9 @@ version_source: pattern: '[Vv](?:ersion\s*)?(\d+(?:\.\d+)+)' profiled_date: "2026-08-07" core_version: "1.1" +version_basis: + kind: unverified + why: no_artifact display_name: "3dSen (3D NES)" cores: - 3dsen diff --git a/emulators/81.yml b/emulators/81.yml index 7860258da..f69854522 100644 --- a/emulators/81.yml +++ b/emulators/81.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-22" source_commit: "fa7094910d040baa5fd8b11dbf6a1a618330ecd9" upstream_commit: "4de3cdb8cff0060ad03e43ae2681b0628a9c5f5d" core_version: "1.0a" +version_basis: + kind: file + path: "src/libretro.cpp" + pattern: '#define\s+VERSION\s+"([^"]+)' display_name: "Sinclair - ZX 81 (EightyOne)" cores: ["81"] systems: diff --git a/emulators/a5200.yml b/emulators/a5200.yml index 2a8f26dca..5d3744763 100644 --- a/emulators/a5200.yml +++ b/emulators/a5200.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/alekmaul/a5200" profiled_date: "2026-03-22" source_commit: "40c6f2f1ad4a3145b328d5baaf010fae6c7e752b" core_version: "2.0.2" +version_basis: + kind: file + path: "libretro/libretro.c" + pattern: 'info->library_version\s*=\s*"([^"\s]+)' display_name: "Atari - 5200 (a5200)" cores: - a5200 diff --git a/emulators/a7800.yml b/emulators/a7800.yml index c6a9cc7c5..efbcab113 100644 --- a/emulators/a7800.yml +++ b/emulators/a7800.yml @@ -7,6 +7,10 @@ profiled_date: "2026-08-08" source_commit: "7a2afdc1ea08fc331b16b750d8c1f02d4ef62fc8" upstream_commit: "39eb9e44c394df9aec8186f2d2ef7517ace232e1" core_version: "5.2" +version_basis: + kind: file + path: "makefile" + pattern: '#define BARE_BUILD_VERSION \\?"([^"\\]+)' display_name: "Atari - 7800 (A7800)" cores: - a7800 diff --git a/emulators/abuse.yml b/emulators/abuse.yml index b0d2c799d..e2def0df1 100644 --- a/emulators/abuse.yml +++ b/emulators/abuse.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/Xenoveritas/abuse" profiled_date: "2026-10-04" source_commit: "c855a923cd7fb27b629b4e1af78b1fcc5f263d01" core_version: "v0.9.1" +version_basis: + kind: tag + tag: "v0.9.1" + distance: 0 display_name: "Abuse" cores: [abuse] systems: [] diff --git a/emulators/ace-dl.yml b/emulators/ace-dl.yml index a3efc25a7..53fa25297 100644 --- a/emulators/ace-dl.yml +++ b/emulators/ace-dl.yml @@ -8,6 +8,10 @@ version_source: pattern: 'New release <i>[^<]*</i> - (\d{4}/\d{2}/\d{2})' profiled_date: "2026-10-20" core_version: "2026/10/16" +version_basis: + kind: package + url: "https://roudoudou.com/ACE-DL/" + artifact: "BNDGEN.tar.gz (ACE-DL Linux x86-64 build), release date on the download page" display_name: "Amstrad - CPC/Plus (ACE-DL)" cores: - ace-dl diff --git a/emulators/advanced_drastic.yml b/emulators/advanced_drastic.yml index 541d8251c..620b41e0a 100644 --- a/emulators/advanced_drastic.yml +++ b/emulators/advanced_drastic.yml @@ -8,6 +8,10 @@ profiled_date: "2026-10-22" source_commit: "eaf41ab9bc8adcc15c010bf9517bc94c38e7bffd" pin_frozen: "KNULLI, the only platform that ships it, builds this revision (KNULLI package/emulators/advanced_drastic/advanced_drastic.mk:7 at tag 20260511). Later commits are not part of that build." core_version: "r2.5.2.0" +version_basis: + kind: file + path: "drastic" + pattern: "Version (r[0-9][0-9.]+[0-9])" display_name: "Nintendo - DS (advanced_drastic)" cores: - "advanced_drastic" diff --git a/emulators/advanced_tests.yml b/emulators/advanced_tests.yml index 4540fa076..0bed39a68 100644 --- a/emulators/advanced_tests.yml +++ b/emulators/advanced_tests.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/libretro-samples" profiled_date: "2026-03-22" source_commit: "7c2ad112d7a5176de2191e473369668da8946b16" core_version: "v1" +version_basis: + kind: file + path: "audio/audio_callback/libretro-test.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Test Core - Advanced Test" cores: - advanced_tests diff --git a/emulators/advancemame.yml b/emulators/advancemame.yml index 7a8398eac..5ece6fafe 100644 --- a/emulators/advancemame.yml +++ b/emulators/advancemame.yml @@ -5,7 +5,12 @@ source: "https://github.com/amadvance/advancemame" upstream: "https://www.mamedev.org" profiled_date: "2026-10-26" source_commit: "81670476672b59f430518a63e9a116db58ed6232" -core_version: "5.0" +core_version: "v5.0-185-g8167047" +version_basis: + kind: tag + match: "v*" + tag: "v5.0" + distance: 185 mame_version: "0.106" display_name: "Arcade (AdvanceMAME)" mode: standalone diff --git a/emulators/advmame-0.94.yml b/emulators/advmame-0.94.yml index 7d8c603a2..5e3d38983 100644 --- a/emulators/advmame-0.94.yml +++ b/emulators/advmame-0.94.yml @@ -5,6 +5,11 @@ source: "https://files.retropie.org.uk/archives/advancemame-0.94.0.tar.gz" upstream: "https://www.mamedev.org" profiled_date: "2026-10-20" core_version: "0.94.0" +version_basis: + kind: package + url: "https://files.retropie.org.uk/archives/advancemame-0.94.0.tar.gz" + pattern: 'advancemame-(\d+\.\d+\.\d+)\.tar\.gz' + artifact: "advancemame-0.94.0.tar.gz" mame_version: "0.94" display_name: "Arcade (AdvanceMAME 0.94)" mode: standalone diff --git a/emulators/advmame-1.4.yml b/emulators/advmame-1.4.yml index e8f0a7697..490b994d7 100644 --- a/emulators/advmame-1.4.yml +++ b/emulators/advmame-1.4.yml @@ -6,7 +6,12 @@ upstream: "https://www.mamedev.org" profiled_date: "2026-10-20" source_commit: "237ca5c898e51e1b7a70ce52bccf008aa722e924" pin_frozen: "RetroPie builds the advancemame-1.4 release tarball, whose src/ tree is this tag. Later commits are not part of that build." -core_version: "1.4" +core_version: "advancemame-1.4" +version_basis: + kind: tag + match: "advancemame-*" + tag: "advancemame-1.4" + distance: 0 mame_version: "0.106" display_name: "Arcade (AdvanceMAME 1.4)" mode: standalone diff --git a/emulators/aethersx2.yml b/emulators/aethersx2.yml index ba3a0c004..ae3851eb9 100644 --- a/emulators/aethersx2.yml +++ b/emulators/aethersx2.yml @@ -8,6 +8,11 @@ profiled_date: "2026-08-11" source_commit: "1d1f795dfc70e38da67724c7298779260f513889" upstream_commit: "f0523f8acb1d3a765ee73b74cffec63093569199" core_version: "1.5-3939" +version_basis: + kind: package + url: "https://web.archive.org/web/20230320102332/https://www.aethersx2.com/archive/desktop/mac/AetherSX2-v1.5-3939-mac.zip" + pattern: 'AetherSX2-v([0-9.]+-[0-9]+)-mac\.zip' + artifact: "AetherSX2-v1.5-3939-mac.zip" display_name: "Sony - PlayStation 2 (AetherSX2)" cores: - aethersx2 diff --git a/emulators/ags.yml b/emulators/ags.yml index ff986e7e5..6aa46a1fa 100644 --- a/emulators/ags.yml +++ b/emulators/ags.yml @@ -5,7 +5,11 @@ source: "https://github.com/adventuregamestudio/ags" source_branch: release-3.6.0 source_commit: "6802bad8dc3189462003d4a169fc591f64cfb9b6" profiled_date: "2026-10-20" -core_version: "3.6.0.60" +core_version: "v3.6.0.60" +version_basis: + kind: tag + tag: "v3.6.0.60" + distance: 0 display_name: "Adventure Game Studio (AGS)" cores: - ags diff --git a/emulators/alephone.yml b/emulators/alephone.yml index a44938d6c..d0dfaf1a6 100644 --- a/emulators/alephone.yml +++ b/emulators/alephone.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/Aleph-One-Marathon/alephone" profiled_date: "2026-10-20" source_commit: "07a9c36aff403d143a0608815f725631a01e382c" pin_frozen: "RetroPie, the only platform that ships it, builds the release-20250829 tag at this commit (scriptmodules/ports/alephone.sh, _get_branch_alephone). Debian 10 and older get release-20250302." -core_version: "1.11 (20250829)" +core_version: "release-20250829" +version_basis: + kind: tag + tag: "release-20250829" + distance: 0 display_name: "Marathon (Aleph One)" cores: - alephone diff --git a/emulators/altirra.yml b/emulators/altirra.yml index 6497c0a66..0c8f070e9 100644 --- a/emulators/altirra.yml +++ b/emulators/altirra.yml @@ -8,6 +8,11 @@ version_source: pattern: '<h3>Current version</h3>\s*<p>\s*Altirra (\d+\.\d+)' profiled_date: "2026-08-11" core_version: "4.40" +version_basis: + kind: package + url: "https://www.virtualdub.org/downloads/Altirra-4.40-src.7z" + pattern: 'Altirra-(\d+\.\d+)-src\.7z' + artifact: "Altirra-4.40-src.7z" display_name: "Atari - 400/800/1200XL/XL/XE/XEGS/5200 (Altirra)" cores: [altirra] found_by: content diff --git a/emulators/amiarcadia.yml b/emulators/amiarcadia.yml index bb5400f98..ae85e38d0 100644 --- a/emulators/amiarcadia.yml +++ b/emulators/amiarcadia.yml @@ -6,6 +6,10 @@ upstream: "https://amigan.1emu.net/releases/" profiled_date: "2026-10-23" source_commit: "8ca27c925833fe0021475155512c7f467924f647" core_version: "4.60" +version_basis: + kind: file + path: "DroidArcadia/app/src/main/cpp/da.h" + pattern: '#define\s+INTEGERVERSION\s+"([^"]+)"' display_name: "Arcadia 2001 / Interton VC 4000 (AmiArcadia)" cores: - amiarcadia diff --git a/emulators/amiberry.yml b/emulators/amiberry.yml index 530c5016b..af888bb54 100644 --- a/emulators/amiberry.yml +++ b/emulators/amiberry.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/tonioni/WinUAE" profiled_date: "2026-10-26" source_commit: "49dbd1f027ef2afc12862b30c0c31ac8efa66221" upstream_commit: "15110cf6606d1188800ea7ad86bf1cbd9c688741" -core_version: "v8.4.2" +core_version: "8.4.2" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'project\(amiberry\s+VERSION\s+([0-9][0-9.]*)' display_name: "Commodore - Amiga (Amiberry)" cores: [amiberry, "AMIBERRY"] systems: diff --git a/emulators/anarch.yml b/emulators/anarch.yml index 5d7d80ad2..33b7b5b2c 100644 --- a/emulators/anarch.yml +++ b/emulators/anarch.yml @@ -6,6 +6,10 @@ upstream: "https://gitlab.com/drummyfish/anarch" profiled_date: "2026-03-22" source_commit: "187050344ef440c4eaf3135304a020093564fa3f" core_version: "1.1d" +version_basis: + kind: file + path: "texts.h" + pattern: '#define\s+SFG_VERSION_STRING\s+"([^"]+)"' display_name: "Anarch" cores: [anarch] systems: [] diff --git a/emulators/applewin.yml b/emulators/applewin.yml index 739874686..6aaca4062 100644 --- a/emulators/applewin.yml +++ b/emulators/applewin.yml @@ -6,7 +6,12 @@ upstream: "https://github.com/AppleWin/AppleWin" profiled_date: "2026-03-23" source_commit: "0a89db3d593a74d04590b902fb7206723591f445" upstream_commit: "1f3cadb88d1158c139d2e3a7b864d91836856948" -core_version: "1.31.0.1" +core_version: "1.32.1.0" +version_basis: + kind: file + path: "resource/version.h" + pattern: '#define\s+APPLEWIN_VERSION\s+(\d+),(\d+),(\d+),(\d+)' + format: "{1}.{2}.{3}.{4}" display_name: "Apple II (AppleWin)" cores: - applewin diff --git a/emulators/aps3e.yml b/emulators/aps3e.yml index 8ab2d6ddc..92b387d3c 100644 --- a/emulators/aps3e.yml +++ b/emulators/aps3e.yml @@ -8,7 +8,11 @@ profiled_date: "2026-10-06" source_commit: "c6ca18217f2f1563235f83d94df78e35423a1720" upstream_commit: "1f080485b95db2700c5cf244b1314f73b6cf564e" upstream_frozen: "RPCS3 is vendored whole under app/src/main/cpp/rpcs3 at this commit, the tree the 2.43 release reports as 20071-1f080485." -core_version: "2.43" +core_version: "1.37-47-gc6ca182" +version_basis: + kind: tag + tag: "1.37" + distance: 47 display_name: "Sony - PlayStation 3 (aPS3e)" cores: - aps3e diff --git a/emulators/arcadeduck.yml b/emulators/arcadeduck.yml index 829d83610..1df81f16e 100644 --- a/emulators/arcadeduck.yml +++ b/emulators/arcadeduck.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/stenzek/duckstation" source_commit: "80cdb6da6b51d687192a6346efe4499b10fe4faf" upstream_commit: "526b4a128827ecaef037134095cd95f431b52d06" profiled_date: "2026-10-26" -core_version: "0.6.0" +core_version: "0.6.0-dev" +version_basis: + kind: file + path: "src/scmversion/arcadeduck_version.h" + pattern: '#define\s+ARCADEDUCK_SEMANTIC_VERSION\s+"([^"]+)"' display_name: "Arcade - Sony ZN-1, Namco System 11, Konami GV/GQ (ArcadeDuck)" cores: - arcadeduck diff --git a/emulators/arcadeflashweb.yml b/emulators/arcadeflashweb.yml index 3a3803863..9d2931b9c 100644 --- a/emulators/arcadeflashweb.yml +++ b/emulators/arcadeflashweb.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/aureyoboss/ArcadeFlashWeb" profiled_date: "2026-08-11" source_commit: "5de66e0cd69b48721a094887a28d042f71ddad08" core_version: "1.0.2" +version_basis: + kind: file + path: "src/ArcadeFlashWeb.pro" + pattern: '^VERSION\s*\+?=\s*(\S+)' display_name: "Adobe Flash (ArcadeFlashWeb)" cores: - arcadeflashweb diff --git a/emulators/ardens.yml b/emulators/ardens.yml index f92f21638..3fb3eb951 100644 --- a/emulators/ardens.yml +++ b/emulators/ardens.yml @@ -5,7 +5,11 @@ source: "https://github.com/tiberiusbrown/ardens" upstream: "https://github.com/tiberiusbrown/ardens" profiled_date: "2026-03-23" source_commit: "e811cc6be72c8a802bd3265a84daf4165c177b93" -core_version: "Git" +core_version: "v0.25.12" +version_basis: + kind: tag + tag: "v0.25.12" + distance: 0 display_name: "Arduboy (Ardens)" cores: - ardens diff --git a/emulators/arduous.yml b/emulators/arduous.yml index a74d091bb..c22c42c2e 100644 --- a/emulators/arduous.yml +++ b/emulators/arduous.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/jmaroeder/arduous" profiled_date: "2026-03-23" source_commit: "831b83e67ad46e8c86b8165649ff81bc6fc6c157" core_version: "0.1.0" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'project\(arduous\s+VERSION\s+([0-9][\w.+-]*)' display_name: "Arduboy (Arduous)" cores: - arduous diff --git a/emulators/ares.yml b/emulators/ares.yml index 274cc2eed..be9726309 100644 --- a/emulators/ares.yml +++ b/emulators/ares.yml @@ -4,7 +4,12 @@ source: "https://github.com/ares-emulator/ares" upstream: "https://github.com/ares-emulator/ares" profiled_date: "2026-10-23" source_commit: "d400eee9764fe22a8de0247a52757d2021d0539c" -core_version: "v148" +core_version: "v148-143-gd400eee" +version_basis: + kind: tag + match: "v[0-9]*" + tag: "v148" + distance: 143 display_name: "ares" cores: [ares, "ares-sa"] mode: standalone diff --git a/emulators/armsx1.yml b/emulators/armsx1.yml index a0c75a604..c54c7fc9d 100644 --- a/emulators/armsx1.yml +++ b/emulators/armsx1.yml @@ -7,6 +7,10 @@ upstream: "https://github.com/allkern/psxe" profiled_date: "2026-10-26" source_commit: "d96a5b8ad6ccccb40a431488aa89b06f0318c217" core_version: "0.3.6" +version_basis: + kind: file + path: "android/app/build.gradle" + pattern: 'armsxVersionName\s*=\s*\(project\.findProperty\(.armsx\.versionName.\)\s*\?:\s*.([^\x27"]+).' display_name: "Sony - PlayStation (ARMSX1)" verification: md5 found_by: content diff --git a/emulators/armsx2.yml b/emulators/armsx2.yml index fc62f866e..7db9c5be5 100644 --- a/emulators/armsx2.yml +++ b/emulators/armsx2.yml @@ -7,7 +7,12 @@ upstream: "https://github.com/PCSX2/pcsx2" profiled_date: "2026-10-26" source_commit: "7f9144189c74edcf5a7ab3ba2aa2247635d24047" upstream_commit: "36907f4b70d149b05c2700acae687bedb720a7af" -core_version: "2.9" +core_version: "2.9-5-g7f91441" +version_basis: + kind: tag + match: "[0-9]*" + tag: "2.9" + distance: 5 display_name: "Sony - PlayStation 2 (ARMSX2)" cores: - armsx2 diff --git a/emulators/atari800.yml b/emulators/atari800.yml index 6419902b2..cc18f11af 100644 --- a/emulators/atari800.yml +++ b/emulators/atari800.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/atari800/atari800" profiled_date: "2026-03-23" source_commit: "4e7fbc73765c1a9670c7506616046ad1d4ccda51" upstream_commit: "98cc2dc749867c9c7317a0271ba062a6c797f886" -core_version: "3.1.0" +core_version: "7.0.0" +version_basis: + kind: file + path: "libretro/config.h" + pattern: '#define\s+PACKAGE_VERSION\s+"([^"]+)"' display_name: "Atari - 400/800/600XL/800XL/130XE/5200 (Atari800)" cores: [atari800] systems: [atari-400-800, atari-5200, xegs] diff --git a/emulators/ax360e.yml b/emulators/ax360e.yml index 2dd253705..57b01d8a6 100644 --- a/emulators/ax360e.yml +++ b/emulators/ax360e.yml @@ -8,7 +8,9 @@ profiled_date: "2026-08-11" source_commit: "b8c1838f21761ac463724e24105eda97b990e704" upstream_commit: "dc561e4df578c903087f9bdf56ff61d9ef9f2a93" upstream_frozen: "The xenia-canary tree vendored under app/src/main/cpp/xenia-canary is canary_experimental at this commit." -core_version: "1.18" +core_version: null +version_basis: + kind: none display_name: "Microsoft - Xbox 360 (aX360e)" cores: - ax360e diff --git a/emulators/azahar-sdl.yml b/emulators/azahar-sdl.yml index 3a146374e..126f775b3 100644 --- a/emulators/azahar-sdl.yml +++ b/emulators/azahar-sdl.yml @@ -5,7 +5,11 @@ source: "https://github.com/johndoe6345789/azahar_libretro" profiled_date: "2026-10-22" source_commit: "c8d39853c1ebacc5657e51d601713a09404ca8a7" pin_frozen: "KNULLI, the only platform that ships it, builds this revision (KNULLI package/emulators/azahar/azahar.mk:7 at tag 20260511). Later commits are not part of that build." -core_version: "Git" +core_version: "2122-rc1-178-gc8d3985" +version_basis: + kind: tag + tag: "2122-rc1" + distance: 178 display_name: "Nintendo - 3DS (Azahar SDL2)" mode: standalone cores: ["azahar"] diff --git a/emulators/azahar.yml b/emulators/azahar.yml index 80ce20d67..d0e7debb9 100644 --- a/emulators/azahar.yml +++ b/emulators/azahar.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/azahar-emu/azahar" profiled_date: "2026-10-22" source_commit: "219d743a1e698101fe1ab8bef43e6563126c3c27" core_version: "2126.2" +version_basis: + kind: tag + tag: "2126.2" + distance: 0 display_name: "Nintendo - 3DS (Azahar)" library_name: "Azahar" cores: diff --git a/emulators/azaharplus.yml b/emulators/azaharplus.yml index 107d3f637..27344f222 100644 --- a/emulators/azaharplus.yml +++ b/emulators/azaharplus.yml @@ -5,7 +5,11 @@ source: "https://github.com/AzaharPlus/AzaharPlus" upstream: "https://github.com/AzaharPlus/AzaharPlus" profiled_date: "2026-08-08" source_commit: "cf9068844820f819a6eabbeeeb6a70d546e3e85a" -core_version: "2126.0-A" +core_version: "AZAHAR_PLUS_2126_2_A" +version_basis: + kind: tag + tag: "AZAHAR_PLUS_2126_2_A" + distance: 0 display_name: "Nintendo - 3DS (AzaharPlus)" cores: - azaharplus diff --git a/emulators/b2.yml b/emulators/b2.yml index a2db29b2c..ded3dd144 100644 --- a/emulators/b2.yml +++ b/emulators/b2.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-23" source_commit: "9793f6a8040c416d147003cada29363b955b54e7" upstream_commit: "e1c9b84a9460909d4dc2030ad1d4e050012b1910" core_version: "v0.4" +version_basis: + kind: file + path: "src/libretro/core.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Acorn - BBC Micro (b2)" cores: [b2] systems: diff --git a/emulators/basiliskii.yml b/emulators/basiliskii.yml index 23cf2b01d..718468f7b 100644 --- a/emulators/basiliskii.yml +++ b/emulators/basiliskii.yml @@ -5,6 +5,11 @@ upstream: "https://github.com/cebix/macemu" profiled_date: "2026-08-08" source_commit: "96e512bd6376e78a2869f16dcc8a9028bce5ee72" core_version: "1.0" +version_basis: + kind: file + path: "BasiliskII/src/include/version.h" + pattern: 'const int VERSION_MAJOR = (\d+);\s*\nconst int VERSION_MINOR = (\d+);' + format: "{1}.{2}" display_name: "Apple - Macintosh 68k (Basilisk II)" cores: - basiliskii diff --git a/emulators/bbkemu.yml b/emulators/bbkemu.yml index 608a01b57..82ef784d8 100644 --- a/emulators/bbkemu.yml +++ b/emulators/bbkemu.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/AloysHF/BBKEmu" profiled_date: "2026-10-26" source_commit: "6641863f6ec59a74635753766e3f9d34adf92b05" core_version: "0.1.0" +version_basis: + kind: file + path: "Cargo.toml" + pattern: 'version\s+=\s+"([^"]+)' display_name: "BBK - Electronic Dictionary (BBKEmu)" cores: - bbkemu diff --git a/emulators/beebem.yml b/emulators/beebem.yml index 2ae84f1b7..d0bd0654c 100644 --- a/emulators/beebem.yml +++ b/emulators/beebem.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/stardot/beebem-windows" profiled_date: "2026-08-11" source_commit: "a982ba6a74af09e801c296b29d514dbd4da09b94" core_version: "4.19" +version_basis: + kind: file + path: "Src/Version.h" + pattern: '#define\s+VERSION_STRING\s+"([^"]+)' display_name: "Acorn - BBC Micro (BeebEm)" cores: [beebem] systems: diff --git a/emulators/beetle_gba.yml b/emulators/beetle_gba.yml index 8a1bfc648..ceb11e511 100644 --- a/emulators/beetle_gba.yml +++ b/emulators/beetle_gba.yml @@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/" profiled_date: "2026-03-24" source_commit: "b158166237b17253188cfdbe73a8a0b9fe4b3a8c" core_version: "v0.9.36" +version_basis: + kind: file + path: "libretro.cpp" + pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)' display_name: "Nintendo - Game Boy Advance (Beetle GBA)" cores: [beetle_gba, mednafen_gba] systems: diff --git a/emulators/beetle_lynx.yml b/emulators/beetle_lynx.yml index cb37f3e14..eeeb118b8 100644 --- a/emulators/beetle_lynx.yml +++ b/emulators/beetle_lynx.yml @@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/" profiled_date: "2026-03-24" source_commit: "fcdefcfb3c11d6d2e71be076a5d3df2e88ab73ed" core_version: "v1.24.0" +version_basis: + kind: file + path: "libretro.cpp" + pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)' display_name: "Atari - Lynx (Beetle Lynx)" cores: [mednafen_lynx, "beetle-lynx"] systems: [atari-lynx] diff --git a/emulators/beetle_ngp.yml b/emulators/beetle_ngp.yml index 15454f2c3..d752361f3 100644 --- a/emulators/beetle_ngp.yml +++ b/emulators/beetle_ngp.yml @@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/" profiled_date: "2026-03-24" source_commit: "a50d5ac288a81f2104ddf43195a4efdd15c72227" core_version: "v1.29.0.0" +version_basis: + kind: file + path: "libretro.c" + pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)' display_name: "SNK - Neo Geo Pocket / Color (Beetle NeoPop)" cores: [mednafen_ngp, "beetle-ngp"] systems: [snk-ngp, snk-ngpc] diff --git a/emulators/beetle_pce.yml b/emulators/beetle_pce.yml index e58ae3595..031541321 100644 --- a/emulators/beetle_pce.yml +++ b/emulators/beetle_pce.yml @@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/" profiled_date: "2026-03-24" source_commit: "b96c11e095b6a40a412d2da02766ae1c3f4fd539" 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 / CD (Beetle PCE)" cores: [beetle_pce, mednafen_pce, pce, beetle-pce] systems: [nec-pc-engine] diff --git a/emulators/beetle_pcfx.yml b/emulators/beetle_pcfx.yml index 10bb21a32..e7686a293 100644 --- a/emulators/beetle_pcfx.yml +++ b/emulators/beetle_pcfx.yml @@ -7,6 +7,10 @@ upstream: "https://mednafen.github.io/" profiled_date: "2026-03-24" source_commit: "c1650bad5fbdcc3c3ccd45e29e3983e1ccf16cff" core_version: "v0.9.36.5" +version_basis: + kind: file + path: "libretro.cpp" + pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)' display_name: "NEC - PC-FX (Beetle PC-FX)" systems: - nec-pcfx diff --git a/emulators/beetle_psx.yml b/emulators/beetle_psx.yml index f20d54eb6..584d4082a 100644 --- a/emulators/beetle_psx.yml +++ b/emulators/beetle_psx.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/beetle-psx-libretro" upstream: "https://mednafen.github.io/" profiled_date: "2026-03-29" source_commit: "7fea57a541abb39b8c5035b6261c3e70962d221b" -core_version: "v0.9.44.1" +core_version: "0.9.44.1" +version_basis: + kind: file + path: "libretro_options.h" + pattern: '#define\s+MEDNAFEN_CORE_SETVERSION\s+"([^"]+)"' display_name: "Sony - PlayStation (Beetle PSX)" cores: [beetle_psx, mednafen_psx, mednafen_psx_hw, "beetle-psx"] systems: [sony-playstation] diff --git a/emulators/beetle_saturn.yml b/emulators/beetle_saturn.yml index 2e95a764f..3bd569eb8 100644 --- a/emulators/beetle_saturn.yml +++ b/emulators/beetle_saturn.yml @@ -7,6 +7,10 @@ upstream: "https://mednafen.github.io/" profiled_date: "2026-10-05" source_commit: "2ce77e20a956dc9a67b8d3d527f477b984310feb" core_version: "v1.32.1" +version_basis: + kind: file + path: "libretro.c" + pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)' display_name: "Sega - Saturn (Beetle Saturn)" cores: - mednafen_saturn diff --git a/emulators/beetle_vb.yml b/emulators/beetle_vb.yml index 64e5d7bc3..a2b1f0355 100644 --- a/emulators/beetle_vb.yml +++ b/emulators/beetle_vb.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/beetle-vb-libretro" upstream: "https://mednafen.github.io/" profiled_date: "2026-03-24" source_commit: "d5ae54c8e23b573a3b34e888b2d48a588027e92c" -core_version: "v0.9.36.1" +core_version: "v1.31.0" +version_basis: + kind: file + path: "libretro.cpp" + pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)' display_name: "Nintendo - Virtual Boy (Beetle VB)" cores: [mednafen_vb, vb, "beetle-vb"] systems: [nintendo-virtualboy] diff --git a/emulators/beetle_wswan.yml b/emulators/beetle_wswan.yml index 32369125a..258f75b5d 100644 --- a/emulators/beetle_wswan.yml +++ b/emulators/beetle_wswan.yml @@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/" profiled_date: "2026-03-24" source_commit: "4b01295838ea89e3f1355bbe4cb5cf98aa6108cd" core_version: "v0.9.35.1" +version_basis: + kind: file + path: "libretro.c" + pattern: '#define\s+MEDNAFEN_CORE_VERSION\s+"([^"]+)' display_name: "Bandai - WonderSwan/Color (Beetle Wonderswan)" cores: [beetle_wswan, mednafen_wswan, "beetle-wswan"] systems: [bandai-wswan, bandai-wswanc] diff --git a/emulators/bennugd-monolithic.yml b/emulators/bennugd-monolithic.yml index ff6cf19ae..3af70c233 100644 --- a/emulators/bennugd-monolithic.yml +++ b/emulators/bennugd-monolithic.yml @@ -4,7 +4,11 @@ core_classification: game_engine source: "https://github.com/christianhaitian/bennugd-monolithic" profiled_date: "2026-10-22" source_commit: "60ee3389efcf9b402d66035e87f33d17d70cbd83" -core_version: "git" +core_version: "1.0.0" +version_basis: + kind: file + path: "projects/cmake/include/common_defs.cmake" + pattern: '-DVERSION="([^"]+)"' display_name: "Bennu Game Development (bennugd-monolithic)" cores: - "bgdi" diff --git a/emulators/bennugd.yml b/emulators/bennugd.yml index 0f1e2d8c8..26e92666e 100644 --- a/emulators/bennugd.yml +++ b/emulators/bennugd.yml @@ -5,7 +5,11 @@ source: "https://github.com/diekleinekuh/BennuGD_libretro" upstream: "https://github.com/SplinterGU/BennuGD" profiled_date: "2026-03-23" source_commit: "5fba764ac709545d4b0d3b28deff2ec0c7d1a91b" -core_version: "git" +core_version: "1.0.0" +version_basis: + kind: file + path: "core/common/bgd_version.c" + pattern: 'snprintf\(buffer, sizeof\(buffer\), "([0-9][0-9.]*)-BennuGD_libretro' display_name: "Bennu Game Development" cores: - bennugd diff --git a/emulators/bermuda.yml b/emulators/bermuda.yml index aceebc460..491a432e8 100644 --- a/emulators/bermuda.yml +++ b/emulators/bermuda.yml @@ -5,6 +5,10 @@ source: "https://github.com/cyxx/bermuda" profiled_date: "2026-10-22" source_commit: "cf6bdb68d53a1618967da565a44c931b15daf791" core_version: "0.1.7" +version_basis: + kind: file + path: "main_libretro.cpp" + pattern: 'static\s+const\s+char\s+\*kVersion\s+=\s+"([^"]+)' display_name: "Bermuda Syndrome (bs)" cores: - "bs" diff --git a/emulators/bigpemu.yml b/emulators/bigpemu.yml index 7f0ecbf10..7c3d28564 100644 --- a/emulators/bigpemu.yml +++ b/emulators/bigpemu.yml @@ -7,6 +7,12 @@ version_source: pattern: 'Current Version: <strong>([0-9.]+)</strong>' profiled_date: "2026-10-20" core_version: "1.221" +version_basis: + kind: package + url: "https://www.richwhitehouse.com/jaguar/builds/BigPEmu_Linux64_v1221.tar.gz" + pattern: 'BigPEmu_Linux64_v(\d)(\d+)\.tar\.gz' + format: "{1}.{2}" + artifact: "BigPEmu_Linux64_v1221.tar.gz" display_name: "BigPEmu (Atari Jaguar)" cores: - bigpemu diff --git a/emulators/bizhawk.yml b/emulators/bizhawk.yml index 4b0647243..2d727fc13 100644 --- a/emulators/bizhawk.yml +++ b/emulators/bizhawk.yml @@ -5,6 +5,10 @@ source: "https://github.com/TASEmulators/BizHawk" source_commit: "bdddf4a58aa1a022afb11dc73294a81a5aa7bbd5" profiled_date: "2026-10-20" core_version: "2.11.1" +version_basis: + kind: tag + tag: "2.11.1" + distance: 0 display_name: "Multi-system (BizHawk EmuHawk)" cores: - bizhawk diff --git a/emulators/bk.yml b/emulators/bk.yml index c0cef9d72..a761667d7 100644 --- a/emulators/bk.yml +++ b/emulators/bk.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/bk-emulator" upstream: "https://github.com/emestee/bk-emulator" profiled_date: "2026-03-23" source_commit: "92042a753289cedf05e7e6bd8e75849f83e48966" -core_version: "1.00" +core_version: "v1.0" +version_basis: + kind: file + path: "libretro.c" + pattern: 'library_version\s*=\s*"([^"]+)"' display_name: "Elektronika - BK-0010/BK-0011(M)" cores: - bk diff --git a/emulators/blastem.yml b/emulators/blastem.yml index 8845b94f8..f3d16d286 100644 --- a/emulators/blastem.yml +++ b/emulators/blastem.yml @@ -7,6 +7,10 @@ profiled_date: "2026-10-20" source_commit: "ab743653ed110eeb7b1fe64d00cc5f9279c2bfec" prose_written_at: "1e0de94dc7e669c0925a22c0fccf6cdc837af0a0" core_version: "1.0.1-pre" +version_basis: + kind: file + path: "version.inc" + pattern: '#define\s+BLASTEM_VERSION\s+"([^"]+)' display_name: "Sega - Mega Drive - Genesis (BlastEm)" cores: - blastem diff --git a/emulators/bluemsx.yml b/emulators/bluemsx.yml index b5b78c22e..b510ba90f 100644 --- a/emulators/bluemsx.yml +++ b/emulators/bluemsx.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/blueMSX-libretro" upstream: "http://bluemsx.msxblue.com/" profiled_date: "2026-03-23" source_commit: "e3086eb5d36d77fa11704cf53dc176686e70127d" -core_version: "SVN" +core_version: "2.8.3" +version_basis: + kind: file + path: "Src/BuildInfo/version.h" + pattern: '#define\s+BLUE_MSX_VERSION\s+"([^"]+)"' display_name: "MSX/SVI/ColecoVision/SG-1000 (blueMSX)" cores: - bluemsx diff --git a/emulators/bnes.yml b/emulators/bnes.yml index 1dc180c51..7914e69f7 100644 --- a/emulators/bnes.yml +++ b/emulators/bnes.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/Sir-Walrus/bsnes-gc" profiled_date: "2026-03-23" source_commit: "8e26e89a93bef8eb8992d1921b539dce1792660a" core_version: "v083" +version_basis: + kind: file + path: "libretro/libretro.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Nintendo - NES / Famicom (bnes)" cores: - bnes diff --git a/emulators/boom3.yml b/emulators/boom3.yml index 6e0fe58ea..251256c5c 100644 --- a/emulators/boom3.yml +++ b/emulators/boom3.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/dhewm/dhewm3" profiled_date: "2026-03-23" source_commit: "efe140ce8fdf8793f5a6fbba062fb34df7f55a81" upstream_commit: "455b88e8dff2be822f08eb498f51b383e851fa38" -core_version: "git" +core_version: "1.5.5" +version_basis: + kind: file + path: "neo/framework/Licensee.h" + pattern: '#define\s+ENGINE_VERSION\s+"dhewm3 ([^"]+)"' display_name: "Doom 3 (boom3)" cores: [boom3, boom3_xp] systems: [doom3] diff --git a/emulators/boytacean.yml b/emulators/boytacean.yml index c1e2a6ded..90f0d912f 100644 --- a/emulators/boytacean.yml +++ b/emulators/boytacean.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/joamag/boytacean" profiled_date: "2026-03-23" source_commit: "4d806d0770f117cc8e6ac0fe75b95aa9dc39fc4f" core_version: "0.13.2" +version_basis: + kind: file + path: "setup.py" + pattern: 'version="([^"]+)' display_name: "Nintendo - Game Boy / Color (Boytacean)" cores: - boytacean diff --git a/emulators/bsnes-jg.yml b/emulators/bsnes-jg.yml index c24b7692e..4fc53e8b2 100644 --- a/emulators/bsnes-jg.yml +++ b/emulators/bsnes-jg.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-23" source_commit: "4aef191fef72be539ccc07ada21c6e0a65360eb7" upstream_commit: "53e6a7c2ec4b4220adc2f8dcb3786489bd04fe0e" core_version: "2.1.2" +version_basis: + kind: file + path: "version.h" + pattern: '#define\s+JG_VERSION\s+"([^"]+)' display_name: "Nintendo - SNES / SFC (bsnes-jg)" cores: [bsnes-jg, bsnes_jg] systems: diff --git a/emulators/bsnes.yml b/emulators/bsnes.yml index 0c0b74ab2..ef31dc4a2 100644 --- a/emulators/bsnes.yml +++ b/emulators/bsnes.yml @@ -8,6 +8,10 @@ profiled_date: "2026-04-05" source_commit: "05439f96121d2b9d7ad7a5fc1f29d7eebdcc8c43" upstream_commit: "78474e5d39f58872dc21aca18cf9eb583b147fe7" core_version: "115" +version_basis: + kind: file + path: "bsnes/emulator/emulator.hpp" + pattern: 'static const string Version\s*=\s*"([^"]+)"' display_name: "Nintendo - SNES / SFC (bsnes)" cores: [bsnes] systems: [nintendo-snes, nintendo-super-game-boy, nintendo-satellaview] diff --git a/emulators/bsnes2014.yml b/emulators/bsnes2014.yml index cf0a8146f..58efca237 100644 --- a/emulators/bsnes2014.yml +++ b/emulators/bsnes2014.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/bsnes-emu/bsnes" profiled_date: "2026-03-25" source_commit: "3c1394e042ee444c8248e1b9210e14ea55e836e9" upstream_commit: "78474e5d39f58872dc21aca18cf9eb583b147fe7" -core_version: "v094" +core_version: "094" +version_basis: + kind: file + path: "emulator/emulator.hpp" + pattern: 'static const char Version\[\]\s*=\s*"([^"]+)"' display_name: "Nintendo - SNES / SFC (bsnes 2014)" cores: [bsnes2014_accuracy, bsnes2014_balanced, bsnes2014_performance] systems: diff --git a/emulators/bsnes_cplusplus98.yml b/emulators/bsnes_cplusplus98.yml index e67ca0899..ac8da33a5 100644 --- a/emulators/bsnes_cplusplus98.yml +++ b/emulators/bsnes_cplusplus98.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/bsnes-libretro-cplusplus98" upstream: "https://byuu.org/" profiled_date: "2026-03-23" source_commit: "baec09aa4796b7575aba1c1ae7335f493c2216af" -core_version: "v085 (Performance)" +core_version: "v085" +version_basis: + kind: file + path: "snes/libretro/libretro.cpp" + pattern: 'static string version\("(v[0-9]+)' display_name: "Nintendo - SNES / SFC (bsnes C++98 (v085))" cores: [bsnes_cplusplus98] systems: diff --git a/emulators/bsnes_hd_beta.yml b/emulators/bsnes_hd_beta.yml index 32f9f80b9..0b9bbcd0d 100644 --- a/emulators/bsnes_hd_beta.yml +++ b/emulators/bsnes_hd_beta.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/bsnes-emu/bsnes" profiled_date: "2026-03-23" source_commit: "fc26b25ea236f0f877f0265d2a2c37dfd93dfde9" upstream_commit: "78474e5d39f58872dc21aca18cf9eb583b147fe7" -core_version: "v10.6" +core_version: "10.6" +version_basis: + kind: file + path: "bsnes/emulator/emulator.hpp" + pattern: 'static const string Version\s*=\s*"([^"]+)"' display_name: "Nintendo - SNES / SFC (bsnes-hd beta)" cores: [bsnes_hd_beta, bsneshd, bsnes_hd] systems: diff --git a/emulators/bsnes_mercury.yml b/emulators/bsnes_mercury.yml index 353f1e6cc..9f62a4fd1 100644 --- a/emulators/bsnes_mercury.yml +++ b/emulators/bsnes_mercury.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/bsnes-mercury" upstream: "https://byuu.org/higan" profiled_date: "2026-03-25" source_commit: "79d7f9de218b6ffa65a80bbdc5828532bc239232" -core_version: "v094 (Mercury)" +core_version: "094" +version_basis: + kind: file + path: "emulator/emulator.hpp" + pattern: 'static const char Version\[\]\s*=\s*"([^"]+)"' display_name: "Nintendo - SNES / SFC (bsnes-mercury)" cores: [bsnes_mercury_accuracy, bsnes_mercury_balanced, bsnes_mercury_performance] systems: diff --git a/emulators/bstone.yml b/emulators/bstone.yml index 643dd76a7..d7f4dbb2e 100644 --- a/emulators/bstone.yml +++ b/emulators/bstone.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/bibendovsky/bstone" profiled_date: "2026-10-04" source_commit: "e65a6b40b960a409d7ea840580c2431f56fb6fd4" core_version: "v1.3.4" +version_basis: + kind: tag + tag: "v1.3.4" + distance: 0 display_name: "Blake Stone (BStone)" cores: [bstone] systems: [blake-stone] diff --git a/emulators/bsyndrome.yml b/emulators/bsyndrome.yml index 7fa271a45..48021079f 100644 --- a/emulators/bsyndrome.yml +++ b/emulators/bsyndrome.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/cyxx/bermuda" source_commit: "c3aaa0337daca116eab5804456673bd01396bbb4" profiled_date: "2026-10-20" core_version: "0.1.7" +version_basis: + kind: tag + tag: "0.1.7" + distance: 0 display_name: "Bermuda Syndrome (bs)" cores: [bsyndrome] systems: [bsyndrome] diff --git a/emulators/c64-emu.yml b/emulators/c64-emu.yml index f437bcfb3..91c6a7db3 100644 --- a/emulators/c64-emu.yml +++ b/emulators/c64-emu.yml @@ -7,6 +7,10 @@ profiled_date: "2026-08-11" source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943" upstream_commit: "46b1bc4393251f838ce3dadd241c1f567d9371d1" core_version: "1.5.85" +version_basis: + kind: file + path: "EmuFramework/CMakeLists.txt" + pattern: 'project\(\s*EmuFramework\s+VERSION\s+([0-9.]+)' display_name: "Commodore - C64 (C64.emu)" mode: standalone cores: diff --git a/emulators/cannonball.yml b/emulators/cannonball.yml index 9e0a1ebf6..13cc17e12 100644 --- a/emulators/cannonball.yml +++ b/emulators/cannonball.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/djyt/cannonball" profiled_date: "2026-03-23" source_commit: "0d83575d920ea4c0a280847a19d7802f673e6a68" upstream_commit: "27493ebf62be3498dff93ed6a45e8e2db819bae1" -core_version: "1.0" +core_version: "0.3" +version_basis: + kind: file + path: "src/main/frontend/menu.c" + pattern: 'menu_about\[[^\]]+\]\s*=\s*"CANNONBALL ([0-9.]+) ' display_name: "Cannonball" cores: - cannonball diff --git a/emulators/cap32.yml b/emulators/cap32.yml index ef710fdf7..fad619729 100644 --- a/emulators/cap32.yml +++ b/emulators/cap32.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/ColinPitrat/caprice32" profiled_date: "2026-10-23" source_commit: "8e61dd69d948cea16c3bad734f7b9d0c2a018cc8" upstream_commit: "8c778bf072a06fb7c23b9cccd179041db7f79a52" -core_version: "v4.5.4" +core_version: "4.5.4" +version_basis: + kind: file + path: "libretro/libretro-core.h" + pattern: '#define\s+VERSION_STRING\s+"([^"]+)"' display_name: "Amstrad - CPC/GX4000 (Caprice32)" cores: - cap32 diff --git a/emulators/capriceforever.yml b/emulators/capriceforever.yml index 0abad5171..aa49293db 100644 --- a/emulators/capriceforever.yml +++ b/emulators/capriceforever.yml @@ -5,6 +5,10 @@ source: "https://www.retrobat.org/repo/win64/stable/emulators/capriceforever.7z" upstream: closed-source profiled_date: "2026-10-20" core_version: "26.4" +version_basis: + kind: package + url: "https://www.retrobat.org/repo/win64/stable/emulators/capriceforever.7z" + artifact: "capriceforever.7z, Caprice.exe version resource ProductVersion" display_name: "Amstrad - CPC / PLUS (CaPriCe Forever)" cores: - capriceforever diff --git a/emulators/capricerpi.yml b/emulators/capricerpi.yml index 31d354280..75743e032 100644 --- a/emulators/capricerpi.yml +++ b/emulators/capricerpi.yml @@ -5,7 +5,11 @@ source: "https://github.com/KaosOverride/CapriceRPI" upstream: "https://sourceforge.net/projects/caprice32/" source_commit: "7d86ebdc6a5e645220c6f30cf407ac57c3256054" profiled_date: "2026-10-20" -core_version: "1.3c" +core_version: "V1.3d" +version_basis: + kind: file + path: "src/cap32.h" + pattern: '#define\s+VERSION_STRING\s+"\s*(V[0-9][^ ]*)' display_name: "Amstrad - CPC (CapriceRPI)" cores: - capricerpi diff --git a/emulators/catacombgl.yml b/emulators/catacombgl.yml index 44d52027e..051201f8e 100644 --- a/emulators/catacombgl.yml +++ b/emulators/catacombgl.yml @@ -6,6 +6,11 @@ upstream: "https://github.com/ArnoAnsems/CatacombGL" profiled_date: "2026-10-04" source_commit: "d11ba549382278cbc67ba6171faf8af338e29f77" core_version: "0.5.7 Beta" +version_basis: + kind: file + path: "src/Engine/EngineCore.cpp" + pattern: 'versionMajor\s*=\s*(\d+);\s*\n.*versionMinor\s*=\s*(\d+);\s*\n.*versionLevel\s*=\s*(\d+);\s*\n.*versionPhase\s*=\s*"([^"]+)"' + format: "{1}.{2}.{3} {4}" display_name: "Catacomb 3-D (CatacombGL)" cores: [catacombgl] systems: [catacomb-3d, catacomb-adventure] diff --git a/emulators/ccleste.yml b/emulators/ccleste.yml index 716025b45..2422cc459 100644 --- a/emulators/ccleste.yml +++ b/emulators/ccleste.yml @@ -4,7 +4,11 @@ core_classification: game_engine source: "https://github.com/lemon32767/ccleste" profiled_date: "2026-10-22" source_commit: "261d96f15af430b8111abc7a5250229246654f52" -core_version: "git" +core_version: "v1.4.0" +version_basis: + kind: tag + tag: "v1.4.0" + distance: 0 display_name: "Celeste Classic (ccleste)" cores: - "ccleste" diff --git a/emulators/cdi2015.yml b/emulators/cdi2015.yml index e703ab99e..4ee554e2a 100644 --- a/emulators/cdi2015.yml +++ b/emulators/cdi2015.yml @@ -6,14 +6,19 @@ upstream: "https://github.com/mamedev/mame/tree/mame0160" profiled_date: "2026-03-23" source_commit: "7adbf440d5f554097f58f8c6ebb673adc4320a31" upstream_commit: "0147bb4cc82e642cf75151f039f240eb20989fbc" -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: "Philips CDi (CDi 2015)" cores: [cdi2015] systems: - philips-cdi notes: | - MAME 0.160 compiled with SUBTARGET=cdi, only cdimono1 driver. + MAME 0.159 (version.c, the core reports 0.160) compiled with SUBTARGET=cdi, + only cdimono1 driver. Checks cdimono1.zip existence in system_dir, then game dir. Returns -2 if missing. cdic.bin and slave.bin are undumped 68HC05 MCUs (NO_DUMP), not in the ZIP. diff --git a/emulators/cdogs.yml b/emulators/cdogs.yml index fd0acf3da..679a2eb5b 100644 --- a/emulators/cdogs.yml +++ b/emulators/cdogs.yml @@ -6,6 +6,11 @@ upstream: "https://github.com/cxong/cdogs-sdl" profiled_date: "2026-10-27" source_commit: "c70d1feaf9d89cf80e23265b256d9a364848e6ea" core_version: "2.5.0" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'set\(VERSION_MAJOR "(\d+)"\)\s*\nset\(VERSION_MINOR "(\d+)"\)\s*\nset\(VERSION_PATCH "(\d+)"\)' + format: "{1}.{2}.{3}" display_name: "C-Dogs" cores: [cdogs, cdogs-sdl] systems: [] diff --git a/emulators/cemu.yml b/emulators/cemu.yml index 6e82bce28..ee4b06a24 100644 --- a/emulators/cemu.yml +++ b/emulators/cemu.yml @@ -8,7 +8,11 @@ logo: "https://raw.githubusercontent.com/cemu-project/Cemu/main/dist/linux/info. profiled_date: "2026-10-23" source_commit: "14f56b569b3668db01a4972225993abd3e8f2096" upstream_commit: "42876aa86d2b92f9c620a76959a792bf392b89d3" -core_version: "2.6" +core_version: "2.6-372-g29f2f1fa" +version_basis: + kind: file + path: "src/libretro/CemuLibretro.cpp" + pattern: 'info->library_version\s*=\s*"([^"]+)"' display_name: "Nintendo - Wii U (Cemu)" library_name: "Cemu" save_folder_once: true diff --git a/emulators/cgenius.yml b/emulators/cgenius.yml index a94708f0c..4ca0e5e17 100644 --- a/emulators/cgenius.yml +++ b/emulators/cgenius.yml @@ -5,7 +5,11 @@ source: "https://gitlab.com/Dringgstein/Commander-Genius" upstream: "https://gitlab.com/Dringgstein/Commander-Genius" profiled_date: "2026-10-27" source_commit: "58a3c4fd0782fa2a087e38a039599b2ecf69ccc9" -core_version: "v3.6.4" +core_version: "3.6.4-Release" +version_basis: + kind: file + path: "version.h" + pattern: '#define\s+CGVERSION\s+"([^"]+)"' display_name: "Commander Genius" cores: [cgenius, "CGeniusExe"] systems: [commander-keen] diff --git a/emulators/chailove.yml b/emulators/chailove.yml index ad9a865e3..311b0d585 100644 --- a/emulators/chailove.yml +++ b/emulators/chailove.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/libretro-chailove" profiled_date: "2026-03-23" source_commit: "524ef951abbd07464830ee12a3f6f5e2aba9efd3" core_version: "2.0.0" +version_basis: + kind: file + path: "src/ChaiLove.h" + pattern: '#define\s+CHAILOVE_VERSION_STRING\s+"([^"]+)' display_name: "ChaiLove" cores: [chailove] systems: [] diff --git a/emulators/chihiro-ds.yml b/emulators/chihiro-ds.yml index f5866a55a..1a0c0d505 100644 --- a/emulators/chihiro-ds.yml +++ b/emulators/chihiro-ds.yml @@ -5,6 +5,10 @@ source: "https://www.retrobat.org/repo/win64/stable/emulators/chihiro-ds.7z" upstream: closed-source profiled_date: "2026-10-20" core_version: "1.0 (Mar 14 2025)" +version_basis: + kind: package + url: "https://www.retrobat.org/repo/win64/stable/emulators/chihiro-ds.7z" + artifact: "chihiro-ds.7z, build string in cxbx.exe and cxbxr-emu.dll" display_name: "Sega - Chihiro (Chihiro-DS)" cores: - chihiro-gun diff --git a/emulators/chimerasnes.yml b/emulators/chimerasnes.yml index 5641c312c..30c5554d7 100644 --- a/emulators/chimerasnes.yml +++ b/emulators/chimerasnes.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/snes9xgit/snes9x" profiled_date: "2026-03-23" source_commit: "04c57c2902c25f36ae5a5d9c57aff851a772c868" upstream_commit: "1bcc369e89f08243e0a462882fb1f3e42e51de3a" -core_version: "" +core_version: "86bab70_20231001-4-g04c57c2" +version_basis: + kind: tag + tag: "86bab70_20231001" + distance: 4 display_name: "Nintendo - SNES / SFC (ChimeraSNES)" cores: [chimerasnes] systems: diff --git a/emulators/chocolate-doom.yml b/emulators/chocolate-doom.yml index 2cd08418f..b3f416c2e 100644 --- a/emulators/chocolate-doom.yml +++ b/emulators/chocolate-doom.yml @@ -6,6 +6,10 @@ profiled_date: "2026-10-22" source_commit: "72d9e9318c4ccc61dab29bb9cc30bc8a9a93266d" pin_frozen: "EmuELEC, the only platform that ships it, builds this revision (EmuELEC packages/sx05re/emuelec-ports/chocolate-doom/package.mk:5). Later commits are not part of that build." core_version: "3.0.0" +version_basis: + kind: file + path: "configure.ac" + pattern: 'AC_INIT\(Chocolate\s+Doom,\s+([0-9][\w.+-]*)' display_name: "Doom (Chocolate Doom)" cores: - "Chocolate-Doom" diff --git a/emulators/citra-mmj.yml b/emulators/citra-mmj.yml index 3a079f61e..a9458d166 100644 --- a/emulators/citra-mmj.yml +++ b/emulators/citra-mmj.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/weihuoya/citra" profiled_date: "2026-08-11" source_commit: "9198dc24df2eb534b8fe7fa1c5c457081a7244ab" core_version: "20251112" +version_basis: + kind: tag + tag: "20251112" + distance: 0 display_name: "Nintendo - 3DS (Citra MMJ)" cores: - citra-mmj diff --git a/emulators/citra.yml b/emulators/citra.yml index 6aa87cb52..8d721e6b9 100644 --- a/emulators/citra.yml +++ b/emulators/citra.yml @@ -5,7 +5,9 @@ source: "https://github.com/libretro/citra" upstream: "https://github.com/citra-emu/citra" profiled_date: "2026-03-23" source_commit: "a0483e9abe8134ae6b290cdca7d49cb77689f05b" -core_version: "Git" +core_version: null +version_basis: + kind: none display_name: "Nintendo - 3DS (Citra)" library_name: "Citra" cores: [citra] diff --git a/emulators/citra2018.yml b/emulators/citra2018.yml index 21a9237a0..3e78e60a7 100644 --- a/emulators/citra2018.yml +++ b/emulators/citra2018.yml @@ -5,7 +5,9 @@ source: "https://github.com/libretro/citra2018" upstream: "https://github.com/citra-emu/citra" profiled_date: "2026-03-23" source_commit: "95e2f06c179c8daf44fa344c206f549cf332d21b" -core_version: "Git" +core_version: null +version_basis: + kind: none display_name: "Nintendo - 3DS (Citra 2018)" library_name: "Citra2018" cores: [citra2018] diff --git a/emulators/citra_canary.yml b/emulators/citra_canary.yml index 4acc1471b..89df6d0f7 100644 --- a/emulators/citra_canary.yml +++ b/emulators/citra_canary.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/citra-emu/citra" profiled_date: "2026-03-23" source_commit: "5401990a9be46e4497abc92db3d5f2042674303d" source_branch: canary -core_version: "Git" +core_version: "canary-82-6-g5401990" +version_basis: + kind: tag + tag: "canary-82" + distance: 6 display_name: "Nintendo - 3DS (Citra Canary/Experimental)" library_name: "Citra Canary" cores: [citra_canary] diff --git a/emulators/citron.yml b/emulators/citron.yml index 2ff13f8f5..d4b5a25ed 100644 --- a/emulators/citron.yml +++ b/emulators/citron.yml @@ -6,6 +6,9 @@ upstream: "https://git.citron-emu.org/citron-emu/Citron" profiled_date: "2026-03-26" upstream_gone: "git.citron-emu.org no longer resolves, and git.citron-emu.com redirects to an unrelated survey site, the domain having been taken. Observed 2026-09-04, no mirror found." core_version: "2026.03.12" +version_basis: + kind: unverified + why: host_gone display_name: "Citron (Nintendo Switch)" cores: - citron diff --git a/emulators/clk.yml b/emulators/clk.yml index fc04a8c9b..a0259ca14 100644 --- a/emulators/clk.yml +++ b/emulators/clk.yml @@ -4,7 +4,11 @@ source: "https://github.com/TomHarte/CLK" upstream: "https://github.com/TomHarte/CLK" profiled_date: "2026-03-26" source_commit: "9020107b563b68595ffdb6ef18aacdec85c78d69" -core_version: "2026-07-28" +core_version: "2026-07-23-136-g9020107" +version_basis: + kind: tag + tag: "2026-07-23" + distance: 136 display_name: "Clock Signal" cores: [clk] systems: diff --git a/emulators/clownmdemu.yml b/emulators/clownmdemu.yml index cacf2a6d3..27120a342 100644 --- a/emulators/clownmdemu.yml +++ b/emulators/clownmdemu.yml @@ -7,7 +7,11 @@ profiled_date: "2026-03-23" source_commit: "d43c2708b0a31c285ce16724b6c4a2e92af07346" # The core the port vendors at source_commit (common -> core submodule). upstream_commit: "88ef45a6585556e2247dd26b6c937d6b9fb4a12d" -core_version: "Git" +core_version: "v1.6.12" +version_basis: + kind: file + path: "source/libretro-interface.c" + pattern: 'info->library_version\s*=\s*"([^"]+)"' display_name: "Sega - MD/CD (ClownMDEmu)" cores: - clownmdemu diff --git a/emulators/colem.yml b/emulators/colem.yml index e13f8f719..c958b3368 100644 --- a/emulators/colem.yml +++ b/emulators/colem.yml @@ -8,6 +8,12 @@ version_source: pattern: 'The Latest Version of ColEm is (\d+(?:\.\d+)+)' profiled_date: "2026-08-08" core_version: "5.6" +version_basis: + kind: package + url: "https://fms.komkon.org/ColEm/ColEm56-Source.zip" + pattern: 'ColEm(\d)(\d+)-Source\.zip' + format: "{1}.{2}" + artifact: "ColEm56-Source.zip" display_name: "Coleco - ColecoVision / Adam (ColEm)" cores: - colem diff --git a/emulators/coolbaremu.yml b/emulators/coolbaremu.yml index 9894753eb..2bd331cc1 100644 --- a/emulators/coolbaremu.yml +++ b/emulators/coolbaremu.yml @@ -5,7 +5,11 @@ source: "https://github.com/AloysHF/CoolbarEmu" upstream: "https://github.com/AloysHF/CoolbarEmu" profiled_date: "2026-10-17" source_commit: "88e90440daab0bdc7bc141728b51841e0c730528" -core_version: "0.2.0" +core_version: "v0.2.0" +version_basis: + kind: tag + tag: "v0.2.0" + distance: 0 display_name: "Coolbar (CoolbarEmu)" cores: - "coolbaremu" diff --git a/emulators/coolcv.yml b/emulators/coolcv.yml index b0927cec8..2a8920e4a 100644 --- a/emulators/coolcv.yml +++ b/emulators/coolcv.yml @@ -5,6 +5,10 @@ source: "https://files.retropie.org.uk/archives/coolcv.tar.gz" upstream: "http://nanochess.org/" profiled_date: "2026-10-20" core_version: "0.6.2" +version_basis: + kind: package + url: "https://files.retropie.org.uk/archives/coolcv.tar.gz" + artifact: "coolcv.tar.gz, latest entry of coolcv.txt" display_name: "Coleco - ColecoVision / Adam (CoolCV)" cores: - coolcv diff --git a/emulators/corsixth.yml b/emulators/corsixth.yml index 06b6dc9cf..e5a4f5009 100644 --- a/emulators/corsixth.yml +++ b/emulators/corsixth.yml @@ -5,7 +5,11 @@ source: "https://github.com/CorsixTH/CorsixTH" upstream: ~ profiled_date: "2026-10-04" source_commit: "e5668b9b2a6f59c492b2446821528d67f5d0ee53" -core_version: "0.70.1" +core_version: "v0.70.1-263-ge5668b9" +version_basis: + kind: tag + tag: "v0.70.1" + distance: 263 display_name: "Theme Hospital (CorsixTH)" cores: [corsixth] systems: [theme-hospital] diff --git a/emulators/cpcemu.yml b/emulators/cpcemu.yml index 4ef0637dd..d98a99f02 100644 --- a/emulators/cpcemu.yml +++ b/emulators/cpcemu.yml @@ -8,6 +8,11 @@ version_source: pattern: 'CPCemu (\d+(?:\.\d+)+) released' profiled_date: "2026-08-08" core_version: "3.0.2" +version_basis: + kind: package + url: "https://cpc-emu.org/Release/2025-04-24/cpcemu_portable-win32-x86-3.0.2.zip" + pattern: 'cpcemu_portable-win32-x86-(\d+(?:\.\d+)+)\.zip' + artifact: "cpcemu_portable-win32-x86-3.0.2.zip" display_name: "Amstrad - CPC (CPCemu)" cores: - cpcemu diff --git a/emulators/craft.yml b/emulators/craft.yml index 246db0dab..a3b3c78a3 100644 --- a/emulators/craft.yml +++ b/emulators/craft.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/Craft" upstream: "https://github.com/fogleman/Craft" profiled_date: "2026-03-23" source_commit: "f30e7b4b1d1406c750a504583adf0cc88449a699" -core_version: "v1" +core_version: "v1.0-353-gf30e7b4" +version_basis: + kind: tag + tag: "v1.0" + distance: 353 display_name: "Minecraft (Craft)" cores: [craft] systems: [] diff --git a/emulators/crocods.yml b/emulators/crocods.yml index 102683af6..d0dd82be7 100644 --- a/emulators/crocods.yml +++ b/emulators/crocods.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/libretro-crocods" upstream: "https://github.com/redbug26/crocods-core" profiled_date: "2026-03-23" source_commit: "a9c63b29443715ae2add392010fca4eae7f93e67" -core_version: "v1" +core_version: "2024-10-16" +version_basis: + kind: file + path: "crocods-core/crocods.h" + pattern: '#define\s+CROCOVERSION\s+"([^"]+)"' display_name: "Amstrad - CPC (CrocoDS)" cores: - crocods diff --git a/emulators/cruzes.yml b/emulators/cruzes.yml index 8214d4920..eea7783c2 100644 --- a/emulators/cruzes.yml +++ b/emulators/cruzes.yml @@ -5,6 +5,10 @@ source: "https://github.com/libretro/libretro-samples/tree/master/tests/cruzes" profiled_date: "2026-03-23" source_commit: "7c2ad112d7a5176de2191e473369668da8946b16" core_version: "0.1" +version_basis: + kind: file + path: "tests/cruzes/cruzes.c" + pattern: 'library_version\s*=\s*"([^"]+)"' display_name: "Cruzes" cores: - cruzes diff --git a/emulators/cspect.yml b/emulators/cspect.yml index c203099b6..fde57e9e5 100644 --- a/emulators/cspect.yml +++ b/emulators/cspect.yml @@ -8,6 +8,10 @@ version_source: pattern: '<u>v(\d+(?:\.\d+)+)</u>' profiled_date: "2026-08-11" core_version: "3.3.1.0" +version_basis: + kind: package + url: "https://mdf200.itch.io/cspect" + artifact: "CSpect zip from itch.io, CSpect.exe and ReadMe.txt" display_name: "Sinclair - ZX Spectrum Next (#CSpect)" verification: existence cores: [cspect] diff --git a/emulators/cxbx-reloaded-2023.yml b/emulators/cxbx-reloaded-2023.yml index cbab84656..b213b70e2 100644 --- a/emulators/cxbx-reloaded-2023.yml +++ b/emulators/cxbx-reloaded-2023.yml @@ -5,6 +5,10 @@ source: "https://github.com/Cxbx-Reloaded/Cxbx-Reloaded" profiled_date: "2026-10-20" source_commit: "31ff15ba1d91a9a00221546c665efb133889f53a" core_version: "CI-31ff15b" +version_basis: + kind: tag + tag: "CI-31ff15b" + distance: 0 display_name: "Microsoft - Xbox / Sega Chihiro (Cxbx-Reloaded 31ff15b)" cores: - chihiro diff --git a/emulators/cxbx-reloaded.yml b/emulators/cxbx-reloaded.yml index 7ff643890..e7716e748 100644 --- a/emulators/cxbx-reloaded.yml +++ b/emulators/cxbx-reloaded.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/Cxbx-Reloaded/Cxbx-Reloaded" profiled_date: "2026-08-11" source_commit: "585c49a50af1255ab155099e06f24505f9c5a800" core_version: "CI-585c49a" +version_basis: + kind: tag + tag: "CI-585c49a" + distance: 0 display_name: "Microsoft - Xbox / Sega Chihiro (Cxbx-Reloaded)" cores: - cxbx-reloaded diff --git a/emulators/daedalusx64.yml b/emulators/daedalusx64.yml index b465f1c2c..526976ff1 100644 --- a/emulators/daedalusx64.yml +++ b/emulators/daedalusx64.yml @@ -5,7 +5,11 @@ source: "https://github.com/DaedalusX64/daedalus" profiled_date: "2026-10-17" source_commit: "f17e9ed86f3806fadeb69abd29c9526ab2d4bd1b" pin_frozen: "ROCKNIX 20261001 builds this revision. Later upstream commits are not part of that build." -core_version: "Git" +core_version: "1.1.8-837-gf17e9ed" +version_basis: + kind: tag + tag: "1.1.8" + distance: 837 display_name: "Nintendo - 64 (DaedalusX64)" cores: - "daedalusx64" diff --git a/emulators/daphne.yml b/emulators/daphne.yml index b1fbca2a4..b31bbcb45 100644 --- a/emulators/daphne.yml +++ b/emulators/daphne.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/daphne" upstream: "https://github.com/mirror/daphne-emu" profiled_date: "2026-03-23" source_commit: "6f1695dd1f376060666eec0a416ff56bb6c9cccc" -core_version: "Git" +core_version: "1.0.7" +version_basis: + kind: file + path: "daphne/daphne-1.0-src/daphne.cpp" + pattern: 'get_daphne_version\(\)\s*\{\s*return "([^"]+)"' display_name: "Arcade (Daphne)" cores: - daphne diff --git a/emulators/darkplaces.yml b/emulators/darkplaces.yml index 5d9e56e45..f0a8655c8 100644 --- a/emulators/darkplaces.yml +++ b/emulators/darkplaces.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/xonotic/darkplaces" source_branch: div0-stable profiled_date: "2026-10-20" source_commit: "ddabe55ae766087df0b82d214ca4b6d79a443345" -core_version: "div0-stable" +core_version: "xonotic-v0.8.6-2-gddabe55" +version_basis: + kind: tag + tag: "xonotic-v0.8.6" + distance: 2 display_name: "Quake (DarkPlaces)" cores: - darkplaces-quake diff --git a/emulators/demul.yml b/emulators/demul.yml index 0664138f5..25227d87f 100644 --- a/emulators/demul.yml +++ b/emulators/demul.yml @@ -6,6 +6,9 @@ version_source: pattern: '<div class="normaltext">\[[^\]]*\] DEmul (\d+\.\d+)' profiled_date: "2026-03-26" core_version: "0.7" +version_basis: + kind: unverified + why: no_artifact display_name: "Sega - Dreamcast/NAOMI/Hikaru (Demul)" cores: - demul diff --git a/emulators/desmume.yml b/emulators/desmume.yml index 717e2702b..6d1dbbafb 100644 --- a/emulators/desmume.yml +++ b/emulators/desmume.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/TASEmulators/desmume" profiled_date: "2026-03-23" source_commit: "95b4d798731caa809125b6c3c11d17cc332ff6ef" upstream_commit: "e06d11f6df8c2e35d570412c45f5a63e5e7c031d" -core_version: "SVN" +core_version: "0.9.12" +version_basis: + kind: file + path: "desmume/src/version.cpp" + pattern: '#define\s+DESMUME_VERSION_STRING\s+" "\s+"([^"]+)"' display_name: "Nintendo - DS (DeSmuME)" cores: [desmume] systems: [nintendo-ds] diff --git a/emulators/desmume2015.yml b/emulators/desmume2015.yml index f0f213b8b..0c607bf75 100644 --- a/emulators/desmume2015.yml +++ b/emulators/desmume2015.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/TASEmulators/desmume" profiled_date: "2026-03-23" source_commit: "422b688009ccb9f2917c086a8fe79c8cecb030ae" upstream_commit: "fc504ee0bc9ac3434cafcaae7b129c025499a609" -core_version: "SVN (2015 snapshot)" +core_version: "0.9.11" +version_basis: + kind: file + path: "desmume/src/version.c" + pattern: '#define\s+DESMUME_VERSION_STRING\s+" "\s+"([^"]+)"' display_name: "Nintendo - DS (DeSmuME 2015)" cores: [desmume2015, desmume_2015] systems: [nintendo-ds] diff --git a/emulators/devilutionx.yml b/emulators/devilutionx.yml index 038e0c468..0c7dfcb13 100644 --- a/emulators/devilutionx.yml +++ b/emulators/devilutionx.yml @@ -6,6 +6,10 @@ upstream: ~ profiled_date: "2026-10-04" source_commit: "9d4871587e1a8d9606f97a43d85e939b82f77900" core_version: "1.6.0-dev" +version_basis: + kind: file + path: "VERSION" + pattern: '^(\S+)$' display_name: "Diablo (DevilutionX)" cores: [devilutionx] systems: [diablo] diff --git a/emulators/dgen.yml b/emulators/dgen.yml index d48858408..2fd22f363 100644 --- a/emulators/dgen.yml +++ b/emulators/dgen.yml @@ -5,6 +5,9 @@ source: "https://sourceforge.net/p/dgen/dgen" profiled_date: "2026-10-20" source_commit: "1c196e63d3e35704a97a2cef60def9df3c9fd1ce" core_version: "1.33" +version_basis: + kind: unverified + why: host_unsupported display_name: "Sega - Mega Drive - Genesis (DGen/SDL)" mode: standalone cores: diff --git a/emulators/dhewm3.yml b/emulators/dhewm3.yml index 38a002c37..35b19d0db 100644 --- a/emulators/dhewm3.yml +++ b/emulators/dhewm3.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/dhewm/dhewm3" profiled_date: "2026-10-04" source_commit: "455b88e8dff2be822f08eb498f51b383e851fa38" core_version: "1.5.5" +version_basis: + kind: tag + tag: "1.5.5" + distance: 0 display_name: "dhewm3" cores: [] systems: [doom3] diff --git a/emulators/dice.yml b/emulators/dice.yml index 836ba3d7d..d4867cc12 100644 --- a/emulators/dice.yml +++ b/emulators/dice.yml @@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/dice/" profiled_date: "2026-03-23" source_commit: "4b4596b8dd2252b9d08bd6aaa02afda4be834db3" core_version: "v0.4.3" +version_basis: + kind: tag + tag: "v0.4.3" + distance: 0 display_name: "Arcade (DICE)" cores: - dice diff --git a/emulators/dingooemu.yml b/emulators/dingooemu.yml index edfc41f63..b7a9483b3 100644 --- a/emulators/dingooemu.yml +++ b/emulators/dingooemu.yml @@ -5,7 +5,11 @@ source: "https://github.com/AloysHF/DingooEmu" upstream: "https://github.com/AloysHF/DingooEmu" profiled_date: "2026-09-17" source_commit: "f57c9339e6021cd37c4bd694c14b0813a9b5ee72" -core_version: "0.3.0" +core_version: "v0.3.0" +version_basis: + kind: tag + tag: "v0.3.0" + distance: 0 display_name: "Dingoo - A320 / Gemei A330 (DingooEmu)" cores: - dingooemu diff --git a/emulators/dinothawr.yml b/emulators/dinothawr.yml index 46ae02f63..19fbb5279 100644 --- a/emulators/dinothawr.yml +++ b/emulators/dinothawr.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/libretro/Dinothawr" profiled_date: "2026-03-23" source_commit: "62d3e1ecf85f0feea01cad6809a80303a87e8a28" core_version: "v1.0" +version_basis: + kind: file + path: "libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Dinothawr" cores: [dinothawr] systems: [dinothawr] diff --git a/emulators/directxbox.yml b/emulators/directxbox.yml index 3cbe83792..2081e3213 100644 --- a/emulators/directxbox.yml +++ b/emulators/directxbox.yml @@ -5,6 +5,10 @@ source: "https://github.com/libretro/libretro-directxbox" profiled_date: "2026-03-23" source_commit: "8b6b806675065efa7addb833e56fff7c05193bb1" core_version: "alpha" +version_basis: + kind: file + path: "src/libretro/libretro.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Microsoft - Xbox (DirectXbox)" cores: - directxbox diff --git a/emulators/dirksimple.yml b/emulators/dirksimple.yml index 361a065b1..66e4e3ec1 100644 --- a/emulators/dirksimple.yml +++ b/emulators/dirksimple.yml @@ -5,7 +5,11 @@ source: "https://github.com/icculus/DirkSimple" upstream: "https://github.com/icculus/DirkSimple" profiled_date: "2026-03-23" source_commit: "d5d75f97af34690010166a786ee41e15dd142c15" -core_version: "0.2" +core_version: "0.3" +version_basis: + kind: file + path: "dirksimple_libretro.c" + pattern: 'library_version\s*=\s*"([^"]+)"' display_name: "Laserdisc arcade game (DirkSimple)" cores: [dirksimple] systems: diff --git a/emulators/dolphin-mmjr.yml b/emulators/dolphin-mmjr.yml index ce9095ae6..932b85f63 100644 --- a/emulators/dolphin-mmjr.yml +++ b/emulators/dolphin-mmjr.yml @@ -7,6 +7,10 @@ profiled_date: "2026-08-11" source_commit: "1b095b64857c2eabb49a42cc56ffd9d7881bc263" upstream_commit: "0f4c971326ae9389b3ad55b0fefacb708d148f4d" core_version: "11460 - bankaimaster999" +version_basis: + kind: file + path: "Source/Android/app/build.gradle" + pattern: 'return versionNumber \+ "([^"]+)"' display_name: "Nintendo - GameCube / Wii (Dolphin MMJR)" cores: - dolphin-mmjr diff --git a/emulators/dolphin-mmjr2.yml b/emulators/dolphin-mmjr2.yml index 83eecabed..16f2e81ea 100644 --- a/emulators/dolphin-mmjr2.yml +++ b/emulators/dolphin-mmjr2.yml @@ -7,6 +7,10 @@ profiled_date: "2026-08-11" source_commit: "4d0fdb8eea616320e967408cf5547c72a95196f3" upstream_commit: "3eb30c7620b4475b2e0404228ba0d2b238a132f1" core_version: "2.0-18025" +version_basis: + kind: file + path: "Source/Android/app/build.gradle" + pattern: 'def getVersion\(\)\s*\{\s*return \u0027([^\u0027]+)\u0027' display_name: "Nintendo - GameCube / Wii (Dolphin MMJR2)" cores: - dolphin-mmjr2 diff --git a/emulators/dolphin.yml b/emulators/dolphin.yml index 34dca89af..2f76cbd20 100644 --- a/emulators/dolphin.yml +++ b/emulators/dolphin.yml @@ -7,7 +7,11 @@ logo: "https://raw.githubusercontent.com/dolphin-emu/dolphin/master/Data/dolphin profiled_date: "2026-03-23" source_commit: "3aac3f052f733f8df2f1616843a4d2e14002ed11" upstream_commit: "0d93fc035539f25790deca638d06ee3b3b45ee64" -core_version: "Git" +core_version: "2609-437-g3aac3f0" +version_basis: + kind: tag + tag: "2609" + distance: 437 display_name: "Nintendo - GameCube / Wii (Dolphin)" library_name: "dolphin-emu" cores: diff --git a/emulators/dolphin_launcher.yml b/emulators/dolphin_launcher.yml index b94a9981b..17fade041 100644 --- a/emulators/dolphin_launcher.yml +++ b/emulators/dolphin_launcher.yml @@ -7,6 +7,10 @@ upstream: "https://github.com/dolphin-emu/dolphin" profiled_date: "2026-03-23" source_commit: "6d72f3093d33461821e4f7fde6d3308187e644b9" core_version: "1.2.0" +version_basis: + kind: tag + tag: "1.2.0" + distance: 0 display_name: "Nintendo - GameCube / Wii (Dolphin Launcher)" cores: - dolphin_launcher diff --git a/emulators/dosbox-pure-unleashed.yml b/emulators/dosbox-pure-unleashed.yml index 6d499708b..0a93e2e21 100644 --- a/emulators/dosbox-pure-unleashed.yml +++ b/emulators/dosbox-pure-unleashed.yml @@ -8,6 +8,10 @@ upstream_commit: "7f6e8fb7385fa446d1444d671063268520bf9b54" logo: "https://raw.githubusercontent.com/schellingb/dosbox-pure-unleashed/main/images/logo.png" profiled_date: "2026-08-08" core_version: "1.0-preview6" +version_basis: + kind: tag + tag: "1.0-preview6" + distance: 0 display_name: "DOS (DOSBox Pure Unleashed)" cores: - dosbox-pure-unleashed diff --git a/emulators/dosbox-sdl2.yml b/emulators/dosbox-sdl2.yml index 205a970e2..c2bba4ae0 100644 --- a/emulators/dosbox-sdl2.yml +++ b/emulators/dosbox-sdl2.yml @@ -5,7 +5,11 @@ source: "https://github.com/duganchen/dosbox" upstream: "https://sourceforge.net/projects/dosbox/" profiled_date: "2026-10-20" source_commit: "e6b88ad03202d1f74e329f54f213d3b070bd6202" -core_version: "0.74-2" +core_version: "r3911-xbrz-428-ge6b88ad" +version_basis: + kind: tag + tag: "r3911-xbrz" + distance: 428 display_name: "DOS (DOSBox SDL2)" cores: [dosbox-sdl2, "DOSBOXSDL2"] systems: [dos] diff --git a/emulators/dosbox-staging.yml b/emulators/dosbox-staging.yml index 1739d9975..689a536d4 100644 --- a/emulators/dosbox-staging.yml +++ b/emulators/dosbox-staging.yml @@ -6,6 +6,11 @@ upstream: "https://github.com/dosbox-staging/dosbox-staging" source_commit: "fa1856c057902be3e08856675d6cc1e7ffe43db6" profiled_date: "2026-10-26" core_version: "0.84.0-alpha" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'project\(dosbox-staging[^)]*?VERSION\s+([0-9.]+)[^)]*\)\s*\n\s*set\(DOSBOX_VERSION \$\{PROJECT_VERSION\}(\S*)\)' + format: "{1}{2}" display_name: "DOS (DOSBox Staging)" cores: - dosbox-staging diff --git a/emulators/dosbox-x.yml b/emulators/dosbox-x.yml index 62997ce6e..74dae2715 100644 --- a/emulators/dosbox-x.yml +++ b/emulators/dosbox-x.yml @@ -5,7 +5,11 @@ source: "https://github.com/joncampbell123/dosbox-x" upstream: "https://github.com/joncampbell123/dosbox-x" source_commit: "997f616e0886a835fc3660f2b935688f17f20d42" profiled_date: "2026-09-17" -core_version: "2026.08.31" +core_version: "2026.10.01" +version_basis: + kind: file + path: "configure.ac" + pattern: 'AC_INIT\(dosbox-x,\s*([0-9.]+)' display_name: "DOS (DOSBox-X)" cores: - dosbox-x diff --git a/emulators/dosbox.yml b/emulators/dosbox.yml index 4cd5e6471..9ea8ff7d6 100644 --- a/emulators/dosbox.yml +++ b/emulators/dosbox.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/dosbox-libretro" upstream: "https://sourceforge.net/projects/dosbox/" profiled_date: "2026-10-04" source_commit: "a563bb5e9753f207d74e9d8c417d793b784fa4be" -core_version: "SVN" +core_version: "r4494" +version_basis: + kind: file + path: "libretro/svn" + pattern: '^(r\d+)' display_name: "DOS (DOSBox)" cores: [dosbox] systems: [dos] diff --git a/emulators/dosbox_core.yml b/emulators/dosbox_core.yml index c0627e052..e9e3b0e08 100644 --- a/emulators/dosbox_core.yml +++ b/emulators/dosbox_core.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/dosbox-core" upstream: "https://sourceforge.net/projects/dosbox/" profiled_date: "2026-03-23" source_commit: "d38fcb5013e4dca8d119a9786279cf41465757f2" -core_version: "SVN" +core_version: "r4494" +version_basis: + kind: file + path: "libretro/svn" + pattern: '^(r\d+)' display_name: "DOS (DOSBox-core)" cores: [dosbox_core] systems: [dos] diff --git a/emulators/dosbox_pure.yml b/emulators/dosbox_pure.yml index a91810ec0..63453181f 100644 --- a/emulators/dosbox_pure.yml +++ b/emulators/dosbox_pure.yml @@ -8,6 +8,11 @@ profiled_date: "2026-03-23" source_commit: "cdaea10ce8cac231bc341ca05d4c5e4e5fe0fbd5" upstream_commit: "cdaea10ce8cac231bc341ca05d4c5e4e5fe0fbd5" core_version: "1.0-preview7" +version_basis: + kind: tag + repo: upstream + tag: "1.0-preview7" + distance: 0 display_name: "DOS (DOSBox-Pure)" cores: [dosbox_pure] systems: [dos] diff --git a/emulators/dosbox_svn.yml b/emulators/dosbox_svn.yml index 1ff1b7a42..595f8df92 100644 --- a/emulators/dosbox_svn.yml +++ b/emulators/dosbox_svn.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/dosbox-svn" upstream: "https://sourceforge.net/projects/dosbox/" profiled_date: "2026-03-23" source_commit: "46a3164fc0f1e808ae56143d81698b00fb6b4781" -core_version: "SVN" +core_version: "SVN-libretro" +version_basis: + kind: file + path: "include/config.h" + pattern: '#define\s+VERSION\s+"([^"]+)"' display_name: "DOS (DOSBox-SVN)" cores: [dosbox_svn] systems: [dos] diff --git a/emulators/dosbox_svn_ce.yml b/emulators/dosbox_svn_ce.yml index 23aa3be53..ba4f37e14 100644 --- a/emulators/dosbox_svn_ce.yml +++ b/emulators/dosbox_svn_ce.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/dosbox-svn" upstream: "https://sourceforge.net/projects/dosbox/" profiled_date: "2026-03-23" source_commit: "46a3164fc0f1e808ae56143d81698b00fb6b4781" -core_version: "SVN CE" +core_version: "SVN-libretro" +version_basis: + kind: file + path: "include/config.h" + pattern: '#define\s+VERSION\s+"([^"]+)"' display_name: "DOS (DOSBox-SVN CE)" cores: [dosbox_svn_ce] systems: [dos] diff --git a/emulators/doublecherrygb.yml b/emulators/doublecherrygb.yml index 1c8e1a853..c433ac8b9 100644 --- a/emulators/doublecherrygb.yml +++ b/emulators/doublecherrygb.yml @@ -5,7 +5,11 @@ source: "https://github.com/TimOelrichs/doublecherryGB-libretro" upstream: "https://github.com/libertyernie/tgbdual" profiled_date: "2026-03-22" source_commit: "2e7a8bd5442ad7b2cb98ea07dbb5000ac95193e9" -core_version: "v0.18.1" +core_version: "0.18.1" +version_basis: + kind: tag + tag: "0.18.1" + distance: 0 display_name: "Nintendo - Game Boy / Color (DoubleCherryGB)" cores: - DoubleCherryGB diff --git a/emulators/doukutsu_rs.yml b/emulators/doukutsu_rs.yml index c7a4cf5be..149a5172c 100644 --- a/emulators/doukutsu_rs.yml +++ b/emulators/doukutsu_rs.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/doukutsu-rs/doukutsu-rs" profiled_date: "2026-03-23" source_commit: "51e64c2e9615bcd476034c69b58927877fe73494" upstream_commit: "d05995b2ce88e583491fcecfbd5883735d3527ea" -core_version: "1.2.0 - 0.101.0" +core_version: "0.102.0" +version_basis: + kind: file + path: "Cargo.toml" + pattern: '\[package\][^\[]*?^version\s*=\s*"([^"]+)"' display_name: "Cave Story (drs)" cores: - doukutsu_rs diff --git a/emulators/drastic.yml b/emulators/drastic.yml index ad8d041fa..a71c488af 100644 --- a/emulators/drastic.yml +++ b/emulators/drastic.yml @@ -9,6 +9,10 @@ version_source: author: "Exophase (Gilead Kutnick)" profiled_date: "2026-08-11" core_version: "r2.6.0.4a" +version_basis: + kind: package + url: "https://archive.org/details/dra-stic-ds-emulator-r-2.6.0.4a" + artifact: "com.dsemu.drastic APK versionName (build 109)" display_name: "Nintendo - DS (DraStic)" cores: - drastic diff --git a/emulators/dreamm.yml b/emulators/dreamm.yml index e25f6b715..e22c8ac61 100644 --- a/emulators/dreamm.yml +++ b/emulators/dreamm.yml @@ -8,6 +8,11 @@ version_source: pattern: 'Latest Release: ([0-9.]+)' profiled_date: "2026-08-08" core_version: "4.0" +version_basis: + kind: package + url: "https://dreamm.aarongiles.com/" + pattern: 'dreamm-(\d+(?:\.\d+)+)-win32-x64\.zip' + artifact: "dreamm-4.0-win32-x64.zip" display_name: "LucasArts (DREAMM)" cores: - dreamm diff --git a/emulators/droidarcadia.yml b/emulators/droidarcadia.yml index da6ad79cc..d2e2202bd 100644 --- a/emulators/droidarcadia.yml +++ b/emulators/droidarcadia.yml @@ -9,6 +9,10 @@ version_source: author: "James Jacobs (Amigan Software)" profiled_date: "2026-10-24" core_version: "4.75" +version_basis: + kind: package + url: "https://amigan.1emu.net/releases/" + artifact: "DroidArcadia-src.rar, release 4.75 on the author's releases page" display_name: "Emerson Arcadia 2001 / Interton VC 4000 (DroidArcadia)" cores: - droidarcadia diff --git a/emulators/dsda-doom.yml b/emulators/dsda-doom.yml index 2b864d709..488cfd6f2 100644 --- a/emulators/dsda-doom.yml +++ b/emulators/dsda-doom.yml @@ -4,7 +4,11 @@ core_classification: game_engine source: "https://github.com/dsda-org/dsda-doom" profiled_date: "2026-10-20" source_commit: "e443ff7a3e6090a1ff92ad7a426916c4fd35ecc5" -core_version: "0.29.4" +core_version: "v0.29.4" +version_basis: + kind: tag + tag: "v0.29.4" + distance: 0 display_name: "Doom (dsda-doom)" cores: [dsda-doom] systems: [doom, heretic, hexen, chex] diff --git a/emulators/dsmile.yml b/emulators/dsmile.yml index ef0d55304..0ee13f513 100644 --- a/emulators/dsmile.yml +++ b/emulators/dsmile.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/derik-dot-digital/D.Smile" profiled_date: "2026-08-11" source_commit: "d4bbc3be27537def21513991647f14ea9a9c2d15" core_version: "0.4.4" +version_basis: + kind: file + path: "app/build.gradle.kts" + pattern: 'versionName\s+=\s+"([^"]+)' display_name: "VTech - V.Smile (D.Smile)" cores: - dsmile diff --git a/emulators/dsperate.yml b/emulators/dsperate.yml index 3074eef96..9058a2dfa 100644 --- a/emulators/dsperate.yml +++ b/emulators/dsperate.yml @@ -5,7 +5,11 @@ source: "https://github.com/beebono/DSperate" profiled_date: "2026-10-17" source_commit: "81b73832c2e68e2b10cb5850f0a6d393ffc6fbb7" pin_frozen: "ROCKNIX 20261001 ships the v2.0.1 release binaries, built by CI from the v2.0.1 tag at this commit." -core_version: "2.0.1" +core_version: "v2.0.1" +version_basis: + kind: tag + tag: "v2.0.1" + distance: 0 display_name: "Nintendo - DS / DSi (DSperate)" cores: - "dsperate" diff --git a/emulators/duckstation-legacy.yml b/emulators/duckstation-legacy.yml index c892d744e..35e5bf244 100644 --- a/emulators/duckstation-legacy.yml +++ b/emulators/duckstation-legacy.yml @@ -5,7 +5,11 @@ source: "https://github.com/stenzek/duckstation" profiled_date: "2026-10-22" source_commit: "56ac3d6c324ec4520dac433b071ecab6227fb459" pin_frozen: "Batocera builds this revision of 4 August 2023 as duckstation-legacy, its package marked not to be bumped, and KNULLI ships the same package." -core_version: "0.1" +core_version: "0.1-5612-g56ac3d6" +version_basis: + kind: tag + tag: "0.1" + distance: 5612 display_name: "Sony - PlayStation (DuckStation legacy)" cores: - "duckstation-legacy" diff --git a/emulators/duckstation.yml b/emulators/duckstation.yml index 2207aeafa..4ca42ece5 100644 --- a/emulators/duckstation.yml +++ b/emulators/duckstation.yml @@ -6,7 +6,12 @@ upstream: "https://github.com/stenzek/duckstation" logo: "https://raw.githubusercontent.com/stenzek/duckstation/master/data/resources/images/duck.png" profiled_date: "2026-03-23" source_commit: "526b4a128827ecaef037134095cd95f431b52d06" -core_version: "v0.1-12070" +core_version: "v0.1-12083-406-g526b4a1" +version_basis: + kind: tag + match: "v*" + tag: "v0.1-12083" + distance: 406 display_name: "Sony - PlayStation (DuckStation)" cores: [duckstation, "Duckstation", "duckstation-sa"] found_by: content diff --git a/emulators/dusklight.yml b/emulators/dusklight.yml index aea0a641f..a4e33fbcd 100644 --- a/emulators/dusklight.yml +++ b/emulators/dusklight.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/TwilitRealm/dusklight" source_commit: "40457c6adb381928e4b5fef6ed459ed291edd5e2" profiled_date: "2026-10-20" core_version: "v2.0.3" +version_basis: + kind: tag + tag: "v2.0.3" + distance: 0 display_name: "The Legend of Zelda: Twilight Princess (Dusklight)" cores: [dusklight] systems: [dusklight] diff --git a/emulators/dxx-rebirth.yml b/emulators/dxx-rebirth.yml index d4c6578aa..c61dec3d4 100644 --- a/emulators/dxx-rebirth.yml +++ b/emulators/dxx-rebirth.yml @@ -5,7 +5,12 @@ source: "https://github.com/dxx-rebirth/dxx-rebirth" upstream: ~ profiled_date: "2026-10-04" source_commit: "6943b924bea749543ef5729f31a28345e79716d2" -core_version: "git" +core_version: "0.61.0" +version_basis: + kind: file + path: "SConstruct" + pattern: 'VERSION_MAJOR: typing.Final\[int\] = (\d+)\s+VERSION_MINOR: typing.Final\[int\] = (\d+)\s+VERSION_MICRO: typing.Final\[int\] = (\d+)' + format: "{1}.{2}.{3}" display_name: "Descent 1 & 2 (DXX-Rebirth)" cores: [dxx-rebirth, d1x-rebirth, d2x-rebirth] systems: [descent, descent2] diff --git a/emulators/easyrpg.yml b/emulators/easyrpg.yml index 272369066..8ad40c578 100644 --- a/emulators/easyrpg.yml +++ b/emulators/easyrpg.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-23" source_commit: "a3fbb7c24fd5a39fd95388d09135256a6b19f1e4" upstream_commit: "096c004def987f3545198304b1b7a8352f284a50" core_version: "0.8.1.1" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'project\(EasyRPG_Player\s+VERSION\s+([0-9][\w.+-]*)' display_name: "RPG Maker 2000/2003 (EasyRPG)" cores: - easyrpg diff --git a/emulators/ecwolf.yml b/emulators/ecwolf.yml index 4b8c52d5d..682135aaa 100644 --- a/emulators/ecwolf.yml +++ b/emulators/ecwolf.yml @@ -6,6 +6,10 @@ upstream: "https://bitbucket.org/ecwolf/ecwolf" profiled_date: "2026-03-23" source_commit: "0cccd9a85d7ffa33fb695691a8a5db246606518c" core_version: "1.3.99999" +version_basis: + kind: file + path: "src/libretro/versiondefs.h" + pattern: '#define\s+DOTVERSIONSTR_NOREV\s+"([^"]+)"' display_name: "Wolfenstein 3D (ECWolf)" cores: [ecwolf, "ECWolf"] systems: [wolfenstein3d] diff --git a/emulators/eden-nightly.yml b/emulators/eden-nightly.yml index 4b77c6a7c..c3efcea5f 100644 --- a/emulators/eden-nightly.yml +++ b/emulators/eden-nightly.yml @@ -9,7 +9,11 @@ upstream: "https://git.eden-emu.dev/eden-emu/eden" source_mirror: "https://codeberg.org/eden-emu/eden" source_commit: "8648c27cbb4c17b176076b09ac245271829cbd9f" profiled_date: "2026-08-11" -core_version: "Git" +core_version: "v0.2.0-220-g8648c27" +version_basis: + kind: tag + tag: "v0.2.0" + distance: 220 display_name: "Eden Nightly (Nintendo Switch)" cores: ["eden-nightly"] systems: [nintendo-switch] diff --git a/emulators/eden.yml b/emulators/eden.yml index a524b4a1d..dda4dfba1 100644 --- a/emulators/eden.yml +++ b/emulators/eden.yml @@ -8,7 +8,11 @@ upstream: "https://git.eden-emu.dev/eden-emu/eden" source_mirror: "https://codeberg.org/eden-emu/eden" profiled_date: "2026-03-26" source_commit: "26a03d2473787abf3223b2d0e65b291eee00c1e1" -core_version: "0.1.1" +core_version: "v0.2.0-401-g26a03d2" +version_basis: + kind: tag + tag: "v0.2.0" + distance: 401 display_name: "Eden (Nintendo Switch)" cores: - eden diff --git a/emulators/eduke32.yml b/emulators/eduke32.yml index 60ad486b8..0c781d974 100644 --- a/emulators/eduke32.yml +++ b/emulators/eduke32.yml @@ -9,6 +9,11 @@ version_source: profiled_date: "2026-10-04" source_commit: "ec5824db81817866f70da326d3811bb0f52b3517" core_version: "20260807-10669-ec5824db8" +version_basis: + kind: package + url: "https://dukeworld.com/eduke32/synthesis/20260807-10669-ec5824db8/eduke32_src_20260807-10669-ec5824db8.tar.xz" + pattern: 'eduke32_src_(\d{8}-\d+-[0-9a-f]+)\.tar' + artifact: "eduke32_src_20260807-10669-ec5824db8.tar.xz" display_name: "EDuke32" cores: [eduke32] systems: [duke3d, nam, napalm, ww2gi] diff --git a/emulators/eightyone.yml b/emulators/eightyone.yml index dc198712b..bd2c20de4 100644 --- a/emulators/eightyone.yml +++ b/emulators/eightyone.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/charlierobson/EightyOne" profiled_date: "2026-10-17" source_commit: "39ea0cc26453f4026458cfff62f10223a6bb1724" core_version: "v1.2" +version_basis: + kind: tag + tag: "v1.2" + distance: 0 pin_frozen: "Recalbox, the only platform that ships this core, builds its tag v1.2 (Recalbox package/libretro-eightyone/libretro-eightyone.mk:8 at tag 10.1.1)." display_name: "Sinclair - ZX81 / ZX Spectrum (EightyOne, Recalbox)" cores: ["eightyone"] diff --git a/emulators/eka2l1.yml b/emulators/eka2l1.yml index a89262391..a9ec08407 100644 --- a/emulators/eka2l1.yml +++ b/emulators/eka2l1.yml @@ -11,7 +11,18 @@ source_commit: libretro: "e39ccac46e631be7470b4f420fb8eebed9c24eca" upstream_commit: libretro: "11f5b6cd141c0884a130ee45743f1f5e17bb1783" -core_version: "0.0.9" +core_version: + standalone: "0.0.9" + libretro: "0.0.9" +version_basis: + standalone: + kind: file + path: "src/emu/common/include/common/configure.h.in" + pattern: '#define\s+CURRENT_EKA2L1_VERSION_STRING\s+"([^"]+)"' + libretro: + kind: file + path: "src/emu/common/include/common/configure.h.in" + pattern: '#define\s+CURRENT_EKA2L1_VERSION_STRING\s+"([^"]+)"' display_name: "Nokia - Symbian / N-Gage (EKA2L1)" cores: - eka2l1 diff --git a/emulators/emucorev.yml b/emulators/emucorev.yml index bbabf6e30..2c43a987b 100644 --- a/emulators/emucorev.yml +++ b/emulators/emucorev.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/Vita3K/Vita3K" author: "Oleksandr (sashkinbro)" profiled_date: "2026-08-11" source_commit: "b71d8cf5397b1c6394e92eeb48427cd17e542971" -core_version: "0.1.8" +core_version: "v0.1.8" +version_basis: + kind: tag + tag: "v0.1.8" + distance: 0 display_name: "Sony - PlayStation Vita (EmuCoreV)" cores: - emucorev diff --git a/emulators/emucorex.yml b/emulators/emucorex.yml index 889c28981..f1e4aa854 100644 --- a/emulators/emucorex.yml +++ b/emulators/emucorex.yml @@ -9,7 +9,11 @@ source_commit: "bda532f311a77c828a4e6bdb4667d9e92dfdf62f" prose_written_at: "bda532f311a77c828a4e6bdb4667d9e92dfdf62f" upstream_commit: "c10a5c9ad951c517dc48b722b5b84eb9bf7fdb42" upstream_frozen: "The PCSX2 tree vendored in the app is this revision. Later PCSX2 commits are not in the port." -core_version: "0.5.1" +core_version: "v0.5.1" +version_basis: + kind: tag + tag: "v0.5.1" + distance: 0 display_name: "Sony - PlayStation 2 (EmuCoreX)" cores: - emucorex diff --git a/emulators/emucv.yml b/emulators/emucv.yml index 070ef664b..693a82b2e 100644 --- a/emulators/emucv.yml +++ b/emulators/emucv.yml @@ -6,6 +6,11 @@ profiled_date: "2026-09-17" pin_frozen: "the shipping core is this revision. HEAD removed the prototype from the tree on 2026-09-11 (a188c0f) and archived its last description files on 2026-09-12 (06a066a). The new skeleton has not yet been copied from libretro-emuscv1 (ROADMAP step 4.3). Recaling would repoint these refs at a tree that currently holds nothing but a roadmap." source_commit: "6a6e1b15c5d5afefbc3fefdd8b52f3f2c8c0a0eb" core_version: "0.00" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'set\(CORE_VERSION_MAJOR\s+"([^"]+)"\)[\s\S]*?set\(CORE_VERSION_MINOR\s+"([^"]+)"\)' + format: "{1}.{2}" display_name: "EPOCH - Cassette Vision (EmuCV)" cores: - emucv diff --git a/emulators/emuscv.yml b/emulators/emuscv.yml index 5c0aabbef..075345385 100644 --- a/emulators/emuscv.yml +++ b/emulators/emuscv.yml @@ -5,7 +5,12 @@ source: "https://github.com/wzcwzc/emuscv" upstream: "http://takeda-toshiya.my.coocan.jp/" profiled_date: "2026-03-23" source_commit: "6d6e4594d79862dd5c2ff7f725c109e4266f0b03" -core_version: "0.12" +core_version: "0.12.20220726135800" +version_basis: + kind: file + path: "Makefile.libretro" + pattern: '^EMUSCV_VERSION_MAJOR := (\d+)\s*\nEMUSCV_VERSION_MINOR := (\d+)\s*\n(?:#.*\n)*EMUSCV_VERSION_DATETIME := \u0027?(\d+)' + format: "{1}.{2}.{3}" display_name: "EPOCH/YENO Super Cassette Vision" cores: - emuscv diff --git a/emulators/emux_chip8.yml b/emulators/emux_chip8.yml index a918f8708..34027feb2 100644 --- a/emulators/emux_chip8.yml +++ b/emulators/emux_chip8.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/sronsse/emux" profiled_date: "2026-03-23" source_commit: "3e558cb15157fcb1944846a845547d369605c963" core_version: "0.1" +version_basis: + kind: file + path: "configure.ac" + pattern: 'AC_INIT\(\[emux\],\s+\[([0-9][\w.+-]*)' display_name: "CHIP-8 (Emux CHIP-8)" cores: - emux_chip8 diff --git a/emulators/emux_gb.yml b/emulators/emux_gb.yml index 2822226cc..660d22424 100644 --- a/emulators/emux_gb.yml +++ b/emulators/emux_gb.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/sronsse/emux" profiled_date: "2026-03-23" source_commit: "3e558cb15157fcb1944846a845547d369605c963" core_version: "0.1" +version_basis: + kind: file + path: "configure.ac" + pattern: 'AC_INIT\(\[emux\],\s+\[([0-9][\w.+-]*)' display_name: "Nintendo - Game Boy / Color (Emux GB)" cores: - emux_gb diff --git a/emulators/emux_nes.yml b/emulators/emux_nes.yml index b97df551c..15a0cbf25 100644 --- a/emulators/emux_nes.yml +++ b/emulators/emux_nes.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/sronsse/emux" profiled_date: "2026-03-23" source_commit: "3e558cb15157fcb1944846a845547d369605c963" core_version: "0.1" +version_basis: + kind: file + path: "configure.ac" + pattern: 'AC_INIT\(\[emux\],\s+\[([0-9][\w.+-]*)' display_name: "Nintendo - NES / Famicom (Emux NES)" cores: - emux_nes diff --git a/emulators/emux_sms.yml b/emulators/emux_sms.yml index 507c04e53..45bc7789b 100644 --- a/emulators/emux_sms.yml +++ b/emulators/emux_sms.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/sronsse/emux" profiled_date: "2026-03-23" source_commit: "3e558cb15157fcb1944846a845547d369605c963" core_version: "0.1" +version_basis: + kind: file + path: "configure.ac" + pattern: 'AC_INIT\(\[emux\],\s+\[([0-9][\w.+-]*)' display_name: "Sega - Master System (Emux SMS)" cores: - emux_sms diff --git a/emulators/ep128emu.yml b/emulators/ep128emu.yml index 6007549e3..0f43da1c4 100644 --- a/emulators/ep128emu.yml +++ b/emulators/ep128emu.yml @@ -3,6 +3,9 @@ type: alias alias_of: "ep128emu_core" profiled_date: "2026-03-30" core_version: "v1.2.13" +version_basis: + kind: alias + of: "ep128emu_core" display_name: "Enterprise - 64/128 (ep128emu)" cores: - ep128emu diff --git a/emulators/ep128emu_core.yml b/emulators/ep128emu_core.yml index 13a044260..23e738ad4 100644 --- a/emulators/ep128emu_core.yml +++ b/emulators/ep128emu_core.yml @@ -7,6 +7,10 @@ upstream: https://github.com/istvan-v/ep128emu profiled_date: "2026-03-30" source_commit: "6af5d03de220e6effcd0ffcd99e0fb8e8c77cc9e" core_version: "v1.2.13" +version_basis: + kind: file + path: "core/main.cpp" + pattern: 'library_version\s*=\s*"([^"]+)"' cores: - ep128emu_core diff --git a/emulators/epsxe.yml b/emulators/epsxe.yml index 2108dd6a6..44ee5dd1d 100644 --- a/emulators/epsxe.yml +++ b/emulators/epsxe.yml @@ -9,6 +9,12 @@ version_source: author: "calb, _Demo_, Galtor (ePSXe Software S.L.)" profiled_date: "2026-08-11" core_version: "2.0.18" +version_basis: + kind: package + url: "https://www.epsxe.com/files/ePSXe2018.zip" + pattern: 'ePSXe(\d)(\d)(\d+)\.zip' + format: "{1}.{2}.{3}" + artifact: "ePSXe2018.zip" display_name: "Sony - PlayStation (ePSXe)" cores: - epsxe diff --git a/emulators/eternal.yml b/emulators/eternal.yml index 774d61ee6..d8de22e9c 100644 --- a/emulators/eternal.yml +++ b/emulators/eternal.yml @@ -7,6 +7,10 @@ source_commit: "13adf143aea822ebc1ee23722a80e93a7979e4c9" upstream_commit: "23d4d6c7c9f930fa72568ce795c9147af09d56ee" profiled_date: "2026-09-17" core_version: "0.0" +version_basis: + kind: file + path: "libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Eternal Computer" cores: - eternal diff --git a/emulators/etlegacy.yml b/emulators/etlegacy.yml index 5dd6473a3..1cb547b8e 100644 --- a/emulators/etlegacy.yml +++ b/emulators/etlegacy.yml @@ -5,7 +5,12 @@ source: "https://github.com/etlegacy/etlegacy" upstream: "https://github.com/etlegacy/etlegacy" profiled_date: "2026-10-04" source_commit: "add16a92f4b10c2955136e3ab7868bfc9bff8fad" -core_version: "2.86.2+git" +core_version: "2.86.2" +version_basis: + kind: file + path: "VERSION.txt" + pattern: '^VERSION_MAJOR (\d+)\s*\nVERSION_MINOR (\d+)\s*\nVERSION_PATCH (\d+)' + format: "{1}.{2}.{3}" display_name: "ET: Legacy" cores: [etlegacy] systems: [enemyterritory] diff --git a/emulators/exel-hence.yml b/emulators/exel-hence.yml index dad472d80..84da03588 100644 --- a/emulators/exel-hence.yml +++ b/emulators/exel-hence.yml @@ -6,6 +6,10 @@ upstream: "https://gitlab.com/recalbox/packages/recalbox-cores/libretro-exel-hen profiled_date: "2026-10-17" source_commit: "213328332cbfd8121e018493443e223a7a780961" core_version: "v0.9" +version_basis: + kind: tag + tag: "v0.9" + distance: 0 pin_frozen: "Recalbox, the only platform that ships this core, builds its tag v0.9 (Recalbox package/libretro-exel-hence/libretro-exel-hence.mk:9 at tag 10.1.1)." display_name: "Exelvision - EXL 100 (Exel-Hence)" cores: diff --git a/emulators/fake08.yml b/emulators/fake08.yml index c70489851..a6a62586a 100644 --- a/emulators/fake08.yml +++ b/emulators/fake08.yml @@ -6,6 +6,11 @@ upstream: "https://github.com/jtothebell/fake-08" profiled_date: "2026-03-23" source_commit: "814991a2571ad3970e386cef48f3b148aa1c27b9" core_version: "v0.0.2.20" +version_basis: + kind: file + path: "Makefile" + pattern: '^export V_MAJOR\s*=\s*(\d+)\s*\nexport V_MINOR\s*=\s*(\d+)\s*\nexport V_PATCH\s*=\s*(\d+)\s*\nexport V_BUILD\s*=\s*(\d+)' + format: "v{1}.{2}.{3}.{4}" display_name: "PICO-8 (FAKE-08)" cores: - fake08 diff --git a/emulators/fallout1-ce.yml b/emulators/fallout1-ce.yml index d1601e0ad..4878f708a 100644 --- a/emulators/fallout1-ce.yml +++ b/emulators/fallout1-ce.yml @@ -5,7 +5,11 @@ source: "https://github.com/alexbatalov/fallout1-ce" upstream: ~ profiled_date: "2026-10-04" source_commit: "0609bcfd0ec40ff0571d0f57fab2821eb461dc8b" -core_version: "1.1" +core_version: "v1.1.0-7-g0609bcf" +version_basis: + kind: tag + tag: "v1.1.0" + distance: 7 display_name: "Fallout (Community Edition)" cores: [fallout1-ce, "fallout-ce"] systems: [fallout] diff --git a/emulators/fallout2-ce.yml b/emulators/fallout2-ce.yml index 9af483cba..65f409c2d 100644 --- a/emulators/fallout2-ce.yml +++ b/emulators/fallout2-ce.yml @@ -5,7 +5,11 @@ source: "https://github.com/alexbatalov/fallout2-ce" upstream: ~ profiled_date: "2026-10-04" source_commit: "e97087b9582f37075db347a89898887320753f8b" -core_version: "v1.3.0" +core_version: "1.3.0" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'MACOSX_BUNDLE_SHORT_VERSION_STRING\s+"([^"]+)"' display_name: "Fallout 2 (Community Edition)" cores: [fallout2-ce] systems: [fallout2] diff --git a/emulators/fba2x.yml b/emulators/fba2x.yml index 4f9f3e2c8..49894d147 100644 --- a/emulators/fba2x.yml +++ b/emulators/fba2x.yml @@ -5,7 +5,12 @@ source: "https://github.com/recalbox/pifba" upstream: "https://github.com/finalburn/fbalpha-fork" profiled_date: "2026-10-04" source_commit: "419faa7c3967560f6fa149f2f78b5aedca5b123a" -core_version: "0.2.97.x" +core_version: "0.2.96.71" +version_basis: + kind: file + path: "burn/version.h" + pattern: '#define\s+VER_MAJOR\s+(\d+)\s*\n#define\s+VER_MINOR\s+(\d+)\s*\n#define\s+VER_BETA\s+(\d+)\s*\n#define\s+VER_ALPHA\s+(\d+)' + format: "{1}.{2}.{3}.{4}" display_name: "Arcade (piFBA / fba2x)" cores: [fba2x, pifba] systems: diff --git a/emulators/fbalpha2012.yml b/emulators/fbalpha2012.yml index 73c886a81..4d54627cb 100644 --- a/emulators/fbalpha2012.yml +++ b/emulators/fbalpha2012.yml @@ -5,7 +5,12 @@ source: "https://github.com/libretro/fbalpha2012" upstream: "https://www.fbalpha.com" profiled_date: "2026-03-23" source_commit: "0ce31536bef3162fe7e69ff5f555334ec4913cef" -core_version: "v0.2.97.29" +core_version: "v0.2.97.30" +version_basis: + kind: file + path: "svn-current/trunk/src/burn/version.h" + pattern: '#define\s+VER_MAJOR\s+(\d+)\s*\n#define\s+VER_MINOR\s+(\d+)\s*\n#define\s+VER_BETA\s+(\d+)\s*\n#define\s+VER_ALPHA\s+(\d+)' + format: "v{1}.{2}.{3}.{4}" display_name: "Arcade (FB Alpha 2012)" library_name: "FB Alpha 2012" cores: diff --git a/emulators/fbalpha2012_cps1.yml b/emulators/fbalpha2012_cps1.yml index 7931815e0..3e85ca306 100644 --- a/emulators/fbalpha2012_cps1.yml +++ b/emulators/fbalpha2012_cps1.yml @@ -6,6 +6,10 @@ upstream: "https://www.fbalpha.com" profiled_date: "2026-03-23" source_commit: "5542c1848ef81e92db311193b01dc349bc29d7cc" core_version: "v0.2.97.28" +version_basis: + kind: file + path: "src/burner/libretro/libretro.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Arcade (FB Alpha 2012 CPS-1)" cores: - fbalpha2012_cps1 diff --git a/emulators/fbalpha2012_cps2.yml b/emulators/fbalpha2012_cps2.yml index 5387c8783..5bee02d1f 100644 --- a/emulators/fbalpha2012_cps2.yml +++ b/emulators/fbalpha2012_cps2.yml @@ -6,6 +6,10 @@ upstream: "https://www.fbalpha.com" profiled_date: "2026-03-23" source_commit: "e3f3ae46f120228b74d41e2b2862f1fbf1d7eb5b" core_version: "v0.2.97.28" +version_basis: + kind: file + path: "src/burner/libretro/libretro.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Arcade (FB Alpha 2012 CPS-2)" cores: - fbalpha2012_cps2 diff --git a/emulators/fbalpha2012_cps3.yml b/emulators/fbalpha2012_cps3.yml index ed6d85bcd..cdb667f78 100644 --- a/emulators/fbalpha2012_cps3.yml +++ b/emulators/fbalpha2012_cps3.yml @@ -5,7 +5,12 @@ source: "https://github.com/libretro/fbalpha2012_cps3" upstream: "https://www.fbalpha.com" profiled_date: "2026-03-23" source_commit: "57f8015c2e0cdebcf42d3add2c314bb7448f9421" -core_version: "v0.2.97.29" +core_version: "v0.2.97.30" +version_basis: + kind: file + path: "svn-current/trunk/src/burn/version.h" + pattern: '#define\s+VER_MAJOR\s+(\d+)\s*\n#define\s+VER_MINOR\s+(\d+)\s*\n#define\s+VER_BETA\s+(\d+)\s*\n#define\s+VER_ALPHA\s+(\d+)' + format: "v{1}.{2}.{3}.{4}" display_name: "Arcade (FB Alpha 2012 CPS-3)" library_name: "FB Alpha 2012 CPS-3" cores: diff --git a/emulators/fbalpha2012_neogeo.yml b/emulators/fbalpha2012_neogeo.yml index e760d3c0d..47de1d852 100644 --- a/emulators/fbalpha2012_neogeo.yml +++ b/emulators/fbalpha2012_neogeo.yml @@ -6,6 +6,10 @@ upstream: "https://www.fbalpha.com" profiled_date: "2026-03-23" source_commit: "e092097037d494c50d17c5144a2758d68538061b" core_version: "v0.2.97.29" +version_basis: + kind: file + path: "src/burner/libretro/libretro.cpp" + pattern: '#define\s+FBA_VERSION\s+"([^"]+)' display_name: "Arcade (FB Alpha 2012 Neo Geo)" cores: - fbalpha2012_neogeo diff --git a/emulators/fbalpha2019.yml b/emulators/fbalpha2019.yml index 3d113071e..dc2100223 100644 --- a/emulators/fbalpha2019.yml +++ b/emulators/fbalpha2019.yml @@ -8,6 +8,10 @@ source_commit: "0581797db6fdffd826086b053ced4b6b29bb6678" upstream_commit: "416948158d88ba17f87813419ddc3c1209ef68c0" pin_frozen: "ROCKNIX 20261001 builds this revision as fbalpha2019. Later commits on libretro/fbalpha are not part of that build." core_version: "v0.2.97.44" +version_basis: + kind: file + path: "src/burner/libretro/libretro.cpp" + pattern: '#define\s+FBA_VERSION\s+"([^"]+)' display_name: "Arcade (FB Alpha 2019)" cores: - "fbalpha2019" diff --git a/emulators/fbneo.yml b/emulators/fbneo.yml index c07e291c5..4c397f359 100644 --- a/emulators/fbneo.yml +++ b/emulators/fbneo.yml @@ -7,7 +7,12 @@ logo: "https://raw.githubusercontent.com/finalburnneo/FBNeo/master/projectfiles/ profiled_date: "2026-10-23" source_commit: "32958a89abd645ed20387c8d31d21ede3bc48161" upstream_commit: "697e385c8285bfc528343b96d948ee3a4f9e24c5" -core_version: "v1.0.0.2" +core_version: "v1.0.0.03" +version_basis: + kind: file + path: "src/burn/version.h" + pattern: '#define\s+VER_MAJOR\s+(\d+)\s*\n#define\s+VER_MINOR\s+(\d+)\s*\n#define\s+VER_BETA\s+(\d+)\s*\n#define\s+VER_ALPHA\s+(\d+)' + format: "v{1}.{2}.{3}.{4:02d}" display_name: "Arcade (FinalBurn Neo)" cores: - fbneo diff --git a/emulators/fbneo_cps12.yml b/emulators/fbneo_cps12.yml index 8851a206b..4d2d50469 100644 --- a/emulators/fbneo_cps12.yml +++ b/emulators/fbneo_cps12.yml @@ -6,7 +6,12 @@ upstream: "https://github.com/finalburnneo/FBNeo" profiled_date: "2026-03-23" source_commit: "32958a89abd645ed20387c8d31d21ede3bc48161" upstream_commit: "697e385c8285bfc528343b96d948ee3a4f9e24c5" -core_version: "v1.0.0.2" +core_version: "v1.0.0.03" +version_basis: + kind: file + path: "src/burn/version.h" + pattern: '#define\s+VER_MAJOR\s+(\d+)\s*\n#define\s+VER_MINOR\s+(\d+)\s*\n#define\s+VER_BETA\s+(\d+)\s*\n#define\s+VER_ALPHA\s+(\d+)' + format: "v{1}.{2}.{3}.{4:02d}" display_name: "Arcade (FinalBurn Neo) (CPS-1 / CPS-2)" cores: - fbneo_cps12 diff --git a/emulators/fbneo_neogeo.yml b/emulators/fbneo_neogeo.yml index e74565e97..631c744e9 100644 --- a/emulators/fbneo_neogeo.yml +++ b/emulators/fbneo_neogeo.yml @@ -6,7 +6,12 @@ upstream: "https://github.com/finalburnneo/FBNeo" profiled_date: "2026-10-23" source_commit: "32958a89abd645ed20387c8d31d21ede3bc48161" upstream_commit: "697e385c8285bfc528343b96d948ee3a4f9e24c5" -core_version: "v1.0.0.2" +core_version: "v1.0.0.03" +version_basis: + kind: file + path: "src/burn/version.h" + pattern: '#define\s+VER_MAJOR\s+(\d+)\s*\n#define\s+VER_MINOR\s+(\d+)\s*\n#define\s+VER_BETA\s+(\d+)\s*\n#define\s+VER_ALPHA\s+(\d+)' + format: "v{1}.{2}.{3}.{4:02d}" display_name: "Arcade (FinalBurn Neo) (Neo-Geo)" cores: - fbneo_neogeo diff --git a/emulators/fbzx.yml b/emulators/fbzx.yml index cdd3d55e5..5151c7190 100644 --- a/emulators/fbzx.yml +++ b/emulators/fbzx.yml @@ -5,6 +5,10 @@ source: "https://github.com/rastersoft/fbzx" profiled_date: "2026-10-20" source_commit: "8d6b4cbe5fec121015e98cf545e9b3b811de2a71" core_version: "3.9.1" +version_basis: + kind: file + path: "src/menus.cpp" + pattern: 'print_string\("FBZX \(([0-9][^)"]*)\)"' display_name: "Sinclair - ZX Spectrum (FBZX)" mode: standalone cores: diff --git a/emulators/fceumm.yml b/emulators/fceumm.yml index b210da2ad..9a952e896 100644 --- a/emulators/fceumm.yml +++ b/emulators/fceumm.yml @@ -5,7 +5,9 @@ source: "https://github.com/libretro/libretro-fceumm" upstream: "https://sourceforge.net/projects/fceumm/" profiled_date: "2026-03-23" source_commit: "ac0d00a7fc01166f84c7bac01d1084ebfd2b865e" -core_version: "SVN" +core_version: null +version_basis: + kind: none display_name: "Nintendo - NES / Famicom (FCEUmm)" cores: - fceumm diff --git a/emulators/fceumm_mod.yml b/emulators/fceumm_mod.yml index 913e41b17..19d7a2065 100644 --- a/emulators/fceumm_mod.yml +++ b/emulators/fceumm_mod.yml @@ -5,7 +5,11 @@ source: "https://github.com/Tippek/libretro-fceumm-mod" upstream: "https://github.com/libretro/libretro-fceumm" profiled_date: "2026-10-22" source_commit: "f0ea52190427b136a51da4490e49087fe2ca2abb" -core_version: "SVN" +core_version: "0.9.45.1.0.0.D7a" +version_basis: + kind: file + path: "src/drivers/libretro/libretro.c" + pattern: 'library_version\s*=\s*"\(([^)"]+)\)"' display_name: "Nintendo - NES / Famicom (FCEUmm Mod)" cores: - "fceumm_mod" diff --git a/emulators/ffmpeg.yml b/emulators/ffmpeg.yml index f061e9a70..fea1d8979 100644 --- a/emulators/ffmpeg.yml +++ b/emulators/ffmpeg.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/FFmpeg" upstream: "https://www.ffmpeg.org/" profiled_date: "2026-03-23" source_commit: "4920879d2f09a78cdf855403c349457cee1c31da" -core_version: "Git" +core_version: "2.7.git" +version_basis: + kind: file + path: "RELEASE" + pattern: '^(\S+)' display_name: "FFmpeg" cores: - ffmpeg diff --git a/emulators/fheroes2.yml b/emulators/fheroes2.yml index 6cd29936c..75f787a71 100644 --- a/emulators/fheroes2.yml +++ b/emulators/fheroes2.yml @@ -6,6 +6,10 @@ profiled_date: "2026-10-22" source_commit: "35ca4c915be00098547d5050412a325dd55cfd7f" pin_frozen: "EmuELEC, the only platform that ships it, builds this revision (EmuELEC packages/sx05re/emuelec-ports/fheroes2/package.mk:5). Later commits are not part of that build." core_version: "1.0.11" +version_basis: + kind: file + path: "version.txt" + pattern: '^(\d[^\s]*)' display_name: "Heroes of Might and Magic II (fheroes2)" cores: - "fheroes2" diff --git a/emulators/fixgb.yml b/emulators/fixgb.yml index 597cc84ac..e166dd8b0 100644 --- a/emulators/fixgb.yml +++ b/emulators/fixgb.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/FIX94/fixGB" profiled_date: "2026-03-23" source_commit: "c7865087b8c8126b8bc60fddb75b20efad992458" core_version: "Alpha v0.8.2" +version_basis: + kind: file + path: "main.c" + pattern: 'VERSION_STRING\s*=\s*"fixGB ([^"]+)"' display_name: "Nintendo - Game Boy / Color (fixGB)" cores: - fixgb diff --git a/emulators/fixnes.yml b/emulators/fixnes.yml index d997942d9..b078437b6 100644 --- a/emulators/fixnes.yml +++ b/emulators/fixnes.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-23" source_commit: "72af6c6fd9d3b6c23d334ba7385b6a09225f2276" prose_written_at: "72af6c6fd9d3b6c23d334ba7385b6a09225f2276" core_version: "Alpha v1.3.1" +version_basis: + kind: file + path: "main.c" + pattern: 'VERSION_STRING\s*=\s*"fixNES ([^"]+)"' display_name: "Nintendo - NES / Famicom (fixNES)" cores: - fixnes diff --git a/emulators/flycast-dojo.yml b/emulators/flycast-dojo.yml index e3fe22e14..b782d6d89 100644 --- a/emulators/flycast-dojo.yml +++ b/emulators/flycast-dojo.yml @@ -9,7 +9,11 @@ upstream_commit: "f13d7ad2dcf141f59a999090dbc0f78be196e665" upstream_frozen: "The dojo branch carries flyinghead/flycast up to this commit, with the Dojo commits on top and no later merge." logo: "https://raw.githubusercontent.com/blueminder/flycast-dojo/master/shell/linux/flycast-dojo.png" profiled_date: "2026-10-27" -core_version: "dojo-7.9" +core_version: "dojo-7.9-1-gfa518ac" +version_basis: + kind: tag + tag: "dojo-7.9" + distance: 1 display_name: "Sega - Dreamcast/Naomi (Flycast Dojo)" cores: - flycast-dojo diff --git a/emulators/flycast.yml b/emulators/flycast.yml index 23925cfb2..0901a1fda 100644 --- a/emulators/flycast.yml +++ b/emulators/flycast.yml @@ -5,7 +5,11 @@ source: "https://github.com/flyinghead/flycast" logo: "https://raw.githubusercontent.com/flyinghead/flycast/master/shell/linux/flycast.png" profiled_date: "2026-10-21" source_commit: "e88b0ced2243de137bcccac0072f0c1dd00d41bd" -core_version: "Git" +core_version: "v2.7-90-ge88b0ce" +version_basis: + kind: tag + tag: "v2.7" + distance: 90 display_name: "Sega - Dreamcast/Naomi (Flycast)" cores: - flycast diff --git a/emulators/flycast2021.yml b/emulators/flycast2021.yml index 9ad942f82..7f6257b87 100644 --- a/emulators/flycast2021.yml +++ b/emulators/flycast2021.yml @@ -8,6 +8,10 @@ source_commit: "603814c9f73b773c455d9a497f389d2f93a257fd" upstream_commit: "4c293f306bc16a265c2d768af5d0cea138426054" pin_frozen: "ROCKNIX 20261001 builds this revision. No branch of metallic77/flycast holds it any more: master was put back on the libretro merge base 4c293f30, so HEAD is the code before the fork's own commits." core_version: "0.1" +version_basis: + kind: file + path: "core/libretro/libretro.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Sega - Dreamcast/Naomi (Flycast 2021)" cores: - "flycast2021" diff --git a/emulators/flycast2021le.yml b/emulators/flycast2021le.yml index a2f9a8f80..e66242c0f 100644 --- a/emulators/flycast2021le.yml +++ b/emulators/flycast2021le.yml @@ -8,6 +8,10 @@ source_commit: "bf93aece5db7050667075a1107ab380856b44a23" upstream_commit: "4c293f306bc16a265c2d768af5d0cea138426054" pin_frozen: "AmberELEC, the only platform that ships it, builds this revision (AmberELEC packages/games/libretro/flycast2021le/package.mk:5). Later commits are not part of that build." core_version: "0.1" +version_basis: + kind: file + path: "core/libretro/libretro.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Sega - Dreamcast/Naomi (Flycast 2021 LE)" cores: - "flycast2021le" diff --git a/emulators/flycast_gles2.yml b/emulators/flycast_gles2.yml index c704b227a..e21f1eca9 100644 --- a/emulators/flycast_gles2.yml +++ b/emulators/flycast_gles2.yml @@ -2,7 +2,10 @@ emulator: "Flycast GLES2" type: alias alias_of: "flycast" profiled_date: "2026-03-23" -core_version: "0.1" +core_version: "v2.7-90-ge88b0ce" +version_basis: + kind: alias + of: "flycast" display_name: "Sega - Dreamcast/Naomi (Flycast GLES2)" cores: - flycast_gles2 diff --git a/emulators/fmsx.yml b/emulators/fmsx.yml index 9cc2334a7..ea64e6953 100644 --- a/emulators/fmsx.yml +++ b/emulators/fmsx.yml @@ -6,6 +6,10 @@ upstream: "https://fms.komkon.org/fMSX/" profiled_date: "2026-03-23" source_commit: "4de11755ce4f196ac1c8a7bb20bb4eccbc87a7d4" core_version: "6.0" +version_basis: + kind: file + path: "libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Microsoft - MSX (fMSX)" cores: - fmsx diff --git a/emulators/fpse-ng.yml b/emulators/fpse-ng.yml index cfb92408a..acff63142 100644 --- a/emulators/fpse-ng.yml +++ b/emulators/fpse-ng.yml @@ -10,6 +10,10 @@ version_source: author: "Schtruck & LDchen" profiled_date: "2026-08-11" core_version: "1.13" +version_basis: + kind: package + url: "https://play.google.com/store/apps/details?id=com.emulator.fpse64" + artifact: "com.emulator.fpse64 APK versionName (versionCode 245)" display_name: "Sony - PlayStation (FPseNG)" cores: - "fpse-ng" diff --git a/emulators/fpse.yml b/emulators/fpse.yml index c476f9758..6ff069202 100644 --- a/emulators/fpse.yml +++ b/emulators/fpse.yml @@ -10,6 +10,10 @@ version_source: author: "Schtruck & LDchen (EMUSOFT)" profiled_date: "2026-08-11" core_version: "12.1" +version_basis: + kind: package + url: "https://play.google.com/store/apps/details?id=com.emulator.fpse" + artifact: "com.emulator.fpse APK versionName (versionCode 940)" display_name: "Sony - PlayStation (FPse)" cores: - fpse diff --git a/emulators/freechaf.yml b/emulators/freechaf.yml index ebb8383b7..a45926c2b 100644 --- a/emulators/freechaf.yml +++ b/emulators/freechaf.yml @@ -4,7 +4,11 @@ core_classification: pure_libretro source: "https://github.com/libretro/FreeChaF" profiled_date: "2026-03-23" source_commit: "76c7a84f1f7e80f3e6f2bba96fe100cb24e99124" -core_version: "GIT" +core_version: "1.0" +version_basis: + kind: file + path: "src/libretro.c" + pattern: 'library_version\s*=\s*"([^"]+)"' display_name: "Fairchild - ChannelF (FreeChaF)" cores: - freechaf diff --git a/emulators/freeintv.yml b/emulators/freeintv.yml index 06a196437..e2448ea51 100644 --- a/emulators/freeintv.yml +++ b/emulators/freeintv.yml @@ -4,7 +4,11 @@ core_classification: pure_libretro source: "https://github.com/libretro/FreeIntv" profiled_date: "2026-03-23" source_commit: "ef3e0fe322bec62a7f916c0bb0834c08c348d0b4" -core_version: "2018.1.5" +core_version: "1.2" +version_basis: + kind: file + path: "src/libretro.c" + pattern: 'library_version\s*=\s*"([^" ]+)\s*"' display_name: "Mattel - Intellivision (FreeIntv)" cores: - freeintv diff --git a/emulators/freeintv_ts_overlay.yml b/emulators/freeintv_ts_overlay.yml index a84b982bc..8d35f02b6 100644 --- a/emulators/freeintv_ts_overlay.yml +++ b/emulators/freeintv_ts_overlay.yml @@ -6,6 +6,9 @@ source: "https://github.com/jcarr71/FreeIntvTSOverlay" upstream: "https://github.com/libretro/FreeIntv" profiled_date: "2026-03-22" core_version: "1.2" +version_basis: + kind: alias + of: "freeintv" display_name: "Mattel - Intellivision (FreeIntvTSOverlay)" cores: - freeintv diff --git a/emulators/freej2me.yml b/emulators/freej2me.yml index 9300db0b2..45a5bc303 100644 --- a/emulators/freej2me.yml +++ b/emulators/freej2me.yml @@ -5,7 +5,11 @@ source: "https://github.com/hex007/freej2me" upstream: "https://github.com/hex007/freej2me" profiled_date: "2026-03-23" source_commit: "eb4ac87e9a0b212ba701691eb67ee4892b2b1173" -core_version: "GIT" +core_version: "1.0.0" +version_basis: + kind: file + path: "META-INF/freej2me-build.version" + pattern: '^build\.version=(\S+)' display_name: "Mobile - J2ME (FreeJ2ME)" cores: - freej2me diff --git a/emulators/freej2me_plus.yml b/emulators/freej2me_plus.yml index 853535675..78f4ad875 100644 --- a/emulators/freej2me_plus.yml +++ b/emulators/freej2me_plus.yml @@ -7,6 +7,10 @@ profiled_date: "2026-10-04" source_commit: "3933e75ef927a95eab9f5e065f0b76dabf336069" upstream_commit: "8b9bc8a19baf26e3d92f88934a64a32f1cbc2795" core_version: "1.53" +version_basis: + kind: file + path: "src/libretro/freej2me_libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Mobile - J2ME (FreeJ2ME-Plus)" cores: - freej2me_plus diff --git a/emulators/frodo.yml b/emulators/frodo.yml index c96866083..6390fb90b 100644 --- a/emulators/frodo.yml +++ b/emulators/frodo.yml @@ -6,7 +6,11 @@ source: "https://github.com/libretro/frodo-libretro" upstream: "https://github.com/cebix/frodo4" profiled_date: "2026-03-23" source_commit: "891d20102a8894dd4fce73fb8bd2f623483fd05f" -core_version: "v4.2" +core_version: "V4.2" +version_basis: + kind: file + path: "Src/Version.h" + pattern: 'VERSION_STRING\[\]\s*=\s*"Frodo ([^"]+)"' display_name: "Commodore - C64 (Frodo)" systems: [commodore-c64] diff --git a/emulators/frotz.yml b/emulators/frotz.yml index f9b6cdf7e..5fb883341 100644 --- a/emulators/frotz.yml +++ b/emulators/frotz.yml @@ -5,7 +5,11 @@ source: "https://gitlab.com/DavidGriffith/frotz" upstream: "https://gitlab.com/DavidGriffith/frotz" profiled_date: "2026-10-17" source_commit: "042d7bcadc1e2a8090cf737c841d8b7fc14b6eff" -core_version: "2.55pre" +core_version: "2.56pre" +version_basis: + kind: file + path: "Makefile" + pattern: '^VERSION\s*=\s*(\S+)' display_name: "Infocom Z-Machine (SDL Frotz)" cores: - "frotz" diff --git a/emulators/fs-uae.yml b/emulators/fs-uae.yml index e201471bc..a27c83e40 100644 --- a/emulators/fs-uae.yml +++ b/emulators/fs-uae.yml @@ -4,7 +4,11 @@ core_classification: other source: "https://github.com/FrodeSolheim/fs-uae" profiled_date: "2026-10-20" source_commit: "596d803944f6498c305ac48d338f6252cbde7ecb" -core_version: "3.1.64" +core_version: "v3.1.64" +version_basis: + kind: tag + tag: "v3.1.64" + distance: 0 display_name: "Commodore - Amiga (FS-UAE 3.1)" cores: [fs-uae] systems: [commodore-amiga] diff --git a/emulators/fsuae.yml b/emulators/fsuae.yml index e059162ee..fc8a997d6 100644 --- a/emulators/fsuae.yml +++ b/emulators/fsuae.yml @@ -6,7 +6,12 @@ upstream: "https://github.com/FrodeSolheim/fs-uae" profiled_date: "2026-03-26" source_commit: "089bb69207fe8fa5eec0006d1d759fb6426f4027" upstream_commit: "f362278ccd4c60991caac3b4d240d4a3f751bea2" -core_version: "v5.0.7" +core_version: "5.0.7" +version_basis: + kind: file + repo: upstream + path: "configure.ac" + pattern: 'AC_INIT\(\[FS-UAE\],\s+\[([0-9][\w.+-]*)' display_name: "Commodore - Amiga (FS-UAE)" library_name: "" cores: diff --git a/emulators/fury.yml b/emulators/fury.yml index a844f8467..2894b01bc 100644 --- a/emulators/fury.yml +++ b/emulators/fury.yml @@ -9,6 +9,11 @@ version_source: profiled_date: "2026-10-04" source_commit: "ec5824db81817866f70da326d3811bb0f52b3517" core_version: "20260807-10669-ec5824db8" +version_basis: + kind: package + url: "https://dukeworld.com/eduke32/synthesis/latest/" + pattern: 'eduke32_src_(\d{8}-\d+-[0-9a-f]+)\.tar' + artifact: "eduke32_src_20260807-10669-ec5824db8.tar.xz" display_name: "Ion Fury" cores: [fury, ionfury] systems: [fury] diff --git a/emulators/fuse.yml b/emulators/fuse.yml index 2fea6a4ad..ec567ca64 100644 --- a/emulators/fuse.yml +++ b/emulators/fuse.yml @@ -6,6 +6,10 @@ upstream: "https://sourceforge.net/projects/fuse-emulator/" profiled_date: "2026-03-23" source_commit: "e997e2bc32c888348f862f69f2c53babfedf7791" core_version: "1.6.0" +version_basis: + kind: file + path: "fuse/configure.ac" + pattern: 'm4_define\(\[fuse_version\],\s*\[([^\]]+)\]\)' display_name: "Sinclair - ZX Spectrum (Fuse)" cores: - fuse diff --git a/emulators/future-pinball-fploader.yml b/emulators/future-pinball-fploader.yml index 7b036a34b..21b5a035f 100644 --- a/emulators/future-pinball-fploader.yml +++ b/emulators/future-pinball-fploader.yml @@ -6,6 +6,9 @@ upstream: "https://www.ravarcade.pl/" profiled_date: "2026-08-11" source_commit: "e13007994011ae57dd9d9f48674c1f774183914b" core_version: "1.5-408" +version_basis: + kind: unverified + why: no_artifact display_name: "Future Pinball (BAM)" cores: - future-pinball-fploader diff --git a/emulators/future-pinball.yml b/emulators/future-pinball.yml index 4f7bd5e8c..2e069ac97 100644 --- a/emulators/future-pinball.yml +++ b/emulators/future-pinball.yml @@ -8,6 +8,10 @@ version_source: pattern: 'The current version is ([0-9.]+)' profiled_date: "2026-08-11" core_version: "1.9.1.20101231" +version_basis: + kind: package + url: "https://futurepinball.com/home.html" + artifact: "Future Pinball release labelled 1.9.1.20101231 on futurepinball.com, Future Pinball.exe" display_name: "Future Pinball" cores: - fpinball diff --git a/emulators/galaksija.yml b/emulators/galaksija.yml index 18c81420b..746d1f918 100644 --- a/emulators/galaksija.yml +++ b/emulators/galaksija.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/galaxy-libretro" upstream: "https://github.com/nikp123/Galaksija-Emulator" profiled_date: "2026-03-23" source_commit: "185758296c17f59cba832511139381a197986474" -core_version: "v0.1" +core_version: "0.1" +version_basis: + kind: file + path: "src/libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Elektronika Inženjering - Galaksija (Galaksija)" cores: - galaksija diff --git a/emulators/gam4980.yml b/emulators/gam4980.yml index a0ced2aa5..6fd297a35 100644 --- a/emulators/gam4980.yml +++ b/emulators/gam4980.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-23" source_commit: "eeaa531b55e7127ab4b5e0bdc5ceba686df59c6a" upstream_commit: "32c531d3e7159081afd8dee72e8266153821095d" core_version: "0.2" +version_basis: + kind: file + path: "src/libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "GAM4980" cores: - gam4980 diff --git a/emulators/gambatte.yml b/emulators/gambatte.yml index e1ff8f20d..b509babbd 100644 --- a/emulators/gambatte.yml +++ b/emulators/gambatte.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/sinamas/gambatte" profiled_date: "2026-03-23" source_commit: "7809bb68ee96c3e6c74e9d9c4c8bb09aa7c6801d" core_version: "v0.5.0" +version_basis: + kind: file + path: "libgambatte/libretro/libretro.cpp" + pattern: 'library_version\s*=\s*"(v[0-9.]+)"\s*GIT_VERSION' display_name: "Nintendo - Game Boy / Color (Gambatte)" cores: - gambatte diff --git a/emulators/gametank.yml b/emulators/gametank.yml index 7479a7323..7ca371ec0 100644 --- a/emulators/gametank.yml +++ b/emulators/gametank.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/clydeshaffer/GameTankEmulator" profiled_date: "2026-10-20" source_commit: "1a6f2608ae302d613ac17ae8584c7239ae47044b" upstream_commit: "be32deab74b7778647273fa5616d1ea92e3e1267" -core_version: "1.0.1" +core_version: "v1.0.1" +version_basis: + kind: tag + tag: "v1.0.1" + distance: 0 display_name: "Clydeware - GameTank (GameTank)" cores: - gametank diff --git a/emulators/gargoyle.yml b/emulators/gargoyle.yml index a61c723f3..cff2a7db1 100644 --- a/emulators/gargoyle.yml +++ b/emulators/gargoyle.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/garglk/garglk" profiled_date: "2026-08-08" source_commit: "9e06211c20bee35026744424a57f50375cdc29db" core_version: "2026.1" +version_basis: + kind: file + path: "VERSION" + pattern: '^(\S+)' display_name: "Interactive Fiction (Gargoyle)" cores: - gargoyle diff --git a/emulators/gba-emu.yml b/emulators/gba-emu.yml index 223103328..a75fc36df 100644 --- a/emulators/gba-emu.yml +++ b/emulators/gba-emu.yml @@ -7,6 +7,10 @@ profiled_date: "2026-08-11" source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943" upstream_commit: "46cc9f6bda114737e99304a4bd8427c038e63084" core_version: "1.5.85" +version_basis: + kind: file + path: "EmuFramework/metadata/conf.mk" + pattern: '^metadata_version\s*=\s*(\S+)' display_name: "Nintendo - Game Boy Advance (GBA.emu)" mode: standalone cores: diff --git a/emulators/gbc-emu.yml b/emulators/gbc-emu.yml index 55833963b..2be409fcf 100644 --- a/emulators/gbc-emu.yml +++ b/emulators/gbc-emu.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/sinamas/gambatte" profiled_date: "2026-08-11" 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: "Nintendo - Game Boy / Color (GBC.emu)" mode: standalone cores: diff --git a/emulators/gearboy.yml b/emulators/gearboy.yml index cd6a624e9..d1a091cb3 100644 --- a/emulators/gearboy.yml +++ b/emulators/gearboy.yml @@ -5,7 +5,11 @@ source: "https://github.com/drhelius/Gearboy" upstream: "https://github.com/drhelius/Gearboy" profiled_date: "2026-03-23" source_commit: "b0a5fcaf68009c288d6b8016164a21af452d81b5" -core_version: "3.8.19" +core_version: "3.8.19-10-gb0a5fca" +version_basis: + kind: tag + tag: "3.8.19" + distance: 10 display_name: "Nintendo - Game Boy / Color (Gearboy)" cores: - gearboy diff --git a/emulators/gearcoleco.yml b/emulators/gearcoleco.yml index 719b262c2..72dd6fc61 100644 --- a/emulators/gearcoleco.yml +++ b/emulators/gearcoleco.yml @@ -5,7 +5,11 @@ source: "https://github.com/drhelius/Gearcoleco" upstream: "https://github.com/drhelius/Gearcoleco" profiled_date: "2026-09-17" source_commit: "be1a3716d2db312c1add1639753258e365f2dfd4" -core_version: "1.7.4-24-g23d0ec2" +core_version: "1.7.4-35-gbe1a371" +version_basis: + kind: tag + tag: "1.7.4" + distance: 35 display_name: "Coleco - ColecoVision / ADAM (Gearcoleco)" cores: - gearcoleco diff --git a/emulators/geargrafx.yml b/emulators/geargrafx.yml index ff8ca2848..dd5113f5b 100644 --- a/emulators/geargrafx.yml +++ b/emulators/geargrafx.yml @@ -5,7 +5,11 @@ source: "https://github.com/drhelius/Geargrafx" upstream: "https://github.com/drhelius/Geargrafx" profiled_date: "2026-10-04" source_commit: "7c447b0bd46b6085abdf1d685b49480f4a295c17" -core_version: "1.8.5" +core_version: "1.8.5-10-g7c447b0" +version_basis: + kind: tag + tag: "1.8.5" + distance: 10 display_name: "NEC - PC Engine / SuperGrafx / CD (Geargrafx)" cores: - geargrafx diff --git a/emulators/gearlynx.yml b/emulators/gearlynx.yml index bbcf38c33..cff689e09 100644 --- a/emulators/gearlynx.yml +++ b/emulators/gearlynx.yml @@ -4,7 +4,11 @@ core_classification: official_port source: "https://github.com/drhelius/Gearlynx" profiled_date: "2026-10-04" source_commit: "77901f3d3b318a12fdf71dd3ebb64048f0070ada" -core_version: "1.2.36" +core_version: "1.2.36-10-g77901f3" +version_basis: + kind: tag + tag: "1.2.36" + distance: 10 display_name: "Atari - Lynx (GearLynx)" cores: [gearlynx] systems: [atari-lynx] diff --git a/emulators/gearsystem.yml b/emulators/gearsystem.yml index f74e5880c..6539cc8b6 100644 --- a/emulators/gearsystem.yml +++ b/emulators/gearsystem.yml @@ -5,7 +5,11 @@ source: "https://github.com/drhelius/Gearsystem" upstream: "https://github.com/drhelius/Gearsystem" profiled_date: "2026-03-23" source_commit: "215c22f73d06871e12291025105c98470509468d" -core_version: "3.9.23" +core_version: "3.9.23-10-g215c22f" +version_basis: + kind: tag + tag: "3.9.23" + distance: 10 display_name: "Sega - MS/GG/SG-1000 (Gearsystem)" cores: - gearsystem diff --git a/emulators/geartowns.yml b/emulators/geartowns.yml index 21a5f9812..907ec2971 100644 --- a/emulators/geartowns.yml +++ b/emulators/geartowns.yml @@ -5,7 +5,11 @@ source: "https://github.com/drhelius/Geartowns" upstream: "https://github.com/drhelius/Geartowns" profiled_date: "2026-10-27" source_commit: "996d42b098bd572ba66a8246bfe56cee39e5ff6e" -core_version: "1.0.1" +core_version: "1.0.1-5-g996d42b" +version_basis: + kind: tag + tag: "1.0.1" + distance: 5 display_name: "Fujitsu - FM Towns (Geartowns)" cores: - "geartowns" diff --git a/emulators/gemrb.yml b/emulators/gemrb.yml index 535b6b38d..6ebb4e9ab 100644 --- a/emulators/gemrb.yml +++ b/emulators/gemrb.yml @@ -5,7 +5,11 @@ source: "https://github.com/gemrb/gemrb" upstream: "https://github.com/gemrb/gemrb" profiled_date: "2026-10-20" source_commit: "ef32a9a8b837ddf4f3a9508b23710146ada2d5fe" -core_version: "0.9.2" +core_version: "v0.9.2" +version_basis: + kind: tag + tag: "v0.9.2" + distance: 0 display_name: "Infinity Engine (GemRB)" cores: - gemrb diff --git a/emulators/genesis_plus_gx.yml b/emulators/genesis_plus_gx.yml index 3373db7d8..df78f744f 100644 --- a/emulators/genesis_plus_gx.yml +++ b/emulators/genesis_plus_gx.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-23" source_commit: "60bee4c724859710ce05672239e5b601ad01382e" upstream_commit: "c7ecd07f0a08db1cdd6871d788476bfac55be638" core_version: "v1.7.4" +version_basis: + kind: file + path: "libretro/libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Sega - MS/GG/MD/CD (Genesis Plus GX)" cores: - genesis_plus_gx diff --git a/emulators/genesis_plus_gx_expanded.yml b/emulators/genesis_plus_gx_expanded.yml index e5ca0821a..c84648943 100644 --- a/emulators/genesis_plus_gx_expanded.yml +++ b/emulators/genesis_plus_gx_expanded.yml @@ -7,6 +7,10 @@ profiled_date: "2026-10-23" source_commit: "dea3528cc0e3f54098e1fb91e3ae7e0e5d90a590" upstream_commit: "e366ca817f5b750ad06a8e2c5976d79b3758ea0a" core_version: "v1.7.4" +version_basis: + kind: file + path: "libretro/libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Sega - MS/GG/MD/CD (Genesis Plus GX Expanded)" cores: - genesis_plus_gx_expanded diff --git a/emulators/genesis_plus_gx_wide.yml b/emulators/genesis_plus_gx_wide.yml index 30fef45a3..dc34679b8 100644 --- a/emulators/genesis_plus_gx_wide.yml +++ b/emulators/genesis_plus_gx_wide.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-23" source_commit: "b7ad005431f5f0b55e559f6d598d1ed2479bf13b" upstream_commit: "f3cb0a5115a2b29426495ae35c2d45c404e650df" core_version: "v1.7.4" +version_basis: + kind: file + path: "libretro/libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Sega - MS/GG/MD/CD (Genesis Plus GX Wide)" cores: - genesis_plus_gx_wide diff --git a/emulators/geolith.yml b/emulators/geolith.yml index a5ff28bd1..b9c4c515e 100644 --- a/emulators/geolith.yml +++ b/emulators/geolith.yml @@ -6,7 +6,11 @@ upstream: "https://gitlab.com/jgemu/geolith" profiled_date: "2026-08-07" source_commit: "2dc88e3fa72ff404213a65d6eb5bb33baa687f73" upstream_commit: "05ddef568212e7225ceec22f3ecb20b5a9b640e6" -core_version: "0.4.1" +core_version: "0.5.0" +version_basis: + kind: file + path: "libretro/libretro.c" + pattern: 'library_version\s*=\s*"([^"]+)"' display_name: "SNK - Neo Geo AES/MVS (Geolith)" cores: - geolith diff --git a/emulators/ghostship.yml b/emulators/ghostship.yml index 0013f1adf..a2dd06aca 100644 --- a/emulators/ghostship.yml +++ b/emulators/ghostship.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/HarbourMasters/Ghostship" source_commit: "deb091c29caff57dd986398087b058f16b11911e" profiled_date: "2026-10-20" core_version: "3.0.0" +version_basis: + kind: tag + tag: "3.0.0" + distance: 0 display_name: "Super Mario 64 (Ghostship)" cores: [ghostship] systems: [ghostship] diff --git a/emulators/gme.yml b/emulators/gme.yml index c4b1b3c50..911e0b3c4 100644 --- a/emulators/gme.yml +++ b/emulators/gme.yml @@ -5,7 +5,12 @@ source: "https://github.com/libretro/libretro-gme" upstream: "https://github.com/libgme/game-music-emu" profiled_date: "2026-03-23" source_commit: "1562f6207a066e9807243c89648d1cb44e411971" -core_version: "Git" +core_version: "0.6.0" +version_basis: + kind: file + path: "deps/game-music-emu/gme/gme.h" + pattern: '#define\s+GME_VERSION\s+0x0?([0-9])0?([0-9])0?([0-9])\b' + format: "{1}.{2}.{3}" display_name: "Game Music Emu" cores: [gme] systems: [] diff --git a/emulators/gngeopi.yml b/emulators/gngeopi.yml index 55c20e78d..d73481531 100644 --- a/emulators/gngeopi.yml +++ b/emulators/gngeopi.yml @@ -4,7 +4,11 @@ core_classification: community_fork source: "https://github.com/ymartel06/GnGeo-Pi" profiled_date: "2026-10-20" source_commit: "b94571c6097b67cfc9db5eaffb98cdc8bcb55c6e" -core_version: "0.85" +core_version: "0.8" +version_basis: + kind: file + path: "gngeo/configure.in" + pattern: 'AC_INIT\(gngeo,\s*([^)\s]+)\)' display_name: "SNK - Neo Geo (GnGeo-Pi)" mode: standalone cores: diff --git a/emulators/gong.yml b/emulators/gong.yml index 8ff212780..066fe86e7 100644 --- a/emulators/gong.yml +++ b/emulators/gong.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/gong" upstream: "https://danzaidan.itch.io/" profiled_date: "2026-03-23" source_commit: "69ef0b69dc542af4c1cf2efcc451dd6c378281b6" -core_version: "1.0" +core_version: "v1.0" +version_basis: + kind: file + path: "gong.c" + pattern: 'library_version\s*=\s*"([^"]+)"' display_name: "Gong" cores: [gong] systems: [] diff --git a/emulators/gopher2600.yml b/emulators/gopher2600.yml index 742c50099..ba38a7837 100644 --- a/emulators/gopher2600.yml +++ b/emulators/gopher2600.yml @@ -5,7 +5,11 @@ source: "https://github.com/JetSetIlly/Gopher2600" upstream: "https://github.com/JetSetIlly/Gopher2600" profiled_date: "2026-08-08" source_commit: "295b9e1c08203453677ea4184e284c0664f5949a" -core_version: "v0.57.0" +core_version: "v0.58.0" +version_basis: + kind: file + path: "Makefile" + pattern: '^version\s*=\s*(\S+)' display_name: "Atari - 2600 (Gopher2600)" cores: - gopher2600 diff --git a/emulators/gopher64.yml b/emulators/gopher64.yml index 2b0197fee..0440a049d 100644 --- a/emulators/gopher64.yml +++ b/emulators/gopher64.yml @@ -5,7 +5,11 @@ source: "https://github.com/gopher64/gopher64" upstream: "https://github.com/gopher64/gopher64" profiled_date: "2026-10-23" source_commit: "87385e77432cd278ac8588c7ed3d5014fc0d6bab" -core_version: "v1.1.42" +core_version: "1.1.42" +version_basis: + kind: file + path: "Cargo.toml" + pattern: 'version\s+=\s+"([^"]+)' display_name: "Nintendo - Nintendo 64 (gopher64)" cores: - gopher64 diff --git a/emulators/gpsp.yml b/emulators/gpsp.yml index 9ab548942..2de95d67a 100644 --- a/emulators/gpsp.yml +++ b/emulators/gpsp.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/gpsp" upstream: "https://github.com/davidgfnet/gpsp" profiled_date: "2026-03-23" source_commit: "5819380c2ffb0900219d700a382ee68c464ebb99" -core_version: "Git" +core_version: "v1.1.0" +version_basis: + kind: file + path: "gpsp_config.h" + pattern: '#define\s+GPSP_VERSION\s+"([^"]+)"' display_name: "Nintendo - Game Boy Advance (gpSP)" cores: - gpsp diff --git a/emulators/groovymame.yml b/emulators/groovymame.yml index f71fe652b..560d088c6 100644 --- a/emulators/groovymame.yml +++ b/emulators/groovymame.yml @@ -8,7 +8,11 @@ source_commit: "953db38f4aaab1d75faa4ac79e1b45686214f987" upstream_commit: "f34f02505e32c1993c6a782b6814232cbfc74e36" logo: "https://raw.githubusercontent.com/mamedev/mame/master/docs/source/images/MAMElogo.svg" profiled_date: "2026-10-26" -core_version: "0.289" +core_version: "gm0289sr222f" +version_basis: + kind: tag + tag: "gm0289sr222f" + distance: 0 display_name: "Arcade (GroovyMAME)" cores: diff --git a/emulators/gsplus-legacy.yml b/emulators/gsplus-legacy.yml index 8c12640b5..f63659cd7 100644 --- a/emulators/gsplus-legacy.yml +++ b/emulators/gsplus-legacy.yml @@ -8,6 +8,10 @@ profiled_date: "2026-10-20" source_commit: "480572054518112647c8fae5d7ea7046a6d6ecfb" pin_frozen: "Recalbox builds this commit (Recalbox package/gsplus/gsplus.mk:8-9 at tag 10.1.1). Batocera builds the applemu fork and RetroBat its release build, as the notes say." core_version: "0.14" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'project\(gsplus\s+VERSION\s+([0-9][\w.+-]*)' display_name: "Apple - Apple IIGS (GSplus legacy)" systems: - apple-iigs diff --git a/emulators/gsplus.yml b/emulators/gsplus.yml index cf6469ce1..911950623 100644 --- a/emulators/gsplus.yml +++ b/emulators/gsplus.yml @@ -5,7 +5,11 @@ source: "https://github.com/digarok/gsplus" upstream: "https://kegs.sourceforge.net/" profiled_date: "2026-03-29" source_commit: "41e71031debbde98e66ea5c5c67498e9b4726a6d" -core_version: "KEGS 1.38" +core_version: "1.38.0" +version_basis: + kind: file + path: "gsplus/src/CMakeLists.txt" + pattern: 'project\(gsplus\s+VERSION\s+([0-9][0-9.]*)' display_name: "Apple - Apple IIGS (GSplus)" cores: - gsplus diff --git a/emulators/gw.yml b/emulators/gw.yml index 9c4b0e606..4764d1354 100644 --- a/emulators/gw.yml +++ b/emulators/gw.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/gw-libretro" upstream: "http://www.madrigaldesign.it/sim/" profiled_date: "2026-03-25" source_commit: "91d599b951e7bfe7e040347f58667cba20074adc" -core_version: "Git" +core_version: "1.6.3" +version_basis: + kind: file + path: "src/libretro_version.c" + pattern: 'gw_version\s*=\s*"([^"]+)"' display_name: "Handheld Electronic (GW)" cores: - gw diff --git a/emulators/gzdoom.yml b/emulators/gzdoom.yml index 844fe3765..026507747 100644 --- a/emulators/gzdoom.yml +++ b/emulators/gzdoom.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/ZDoom/gzdoom" profiled_date: "2026-10-04" source_commit: "c26ce2e6ca2a0c770f140cb25dde0d30073ca8f7" core_version: "4.15pre" +version_basis: + kind: file + path: "src/version.h" + pattern: '#define\s+VERSIONSTR\s+"([^"]+)' display_name: "GZDoom" cores: ["GZDoom", "gzdoom-sa"] systems: [doom, heretic, hexen, strife, chex] diff --git a/emulators/hakux.yml b/emulators/hakux.yml index 9b56a189e..b8daffc28 100644 --- a/emulators/hakux.yml +++ b/emulators/hakux.yml @@ -7,7 +7,11 @@ author: "rfandango" profiled_date: "2026-08-11" source_commit: "e64e336d27dc8a56b67f441ec36bfddc1379cb6e" upstream_commit: "26fcbe54f17e496bdf530dad9f237e74e2f943fc" -core_version: "0.3.1" +core_version: "v0.3.1" +version_basis: + kind: tag + tag: "v0.3.1" + distance: 0 display_name: "Microsoft - Xbox (hakuX)" cores: - hakux diff --git a/emulators/handy.yml b/emulators/handy.yml index 91b6ba84c..55eafab8c 100644 --- a/emulators/handy.yml +++ b/emulators/handy.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/libretro-handy" upstream: "https://handy.sourceforge.net/" profiled_date: "2026-03-23" source_commit: "bc55d462f0b2d6b073ea93dc552ebd73cec60fd1" -core_version: "0.95" +core_version: "0.97" +version_basis: + kind: file + path: "libretro/handy.h" + pattern: '#define\s+HANDYVER\s+"([^"]+)"' display_name: "Atari - Lynx (Handy)" cores: - handy diff --git a/emulators/hatari.yml b/emulators/hatari.yml index 5c97c17ce..2930e467d 100644 --- a/emulators/hatari.yml +++ b/emulators/hatari.yml @@ -8,6 +8,10 @@ profiled_date: "2026-10-21" source_commit: "50929946d196f431906932420c55e1b3593dacc5" upstream_commit: "23bc3060ebf0da348cc842e358c102ba88002b56" core_version: "2.6.1-devel" +version_basis: + kind: file + path: "src/includes/version.h" + pattern: '^#define\s+HATARI_VERSION\s+"([^" ]+)' display_name: "Atari - ST/STE/TT/Falcon (Hatari)" cores: - hatari diff --git a/emulators/hatari2014.yml b/emulators/hatari2014.yml index 9e94a5f05..f9e2e2ecf 100644 --- a/emulators/hatari2014.yml +++ b/emulators/hatari2014.yml @@ -7,7 +7,12 @@ upstream: "https://github.com/hatari/hatari" profiled_date: "2026-09-03" source_commit: "ab55c3ed0e620c91e7f059a6d3fbef7acf9bfca8" upstream_commit: "c1a5c88bc06e56778b6b7f8483becfd58a7da2b4" -core_version: "1.8.0" +core_version: "v1.8.0" +version_basis: + kind: tag + repo: upstream + tag: "v1.8.0" + distance: 0 display_name: "Atari - ST/STE/TT/Falcon (Hatari 2014)" cores: - hatari2014 diff --git a/emulators/hatarib.yml b/emulators/hatarib.yml index fea3bbe43..502f6fec2 100644 --- a/emulators/hatarib.yml +++ b/emulators/hatarib.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/hatari/hatari" profiled_date: "2026-09-17" source_commit: "5f05733fc21903dce7d978bd0d7aae445ffe1185" upstream_commit: "23bc3060ebf0da348cc842e358c102ba88002b56" -core_version: "1.1 unstable preview" +core_version: "v1.1" +version_basis: + kind: file + path: "core/core.c" + pattern: '#define\s+CORE_VERSION\s+"([^" ]+)' display_name: "Atari - ST/STE/TT/Falcon (hatariB)" cores: - hatarib diff --git a/emulators/hbmame.yml b/emulators/hbmame.yml index 3a3a86b1e..33b99273e 100644 --- a/emulators/hbmame.yml +++ b/emulators/hbmame.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/Robbbert/hbmame" logo: "https://raw.githubusercontent.com/mamedev/mame/master/docs/source/images/MAMElogo.svg" profiled_date: "2026-03-23" source_commit: "840eeef5ee0fd75dab62c90bf25789b898f3dba3" -core_version: "Git" +core_version: "0.289" +version_basis: + kind: file + path: "makefile" + pattern: '^BARE_VERS\s*:=\s*(\S+)' display_name: "Arcade (HBMAME)" cores: - hbmame diff --git a/emulators/hcl.yml b/emulators/hcl.yml index 6c40618cb..d7691ffd5 100644 --- a/emulators/hcl.yml +++ b/emulators/hcl.yml @@ -5,7 +5,11 @@ source: "https://github.com/ptitSeb/hydracastlelabyrinth" upstream: "https://github.com/ptitSeb/hydracastlelabyrinth" profiled_date: "2026-10-04" source_commit: "e31648688434a789e3056e52cdf1b9a842eeed48" -core_version: ~ +core_version: "v0.1.0-4-ge316486" +version_basis: + kind: tag + tag: "v0.1.0" + distance: 4 display_name: "Hydra Castle Labyrinth (hcl)" cores: [hcl] systems: [] diff --git a/emulators/higan_sfc.yml b/emulators/higan_sfc.yml index 49eb06e11..476243f59 100644 --- a/emulators/higan_sfc.yml +++ b/emulators/higan_sfc.yml @@ -7,6 +7,10 @@ upstream: "https://gitlab.com/higan/higan" profiled_date: "2026-09-17" source_commit: "d3f592013a27cb78f17d84f90a6be6cf6f6af1d1" core_version: "106" +version_basis: + kind: file + path: "higan/emulator/emulator.hpp" + pattern: 'static\s+const\s+string\s+Version\s*=\s*"([^"]+)"' display_name: "Nintendo - SNES / Famicom (higan Accuracy)" cores: - higan_sfc diff --git a/emulators/higan_sfc_balanced.yml b/emulators/higan_sfc_balanced.yml index 67526a74b..6c88faec8 100644 --- a/emulators/higan_sfc_balanced.yml +++ b/emulators/higan_sfc_balanced.yml @@ -8,6 +8,10 @@ source_commit: "5b2b5da3a9654d88b2e51fb7a1637808ac601a18" upstream_commit: "3353efd3a1087f4d16d9b9fd2a8e465087ab8ec0" upstream_frozen: "nSide is synced with the higan v106r25 release, this commit. Later higan history is another program." core_version: "106.25" +version_basis: + kind: file + path: "nSide/emulator/emulator.hpp" + pattern: 'static\s+const\s+string\s+Version\s*=\s*"([^"]+)"' display_name: "Nintendo - SNES / SFC (nSide Balanced)" cores: - nside_sfc_balanced diff --git a/emulators/hode.yml b/emulators/hode.yml index 500526c9e..db6e30537 100644 --- a/emulators/hode.yml +++ b/emulators/hode.yml @@ -5,6 +5,10 @@ source: "https://github.com/usineur/hode" profiled_date: "2026-10-22" source_commit: "59f3c466923c6b8d49e29176ff697a91165e7efc" core_version: "0.2.9f" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'nacptool --create "[^"]*" "[^"]*" "([^"]+)"' display_name: "Heart of Darkness (hode)" cores: - "hode" diff --git a/emulators/holani.yml b/emulators/holani.yml index 04ec9d216..b24ff6ed7 100644 --- a/emulators/holani.yml +++ b/emulators/holani.yml @@ -7,6 +7,11 @@ profiled_date: "2026-10-27" source_commit: "d0d79b4928e3d32695294142784c033ce7aeda9b" upstream_commit: "7871f5cc6a26cf458f1adaaf54c4ccbd3ab3e338" core_version: "1.0.0" +version_basis: + kind: tag + repo: upstream + tag: "1.0.0" + distance: 0 display_name: "Atari - Lynx (Holani)" cores: [holani] systems: [atari-lynx] diff --git a/emulators/hurrican.yml b/emulators/hurrican.yml index 43d518712..8dcc13220 100644 --- a/emulators/hurrican.yml +++ b/emulators/hurrican.yml @@ -5,7 +5,11 @@ source: "https://github.com/HurricanGame/Hurrican" upstream: "https://github.com/HurricanGame/Hurrican" profiled_date: "2026-10-04" source_commit: "0d91d1f2d40f38d21e7f41afc01369ee55649fb6" -core_version: ~ +core_version: "v1.0.9.3-398-g0d91d1f" +version_basis: + kind: tag + tag: "v1.0.9.3" + distance: 398 display_name: "Hurrican" cores: [hurrican] systems: [] diff --git a/emulators/hypseus-singe.yml b/emulators/hypseus-singe.yml index c18965f59..261098e32 100644 --- a/emulators/hypseus-singe.yml +++ b/emulators/hypseus-singe.yml @@ -7,6 +7,10 @@ source_commit: "a16e2521ee3233ef20c44e562008c47f4b4293df" upstream_commit: "cd214c6046365047704f8a0563928970b30444e6" profiled_date: "2026-10-27" core_version: "v3.0.2" +version_basis: + kind: tag + tag: "v3.0.2" + distance: 0 display_name: "Arcade (Hypseus Singe)" cores: - hypseus-singe diff --git a/emulators/idtech4a++.yml b/emulators/idtech4a++.yml index 79573fe57..25130e05c 100644 --- a/emulators/idtech4a++.yml +++ b/emulators/idtech4a++.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/glKarin/com.n0n3m4.diii4a" author: "glKarin" profiled_date: "2026-09-17" source_commit: "ce49510f3b0c04969eab8ebe8238416e84e4f0bb" -core_version: "1.1.0harmattan73" +core_version: "v1.1.0harmattan73" +version_basis: + kind: tag + tag: "v1.1.0harmattan73" + distance: 0 display_name: "id Tech engine collection (idTech4A++)" cores: - "idtech4a++" diff --git a/emulators/ikemen.yml b/emulators/ikemen.yml index eda036bfd..a2c128ec9 100644 --- a/emulators/ikemen.yml +++ b/emulators/ikemen.yml @@ -5,7 +5,12 @@ source: "https://github.com/ikemen-engine/Ikemen-GO" upstream: "https://github.com/ikemen-engine/Ikemen-GO" profiled_date: "2026-10-04" source_commit: "e9bfe1125dea88769b265e57bcfaefdb22fa4322" -core_version: ~ +core_version: "v1.0.0-rc.1-545-ge9bfe11" +version_basis: + kind: tag + match: "v*" + tag: "v1.0.0-rc.1" + distance: 545 display_name: "Ikemen GO" cores: [ikemen] systems: [] diff --git a/emulators/imageviewer.yml b/emulators/imageviewer.yml index 9452a4ff6..62628ffc8 100644 --- a/emulators/imageviewer.yml +++ b/emulators/imageviewer.yml @@ -4,7 +4,11 @@ core_classification: pure_libretro source: "https://github.com/libretro/RetroArch/tree/master/cores/libretro-imageviewer" profiled_date: "2026-03-24" source_commit: "5078a39b624e31b359e1527ea7fbfddfc7de96cb" -core_version: "1.0" +core_version: "1.22.2" +version_basis: + kind: file + path: "version.all" + pattern: '#define\s+PACKAGE_VERSION\s+"([^"]+)"' display_name: "Imageviewer" cores: - imageviewer diff --git a/emulators/ines.yml b/emulators/ines.yml index 9066a47a3..fe94bcf5d 100644 --- a/emulators/ines.yml +++ b/emulators/ines.yml @@ -9,6 +9,10 @@ version_source: author: "Marat Fayzullin (Garage Research)" profiled_date: "2026-10-20" core_version: "6.1.12" +version_basis: + kind: package + url: "https://play.google.com/store/apps/details?id=com.fms.ines.free" + artifact: "com.fms.ines.free APK versionName (versionCode 6112)" display_name: "Nintendo - NES / Famicom (iNES)" cores: - ines diff --git a/emulators/infinity.yml b/emulators/infinity.yml index 127b94351..7a756ed86 100644 --- a/emulators/infinity.yml +++ b/emulators/infinity.yml @@ -10,6 +10,10 @@ version_source: author: "GuoPing He" profiled_date: "2026-08-11" core_version: "5" +version_basis: + kind: package + url: "https://play.google.com/store/apps/details?id=me.dt2dev.infinity" + artifact: "me.dt2dev.infinity APK versionName (versionCode 501)" display_name: "PICO-8 (Infinity)" cores: - infinity diff --git a/emulators/ioquake3.yml b/emulators/ioquake3.yml index acf8d129c..58020f253 100644 --- a/emulators/ioquake3.yml +++ b/emulators/ioquake3.yml @@ -5,7 +5,11 @@ source: "https://github.com/ioquake/ioq3" upstream: "https://github.com/ioquake/ioq3" profiled_date: "2026-10-04" source_commit: "87a99d18206e878a20289f05ac3c16005dcae5c3" -core_version: "1.36+git" +core_version: "1.36" +version_basis: + kind: file + path: "cmake/identity.cmake" + pattern: 'set\(PROJECT_VERSION\s+([^)\s]+)\)' display_name: "ioquake3" cores: [ioquake3] systems: [quake3, teamarena] diff --git a/emulators/iortcw.yml b/emulators/iortcw.yml index 1ec447039..7116d0f85 100644 --- a/emulators/iortcw.yml +++ b/emulators/iortcw.yml @@ -5,6 +5,11 @@ source: "https://github.com/iortcw/iortcw" upstream: "https://github.com/iortcw/iortcw" profiled_date: "2026-10-04" source_commit: "438e7d413b5f7277187c35b032eb0ef9093ae778" +core_version: "1.51d" +version_basis: + kind: file + path: "SP/Makefile" + pattern: '^VERSION=([0-9][^-\s]*)-SP' display_name: "ioRTCW" cores: [] systems: [return-to-castle-wolfenstein] diff --git a/emulators/iratajaguar.yml b/emulators/iratajaguar.yml index b40a36c2e..1917a6504 100644 --- a/emulators/iratajaguar.yml +++ b/emulators/iratajaguar.yml @@ -9,6 +9,11 @@ version_source: author: "Maxim Grishin (Altmer)" profiled_date: "2026-08-11" core_version: "1.0.7" +version_basis: + kind: package + url: "http://www.arts-union.ru/node/23" + pattern: 'IrataJaguar_free_(\d+(?:\.\d+)+)\.zip' + artifact: "IrataJaguar_free_1.0.7.zip" display_name: "Atari - Jaguar (IrataJaguar)" cores: - iratajaguar diff --git a/emulators/irogb.yml b/emulators/irogb.yml index 96bc9d655..4db88ce13 100644 --- a/emulators/irogb.yml +++ b/emulators/irogb.yml @@ -5,7 +5,11 @@ source: "https://github.com/AlexSutila/IroGB" upstream: "https://github.com/AlexSutila/IroGB" profiled_date: "2026-09-03" source_commit: "7b6bb9b1f9cbf04e49eea0dc89c54635c2b54056" -core_version: "1.0.0" +core_version: "v1.0.0" +version_basis: + kind: file + path: "src/frontend/libretro/libretro_host.cpp" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Nintendo - Game Boy / Color (IroGB)" cores: - irogb diff --git a/emulators/ishiiruka.yml b/emulators/ishiiruka.yml index 1c4f4d24b..5077d64ef 100644 --- a/emulators/ishiiruka.yml +++ b/emulators/ishiiruka.yml @@ -6,7 +6,9 @@ upstream: "https://github.com/Tinob/Ishiiruka" profiled_date: "2026-03-24" source_commit: "f228bde5fbf4861ba749a229803addcd6238663f" upstream_commit: "cba6d7279b44894297d8c1dde1effdbba3605c32" -core_version: "Git" +core_version: null +version_basis: + kind: none display_name: "Nintendo - GameCube / Wii (Ishiiruka)" library_name: "Ishiiruka" cores: diff --git a/emulators/izapple2.yml b/emulators/izapple2.yml index 58b69eba4..e01c623ea 100644 --- a/emulators/izapple2.yml +++ b/emulators/izapple2.yml @@ -5,7 +5,11 @@ source: "https://github.com/ivanizag/izapple2" upstream: "https://github.com/ivanizag/izapple2" profiled_date: "2026-08-08" source_commit: "4e773baf9f1f3fb692e3f3444eb5f42fddb205ef" -core_version: "v2.5.0" +core_version: "v2.5.0-3-g4e773ba" +version_basis: + kind: tag + tag: "v2.5.0" + distance: 3 display_name: "Apple - Apple II (izapple2)" cores: - izapple2 diff --git a/emulators/j2me-loader.yml b/emulators/j2me-loader.yml index 33f79785d..f1df6771e 100644 --- a/emulators/j2me-loader.yml +++ b/emulators/j2me-loader.yml @@ -7,6 +7,10 @@ author: "Nikita Shakarun" profiled_date: "2026-10-23" source_commit: "76383ebf348b5d477256fc884dfc55f053b45a86" core_version: "1.8.2" +version_basis: + kind: file + path: "app/build.gradle" + pattern: 'versionName\s+"([^"]+)' display_name: "Mobile - J2ME (J2ME Loader)" cores: - j2me-loader diff --git a/emulators/jaxe.yml b/emulators/jaxe.yml index a30826091..2903f0db7 100644 --- a/emulators/jaxe.yml +++ b/emulators/jaxe.yml @@ -5,7 +5,11 @@ source: "https://github.com/kurtjd/jaxe" upstream: "https://github.com/kurtjd/jaxe" profiled_date: "2026-03-24" source_commit: "c767afd785e01a15bcd575a2d93b737add82b675" -core_version: "GIT" +core_version: "1.0" +version_basis: + kind: file + path: "src/libretro.c" + pattern: 'library_version\s*=\s*"([^"]+)"' display_name: "CHIP-8/S-CHIP/XO-CHIP (JAXE)" cores: - jaxe diff --git a/emulators/jazz2-native.yml b/emulators/jazz2-native.yml index df891231e..8e5972a45 100644 --- a/emulators/jazz2-native.yml +++ b/emulators/jazz2-native.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/deathkiller/jazz2-native" profiled_date: "2026-10-04" source_commit: "ff9ddb7be9c84bdcf71b22d9380da06135edeb62" core_version: "3.9.0" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'set\(NCINE_VERSION\s+"([^"]+)"\)' display_name: "Jazz Jackrabbit 2 (Jazz² Resurrection)" cores: [jazz2] systems: [jazz-jackrabbit-2] diff --git a/emulators/jgenesis.yml b/emulators/jgenesis.yml index 579ea3a12..20fe6fdad 100644 --- a/emulators/jgenesis.yml +++ b/emulators/jgenesis.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/jsgroth/jgenesis" profiled_date: "2026-08-08" source_commit: "0a00c47ba7a16bd6e77ef7e0b6c8fab2fc603cba" core_version: "0.14.1" +version_basis: + kind: file + path: "Cargo.toml" + pattern: 'version\s+=\s+"([^"]+)' display_name: "Multi-System (jgenesis)" cores: - jgenesis diff --git a/emulators/jl-mod.yml b/emulators/jl-mod.yml index a8aee96d1..927a98b9a 100644 --- a/emulators/jl-mod.yml +++ b/emulators/jl-mod.yml @@ -8,6 +8,10 @@ profiled_date: "2026-08-11" source_commit: "f723a190c0bdb44b31c3bc0ead6f8665c7ea517d" upstream_commit: "76383ebf348b5d477256fc884dfc55f053b45a86" core_version: "0.87.1" +version_basis: + kind: file + path: "app/build.gradle.kts" + pattern: 'versionName\s+=\s+"([^"]+)' display_name: "Mobile - J2ME (JL-Mod)" cores: - jl-mod diff --git a/emulators/jollycv.yml b/emulators/jollycv.yml index ef37d8e71..9c74ff459 100644 --- a/emulators/jollycv.yml +++ b/emulators/jollycv.yml @@ -7,6 +7,10 @@ profiled_date: "2026-09-17" source_commit: "2790d59c7194d29e83f8603ffd7e9da17f11575e" upstream_commit: "3caacb22143543d5c7de4f3208def645a2ec0ab5" core_version: "2.1.0" +version_basis: + kind: file + path: "version.h" + pattern: '#define\s+JG_VERSION\s+"([^"]+)' display_name: "ColecoVision/CreatiVision/My Vision (JollyCV)" cores: - jollycv diff --git a/emulators/julius.yml b/emulators/julius.yml index 40a235cab..504f3d47b 100644 --- a/emulators/julius.yml +++ b/emulators/julius.yml @@ -5,7 +5,12 @@ source: "https://github.com/bvschaik/julius" upstream: "https://github.com/bvschaik/julius" profiled_date: "2026-10-17" source_commit: "34d1ecd54befb845c0139b371fa8a0438210dac1" -core_version: "v1.8.0" +core_version: "1.8.0" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'set\(PROJECT_VERSION_MAJOR (\d+)\)\s*\nset\(PROJECT_VERSION_MINOR (\d+)\)\s*\nset\(PROJECT_VERSION_PATCH (\d+)\)' + format: "{1}.{2}.{3}" display_name: "Caesar III (Julius)" cores: - "julius" diff --git a/emulators/jumpnbump.yml b/emulators/jumpnbump.yml index 8f428e943..53adc6fb4 100644 --- a/emulators/jumpnbump.yml +++ b/emulators/jumpnbump.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/jumpnbump-libretro" upstream: "https://github.com/fabiangreffrath/jumpnbump" profiled_date: "2026-03-24" source_commit: "2cc840155ce4bf42b2e9e0ef3854b32d534e607e" -core_version: "v0.1" +core_version: "0.1" +version_basis: + kind: file + path: "libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Jump 'n Bump" cores: [jumpnbump] systems: [] diff --git a/emulators/jynx.yml b/emulators/jynx.yml index 05c82f717..3acc894d6 100644 --- a/emulators/jynx.yml +++ b/emulators/jynx.yml @@ -4,7 +4,11 @@ core_classification: other source: "https://github.com/jonathan-markland/Jynx" profiled_date: "2026-10-20" source_commit: "c1247f2c957b78d3ca6f2b814ee346a298f3573c" -core_version: "1.0.8" +core_version: "v1.0.8" +version_basis: + kind: tag + tag: "v1.0.8" + distance: 0 display_name: "Camputers - Lynx (Jynx)" cores: - jynx diff --git a/emulators/jzintv.yml b/emulators/jzintv.yml index b34f27c35..c35eb8ddd 100644 --- a/emulators/jzintv.yml +++ b/emulators/jzintv.yml @@ -8,6 +8,11 @@ version_source: pattern: 'jzintv-(\d{8})-src\.zip' profiled_date: "2026-10-20" core_version: "20200712" +version_basis: + kind: package + url: "http://spatula-city.org/~im14u2c/intv/" + pattern: 'jzintv-(\d{8})-src\.zip' + artifact: "jzintv-20200712-src.zip" display_name: "Mattel - Intellivision (jzIntv)" cores: - jzintv diff --git a/emulators/kega-fusion.yml b/emulators/kega-fusion.yml index 3b17be7eb..cf81fc678 100644 --- a/emulators/kega-fusion.yml +++ b/emulators/kega-fusion.yml @@ -6,6 +6,10 @@ upstream: closed-source author: "Steve Snake" profiled_date: "2026-10-20" core_version: "3.64" +version_basis: + kind: package + url: "https://www.retrobat.ovh/repo/win64/stable/emulators/kega-fusion.7z" + artifact: "kega-fusion.7z, Fusion.exe banner" display_name: "Sega - Master System / Mega Drive / Mega CD / 32X (Kega Fusion)" cores: - kega-fusion diff --git a/emulators/kegs.yml b/emulators/kegs.yml index 9293527db..783eae855 100644 --- a/emulators/kegs.yml +++ b/emulators/kegs.yml @@ -8,6 +8,11 @@ version_source: pattern: 'Download the KEGS v(\d+\.\d+) zip' profiled_date: "2026-08-08" core_version: "1.38" +version_basis: + kind: package + url: "https://kegs.sourceforge.net/kegs.1.38.zip" + pattern: 'kegs\.(\d+\.\d+)\.zip' + artifact: "kegs.1.38.zip" display_name: "Apple - Apple IIGS (KEGS)" cores: - kegs diff --git a/emulators/kemulator.yml b/emulators/kemulator.yml index bf884f844..9b77503d1 100644 --- a/emulators/kemulator.yml +++ b/emulators/kemulator.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/shinovon/KEmulator" author: "shinovon" profiled_date: "2026-08-11" source_commit: "1ab219907cfcac0e6313d1b6ccba13ad74015d08" -core_version: "v2.22" +core_version: "2.22" +version_basis: + kind: file + path: "src/main/emulator/Emulator.java" + pattern: 'public static String version = "([^"]+)"' display_name: "Mobile - J2ME (KEmulator nnmod)" cores: - kemulator diff --git a/emulators/kenji-nx.yml b/emulators/kenji-nx.yml index 9c9c60a5e..9cbf4c8ec 100644 --- a/emulators/kenji-nx.yml +++ b/emulators/kenji-nx.yml @@ -7,6 +7,9 @@ upstream: "https://git.ryujinx.app/projects/Ryubing" profiled_date: "2026-08-11" source_commit: "53d32a761e61dc8e9b5c0744199a890fc8d934d5" core_version: "2.1.0-pr.2" +version_basis: + kind: unverified + why: host_unreachable display_name: "Nintendo - Switch (Kenji-NX)" cores: - kenji-nx diff --git a/emulators/kronos.yml b/emulators/kronos.yml index 4f55dc454..f5cf0a6dc 100644 --- a/emulators/kronos.yml +++ b/emulators/kronos.yml @@ -7,7 +7,11 @@ upstream: "https://github.com/FCare/Kronos" profiled_date: "2026-03-24" source_commit: "3791ffb2221fda61d70aec5cca376418caf05669" upstream_commit: "d0384bc33aad06bfa9cf5cd308c4bd7ae6cfc814" -core_version: "v2.6.0" +core_version: "2.7.0" +version_basis: + kind: file + path: "yabause/src/libretro/Makefile.common" + pattern: '-DVERSION=\\"([^\\"]+)\\"' display_name: "Sega - Saturn/ST-V (Kronos)" cores: - kronos diff --git a/emulators/lexaloffle.yml b/emulators/lexaloffle.yml index ef12e99e6..2ee407462 100644 --- a/emulators/lexaloffle.yml +++ b/emulators/lexaloffle.yml @@ -2,6 +2,10 @@ emulator: Lexaloffle type: standalone upstream: "https://www.lexaloffle.com/" profiled_date: "2026-03-26" +core_version: null +version_basis: + kind: unverified + why: no_artifact display_name: "Lexaloffle - PICO-8 / Voxatron" cores: - lexaloffle diff --git a/emulators/lightspark.yml b/emulators/lightspark.yml index e2b7a07c1..18a5852f1 100644 --- a/emulators/lightspark.yml +++ b/emulators/lightspark.yml @@ -6,6 +6,11 @@ upstream: "https://github.com/lightspark/lightspark" profiled_date: "2026-08-08" source_commit: "6f07fd65cb95b88ff73e905bf78a1a8b1ddf6049" core_version: "0.10.0" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'SET\(MAJOR_VERSION "(\d+)"\)\s*\nSET\(MINOR_VERSION "(\d+)"\)\s*\nSET\(PATCH_VERSION "(\d+)"\)' + format: "{1}.{2}.{3}" display_name: "Adobe Flash (Lightspark)" cores: - lightspark diff --git a/emulators/lime3ds.yml b/emulators/lime3ds.yml index 7cfe2e447..04d5ef794 100644 --- a/emulators/lime3ds.yml +++ b/emulators/lime3ds.yml @@ -5,7 +5,11 @@ source: "https://github.com/Lime3DS/lime3ds-archive" upstream: "https://github.com/Lime3DS/lime3ds-archive" profiled_date: "2026-08-08" source_commit: "c9dbb5bb40d90dfa6a2b085d152ae6ecf915b2f9" -core_version: "2119.1" +core_version: "2119.1-2-gc9dbb5b" +version_basis: + kind: tag + tag: "2119.1" + distance: 2 display_name: "Nintendo - 3DS (Lime3DS)" cores: - lime3ds diff --git a/emulators/linapple.yml b/emulators/linapple.yml index 094537ba2..fb57da131 100644 --- a/emulators/linapple.yml +++ b/emulators/linapple.yml @@ -7,6 +7,10 @@ profiled_date: "2026-10-20" source_commit: "7f4b60898d91bfecd6f4d8683a374f38b0564962" upstream_commit: "9970ef6b668a7cf057ce6d82f100dde6a751f4d3" core_version: "3.0.0" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'project\(linapple\s+VERSION\s+([0-9][\w.+-]*)' display_name: "Apple - Apple II (LinApple)" cores: - linapple diff --git a/emulators/lindbergh-loader.yml b/emulators/lindbergh-loader.yml index 48b55b29e..b2a746158 100644 --- a/emulators/lindbergh-loader.yml +++ b/emulators/lindbergh-loader.yml @@ -5,7 +5,12 @@ source: "https://github.com/lindbergh-loader/lindbergh-loader" upstream: "https://github.com/lindbergh-loader/lindbergh-loader" source_commit: "0af606d845b70339c335785c0eba68b47b78df3c" profiled_date: "2026-08-08" -core_version: "2.1.4" +core_version: "2.0.0" +version_basis: + kind: file + path: "src/lindbergh/version.h" + pattern: '#define MAJOR_VERSION (\d+)\s*\n#define MINOR_VERSION (\d+)\s*\n#define UPDATE_VERSION (\d+)' + format: "{1}.{2}.{3}" display_name: "Sega - Lindbergh (Lindbergh Loader)" cores: - lindbergh-loader diff --git a/emulators/linkboy.yml b/emulators/linkboy.yml index 7f7e2680d..3a7b18261 100644 --- a/emulators/linkboy.yml +++ b/emulators/linkboy.yml @@ -9,6 +9,10 @@ version_source: author: "Pixel Respawn LLC" profiled_date: "2026-10-24" core_version: "4.0.0" +version_basis: + kind: package + url: "https://play.google.com/store/apps/details?id=com.pixelrespawn.linkboy" + artifact: "com.pixelrespawn.linkboy APK versionName (versionCode 95)" display_name: "Nintendo - Game Boy / Color / Advance (Linkboy)" mode: standalone cores: diff --git a/emulators/linuxloader.yml b/emulators/linuxloader.yml index 30b50640a..220dceb93 100644 --- a/emulators/linuxloader.yml +++ b/emulators/linuxloader.yml @@ -5,7 +5,11 @@ source: "https://github.com/lindbergh-loader/linuxloader" upstream: "https://github.com/lindbergh-loader/linuxloader" source_commit: "9aa6e3e45ccfbbc60eb0f975aa9d3d158a13706c" profiled_date: "2026-10-20" -core_version: "3.0.12" +core_version: "v3.0.12" +version_basis: + kind: tag + tag: "v3.0.12" + distance: 0 display_name: "Sega - Lindbergh (Linux Loader)" cores: - linuxloader diff --git a/emulators/love-0.10.2.yml b/emulators/love-0.10.2.yml index e8f6344a9..ca41ff412 100644 --- a/emulators/love-0.10.2.yml +++ b/emulators/love-0.10.2.yml @@ -7,6 +7,10 @@ profiled_date: "2026-10-20" source_commit: "9e139779956ddba89333eece8104bd7b1b4ff2e7" pin_frozen: "RetroPie, the only platform that ships it, builds the 0.10.2 tag at this commit (RetroPie scriptmodules/ports/love-0.10.2.sh:16). Later releases are packaged separately as love." core_version: "0.10.2" +version_basis: + kind: tag + tag: "0.10.2" + distance: 0 display_name: "LOVE 2D game framework (0.10.2)" cores: - love-0.10.2 diff --git a/emulators/love.yml b/emulators/love.yml index 8f195e8c9..3aa22fce8 100644 --- a/emulators/love.yml +++ b/emulators/love.yml @@ -6,6 +6,10 @@ upstream: "https://github.com/love2d/love" profiled_date: "2026-10-20" source_commit: "6eb8d546736d5915a8b5af30b2cf33456dfdcb1a" core_version: "11.5" +version_basis: + kind: tag + tag: "11.5" + distance: 0 display_name: "LOVE 2D game framework" cores: - love diff --git a/emulators/lowresnx.yml b/emulators/lowresnx.yml index 38439c489..6d666d5b6 100644 --- a/emulators/lowresnx.yml +++ b/emulators/lowresnx.yml @@ -5,7 +5,11 @@ source: "https://github.com/timoinutilis/lowres-nx" upstream: "https://github.com/timoinutilis/lowres-nx" profiled_date: "2026-03-25" source_commit: "63d5988f74e1eea2ecd6ea0445a8ce260b0a9822" -core_version: "v1.2" +core_version: "1.2" +version_basis: + kind: file + path: "core/core.h" + pattern: '#define\s+CORE_VERSION\s+"([^"]+)"' display_name: "LowRes NX" cores: - lowresnx diff --git a/emulators/lrps2.yml b/emulators/lrps2.yml index 1740b9a97..8977a465b 100644 --- a/emulators/lrps2.yml +++ b/emulators/lrps2.yml @@ -11,7 +11,11 @@ upstream: "https://github.com/PCSX2/pcsx2" profiled_date: "2026-10-21" source_commit: "8c2b679622d602fe656ceba646b2f1f3a981c717" upstream_commit: "f0523f8acb1d3a765ee73b74cffec63093569199" -core_version: "Git" +core_version: "v2.0.0" +version_basis: + kind: file + path: "libretro/main.cpp" + pattern: 'library_version\s*=\s*"(v[0-9.]+)-"' display_name: "Sony - PlayStation 2 (LRPS2)" cores: [lrps2, pcsx2, "ps2"] systems: [sony-playstation-2] diff --git a/emulators/lutro.yml b/emulators/lutro.yml index d0456d82e..4e7a54482 100644 --- a/emulators/lutro.yml +++ b/emulators/lutro.yml @@ -5,7 +5,11 @@ source: "https://github.com/libretro/libretro-lutro" upstream: "https://github.com/libretro/libretro-lutro" profiled_date: "2026-03-24" source_commit: "6224157a615b18507bc0b117a3398c7a324cd3e5" -core_version: "v1" +core_version: "0.0.1" +version_basis: + kind: file + path: "lutro.h" + pattern: '#define\s+VERSION_STRING\s+"([^"]+)"' display_name: "Lua Engine (Lutro)" cores: - lutro diff --git a/emulators/lynx-emu.yml b/emulators/lynx-emu.yml index eefcc55a3..d37d37c76 100644 --- a/emulators/lynx-emu.yml +++ b/emulators/lynx-emu.yml @@ -6,6 +6,10 @@ upstream: "https://mednafen.github.io/" profiled_date: "2026-08-11" 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: "Atari - Lynx (Lynx.emu)" mode: standalone cores: diff --git a/emulators/lzdoom.yml b/emulators/lzdoom.yml index c29441683..8ca3a0e57 100644 --- a/emulators/lzdoom.yml +++ b/emulators/lzdoom.yml @@ -6,6 +6,10 @@ profiled_date: "2026-10-20" source_commit: "4ce19a12164ebd6802b7fb2f94d700a6e736d0fb" pin_frozen: "The platforms that ship it run the 3.88 line: RetroPie builds tag 3.88b (RetroPie scriptmodules/ports/lzdoom.sh:15) and AmberELEC a fork of the g3.3mgw branch that still reports 3.88b (AmberELEC packages/games/emulators/lzdoom/package.mk:5). The l4.x releases are a GZDoom 4 based line that no platform ships." core_version: "3.88b" +version_basis: + kind: tag + tag: "3.88b" + distance: 0 display_name: "LZDoom" cores: [lzdoom, "LZDoom"] systems: [doom, heretic, hexen, strife, chex] diff --git a/emulators/m2000.yml b/emulators/m2000.yml index 8216cd1a2..c84009985 100644 --- a/emulators/m2000.yml +++ b/emulators/m2000.yml @@ -5,7 +5,11 @@ source: "https://github.com/p2000t/M2000" upstream: "https://github.com/p2000t/M2000" profiled_date: "2026-03-24" source_commit: "da0b8cbef2007c907f5db2166847c3209643865d" -core_version: "0.9.4" +core_version: "0.9.5" +version_basis: + kind: file + path: "src/P2000.h" + pattern: '#define\s+EMULATOR_VERSION\s+"([^"]+)"' display_name: "Philips - P2000T (M2000)" cores: - m2000 diff --git a/emulators/m64plus-fz.yml b/emulators/m64plus-fz.yml index b6215173e..a58a793fb 100644 --- a/emulators/m64plus-fz.yml +++ b/emulators/m64plus-fz.yml @@ -7,6 +7,10 @@ source_branch: fz-master profiled_date: "2026-08-11" source_commit: "f3c381bcc58e4adce897aba323193d3f407e30e3" core_version: "3.0.335 (beta)" +version_basis: + kind: file + path: "build_common/version_common.gradle" + pattern: 'versionName\s*=\s*"([^"]+)"' display_name: "Nintendo - Nintendo 64 (M64Plus FZ)" cores: - m64plus-fz diff --git a/emulators/magicengine.yml b/emulators/magicengine.yml index 4df3ff8ab..20c2d0a5e 100644 --- a/emulators/magicengine.yml +++ b/emulators/magicengine.yml @@ -6,6 +6,10 @@ upstream: closed-source author: "David Michel, Cedric Michel" profiled_date: "2026-10-20" core_version: "1.1.3" +version_basis: + kind: package + url: "https://www.retrobat.ovh/repo/win64/stable/emulators/magicengine.7z" + artifact: "magicengine.7z, readme.txt" display_name: "NEC - PC Engine / CD-ROM / SuperGrafx (MagicEngine)" cores: - magicengine diff --git a/emulators/mame.yml b/emulators/mame.yml index 8b305eb2f..c35f61500 100644 --- a/emulators/mame.yml +++ b/emulators/mame.yml @@ -8,6 +8,10 @@ profiled_date: "2026-03-29" source_commit: "5f5a07748ce5c239c4118463ce8e6473743d4859" upstream_commit: "f34f02505e32c1993c6a782b6814232cbfc74e36" core_version: "0.289" +version_basis: + kind: file + path: "makefile" + pattern: '#define BARE_BUILD_VERSION "([^"]+)"' display_name: "Arcade (MAME)" cores: diff --git a/emulators/mame0258.yml b/emulators/mame0258.yml index 552715571..9420c9fd8 100644 --- a/emulators/mame0258.yml +++ b/emulators/mame0258.yml @@ -7,7 +7,11 @@ profiled_date: '2026-10-22' source_commit: dc1a96537089d6d4ecd01d9886b21061471c70c0 upstream_commit: 2e0aa823508663c4a27164be43467d1ef740cdcd pin_frozen: 'Recalbox, the only platform that ships it, builds this revision (Recalbox package/libretro-mame0258/libretro-mame0258.mk:8 at tag 10.1.1). Later commits are not part of that build.' -core_version: '0.258' +core_version: "0.258" +version_basis: + kind: file + path: "makefile" + pattern: '#define BARE_BUILD_VERSION "([^"]+)"' display_name: Arcade (MAME 0.258) cores: - mame0258 diff --git a/emulators/mame0278.yml b/emulators/mame0278.yml index ba1390e6b..ce20e9276 100644 --- a/emulators/mame0278.yml +++ b/emulators/mame0278.yml @@ -7,7 +7,11 @@ profiled_date: '2026-10-22' source_commit: fba50741006fa3e27e1897f589d2048a505b8fd6 upstream_commit: eb05584a2b8d98d5af5587244c9cb7dcccf660fd pin_frozen: 'Recalbox, the only platform that ships it, builds this revision (Recalbox package/libretro-mame0278/libretro-mame0278.mk:8 at tag 10.1.1). Later commits are not part of that build.' -core_version: '0.278' +core_version: "0.278" +version_basis: + kind: file + path: "makefile" + pattern: '#define BARE_BUILD_VERSION "([^"]+)"' display_name: Arcade (MAME 0.278) cores: - mame0278 diff --git a/emulators/mame2000.yml b/emulators/mame2000.yml index a29ae3bc7..a9e514da5 100644 --- a/emulators/mame2000.yml +++ b/emulators/mame2000.yml @@ -6,6 +6,10 @@ upstream: "https://www.mamedev.org" profiled_date: "2026-03-24" source_commit: "181b42e5da0d2f0c7a2e59244f23633ed594de2e" core_version: "0.37b5" +version_basis: + kind: file + path: "src/libretro/libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Arcade (MAME 2000)" cores: diff --git a/emulators/mame2003.yml b/emulators/mame2003.yml index a425c113c..5078311bd 100644 --- a/emulators/mame2003.yml +++ b/emulators/mame2003.yml @@ -6,6 +6,10 @@ upstream: "https://www.mamedev.org" profiled_date: "2026-03-24" source_commit: "b9ac708f922386a0ae1f2379dbb31b60d7ec7b25" core_version: "0.78" +version_basis: + kind: file + path: "src/mame2003/mame2003.c" + pattern: 'library_version\s*=\s*"([^"]+)"' display_name: "Arcade (MAME 2003)" cores: diff --git a/emulators/mame2003_midway.yml b/emulators/mame2003_midway.yml index 414255694..d0580e995 100644 --- a/emulators/mame2003_midway.yml +++ b/emulators/mame2003_midway.yml @@ -6,6 +6,10 @@ upstream: "https://www.mamedev.org" profiled_date: "2026-03-24" source_commit: "fb5fb047d0efd9fdc9d56e50fa377f5e47569d35" core_version: "0.78" +version_basis: + kind: file + path: "src/libretro/libretro.c" + pattern: 'info->library_version\s+=\s+"([^"]+)' display_name: "Arcade (MAME 2003 Midway)" cores: diff --git a/emulators/mame2003_plus.yml b/emulators/mame2003_plus.yml index 331c30bf2..e8ca9c588 100644 --- a/emulators/mame2003_plus.yml +++ b/emulators/mame2003_plus.yml @@ -6,7 +6,9 @@ upstream: "https://www.mamedev.org" logo: "https://raw.githubusercontent.com/mamedev/mame/master/docs/source/images/MAMElogo.svg" profiled_date: "2026-03-24" source_commit: "993403d0332946b8aa6abab56fbd9864498790a1" -core_version: "2003-Plus" +core_version: null +version_basis: + kind: none display_name: "Arcade (MAME 2003-Plus)" mame_version: "0.78 (plus backports)" diff --git a/emulators/mame2009.yml b/emulators/mame2009.yml index 1f034cc6b..e6d3dfe33 100644 --- a/emulators/mame2009.yml +++ b/emulators/mame2009.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-24" source_commit: "45048806fbc0fea82709a75a523f2eda945e0a47" upstream_commit: "c285eb9bcd3505c14a338e29f251ba00776cc75c" core_version: "0.135u4" +version_basis: + kind: file + path: "src/version.c" + pattern: 'build_version\[\]\s*=\s*"([^" ]+)' display_name: "Arcade (MAME 2009)" cores: diff --git a/emulators/mame2010.yml b/emulators/mame2010.yml index b4b3de5c4..660d7e629 100644 --- a/emulators/mame2010.yml +++ b/emulators/mame2010.yml @@ -8,6 +8,10 @@ profiled_date: "2026-03-22" source_commit: "dff8aadd1c3f38215af3955746d6e19abe0ddcea" upstream_commit: "f4f24ba7274010fb8ccb84548b5ca95c2be2f85b" core_version: "0.139" +version_basis: + kind: file + path: "src/osd/retro/retroos.c" + pattern: 'const char build_version\[\]\s*=\s*"([^" ]+)' display_name: "Arcade (MAME 2010)" mame_version: "0.139" diff --git a/emulators/mame2014.yml b/emulators/mame2014.yml index 8c894187c..a0d023ada 100644 --- a/emulators/mame2014.yml +++ b/emulators/mame2014.yml @@ -8,9 +8,13 @@ profiled_date: "2026-10-20" source_commit: "c8fe6c7399f24466808b67933b2b96feea188e0b" upstream_commit: "0147bb4cc82e642cf75151f039f240eb20989fbc" pin_frozen: "RetroBat, the only platform that ships it, distributes a build of this revision: the mame2014_libretro.dll of its win64 stable core repository reports library_version \"0.160 c8fe6c7\". Later commits are not part of that build." -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: "Arcade (MAME 2014)" -mame_version: "0.160" +mame_version: "0.159" cores: - mame2014 @@ -89,7 +93,7 @@ notes: | reports library_version "0.160 c8fe6c7" and library_name "MAME 2014", so this profile reads the tree at c8fe6c7. - Same MAME 0.160 code as mame2015: between c8fe6c7 and the mame2015 pin no + Same MAME 0.159 code as mame2015 (version.c): between c8fe6c7 and the mame2015 pin no driver ROM definition, romload.c or hiscore.c changes. What differs is the directory name. libretro_shared.h sets core to "mame2014", and Set_Path_Option builds samples, artwork, cheat, ini and hash under diff --git a/emulators/mame2015.yml b/emulators/mame2015.yml index 390206d4f..8a8216a08 100644 --- a/emulators/mame2015.yml +++ b/emulators/mame2015.yml @@ -7,9 +7,13 @@ logo: "https://raw.githubusercontent.com/mamedev/mame/master/docs/source/images/ profiled_date: "2026-03-24" source_commit: "7adbf440d5f554097f58f8c6ebb673adc4320a31" upstream_commit: "0147bb4cc82e642cf75151f039f240eb20989fbc" -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: "Arcade (MAME 2015)" -mame_version: "0.160" +mame_version: "0.159" cores: - mame2015 @@ -80,14 +84,15 @@ systems: - barcrest-mpu4 notes: | - Frozen snapshot of MAME 0.160 (mid-2015). 69 BIOS root sets. Upstream + Frozen snapshot of MAME 0.159 (version.c says 0.159, the core reports + 0.160). 69 BIOS root sets. Upstream comparison (tag mame0160): BIOS root sets identical, romload.c unmodified. hiscore.c added (not in upstream), per-game XML cheat support via cheat_path(). Paths under system_dir/mame2015/ (samples, artwork, cheat, hash). hiscore.dat loaded via f.open("hiscore", ".dat") without explicit search path (libretro-specific path code disabled via #if 0). - ROM naming follows MAME 0.160 conventions. + ROM naming follows the BIOS root sets of tag mame0160, identical here. files: # Data files diff --git a/emulators/mame2016.yml b/emulators/mame2016.yml index 4dba2460b..28e9a8fcf 100644 --- a/emulators/mame2016.yml +++ b/emulators/mame2016.yml @@ -8,6 +8,10 @@ profiled_date: "2026-03-22" source_commit: "ae07c2f88ff2482ba9f50ffc8c9e7e6fbfe97d0a" upstream_commit: "68785dccfee3208163c8169abc5c6d01becf36d4" core_version: "0.174" +version_basis: + kind: file + path: "src/version.cpp" + pattern: '#define\s+BARE_BUILD_VERSION\s+"([^"]+)' cores: - mame2016 diff --git a/emulators/mame4all.yml b/emulators/mame4all.yml index 77c8d1716..4856d7b22 100644 --- a/emulators/mame4all.yml +++ b/emulators/mame4all.yml @@ -5,7 +5,11 @@ source: "https://github.com/RetroPie/mame4all-pi" upstream: "https://sourceforge.net/projects/mame4allpi/" profiled_date: "2026-10-20" source_commit: "c6bfd2aef331b5a5e157c15d8bf8a77393c85689" -core_version: "0.37b5" +core_version: "0.37 BETA 5" +version_basis: + kind: file + path: "src/version.cpp" + pattern: 'build_version\[\]\s*=\s*"(.+?) \("' display_name: "Arcade (MAME4ALL-Pi)" mode: standalone cores: diff --git a/emulators/mame4droid-2024.yml b/emulators/mame4droid-2024.yml index 539d07c48..8400e2993 100644 --- a/emulators/mame4droid-2024.yml +++ b/emulators/mame4droid-2024.yml @@ -7,7 +7,11 @@ logo: "https://raw.githubusercontent.com/mamedev/mame/master/docs/source/images/ profiled_date: "2026-08-11" source_commit: "32d781923fc0fb7621f3d78903f1b37fd4a5694a" upstream_commit: "f34f02505e32c1993c6a782b6814232cbfc74e36" -core_version: "1.38.1" +core_version: "v1.38.1" +version_basis: + kind: tag + tag: "v1.38.1" + distance: 0 display_name: "Arcade (MAME4droid 2024)" mame_version: "0.289" diff --git a/emulators/mame4droid-current.yml b/emulators/mame4droid-current.yml index 1a3d5b225..d08f09fe2 100644 --- a/emulators/mame4droid-current.yml +++ b/emulators/mame4droid-current.yml @@ -7,7 +7,11 @@ upstream: "https://github.com/mamedev/mame/tree/mame0289" profiled_date: "2026-08-11" source_commit: "32d781923fc0fb7621f3d78903f1b37fd4a5694a" upstream_commit: "f34f02505e32c1993c6a782b6814232cbfc74e36" -core_version: "1.38.1" +core_version: "v1.38.1" +version_basis: + kind: tag + tag: "v1.38.1" + distance: 0 display_name: "Arcade (MAME4droid Current)" cores: diff --git a/emulators/mame4droid.yml b/emulators/mame4droid.yml index 06e84c83a..d2dec9ee7 100644 --- a/emulators/mame4droid.yml +++ b/emulators/mame4droid.yml @@ -8,6 +8,10 @@ profiled_date: "2026-08-11" source_commit: "9e4f9a36a65dd355ca9e28b1df3ef22a2c81d3a1" upstream_commit: "f4f24ba7274010fb8ccb84548b5ca95c2be2f85b" core_version: "0.139u1" +version_basis: + kind: file + path: "src/version.c" + pattern: 'build_version\[\]\s*=\s*"([^" ]+)' display_name: "Arcade (MAME4droid)" mame_version: "0.139u1" diff --git a/emulators/mamearcade.yml b/emulators/mamearcade.yml index 006f19b3b..1eea839c2 100644 --- a/emulators/mamearcade.yml +++ b/emulators/mamearcade.yml @@ -7,6 +7,10 @@ profiled_date: "2026-03-24" source_commit: "5f5a07748ce5c239c4118463ce8e6473743d4859" upstream_commit: "f34f02505e32c1993c6a782b6814232cbfc74e36" core_version: "0.289" +version_basis: + kind: file + path: "makefile" + pattern: '#define BARE_BUILD_VERSION "([^"]+)"' display_name: "Arcade (MAME/Arcade)" cores: diff --git a/emulators/mamemess.yml b/emulators/mamemess.yml index 46e3090d2..7bfd1f9d4 100644 --- a/emulators/mamemess.yml +++ b/emulators/mamemess.yml @@ -6,7 +6,11 @@ upstream: "https://github.com/mamedev/mame" profiled_date: "2026-03-29" source_commit: "5f5a07748ce5c239c4118463ce8e6473743d4859" upstream_commit: "f34f02505e32c1993c6a782b6814232cbfc74e36" -core_version: "0.287" +core_version: "0.289" +version_basis: + kind: file + path: "makefile" + pattern: '#define BARE_BUILD_VERSION "([^"]+)"' display_name: "Multi (MAME/MESS)" cores: diff --git a/emulators/mandarine.yml b/emulators/mandarine.yml index 3a68a9a78..cabf6b861 100644 --- a/emulators/mandarine.yml +++ b/emulators/mandarine.yml @@ -5,7 +5,11 @@ source: "https://github.com/ptyfyre/mandarine-neo" upstream: "https://github.com/ptyfyre/mandarine-neo" profiled_date: "2026-08-08" source_commit: "7f8ec960b6183be4350bca099d23ddd9fc7bcaa3" -core_version: "1.3.0" +core_version: "r1.3.0-6-g7f8ec96" +version_basis: + kind: tag + tag: "r1.3.0" + distance: 6 display_name: "Nintendo - 3DS (Mandarine)" cores: - mandarine diff --git a/emulators/mariani.yml b/emulators/mariani.yml index 747b12370..b574804b9 100644 --- a/emulators/mariani.yml +++ b/emulators/mariani.yml @@ -7,6 +7,10 @@ profiled_date: "2026-08-12" source_commit: "51893cab7e8a3d1298734848f4fd616f2e1831fb" upstream_commit: "3e8054b4627624398e4589f7f27b3d40a6b9718e" core_version: "1.7" +version_basis: + kind: file + path: "Mariani.xcodeproj/project.pbxproj" + pattern: "MARKETING_VERSION = ([^;]+);" display_name: "Apple - Apple II (Mariani)" cores: - mariani diff --git a/emulators/mastergear.yml b/emulators/mastergear.yml index 99519ecff..824418703 100644 --- a/emulators/mastergear.yml +++ b/emulators/mastergear.yml @@ -10,6 +10,10 @@ version_source: author: "Marat Fayzullin (Garage Research)" profiled_date: "2026-08-12" core_version: "4.9" +version_basis: + kind: package + url: "https://play.google.com/store/apps/details?id=com.fms.mg" + artifact: "com.fms.mg APK versionName (versionCode 4900)" display_name: "Sega - Master System / Game Gear (MasterGear)" cores: - mastergear diff --git a/emulators/mcsoftserve.yml b/emulators/mcsoftserve.yml index 2be6b2ed2..101bd798a 100644 --- a/emulators/mcsoftserve.yml +++ b/emulators/mcsoftserve.yml @@ -5,6 +5,10 @@ source: "https://github.com/JesseTG/McSoftServe" profiled_date: "2026-03-24" source_commit: "fbdd09c7dfc11e35d900e51086f63e7f853e6c01" core_version: "1.0.0" +version_basis: + kind: file + path: "CMakeLists.txt" + pattern: 'project\("McSoftServe"\s*\n\s*VERSION "([^"]+)"' display_name: "McSoftServe" cores: - mcsoftserve diff --git a/emulators/md-emu.yml b/emulators/md-emu.yml index bfc4fb35b..abbc45760 100644 --- a/emulators/md-emu.yml +++ b/emulators/md-emu.yml @@ -7,6 +7,10 @@ profiled_date: "2026-08-12" source_commit: "1c12fac5ce49badaadff2e2f210dcc30b89f4943" upstream_commit: "f3cb0a5115a2b29426495ae35c2d45c404e650df" core_version: "1.5.85" +version_basis: + kind: file + path: "EmuFramework/metadata/conf.mk" + pattern: '^metadata_version\s*=\s*(\S+)' display_name: "Sega - MS/MD/CD (MD.emu)" mode: standalone cores: diff --git a/emulators/mednafen.yml b/emulators/mednafen.yml index 2053f6b6f..5cdc34595 100644 --- a/emulators/mednafen.yml +++ b/emulators/mednafen.yml @@ -8,6 +8,11 @@ version_source: pattern: '<li><b>Mednafen (\d+(?:\.\d+)+)</b>' 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+)+)<br>' 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*<strong>([0-9.]+)</strong>, 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"(?<!of )\b[1-9][\d,]* (?:files? )?(?:installed|in place|already (?:up to date|in place)" + r"|up to date|ok)\b|\bDone\b|\bEverything is in place\b|\bAll files up to date\b" + r"|^ *ok\b", "success"), + (r"(?<!of )\b[1-9][\d,]* (?:files? )?(?:failed|refused)\b|\bFAILED\b|\bfailed:" + r"|\bFinished with errors\b|\bNothing was installed\b|\bSomething went wrong\b", + "failure"), + (r"(?<!of )\b[1-9][\d,]* (?:files? )?(?:missing|wrong cop(?:y|ies)|not fetched|left out" + r"|not downloaded)\b|\bFinished with files missing\b|\bNothing installed\b" + r"|\bStopped\b|\bLeft out\b|\bNot enough space\b", "warning"), +)) + + +def _apart(spans: list[tuple[int, int, str]]) -> 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: + # <RetroBat>/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<ESC>]0;PWN<BEL>.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/<platform>.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}<system>" 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 <system> and <core> it ends with, and those said in words. + "go to: /sdcard/RetroArch/saves/<core>" read as a folder named <core>.""" + parts = re.split(r"[\\/]", where) + by_core = bool(parts) and parts[-1] == "<core>" + if by_core: + parts = parts[:-1] + by_system = bool(parts) and parts[-1] == "<system>" + 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/<platform>.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'<span class="rb-badge rb-badge-{tone}">{html.escape(word)}</span>' + + +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'<button class="rb-copy" data-copy="{first}" ' + f'aria-label="Copy {algorithm.upper()} {first[:SHORT_HASH]}{more}">' + f'{first[:SHORT_HASH]}{more}</button>') + + +# 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<label>(?:`[^`]*`|[^\]`])+)\]\((?P<url>[^)\s]+)\)" + r"|`(?P<code>[^`]+)`" + r"|\*\*(?P<bold>[^*]+)\*\*" +) + + +def _text_html(text: str) -> str: + """Plain text as HTML that neither Markdown nor MDX rewrites.""" + return html.escape(html.unescape(text), quote=False).translate(_INERT) + + +def _inline_html(markdown: str) -> str: + """The inline Markdown of a row's evidence (links, code, bold) as HTML. + + Code spans become plain text and a linked path loses its code element: + one per reference and per reader was the bulk of the elements of a + platform page, and the words say the same without them. + """ + out: list[str] = [] + position = 0 + for match in _INLINE_MD.finditer(markdown): + out.append(_text_html(markdown[position:match.start()])) + if match["url"]: + out.append(f'<a href="{html.escape(match["url"])}">' + f'{_text_html(match["label"].replace("`", ""))}</a>') + elif match["code"]: + out.append(_text_html(match["code"])) + else: + out.append(f"<strong>{_text_html(match['bold'])}</strong>") + position = match.end() + out.append(_text_html(markdown[position:])) + return "".join(out) + + +def _row_path(fname: str, cfg_entry: dict) -> str: + """Where the file goes, when its name alone does not say: three GameCube + IPL.bin differ only by GC/USA, GC/EUR and GC/JAP. A destination that is + the name says nothing, and one that ends with it shows its folder.""" + destination = str(cfg_entry.get("destination") or "") + if destination.endswith("/" + fname): + return destination[:len(destination) - len(fname)] + return "" if destination == fname else destination + + +def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "", + launched: list[str] | None = None) -> str: + """One file of a platform's list as one row, written as HTML. + + The row is a details element whose summary holds the name, the folder + when there is one and the status, followed by the short hash on its copy + button: a button cannot sit in a summary, which is itself a control. + The details hold every hash the platform declares, the size, the copy + that serves it and its readers, across the width of the list. + """ + fname = f["name"] + hashes = { + "sha1": cfg_entry.get("sha1", f.get("sha1", "")), + "md5": cfg_entry.get("md5", f.get("expected_md5", "")), + # The crc32 is what System.dat and clrmamepro print. RetroDECK + # declares sha256 too (dsifirmware.bin). + "crc32": str(cfg_entry.get("crc32") or ""), + "sha256": str(cfg_entry.get("sha256") or ""), + } + size = cfg_entry.get("size", f.get("size", 0)) + shown = next((alg for alg in (hash_type, *_ROW_HASHES) if alg in hashes and hashes[alg]), "") + + facts: list[str] = [] + # One code element per value: Recalbox lists accepted md5s joined by + # commas, and each is a value a reader compares a dump with. + facts.extend(f"{alg.upper()}: {_hash_spans(hashes[alg])}" + for alg in ("sha1", "md5", "crc32", "sha256") if hashes[alg]) + # The size closes the hash lines: a line of its own was one more + # element per row. + if size and facts: + facts[-1] += f" ({_fmt_size(size)})" + elif size: + facts.append(f"Size: {_fmt_size(size)}") + if f.get("status") == "untested" and f.get("reason"): + facts.append(_text_html(f"Not confirmed: {f['reason']}")) + # Not every entry is firmware: the platform may list a game's data or a + # file no core it runs opens. + kind = {"game_data": "Game data, not firmware", + "unread": "No core the platform runs opens it"}.get(f.get("category", "")) + if kind: + facts.append(kind) + served = _served_line(f) + if served: + facts.append(_inline_html(served)) + if f.get("category_evidence"): + facts.append(_inline_html(_category_evidence_md(f["category_evidence"], launched))) + + path = _row_path(fname, cfg_entry) + summary = (_text_html(fname) + (f"<code>{_text_html(path)}</code>" if path else "") + + _status_chip(f)) + copy = _copy_hash(shown, hashes[shown]) if shown else "" + return (f'<details class="rb-file"><summary>{summary}</summary>' + f'{"<br>".join(facts)}</details>{copy}') + + +def _platform_file_list(rows: list[str], has_path: bool, anchor: str) -> list[str]: + """A system's rows as one HTML block, with a filter box when long. + + The list drops its path column when no row has one, so a blank cell + never reads as missing data. + """ + lines: list[str] = [] + if len(rows) >= FILTER_ROWS: + field = f"rb-files-filter-{anchor}" + lines += [ + '<div class="rb-table-filter">' + f'<label for="{field}">Filter files</label>' + f'<input id="{field}" type="search" autocomplete="off" placeholder="File, path, status, hash...">' + f'<span class="rb-table-filter-count" aria-live="polite">{len(rows):,} rows</span></div>', + "", + ] + kind = "rb-files rb-files-path" if has_path else "rb-files" + return [*lines, f'<div class="{kind}">', *rows, "</div>"] + + +# The elements a line puts on the page: its HTML tags, and the links and +# code spans of the Markdown before a list. +_ELEMENT = re.compile(r"<[a-zA-Z]|\]\(|`[^`]+`") + + +def _element_weight(lines: list[str]) -> int: + """How many elements the lines render to.""" + return sum(len(_ELEMENT.findall(line)) for line in lines) + + +def _page_share(lines: list[str]) -> float: + """The share of a page the lines render to, in thousandths: the rows + are HTML already, so their length is their weight in bytes.""" + return max(_element_weight(lines) * 1000 / PAGE_ELEMENTS, + _characters(lines) * 1000 / PAGE_BYTES) + + def _render_platform_systems( by_system: dict, config_files: dict, emulator_files: dict, platform: tuple[str, str] | None = None, moved: dict[str, str] | None = None, @@ -3450,102 +3633,42 @@ def _render_platform_systems( sys_emus = _system_emulators(sys_id, emulator_files, by_system_declared.get(sys_id)) anchor = sys_id.replace(" ", "-") - body = [f"{ok_count}/{total} files verified"] + head = [f"{ok_count}/{total} files verified"] if sys_emus: emu_links = ", ".join(_emulator_named_link(e, "../") for e in sorted(sys_emus)) - body[-1] += "\\" - body.append(f"Emulators: {emu_links}") - body.append("") + head[-1] += "\\" + head.append(f"Emulators: {emu_links}") + head.append("") - # File listing. The sort is stable, so same-named entries keep the - # order verify reported them in, which is the declaration order. + # One row per file, the evidence in its details. The sort is stable, + # so same-named entries keep the order verify reported them in, + # which is the declaration order. + rows: list[str] = [] + has_path = False seen_names: dict[str, int] = {} for f in sorted(files, key=lambda x: x["name"]): - status = f["status"] fname = f["name"] declared = config_files.get((sys_id, fname), []) nth = seen_names.get(fname, 0) seen_names[fname] = nth + 1 cfg_entry = declared[nth] if nth < len(declared) else {} - sha1 = cfg_entry.get("sha1", f.get("sha1", "")) - md5 = cfg_entry.get("md5", f.get("expected_md5", "")) - size = cfg_entry.get("size", f.get("size", 0)) + has_path = has_path or bool(_row_path(fname, cfg_entry)) + rows.append(_platform_file_row( + f, cfg_entry, str((config or {}).get("hash_type") or ""), + launched.get(fname.rsplit("/", 1)[-1].casefold()))) + body = head + _platform_file_list(rows, has_path, anchor) - if status == "ok": - status_display = "OK" - elif status == "untested": - reason = f.get("reason", "") - status_display = f"untested: {reason}" if reason else "untested" - elif status == "missing": - status_display = "**missing**" - else: - status_display = status - # Not every entry is firmware: the platform may list a game's data - # or a file no core it runs opens. - kind = {"game_data": "game data", - "unread": "no core the platform runs opens it"}.get(f.get("category", "")) - if kind: - status_display += f", {kind}" - - size_str = _fmt_size(size) if size else "" - details = [status_display] - if size_str: - details.append(size_str) - - # Where the file goes, when its name alone does not say: three - # GameCube IPL.bin differ only by GC/USA, GC/EUR and GC/JAP. - destination = str(cfg_entry.get("destination") or "") - where = f" at `{destination}`" if destination and destination != fname else "" - body.append(f"- `{fname}`{where} - {', '.join(details)}") - item_line = len(body) - 1 - facts: list[str] = [] - # Every hash the platform declares, whole: the crc32 is what - # System.dat and clrmamepro print, and pasting it found nothing - # for a pinned file the collection holds under other bytes. - crc32 = str(cfg_entry.get("crc32") or "") - # RetroDECK declares sha256 too: dsifirmware.bin's was in the CSV - # exports only, and pasting it into the search found nothing. - sha256 = str(cfg_entry.get("sha256") or "") - if sha1 or md5 or crc32 or sha256: - hash_parts = [] - # One code span per value: Recalbox lists accepted md5s joined - # by commas, and the search indexed the joined run as a single - # word, so the second and later values were never found. - if sha1: - hash_parts.append(f"SHA1: {_hash_spans(sha1)}") - if md5: - hash_parts.append(f"MD5: {_hash_spans(md5)}") - if crc32: - hash_parts.append(f"CRC32: `{crc32}`") - if sha256: - hash_parts.append(f"SHA256: `{sha256}`") - facts.append(" | ".join(hash_parts)) - served = _served_line(f) - if served: - facts.append(served) - if f.get("category_evidence"): - facts.append(_category_evidence_md( - f["category_evidence"], launched.get(fname.rsplit("/", 1)[-1].casefold()))) - # One line each, hashes, the copy that serves it, its readers: as - # continuation lines they ran into one paragraph. - if facts: - body[item_line] += "\\" - body.extend(f" {fact}" + ("\\" if n < len(facts) - 1 else "") - for n, fact in enumerate(facts)) - - # Measured as the Markdown the split was tuned on, a collapsible - # block indented by four, so both engines split the same systems. - size = sum(len(line) + 1 for line in body) + # Measured in the share of a page the list renders to. + size = _page_share(body) if use_collapsible: status_tag = "OK" if ok_count == total else _count(total - ok_count, "issue") - title = f"{_count(total, 'file')} ({status_tag})" - size += len(f'??? note "{title}"') + 2 + 4 * sum(1 for line in body if line) - block = markup.details("note", title, body) + block = markup.details("note", f"{_count(total, 'file')} ({status_tag})", body) while block and not block[-1]: block.pop() else: block = body - sections.append({"sys_id": sys_id, "anchor": anchor, "body": body, "block": block, + sections.append({"sys_id": sys_id, "anchor": anchor, "head": head, "rows": rows, + "has_path": has_path, "block": block, "size": size, "total": total, "ok": ok_count}) # A list longer than its budget goes to a page of its own, then the @@ -3564,7 +3687,7 @@ def _render_platform_systems( kept -= sec["size"] for sec in sections: - sys_id, anchor, body = sec["sys_id"], sec["anchor"], sec["body"] + sys_id, anchor = sec["sys_id"], sec["anchor"] total, ok_count = sec["total"], sec["ok"] # Every system is a heading with the same anchor in both layouts: # emulator cards link here, and the site search splits a page at its @@ -3574,7 +3697,9 @@ def _render_platform_systems( lines.append("") if moved is not None and platform and sys_id in own_page: key, display = platform - runs = _file_runs(body, PLATFORM_PART_BUDGET) + runs = [sec["head"] + _platform_file_list(run, sec["has_path"], anchor) + for run in _blocks_in_runs([[row] for row in sec["rows"]], + PLATFORM_PART_BUDGET, _page_share)] stems = [f"{key}-{anchor}" + (f"-{n}" if n > 1 else "") for n in range(1, len(runs) + 1)] for n, (stem, run) in enumerate(zip(stems, runs), 1): @@ -3591,15 +3716,18 @@ def _render_platform_systems( *([""] + [", ".join( f"[part {i}]({other}.md)" if other != stem else f"part {i}" for i, other in enumerate(stems, 1))] if len(runs) > 1 else []), - "", "## Files", "", + "", "## Files", "", *markup.details("note", ROLE_LEGEND_TITLE, [ROLE_LEGEND]), *search_excluded(run), "", _date_line(), ]) + "\n" - where = (f"[a page of their own]({stems[0]}.md)" if len(stems) == 1 else - "pages of their own: " + ", ".join( - f"[part {i}]({stem}.md)" for i, stem in enumerate(stems, 1))) + # The count and status a folded system shows in its summary, + # linked to the page that lists the files. + tag = "OK" if ok_count == total else _count(total - ok_count, "issue") + count = f"{_count(total, 'file')} ({tag})" lines.extend([ - f"{_count(total, 'file')}, {ok_count} verified: listed on {where}.", "", + f"[{count}]({stems[0]}.md)" if len(stems) == 1 else f"{count}: " + ", ".join( + f"[part {i}]({stem}.md)" for i, stem in enumerate(stems, 1)), + "", ]) continue # The section's heading stays searchable, its file list with every @@ -3635,6 +3763,40 @@ def _site_profiles() -> dict[str, dict]: return load_emulator_profiles(str(_REPO_ROOT / "emulators")) +# Whose code a source reference cites, in one word: a platform page names +# each role once in ROLE_LEGEND. Written after every reference, "(emulator +# code, libretro port)" ran through Batocera's 14,000 words. +ROLE_WORDS = { + "libretro glue": "glue", + "emulator code, libretro port": "port", + "upstream emulator": "upstream", + "this build": "build", +} +ROLE_LEGEND_TITLE = "Source reference words" +ROLE_LEGEND = ( + "In a file's details, a word before its source references says whose code they are. " + "**glue** means libretro glue, **port** means emulator code in the libretro port, " + "**upstream** means the upstream emulator and **build** means this build's own code." +) + + +def _refs_by_role(profile: dict, value: str | dict | None) -> str: + """A source_ref's parts grouped by whose code they cite, each role named + once by its ROLE_WORDS word: `glue a.c:1, b.c:2 and port c.c:3`.""" + from sitesources import source_ref_items + + texts = [] + for label, items in source_ref_items(profile, value): + by_role: dict[str, list[str]] = {} + for part, role in items: + by_role.setdefault(role, []).append(part) + text = " and ".join( + (f"{ROLE_WORDS.get(role, role)} " if role else "") + ", ".join(parts) + for role, parts in by_role.items()) + texts.append(f"**{label}:** {text}" if label else text) + return ", ".join(texts) + + def _category_evidence_md(evidence: dict, launched: list[str] | None = None) -> str: """What establishes an entry's category, as the page shows it. @@ -3650,7 +3812,7 @@ def _category_evidence_md(evidence: dict, launched: list[str] | None = None) -> # plain code with the role word ("source") left in front. profile = profiles.get(reader["emulator"], {}) refs = [ - (f"`{e['name']}` at {_source_ref_markdown(profile, e['source_ref'])}" + (f"`{e['name']}` at {_refs_by_role(profile, e['source_ref'])}" if e.get("source_ref") else f"`{e['name']}`") + (" (by name)" if e.get("by") == "name" else "") for e in reader.get("entries", []) @@ -4173,8 +4335,7 @@ def generate_platform_page( "", level_label("reference"), "", - "For checking the work: the counts, where the list comes from, and " - "every file with its hashes.", + "For checking the work: counts, the list's source and each file's hashes.", "", *(line for note in (GUIDES[name].reference_notes if name in GUIDES else ()) for line in (note, "")), @@ -4305,6 +4466,8 @@ def generate_platform_page( f"| <span data-pagefind-ignore>{ok_count}/{total}</span> | {status} | {emu_str} |" ) lines.append("") + if by_system: + lines.extend(markup.details("note", ROLE_LEGEND_TITLE, [ROLE_LEGEND])) lines.extend( _render_platform_systems(by_system, config_files, emulator_files, @@ -5101,12 +5264,14 @@ def generate_provenance_page( # the public for 79 of them. derivable = derivable or {} derived_entries: dict[str, dict] = {} - for data in report.values(): + derived_by_source: dict[str, int] = {} + for source, data in report.items(): kept = [] for entry in data["missing"]: sha1 = (entry.get("sha1") or "").lower() if sha1 in derivable: derived_entries.setdefault(sha1, entry) + derived_by_source[source] = derived_by_source.get(source, 0) + 1 else: kept.append(entry) data["missing"] = kept @@ -5134,7 +5299,19 @@ def generate_provenance_page( for dat in entry.get("dats") or [entry.get("dat", "")]: by_dat.setdefault(dat, []).append(entry) lines.extend([f"### {label}", ""]) - blocks: list[list[str]] = [] + if moved is not None: + where = _list_pages( + f"provenance-{_slugify_anchor(source)}", f"Wanted from {label}", + f"Dumps {label} catalogues and the collection lacks. Part of " + f"[Dump provenance](provenance.md#{_slugify_anchor(label)}).", + [], [_wanted_list(source, label, data, by_dat, derived_by_source.get(source, 0))], + moved, parent=("Dump provenance", "/provenance/"), + ) + lines.extend([ + f"{_count(len(data['missing']), 'dump')} in {_count(len(by_dat), 'DAT')}: " + f"listed on {where}.", "", + ]) + continue for dat in sorted(by_dat): entries = by_dat[dat] table = ["| Name | Description | SHA1 |", "|------|-------------|------|"] @@ -5150,20 +5327,7 @@ def generate_provenance_page( f"`{hit['archive']}` as `{hit['member']}`." ) table.append(f"| `{name}` | {desc} | `{sha1}` |") - blocks.append(markup.details("note", f"{dat} ({len(entries)})", table)) - if moved is None: - lines.extend(line for block in blocks for line in block) - continue - where = _list_pages( - f"provenance-{_slugify_anchor(source)}", f"Wanted from {label}", - f"Dumps {label} catalogues and the collection lacks, by DAT. Part of " - f"[Dump provenance](provenance.md#{_slugify_anchor(label)}).", - [], blocks, moved, parent=("Dump provenance", "/provenance/"), - ) - lines.extend([ - f"{_count(len(data['missing']), 'dump')} in {_count(len(by_dat), 'DAT')}: " - f"listed on {where}.", "", - ]) + lines.extend(markup.details("note", f"{dat} ({len(entries)})", table)) if derived_entries: lines.extend( @@ -5189,6 +5353,69 @@ def generate_provenance_page( return "\n".join(lines) + "\n" +def _wanted_list(source: str, label: str, data: dict, by_dat: dict[str, list], + derived: int) -> list[str]: + """A catalog's wanted page: the counts, then every missing dump in one + table folded under a filter box. + + The page was one closed block per DAT, 46 for TOSEC: a name was found + by opening each in turn. The box filters the whole list by name, DAT, + description or SHA1, and says how many rows it keeps. + """ + missing = data["missing"] + held = data["matched"] + reached = held + len(missing) + derived + dats = _count(len(data["covered_dats"]), "DAT") + lines = [ + f"Held: **{held:,}** of {reached:,}, in the {dats} the collection reaches. " + f"Wanted: **{len(missing):,}**, from {_count(len(by_dat), 'DAT')}.", + "", + ] + if derived: + verb = "is" if derived == 1 else "are" + lines.extend([ + f"{derived:,} more {verb} [derivable]" + "(provenance.md#derivable-dumps-the-collection-already-produces).", ""]) + if data.get("out_of_scope"): + lines.extend([f"{data['out_of_scope']:,} more sit in DATs the collection does not reach.", ""]) + counts = ["| DAT | Wanted |", "|-----|--------|", + *(f"| {dat.replace('|', '-')} | {len(entries):,} |" + for dat, entries in sorted(by_dat.items()))] + lines.extend(markup.details("note", f"Wanted by DAT ({len(by_dat):,})", counts)) + rows = [] + for entry in sorted(missing, key=lambda e: (e["name"], e.get("sha1") or "")): + desc = markup.inline_html(entry.get("description") or "") + for hit in entry.get("romsets", []): + desc += markup.inline_html( + f" Ships in {_ROMSET_SOURCES.get(hit['source'], hit['source'])} " + f"`{hit['archive']}` as `{hit['member']}`.") + in_dats = ", ".join(html.escape(d) for d in (entry.get("dats") or [entry.get("dat", "")])) + sha1 = html.escape(entry.get("sha1") or "-") + # The search reads the name alone. The DAT names are searched once, in + # the count table above, and the site search holds no hashes. With the + # descriptions indexed too, the TOSEC list matched "2" 556 times + # (years, versions) and put the PlayStation page ahead of the + # PlayStation 2 one for "playstation 2 bios". The name and the hash + # sit in their cells as text: a catalog name is words, and in code + # each word wore its own box on a narrow column. + skip = markup.search_exclude() + rows.append("<tr>" + f"<td>{html.escape(entry['name'], quote=False)}</td>" + f"<td {skip}>{in_dats}</td><td {skip}>{desc or '-'}</td>" + f'<td class="rb-hash" {skip}>{sha1}</td></tr>') + heads = ["Name", "DAT", "Description", "SHA1"] + lines.extend([ + siteentries.filter_box(f"rb-wanted-filter-{_slugify_anchor(source)}", + "dumps", heads, len(rows)), + '<details class="rb-details rb-details-note">', + f"<summary>All {_count(len(rows), 'wanted dump')}</summary>", + '<table class="rb-wanted" data-rb-stack="true">', + "<thead><tr>" + "".join(f'<th scope="col">{h}</th>' for h in heads) + "</tr></thead>", + "<tbody>", *rows, "</tbody>", "</table>", "</details>", "", + ]) + return lines + + _RELEASE_ASSET_NAMES: dict[str, str] | None = None @@ -5325,6 +5552,17 @@ def _places_lines(places: list[Place]) -> list[str]: ] +def _places_markdown(places: list[Place]) -> list[str]: + """_places_lines in Markdown, for a card written as a list: the places + as code spans, which the glossary and the line breaks treat as code.""" + by_place: dict[tuple[str, ...], list[str]] = {} + for name, href, dests in places: + by_place.setdefault(tuple(dests), []).append(f"[{name}]({href})") + ordered = sorted(by_place.items(), key=lambda item: (not item[0], -len(item[1]), item[0])) + return [(" and ".join(_code(d) for d in dests) + ": " if dests else "") + ", ".join(links) + for dests, links in ordered] + + def _inline_card_html(text: str) -> str: """A card value written in Markdown (code spans, links), as the HTML a fact list holds. HTML already in the value is kept.""" @@ -5591,8 +5829,11 @@ def _system_file_card( for label, value in (("SHA1", sha1_full), ("MD5", md5_full), ("CRC32", f.get("crc32")), ("SHA256", f.get("sha256"))): if value and value != "unknown": - facts.append((label, [f'<span class="rb-hash">{html.escape(str(value))}</span>'], - False)) + # Starlight writes the value bare: its cell carries the hash + # style and the copy button (_fact_list). + shown = (html.escape(str(value)) if markup.starlight() + else f'<span class="rb-hash">{html.escape(str(value))}</span>') + facts.append((label, [shown], False)) if not markup.starlight(): # The size yardstick pages are cut by: the list a card was when the @@ -5600,26 +5841,110 @@ def _system_file_card( lines.extend(search_excluded([f"- {label}: {', '.join(values)}" for label, values, _searched in facts])) else: - # A grid of facts, label then value: two bullet lists of code chips - # read as a dump. What the search skips is one list with one - # attribute: on each row it was 77 KB of the Sony page. - read = [fact for fact in facts if fact[2] and searchable] - skipped = [fact for fact in facts if fact not in read] - for group, attributes in ((read, ""), (skipped, f" {skip}")): - if group: - lines.append(f'<dl class="rb-facts"{attributes}>') - # One value cell per label: the grid pairs each dt with one dd. - # A searched value keeps its label out: the excerpt ran the two - # together, "On the setup's listAmberELEC". - label_skip = f" {skip}" if not attributes else "" - lines.extend(f"<dt{label_skip}>{label}</dt><dd>" - + "<br>".join(_inline_card_html(v) for v in values) + "</dd>" - for label, values, _searched in group) - lines.append("</dl>") + lines.extend(_card_fact_groups(facts, sha1_full, searchable)) lines.extend(["", "</div>", ""]) 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'<dd class="{copy[label]}">{values[0]}</dd>' + return "<dd>" + "<br>".join(_inline_card_html(v) for v in values) + "</dd>" + + return [f'<dl class="rb-facts"{skip}>', + *(f"<dt>{label}</dt>{cell(label, values)}" for label, values, _searched in facts), + "</dl>"] + + +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"<summary>{html.escape(summary, quote=False)}</summary>" + # 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"<details{attribute}>", summary_line, *gap, *body, *gap, "</details>"] + + +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 += ['<dl class="rb-facts">', + *(f"<dt{skip}>{label}</dt><dd>" + + "<br>".join(_inline_card_html(v) for v in values) + "</dd>" + for label, values, _searched in searched), + "</dl>"] + 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(["", "</div>", ""]) + 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("</div>") @@ -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}: <span class="rb-hash" data-copy="{html.escape(value)}">' + f"{html.escape(value[:SHORT_HASH])}</span>{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, "", "</div>", "", *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, "", "</div>", ""] + 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"(<!-- generated: romsets -->\n)(.*?)(<!-- /generated: romsets -->)", 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'<option value="{attribute(platform)}" ' + f'data-sh="{attribute(_install_args(platform, system, "sh"))}" ' + f'data-ps="{attribute(_install_args(platform, system, "powershell"))}">' + + html.escape(_platform_display(platform) + (f" ({system})" if names[platform] > 1 else ""), + quote=False) + + "</option>" + 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.", + "", + '<div class="rb-install" data-rb-install="" hidden>', + "", + f'<label class="rb-install-setup">Your setup <select>{options}</select></label>', + "", + *terminal_tabs(INSTALL_PICKER_OSES, args, steps=False), + "", + "</div>", + "", + # 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. + '<details class="rb-install-all" open>', + "<summary>Every setup's command</summary>", + "", + *_command_list("Terminal (Linux, macOS, Steam Deck):", declared, "sh"), + *_command_list("PowerShell (Windows):", declared, "powershell"), + "</details>", + ] + + +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("://", "://<wbr>") + + # 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"<li>{html.escape(_platform_display(platform), quote=False)}" + + (f" ({html.escape(system, quote=False)})" if names[platform] > 1 else "") + + f': <code data-copy="">{command(platform, system)}</code></li>' + for platform, system in declared) + return [label, "", f"<ul>{items}</ul>", ""] + + 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</span>", "optional": ">optional</span>", + "with_app": ">comes with the app</span>"} + + +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'<div class="rb-table-filter"><label for="{ident}">Filter {html.escape(heading)}</label>' + f'<input id="{ident}" type="search" autocomplete="off" ' + f'placeholder="{html.escape(filter_placeholder(labels))}">' + f'<span class="rb-table-filter-count" aria-live="polite">{rows:,} {noun}</span></div>' + ) + + +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("<h3")) + closing = max(index for index, line in enumerate(card) if line.strip() == "</div>") + 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 = ["", '<div class="rb-entries-wrap">'] + if len(rows) >= RECORD_ROWS: + lines.append(filter_box(ident, heading, labels, len(rows))) + lines.append('<table class="rb-entries" data-rb-stack="true">') + head_cells = "" + for label, name in zip(labels, short): + title = f' title="{html.escape(name)}"' if name != label else "" + head_cells += f'<th scope="col"{title}>{html.escape(label)}</th>' + lines.extend(["<thead>", f"<tr>{head_cells}</tr>", "</thead>", "<tbody>"]) + for row in rows: + lines.append("<tr>") + if row.body: + lines.extend(["<td>", '<details class="rb-entry">', f"<summary>{row.title}</summary>", + "", *row.body, "", "</details>", "</td>"]) + else: + lines.append(f"<td>{row.title}</td>") + 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"<td{label}{ignored}>{row.cells[column - 1] or '-'}</td>") + lines.append("</tr>") + lines.extend(["</tbody>", "</table>", "</div>", ""]) + 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'<img src="{html.escape(src)}" alt="{html.escape(alt)}"{attrs}>' -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'<span class="rb-hash">`{text}`</span>' - return f'<code class="rb-hash">{html.escape(text, quote=False)}</code>' + marker = ' data-copy=""' if copy else "" + return f'<code class="rb-hash"{marker}>{html.escape(text, quote=False)}</code>' 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 ``<tag>-<distance>-g<pin7>``, 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 <streams/file_stream_transforms.h>\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'<span class="rb-hash">{value}</span>', text) + self.assertIn(f'<dd class="rb-hash">{value}</dd>', 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" / "<core>")} lines = install._saves_lines(state, saves) - self.assertEqual(lines, ["1 file read from RetroArch's save directory goes to:", - "~/.config/retroarch/saves/<core>"]) + # In words: "saves/<core>" read as a folder named <core>. + 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/<core>"} - LINE = "1 file read from RetroArch's save directory goes to: ~/MySaves/<core>" + 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/<platform>.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("<!-- generated: card-markers -->"): + page.index("<!-- /generated: card-markers -->")] + 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/<core> read as a folder named + <core>, 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/<core>" + 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/<system>"), + ("a folder per system under", "/s/saves")) + state = checked_state() + screen = install._saves_lines(state, {"files": 106, "folder": where}) + self.assertNotIn("<core>", " ".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("<main"):text.index("</main>")] + + +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("<details"):self.text.index("</details>")] + self.shown = self.text.replace(self.fold, "") + + def test_the_open_part(self) -> None: + for fact in ("<code>ppge_atlas.zim</code>", ">650.9 KB<", ">Download</a>", + "<dt>On the setup's list</dt>", "<dt>Packed for its emulators by</dt>", + "<dt>Emulators</dt>", f'<dt>SHA1</dt><dd class="rb-hash">{SHA1[:12]}</dd>'): + 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'<dt>{label}</dt><dd class="rb-hash">{value}</dd>', self.fold) + shown = " ".join(visible_words(self.text)) + for value in (MD5, CRC32, SHA256, SHA1): + self.assertNotIn(value, shown) + self.assertIn("<dt>Collection path</dt>", self.fold) + self.assertIn("<dt>Catalogued</dt>", self.fold) + self.assertEqual(self.text.count("<details"), 1) + self.assertIn("<summary>Hashes, collection path and catalogues</summary>", self.fold) + + def test_the_fold_is_out_of_the_search(self) -> None: + self.assertTrue(self.fold.startswith("<details data-pagefind-ignore>\n<summary>")) + + 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"<dt>{label}</dt>", self.text) + for value in re.findall(r"[0-9a-f]{8,64}|`[^`]+`", values): + self.assertIn(value.strip("`"), self.text, label) + + def test_a_card_reads_in_few_words(self) -> None: + # Measured on this card: 31 words open, 40 before the round. + self.assertLessEqual(len(visible_words(self.text)), min(40, int(31 * 1.05))) + + +class AnEmulatorCardFoldsItsHashes(unittest.TestCase): + def setUp(self) -> None: + entry = {"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246", + "sha1": "b05def971d8ec59f346f2d9ac21fb742e3eb6917", "size": 524288, + "required": True, "note": "Read at boot.", "priority": 5, + "validation": ["md5"], "source_ref": "src/core/bios.cpp:47"} + profile = {"emulator": "DuckStation", "files": [entry], "systems": ["sony-playstation"]} + self.text = "\n".join(gs._render_emulator_file( + entry, profile, None, profile["files"], lambda _f: True, anchor="file-scph5501-bin")) + self.fold = self.text[self.text.index("<details"):self.text.index("</details>")] + self.shown = self.text.replace(self.fold, "") + + def test_a_short_hash_open_and_every_hash_folded(self) -> None: + # On the size's line. + self.assertIn('- Size: 512.0 KB, SHA1 <span class="rb-hash" ' + 'data-copy="b05def971d8ec59f346f2d9ac21fb742e3eb6917">b05def971d8e</span>', + self.shown) + self.assertIn('<code class="rb-hash" data-copy="">490f666e1afb15b7362b406ed1cea246</code>', + self.fold) + self.assertNotIn("490f666e1afb15b7362b406ed1cea246", self.shown) + + def test_notes_open_the_rest_folded(self) -> None: + self.assertIn("Read at boot.", self.shown) + for fact in ("Priority: 5", "Validation: md5", "Source:"): + self.assertIn(fact, self.fold) + self.assertNotIn(fact, self.shown) + self.assertIn("<summary>Hashes, priority, validation and more</summary>", self.fold) + + def test_the_markdown_yardstick_is_unchanged(self) -> None: + with markup.using("mkdocs"): + entry = {"name": "a.bin", "md5": "b" * 32, "size": 4} + text = "\n".join(gs._render_emulator_file( + entry, {"files": [entry]}, None, [entry], lambda _f: True)) + self.assertNotIn("<details", text) + self.assertIn("- MD5: ", text) + + +PAIRS = [("emudeck", "sony-psp"), ("retroarch", "sony-psp"), ("retrobat", "Neo Geo")] + + +class AnInstallBoxShowsOneCommand(unittest.TestCase): + def setUp(self) -> None: + self.text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS)) + + def test_one_command_per_tab_for_the_setup_picked(self) -> None: + tabs = self.text[self.text.index("<Tabs"):self.text.index("</Tabs>")] + self.assertEqual(re.findall(r'<TabItem label="([^"]+)"', tabs), + ["Windows", "macOS", "Linux", "Steam Deck"]) + self.assertEqual(tabs.count("--platform"), 4) + self.assertEqual(tabs.count("--platform emudeck --system sony-psp"), 4) + + def test_the_setup_list_carries_every_command(self) -> None: + select = re.search(r"<select>(.*?)</select>", self.text).group(1) + options = re.findall(r'<option value="([^"]+)" data-sh="([^"]+)" data-ps="([^"]+)">', select) + self.assertEqual([value for value, _sh, _ps in options], ["emudeck", "retroarch", "retrobat"]) + self.assertIn(("retrobat", "--platform retrobat --system 'Neo Geo'", + "--platform retrobat --system 'Neo Geo'"), options) + + def test_without_script_the_list_of_every_command_shows(self) -> None: + self.assertIn('<div class="rb-install" data-rb-install="" hidden>', self.text) + listed = self.text[self.text.index('<details class="rb-install-all" open>'):] + for platform, _system in PAIRS: + self.assertEqual(listed.count(f"--platform {platform} --system"), 2, platform) + self.assertIn("install.sh | sh -s -- --platform retrobat --system 'Neo Geo'</code>", listed) + self.assertIn("install.ps1))) --platform retrobat --system 'Neo Geo'</code>", listed) + # The URL is no link inside the command, and the break adds nothing. + self.assertIn("https://<wbr>raw.githubusercontent.com", listed) + + def test_one_setup_keeps_its_two_blocks(self) -> None: + text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS[:1])) + self.assertNotIn("<select", text) + self.assertIn('```sh title="Terminal"', text) + self.assertIn('```powershell title="PowerShell"', text) + + def test_the_yardstick_is_unchanged(self) -> None: + # The budgets that cut a system page are measured on this Markdown: + # a box that changed it would move cards to other pages. + with markup.using("mkdocs"): + text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS)) + self.assertNotIn("<select", text) + self.assertEqual(text.count("# RetroBat"), 2) + self.assertEqual(text.count("install.sh | sh"), 3) + + +class TheSetupListLooksLikeAList(unittest.TestCase): + """The setup list had a rule of its own without the arrow, and read as + a text field at 390 and 1440 px.""" + + def test_the_select_takes_the_site_arrow(self) -> None: + css = (REPO_ROOT / "website" / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8") + shared = re.search(r"\.sl-markdown-content select \{([^}]*)\}", css) + self.assertIsNotNone(shared) + self.assertIn("appearance: none", shared.group(1)) + self.assertIn("var(--rb-select-arrow)", shared.group(1)) + # No rule for the install box's select overrides it. + self.assertNotRegex(css, r"rb-install-setup select") + # The arrow is defined for both themes. + self.assertEqual(len(re.findall(r"--rb-select-arrow:", css)), 2) + # The box's select sits in Markdown content, where the shared rule applies. + box = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS)) + self.assertIn('<label class="rb-install-setup">Your setup <select>', box) + + def test_the_built_select_shows_the_arrow(self) -> None: + if not (DIST / "systems").is_dir(): + self.skipTest("no built site") + css = "".join(path.read_text(encoding="utf-8") for path in (DIST / "_astro").glob("*.css")) + self.assertNotIn("rb-install-setup select", css) + self.assertRegex(css, r"\.sl-markdown-content select\{[^}]*--rb-select-arrow") + + +class TheScriptWiresBoth(unittest.TestCase): + def test_pickers_and_copy_buttons_run(self) -> None: + source = SITE_TS.read_text(encoding="utf-8") + self.assertIn("\ninstallPickers();\n", source) + self.assertIn("\ncopyButtons();\n", source) + self.assertIn("const SETUP_KEY = 'rb-setup';", source) + self.assertIn("const COPYABLE = '[data-copy]:not(button), .rb-sys-file dd.rb-hash';", source) + # A platform row's button carries its own data-copy: never given a second button. + self.assertIn("const own = button.dataset.copy;", source) + + +# Visible words of each built page: measured before the round and after it, +# the ceiling being the after value with 5 % of room. +WORD_CEILINGS = { + "systems/sony": (2012, 1407), + "systems/sony-playstation": (2709, 2130), + "systems/nintendo": (3598, 2941), + "systems/sega": (4151, 3370), + "emulators/duckstation": (1188, 1117), + "emulators/yaps2": (3159, 2655), +} + + +class BuiltPagesReadInFewerWords(unittest.TestCase): + def test_visible_words_under_the_ceiling(self) -> None: + if not (DIST / "systems").is_dir(): + self.skipTest("no built site") + for page, (before, after) in WORD_CEILINGS.items(): + with self.subTest(page=page): + ceiling = min(before, int(after * 1.05)) + self.assertLessEqual(len(visible_words(_main(page))), ceiling) + + def test_a_built_card_holds_its_hashes(self) -> None: + if not (DIST / "systems").is_dir(): + self.skipTest("no built site") + main = _main("systems/sony") + card = main[main.index('id="sha1-30a33f3651f8"'):] + card = card[:card.index('class="rb-sys-file"')] + self.assertRegex(card, r'<dd class="rb-hash">30a33f3651f8</dd>') + self.assertIn('<details data-pagefind-ignore', card) + self.assertIn(">30a33f3651f8e1b0967a5dfe6ff714baebcce41a</dd>", card) + # No link inside a command of the list: the URL is text. + listed = main[main.index("rb-install-all"):] + self.assertNotRegex(listed[:listed.index("</details>")], r"<code[^>]*>[^<]*<a ") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_site_entry_tables.py b/tests/test_site_entry_tables.py new file mode 100644 index 000000000..ab8a733aa --- /dev/null +++ b/tests/test_site_entry_tables.py @@ -0,0 +1,311 @@ +"""Long lists read as a table first, details on demand (density round). + +MAME's card pages read 13,000 to 16,700 words, card after card, and +GroovyMAME 14,400 before its last entry. The TOSEC wanted list was 46 +closed blocks, one per DAT, opened one by one to find a name. Each entry is +now a table row that opens to its card, and each wanted list one filtered +table under its counts. Nothing the pages said is lost: the tests below read +every name and hash back. +""" + +from __future__ import annotations + +import re +import sys +import unittest +from html.parser import HTMLParser +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_site as gs # noqa: E402 +import siteentries # noqa: E402 +import validate_site # noqa: E402 + +EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}} +DIST = REPO_ROOT / "website" / "dist" + + +def _archive(i: int, members: int = 3, **fields: object) -> dict: + return { + "name": f"set{i:04d}.zip", "required": True, "category": "bios_zip", + "contents": [{"name": f"m{i}_{k}.rom", "size": 1024, "crc32": f"{i:04x}{k:04x}", + "sha1": f"{i:04x}{k:04x}" * 5} for k in range(members)], + **fields, + } + + +class TheEntryTable(unittest.TestCase): + def _rows(self, count: int) -> list[siteentries.Row]: + return [siteentries.Row(f'<h3 id="file-{i}" class="rb-file-title">n{i}</h3>', + [f"- Size: {i} bytes"], [str(i), "", f"MD5 <code>{i}</code>"]) + for i in range(count)] + + def test_a_long_table_has_its_filter_box_and_stacks(self): + lines = siteentries.entry_table(self._rows(25), gs.ENTRY_HEADS, "rb-entry-filter-x", "Required") + text = "\n".join(lines) + self.assertRegex(text, r'<div class="rb-table-filter"><label for="rb-entry-filter-x">' + r"Filter Required</label><input id=\"rb-entry-filter-x\" type=\"search\"") + self.assertIn('aria-live="polite">25 rows</span></div>\n<table class="rb-entries" ' + 'data-rb-stack="true">', text) + # The Status column is empty on every row: not written. + self.assertIn('<th scope="col">File</th><th scope="col">Contents</th>' + '<th scope="col">Hash</th>', text) + self.assertNotIn("Status", text) + self.assertEqual(text.count('<details class="rb-entry">'), 25) + # Labels are written by site.ts on a table this long, as on a Markdown one. + self.assertNotIn("data-label", text) + + def test_a_card_opens_under_its_name_as_markdown(self): + lines = siteentries.entry_table(self._rows(2), gs.ENTRY_HEADS, "x", "Required") + at = lines.index('<summary><h3 id="file-0" class="rb-file-title">n0</h3></summary>') + # A blank line after the summary: the body is read as Markdown. + self.assertEqual(lines[at + 1:at + 4], ["", "- Size: 0 bytes", ""]) + self.assertNotIn("rb-table-filter", "\n".join(lines)) + self.assertIn('<td data-label="Contents" data-pagefind-ignore>1</td>', lines) + + def test_a_card_loses_only_its_wrapper(self): + card = ['<div class="rb-file-entry">', "", '<h3 id="a">x</h3>', "", "Desc.", "", + '<div class="rb-search-skip">', "", "- SHA1: s", "", "</div>", "", "</div>", ""] + title, body = siteentries.card_title_and_body(card) + self.assertEqual(title, '<h3 id="a">x</h3>') + self.assertEqual(body, ["Desc.", "", '<div class="rb-search-skip">', "", "- SHA1: s", "", + "</div>"]) + + def test_the_placeholder_reads_like_the_plugin(self): + self.assertEqual(siteentries.filter_placeholder(["File", "Contents", "Status", "Hash"]), + "File, contents, status, hash...") + self.assertEqual(siteentries.filter_placeholder(["Name", "DAT", "Description", "SHA1"]), + "Name, DAT, description, SHA1...") + + +class ASplitEmulatorListsItsCardsAsRows(unittest.TestCase): + def setUp(self): + self.files = [_archive(i, members=1 + i % 4) for i in range(gs.EMULATOR_CARDS_PER_PAGE * 2)] + self.pages = gs.emulator_pages( + "big", {"emulator": "Big", "type": "standalone", "files": self.files}, EMPTY_DB) + + def test_parts_hold_at_most_a_part_of_cards(self): + parts = gs.emulator_parts("big", self.files) + self.assertEqual(len(parts), -(-len(self.files) // gs.EMULATOR_CARDS_PER_PART)) + self.assertLessEqual(max(len(entries) for _, entries in parts), gs.EMULATOR_CARDS_PER_PART) + + def test_every_name_and_member_hash_is_on_its_part(self): + parts = {stem: page for stem, page in self.pages.items() if stem != "big"} + anchors = gs.emulator_file_anchors(self.files) + for stem, entries in gs.emulator_parts("big", self.files): + page = parts[stem] + self.assertIn('<table class="rb-entries" data-rb-stack="true">', page) + self.assertNotIn('class="rb-file-entry"', page) + for i in entries: + f = self.files[i] + self.assertIn(f'<summary><h3 id="{anchors[i]}" class="rb-file-title"', page) + for member in f["contents"]: + self.assertIn(f"`{member['name']}`", page) + self.assertIn(f"CRC32 `{member['crc32']}`", page) + self.assertIn(f"SHA1 `{member['sha1']}`", page) + # The row says how many members the archive holds. + self.assertIn(f"data-pagefind-ignore>{len(f['contents'])}</td>", page) + + def test_the_section_badge_is_not_repeated_on_each_row(self): + page = self.pages["big-files-1"] + self.assertIn("## Required", page) + self.assertNotIn(">required</span>", page) + self.assertIn(">missing</span>", page) + + +class ALongEmulatorPageIsOneTable(unittest.TestCase): + def test_rows_replace_the_index_and_the_cards(self): + files = ([_archive(i) for i in range(gs.EMULATOR_TABLE_MIN - 10)] + + [{"name": f"opt{i}.bin", "md5": f"{i:032x}"} for i in range(10)]) + page = gs.generate_emulator_page( + "long", {"emulator": "Long", "type": "standalone", "files": files}, EMPTY_DB, None, set()) + self.assertNotIn("| File |", page) + self.assertNotIn('class="rb-file-entry"', page) + self.assertEqual(page.count('<details class="rb-entry">'), len(files)) + optional = page[page.index("## Optional"):] + # Hash column: the first 12 characters, the whole value in the title + # and in the card. + md5 = f"{9:032x}" + self.assertIn(f'MD5 <code title="{md5}">{md5[:12]}</code>', optional) + self.assertRegex(optional, rf'MD5: <code class="rb-hash"[^>]*>{md5}</code>') + + def test_a_shorter_page_keeps_its_cards(self): + files = [_archive(i) for i in range(gs.EMULATOR_TABLE_MIN - 1)] + page = gs.generate_emulator_page( + "short", {"emulator": "Short", "type": "standalone", "files": files}, EMPTY_DB, None, set()) + self.assertIn('class="rb-file-entry"', page) + self.assertNotIn('class="rb-entries"', page) + + +class TheWantedListIsOneFilteredTable(unittest.TestCase): + def setUp(self): + dats = ["Sony - PlayStation 2 - BIOS Images", "Nintendo - GameCube - BIOS Images"] + self.missing = [{"name": f"bios{i}.bin", "sha1": f"{i:040x}", "description": f"Dump {i}", + "dat": dats[i % 2]} for i in range(30)] + self.missing.append({"name": "both.bin", "sha1": "f" * 40, "dats": dats}) + report = {"redump": { + "imported_at": "2026-01-01", "dats": {}, "matched": 12, "out_of_scope": 7, + "covered_dats": dats + ["Sega - Dreamcast - BIOS Images"], + "missing": [dict(entry) for entry in self.missing] + [ + {"name": "made.bin", "sha1": "a" * 40, "dat": dats[0]}], + }} + moved: dict[str, str] = {} + self.main = gs.generate_provenance_page( + {"files": {}}, report, {"a" * 40: {"from": ["bios/x.bin"], "transform": "half"}}, moved) + self.page = moved["provenance-redump"] + + def test_the_counts_come_first(self): + head = self.page[:self.page.index("<details")] + self.assertIn("Held: **12** of 44, in the 3 DATs the collection reaches. " + "Wanted: **31**, from 2 DATs.", head) + self.assertIn("1 more is [derivable]", head) + self.assertIn("7 more sit in DATs the collection does not reach.", head) + self.assertIn("| Sony - PlayStation 2 - BIOS Images | 16 |", self.page) + self.assertIn("| Nintendo - GameCube - BIOS Images | 16 |", self.page) + + def test_every_dump_is_one_row_under_a_filter_box(self): + box = re.search(r'<div class="rb-table-filter">.*?</div>\n<details[^>]*>\n' + r"<summary>All 31 wanted dumps</summary>\n<table class=\"rb-wanted\"", + self.page, re.S) + self.assertIsNotNone(box) + self.assertIn('aria-live="polite">31 rows</span>', box.group(0)) + self.assertEqual(self.page.count("<tr><td>"), 31) + for entry in self.missing: + self.assertIn(f"<tr><td>{entry['name']}</td>", self.page) + self.assertIn(f'<td class="rb-hash" data-pagefind-ignore>{entry["sha1"]}</td>', + self.page) + self.assertIn("Sony - PlayStation 2 - BIOS Images, Nintendo - GameCube - BIOS Images", + self.page) + self.assertNotIn("made.bin", self.page) + + +class TheBrowserSide(unittest.TestCase): + CSS = (REPO_ROOT / "website" / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8") + TS = (REPO_ROOT / "website" / "src" / "scripts" / "site.ts").read_text(encoding="utf-8") + + def test_a_filtered_row_stays_hidden_on_a_phone(self): + # A stacked row is `display: block`, which outweighed `hidden`. + self.assertIn(".sl-markdown-content table[data-rb-stack] tr[hidden] { display: none; }", + self.CSS) + + def test_a_box_above_a_closed_block_opens_it(self): + self.assertIn("block?.previousElementSibling?.matches('.rb-table-filter')", self.TS) + self.assertIn("if (block && query) block.open = true;", self.TS) + + def test_a_link_to_a_folded_card_opens_its_row(self): + start = self.TS.index("const openTargetBlock = () =>") + body = self.TS[start:self.TS.index("};", start)] + self.assertIn("closest('details')", body) + self.assertIn("block.open = true;", body) + self.assertIn("addEventListener('hashchange', openTargetBlock);", self.TS) + + def test_an_open_card_takes_the_table_width(self): + self.assertIn(".sl-markdown-content .rb-entries-wrap { container-type: inline-size; }", + self.CSS) + self.assertIn("details.rb-entry[open] > :not(summary) { width: 100cqi; }", self.CSS) + + +class _Visible(HTMLParser): + """Words in <main> outside closed blocks (their summary shows).""" + + VOID = frozenset({"area", "base", "br", "col", "embed", "hr", "img", "input", "link", + "meta", "source", "track", "wbr"}) + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.stack: list[tuple[str, str]] = [] + self.hidden = self.main = self.skip = 0 + self.words = 0 + + def handle_starttag(self, tag, attrs): + if tag in self.VOID: + return + effect = "" + if tag == "summary" and self.stack and self.stack[-1][1] == "hide": + self.hidden -= 1 + effect = "show" + elif tag == "details" and "open" not in dict(attrs): + self.hidden += 1 + effect = "hide" + if tag in ("script", "style", "template"): + self.skip += 1 + if tag == "main": + self.main += 1 + self.stack.append((tag, effect)) + + def handle_endtag(self, tag): + if tag in self.VOID: + return + while self.stack: + name, effect = self.stack.pop() + self.hidden += {"hide": -1, "show": 1}.get(effect, 0) + self.skip -= name in ("script", "style", "template") + self.main -= name == "main" + if name == tag: + break + + def handle_data(self, data): + if self.main and not self.skip and not self.hidden: + self.words += len(data.split()) + + +def _visible_words(page: str) -> int: + parser = _Visible() + parser.feed((DIST / page / "index.html").read_text(encoding="utf-8")) + return parser.words + + +# Words a visitor sees with every block closed, by page type: the largest +# measured after the change plus 5 %, never above the largest before it. +# Before: MAME parts 16,680, long emulator pages 14,396 (groovymame), +# wanted lists 346 (tosec). After: 1,612, 2,000 (yaps2), 118. +VISIBLE_CEILINGS = { + "emulator card parts": (r"emulators/[a-z0-9-]+-files-\d+$", 1_693), + "long emulator pages": (r"emulators/(groovymame|yaps2|bizhawk|bluemsx|fbneo)$", 2_100), + "wanted lists": (r"provenance-[a-z-]+$", 124), +} + + +@unittest.skipUnless((DIST / "emulators" / "mame-files-1" / "index.html").exists(), + "website/dist is not built") +class TheBuiltPagesStayShort(unittest.TestCase): + def _pages(self, pattern: str) -> list[str]: + found = [path.parent.relative_to(DIST).as_posix() for path in DIST.glob("**/index.html")] + return sorted(page for page in found if re.fullmatch(pattern, page)) + + def test_visible_words_under_each_ceiling(self): + for kind, (pattern, ceiling) in VISIBLE_CEILINGS.items(): + pages = self._pages(pattern) + self.assertTrue(pages, kind) + for page in pages: + with self.subTest(page=page): + self.assertLessEqual(_visible_words(page), ceiling, kind) + + def test_under_the_element_budget(self): + for pattern, _ceiling in VISIBLE_CEILINGS.values(): + for page in self._pages(pattern): + with self.subTest(page=page): + parsed = validate_site._PageParser(DIST / page / "index.html") + parsed.feed((DIST / page / "index.html").read_text(encoding="utf-8")) + self.assertLessEqual(parsed.page.elements, validate_site.MAX_PAGE_ELEMENTS) + + def test_every_mame_set_and_member_is_on_a_part(self): + profile = yaml.safe_load((REPO_ROOT / "emulators" / "mame.yml").read_text(encoding="utf-8")) + html = "".join((DIST / page / "index.html").read_text(encoding="utf-8") + for page in self._pages(r"emulators/mame-files-\d+")) + for f in profile["files"]: + with self.subTest(name=f["name"]): + self.assertIn(f"{f['name']}</code></h3></summary>", html) + for member in f.get("contents") or []: + if member.get("crc32"): + self.assertIn(str(member["crc32"]), html) + if member.get("sha1"): + self.assertIn(str(member["sha1"]), html) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index efc0490b9..373acd0ed 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -340,7 +340,9 @@ class APlatformPageSaysWhereEachFileGoes(unittest.TestCase): lines = generate_site._render_platform_systems( {"nintendo-gamecube": files}, {("nintendo-gamecube", "IPL.bin"): declared}, {}) for region in regions: - self.assertIn(f"- `IPL.bin` at `dolphin-emu/Sys/GC/{region}/IPL.bin` - OK", + # The folder: the name is before it in the row. + self.assertIn(f"<summary>IPL.bin<code>dolphin-emu/Sys/GC/{region}/</code>" + '<span class="rb-badge rb-badge-success">OK</span></summary>', "\n".join(lines)) @@ -369,7 +371,7 @@ class AVariantShowsWhoPinsIt(unittest.TestCase): ".variants/advision.zip.1c086064</code>", block) self.assertIn('<a href="../platforms/batocera.md">Batocera</a>', block) self.assertIn('<a href="../platforms/retrobat.md">RetroBat</a>', block) - self.assertIn('<span class="rb-hash">' + "c" * 40 + "</span>", block) + self.assertIn('<dd class="rb-hash">' + "c" * 40 + "</dd>", block) self.assertIn("Catalogued", block) @@ -992,7 +994,8 @@ class TheOneLinerIsWhereUsersLand(unittest.TestCase): page = generate_site.generate_system_page( "Microsoft", {"MSX": [entry]}, FileConsumers({"files": {}}), {"MSX": [("recalbox", "msx1,msx2,msxturbor"), ("batocera", "msx1")]}) - self.assertIn("--platform recalbox --system msx1,msx2,msxturbor\n", page) + # The whole system id, up to the end of the command. + self.assertRegex(page, r"--platform recalbox --system msx1,msx2,msxturbor(\n|</code>)") self.assertIn("--platform batocera --system msx1", page) def test_a_system_is_offered_only_where_the_manifest_records_it(self): diff --git a/tests/test_site_platform_density.py b/tests/test_site_platform_density.py new file mode 100644 index 000000000..dae52b5ec --- /dev/null +++ b/tests/test_site_platform_density.py @@ -0,0 +1,294 @@ +"""A platform page lists one row per file, the evidence on demand. + +Batocera, AmberELEC and RomM ran to 14,000 to 16,000 words: every file +spelled its full MD5, the copy serving it and each source reference of +each reader, with "(emulator code, libretro port)" after every one. A row +now shows the name, the folder, the status and a short hash. Opened, it +holds every hash, the served copy and the readers across the list, the +role of each reference named by one word that a legend defines once. +""" + +from __future__ import annotations + +import html +import re +import sys +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 # noqa: E402 + +_SKIPPED = frozenset({"script", "style", "template"}) +_VOID = frozenset({"area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "source", "track", "wbr"}) + + +class _Words(HTMLParser): + """Words in <main>: all of them, and those a reader sees with every + details block closed (a closed block shows its summary only).""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.stack: list[tuple[str, bool]] = [] + self.visible = 0 + self.total = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag not in _VOID: + closed = tag == "details" and "open" not in dict(attrs) + self.stack.append((tag, closed)) + + def handle_endtag(self, tag: str) -> None: + for index in range(len(self.stack) - 1, -1, -1): + if self.stack[index][0] == tag: + del self.stack[index:] + return + + def _counted(self) -> bool: + names = {name for name, _ in self.stack} + return "main" in names and not names & _SKIPPED + + def _shown(self) -> bool: + for index, (_name, closed) in enumerate(self.stack): + if closed: + rest = self.stack[index + 1:] + if not rest or rest[0][0] != "summary": + return False + return True + + def handle_data(self, data: str) -> None: + if not self._counted(): + return + count = len(data.split()) + self.total += count + if self._shown(): + self.visible += count + + +def page_words(html: str) -> tuple[int, int]: + """(visible words, all words) in a page's <main>.""" + parser = _Words() + parser.feed(html) + return parser.visible, parser.total + + +class VisibleWordsAreCounted(unittest.TestCase): + def test_a_closed_block_shows_its_summary_only(self): + page = ("<main><p>one two</p><details><summary>three</summary>four five" + "<details open><summary>six</summary>seven</details></details>" + "<details open><summary>eight</summary>nine</details>" + "<script>no</script></main><p>outside</p>") + self.assertEqual(page_words(page), (5, 9)) + + +class ARowPerFile(unittest.TestCase): + def _row(self, f: dict, entry: dict, hash_type: str = "md5") -> str: + return generate_site._platform_file_row(f, entry, hash_type) + + def test_the_row_at_a_glance(self): + md5, sha1 = "a" * 32, "b" * 40 + row = self._row({"name": "bios_a.bin", "status": "ok", "size": 2048}, + {"name": "bios_a.bin", "md5": md5, "sha1": sha1, "crc32": "0badf00d", + "destination": "bios/bios_a.bin"}) + # The summary is the row: the name, its folder, the status. + self.assertIn('<details class="rb-file"><summary>bios_a.bin<code>bios/</code>' + '<span class="rb-badge rb-badge-success">OK</span></summary>', row) + # The hash the platform checks, short, on one button beside the + # summary (a summary is a control: no button inside it). + details, button = row.split("</details>") + self.assertNotIn("<button", details) + self.assertEqual(button, f'<button class="rb-copy" data-copy="{md5}" ' + f'aria-label="Copy MD5 {md5[:generate_site.SHORT_HASH]}">{md5[:generate_site.SHORT_HASH]}</button>') + # Every hash whole in the details, with the size. + for value in (md5, sha1, "0badf00d"): + self.assertIn(value, details) + self.assertIn("(2.0 KB)", details) + + def test_a_file_at_the_root_prints_no_path(self): + for destination in ("", "x.bin"): + row = self._row({"name": "x.bin", "status": "ok"}, {"destination": destination}) + self.assertIn("<summary>x.bin<span", row) + + def test_a_list_drops_the_path_column_when_no_row_has_one(self): + files = [{"name": "a.bin", "status": "ok"}, {"name": "b.bin", "status": "ok"}] + system = "nintendo-gamecube" + config = {(system, "a.bin"): [{"destination": "a.bin"}], (system, "b.bin"): [{}]} + text = "\n".join(generate_site._render_platform_systems({system: files}, config, {})) + self.assertIn('<div class="rb-files">', text) + config[(system, "b.bin")] = [{"destination": "sub/b.bin"}] + text = "\n".join(generate_site._render_platform_systems({system: files}, config, {})) + self.assertIn('<div class="rb-files rb-files-path">', text) + + def test_status_words(self): + cases = { + ("missing", ""): ("Missing", "danger"), + ("untested", "declared hash contradicted by the local file"): ("Wrong hash", "warning"), + ("untested", "expected 1234... got 5678..."): ("Wrong hash", "warning"), + ("untested", "bios/x read error"): ("Untested", "warning"), + } + for (status, reason), (word, tone) in cases.items(): + row = self._row({"name": "x", "status": status, "reason": reason}, {"md5": "c" * 32}) + self.assertIn(f'<span class="rb-badge rb-badge-{tone}">{word}</span>', row) + if reason: + self.assertIn(f"Not confirmed: {reason}", row) + + def test_accepted_values_all_in_the_details(self): + values = ["1" * 32, "2" * 32, "3" * 32] + row = self._row({"name": "x", "status": "ok"}, {"md5": ",".join(values)}) + self.assertIn(f'data-copy="{values[0]}"', row) + self.assertIn(f">{values[0][:generate_site.SHORT_HASH]} +2</button>", row) + for value in values: + self.assertIn(f'<code class="rb-hash">{value}</code>', row) + + def test_markdown_never_reads_the_text(self): + row = self._row({"name": "*a_b*[1].bin", "status": "ok"}, {}) + self.assertIn("*a_b*[1].bin", row) + + def test_a_long_list_gets_a_filter(self): + rows = ["<details></details>"] * generate_site.FILTER_ROWS + block = generate_site._platform_file_list(rows, False, "arcade") + self.assertTrue(block[0].startswith('<div class="rb-table-filter"><label for="rb-files-filter-arcade">')) + self.assertNotIn("rb-table-filter", "".join( + generate_site._platform_file_list(rows[1:], False, "arcade"))) + + +class ReferencesNameTheirRoleOnce(unittest.TestCase): + PROFILE = {"type": "libretro", "source": "https://example.org/port", + "upstream": "https://example.org/emu"} + REFS = "source libretro/libretro.c:1, source src/a.c:2, source src/b.c:3, upstream src/c.c:4" + + def test_grouped_by_role(self): + text = generate_site._refs_by_role(self.PROFILE, self.REFS) + self.assertEqual(text, "glue `libretro/libretro.c:1` and port `src/a.c:2`, `src/b.c:3` " + "and upstream `src/c.c:4`") + for word in generate_site.ROLE_WORDS.values(): + self.assertIn(f"**{word}**", generate_site.ROLE_LEGEND) + + def test_other_pages_keep_the_role_after_each_reference(self): + text = generate_site._source_ref_markdown(self.PROFILE, self.REFS) + self.assertEqual(text.count("(emulator code, libretro port)"), 2) + self.assertIn("`src/c.c:4` (upstream emulator)", text) + + +def _unescaped(text: str) -> str: + return html.unescape(text) + + +@unittest.skipUnless((REPO_ROOT / "database.json").is_file(), "database.json absent") +class NothingIsLost(unittest.TestCase): + """Every fact a file row carried is on the page or its parts: each + declared hash whole, the served copy, every reader's references.""" + + @classmethod + def setUpClass(cls) -> None: + from common import load_database + from generate_readme import compute_coverage + + db = load_database(str(REPO_ROOT / "database.json")) + cls.pages = {} + for name in ("batocera", "retroarch"): + cov = compute_coverage(name, str(REPO_ROOT / "platforms"), db) + moved: dict[str, str] = {} + page = generate_site.generate_platform_page(name, cov, {}, {}, moved) + cls.pages[name] = (cov, page, moved) + + def test_every_fact_is_there(self): + for name, (cov, page, moved) in self.pages.items(): + text = _unescaped("\n".join([page, *moved.values()])) + systems = {d.get("system") for d in cov["details"]} + for sys_id, system in cov["config"].get("systems", {}).items(): + if sys_id not in systems: + continue + for entry in system.get("files", []): + for algorithm in ("sha1", "md5", "crc32", "sha256"): + for value in re.split(r"[,\s]+", str(entry.get(algorithm) or "")): + if value: + self.assertIn(value, text, (name, entry.get("name"), algorithm)) + for detail in cov["details"]: + self.assertIn(detail["name"], text) + if detail.get("path") and detail.get("status") != "missing": + self.assertIn(detail["path"], text, (name, detail["name"])) + readers = (detail.get("category_evidence") or {}).get("read_by", []) + # Cores of other systems that share the name are named, not + # cited, when a core of this system reads the file. + running = [reader for reader in readers if reader.get("runs_system")] + for reader in running or readers: + for url in re.findall(r"\]\((https://[^)]+)\)", generate_site._category_evidence_md( + {"read_by": [reader]})): + self.assertIn(url, text, (name, detail["name"], reader["emulator"])) + + def test_the_role_is_a_word_defined_once(self): + for name, (_cov, page, moved) in self.pages.items(): + for text in (page, *moved.values()): + self.assertNotIn("(emulator code, libretro port)", text, name) + self.assertEqual(text.count(generate_site.ROLE_LEGEND), 1, name) + + +# Visible words (outside closed details) measured on the built site, +# 2026-10-27. A ceiling is the measured value plus 5 %, never above the +# value before the rows were tables (Batocera 2,433, AmberELEC 1,301, RomM +# 1,607, RetroArch 2,207, the largest main page 2,459, the largest part +# 19,127: RetroDECK's arcade list). +PAGE_CEILINGS = {"batocera": 2433, "amberelec": 1301, "romm": 1607, "retroarch": 2192} +MAIN_PAGE_CEILING = 2459 +PART_PAGE_CEILING = 2219 + +_SITE = REPO_ROOT / "website" / "dist" / "platforms" + + +@unittest.skipUnless(_SITE.is_dir(), "website/dist not built") +class VisibleWordsStayUnderTheirCeiling(unittest.TestCase): + def test_platform_pages(self): + from common import list_registered_platforms + + mains = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True)) + for page in sorted(_SITE.glob("*/index.html")): + name = page.parent.name + text = page.read_text(encoding="utf-8") + if 'http-equiv="refresh"' in text[:2000]: + continue + visible, _total = page_words(text) + ceiling = PAGE_CEILINGS.get(name, MAIN_PAGE_CEILING if name in mains else PART_PAGE_CEILING) + self.assertLessEqual(visible, ceiling, name) + + +# Each platform with its part pages, before the rows were rewritten: pages, +# elements (outside scripts and styles, as validate_site counts them) and +# bytes of HTML, on the built site of 2026-10-27. +BEFORE_WEIGHT = { + "batocera": (12, 18_854, 1_211_117), + "amberelec": (1, 7_928, 473_388), + "romm": (2, 9_561, 572_487), + "retroarch": (14, 21_503, 1_430_802), +} + + +@unittest.skipUnless(_SITE.is_dir(), "website/dist not built") +class APlatformWeighsNoMoreThanBefore(unittest.TestCase): + """The split budget measured Markdown length: rows written as a table + were longer Markdown, 38 systems moved to new part pages and Batocera + with its parts rose from 18,854 to 21,875 elements.""" + + def test_pages_elements_and_bytes(self): + from validate_site import _PageParser + + for name, (pages, elements, size) in BEFORE_WEIGHT.items(): + paths, weight = [], 0 + for path in [_SITE / name / "index.html", *sorted(_SITE.glob(f"{name}-*/index.html"))]: + parser = _PageParser(path) + parser.feed(path.read_text(encoding="utf-8")) + # A part folded back into its platform page left a redirect. + if not parser.page.redirect: + paths.append(path) + weight += parser.page.elements + self.assertLessEqual(len(paths), pages, name) + self.assertLessEqual(weight, elements, name) + self.assertLessEqual(sum(path.stat().st_size for path in paths), size, name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_site_r2_emulators.py b/tests/test_site_r2_emulators.py index 0ca86ecf2..9a5c5916e 100644 --- a/tests/test_site_r2_emulators.py +++ b/tests/test_site_r2_emulators.py @@ -86,9 +86,10 @@ class TheHeaderReadsAsWords(unittest.TestCase): self.assertNotIn('rb-badge-muted">japan</span>', self.page) def test_profile_version_names_the_pinned_build(self): - # DuckStation numbers builds v0.1-<commits since tag 0.1>: the pin, - # 4122fed9, is 12,070 commits past it. - self.assertRegex(str(_profile("duckstation")["core_version"]), r"^v0\.1-\d+$") + # DuckStation tags builds v0.1-<commits since tag 0.1>, and the + # pin sits past the last such tag: git describe's form names both. + self.assertRegex(str(_profile("duckstation")["core_version"]), + r"^v0\.1-\d+(-\d+-g[0-9a-f]{7})?$") def test_the_second_spelling_is_a_platform_name(self): # The lookup is case-sensitive and EmuELEC launches "Duckstation": diff --git a/tests/test_site_r2_entry.py b/tests/test_site_r2_entry.py index da795d69a..251e9657f 100644 --- a/tests/test_site_r2_entry.py +++ b/tests/test_site_r2_entry.py @@ -285,7 +285,12 @@ class CommandBlocks(unittest.TestCase): self.assertIn("install.ps1", group) block = "\n".join(generate_site._console_install_block( "PlayStation", [("batocera", "psx"), ("recalbox", "psx")])) - self.assertEqual(block.count("install.sh | sh"), block.count("install.ps1))) ")) + # Every setup's options in both forms: the list of every command, + # and the one command under the OS tabs. + sh_args = re.findall(r"install\.sh \| sh -s -- ([^<\n]+)", block) + ps_args = re.findall(r"install\.ps1\)\)\) ([^<\n]+)", block) + self.assertEqual(sorted(set(sh_args)), sorted(set(ps_args))) + self.assertEqual(len(set(sh_args)), 2) self.assertIn('```powershell title="PowerShell"', block) for key, guide in siteguides.GUIDES.items(): if "windows" in guide.run: diff --git a/tests/test_site_r2_reference.py b/tests/test_site_r2_reference.py index 44f3cea91..cd6f9aaee 100644 --- a/tests/test_site_r2_reference.py +++ b/tests/test_site_r2_reference.py @@ -158,16 +158,13 @@ class EachEntryNamesItsFile(unittest.TestCase): # Existence mode reports no path: nothing is claimed, held or not. self.assertEqual(generate_site._served_line({"status": "ok"}), "") - def test_a_long_system_list_is_cut_between_files(self): - body = ["2/2 files verified", ""] - for n in range(4): - body += [f"- `f{n}.bin` - OK", " " + "x" * 100] - runs = generate_site._file_runs(body, 250) - self.assertEqual(len(runs), 2) - for run in runs: - self.assertEqual(run[:2], body[:2]) - self.assertTrue(run[2].startswith("- ")) - self.assertEqual(sum(len(run) - 2 for run in runs), len(body) - 2) + def test_a_long_system_list_is_cut_between_rows_by_elements(self): + rows = [[f'<details class="rb-file"><summary>f{n}.bin</summary>x<br>y</details>'] + for n in range(4)] + # Three elements a row: two rows fit in a budget of seven. + runs = generate_site._blocks_in_runs(rows, 7, generate_site._element_weight) + self.assertEqual([len(run) for run in runs], [2, 2]) + self.assertEqual(sum(runs, []), sum(rows, [])) class AlsoNamedIsNotCited(unittest.TestCase): diff --git a/tests/test_site_r2_systems.py b/tests/test_site_r2_systems.py index 6579e2d97..b272f2a93 100644 --- a/tests/test_site_r2_systems.py +++ b/tests/test_site_r2_systems.py @@ -182,8 +182,14 @@ class TheCardLinesSayWhatTheyMean(unittest.TestCase): def test_the_facts_are_out_of_the_search(self): # The setup list made every card's excerpt a run of names and # paths (round 4, V6): the card's title is what the search reads. - lists = [line for line in self.lines if line.startswith("<dl")] + # The facts shown open are one list, the others one fold, both + # out of the search. + shown = self.lines[:next(i for i, line in enumerate(self.lines) if line.startswith("<details"))] + lists = [line for line in shown if line.startswith("<dl")] self.assertEqual(lists, ['<dl class="rb-facts" data-pagefind-ignore>']) + folds = [line for line in self.lines if line.startswith("<details")] + self.assertEqual(len(folds), 1) + self.assertEqual(folds, ["<details data-pagefind-ignore>"]) def test_the_page_defines_both_lines(self): page = gs.generate_system_page("Sony", {"PlayStation": [_file("scph5501.bin", "4")]}, @@ -206,15 +212,17 @@ class FileFactsAreAGrid(unittest.TestCase): "provenance": {"redump": {"name": "ps-30a.bin"}, "tosec": {"name": "SCPH-5501 (1996).bin"}}} lines = gs._system_file_card(entry, FileConsumers({"files": {}})) - facts = [line for line in lines if line.startswith("<dt>")] - self.assertTrue(facts) - for line in facts: - self.assertEqual(line.count("<dt>"), 1, line) - self.assertEqual(line.count("<dd>"), 1, line) text = "\n".join(lines) - self.assertIn('<dt>SHA1</dt><dd><span class="rb-hash">' + "4" * 40 + "</span></dd>", text) + # One value cell after each label, in the open list and in the fold. + facts = re.findall(r"<dt>[^<]*</dt><dd[^>]*>.*?</dd>", text) + self.assertTrue(facts) + self.assertEqual(len(facts), text.count("<dt>")) + self.assertEqual(text.count("<dt>"), len(re.findall(r"<dd[ >]", text))) + # The hash is the cell's text, styled by the cell: no element around it. + self.assertIn('<dt>SHA1</dt><dd class="rb-hash">' + "4" * 40 + "</dd>", text) self.assertNotIn("<code class=\"rb-hash\">", text) - catalogued = next(line for line in facts if line.startswith("<dt>Catalogued")) + self.assertNotIn('<span class="rb-hash">', text) + catalogued = next(fact for fact in facts if fact.startswith("<dt>Catalogued")) self.assertIn("<br>", catalogued) self.assertNotRegex(text, r"(?m)^- ") diff --git a/tests/test_site_r3_reference.py b/tests/test_site_r3_reference.py index c363b529b..4b15c11dd 100644 --- a/tests/test_site_r3_reference.py +++ b/tests/test_site_r3_reference.py @@ -393,7 +393,7 @@ class PlatformPagesLinkTheirSystemsAndEmulators(unittest.TestCase): def test_a_moved_list_opens_on_its_console_and_names_its_parent(self): # Past PLATFORM_SECTION_BUDGET, so the list leaves for a page of its own. - files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(1200)] + files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(2000)] moved: dict[str, str] = {} generate_site._render_platform_systems( {"sony-playstation": files}, {}, {}, ("retroarch", "RetroArch"), moved, {}) diff --git a/tests/test_site_r4_components.py b/tests/test_site_r4_components.py index d21f94fb5..ea5b6b5e4 100644 --- a/tests/test_site_r4_components.py +++ b/tests/test_site_r4_components.py @@ -141,7 +141,10 @@ class TheVisitorsTab(unittest.TestCase): block = source[source.index("const osTabs = () =>"):source.index("// The sidebar lists")] self.assertIn("savedTab(OS_SYNC_KEY)", block) self.assertIn("set.switchTab(set.tabs[index], index, false)", block) - self.assertNotIn("localStorage.setItem", source) + self.assertNotIn("localStorage.setItem", block) + # The one choice the script saves is the setup picked in an install + # box, under its own key: never the OS guess. + self.assertEqual(re.findall(r"localStorage\.setItem\((\w+)", source), ["SETUP_KEY"]) self.assertNotIn("keepTabInView", source) diff --git a/tests/test_site_r4_emulators.py b/tests/test_site_r4_emulators.py index 75198dcbe..9ba99adb5 100644 --- a/tests/test_site_r4_emulators.py +++ b/tests/test_site_r4_emulators.py @@ -89,7 +89,7 @@ class AnAppThatReadsAnyBios(unittest.TestCase): class EveryArcadeCoreNamesItsRomset(unittest.TestCase): def test_finalburn_and_mame_2003_plus(self): - for name, romset in (("fbneo", "FinalBurn Neo v1.0.0.2"), + for name, romset in (("fbneo", f"FinalBurn Neo {_profile('fbneo')['core_version']}"), ("mame2003_plus", "MAME 0.78 (plus backports)")): page = _pages(name)[name] line = page.index(f"Its BIOS and game sets must come from the {romset} romset.") diff --git a/tests/test_site_r4_merge.py b/tests/test_site_r4_merge.py index 6879f885a..6b7d51031 100644 --- a/tests/test_site_r4_merge.py +++ b/tests/test_site_r4_merge.py @@ -181,9 +181,10 @@ class FaqInstallsFromWhatYouHold(unittest.TestCase): for name in ("muOS", "Onion", "spruceOS", "MinUI", "KNULLI", "ROCKNIX"): with self.subTest(card=name): self.assertIn(name, known) - source = (REPO_ROOT / "install.py").read_text(encoding="utf-8") - self.assertIn('f"{card[2]} {kind} ({_mount_name(mount)})"', source) - self.assertIn('else "SD card"', source) + # The label the first screen gives a card found: "Onion SD card". + self.assertEqual(install._card_word(("retroarch", Path("/x/BIOS"), "Onion")), "SD card") + self.assertEqual(install._card_word(("batocera", Path("/x/bios"), "Batocera")), + "SHARE drive") def test_a_wiped_card_links_the_copy(self): section = self._section("Lost or wiped card?") diff --git a/tests/test_site_r4_systems.py b/tests/test_site_r4_systems.py index edb919f9d..d5fcded22 100644 --- a/tests/test_site_r4_systems.py +++ b/tests/test_site_r4_systems.py @@ -149,7 +149,8 @@ class TheNeoGeoOpensOnItsSet(unittest.TestCase): pairs = gs._install_pairs([("batocera", "snk-neogeo")], "a" * 40, consumers) self.assertIn(("retroarch", "arcade"), pairs) block = "\n".join(gs._console_install_block("Neo Geo", pairs)) - self.assertIn("# RetroArch\n", block) + self.assertIn("<li>RetroArch: <code", block) + self.assertIn("--platform retroarch --system arcade</code>", block) def test_the_built_section(self) -> None: html = _page(DIST / "systems" / "snk" / "index.html") diff --git a/tests/test_site_r5_systems.py b/tests/test_site_r5_systems.py index dec05b638..ddfb8950b 100644 --- a/tests/test_site_r5_systems.py +++ b/tests/test_site_r5_systems.py @@ -260,8 +260,9 @@ class EnginesGoByEngine(unittest.TestCase): if not SYSTEMS.is_dir(): self.skipTest("pages not generated") for old, new in gs.SYSTEM_PAGES_MOVED.items(): - self.assertFalse((CONTENT / old).exists(), old) - self.assertTrue((CONTENT / new).exists(), new) + self.assertFalse((CONTENT / old).exists() or (CONTENT / old).with_suffix(".mdx").exists(), old) + # A page with tabs is written as MDX. + self.assertTrue((CONTENT / new).exists() or (CONTENT / new).with_suffix(".mdx").exists(), new) redirects = CONTENT.parents[1] / "generated" / "redirects.json" if redirects.is_file(): table = json.loads(redirects.read_text(encoding="utf-8")) diff --git a/tests/test_site_validation.py b/tests/test_site_validation.py index ad953d906..35acb41e7 100644 --- a/tests/test_site_validation.py +++ b/tests/test_site_validation.py @@ -377,7 +377,7 @@ class PlatformPagesPrintTheDeclaredCrc32(unittest.TestCase): path.read_text(encoding="utf-8") for path in sitepages.pages() if sitepages.name(path) == "platforms/retroarch.md" or sitepages.name(path).startswith("platforms/retroarch-")) - self.assertIn("CRC32: `91e93107`", text) + self.assertIn('CRC32: <code class="rb-hash">91e93107</code>', text) class PlainHouseStyle(unittest.TestCase): diff --git a/tests/test_site_weight.py b/tests/test_site_weight.py index 90eef5d5b..8918eba61 100644 --- a/tests/test_site_weight.py +++ b/tests/test_site_weight.py @@ -119,7 +119,7 @@ class APlatformSystemOverItsBudgetMoves(unittest.TestCase): config = {("arcade", f["name"]): [{"name": f["name"], "md5": "a" * 32}] for f in files} moved: dict[str, str] = {} budget = generate_site.PLATFORM_SECTION_BUDGET - generate_site.PLATFORM_SECTION_BUDGET = 500 + generate_site.PLATFORM_SECTION_BUDGET = 5 try: lines = generate_site._render_platform_systems( {"arcade": files}, config, {}, ("deck", "Deck"), moved) diff --git a/tests/test_unplaced_extras.py b/tests/test_unplaced_extras.py index 65c4acea5..06b3d58c9 100644 --- a/tests/test_unplaced_extras.py +++ b/tests/test_unplaced_extras.py @@ -58,6 +58,7 @@ class SlugPlatform(unittest.TestCase): self.db = {"files": files, "indexes": generate_db.build_indexes(files, {})} self.config = { "platform": "Slugs", + "version": "2.1", "verification_mode": "md5", "base_destination": "bios", "cores": ["dosbox"], @@ -139,7 +140,7 @@ class RegionKeysForEveryClaimant(unittest.TestCase): ) common._emulator_profiles_cache.clear() profiles = common.load_emulator_profiles("emulators") - config = {"platform": "P", "verification_mode": "existence", "cores": ["one", "two"], + config = {"platform": "P", "version": "2.1", "verification_mode": "existence", "cores": ["one", "two"], "systems": {"sys": {"files": []}}} _groups, extra_dests = platform_region_groups( config, config["systems"], "emulators", db, "", profiles @@ -178,7 +179,7 @@ class FlatFirmwareFolder(unittest.TestCase): } db = {"files": files, "indexes": generate_db.build_indexes(files, {})} config = { - "platform": "Flat", "verification_mode": "md5", + "platform": "Flat", "version": "2.1", "verification_mode": "md5", "base_destination": "bios", "cores": ["psx"], "systems": { "psx": {"files": [{"name": "scph5501.bin", @@ -300,7 +301,7 @@ class OnePathCountsOnce(unittest.TestCase): common._emulator_profiles_cache.clear() self.addCleanup(common._emulator_profiles_cache.clear) profiles = common.load_emulator_profiles("emulators") - config = {"platform": "P", "verification_mode": "existence", "cores": ["one", "two"], + config = {"platform": "P", "version": "2.1", "verification_mode": "existence", "cores": ["one", "two"], "systems": {"amiga": {"files": []}}} report = verify_platform(config, db, "emulators", profiles) claims = [u for u in report["undeclared_files"] if u["name"] == "rom.key"] diff --git a/tests/test_version_basis.py b/tests/test_version_basis.py new file mode 100644 index 000000000..bb0f01511 --- /dev/null +++ b/tests/test_version_basis.py @@ -0,0 +1,351 @@ +"""core_version is read from the code at the pin, where version_basis says. + +Four bulk pin advances moved 295 pins and no version, and 54 profiles said +"Git" or "SVN" where the code states a release. These tests read the +recorded sources offline from a fixture of forge answers, hold every profile +to a basis, and hold a pin advance to reading the version again. +""" + +from __future__ import annotations + +import contextlib +import copy +import hashlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + +import profile_sync # noqa: E402 +import upstream # noqa: E402 +import version_basis # noqa: E402 +from common import load_emulator_profiles # noqa: E402 +from profile_sync import ProfileReport, bump_commit # noqa: E402 + +FIXTURE = ROOT / "tests" / "fixtures" / "version_basis.json" + + +def _fixture_cache(directory: Path) -> dict: + """The fixture's forge answers, laid out as upstream caches them.""" + data = json.loads(FIXTURE.read_text(encoding="utf-8")) + for url, payload in data["api"].items(): + target = directory / "_api" / f"{hashlib.sha256(url.encode()).hexdigest()}.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(upstream.ABSENT if payload is None else json.dumps(payload), + encoding="utf-8") + for key, text in data["files"].items(): + host, owner, name, sha, path = key.split("/", 4) + repo = upstream.make_repo(host, owner, name) + target = upstream.cache_path(str(directory), repo, sha, path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(upstream.ABSENT if text is None else text, encoding="utf-8") + return data["profiles"] + + +class ReadsTheRecordedSourceOffline(unittest.TestCase): + """The check needs nothing the cache does not hold.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.cache = Path(self.tmp.name) / "cache" + self.profiles = _fixture_cache(self.cache) + + def tearDown(self): + self.tmp.cleanup() + + def _check(self, name: str, profiles: dict | None = None) -> list: + profiles = profiles or self.profiles + return version_basis.check(profiles[name], str(self.cache), True, profiles) + + def test_every_kind_reads_its_version(self): + for name in self.profiles: + with self.subTest(profile=name): + readings = self._check(name) + self.assertEqual([r.status for r in readings], [version_basis.MATCH], + readings) + + def test_a_file_basis_names_the_line_it_read(self): + (reading,) = self._check("snes9x") + self.assertEqual(reading.read, "1.63") + self.assertTrue(reading.where.endswith("snes9x.h:11"), reading.where) + + def test_groups_are_laid_out_by_the_format(self): + (reading,) = self._check("fbneo") + self.assertEqual(reading.read, "v1.0.0.03") + + def test_a_version_the_code_does_not_state_is_drift(self): + for name, stale in (("snes9x", "1.61"), ("fbneo", "v1.0.0.2"), + ("dolphin", "2609"), ("mednafen_psx", "v0.9.44"), + ("anarch", "Git")): + with self.subTest(profile=name): + profiles = copy.deepcopy(self.profiles) + profiles[name]["core_version"] = stale + (reading,) = self._check(name, profiles) + self.assertEqual(reading.status, version_basis.DRIFT) + + def test_a_tag_distance_the_forge_contradicts_is_drift(self): + profiles = copy.deepcopy(self.profiles) + profiles["dolphin"]["version_basis"]["distance"] = 400 + (reading,) = self._check("dolphin", profiles) + self.assertEqual(reading.status, version_basis.DRIFT) + self.assertEqual(dict(reading.updates)["distance"], 437) + + def test_a_pattern_that_no_longer_matches_is_unreadable(self): + profiles = copy.deepcopy(self.profiles) + profiles["snes9x"]["version_basis"]["pattern"] = "#define\\s+NOPE\\s+(\\S+)" + (reading,) = self._check("snes9x", profiles) + self.assertEqual(reading.status, version_basis.UNREADABLE) + + def test_a_profile_without_basis_is_reported(self): + profiles = copy.deepcopy(self.profiles) + del profiles["snes9x"]["version_basis"] + (reading,) = self._check("snes9x", profiles) + self.assertEqual(reading.status, version_basis.NO_BASIS) + + def test_the_command_fails_on_drift_and_passes_clean(self): + emulators = Path(self.tmp.name) / "emulators" + emulators.mkdir() + for name, profile in self.profiles.items(): + body = dict(profile, files=[] if profile["type"] != "alias" else None) + body = {k: v for k, v in body.items() if v is not None or k == "core_version"} + (emulators / f"{name}.yml").write_text(yaml.safe_dump(body), encoding="utf-8") + argv = ["profile_sync.py", "--all", "--verify-version", "--offline", + "--emulators-dir", str(emulators), "--cache-dir", str(self.cache)] + + def run() -> int: + out, err = io.StringIO(), io.StringIO() + with mock.patch.object(sys, "argv", argv), contextlib.redirect_stdout(out), \ + contextlib.redirect_stderr(err): + try: + profile_sync.main() + except SystemExit as exc: + return int(exc.code or 0) + return 0 + + import common # noqa: PLC0415 + + common._emulator_profiles_cache.clear() + self.assertEqual(run(), 0) + stale = emulators / "snes9x.yml" + stale.write_text(stale.read_text(encoding="utf-8").replace("1.63", "1.61"), + encoding="utf-8") + common._emulator_profiles_cache.clear() + self.assertEqual(run(), 1) + + +class EveryProfileSaysWhereItsVersionIsRead(unittest.TestCase): + """What can be held without a forge is held here, for every profile.""" + + @classmethod + def setUpClass(cls): + cls.profiles = load_emulator_profiles(str(ROOT / "emulators"), skip_aliases=False) + + def test_every_profile_has_a_basis(self): + missing = sorted(name for name, p in self.profiles.items() + if not version_basis.bases(p)) + self.assertEqual(missing, [], "profiles without version_basis") + + def test_no_placeholder_is_a_version(self): + named = sorted( + name for name, p in self.profiles.items() + for mode in (version_basis.bases(p) or {"": {}}) + if version_basis.is_placeholder(version_basis.declared(p, mode)) + ) + self.assertEqual(named, []) + + def test_keyed_bases_follow_the_keyed_version(self): + for name, profile in self.profiles.items(): + found = version_basis.bases(profile) + if not found or "" in found: + continue + with self.subTest(profile=name): + self.assertIsInstance(profile.get("core_version"), dict) + self.assertEqual(set(found), set(profile["core_version"])) + + def test_none_states_no_version(self): + for name, profile in self.profiles.items(): + for mode, basis in version_basis.bases(profile).items(): + if basis.get("kind") == "none": + with self.subTest(profile=name, mode=mode): + self.assertIsNone(version_basis.declared(profile, mode)) + + def test_a_tag_version_is_the_describe_form_of_its_pin(self): + for name, profile in self.profiles.items(): + for mode, basis in version_basis.bases(profile).items(): + if basis.get("kind") != "tag" or basis.get("url"): + continue + field = version_basis.PIN_FIELDS[str(basis.get("repo") or "source")] + pin = version_basis._by_mode(profile, field, mode) + if not pin: + continue + with self.subTest(profile=name, mode=mode): + self.assertEqual( + version_basis.declared(profile, mode), + version_basis.describe_form(basis["tag"], basis["distance"], pin), + ) + + def test_an_alias_carries_the_version_of_its_profile(self): + for name, profile in self.profiles.items(): + for mode, basis in version_basis.bases(profile).items(): + if basis.get("kind") != "alias": + continue + with self.subTest(profile=name): + (reading,) = [r for r in version_basis.check( + profile, "", True, self.profiles) if r.mode == mode] + self.assertEqual(reading.status, version_basis.MATCH, reading) + + def test_a_package_named_after_its_version_states_it(self): + for name, profile in self.profiles.items(): + for mode, basis in version_basis.bases(profile).items(): + if basis.get("kind") != "package" or not basis.get("pattern"): + continue + with self.subTest(profile=name): + (reading,) = [r for r in version_basis.check(profile, "", True) + if r.mode == mode] + self.assertEqual(reading.status, version_basis.MATCH, reading) + + +SAMPLE = """emulator: Sample +type: libretro +source: "https://github.com/o/n" +profiled_date: "2026-01-01" +source_commit: "aaaaaaa" +core_version: "1.0" +version_basis: + kind: file + path: "version.h" + pattern: '#define\\s+VERSION\\s+"([^"]+)"' +files: [] +""" + + +class AnAdvanceReadsTheVersionAgain(unittest.TestCase): + """A pin never moves without its version: read again, or refused.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "p.yml" + self.path.write_text(SAMPLE, encoding="utf-8") + self.report = ProfileReport( + name="p", repo="o/n", pin="aaaaaaa", head="bbbbbbb", entries=[], counts={}, + skipped=profile_sync.NO_SOURCE_REF, + ) + + def tearDown(self): + self.tmp.cleanup() + + def _reading(self, status: str, value: str | None): + return version_basis.Reading("", "file", "1.0", value, "o/n@bbbbbbb:version.h:3", status) + + def test_the_version_moves_with_the_pin(self): + seen = [] + + def check(profile, cache_dir, offline, profiles=None, recompute=False): + seen.append((profile["source_commit"], recompute)) + return [self._reading(version_basis.DRIFT, "2.0")] + + with mock.patch.object(version_basis, "check", check): + self.assertTrue(bump_commit(self.path, self.report)) + written = yaml.safe_load(self.path.read_text(encoding="utf-8")) + self.assertEqual(written["source_commit"], "bbbbbbb") + self.assertEqual(written["core_version"], "2.0") + # Read at the new pin, the nearest tag asked again. + self.assertEqual(seen, [("bbbbbbb", True)]) + + def test_an_unreadable_version_holds_the_pin(self): + with mock.patch.object(version_basis, "check", + return_value=[self._reading(version_basis.UNREADABLE, None)]): + self.assertFalse(bump_commit(self.path, self.report)) + self.assertEqual(self.path.read_text(encoding="utf-8"), SAMPLE) + + def test_a_profile_without_basis_is_not_advanced(self): + self.path.write_text(SAMPLE.split("version_basis:")[0] + "files: []\n", encoding="utf-8") + before = self.path.read_text(encoding="utf-8") + self.assertFalse(bump_commit(self.path, self.report)) + self.assertEqual(self.path.read_text(encoding="utf-8"), before) + + def test_a_package_version_is_advanced_by_hand(self): + reading = version_basis.Reading("", "package", "1.0", None, "read from x.apk", + version_basis.UNCHECKED) + with mock.patch.object(version_basis, "check", return_value=[reading]): + self.assertFalse(bump_commit(self.path, self.report)) + + +class TheWriterKeepsTheDocument(unittest.TestCase): + def test_keyed_versions_and_bases_round_trip(self): + text = ('emulator: X\nsource_commit: "abc"\ncore_version:\n standalone: "1"\n' + ' libretro: "2"\ndisplay_name: X\n') + basis = {"standalone": {"kind": "tag", "tag": "2407", "distance": 0}, + "libretro": {"kind": "none"}} + written = profile_sync.set_version_fields( + text, {"standalone": "2407", "libretro": None}, basis) + document = yaml.safe_load(written) + self.assertEqual(document["core_version"], {"standalone": "2407", "libretro": None}) + self.assertEqual(document["version_basis"], basis) + self.assertEqual(document["display_name"], "X") + self.assertIn('tag: "2407"', written) + + def test_a_missing_version_is_written_after_the_pins(self): + text = 'emulator: X\nsource_commit: "abc"\nfiles: []\n' + written = profile_sync.set_version_fields(text, None, {"kind": "none"}) + self.assertEqual(written.splitlines()[2:4], ["core_version: null", "version_basis:"]) + + +class TheSchemaRefusesPlaceholders(unittest.TestCase): + def setUp(self): + try: + import jsonschema # noqa: PLC0415 + except ImportError: + self.skipTest("jsonschema is not installed") + schema = json.loads((ROOT / "schemas" / "emulator.schema.json").read_text()) + self.validator = jsonschema.Draft202012Validator(schema) + + def _valid(self, **fields) -> bool: + profile = {"emulator": "X", "type": "libretro", "files": [], **fields} + return self.validator.is_valid(profile) + + def test_placeholders(self): + for value in ("Git", "GIT", "svn", "SVN (2015 snapshot)", "unknown", "master", + "latest", "dev", "main", "HEAD", ""): + with self.subTest(value=value): + self.assertFalse(self._valid(core_version=value)) + + def test_versions(self): + for value in ("1.63", "v1.0.0.03", "2609-437-g3aac3f0", "0.37b5", "r22", + "2026.4.1git", "developer-1", "Dev-4.0.0"): + with self.subTest(value=value): + self.assertTrue(self._valid(core_version=value)) + + def test_none_means_null(self): + self.assertTrue(self._valid(core_version=None, version_basis={"kind": "none"})) + self.assertFalse(self._valid(core_version="1.0", version_basis={"kind": "none"})) + + def test_each_kind_names_what_it_reads(self): + self.assertFalse(self._valid(version_basis={"kind": "file", "path": "v.h"})) + self.assertFalse(self._valid(version_basis={"kind": "tag", "tag": "v1"})) + self.assertFalse(self._valid(version_basis={"kind": "package"})) + self.assertTrue(self._valid(core_version="1", version_basis={"kind": "tag", "tag": "1", + "distance": 0})) + + +class VersionModesRefuseOtherFlags(unittest.TestCase): + def test_refused(self): + for extra in (["--bump-commit"], ["--check-version"], ["--strict"]): + argv = ["profile_sync.py", "--all", "--verify-version", *extra] + with self.subTest(flags=extra), mock.patch.object(sys, "argv", argv), \ + contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as ctx: + profile_sync.main() + self.assertEqual(ctx.exception.code, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/website/src/data/platform-parts.json b/website/src/data/platform-parts.json new file mode 100644 index 000000000..7c119e52a --- /dev/null +++ b/website/src/data/platform-parts.json @@ -0,0 +1,82 @@ +{ + "platforms/batocera-acorn-bbc-micro.md": "platforms/batocera.md#acorn-bbc-micro", + "platforms/batocera-apple-ii.md": "platforms/batocera.md#apple-ii", + "platforms/batocera-apple-macintosh-ii.md": "platforms/batocera.md#apple-macintosh-ii", + "platforms/batocera-archimedes.md": "platforms/batocera.md#archimedes", + "platforms/batocera-atari-st.md": "platforms/batocera.md#atari-st", + "platforms/batocera-commodore-amiga.md": "platforms/batocera.md#commodore-amiga", + "platforms/batocera-elektronika-bk.md": "platforms/batocera.md#elektronika-bk", + "platforms/batocera-microsoft-msx.md": "platforms/batocera.md#microsoft-msx", + "platforms/batocera-nintendo-ds.md": "platforms/batocera.md#nintendo-ds", + "platforms/batocera-sega-dreamcast-arcade.md": "platforms/batocera.md#sega-dreamcast-arcade", + "platforms/batocera-sony-playstation.md": "platforms/batocera.md#sony-playstation", + "platforms/emuelec-microsoft-msx.md": "platforms/emuelec.md#microsoft-msx", + "platforms/knulli-acorn-bbc-micro.md": "platforms/knulli.md#acorn-bbc-micro", + "platforms/knulli-apple-macintosh-ii.md": "platforms/knulli.md#apple-macintosh-ii", + "platforms/knulli-archimedes.md": "platforms/knulli.md#archimedes", + "platforms/knulli-atari-st.md": "platforms/knulli.md#atari-st", + "platforms/knulli-commodore-amiga.md": "platforms/knulli.md#commodore-amiga", + "platforms/knulli-sega-dreamcast-arcade.md": "platforms/knulli.md#sega-dreamcast-arcade", + "platforms/lakka-arcade.md": "platforms/lakka.md#arcade", + "platforms/lakka-commodore-amiga.md": "platforms/lakka.md#commodore-amiga", + "platforms/lakka-nec-pc-98.md": "platforms/lakka.md#nec-pc-98", + "platforms/lakka-nintendo-ds.md": "platforms/lakka.md#nintendo-ds", + "platforms/lakka-nintendo-gamecube.md": "platforms/lakka.md#nintendo-gamecube", + "platforms/lakka-nintendo-sgb.md": "platforms/lakka.md#nintendo-sgb", + "platforms/lakka-nintendo-snes.md": "platforms/lakka.md#nintendo-snes", + "platforms/lakka-scummvm.md": "platforms/lakka.md#scummvm", + "platforms/lakka-sega-dreamcast-arcade.md": "platforms/lakka.md#sega-dreamcast-arcade", + "platforms/lakka-sega-saturn.md": "platforms/lakka.md#sega-saturn", + "platforms/lakka-sinclair-zx-spectrum.md": "platforms/lakka.md#sinclair-zx-spectrum", + "platforms/lakka-sony-playstation-2.md": "platforms/lakka.md#sony-playstation-2", + "platforms/lakka-sony-playstation.md": "platforms/lakka.md#sony-playstation", + "platforms/recalbox-atari-st.md": "platforms/recalbox.md#atari-st", + "platforms/recalbox-commodore-amiga.md": "platforms/recalbox.md#commodore-amiga", + "platforms/recalbox-microsoft-msx.md": "platforms/recalbox.md#microsoft-msx", + "platforms/retroarch-arcade.md": "platforms/retroarch.md#arcade", + "platforms/retroarch-commodore-amiga.md": "platforms/retroarch.md#commodore-amiga", + "platforms/retroarch-nec-pc-engine.md": "platforms/retroarch.md#nec-pc-engine", + "platforms/retroarch-nintendo-ds.md": "platforms/retroarch.md#nintendo-ds", + "platforms/retroarch-nintendo-gamecube.md": "platforms/retroarch.md#nintendo-gamecube", + "platforms/retroarch-nintendo-sgb.md": "platforms/retroarch.md#nintendo-sgb", + "platforms/retroarch-nintendo-snes.md": "platforms/retroarch.md#nintendo-snes", + "platforms/retroarch-scummvm.md": "platforms/retroarch.md#scummvm", + "platforms/retroarch-sega-dreamcast-arcade.md": "platforms/retroarch.md#sega-dreamcast-arcade", + "platforms/retroarch-sega-saturn.md": "platforms/retroarch.md#sega-saturn", + "platforms/retroarch-sinclair-zx-spectrum.md": "platforms/retroarch.md#sinclair-zx-spectrum", + "platforms/retroarch-sony-playstation-2.md": "platforms/retroarch.md#sony-playstation-2", + "platforms/retroarch-sony-playstation.md": "platforms/retroarch.md#sony-playstation", + "platforms/retrobat-atari-st.md": "platforms/retrobat.md#atari-st", + "platforms/retrobat-commodore-amiga.md": "platforms/retrobat.md#commodore-amiga", + "platforms/retrobat-microsoft-msx.md": "platforms/retrobat.md#microsoft-msx", + "platforms/retrobat-nec-pc-engine.md": "platforms/retrobat.md#nec-pc-engine", + "platforms/retrobat-nintendo-ds.md": "platforms/retrobat.md#nintendo-ds", + "platforms/retrobat-sega-saturn.md": "platforms/retrobat.md#sega-saturn", + "platforms/retrodeck-arcade-2.md": "platforms/retrodeck.md#arcade", + "platforms/retrodeck-arcade-3.md": "platforms/retrodeck.md#arcade", + "platforms/retrodeck-arcade-4.md": "platforms/retrodeck.md#arcade", + "platforms/retrodeck-arcade.md": "platforms/retrodeck.md#arcade", + "platforms/retrodeck-atari-400-800.md": "platforms/retrodeck.md#atari-400-800", + "platforms/retrodeck-microsoft-msx.md": "platforms/retrodeck.md#microsoft-msx", + "platforms/retrodeck-nintendo-snes.md": "platforms/retrodeck.md#nintendo-snes", + "platforms/retrodeck-sega-saturn.md": "platforms/retrodeck.md#sega-saturn", + "platforms/retrodeck-sinclair-zx-spectrum.md": "platforms/retrodeck.md#sinclair-zx-spectrum", + "platforms/retrodeck-sony-playstation-2.md": "platforms/retrodeck.md#sony-playstation-2", + "platforms/retrodeck-sony-playstation.md": "platforms/retrodeck.md#sony-playstation", + "platforms/retropie-arcade.md": "platforms/retropie.md#arcade", + "platforms/retropie-commodore-amiga.md": "platforms/retropie.md#commodore-amiga", + "platforms/retropie-nec-pc-98.md": "platforms/retropie.md#nec-pc-98", + "platforms/retropie-nec-pc-engine.md": "platforms/retropie.md#nec-pc-engine", + "platforms/retropie-nintendo-ds.md": "platforms/retropie.md#nintendo-ds", + "platforms/retropie-nintendo-gamecube.md": "platforms/retropie.md#nintendo-gamecube", + "platforms/retropie-nintendo-sgb.md": "platforms/retropie.md#nintendo-sgb", + "platforms/retropie-nintendo-snes.md": "platforms/retropie.md#nintendo-snes", + "platforms/retropie-scummvm.md": "platforms/retropie.md#scummvm", + "platforms/retropie-sega-dreamcast-arcade.md": "platforms/retropie.md#sega-dreamcast-arcade", + "platforms/retropie-sega-saturn.md": "platforms/retropie.md#sega-saturn", + "platforms/retropie-sinclair-zx-spectrum.md": "platforms/retropie.md#sinclair-zx-spectrum", + "platforms/retropie-snk-neogeo-cd.md": "platforms/retropie.md#snk-neogeo-cd", + "platforms/retropie-sony-playstation-2.md": "platforms/retropie.md#sony-playstation-2", + "platforms/retropie-sony-playstation.md": "platforms/retropie.md#sony-playstation", + "platforms/romm-sony-playstation-2.md": "platforms/romm.md#sony-playstation-2" +} diff --git a/website/src/scripts/site.ts b/website/src/scripts/site.ts index 3c7315a1a..3f4470325 100644 --- a/website/src/scripts/site.ts +++ b/website/src/scripts/site.ts @@ -1,7 +1,8 @@ // Progressive enhancement of the generated pages: table captions, filters // and keyboard scrolling, the hash lookup on files/ (a pasted hash or a file // hashed on this device), moved cards, a hash typed in the search, glossary -// terms on a tap, the OS tab of the visitor's system, the current page in +// terms on a tap, the OS tab of the visitor's system, the setup picked in an +// install box, copy buttons on hashes, the current page in // the sidebar, and the 404 page's hints. // Which tables stack on a phone is decided when the page is built // (src/plugins/rehype-stack-tables.mjs), so the layout is right on first paint. @@ -87,9 +88,19 @@ const setScrollable = (table: HTMLTableElement, overflows: boolean) => { // The filter box above a long table is written when the page is built // (src/plugins/rehype-stack-tables.mjs), so the table never moves down once // drawn. Here it only filters. -const filterTable = (table: HTMLTableElement) => { +// A table folded in a closed block can have its box above the block +// (the provenance lists): typing opens the block on the rows it keeps. +const filterControls = (table: HTMLTableElement) => { const controls = table.previousElementSibling; - if (!controls?.matches('.rb-table-filter')) return; + if (controls?.matches('.rb-table-filter')) return controls; + const block = table.closest('details'); + return block?.previousElementSibling?.matches('.rb-table-filter') ? block.previousElementSibling : null; +}; + +const filterTable = (table: HTMLTableElement) => { + const controls = filterControls(table); + if (!controls) return; + const block = controls.nextElementSibling instanceof HTMLDetailsElement ? controls.nextElementSibling : null; const input = controls.querySelector('input'); const count = controls.querySelector('.rb-table-filter-count'); if (!input || !count || input.dataset.rbWired) return; @@ -100,6 +111,7 @@ const filterTable = (table: HTMLTableElement) => { input.addEventListener('input', () => { searchable ??= rows.map((row) => ({ row, text: row.textContent!.normalize('NFKD').toLocaleLowerCase() })); const query = input.value.trim().normalize('NFKD').toLocaleLowerCase(); + if (block && query) block.open = true; let visible = 0; for (const entry of searchable) { entry.row.hidden = !!query && !entry.text.includes(query); @@ -171,6 +183,9 @@ const enhanceTables = () => { }); if (document.readyState === 'complete') start(); else addEventListener('load', start, { once: true }); + // A box above a closed block is typed in before its table is ever near + // the screen: it is wired now. + for (const table of tables) if (table.closest('details:not([open])') && filterControls(table)) filterTable(table); let timer = 0; addEventListener('resize', () => { clearTimeout(timer); @@ -743,6 +758,18 @@ const followMovedCard = () => { if (match && !target) location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot())); }; +// A link to a card folded in a table row (emulators with many entries) +// opens its row. Chrome does it on its own. Where the browser left the +// target hidden, the same address is followed again once the row is open, +// so the browser brings the card into view as for any link. +const openTargetBlock = () => { + const id = decodeURIComponent(location.hash.slice(1)); + const block = id ? document.getElementById(id)?.closest('details') : null; + if (!block || block.open) return; + block.open = true; + location.replace(location.hash); +}; + // The site search holds no hashes. A hash typed in it gets "Look up this // hash" as its first result, and Enter opens the lookup. The pages Pagefind // finds for the same characters (a file name that holds them, a word that @@ -931,6 +958,251 @@ const glossaryTerms = () => { document.addEventListener('click', close); }; +// Copy buttons. A system card's hash cell (dd.rb-hash) gets one: in its +// fold, each hash copies itself, and the short SHA1 shown open copies the +// whole value, the fold's first hash. An emulator card's hash and each +// command of an install box's list carry data-copy: the value to copy, or +// empty when the text shown is the value. The buttons are added where the +// reader sees the values, and when a fold opens for the ones inside it, so a +// page of 250 closed folds holds none of them. +// A platform row's button carries its own data-copy: it is not a value to +// give a button. +const COPYABLE = '[data-copy]:not(button), .rb-sys-file dd.rb-hash'; + +// What the button copies, in words: the label of its grid row, else the +// words before the value ("MD5: ", ", SHA1 ", "RetroArch: "), else the +// label its list item opens on. +const copyLabel = (value: HTMLElement) => { + const term = (value.tagName === 'DD' ? value : value.closest('dd'))?.previousElementSibling; + if (term?.tagName === 'DT') return term.textContent?.trim() ?? ''; + const before = (value.previousSibling?.textContent ?? '').replace(/[:\s]+$/, '').split(',').pop()?.trim(); + if (before) return before; + return /^\s*([^:]+):/.exec(value.closest('li')?.textContent ?? '')?.[1]?.trim() ?? ''; +}; + +// The value's own text, without the button a cell holds. +const shownText = (value: Element) => + [...value.childNodes].filter((node) => !(node instanceof HTMLButtonElement)).map((node) => node.textContent).join('').trim(); + +const copyText = (value: HTMLElement) => { + const own = value.dataset.copy; + // An MDX page writes an empty attribute as "true". + if (own && own !== 'true') return own; + if (value.matches('dd.rb-hash') && !value.closest('details')) { + const whole = value.closest('.rb-sys-file')?.querySelector('details dd.rb-hash'); + if (whole) return shownText(whole); + } + return shownText(value); +}; + +const addCopyButtons = (root: ParentNode) => { + for (const value of root.querySelectorAll<HTMLElement>(COPYABLE)) { + if (value.hasAttribute('data-copy-ready') || value.closest('details:not([open])')) continue; + value.setAttribute('data-copy-ready', ''); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'rb-copy'; + button.textContent = 'Copy'; + // The word that replaces "Copy" is read out. + button.setAttribute('aria-live', 'polite'); + const label = copyLabel(value); + button.setAttribute('aria-label', label ? `Copy ${label}` : 'Copy'); + // A grid cell holds its button, a value elsewhere is followed by it. + if (value.tagName === 'DD') value.append(button); + else value.after(button); + } +}; + +const copyButtons = () => { + if (!document.querySelector(`${COPYABLE}, button.rb-copy[data-copy]`)) return; + addCopyButtons(document); + document.addEventListener('toggle', (event) => { + const fold = event.target as HTMLDetailsElement; + if (fold.open) addCopyButtons(fold); + }, true); + document.addEventListener('click', async (event) => { + const button = (event.target as Element).closest?.<HTMLButtonElement>('button.rb-copy'); + if (!button) return; + // A platform row's button shows a short hash and copies the whole one + // from data-copy: its text stays, a mark shows for two seconds. + const own = button.dataset.copy; + if (own && own !== 'true') { + try { + await navigator.clipboard.writeText(own); + let status = document.querySelector<HTMLElement>('#rb-copy-status'); + if (!status) { + status = document.createElement('p'); + status.id = 'rb-copy-status'; + status.className = 'rb-visually-hidden'; + status.setAttribute('role', 'status'); + document.body.append(status); + } + status.textContent = `Copied ${own}`; + button.dataset.copied = ''; + window.setTimeout(() => delete button.dataset.copied, 2000); + } catch { + // No clipboard: the full value is in the row's details. + } + return; + } + const value = button.parentElement?.matches(COPYABLE) + ? button.parentElement + : (button.previousElementSibling as HTMLElement | null); + if (!value?.matches(COPYABLE)) return; + try { + await navigator.clipboard.writeText(copyText(value)); + button.textContent = 'Copied'; + } catch { + // No clipboard (an insecure page, a refused permission): the value + // is selected, for Ctrl+C. + const range = document.createRange(); + range.selectNodeContents(value.tagName === 'DD' && value.firstChild ? value.firstChild : value); + getSelection()?.removeAllRanges(); + getSelection()?.addRange(range); + button.textContent = 'Selected'; + } + window.setTimeout(() => { button.textContent = 'Copy'; }, 1500); + }); +}; + +// A system's install box: one command, for the setup picked in its list, +// under the site's OS tabs. The box is built with the first setup's command; +// the choice rewrites the options of each command and its copy text, and is +// remembered for the next box and the next page. Without this script the +// picker stays hidden and the list of every command shows instead. +const SETUP_KEY = 'rb-setup'; + +const savedSetup = (): string | null => { + try { + return localStorage.getItem(SETUP_KEY); + } catch { + // Storage refused (a private window, blocked site data): no saved setup. + return null; + } +}; + +// The colours a flag and a value take in the highlighted sh command, read +// from the box's own first command so a rewritten one looks the same. +type ArgStyles = { flag: string; value: string; space: string }; + +const argStyles = (block: Element): ArgStyles | null => { + const leaves = [...block.querySelectorAll<HTMLElement>('pre[data-language="sh"] .ec-line .code span')].filter((span) => !span.children.length); + const at = leaves.findIndex((leaf) => leaf.textContent === '--platform'); + if (at < 0) return null; + const value = leaves.slice(at + 1).find((leaf) => leaf.textContent?.trim()); + const space = leaves.find((leaf) => leaf.textContent === ' '); + return { flag: leaves[at].getAttribute('style') ?? '', value: value?.getAttribute('style') ?? '', space: space?.getAttribute('style') ?? '' }; +}; + +// Rewrites the options of a highlighted command, from " --platform": the +// installer's own part keeps its colours, the options take *styles*. +const setCommandArgs = (frame: Element, args: string, styles: ArgStyles | null) => { + const line = frame.querySelector<HTMLElement>('.ec-line .code'); + if (!line) return; + const text = line.textContent ?? ''; + const start = text.indexOf(' --platform '); + if (start < 0) return; + const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT); + let seen = 0; + const range = document.createRange(); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const length = node.textContent?.length ?? 0; + if (seen + length > start) { + range.setStart(node, start - seen); + break; + } + seen += length; + } + range.setEndAfter(line.lastChild!); + range.deleteContents(); + for (const part of ` ${args}`.split(/(\s+)/)) { + if (!part) continue; + const span = document.createElement('span'); + const space = /^\s+$/.test(part); + const style = space ? styles?.space : part.startsWith('-') ? styles?.flag : styles?.value; + if (style) span.setAttribute('style', style); + span.textContent = part; + // The theme colours a span that has no class: a word kept whole is + // wrapped, as the build wraps it (ec-unbroken-tokens.mjs). + if (space) { + line.append(span); + } else { + const whole = document.createElement('span'); + whole.className = 'rb-nb'; + whole.append(span); + line.append(whole); + } + } + frame.querySelector('.copy button')?.setAttribute('data-code', text.slice(0, start) + ` ${args}`); +}; + +const installPickers = () => { + const blocks = [...document.querySelectorAll<HTMLElement>('[data-rb-install]')]; + if (!blocks.length) return; + const pick = (setup: string | null) => { + for (const block of blocks) { + const select = block.querySelector('select'); + if (!select) continue; + if (setup && [...select.options].some((option) => option.value === setup)) select.value = setup; + const option = select.selectedOptions[0]; + if (!option) continue; + const styles = (block as HTMLElement & { rbStyles?: ArgStyles | null }).rbStyles; + for (const frame of block.querySelectorAll('.expressive-code')) { + const shell = frame.querySelector('pre')?.dataset.language === 'powershell' ? 'ps' : 'sh'; + setCommandArgs(frame, option.dataset[shell] ?? '', styles ?? null); + } + } + }; + for (const block of blocks) { + (block as HTMLElement & { rbStyles?: ArgStyles | null }).rbStyles = argStyles(block); + block.hidden = false; + const list = block.nextElementSibling; + if (list instanceof HTMLDetailsElement && list.classList.contains('rb-install-all')) list.open = false; + block.querySelector('select')?.addEventListener('change', (event) => { + const setup = (event.target as HTMLSelectElement).value; + try { + localStorage.setItem(SETUP_KEY, setup); + } catch { + // Storage refused: the choice holds for this page only. + } + pick(setup); + }); + } + pick(savedSetup()); +}; + +// A platform's file list (div.rb-files) is rows of a details and its copy +// button. The filter box written above a long one hides both of a row. +const filterFiles = () => { + for (const list of document.querySelectorAll<HTMLElement>('.rb-table-filter + .rb-files')) { + const controls = list.previousElementSibling!; + const input = controls.querySelector('input'); + const count = controls.querySelector('.rb-table-filter-count'); + if (!input || !count) continue; + const rows = [...list.querySelectorAll<HTMLElement>(':scope > details.rb-file')]; + let searchable: { row: HTMLElement; text: string }[] | null = null; + input.addEventListener('input', () => { + searchable ??= rows.map((row) => ({ + row, + text: `${row.querySelector('summary')?.textContent ?? ''} ${row.nextElementSibling?.matches('button.rb-copy') ? row.nextElementSibling.textContent : ''}` + .normalize('NFKD') + .toLocaleLowerCase(), + })); + const query = input.value.trim().normalize('NFKD').toLocaleLowerCase(); + let visible = 0; + for (const { row, text } of searchable) { + row.hidden = !!query && !text.includes(query); + const button = row.nextElementSibling; + if (button?.matches('button.rb-copy')) (button as HTMLElement).hidden = row.hidden; + if (!row.hidden) visible += 1; + } + count.textContent = query + ? `${visible.toLocaleString('en')} of ${rows.length.toLocaleString('en')} rows` + : `${rows.length.toLocaleString('en')} rows`; + }); + } +}; + // The 404 page: a search button, and the list a moved page belongs to. const notFound = () => { document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => { @@ -948,10 +1220,15 @@ const notFound = () => { resolveFileAddress(); followMovedCard(); +openTargetBlock(); +addEventListener('hashchange', openTargetBlock); enhanceTables(); breakPaths(); hashSearch(); glossaryTerms(); +copyButtons(); +filterFiles(); osTabs(); +installPickers(); showCurrentPage(); notFound(); diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css index 0d801de72..c66667800 100644 --- a/website/src/styles/retrobios.css +++ b/website/src/styles/retrobios.css @@ -17,6 +17,9 @@ .rb-bar inline coverage bar, width from --v .rb-details-* collapsible block, neutral unless caution or danger dl.rb-facts the facts of a file card, label and value side by side + .rb-install-all the list of every command in a system's install box + .rb-copy the copy button site.ts adds after a hash + .rb-install-setup the setup list of a system's install box abbr.rb-term a glossary term, its definition on hover or tap */ @@ -530,6 +533,88 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width } :is(.rb-sys-file, .rb-file-entry):has(> .rb-file-title:target) { background: var(--rb-surface); } +/* Entries as table rows (scripts/siteentries.py): the name takes the room + and its card opens under it. On a phone the row stacks like any table + with a hash column, the name on the full width. */ +body:has(table.rb-entries) { --sl-content-width: var(--rb-grid-width); } +/* A row the filter leaves out stays out on a phone, where a stacked row is + a block: `display: block` outweighed the hidden attribute, and every + stacked table showed all its rows under "11 of 1,037 rows". */ +.sl-markdown-content table[data-rb-stack] tr[hidden] { display: none; } +@media (min-width: 50rem) { + .sl-markdown-content table.rb-wanted td.rb-hash { min-width: 10rem; } +} +.sl-markdown-content .rb-entries-wrap { container-type: inline-size; } +.sl-markdown-content table.rb-entries { width: 100%; } +@media (min-width: 50rem) { + .sl-markdown-content table.rb-entries { display: table; } + .sl-markdown-content table.rb-entries td:first-child { width: 100%; } + /* An open card takes the table's width, not its column's: the other + cells of its row keep to one line, so the room under them is free. */ + .sl-markdown-content table.rb-entries tr:has(> td > details[open]) > td:not(:first-child) { white-space: nowrap; } + .sl-markdown-content details.rb-entry[open] { contain: inline-size; } + .sl-markdown-content details.rb-entry[open] > :not(summary) { width: 100cqi; } +} +.sl-markdown-content details.rb-entry { border: 0; padding: 0; } +.sl-markdown-content details.rb-entry > summary { min-height: 24px; font-weight: 400; } +.sl-markdown-content details.rb-entry > summary > .rb-file-title { display: inline; margin: 0; } +.sl-markdown-content details.rb-entry[open] > summary { margin-bottom: 0.4rem; } +.sl-markdown-content details.rb-entry ul { margin: 0.3rem 0 0; padding-left: 1.2rem; font-size: 0.85rem; } +.sl-markdown-content details.rb-entry li { margin: 0.1rem 0; } +.sl-markdown-content details.rb-entry:has(.rb-file-title:target) { background: var(--rb-surface); } +@media (max-width: 50rem) { + .sl-markdown-content table.rb-entries td:first-child { padding-left: 0.6rem; font-size: 1rem; } + .sl-markdown-content table.rb-entries td:first-child::before { content: none; } + /* "required for Data East DECO Cassette System" ran 12 px past a phone + beside its label: a long badge wraps in its cell. */ + .sl-markdown-content table.rb-entries .rb-badge { white-space: normal; } + .sl-markdown-content details.rb-entry ul { font-size: 0.75rem; } +} + +/* A fold of a file card (Hashes, the rest): no frame, a small summary in + the muted colour, a 24 px target (WCAG 2.5.8). */ +.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) { margin: 0.3rem 0 0; padding: 0; border: 0; font-size: 0.85rem; } +.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary { + min-height: 24px; + line-height: 24px; + font-size: 0.8rem; + color: var(--rb-text-secondary); + cursor: pointer; +} +.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary::before { background-color: var(--rb-text-secondary); } +.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary:hover { color: var(--sl-color-white); } +.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > :not(summary) { margin-top: 0.2rem; } + +/* The copy button after a hash (site.ts): the Download button's frame, + smaller, a 24 px target that stays inside a 24 px line, so it never + covers the fold below it (WCAG 2.5.8). */ +.rb-copy { + display: inline-flex; + align-items: center; + box-sizing: border-box; + height: 24px; + margin-left: 0.4rem; + padding: 0 0.5rem; + border: 1px solid var(--rb-border-strong); + border-radius: 4px; + background: transparent; + font: inherit; + font-family: var(--sl-font); + font-size: 0.75rem; + line-height: 1; + color: var(--rb-link); + vertical-align: top; + cursor: pointer; +} +.rb-copy:hover { background: var(--sl-color-accent-low); } + +/* A system's install box: the setup list above the OS tabs, and the list + of every command, which wraps anywhere rather than scroll a phone. */ +.sl-markdown-content .rb-install-all code { overflow-wrap: anywhere; } +.sl-markdown-content .rb-install-setup { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 0.9rem; } +/* Its select is styled as every other choice list on the site (the + arrow of --rb-select-arrow, below): a rule of its own hid the arrow. */ + /* Collapsible blocks are quiet: a closed block must not outweigh the command it explains. Colour is kept for caution and danger. */ .sl-markdown-content details.rb-details { @@ -551,6 +636,95 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width .sl-markdown-content details.rb-details > summary::before { background-color: var(--rb-text-secondary); } .sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; } +/* A platform's file list (generate_site._platform_file_list), on the + pattern of the Tested block: each row a details whose summary is the + row (name, folder, status), its body the evidence across the whole + list. The short hash sits on its copy button (site.ts) beside the + summary, since a summary is itself a control. The details reaches under + the button column, so the row rule and an opened body run full width. */ +.sl-markdown-content .rb-files { + --rb-hash-column: 6.75rem; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--rb-hash-column); + margin-top: 0.75rem; + border-top: 1px solid var(--rb-border-strong); +} +.sl-markdown-content .rb-files > * { margin: 0; } +.sl-markdown-content .rb-files > details.rb-file { + grid-column: 1; + margin-right: calc(-1 * var(--rb-hash-column)); + padding: 0 0 0 1.75rem; + border: 0; + border-bottom: 1px solid var(--rb-border); + font-size: 0.8125rem; + line-height: 1.5; + overflow-wrap: anywhere; +} +.sl-markdown-content .rb-files > details.rb-file[open] { padding-bottom: 0.6rem; background: var(--rb-surface); } +.sl-markdown-content .rb-files > details.rb-file > summary { + display: grid; + grid-template-columns: 1.25rem minmax(0, 1fr) 9rem 6rem; + gap: 0.5rem; + align-items: center; + min-height: 2.25rem; + margin-left: -1.75rem; + padding: 0.3rem calc(var(--rb-hash-column) + 0.5rem) 0.3rem 0; + font: 0.8125rem/1.4 var(--sl-font-mono); + cursor: pointer; +} +.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary { grid-template-columns: 1.25rem minmax(0, 1fr) 6rem; } +.sl-markdown-content .rb-files > details.rb-file > summary:hover { background: var(--rb-surface); } +.sl-markdown-content .rb-files > details.rb-file > summary::before { margin: 0; background-color: var(--rb-text-secondary); } +.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 3; padding: 0; background: none; color: var(--rb-text-secondary); } +.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: -2; justify-self: start; font-family: var(--sl-font); } +.sl-markdown-content .rb-files > button.rb-copy { + position: relative; + grid-column: 2; + align-self: start; + justify-self: start; + margin-top: 0.4rem; +} +.sl-markdown-content .rb-files > [hidden] { display: none; } + +.sl-markdown-content .rb-files button.rb-copy { + display: inline-flex; + align-items: center; + gap: 0.3em; + min-height: 24px; + padding: 0 0.35em; + border: 1px solid var(--rb-border-strong); + border-radius: 3px; + background: var(--sl-color-bg); + color: var(--sl-color-white); + font: 0.8125rem/1.4 var(--sl-font-mono); + cursor: pointer; + white-space: nowrap; +} +.sl-markdown-content .rb-files button.rb-copy::after { + content: ''; + width: 0.9em; + height: 0.9em; + background: var(--rb-text-secondary); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M5 1h8a2 2 0 0 1 2 2v8h-2V3H5zM3 5h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v7h7V7z'/%3E%3C/svg%3E") center / contain no-repeat; +} +.sl-markdown-content .rb-files button.rb-copy[data-copied]::after { + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 11.2 2.8 8 1.4 9.4 6 14l8.6-8.6-1.4-1.4z'/%3E%3C/svg%3E"); + background: var(--rb-ok-fg); +} +.sl-markdown-content .rb-files button.rb-copy:hover { border-color: var(--rb-link); } +@media (max-width: 50rem) { + /* On a phone the folder goes under the name, the status stays on the + first line. A row with no folder prints nothing for it. */ + .sl-markdown-content .rb-files > details.rb-file > summary, + .sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary { + grid-template-columns: 1.25rem minmax(0, 1fr) auto; + row-gap: 0.1rem; + } + .sl-markdown-content .rb-files > details.rb-file > summary::before { grid-row: 1; } + .sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 2; grid-row: 2; } + .sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: 3; grid-row: 1; } +} + /* Asides: a neutral box for notes and tips, no side stripe, no icon. Caution and danger keep their colour, as a 1 px border all round. */ .sl-markdown-content .starlight-aside { diff --git a/website/tools/measure.mjs b/website/tools/measure.mjs index 26f65b541..0e0212060 100644 --- a/website/tools/measure.mjs +++ b/website/tools/measure.mjs @@ -68,6 +68,27 @@ if (mode === 'weight') { if (n === 0) await page.click('button[data-open-modal]'); const input = page.locator('dialog input[type="text"], dialog input[type="search"]').first(); await input.fill(query); + if (/^[0-9a-f]+$/.test(query) && [8, 32, 40, 64].includes(query.length)) { + // A pasted hash: the search offers "Look up this hash" first, and + // the lookup names the cards that hold the value. Its results + // are those cards. + const lookup = await page.waitForSelector('.rb-search-hash a', { timeout: 15000 }); + const found = await context.newPage(); + await found.goto(await lookup.evaluate((a) => a.href), { waitUntil: 'networkidle' }); + await found.waitForFunction(() => /^Found|^No file/.test(document.getElementById('rb-file-lookup-status')?.textContent ?? ''), null, { timeout: 15000 }); + results = await found.evaluate(() => { + const cards = [...document.querySelectorAll('#rb-file-lookup-result a')].filter((a) => a.textContent === 'Open its card'); + return { + count: document.getElementById('rb-file-lookup-status')?.textContent ?? '', + top: cards.slice(0, 5).map((a) => `Open its card | ${new URL(a.href).pathname}${new URL(a.href).hash}`), + }; + }); + await found.close(); + await page.waitForTimeout(300); + const transfer = sum(rows.slice(mark), () => true); + console.log(JSON.stringify({ query, transfer_kb: (transfer / 1024).toFixed(1), ...results })); + continue; + } await page.waitForSelector('.pagefind-ui__result', { timeout: 15000 }); await page.waitForTimeout(1200); results = await page.evaluate(() => { diff --git a/website/tools/search_gate.py b/website/tools/search_gate.py index ee8d04060..2c0d0368d 100644 --- a/website/tools/search_gate.py +++ b/website/tools/search_gate.py @@ -112,6 +112,19 @@ QUERIES: dict[str, tuple[tuple[str, ...], int]] = { "r36s": (("platforms/", "which-pack/"), 3), "steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1), "knulli": (("platforms/knulli/",), 1), + # A card folded in a table row, on the last part of MAME's cards. + "bbc_flop_torch.xml": (("emulators/mame-files-",), 3), + # FinalBurn Neo has no pgm.zip entry: the archive is named by the badge + # of each member it reads there, in the Status column of its table. + "pgm.zip": (("emulators/fbneo/",), 3), + # A dump name in a wanted list folded under its filter box. + "speeddos": (("provenance-tosec/",), 1), + # A pasted hash of any kind finds the file's card, its hashes now in a + # closed fold: the MD5, CRC32 and SHA256 of scph5501.bin. The search box + # offers "Look up this hash", and the lookup opens the card. + "490f666e1afb15b7362b406ed1cea246": (("systems/sony-playstation/",), 1), + "8d8cb7e4": (("systems/sony-playstation/",), 1), + "11052b6499e466bbf0a709b1f9cb6834a9418e66680387912451e971cf8a1fef": (("systems/sony-playstation/",), 1), } diff --git a/wiki/first-install.md b/wiki/first-install.md index 00df0a3af..29fac6118 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -88,10 +88,12 @@ Arrows move, Enter chooses, Esc goes back, as A and B on a handheld. ## 3. Install -The next screen counts missing files and free space. +It counts missing files and free space. Press Enter on **Install**. **Choose systems** keeps the consoles you play. +**Game data** (only a game reads it) has its own row. + **Old card?** In a card reader, choose **Copy the BIOS files from your old card or a folder**, then the card. @@ -102,11 +104,11 @@ Esc or Ctrl+C stops, B on a handheld. Run again to resume. ## 5. Read the result - **Done**: all files are in place. -- **Finished with errors**: run the command again. It retries only those. +- **Finished with errors**: run it again. It retries only those. - **Finished with files missing**: run it again online. - **Nothing installed**: the screen says why. See [If it stops](#if-it-stops). -For questions line by line (screen readers), add `--plain`. +For line-by-line questions (screen readers), add `--plain`. ## What the command does @@ -129,8 +131,8 @@ All good: ## Undo -Files it replaces go to a `retrobios-backup` folder beside your BIOS folder. -The result says how to restore them. +Replaced files go to `retrobios-backup` beside your BIOS folder. The result +says how to restore them. ## If it stops @@ -166,7 +168,7 @@ You cannot write there. Do what the message says. ### The card is full -Nothing was written. Free some space, or choose another folder. +Nothing was written. Free some space there. ### The game still does not start diff --git a/wiki/installer.md b/wiki/installer.md index 69929a48a..19a72b5ac 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -176,7 +176,7 @@ install starts. | Back | Esc, Backspace | B, Select (B alone with muxterm's own keys, where Select shows its keyboard) | | Options | `o`, Tab | Y | | Filter a long list | `/` then type, Enter on a lone match chooses it | muxterm: Select shows a keyboard. Elsewhere none (the setup list puts the SD cards after RetroArch) | -| Keys of the screen | `?`, F1 | | +| Keys of the screen | `?`, F1 (F1 alone while typing a filter, where the help lists the keys that do not type) | | | Stop an install | Esc, `q`, Ctrl+C | B, Select+Start (B alone with muxterm's own keys) | | Quit | `q`, Ctrl+C | B on the first screen | | Pause, back with `fg` (Linux, macOS) | Ctrl+Z | | @@ -197,10 +197,17 @@ progress, and a second one stops at once. Select+Start on a handheld ends the installer with a signal (SIGTERM), which stops it the same way. Either way the result says how many files were installed, and it stays printed in the terminal once the screen closes, with the command that repeats the run. A run that failed -keeps its log and names it, and every other run leaves nothing behind. +keeps its log and names it, and every other run leaves nothing behind. A path +or a command on the result is never split inside a word: one longer than the +window takes a line of its own, which the terminal folds, and a command breaks +only between its arguments. Every path a run names is spelled the way its BIOS +folder was named, the backup folder and the save directory included +(`/sdcard/RetroArch` on Android, not the `/storage/emulated/0` it links to). It opens only for an install, at a terminal of at least 40 columns and 12 rows -(a smaller window says so, with its size) whose `TERM` is set, is not `dumb` +(a smaller window says so, with its size, before the first question: +`The window is 39x12: questions come line by line (40x12 for the full +screen).`) whose `TERM` is set, is not `dumb` and can draw it: where the terminal's description (terminfo) has no way to move the cursor, or moves it with other sequences than the ANSI ones the screen writes (`TERM=vt52`), the questions come line by @@ -213,11 +220,42 @@ given on the command line fill the screens in advance and are kept as they are. menu shows it the same way, Enter keeping what `--system` named and `c` choosing anew. +Colour is added to words, never put in their place. RetroBIOS in the title, the row in focus +and the key the bottom line names first are magenta, files installed or in place +green, files missing or left out yellow, failures and a refused folder red, and +secondary text dim. Only the 16 basic colours are used, the ones a handheld's +console has. `NO_COLOR` set to any value but an empty one turns colour off, as do +`TERM=dumb` or no `TERM`, a terminal whose description names fewer than 8 colours +and an output that is not a terminal. The text stays the same. The line-by-line +questions paint the end of a run the same way. + However the screen ends (Enter on the result, `q`, Ctrl+C, Ctrl+D, SIGTERM, a hangup), the terminal is given back as the screen found it: the cursor shown, the main screen back, echo and line editing on. Ctrl+Z, or SIGTSTP from outside, gives it back the same way and stops the run, and `fg` redraws the -screen where it was. +screen where it was. Ctrl+Z stops it only when a shell with job control +started it, the one thing that can resume it: started from a terminal app's +`-e`, a desktop entry or a launcher's `sh -c`, the key is ignored and the bar +says why. + +A mouse or a touch screen works too, beside the keys and buttons. A click or +a tap on a row moves the cursor there, and a second one on the row under the +cursor does what Enter does: one row of a terminal is far smaller than a +finger. The entries of the bottom line press their key, the line that says +which rows are in view shows the next page, and the wheel scrolls a list or a +text without moving the cursor unless it would leave the screen. Nothing +happens until the button or the finger comes up, and only where it went down. +The right and middle buttons do nothing. The installer asks the terminal for +clicks only on a terminal that reports them (`TERM` not `linux`, `dumb` or +unset), never on a handheld route (`RETROBIOS_PAD`) or with +`RETROBIOS_NO_MOUSE=1`, and gives them back on every way out, a stop with +Ctrl+Z included. On the result, the command line screen, any screen showing +a command and the screen where a path is typed, the terminal keeps the mouse, +so text is selected, copied and pasted as usual. Elsewhere, hold Shift while +selecting. `--plain` never asks for clicks. Clicks and taps were checked on +2026-10-27 in xterm, tmux, GNU screen 4.09, Windows Terminal (WSL) and Termux +on Android 17. macOS Terminal and iTerm2 are not checked yet, nor the key +they use to select over the installer. - `--tui` asks for the full screen, and names the reason when it cannot open (code 2). @@ -260,6 +298,20 @@ file list is downloaded once and handed to each run. [Files you already had](#files-you-already-had). 8. Copies into standalone emulator directories, only with `--standalone-copies`. +The file list names the version of the platform it was built for (Batocera +43.1, RetroBat 8.3.1). Where the system writes its own version in a file, the +installer reads it, and when the two differ it says so before the install and +again with the result: "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." The install goes on. The files read are +`/usr/share/batocera/batocera.version` on Batocera, `/recalbox/recalbox.version` +on Recalbox (a release, not a branch build), `system\version.info` beside the +`bios` folder of RetroBat, and `VERSION_ID` in `/etc/os-release` on Lakka and +EmuELEC. Nothing is run to find it, and an unknown version says nothing: +RetroArch writes its version in no file, KNULLI writes a codename, ROCKNIX the +date of the build, and AmberELEC's prerelease tags are not matched to the version +its image writes. + Some cores read their system data below the frontend's save directory, not below the BIOS folder: Azahar's keys, shared font and NAND files, Citra's `sysdata`, Dolphin's `User` tree. The file list marks them `root: save`, and @@ -288,17 +340,22 @@ names `~/Emulation/saves/retroarch/saves`. RetroPie, ROCKNIX, AmberELEC and EmuELEC keep saves beside each game, and RomM keeps them in the browser: their lists record these files as omitted. -A RetroArch run into an empty folder printed this on 2026-10-21: +A RetroArch dry run into an empty folder printed this on 2026-10-27: ``` Fetching file index for retroarch... - 8069 files (6.5 GB) - Not installable: 11 files (1 required, not found: 11). + 106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves + 8358 files (6.6 GB) + 3269 BIOS files (4.1 GB) and 5089 game data files (2.5 GB). + Not installable: 37 files (1 required, not found: 37). Checking existing files... - 0/8069 present (0 verified, 0 wrong hash) + 0/8358 present (0 verified, 0 wrong hash) ``` +The second line divides the first, and every later count of the run (the +menu, the plan, `--dry-run`, the result) is taken from the same list. + When the manifest leaves entries out (a file the collection cannot supply, or one that failed its hash check), a `Not installable:` line under the index count says how many and why. `--check` and the closing report also name the required @@ -596,18 +653,30 @@ By default the installer fetches everything the platform runs. At a terminal, the plan screen of the [full-screen interface](#the-full-screen-interface) offers **Install**, **Choose systems** and **Options**. With `--plain`, or where the full screen cannot open (a terminal smaller than 40x12, `TERM=dumb`), the -same choice comes as questions after detection: +same choice comes as questions after detection. A RetroArch run with `--plain` +printed this on 2026-10-27: ``` -RetroArch: 8069 files, 6.5 GB. +RetroArch: 8358 files, 6.6 GB, and 2636 data files (38.5 MB). + 3269 BIOS files (4.1 GB) and 5089 game data files (2.5 GB). BIOS folder: /home/me/.config/retroarch/system + 106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves Enter) install everything - i) only what the installed cores read: 100 files, 44.2 MB + i) only what the installed cores read: 87 files, 43.8 MB c) choose systems, cores and regions f) copy from a folder or an old card + g) leave out the 5089 game data files (2.5 GB), game data: files a game or a game engine reads, of no use to the other cores q) quit ``` +A list that marks its game data counts it apart from the BIOS files, on the +plan, in this menu and on the result (`Installed: 3269 BIOS files, 5089 game +data files and 2636 data files.`), the parts adding up to the total. `g` leaves +the game data of the choice out, as `--no-game-data` does, and counts again +(`g` then keeps it). The plan screen has the same choice as a row of its own, +**Game data (5089 files, 2.5 GB): kept, leave out**, one press toggling it, +and Options keeps its **Game data** row too. + `i` appears for RetroArch, Lakka and RetroPie when cores are found installed, and the line above is a RetroArch with Beetle PSX HW, Snes9x, mGBA, Genesis Plus GX and FCEUmm. @@ -615,7 +684,9 @@ It keeps what those cores read and drops the systems none of them emulates, the rule `--installed-cores` applies. `c` walks through three numbered lists, each answered with numbers such as -`1,4,7-9`, or Enter to keep everything on that axis. +`1,4,7-9`, or Enter to keep everything on that axis. The letter names the +lists it asks (`c) choose systems` with a MiSTer pack), and a list with one +answer is not asked. - **Systems.** The consoles and computers the platform declares, titled and ordered as the full screen's Choose systems lists them, the id `--system` @@ -637,6 +708,16 @@ without questions, every other option of the run in it, for instance --system sony-playstation --region north-america`. Enter then installs, and `c` chooses again. +An option is refused by the axis the list does not record, with what works +instead. The MinUI, NextUI and MyMinUI lists record cores and regions and no +system: `--core` and `--region` narrow them, `--system` is refused with +`Run it without --system, or narrow with --core and --region`. A pack records +the systems of its files and what its platform's list narrows by: with the +pack alone, `--core` or `--region` names that list (`--manifest FILE`, a copy +of `install/minui.json`) only when it records them, and MiSTer FPGA's pack +says its list records no cores. A list from an older release records none of +the three, and the one-line installer fetches the current one. + The same choices on the command line: ```bash @@ -746,7 +827,7 @@ what the options name, everything by default. | <span id="option-list-cores"></span>`--list-cores` | Print the cores whose extra files a platform installs | | <span id="option-list-emulators"></span>`--list-emulators` | Print every name `--emulator` accepts, each buildbot name beside the emulator it stands for | | <span id="option-romm-firmware"></span>`--romm-firmware TEMPLATE` | RomM: where each platform's firmware goes in the library, as `filesystem.structure.firmware` in RomM's `config.yml` says (`{platform}/bios` for a library laid out as `{platform}/roms`). Read from `config.yml` beside the library when it is there. See [RomM firmware layout](#romm-firmware-layout) | -| <span id="option-no-input"></span>`--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr | +| <span id="option-no-input"></span>`--no-input` | Never ask a question, even at a terminal. A run whose output goes to a file or a pipe asks nothing either, and says so on stderr. It finds the setups and the mounted cards the screen finds: one is taken, as Enter takes it, and two stop the run with an `error` event (`stage` `detect`, `reason` `conflict`) naming each with its `--platform` and `--dest` | | <span id="option-check"></span>`--check` | Report and exit without writing | | <span id="option-dry-run"></span>`--dry-run` | Show what would be downloaded, the totals first, then by system and with sizes, without writing anything. A plan taller than the screen names the ten largest systems, and `--verbose` lists every system and file | | <span id="option-yes"></span>`--yes`, `-y` | Do not ask before a download of 1 GB or more, nor anything else. Implies `--no-input`, so a run that needs an answer (nothing found here, two setups found) stops on what to name instead | @@ -864,20 +945,42 @@ A card that holds a system's own files is read the same way, without `--platform` or with the one that names its list, by the rules the table above gives for the device, below the card's root instead of its mount point: +<!-- generated: card-markers --> + | On the card | System | Files go to | |-------------|--------|-------------| +| `system/configs/emulationstation/about.info` holding the line `KNULLI` | KNULLI | `bios`, KNULLI's list | +| `system/batocera.conf` holding the line `system.hostname=KNULLI` | KNULLI | `bios`, KNULLI's list | +| `.config/system/configs/system.cfg` holding the line `root.password=rocknix` | ROCKNIX | `roms/bios`, ROCKNIX's list | | `MUOS/` | muOS | `MUOS/bios`, RetroArch's list | -| `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list | | `spruce/` | spruceOS | `BIOS`, RetroArch's list | | `System/usr/trimui/crossmix-version.txt` | CrossMix-OS | `BIOS`, RetroArch's list | +| `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list | | `.allium/` | Allium | `BIOS`, RetroArch's list | | `Koriki/version.txt` | Koriki | `BIOS`, RetroArch's list | -| `system/batocera.conf` with `system.hostname=KNULLI`, as KNULLI's first boot writes its share | KNULLI | `bios`, KNULLI's list | -| `.config/system/configs/system.cfg` with `root.password=rocknix`, as ROCKNIX's first boot writes its storage | ROCKNIX | `roms/bios`, ROCKNIX's list | +| `system/batocera.conf` holding `system.hostname=BATOCERA` or no hostname line, and no KNULLI file above | Batocera SHARE drive | `bios`, Batocera's list | +| `system/recalbox.conf` | Recalbox SHARE drive | `bios`, Recalbox's list | +| `system/batocera.conf` 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` | -The full screen lists such a card on its first screen ("muOS SD card (E:\)"). +<!-- /generated: card-markers --> + +Each file is one the system writes. KNULLI is read by `about.info` first, +which no menu writes: its HOSTNAME row rewrites `system.hostname`, and a KNULLI +card renamed there was read as a Batocera SHARE drive. ROCKNIX's line changes +with its root password, and a card whose password was changed is named by +nothing, never by another system. A drive whose files fit two systems is named +by neither: the first screen and `--plain` say which systems it could be and +the two commands that name it, and a `--dest` named with one of them takes it +as that system's, with a note. + +The full screen lists such a card on its first screen ("muOS SD card (E:\)"), +and a SHARE drive the same way ("Batocera SHARE drive (SHARE)"): its root, typed +or given with `--dest`, stands for its `bios` folder. Two cards of one system +say what their BIOS folder holds ("Onion SD card (OLDONION, 6 BIOS files)"), +the one holding fewer first. Named by its system (`--platform muos`, `--platform knulli`), a `--dest` that -is another system's card is refused. One that holds none of the system's +is another system's card is refused, and so is another system's SHARE drive +for Batocera and Recalbox. One that holds none of the system's files is refused where the card always carries them. For KNULLI, ROCKNIX and the systems no file on the card names, it gets the system's folder below it (`bios`, `roms/bios`), with a note. Named with a file list instead @@ -1009,9 +1112,16 @@ and `--plain` gives the same list numbered, in the same order, with `--dest` too. Both find the SD cards mounted here, the screen under **Found on this machine**. With a setup found, the setup screen lists it with **Another setup or folder**, and `--plain` asks the same: Enter takes the first, `o` opens the -numbered list and then the folder, `q` quits. A platform named, or read from -a list or a pack, and found nowhere gets its folder asked, its usual one under -Enter, the mounted drives numbered. A folder typed is read from the current +numbered list and then the folder, `q` quits. A platform named and found +nowhere gets its folder asked, its usual one under Enter, the mounted drives +numbered. A list or a pack read by card setups too (RetroArch's, read by Onion, +muOS, ArkOS and the others) opens the setup list instead, filtered to the +setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At +that question a drive is numbered by the folder its system reads (`ONION: +BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never +by its root, which no setup reads. Another system's card or SHARE drive, and a +drive two systems fit, are left out, and a line names them: each is reached +through its own setup. A folder typed is read from the current one when it is not a full path, and named in full after. `q` quits there too (a folder named `q` is typed `./q`), and Enter with no default says what to type. On a computer Batocera and Recalbox have no default: the drives are @@ -1360,12 +1470,20 @@ That keeps the installer's questions working when the script is piped from The manifest is fetched over the network and treated as untrusted input. Every path it carries goes through one validator that refuses an absolute path, a -drive letter, a backslash, a null byte, a doubled separator, a trailing slash -and any `..` component, so a destination cannot climb out of the BIOS -directory. Manifest, target list and payloads each have a size cap, the file +drive letter, a backslash, a null byte or any other control character, a +doubled separator, a trailing slash and any `..` component, so a destination +cannot climb out of the BIOS directory and no file is named with a terminal +command. Manifest, target list and payloads each have a size cap, the file list a count cap, and the sum of the declared sizes is checked against the total the manifest claims. +Every other string from outside is shown, never obeyed: the names a list or +a pack records, a card's label and a folder a configuration names reach the +terminal, line by line or on the screen, and the `--events` stream with a `?` +for each control character. A folder a configuration names with one is not +taken: the run says so and asks for `--dest`, its default being no answer +for a setup that reads another folder. + Symbolic links met under the BIOS root are followed on purpose: they are the user's own layout, and EmuDeck links `bios/shadps4/sys_modules` into shadPS4's data directory where the emulator actually reads it. The boundary is lexical, @@ -1419,7 +1537,7 @@ fields, the installer refuses to narrow rather than install everything. | `0` | everything asked for is installed or already up to date | | `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card | | `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup` | -| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question, or the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`) before an install. Run the same command again to carry on | +| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question, or the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`) before an install. A quit chosen (`q`, **Quit**) is code `130` too, and the terminal keeps `RetroBIOS: quit. Nothing was installed.` where a stop keeps `stopped`. Run the same command again to carry on | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | <!-- /generated: exit-codes --> @@ -1466,6 +1584,7 @@ other schemas under `api/v1/schemas/`. | `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | | `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | +| `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of | | `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` | | `system` | `platform`, `system`, `name`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed` | Each system, sorted by id, and with `--system` only the systems named. Files with no system count under `other`. `name` is the platform's own name for it | @@ -1475,7 +1594,7 @@ other schemas under `api/v1/schemas/`. | `data` | `platform`, `files`, `bytes` | Right after `declared`: the data-tree files to fetch (the `data_missing` of `check`) and the bytes they write once in place | | `found` | `platform`, `dest`, `folder`, `path`, `member`, `reason` | Each file to install that this machine already holds, after `check` and before anything is written, in `--check` and `--dry-run` too. `folder` is the `--from` folder as given, or the `bios_dir` of the check when the BIOS folder holds the file at another place, and then `reason` says where in words (`nested folder: ...` for a misplaced file, `the same file is at ...` for one the list installs there too). `path` is the file, and `member` the ZIP member holding the bytes when `path` is an archive, an empty string otherwise. Its copy is reported by a `downloaded` event | | `unreadable` | `platform`, `folder`, `path`, `member`, `reason` | Each file in a `--from` folder that could not be read and may be one the run still needs, right after the `found` events: its size is that of a file no copy was found for, or unknown (a folder that could not be listed, a ZIP that would not open). `member` names the ZIP member, or is empty. `reason` says why in words (`Permission denied`, `it is encrypted, a password is needed`) | -| `saves` | `platform`, `files`, `folder` | After `choices` and before `checking`, when the files the run installs (once `--system` and the other choices narrowed the list) hold some read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `<system>` and `<core>` stand for one folder per system or per core. When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) | +| `saves` | `platform`, `files`, `folder` | After `choices` and before `checking`, when the files the run installs (once `--system` and the other choices narrowed the list) hold some read from the frontend's save directory and that directory is known: how many, and the `folder` they go to, where `<system>` and `<core>` stand for one folder per system or per core (the screen and `--plain` say it in words: "a folder per core under /sdcard/RetroArch/saves"). When it is not known, the files are counted in the summary's `skipped` instead (`no_save_directory`) | | `download` | `platform`, `files`, `bytes` | Before the downloads start. Counts the files copied from this machine too | | `progress` | `files_done`, `files_total`, `bytes_done`, `bytes_total` | At most every half second while bytes arrive | | `transfer` | `bytes_local`, `bytes_network` | Right after each `progress` event, and once when the files of a `download` event are done. It splits `bytes_done`: `bytes_local` came from this machine (a copy found by `--from` or in the BIOS folder, the pack, a partial copy resumed), `bytes_network` from the network. A download speed is `bytes_network` over the time elapsed | @@ -1486,7 +1605,7 @@ other schemas under `api/v1/schemas/`. <!-- /generated: events --> -Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees), never a key of an +Schema 1 can gain a kind (`found` came with `--from`, `declared` with `--declared-only`, `unsearched` with the search that goes on past a closed folder, `app` with `--add-app`, `data` with the size of the data trees, `version` with the list's version), never a key of an existing kind: a reader skips a kind it does not know. An option refused before the run starts (`install.py: error:`, code 2) writes no event at all. A program reads a missing `end` as that case, or as a @@ -1510,19 +1629,20 @@ RetroArch folder that holds only `scph5501.bin`, in the order written: |----------|--------| | `RETROBIOS_REF` | Branch or tag the file list and the files committed to the repository are read from. Defaults to `main`. Files the manifest marks `release_asset` always come from the `large-files` release, whatever the ref, and that release replaces an asset in place when it is rebuilt. Each release deletes the previous release tags, so a tag holds a file list until the next release. For contributors: [release process](release-process.md#cutting-a-release) | | `RETROBIOS_BASE_URL` | Base URL for the manifest and the files it declares. HTTPS only, plain HTTP allowed to loopback for end-to-end tests. Whoever controls this base controls the expected hashes, hence the check. A file list that cannot be fetched names the address, and this variable when it is set | -| `RETROBIOS_INSTALL_OFFLINE` | Set to anything but `0`: no network request, as with `--offline`. The runs the full-screen interface starts inherit it. `RETROBIOS_OFFLINE` is the build scripts' own and leaves the installer alone | +| `RETROBIOS_INSTALL_OFFLINE` | Set to anything but `0`: no network request, as with `--offline`. The runs the full-screen interface starts inherit it. The words then name the variable (`not fetched (RETROBIOS_INSTALL_OFFLINE=1)`, `run it with RETROBIOS_INSTALL_OFFLINE unset`), and `--offline` where the option was given. `RETROBIOS_OFFLINE` is the build scripts' own and leaves the installer alone | | `RETROBIOS_OS` | Names the host outright (`linux`, `wsl`, `windows`, `darwin`, `android`) instead of detecting it | | `EXTERNAL_STORAGE` | Android only, the shared storage root RetroArch builds its default directories from. Defaults to `/storage/emulated/0` | | `RETROBIOS_INSTALL_URL` | Where the bootstrap fetches `install.py`. HTTPS only | | `RETROBIOS_INSTALL_SHA256` | The SHA-256 the bootstrap requires of that installer, 64 hex characters. When either of these two is set and the fingerprint does not match, the error names the variable instead of suggesting a retry | | `RETROBIOS_BOOTSTRAP` | Set by `install.sh` (`sh`) and `install.ps1` (`ps1`) for the temporary installer they run, so that its hints give back the one-line command rather than `python3 install.py` | -| `NO_COLOR` | Plain text, no colour, even at a terminal, when set to any value but an empty one. `TERM=dumb`, or no `TERM` outside Windows, also turns the live progress line and the colour off, as does a terminal whose description moves no cursor the ANSI way (`TERM=vt52`). The full screen draws no colour at all: the row in focus is marked with `>` as well as reverse video, and every state is written in words | +| `NO_COLOR` | Plain text, no colour, even at a terminal, when set to any value but an empty one. `TERM=dumb`, or no `TERM` outside Windows, also turns the live progress line and the colour off, as does a terminal whose description moves no cursor the ANSI way (`TERM=vt52`). A description naming fewer than 8 colours keeps the live line and drops the colour. The full screen then draws the same text without colour: the row in focus is marked with `>` as well as reverse video, and every state is written in words | | `TERM`, `TERMINFO`, `TERMINFO_DIRS` | Decide whether the full screen opens. `TERM` unset or `dumb` (outside Windows) gives the questions line by line. Otherwise the terminal's description is read where ncurses reads it: `TERMINFO`, then `~/.terminfo`, then each folder of `TERMINFO_DIRS`, then the system's own. A description whose cursor moves are not the ANSI ones the screen writes (`TERM=vt52`) gives the questions too, and `--tui` there stops with the reason | | `FORCE_COLOR`, `CLICOLOR_FORCE` | Colour even when the output is a pipe or a file, set to any value but `0` or an empty one. `NO_COLOR` wins over both, and the progress line stays one line per file | | `SSL_CERT_FILE`, `SSL_CERT_DIR` | The trusted certificates to check servers with, for a Python that finds none of its own | | `RETROBIOS_PLAIN`, `ACCESSIBLE` | Set to anything but `0`: the questions come line by line, as with `--plain`, instead of the full-screen interface | | `RETROBIOS_PAD` | The buttons a handheld launcher's mapper sends to the full screen (`gptokeyb`, `gptokeyb_muxterm`, `muxterm`, `evmapy`, `miyoo`), so its hints name them. See [On a handheld](#on-a-handheld-the-retrobios-port) | | `RETROBIOS_IDLE_EXIT` | Seconds the full screen waits for a first button, counted while nothing runs. With none pressed, it closes with exit status 130, or with the code of the error or result screen it closed on | +| `RETROBIOS_NO_MOUSE` | Set to anything but `0`: the full screen never asks the terminal for clicks, and the mouse selects text everywhere as it does outside the installer | | `HTTPS_PROXY`, `NO_PROXY` | Honoured for every download through the standard library's default proxy handling: one names the proxy, the other the hosts to reach directly | ## When something goes wrong diff --git a/wiki/profiling.md b/wiki/profiling.md index aacb9123f..2bc88aab1 100644 --- a/wiki/profiling.md +++ b/wiki/profiling.md @@ -278,7 +278,11 @@ core_classification: community_fork source: https://github.com/libretro/dolphin upstream: https://github.com/dolphin-emu/dolphin profiled_date: 2026-03-25 -core_version: 5.0-21264 +core_version: "2609-437-g3aac3f0" +version_basis: + kind: tag + tag: "2609" + distance: 437 systems: - nintendo-gamecube - nintendo-wii @@ -509,7 +513,8 @@ reports the profile as uncheckable, and the CI job that runs | `source` | no | libretro core repository URL. A dict when the core ships under several repos | | `upstream` | no | original emulator repository URL | | `profiled_date` | see above | date of source analysis. Quote it, or YAML parses it as a date object | -| `core_version` | no | version analyzed | +| `core_version` | every profile (`tests/test_version_basis.py`) | the version of the code at the pin, read where `version_basis` says. null when the code states none, never `Git`, `SVN` or a branch name. A dict keyed by build mode when the builds differ | +| `version_basis` | every profile (`tests/test_version_basis.py`) | where `core_version` is read: `file` (`path`, `pattern`, optional `format`), `tag` (the nearest `tag` in the pin's history and its `distance`, `core_version` in git describe's form), `alias` (`of`), `package` (`artifact`, the APK or binary read) or `none`. `repo` names `upstream` when the version is the upstream tree's. `profile_sync --verify-version` reads it again, and a pin advance rewrites `core_version` from it | | `source_commit` | see above | commit SHA of `source` the code was read at. Anchors every `source_ref` line number | | `upstream_commit` | no | same, for `upstream` | | `source_branch` | no | branch `source` was read from when it is not the default one (a port living on a pull request branch). profile_sync follows it | diff --git a/wiki/technical-faq.md b/wiki/technical-faq.md index c03bd9393..27a018bb0 100644 --- a/wiki/technical-faq.md +++ b/wiki/technical-faq.md @@ -124,38 +124,40 @@ per catalog and the full list of catalogued dumps still missing. Your game sets and BIOS sets must come from the same version as your arcade emulator. +<!-- generated: romsets --> | Emulator, as RetroArch names it | Sets from | |---|---| | MAME 2000 | MAME 0.37b5 | | MAME 2003, MAME 2003 Midway | MAME 0.78 | -| MAME 2003-Plus | MAME 0.78, with later fixes | +| MAME 2003-Plus | MAME 0.78 (plus backports) | | MAME 2009 | MAME 0.135u4 | | MAME 2010 | MAME 0.139 | -| MAME 2014, MAME 2015 | MAME 0.160 | +| MAME 2014, MAME 2015 | MAME 0.159 | | MAME 2016 | MAME 0.174 | -| MAME/MESS | MAME 0.287 | -| MAME, MAME/Arcade | MAME 0.289 | -| FB Alpha 2012 CPS-1, FB Alpha 2012 CPS-2 | FB Alpha 0.2.97.28 | -| FB Alpha 2012, FB Alpha 2012 CPS-3, FB Alpha 2012 Neo Geo | FB Alpha 0.2.97.29 | -| FB Alpha 2019 | FB Alpha 0.2.97.44 | -| FinalBurn Neo, FinalBurn Neo (Neo-Geo), FinalBurn Neo (CPS-1 / CPS-2) | FinalBurn Neo 1.0.0.2 | +| MAME, MAME/Arcade, MAME/MESS | MAME 0.289 | +| FB Alpha 2012 CPS-1, FB Alpha 2012 CPS-2 | FB Alpha v0.2.97.28 | +| FB Alpha 2012 Neo Geo | FB Alpha v0.2.97.29 | +| FB Alpha 2012, FB Alpha 2012 CPS-3 | FB Alpha v0.2.97.30 | +| FB Alpha 2019 | FB Alpha v0.2.97.44 | +| FinalBurn Neo, FinalBurn Neo (CPS-1 / CPS-2), FinalBurn Neo (Neo-Geo) | FinalBurn Neo v1.0.0.03 | +<!-- /generated: romsets --> Each emulator's page names its version too. ### How this is known Arcade BIOS sets are coupled to the romset version, so there is one -profile per MAME core generation: MAME 2000 (0.37b5), MAME 2003 (0.78), -MAME 2003-Plus, MAME 2009 (0.135u4), MAME 2010 (0.139), MAME 2015 -(0.160), MAME 2016 (0.174), and current MAME (0.289), each read from that -version's own source tree, because the BIOS root sets differ between -versions. Derivatives and ports carry their own profiles on top of those -generations: `mamearcade`, `mamemess`, `mame2003_midway`, the three -`mame4droid` drops, `advancemame`, `groovymame` and `hbmame`. Recalbox -compiles two MAME versions of its own, 0.258 and 0.278, profiled as -`mame0258` and `mame0278`. The pack -ships the sets matching the core you run, and a generic unversioned arcade -pack cannot do that. +profile per MAME core generation (MAME 2000, 2003, 2003-Plus, 2009, 2010, +2015, 2016 and current MAME), each read from that version's own source +tree, because the BIOS root sets differ between versions. The table is +written from the profiles, and each version is the one the core's source +states at the revision its profile was read at. Derivatives and ports +carry their own profiles on top of those generations: `mamearcade`, +`mamemess`, `mame2003_midway`, the three `mame4droid` drops, +`advancemame`, `groovymame` and `hbmame`. Recalbox compiles two MAME +versions of its own, 0.258 and 0.278, profiled as `mame0258` and +`mame0278`. The pack ships the sets matching the core you run, and a +generic unversioned arcade pack cannot do that. ## Why does my verification report say UNTESTED? diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 84c1bd8cf..fb000aaf2 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -61,7 +61,10 @@ Every module in `tests/`, by the area it guards. | Real-condition campaign | synthetic profiles, records and content folders, a fake host for load, disk, clock and commands, and whole passes against a libretro core built from C when a compiler and strace exist | `test_campaign` | | Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | -| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_version_basis`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_version_basis`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture diff --git a/wiki/tools.md b/wiki/tools.md index 08ae6443c..70ca1aa91 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -585,6 +585,21 @@ match no entry. Both land in the `--json` report (`new_files`, with since the pin or an undeclared hash makes the profile stale. `--tree-diff` shows added, removed and renamed files in the directories the refs point at. +`core_version` is the version of the code at the pin, and `version_basis` +says where it is read: a pattern in one file (`file`), the nearest tag and +the commits after it (`tag`, written in git describe's form), the profile an +alias follows (`alias`), an artefact read outside any source tree +(`package`), or `none` when the code states nothing, with `core_version` +null. `--verify-version` reads it again at the pin and exits 1 on a +difference or a missing basis. `--set-version` writes what the code states. +A pin advance reads the version at the new pin in the same write and is +refused when it cannot: a package version moves by hand. + +```bash +python scripts/profile_sync.py --all --verify-version --changed-only +python scripts/profile_sync.py --emulator snes9x --set-version +``` + Branch tips, tags and releases are cached for six hours. `--fresh` asks the forges again for them, which matters right after an upstream commit or a hand-set pin: a tip cached before the pin reads as behind it. It is refused