chore: merge main into the interface fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 18:32:43 +02:00
commit f4f95740dd
56 files changed
+1627 -118

No files matched your search

+16 -16
View File
@@ -77,12 +77,12 @@ These are the files an emulator loads from disk instead of carrying inside itsel
Each file is checked the way your platform checks it. Most compare a checksum, the fingerprint of a file's contents, which catches a corrupt or unexpected copy. RetroArch, Lakka and RetroPie check that the file is present under its name without reading its contents (`path_is_valid` in RetroArch's `core_info.c`), and MinUI, NextUI and MyMinUI hand each core its folder without opening a file in it: the Coverage table says which applies to you. Independently of that, the collection records five fingerprints per file, and wherever an emulator's code states an expected size or hash, that value is read from its source and rechecked here.
- **998 files** the platforms' emulators load are still to be found, and 138 more cannot be sourced at all (per-user keys, user-filled slots, dumps nobody has made). Both are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/)
- **995 files** the platforms' emulators load are still to be found, and 138 more cannot be sourced at all (per-user keys, user-filled slots, dumps nobody has made). Both are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/)
- **18 platforms** supported with platform-specific verification
- **625 emulators** profiled from source (RetroArch cores + standalone)
- **541 systems** handled by those emulators (NES, SNES, PlayStation, Saturn, Dreamcast, ...)
- **19,894 files in the collection**, each with its SHA1, MD5, SHA256, CRC32 and Adler-32 fingerprints: 5,692 system files, 2,974 arcade ROM sets, 11,228 game and engine data files. That is every platform and emulator together, so no pack holds them all: each carries what its own emulators load, counted in the download table
- **565 of 5,692 system files** matched to dump-preservation catalogs (No-Intro, Redump, TOSEC). Arcade sets and engine data fall outside what those catalogs index
- **19,910 files in the collection**, each with its SHA1, MD5, SHA256, CRC32 and Adler-32 fingerprints: 5,708 system files, 2,974 arcade ROM sets, 11,228 game and engine data files. That is every platform and emulator together, so no pack holds them all: each carries what its own emulators load, counted in the download table
- **565 of 5,708 system files** matched to dump-preservation catalogs (No-Intro, Redump, TOSEC). Arcade sets and engine data fall outside what those catalogs index
- **15.7 GB** for the whole collection
## Supported systems
@@ -95,23 +95,23 @@ Full list with per-file details: **[https://abdess.github.io/retrobios/](https:/
| Platform | On its BIOS list | Files its emulators load | Checked by |
|----------|-----------------:|-------------------------:|------------|
| AmberELEC | 414/434 | 4,742/4,879 | MD5 hash |
| Batocera | 353/353 | 14,146/15,271 | MD5 hash |
| AmberELEC | 414/434 | 4,743/4,879 | MD5 hash |
| Batocera | 353/353 | 14,147/15,271 | MD5 hash |
| BizHawk | 118/118 | - | SHA1 hash |
| EmuDeck | 168/168 | 8,365/8,469 | MD5 hash |
| EmuELEC | 544/572 | 4,787/10,170 | MD5 hash |
| KNULLI | 290/290 | 9,103/15,332 | MD5 hash |
| Lakka | 532/532 | 7,853/7,939 | file presence |
| EmuDeck | 168/168 | 8,381/8,482 | MD5 hash |
| EmuELEC | 544/572 | 4,788/10,170 | MD5 hash |
| KNULLI | 290/290 | 9,104/15,332 | MD5 hash |
| Lakka | 532/532 | 7,868/7,952 | file presence |
| MiSTer FPGA | 81/81 | - | MD5 hash |
| MinUI | 0/0 | 78/79 | file presence |
| MyMinUI | 0/0 | 354/363 | file presence |
| NextUI | 0/0 | 343/349 | file presence |
| ROCKNIX | 38/38 | 7,273/8,620 | MD5 hash |
| Recalbox | 351/351 | 3,117/3,337 | MD5 hash |
| RetroArch | 526/526 | 7,862/7,930 | file presence |
| RetroBat | 348/348 | 5,099/9,625 | MD5 hash |
| RetroDECK | 2,007/2,008 | 6,640/6,929 | MD5 hash |
| RetroPie * | 532/532 | 8,054/12,716 | file presence |
| ROCKNIX | 38/38 | 7,289/8,633 | MD5 hash |
| Recalbox | 351/351 | 3,119/3,337 | MD5 hash |
| RetroArch | 526/526 | 7,877/7,943 | file presence |
| RetroBat | 348/348 | 5,116/9,638 | MD5 hash |
| RetroDECK | 2,007/2,008 | 6,656/6,942 | MD5 hash |
| RetroPie * | 532/532 | 8,069/12,729 | file presence |
| RomM | 383/383 | 83/873 | MD5 hash |
Each fraction is what the pack has over what is needed, counting required and optional files alike since both ship. The first column is the BIOS list the platform publishes. The second counts files its emulators load that this list never mentions, found by reading their source code, and it is routinely several times larger. A short fraction means files are still missing, and they are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/).
@@ -199,4 +199,4 @@ The scripts and tooling are released under the [MIT License](LICENSE).
The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed.
The reasoning, and where it is weakest, is in [the project's legal position](https://abdess.github.io/retrobios/wiki/legal/).
*Auto-generated on 2026-10-23T15:16:48Z*
*Auto-generated on 2026-10-23T15:59:33Z*
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+470 -6
View File
@@ -1,8 +1,8 @@
{
"schema_version": 1,
"generated_at": "2026-10-23T14:55:50Z",
"total_files": 19894,
"total_size": 16847486057,
"generated_at": "2026-10-23T15:57:31Z",
"total_files": 19910,
"total_size": 16891869309,
"bios_dir": "bios",
"files": {
"738c7f5fd0c8b57ee2e87ae7a97bf8e21a821d07": {
@@ -131176,6 +131176,166 @@
"crc32": "150b160b",
"adler32": "21702f6f"
},
"543ff840043256e648ca5b9990f41b48e0136599": {
"path": "bios/Microsoft/Xbox 360/Content/0000000000000000/FFFE07D2/000B0000/tu20075c00_00000000",
"name": "tu20075c00_00000000",
"size": 5529600,
"sha1": "543ff840043256e648ca5b9990f41b48e0136599",
"md5": "21e0cf5cd195cd19ca8d41663297d251",
"sha256": "6aabcdb33fb401f7ac0c252281fdb8cdca2b8e44689c2df3f40e1f9d2941d773",
"crc32": "1e896af6",
"adler32": "038fd3f6"
},
"4cc817432b81b7bd3b9f782b678bd69e3d4d5715": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/dash/fonts/Xbox Book.xtf",
"name": "Xbox Book.xtf",
"size": 14440996,
"sha1": "4cc817432b81b7bd3b9f782b678bd69e3d4d5715",
"md5": "8f5b7cee650f389f1c5c7bb5f70386bb",
"sha256": "dea8f97d13668de5bca3ccf8a318d727ce29422c2da898cbc4e9acfd13f05cbe",
"crc32": "c895c21a",
"adler32": "8096a019"
},
"df0cadf1a4a080992ef0459c413a841bc3906cc7": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/dash/fonts/xbox.xtf",
"name": "xbox.xtf",
"size": 19124720,
"sha1": "df0cadf1a4a080992ef0459c413a841bc3906cc7",
"md5": "de4a26c39de8001b95c749f8d3d22128",
"sha256": "531f7ad06cac7177ffd679ec797046d68b2af46204e9b9562d381380690ee108",
"crc32": "513df3cd",
"adler32": "71518986"
},
"1c577b7495d3d119f5a584b328e837901c537a28": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xbox.xex",
"name": "xbox.xex",
"size": 135168,
"sha1": "1c577b7495d3d119f5a584b328e837901c537a28",
"md5": "1a4d640b5780ffd021a40355c0496117",
"sha256": "c73c48c7eec019d77ede18fa0b7ca8e012086662c2886f4dcbc98aa0dd57da0c",
"crc32": "4037d08b",
"adler32": "f8c5b168"
},
"a97d65c4860dede8a2f58c9c9bbb68270cdd3a64": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefu.xex",
"name": "xefu.xex",
"size": 552960,
"sha1": "a97d65c4860dede8a2f58c9c9bbb68270cdd3a64",
"md5": "f04a57a9f51ed79be301e0e808d5280b",
"sha256": "9203180a53f79abfc44df2f5f9132d0b9d1c4df3d6c1ebbaf0382191d4eed535",
"crc32": "19e8d76e",
"adler32": "faf8d370"
},
"c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefu1_1.xex",
"name": "xefu1_1.xex",
"size": 643072,
"sha1": "c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc",
"md5": "6ab77df6f5df3193a8850007483b1ad9",
"sha256": "149a8e4bf19e089f14bf020b0bad8e6cea70f198e8a21ac6535e5b46d481fa1c",
"crc32": "ebcf5726",
"adler32": "22f95ce9"
},
"e6b4df2610a0c230addf0ea5e5319ce015ef2c09": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefu2.xex",
"name": "xefu2.xex",
"size": 647168,
"sha1": "e6b4df2610a0c230addf0ea5e5319ce015ef2c09",
"md5": "c6c6925d4f59c5d273351c681f04e109",
"sha256": "51f13581929f2bd75f5e5a89daf40e1a883d027b8a5ce9f2deab93b560d81db9",
"crc32": "b6e72128",
"adler32": "aa43fd60"
},
"932822d302bb96d3459e4ff9a5ee482f80a17e17": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefu3.xex",
"name": "xefu3.xex",
"size": 651264,
"sha1": "932822d302bb96d3459e4ff9a5ee482f80a17e17",
"md5": "c7e15e4ff93059d01fbb9b38915d73f1",
"sha256": "0d2c1fe35d98e37f94d24eec4d84296d098e80d762279b8b704669f549897bb0",
"crc32": "e5345b1b",
"adler32": "535d3551"
},
"cad2569c3cd08edf82991237c628abe54fc7bb95": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefu5.xex",
"name": "xefu5.xex",
"size": 638976,
"sha1": "cad2569c3cd08edf82991237c628abe54fc7bb95",
"md5": "e67931ca0ee624e2e75db28f4050a5d4",
"sha256": "020c211f47fc41015e566d19050e6a28f9dd91f55f1e1086345fc3dd0cd7d4ad",
"crc32": "4ecc054e",
"adler32": "938bfd98"
},
"7154a87eca9ce2e353048e27aa7adebbbe19ece2": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefu6.xex",
"name": "xefu6.xex",
"size": 651264,
"sha1": "7154a87eca9ce2e353048e27aa7adebbbe19ece2",
"md5": "9b3f026d334db47fce8c9f18debc9c9c",
"sha256": "c87b42dfbd0af7d3c8ed76af6a5de870ea7cf211121a2b96b6004442d3b8407e",
"crc32": "51581552",
"adler32": "41443ee8"
},
"d46cb956a02912f7d21cf305f9183fa4fde13d34": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefu7.xex",
"name": "xefu7.xex",
"size": 663552,
"sha1": "d46cb956a02912f7d21cf305f9183fa4fde13d34",
"md5": "c6c982725de3cfea7e0e14fdebce04be",
"sha256": "a9307b3b4d82c7b1be1c2f56eba1fea265d6e7fdb1864b15bf731f0f334d0867",
"crc32": "8e157a2a",
"adler32": "ae6a461b"
},
"36b47c464df424ad01eb6bf43a7d2bc172ad04f9": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefu7b.xex",
"name": "xefu7b.xex",
"size": 659456,
"sha1": "36b47c464df424ad01eb6bf43a7d2bc172ad04f9",
"md5": "677d6d265f47df3e66bcd87f1d1ac54f",
"sha256": "38dd42b52b2e55aae1838878754344fd4c92c22f2860fedbae34e7a469aa4e65",
"crc32": "4b175c12",
"adler32": "d5cfcd9b"
},
"e5a22d00560ba57e5d04fe5e6e47fcadf8820b40": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefutitle5.xex",
"name": "xefutitle5.xex",
"size": 8192,
"sha1": "e5a22d00560ba57e5d04fe5e6e47fcadf8820b40",
"md5": "c72ab90ffa3a422b5a22277fd3524ff2",
"sha256": "b8c522c0bd831e3b26d71a3bee449d2e4d01802900f13f798f6ddf72351071f2",
"crc32": "97e3b542",
"adler32": "2c1d7522"
},
"827e41c0e98ae9d4a9da2680968e2dd19786ccbe": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefutitle6.xex",
"name": "xefutitle6.xex",
"size": 12288,
"sha1": "827e41c0e98ae9d4a9da2680968e2dd19786ccbe",
"md5": "210305ca547d51e541a28a10ab5c4a67",
"sha256": "3cdfed905c0e0b56623b070265b9a03d2f7ef9da1b4bf1a66dad47fa8a8b3aef",
"crc32": "034b642d",
"adler32": "3f187da6"
},
"0249630d6986838ff61e5a47ef25a75eedf7953c": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefutitle7.xex",
"name": "xefutitle7.xex",
"size": 12288,
"sha1": "0249630d6986838ff61e5a47ef25a75eedf7953c",
"md5": "22147a97335aa6938246e2e1faab7e1c",
"sha256": "59cbdfcce8369cf8574c9986d9306738653c025806517ec56620693e0ff85848",
"crc32": "dca82de5",
"adler32": "3564449e"
},
"c1d5e5ef33adc0595bddf6bb67d7fee44af942f7": {
"path": "bios/Microsoft/Xbox 360/Xenia-Edge/xefu/xefutitle7b.xex",
"name": "xefutitle7b.xex",
"size": 12288,
"sha1": "c1d5e5ef33adc0595bddf6bb67d7fee44af942f7",
"md5": "5e72f083951577216508ae145577d8f8",
"sha256": "744522723509b4dd1ee14441863f449c5e049cec124b86e8a25a702ccebfa6b0",
"crc32": "0b54a8e0",
"adler32": "a7ef8037"
},
"5cd9d0f008a380bb5cddd0a32bcbedf09500ab57": {
"path": "bios/Monolith/Blood/BLOOD.RFF",
"name": "BLOOD.RFF",
@@ -217129,6 +217289,22 @@
"c5907d241de37c22b083b6e30fa934b0": "b9489e883c650b5e5fe2f83a32237dbf74f0e9f1",
"3e647719d47e4a3992fc8bc5a7e56fce": "ff3e7eaf715fe5612e46fc984d686ed3b115baef",
"cf15087fd5fd7593e293faaa7281cff8": "9da5f9ecfb1c9c32efa616f0300d02e8f702244d",
"21e0cf5cd195cd19ca8d41663297d251": "543ff840043256e648ca5b9990f41b48e0136599",
"8f5b7cee650f389f1c5c7bb5f70386bb": "4cc817432b81b7bd3b9f782b678bd69e3d4d5715",
"de4a26c39de8001b95c749f8d3d22128": "df0cadf1a4a080992ef0459c413a841bc3906cc7",
"1a4d640b5780ffd021a40355c0496117": "1c577b7495d3d119f5a584b328e837901c537a28",
"f04a57a9f51ed79be301e0e808d5280b": "a97d65c4860dede8a2f58c9c9bbb68270cdd3a64",
"6ab77df6f5df3193a8850007483b1ad9": "c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc",
"c6c6925d4f59c5d273351c681f04e109": "e6b4df2610a0c230addf0ea5e5319ce015ef2c09",
"c7e15e4ff93059d01fbb9b38915d73f1": "932822d302bb96d3459e4ff9a5ee482f80a17e17",
"e67931ca0ee624e2e75db28f4050a5d4": "cad2569c3cd08edf82991237c628abe54fc7bb95",
"9b3f026d334db47fce8c9f18debc9c9c": "7154a87eca9ce2e353048e27aa7adebbbe19ece2",
"c6c982725de3cfea7e0e14fdebce04be": "d46cb956a02912f7d21cf305f9183fa4fde13d34",
"677d6d265f47df3e66bcd87f1d1ac54f": "36b47c464df424ad01eb6bf43a7d2bc172ad04f9",
"c72ab90ffa3a422b5a22277fd3524ff2": "e5a22d00560ba57e5d04fe5e6e47fcadf8820b40",
"210305ca547d51e541a28a10ab5c4a67": "827e41c0e98ae9d4a9da2680968e2dd19786ccbe",
"22147a97335aa6938246e2e1faab7e1c": "0249630d6986838ff61e5a47ef25a75eedf7953c",
"5e72f083951577216508ae145577d8f8": "c1d5e5ef33adc0595bddf6bb67d7fee44af942f7",
"52b1f25a1bd4606967f21c60f1444a97": "5cd9d0f008a380bb5cddd0a32bcbedf09500ab57",
"7f95f0a4ca6b7edd9ea3c0608e957a2f": "3a9f77e2fa80a7871ad6639e795f547e065b13c4",
"24e1f88cc15f8bbeb10501786d2b0965": "3cdc5973be764387cd0b4826d73530fc5ed462e7",
@@ -257812,6 +257988,55 @@
"xbox_eeprom.bin": [
"ff3e7eaf715fe5612e46fc984d686ed3b115baef"
],
"tu20075c00_00000000": [
"543ff840043256e648ca5b9990f41b48e0136599"
],
"Xbox Book.xtf": [
"4cc817432b81b7bd3b9f782b678bd69e3d4d5715"
],
"xbox.xtf": [
"df0cadf1a4a080992ef0459c413a841bc3906cc7",
"f957c9aedd8ea478dafd98babe41faec7f339d8a"
],
"xbox.xex": [
"1c577b7495d3d119f5a584b328e837901c537a28"
],
"xefu.xex": [
"a97d65c4860dede8a2f58c9c9bbb68270cdd3a64"
],
"xefu1_1.xex": [
"c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc"
],
"xefu2.xex": [
"e6b4df2610a0c230addf0ea5e5319ce015ef2c09"
],
"xefu3.xex": [
"932822d302bb96d3459e4ff9a5ee482f80a17e17"
],
"xefu5.xex": [
"cad2569c3cd08edf82991237c628abe54fc7bb95"
],
"xefu6.xex": [
"7154a87eca9ce2e353048e27aa7adebbbe19ece2"
],
"xefu7.xex": [
"d46cb956a02912f7d21cf305f9183fa4fde13d34"
],
"xefu7b.xex": [
"36b47c464df424ad01eb6bf43a7d2bc172ad04f9"
],
"xefutitle5.xex": [
"e5a22d00560ba57e5d04fe5e6e47fcadf8820b40"
],
"xefutitle6.xex": [
"827e41c0e98ae9d4a9da2680968e2dd19786ccbe"
],
"xefutitle7.xex": [
"0249630d6986838ff61e5a47ef25a75eedf7953c"
],
"xefutitle7b.xex": [
"c1d5e5ef33adc0595bddf6bb67d7fee44af942f7"
],
"BLOOD.RFF": [
"5cd9d0f008a380bb5cddd0a32bcbedf09500ab57"
],
@@ -260257,9 +260482,6 @@
"letgothl.ttf": [
"328b2ec7402df86add5c9cd68692c126b7e79707"
],
"xbox.xtf": [
"f957c9aedd8ea478dafd98babe41faec7f339d8a"
],
"controller_map.txt": [
"231b29ef9d6314930eb18e57240e27ae81a6e88f"
],
@@ -294835,6 +295057,22 @@
"5be2413d": "b9489e883c650b5e5fe2f83a32237dbf74f0e9f1",
"151aca4c": "ff3e7eaf715fe5612e46fc984d686ed3b115baef",
"150b160b": "9da5f9ecfb1c9c32efa616f0300d02e8f702244d",
"1e896af6": "543ff840043256e648ca5b9990f41b48e0136599",
"c895c21a": "4cc817432b81b7bd3b9f782b678bd69e3d4d5715",
"513df3cd": "df0cadf1a4a080992ef0459c413a841bc3906cc7",
"4037d08b": "1c577b7495d3d119f5a584b328e837901c537a28",
"19e8d76e": "a97d65c4860dede8a2f58c9c9bbb68270cdd3a64",
"ebcf5726": "c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc",
"b6e72128": "e6b4df2610a0c230addf0ea5e5319ce015ef2c09",
"e5345b1b": "932822d302bb96d3459e4ff9a5ee482f80a17e17",
"4ecc054e": "cad2569c3cd08edf82991237c628abe54fc7bb95",
"51581552": "7154a87eca9ce2e353048e27aa7adebbbe19ece2",
"8e157a2a": "d46cb956a02912f7d21cf305f9183fa4fde13d34",
"4b175c12": "36b47c464df424ad01eb6bf43a7d2bc172ad04f9",
"97e3b542": "e5a22d00560ba57e5d04fe5e6e47fcadf8820b40",
"034b642d": "827e41c0e98ae9d4a9da2680968e2dd19786ccbe",
"dca82de5": "0249630d6986838ff61e5a47ef25a75eedf7953c",
"0b54a8e0": "c1d5e5ef33adc0595bddf6bb67d7fee44af942f7",
"2a937785": "5cd9d0f008a380bb5cddd0a32bcbedf09500ab57",
"112343d3": "3a9f77e2fa80a7871ad6639e795f547e065b13c4",
"0bfbb75d": "3cdc5973be764387cd0b4826d73530fc5ed462e7",
@@ -314730,6 +314968,22 @@
"148d279bdaa85f54f823722d55ea50eece78fc8a537e7d1efeea653b5201481f": "b9489e883c650b5e5fe2f83a32237dbf74f0e9f1",
"e652203e6d91140729c29588b18ff8838896cdeb9a3100fbf8d20763af27c9d2": "ff3e7eaf715fe5612e46fc984d686ed3b115baef",
"c5ec3e0fbc4f31d36c13211ded7f070f3b5f04e99a367fbe5d8c2d8c6e4f925d": "9da5f9ecfb1c9c32efa616f0300d02e8f702244d",
"6aabcdb33fb401f7ac0c252281fdb8cdca2b8e44689c2df3f40e1f9d2941d773": "543ff840043256e648ca5b9990f41b48e0136599",
"dea8f97d13668de5bca3ccf8a318d727ce29422c2da898cbc4e9acfd13f05cbe": "4cc817432b81b7bd3b9f782b678bd69e3d4d5715",
"531f7ad06cac7177ffd679ec797046d68b2af46204e9b9562d381380690ee108": "df0cadf1a4a080992ef0459c413a841bc3906cc7",
"c73c48c7eec019d77ede18fa0b7ca8e012086662c2886f4dcbc98aa0dd57da0c": "1c577b7495d3d119f5a584b328e837901c537a28",
"9203180a53f79abfc44df2f5f9132d0b9d1c4df3d6c1ebbaf0382191d4eed535": "a97d65c4860dede8a2f58c9c9bbb68270cdd3a64",
"149a8e4bf19e089f14bf020b0bad8e6cea70f198e8a21ac6535e5b46d481fa1c": "c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc",
"51f13581929f2bd75f5e5a89daf40e1a883d027b8a5ce9f2deab93b560d81db9": "e6b4df2610a0c230addf0ea5e5319ce015ef2c09",
"0d2c1fe35d98e37f94d24eec4d84296d098e80d762279b8b704669f549897bb0": "932822d302bb96d3459e4ff9a5ee482f80a17e17",
"020c211f47fc41015e566d19050e6a28f9dd91f55f1e1086345fc3dd0cd7d4ad": "cad2569c3cd08edf82991237c628abe54fc7bb95",
"c87b42dfbd0af7d3c8ed76af6a5de870ea7cf211121a2b96b6004442d3b8407e": "7154a87eca9ce2e353048e27aa7adebbbe19ece2",
"a9307b3b4d82c7b1be1c2f56eba1fea265d6e7fdb1864b15bf731f0f334d0867": "d46cb956a02912f7d21cf305f9183fa4fde13d34",
"38dd42b52b2e55aae1838878754344fd4c92c22f2860fedbae34e7a469aa4e65": "36b47c464df424ad01eb6bf43a7d2bc172ad04f9",
"b8c522c0bd831e3b26d71a3bee449d2e4d01802900f13f798f6ddf72351071f2": "e5a22d00560ba57e5d04fe5e6e47fcadf8820b40",
"3cdfed905c0e0b56623b070265b9a03d2f7ef9da1b4bf1a66dad47fa8a8b3aef": "827e41c0e98ae9d4a9da2680968e2dd19786ccbe",
"59cbdfcce8369cf8574c9986d9306738653c025806517ec56620693e0ff85848": "0249630d6986838ff61e5a47ef25a75eedf7953c",
"744522723509b4dd1ee14441863f449c5e049cec124b86e8a25a702ccebfa6b0": "c1d5e5ef33adc0595bddf6bb67d7fee44af942f7",
"a5f46ec0a2dc8472195de3364f22681cada2a65d8eaebf645021cf6cab8c1e32": "5cd9d0f008a380bb5cddd0a32bcbedf09500ab57",
"57f24594f528bbfc34350075fb1f6a5373d5b31835e5f04deb6b2110f14df46d": "3a9f77e2fa80a7871ad6639e795f547e065b13c4",
"2745f8043d4bb4a06ea104ffa89eee21b878114f318d7fe8552bfc9c1df2b721": "3cdc5973be764387cd0b4826d73530fc5ed462e7",
@@ -482979,6 +483233,216 @@
"Xbox/xbox_hdd.qcow2": [
"9da5f9ecfb1c9c32efa616f0300d02e8f702244d"
],
"Microsoft/Xbox 360/Content/0000000000000000/FFFE07D2/000B0000/tu20075c00_00000000": [
"543ff840043256e648ca5b9990f41b48e0136599"
],
"Xbox 360/Content/0000000000000000/FFFE07D2/000B0000/tu20075c00_00000000": [
"543ff840043256e648ca5b9990f41b48e0136599"
],
"Content/0000000000000000/FFFE07D2/000B0000/tu20075c00_00000000": [
"543ff840043256e648ca5b9990f41b48e0136599"
],
"0000000000000000/FFFE07D2/000B0000/tu20075c00_00000000": [
"543ff840043256e648ca5b9990f41b48e0136599"
],
"FFFE07D2/000B0000/tu20075c00_00000000": [
"543ff840043256e648ca5b9990f41b48e0136599"
],
"000B0000/tu20075c00_00000000": [
"543ff840043256e648ca5b9990f41b48e0136599"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/dash/fonts/Xbox Book.xtf": [
"4cc817432b81b7bd3b9f782b678bd69e3d4d5715"
],
"Xbox 360/Xenia-Edge/xefu/dash/fonts/Xbox Book.xtf": [
"4cc817432b81b7bd3b9f782b678bd69e3d4d5715"
],
"Xenia-Edge/xefu/dash/fonts/Xbox Book.xtf": [
"4cc817432b81b7bd3b9f782b678bd69e3d4d5715"
],
"xefu/dash/fonts/Xbox Book.xtf": [
"4cc817432b81b7bd3b9f782b678bd69e3d4d5715"
],
"dash/fonts/Xbox Book.xtf": [
"4cc817432b81b7bd3b9f782b678bd69e3d4d5715"
],
"fonts/Xbox Book.xtf": [
"4cc817432b81b7bd3b9f782b678bd69e3d4d5715"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/dash/fonts/xbox.xtf": [
"df0cadf1a4a080992ef0459c413a841bc3906cc7"
],
"Xbox 360/Xenia-Edge/xefu/dash/fonts/xbox.xtf": [
"df0cadf1a4a080992ef0459c413a841bc3906cc7"
],
"Xenia-Edge/xefu/dash/fonts/xbox.xtf": [
"df0cadf1a4a080992ef0459c413a841bc3906cc7"
],
"xefu/dash/fonts/xbox.xtf": [
"df0cadf1a4a080992ef0459c413a841bc3906cc7"
],
"dash/fonts/xbox.xtf": [
"df0cadf1a4a080992ef0459c413a841bc3906cc7"
],
"fonts/xbox.xtf": [
"df0cadf1a4a080992ef0459c413a841bc3906cc7"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xbox.xex": [
"1c577b7495d3d119f5a584b328e837901c537a28"
],
"Xbox 360/Xenia-Edge/xefu/xbox.xex": [
"1c577b7495d3d119f5a584b328e837901c537a28"
],
"Xenia-Edge/xefu/xbox.xex": [
"1c577b7495d3d119f5a584b328e837901c537a28"
],
"xefu/xbox.xex": [
"1c577b7495d3d119f5a584b328e837901c537a28"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefu.xex": [
"a97d65c4860dede8a2f58c9c9bbb68270cdd3a64"
],
"Xbox 360/Xenia-Edge/xefu/xefu.xex": [
"a97d65c4860dede8a2f58c9c9bbb68270cdd3a64"
],
"Xenia-Edge/xefu/xefu.xex": [
"a97d65c4860dede8a2f58c9c9bbb68270cdd3a64"
],
"xefu/xefu.xex": [
"a97d65c4860dede8a2f58c9c9bbb68270cdd3a64"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefu1_1.xex": [
"c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc"
],
"Xbox 360/Xenia-Edge/xefu/xefu1_1.xex": [
"c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc"
],
"Xenia-Edge/xefu/xefu1_1.xex": [
"c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc"
],
"xefu/xefu1_1.xex": [
"c39e1de54c7a2a4d3105a6bdd8a2b281e5b637fc"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefu2.xex": [
"e6b4df2610a0c230addf0ea5e5319ce015ef2c09"
],
"Xbox 360/Xenia-Edge/xefu/xefu2.xex": [
"e6b4df2610a0c230addf0ea5e5319ce015ef2c09"
],
"Xenia-Edge/xefu/xefu2.xex": [
"e6b4df2610a0c230addf0ea5e5319ce015ef2c09"
],
"xefu/xefu2.xex": [
"e6b4df2610a0c230addf0ea5e5319ce015ef2c09"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefu3.xex": [
"932822d302bb96d3459e4ff9a5ee482f80a17e17"
],
"Xbox 360/Xenia-Edge/xefu/xefu3.xex": [
"932822d302bb96d3459e4ff9a5ee482f80a17e17"
],
"Xenia-Edge/xefu/xefu3.xex": [
"932822d302bb96d3459e4ff9a5ee482f80a17e17"
],
"xefu/xefu3.xex": [
"932822d302bb96d3459e4ff9a5ee482f80a17e17"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefu5.xex": [
"cad2569c3cd08edf82991237c628abe54fc7bb95"
],
"Xbox 360/Xenia-Edge/xefu/xefu5.xex": [
"cad2569c3cd08edf82991237c628abe54fc7bb95"
],
"Xenia-Edge/xefu/xefu5.xex": [
"cad2569c3cd08edf82991237c628abe54fc7bb95"
],
"xefu/xefu5.xex": [
"cad2569c3cd08edf82991237c628abe54fc7bb95"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefu6.xex": [
"7154a87eca9ce2e353048e27aa7adebbbe19ece2"
],
"Xbox 360/Xenia-Edge/xefu/xefu6.xex": [
"7154a87eca9ce2e353048e27aa7adebbbe19ece2"
],
"Xenia-Edge/xefu/xefu6.xex": [
"7154a87eca9ce2e353048e27aa7adebbbe19ece2"
],
"xefu/xefu6.xex": [
"7154a87eca9ce2e353048e27aa7adebbbe19ece2"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefu7.xex": [
"d46cb956a02912f7d21cf305f9183fa4fde13d34"
],
"Xbox 360/Xenia-Edge/xefu/xefu7.xex": [
"d46cb956a02912f7d21cf305f9183fa4fde13d34"
],
"Xenia-Edge/xefu/xefu7.xex": [
"d46cb956a02912f7d21cf305f9183fa4fde13d34"
],
"xefu/xefu7.xex": [
"d46cb956a02912f7d21cf305f9183fa4fde13d34"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefu7b.xex": [
"36b47c464df424ad01eb6bf43a7d2bc172ad04f9"
],
"Xbox 360/Xenia-Edge/xefu/xefu7b.xex": [
"36b47c464df424ad01eb6bf43a7d2bc172ad04f9"
],
"Xenia-Edge/xefu/xefu7b.xex": [
"36b47c464df424ad01eb6bf43a7d2bc172ad04f9"
],
"xefu/xefu7b.xex": [
"36b47c464df424ad01eb6bf43a7d2bc172ad04f9"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefutitle5.xex": [
"e5a22d00560ba57e5d04fe5e6e47fcadf8820b40"
],
"Xbox 360/Xenia-Edge/xefu/xefutitle5.xex": [
"e5a22d00560ba57e5d04fe5e6e47fcadf8820b40"
],
"Xenia-Edge/xefu/xefutitle5.xex": [
"e5a22d00560ba57e5d04fe5e6e47fcadf8820b40"
],
"xefu/xefutitle5.xex": [
"e5a22d00560ba57e5d04fe5e6e47fcadf8820b40"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefutitle6.xex": [
"827e41c0e98ae9d4a9da2680968e2dd19786ccbe"
],
"Xbox 360/Xenia-Edge/xefu/xefutitle6.xex": [
"827e41c0e98ae9d4a9da2680968e2dd19786ccbe"
],
"Xenia-Edge/xefu/xefutitle6.xex": [
"827e41c0e98ae9d4a9da2680968e2dd19786ccbe"
],
"xefu/xefutitle6.xex": [
"827e41c0e98ae9d4a9da2680968e2dd19786ccbe"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefutitle7.xex": [
"0249630d6986838ff61e5a47ef25a75eedf7953c"
],
"Xbox 360/Xenia-Edge/xefu/xefutitle7.xex": [
"0249630d6986838ff61e5a47ef25a75eedf7953c"
],
"Xenia-Edge/xefu/xefutitle7.xex": [
"0249630d6986838ff61e5a47ef25a75eedf7953c"
],
"xefu/xefutitle7.xex": [
"0249630d6986838ff61e5a47ef25a75eedf7953c"
],
"Microsoft/Xbox 360/Xenia-Edge/xefu/xefutitle7b.xex": [
"c1d5e5ef33adc0595bddf6bb67d7fee44af942f7"
],
"Xbox 360/Xenia-Edge/xefu/xefutitle7b.xex": [
"c1d5e5ef33adc0595bddf6bb67d7fee44af942f7"
],
"Xenia-Edge/xefu/xefutitle7b.xex": [
"c1d5e5ef33adc0595bddf6bb67d7fee44af942f7"
],
"xefu/xefutitle7b.xex": [
"c1d5e5ef33adc0595bddf6bb67d7fee44af942f7"
],
"Monolith/Blood/BLOOD.RFF": [
"5cd9d0f008a380bb5cddd0a32bcbedf09500ab57"
],
+1
View File
@@ -7,6 +7,7 @@ upstream: "https://github.com/RPCS3/rpcs3"
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"
display_name: "Sony - PlayStation 3 (aPS3e)"
cores:
+1
View File
@@ -7,6 +7,7 @@ upstream: "https://github.com/xenia-canary/xenia-canary"
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"
display_name: "Microsoft - Xbox 360 (aX360e)"
cores:
+1
View File
@@ -5,6 +5,7 @@ source: "https://github.com/libretro/boom3"
upstream: "https://github.com/dhewm/dhewm3"
profiled_date: "2026-03-23"
source_commit: "efe140ce8fdf8793f5a6fbba062fb34df7f55a81"
upstream_commit: "455b88e8dff2be822f08eb498f51b383e851fa38"
core_version: "git"
display_name: "Doom 3 (boom3)"
cores: [boom3, boom3_xp]
+1
View File
@@ -5,6 +5,7 @@ source: "https://github.com/libretro/bsnes-jg"
upstream: "https://gitlab.com/jgemu/bsnes"
profiled_date: "2026-03-23"
source_commit: "4aef191fef72be539ccc07ada21c6e0a65360eb7"
upstream_commit: "53e6a7c2ec4b4220adc2f8dcb3786489bd04fe0e"
core_version: "2.1.2"
display_name: "Nintendo - SNES / SFC (bsnes-jg)"
cores: [bsnes-jg, bsnes_jg]
+1 -1
View File
@@ -5,7 +5,7 @@ source: "https://github.com/mittonk/dice-libretro"
upstream: "https://sourceforge.net/projects/dice/"
profiled_date: "2026-03-23"
source_commit: "4b4596b8dd2252b9d08bd6aaa02afda4be834db3"
core_version: "v0.4.2"
core_version: "v0.4.3"
display_name: "Arcade (DICE)"
cores:
- dice
+1 -1
View File
@@ -5,7 +5,7 @@ source: "https://github.com/nachoverdon/Dolphin-MMJR2"
upstream: "https://github.com/dolphin-emu/dolphin"
profiled_date: "2026-08-11"
source_commit: "4d0fdb8eea616320e967408cf5547c72a95196f3"
upstream_commit: "4ff214cdd26f135251d2266d676ed72dc1c2f8c9"
upstream_commit: "e1d35f666cc35f6ead55d32ea2679df44d04da72"
core_version: "2.0-18025"
display_name: "Nintendo - GameCube / Wii (Dolphin MMJR2)"
cores:
+1
View File
@@ -8,6 +8,7 @@ profiled_date: "2026-10-04"
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"
display_name: "Sony - PlayStation 2 (EmuCoreX)"
cores:
+1
View File
@@ -6,6 +6,7 @@ upstream: "https://github.com/flyinghead/flycast"
source_branch: "dojo"
source_commit: "745815c929fca4a893be28c99351b44d984b6642"
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-23"
core_version: "dojo-7.8"
+1 -1
View File
@@ -5,7 +5,7 @@ source: "https://github.com/drhelius/Geargrafx"
upstream: "https://github.com/drhelius/Geargrafx"
profiled_date: "2026-10-04"
source_commit: "57c257dda25f441fff3c63a63bbc4ba7cbb713e3"
core_version: "1.8.1"
core_version: "1.8.4"
display_name: "NEC - PC Engine / SuperGrafx / CD (Geargrafx)"
cores:
- geargrafx
+1 -1
View File
@@ -4,7 +4,7 @@ core_classification: official_port
source: "https://github.com/drhelius/Gearlynx"
profiled_date: "2026-10-04"
source_commit: "675201c62f77b6683d7943cac2b69a23eb2ae573"
core_version: "1.2.33"
core_version: "1.2.35"
display_name: "Atari - Lynx (GearLynx)"
cores: [gearlynx]
systems: [atari-lynx]
+1 -1
View File
@@ -3,7 +3,7 @@ type: standalone
core_classification: enhanced_fork
source: "https://github.com/antonioginer/GroovyMAME"
upstream: "https://github.com/mamedev/mame"
source_commit: "953db38f4aaab1d75faa4ac79e1b45686214f987"
source_commit: "ac85e94d491ad1277ac73f81148205657b440b83"
upstream_commit: "f34f02505e32c1993c6a782b6814232cbfc74e36"
logo: "https://raw.githubusercontent.com/mamedev/mame/master/docs/source/images/MAMElogo.svg"
profiled_date: "2026-08-08"
+1 -1
View File
@@ -6,7 +6,7 @@ upstream: "https://github.com/hatari/hatari"
logo: "https://raw.githubusercontent.com/hatari/hatari/main/share/icons/hicolor/scalable/apps/hatari.svg"
profiled_date: "2026-10-21"
source_commit: "50929946d196f431906932420c55e1b3593dacc5"
upstream_commit: "90949cfa04acbd48ba28c48430fdb58cd82b9053"
upstream_commit: "7dd6a196cde1b1f9a55cd09cfbecb6edcb1bb000"
core_version: "2.6.1-devel"
display_name: "Atari - ST/STE/TT/Falcon (Hatari)"
cores:
+1
View File
@@ -6,6 +6,7 @@ upstream: "https://gitlab.com/higan/higan"
profiled_date: "2026-09-17"
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"
display_name: "Nintendo - SNES / SFC (nSide Balanced)"
cores:
+1 -1
View File
@@ -5,7 +5,7 @@ source: "https://github.com/ivanizag/izapple2"
upstream: "https://github.com/ivanizag/izapple2"
profiled_date: "2026-08-08"
source_commit: "dc28a566c9c733119b0511d9f77e1b4574e28718"
core_version: "v2.4.0"
core_version: "v2.5.0"
display_name: "Apple - Apple II (izapple2)"
cores:
- izapple2
+1 -1
View File
@@ -6,7 +6,7 @@ upstream: "https://github.com/nikita36078/J2ME-Loader"
author: "Yury Kharchenko"
profiled_date: "2026-08-11"
source_commit: "f723a190c0bdb44b31c3bc0ead6f8665c7ea517d"
upstream_commit: "c2fdac77edba94709796b67ed5493915f31d620f"
upstream_commit: "c1aff868a674ea0c9fd9d565dfb4819987ee447f"
core_version: "0.87.1"
display_name: "Mobile - J2ME (JL-Mod)"
cores:
+1 -1
View File
@@ -5,7 +5,7 @@ source: "https://github.com/lightspark/lightspark"
upstream: "https://github.com/lightspark/lightspark"
profiled_date: "2026-08-08"
source_commit: "6f07fd65cb95b88ff73e905bf78a1a8b1ddf6049"
core_version: "0.9.0"
core_version: "0.10.0"
display_name: "Adobe Flash (Lightspark)"
cores:
- lightspark
+1 -1
View File
@@ -5,7 +5,7 @@ source: "https://github.com/sh95014/AppleWin"
upstream: "https://github.com/AppleWin/AppleWin"
profiled_date: "2026-08-12"
source_commit: "51893cab7e8a3d1298734848f4fd616f2e1831fb"
upstream_commit: "75bd90d16a2e4eb16026bf615685ee2a02dfc0d6"
upstream_commit: "3e8054b4627624398e4589f7f27b3d40a6b9718e"
core_version: "1.7"
display_name: "Apple - Apple II (Mariani)"
cores:
+1
View File
@@ -6,6 +6,7 @@ upstream: "https://github.com/mupen64plus/mupen64plus-core"
profiled_date: "2026-10-22"
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"
display_name: "Nintendo - Nintendo 64 (Mupen64Plus)"
cores:
+1
View File
@@ -6,6 +6,7 @@ upstream: "https://github.com/TASEmulators/fceux"
profiled_date: "2026-08-12"
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"
display_name: "Nintendo - NES / Famicom (NES.emu)"
mode: standalone
+1 -1
View File
@@ -5,7 +5,7 @@ source: "https://github.com/WizzardSK/nuance-libretro"
upstream: "https://github.com/andkrau/NuanceResurrection"
profiled_date: "2026-09-03"
source_commit: "daf3ca642756a559ecce91e10da0e73cbad180ff"
upstream_commit: "700d28553f3a06b16385ed9e48f194377e131b03"
upstream_commit: "daf3ca642756a559ecce91e10da0e73cbad180ff"
core_version: "0.6.7"
display_name: "VM Labs - NUON (Nuance)"
cores: [nuance]
+1 -1
View File
@@ -6,7 +6,7 @@ source: "https://github.com/PS2Homebrew-arcade/pcsx2x6"
upstream: "https://github.com/PCSX2/pcsx2"
profiled_date: "2026-08-08"
source_commit: "583f0e90907d69bb2db670f570d0865260fcc71b"
upstream_commit: "5c611f85e13ba250a69c155c678482b219b10d48"
upstream_commit: "3df128d9f9cd89cfc47d08d94b3439136ba22b33"
prose_written_at: "583f0e90907d69bb2db670f570d0865260fcc71b"
core_version: "v0.2.22"
display_name: "Namco - System 246 / 256 (PCSX2x6)"
+1
View File
@@ -6,6 +6,7 @@ upstream: "https://github.com/snes9xgit/snes9x"
profiled_date: "2026-08-12"
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"
display_name: "Nintendo - SNES / SFC (Snes9x EX+)"
mode: standalone
+1
View File
@@ -7,6 +7,7 @@ author: "blueminder"
profiled_date: "2026-08-12"
source_commit: "d409fe784b8328d798098708adc760900bcc6937"
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-20261022-git-d409fe7"
display_name: "Sega - Model 3 (Supermodel Dojo)"
verification: existence
+1
View File
@@ -7,6 +7,7 @@ upstream: "https://github.com/PCSX2/pcsx2"
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"
display_name: "Sony - PlayStation 2 (yaps2)"
cores: [yaps2]
+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "7ac3e6030b12bacd262964fea5373f27fb09e62296255bc3c22255a1aaad5fe8"
$defaultInstallSha256 = "e0a65cc08918a2f53032f63d08974995542a0beabb7528ac3de35d0fdb698e36"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+452 -69
View File
@@ -50,7 +50,7 @@ import zipfile
import zlib
from collections.abc import Callable, Collection, Iterator, Mapping
from pathlib import Path, PurePosixPath
from typing import BinaryIO, ContextManager, NamedTuple, TextIO
from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO
# Manifests are read from the same ref the bootstrap verified this installer
# against, so a file list and the code reading it always come from one commit.
@@ -392,6 +392,9 @@ class Progress:
self.files = 0
self.bytes = 0
self.resumed = 0
# Bytes that came from this machine and not the network: a copy
# found here, the pack, a resumed partial copy.
self.local = 0
# 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
@@ -400,11 +403,16 @@ class Progress:
self._evented = self.start
self._lock = threading.Lock()
def add(self, count: int, resumed: bool = False) -> None:
def add(self, count: int, resumed: bool = False, network: bool | None = None) -> None:
"""count bytes written, resumed ones left out of the speed. network
is whether they came over the network, by default unless resumed."""
with self._lock:
self.bytes += count
if resumed:
self.resumed += count
fetched = not resumed if network is None else network
if not fetched:
self.local += count
now = time.monotonic()
if self.term.live and not self.term.quiet and now - self._drawn >= 0.1:
self._drawn = now
@@ -413,10 +421,18 @@ class Progress:
self._evented = now
EVENTS.emit("progress", files_done=self.files, files_total=self.total_files,
bytes_done=self.bytes, bytes_total=self.expected_bytes)
self.emit_transfer()
def emit_transfer(self) -> None:
"""The transfer event: of the bytes done, those this machine held
and those the network brought."""
EVENTS.emit("transfer", bytes_local=self.local,
bytes_network=self.bytes - self.local)
def forget_resumed(self, count: int) -> None:
with self._lock:
self.resumed -= count
self.local -= count
def transferred(self) -> int:
"""Bytes fetched by this run, a resumed partial copy excluded."""
@@ -494,9 +510,14 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# stage: manifest (reason: network, not_found, invalid, clock for a
# refused certificate under a clock set in the past, or no_ca for no
# trusted certificates on the machine) or detect (reason: conflict, two
# systems found on one machine) or from (reason: not_found or
# not_a_folder, a folder named with --from).
# systems found on one machine) or from (a folder named with --from,
# reason: not_found, not_a_folder, unreadable, or system for the root
# of the system and /proc, /sys, /dev).
"error": ("stage", "reason", "platform", "message"),
# A folder named with --from, once read: the files it holds (counted
# up to MAX_SCANNED_FILES, truncated past them), and the folders below
# it on another drive, not entered.
"scanned": ("folder", "files", "truncated", "other_drives"),
# state: present (hash verified), wrong_hash or missing.
"file": ("platform", "dest", "systems", "size", "state"),
"system": ("platform", "system", "name", "files", "present", "missing",
@@ -510,8 +531,17 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = {
# a folder named with --from (reason empty). member names the ZIP member
# when path is an archive, else "". Its copy is a downloaded event.
"found": ("platform", "dest", "folder", "path", "member", "reason"),
# What a folder named with --from holds that could not be read, of the
# size of a file no copy was found for, or of no known size (a folder,
# a ZIP that would not open). member as in found, reason in words.
"unreadable": ("platform", "folder", "path", "member", "reason"),
"download": ("platform", "files", "bytes"),
"progress": ("files_done", "files_total", "bytes_done", "bytes_total"),
# Right after each progress event, and once when the files of a
# download event are done: bytes_done split into the bytes this machine
# held (a copy found here, the pack, a resumed partial copy) and the
# bytes the network brought. A speed is bytes_network over time.
"transfer": ("bytes_local", "bytes_network"),
"downloaded": ("dest", "ok", "reason", "size", "files_done", "files_total",
"bytes_done", "bytes_total"),
# failed: [{"dest", "reason"}]. skipped: [{"reason", "label", "files",
@@ -4221,9 +4251,17 @@ def _download_one(
if result[2] in ("stopped", NOT_WRITABLE, READ_ONLY) or result[2].startswith(NOT_KEPT):
return result
# The copy went or changed since it was read (a card pulled out, a
# file another run replaced): the file is fetched as if never found.
# file another run replaced): the file is fetched as if never found,
# and a fetch that fails too names both reasons.
if verbose:
print(f" {f['dest']}: {result[2]}, fetching it instead", file=sys.stderr)
fetched = _download_one(f, bios_path, verbose, progress)
why = fetched[2]
if (fetched[1] or why in ("stopped", NOT_WRITABLE, READ_ONLY)
or why.startswith(NOT_KEPT)):
return fetched
both = f"{result[2]}, and {why}"
return fetched[0], False, NetworkReason(both) if isinstance(why, NetworkReason) else both
if LOCAL.pack is not None:
return _extract_from_pack(f, bios_path, dest, progress)
@@ -5155,6 +5193,7 @@ def download_files(
if not future.cancelled() and future.exception() is None and future.result()[1]
)
progress.finish()
progress.emit_transfer()
return failed, interrupted
@@ -6529,7 +6568,7 @@ def _extract_from_pack(
return f["dest"], False, NOT_IN_PACK
archive, info = held
return _write_verified(f, bios_path, dest, lambda: archive.open(info), progress,
"the pack")
"the pack", named=f"{info.filename} inside {archive.filename}")
@contextlib.contextmanager
@@ -6549,30 +6588,67 @@ def _copy_local(
"""Copy one file found on this machine to its place, checked like a
download. Its bytes count as already here, not in the download speed."""
return _write_verified(f, bios_path, dest, lambda: _open_copy(copy), progress,
f"the copy in {copy.index.label}", local=True)
f"the copy in {copy.index.label}", local=True,
named=copy.named())
class _SourceUnreadable(Exception):
"""The bytes to write could not be read: the source's fault, never the
folder's, so it falls back like any copy that failed."""
def unreadable_words(exc: BaseException) -> str:
"""Why a file or a ZIP member could not be read, in words."""
if isinstance(exc, RuntimeError) and "encrypted" in str(exc):
return "it is encrypted, a password is needed"
if isinstance(exc, NotImplementedError):
return "it is packed with a compression method this installer cannot read"
if isinstance(exc, (zipfile.BadZipFile, zlib.error, EOFError)):
return "it is damaged"
if isinstance(exc, OSError) and exc.strerror:
return exc.strerror
return str(exc) or type(exc).__name__
def _write_verified(
f: dict, bios_path: Path, dest: Path,
source: Callable[[], ContextManager[BinaryIO]], progress: Progress | None,
held_in: str, local: bool = False,
held_in: str, local: bool = False, named: str = "",
) -> tuple[str, bool, str]:
"""Write the bytes source opens at dest once they match the list's size
and hashes, the file in place kept aside first when it differs."""
and hashes, the file in place kept aside first when it differs.
The source is opened before the temporary file, and its read errors
are told apart from the write errors: a copy that could not be read
was reported as a folder not writable. named is the source in the
reason then, held_in when empty."""
expected = {name: f[name].lower() for name in ("sha256", "sha1") if f.get(name)}
tmp_path: Path | None = None
counted = 0
def unreadable(exc: BaseException) -> _SourceUnreadable:
return _SourceUnreadable(f"{named or held_in} could not be read: "
f"{unreadable_words(exc)}")
try:
with source() as src, tempfile.NamedTemporaryFile(
mode="wb", dir=dest.parent, prefix=f".{dest.name}.", suffix=".part",
delete=False,
) as out:
with contextlib.ExitStack() as stack:
try:
src = stack.enter_context(source())
except _MEMBER_ERRORS as exc:
raise unreadable(exc) from exc
out = stack.enter_context(tempfile.NamedTemporaryFile(
mode="wb", dir=dest.parent, prefix=f".{dest.name}.", suffix=".part",
delete=False,
))
tmp_path = Path(out.name)
size = 0
while True:
if _stop.is_set():
raise _Interrupted()
chunk = src.read(min(1024 * 1024, f["size"] + 1 - size))
try:
chunk = src.read(min(1024 * 1024, f["size"] + 1 - size))
except _MEMBER_ERRORS as exc:
raise unreadable(exc) from exc
if not chunk:
break
size += len(chunk)
@@ -6580,7 +6656,7 @@ def _write_verified(
raise ValueError(f"{held_in} holds more bytes than the list records")
out.write(chunk)
if progress is not None:
progress.add(len(chunk), resumed=local)
progress.add(len(chunk), resumed=local, network=False)
counted += len(chunk)
if size != f["size"]:
raise ValueError(f"{held_in} holds {size} bytes, the list records {f['size']}")
@@ -6598,9 +6674,9 @@ def _write_verified(
return f["dest"], False, "stopped"
except _NotKept as exc:
return f["dest"], False, f"{NOT_KEPT} ({exc})"
except (ValueError, *_MEMBER_ERRORS) as exc:
except (_SourceUnreadable, ValueError, *_MEMBER_ERRORS) as exc:
if progress is not None and counted:
progress.add(-counted, resumed=local)
progress.add(-counted, resumed=local, network=False)
if isinstance(exc, OSError) and _write_refused(exc):
return f["dest"], False, READ_ONLY if _read_only_file(dest) else NOT_WRITABLE
return f["dest"], False, str(exc)
@@ -6626,6 +6702,10 @@ SYSTEM_FOLDERS = frozenset(name.casefold() for name in (
# A Mac copying to a FAT or exFAT card writes ._name beside every file: its
# Finder data, 4 KB, never the file.
APPLE_DOUBLE = "._"
# Folders the kernel makes up on the fly: endless, and never a copy.
PSEUDO_FOLDERS = frozenset() if os.name == "nt" else frozenset(("/proc", "/sys", "/dev"))
# FILE_ATTRIBUTE_REPARSE_POINT: a junction, a mount point or a link on Windows.
_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
# Errors a member of someone's ZIP can raise on reading: damaged, encrypted
# (RuntimeError), or packed with a method zipfile lacks.
_MEMBER_ERRORS = (OSError, EOFError, RuntimeError, NotImplementedError,
@@ -6647,6 +6727,10 @@ class LocalCopy(NamedTuple):
rel = self.index.relative(self.path)
return f"{rel}, inside as {self.member}" if self.member else rel
def named(self) -> str:
"""The copy by its full path, the member first when it is one."""
return f"{self.member} inside {self.path}" if self.member else str(self.path)
class FolderIndex:
"""The files under a folder, by name and by size, hashed on demand.
@@ -6678,14 +6762,37 @@ class FolderIndex:
self._digests: dict[tuple[Path, str, str], str] = {}
self._members: dict[Path, list[str]] = {}
self._member_sizes: dict[int, list[tuple[Path, str]]] | None = None
# (size, casefolded name) -> the files and the ZIP members, built
# the first time a copy is looked for.
self._loose_named: dict[tuple[int, str], list[Path]] | None = None
self._member_named: dict[int, dict[str, list[tuple[Path, str]]]] = {}
self._scans: dict[tuple[str, int, str], _HashScan] = {}
self._member_size: dict[tuple[Path, str], int] = {}
# (path, member or "") -> (its size or None when unknown, why) of each
# file, member or folder below root that could not be read.
self.unreadable: dict[tuple[Path, str], tuple[int | None, str]] = {}
# Folders below root on another drive, not entered.
self.other_drives: list[Path] = []
self.files = 0
if root.is_file():
if root.name.casefold().endswith(".zip"):
self.archives.append(root)
self.files = 1
return
seen = 0
for folder, dirs, names in os.walk(root):
try:
device = os.stat(root).st_dev
except OSError as exc:
self._unread(root, "", None, exc)
return
def unlisted(exc: OSError) -> None:
self._unread(Path(exc.filename or root), "", None, exc)
for folder, dirs, names in os.walk(root, onerror=unlisted):
dirs[:] = sorted(d for d in dirs
if d != BACKUP_DIR and d.casefold() not in SYSTEM_FOLDERS)
if d != BACKUP_DIR and d.casefold() not in SYSTEM_FOLDERS
and self._enters(Path(folder) / d, device))
for name in sorted(names):
path = Path(folder) / name
if name.startswith(APPLE_DOUBLE):
@@ -6699,7 +6806,8 @@ class FolderIndex:
if not path.is_file():
continue
size = path.stat().st_size
except OSError:
except OSError as exc:
self._unread(path, "", None, exc)
continue
self.by_name.setdefault(name.casefold(), []).append(path)
self.by_size.setdefault(size, []).append(path)
@@ -6707,19 +6815,45 @@ class FolderIndex:
if name.casefold().endswith(_ARCHIVE_SUFFIXES):
self.archives.append(path)
seen += 1
self.files = seen
if seen >= MAX_SCANNED_FILES:
self.truncated = True
return
def _enters(self, path: Path, device: int) -> bool:
"""Whether the walk goes into a folder: not a pseudo file system, a
Windows junction or reparse point (a link the walk cannot tell from
a folder, which led out of the folder named), nor another drive
mounted below the root, which is named instead."""
if str(path) in PSEUDO_FOLDERS:
return False
try:
held = os.lstat(path)
except OSError as exc:
self._unread(path, "", None, exc)
return False
if getattr(held, "st_file_attributes", 0) & _REPARSE_POINT:
return False
if held.st_dev != device:
self.other_drives.append(path)
return False
return True
def seed(self, path: Path, sha1: str) -> None:
"""A SHA-1 already known, so the file is not read again."""
self._sha1[path] = sha1
def _unread(self, path: Path, member: str, size: int | None, exc: BaseException) -> None:
"""Note a file, a member or a folder that could not be read: a copy
the run needs may be the one, and it is named instead of dropped."""
self.unreadable.setdefault((path, member), (size, unreadable_words(exc)))
def sha1(self, path: Path) -> str:
if path not in self._sha1:
try:
self._sha1[path] = _sha1_file(path)
except OSError:
except OSError as exc:
self._unread(path, "", self.size.get(path), exc)
self._sha1[path] = ""
return self._sha1[path]
@@ -6735,15 +6869,43 @@ class FolderIndex:
value = _digest_handle(handle, (algorithm,))[algorithm]
else:
value = _digest_file(path, (algorithm,))[algorithm]
except _MEMBER_ERRORS:
except _MEMBER_ERRORS as exc:
size = self._member_size.get((path, member)) if member else self.size.get(path)
self._unread(path, member, size, exc)
value = ""
self._digests[key] = value
return self._digests[key]
def holding(self, sizes: set[int], sha1s: set[str]) -> list[Path]:
"""Files whose contents are one of sha1s, read only when the size fits."""
return [path for size in sorted(sizes) for path in self.by_size.get(size, [])
if self.sha1(path) in sha1s]
def unreadable_for(self, sizes: Collection[int]) -> list[tuple[Path, str, str]]:
"""(path, member, why) of what could not be read and may hold a file
of one of sizes: its size is one of them, or unknown (a folder, an
archive whose list of members could not be read)."""
return sorted((path, member, why)
for (path, member), (size, why) in self.unreadable.items()
if size is None or size in sizes)
def _scan(self, kind: str, size: int, algorithm: str) -> _HashScan:
"""The files (kind "loose") or ZIP members ("member") of one size,
hashed in the order the folder was read, each read once."""
key = (kind, size, algorithm)
if key not in self._scans:
if kind == "loose":
self._scans[key] = _HashScan(
self.by_size.get(size, []), lambda path: self.digest(path, algorithm))
else:
self._scans[key] = _HashScan(
self._members_of_size(size),
lambda item: self.digest(item[0], algorithm, item[1]))
return self._scans[key]
def first_holding(self, sizes: set[int], sha1s: set[str]) -> Path | None:
"""The first file whose contents are one of sha1s, the smallest size
first, read only when the size fits."""
for size in sorted(sizes):
path = self._scan("loose", size, "sha1").first(sha1s)
if path is not None:
return path
return None
def zip_members(self, path: Path) -> list[str]:
if path not in self._members:
@@ -6767,12 +6929,18 @@ class FolderIndex:
try:
with zipfile.ZipFile(path) as archive:
infos = archive.infolist()
except (OSError, zipfile.BadZipFile):
except OSError as exc:
# A ZIP that will not open may hold anything: named. A
# damaged one is a download cut short, not a copy.
self._unread(path, "", None, exc)
continue
except zipfile.BadZipFile:
continue
for info in infos:
if not info.is_dir():
self._member_sizes.setdefault(info.file_size, []).append(
(path, info.filename))
self._member_size[(path, info.filename)] = info.file_size
return self._member_sizes.get(size, [])
def find(self, entry: dict, skip: Collection[Path] = ()) -> LocalCopy | None:
@@ -6786,15 +6954,27 @@ class FolderIndex:
if not wanted:
return None
name = str(entry.get("dest", "")).rsplit("/", 1)[-1].casefold()
loose = sorted(self.by_size.get(size, []), key=lambda p: p.name.casefold() != name)
for path in loose:
if path not in skip and self.digest(path, algorithm) == wanted:
if self._loose_named is None:
self._loose_named = {}
for path, held in self.size.items():
self._loose_named.setdefault((held, path.name.casefold()), []).append(path)
for path in self._loose_named.get((size, name), []):
if not _being_written(path, skip) and self.digest(path, algorithm) == wanted:
return LocalCopy(self, path)
members = sorted(self._members_of_size(size),
key=lambda item: item[1].rsplit("/", 1)[-1].casefold() != name)
for path, member in members:
if self.digest(path, algorithm, member) == wanted:
return LocalCopy(self, path, member)
path = self._scan("loose", size, algorithm).first({wanted}, skip)
if path is not None:
return LocalCopy(self, path)
if size not in self._member_named:
named = self._member_named[size] = {}
for item in self._members_of_size(size):
named.setdefault(item[1].rsplit("/", 1)[-1].casefold(), []).append(item)
for archive, member in self._member_named[size].get(name, []):
if (not _being_written(archive, skip)
and self.digest(archive, algorithm, member) == wanted):
return LocalCopy(self, archive, member)
held = self._scan("member", size, algorithm).first({wanted}, skip)
if held is not None:
return LocalCopy(self, held[0], held[1])
return None
def count_copy(self, misplaced: bool = False) -> None:
@@ -6812,6 +6992,98 @@ class FolderIndex:
return rel if rel != "." else path.name
def _being_written(path: Path, skip: Collection[Path]) -> bool:
"""Whether a file, or the archive holding a member, is one this run
writes: its bytes are being replaced, so they are no source."""
return bool(skip) and (path in skip or Path(os.path.realpath(path)) in skip)
class _HashScan:
"""Candidates of one size, hashed in order and each once, so finding
the first that holds a digest never reads the earlier ones again: 3,000
same-sized files and 3,000 entries of that size cost 3,000 reads, not
nine million comparisons."""
def __init__(self, items: list, digest: Callable[[Any], str]) -> None:
self.items = items
self.digest = digest
self.read = 0
self.at: dict[str, list[int]] = {}
def first(self, wanted: Collection[str], skip: Collection[Path] = ()) -> Any:
"""The first candidate, in the folder's order, whose digest is one of
wanted and that skip does not name, or None."""
best = None
for value in wanted:
for position in self.at.get(value, ()):
if not _being_written(self._holder(position), skip):
best = position if best is None else min(best, position)
break
if best is not None:
return self.items[best]
while self.read < len(self.items):
position = self.read
self.read += 1
value = self.digest(self.items[position])
self.at.setdefault(value, []).append(position)
if value in wanted and not _being_written(self._holder(position), skip):
return self.items[position]
return None
def _holder(self, position: int) -> Path:
item = self.items[position]
return item[0] if isinstance(item, tuple) else item
class _ZippedLookup:
"""The archives of a folder not listed as files to install, by the
folder holding them: each one's names read once, so telling that a
missing file is still zipped costs two lookups and never a walk."""
def __init__(self, index: FolderIndex, known: set[str]) -> None:
self.index = index
self._by_parent: dict[Path, list[tuple[int, Path]]] = {}
for position, path in enumerate(index.archives):
if index.relative(path).casefold() not in known:
self._by_parent.setdefault(path.parent, []).append((position, path))
# folder -> (member name -> first archive, stem -> first archive).
self._maps: dict[Path, tuple[dict[str, tuple[int, Path]],
dict[str, tuple[int, Path]]]] = {}
def _folder(self, folder: Path) -> tuple[dict[str, tuple[int, Path]],
dict[str, tuple[int, Path]]]:
if folder not in self._maps:
members: dict[str, tuple[int, Path]] = {}
stems: dict[str, tuple[int, Path]] = {}
for position, path in self._by_parent.get(folder, []):
lowered = path.name.casefold()
if lowered.endswith(".zip"):
for member in self.index.zip_members(path):
members.setdefault(member, (position, path))
stems.setdefault(lowered.rsplit(".", 1)[0], (position, path))
self._maps[folder] = (members, stems)
return self._maps[folder]
def reason(self, name: str, folders: Collection[Path]) -> str:
"""Why name is missing when an archive in folders holds it or carries
its name: the first such archive in the order the folder was read,
and one holding it before one carrying its name."""
lowered = name.casefold()
stem = lowered.rsplit(".", 1)[0]
best: tuple[int, int, Path] | None = None
for folder in folders:
members, stems = self._folder(folder)
for kind, held in ((0, members.get(lowered)), (1, stems.get(stem))):
if held is not None and (best is None or (held[0], kind) < best[:2]):
best = (held[0], kind, held[1])
if best is None:
return ""
where = self.index.relative(best[2])
if best[1] == 0:
return f"still zipped: it is inside {where}"
return f"still zipped: {where} needs extracting"
def _inside(path: Path, root: Path) -> bool:
"""Whether a link at path leads to a place under root."""
try:
@@ -6858,7 +7130,19 @@ def _known_destinations(files: list[dict]) -> set[str]:
return {str(f.get("dest", "")).casefold() for f in files}
def _content_owner(sha1: str, entry: dict, files: list[dict]) -> str:
def _content_owners(files: list[dict]) -> dict[str, list[dict]]:
"""SHA-1 -> the entries one of whose copies holds it, in the list's order."""
owners: dict[str, list[dict]] = {}
for other in files:
for sha1 in dict.fromkeys(c.get("sha1") for c in _copies(other) if c.get("sha1")):
owners.setdefault(sha1, []).append(other)
return owners
def _content_owner(
sha1: str, entry: dict, files: list[dict],
owners: dict[str, list[dict]] | None = None,
) -> str:
"""Why a file holding sha1 is not the one entry wants, if the list knows."""
for copy in entry.get("alternatives", []) + entry.get("slot_choices", []):
if copy.get("sha1") == sha1 and copy.get("cores"):
@@ -6868,15 +7152,16 @@ def _content_owner(sha1: str, entry: dict, files: list[dict]) -> str:
if copy.get("sha1") == sha1:
return ("another revision: the one the platform's own list declares, "
"not the one the cores read")
for other in files:
if owners is None:
owners = _content_owners(files)
for other in owners.get(sha1, []):
if other is entry or other.get("dest") == entry.get("dest"):
continue
if any(copy.get("sha1") == sha1 for copy in _copies(other)):
regions = [r for r in other.get("regions") or [] if r != WORLD_REGION]
if regions:
return (f"another region: the contents of {other['dest']} "
f"({', '.join(regions)})")
return f"another file: the contents of {other['dest']}"
regions = [r for r in other.get("regions") or [] if r != WORLD_REGION]
if regions:
return (f"another region: the contents of {other['dest']} "
f"({', '.join(regions)})")
return f"another file: the contents of {other['dest']}"
return ""
@@ -6935,7 +7220,7 @@ def _copy_reason(entry: dict, copy: LocalCopy, bios_path: Path, known: set[str])
def _found_elsewhere(
entry: dict, index: FolderIndex, bios_path: Path, files: list[dict],
known: set[str],
known: set[str], zipped: _ZippedLookup | None = None,
) -> tuple[str, str]:
"""(reason, folder too deep) for an entry missing at its place."""
dest = str(entry.get("dest", ""))
@@ -6954,28 +7239,25 @@ def _found_elsewhere(
return "", ""
same_name = [p for p in index.by_name.get(name.casefold(), []) if p != expected]
matching = [p for p in same_name if index.size[p] in sizes and index.sha1(p) in sha1s]
held = set(matching)
for path in matching or same_name:
contents = "" if path in matching else ", with other contents"
contents = "" if path in held else ", with other contents"
found = _place_reason(entry, path, index, expected, known, contents)
if found[0]:
return found
above = index.root.parent / dest
if index.root.parent != index.root and above.is_file():
return f"one folder too high: it is at {above}", ""
for path in index.holding(sizes, sha1s):
return _place_reason(entry, path, index, expected, known)
holder = index.first_holding(sizes, sha1s)
if holder is not None:
return _place_reason(entry, holder, index, expected, known)
if name.casefold().endswith(_ARCHIVE_SUFFIXES):
return "", ""
stem = name.rsplit(".", 1)[0].casefold()
folders = {expected.parent, index.root}
for path in index.archives:
if path.parent not in folders or index.relative(path).casefold() in known:
continue
lowered = path.name.casefold()
if lowered.endswith(".zip") and name.casefold() in index.zip_members(path):
return f"still zipped: it is inside {index.relative(path)}", ""
if lowered.rsplit(".", 1)[0] == stem:
return f"still zipped: {index.relative(path)} needs extracting", ""
if zipped is None:
zipped = _ZippedLookup(index, known)
reason = zipped.reason(name, {expected.parent, index.root})
if reason:
return reason, ""
for path in index.apple_double.get(name.casefold(), []):
if path.parent == expected.parent:
return (f"only a Mac's ._ file: {path.name} holds Finder data, not the "
@@ -6983,7 +7265,10 @@ def _found_elsewhere(
return "", ""
def _wrong_contents(entry: dict, path: Path, index: FolderIndex, files: list[dict]) -> str:
def _wrong_contents(
entry: dict, path: Path, index: FolderIndex, files: list[dict],
owners: dict[str, list[dict]] | None = None,
) -> str:
"""Why the file at an entry's place is not the one the list installs."""
try:
size = path.stat().st_size
@@ -6992,7 +7277,7 @@ def _wrong_contents(entry: dict, path: Path, index: FolderIndex, files: list[dic
except OSError:
return ""
sha1 = index.sha1(path)
owner = _content_owner(sha1, entry, files)
owner = _content_owner(sha1, entry, files, owners)
if owner:
return owner
if head == b"PK\x03\x04" and not str(entry.get("dest", "")).casefold().endswith(".zip"):
@@ -7024,18 +7309,20 @@ def diagnose(
if index is None:
index = bios_folder_index(bios_path, files, verified or [])
known = _known_destinations(files)
zipped = _ZippedLookup(index, known)
for entry in missing:
reason, extra = _found_elsewhere(entry, index, bios_path, files, known)
reason, extra = _found_elsewhere(entry, index, bios_path, files, known, zipped)
if reason:
reasons[id(entry)] = reason
if extra:
deep[extra] = deep.get(extra, 0) + 1
owners = _content_owners(files) if mismatched else {}
for entry in mismatched:
try:
path = _entry_path(bios_path, entry)
except ValueError:
continue
reason = _wrong_contents(entry, path, index, files)
reason = _wrong_contents(entry, path, index, files, owners)
if reason:
reasons[id(entry)] = reason
return Diagnosis(reasons, deep)
@@ -7270,6 +7557,47 @@ def emit_copies(
path=str(copy.path), member=copy.member, reason=reason)
class Unreadable(NamedTuple):
"""A file, ZIP member or folder below a --from folder that could not be
read, and may be a file the run still needs."""
index: FolderIndex
path: Path
member: str
why: str
def line(self) -> str:
where = f"{self.member} inside {self.path}" if self.member else str(self.path)
return f"{where} could not be read: {self.why}"
def unreadable_copies(
entries: list[dict], copies: dict[int, LocalCopy], folders: list[FolderIndex],
) -> list[Unreadable]:
"""What the --from folders hold that could not be read, of the size of
a file no copy was found for, or of no known size: dropped silently,
a file a user had read as one to download."""
sizes = {e["size"] for e in entries
if id(e) not in copies and isinstance(e.get("size"), int)}
if not sizes:
return []
return [Unreadable(index, *held) for index in folders
for held in index.unreadable_for(sizes)]
def unreadable_lines(found: list[Unreadable], every: bool) -> list[str]:
lines = [f" {item.line()}" for item in (found if every else found[:10])]
if len(found) > len(lines):
lines.append(f" ... and {len(found) - len(lines)} more could not be read "
"(--verbose lists them all)")
return lines
def emit_unreadable(plat: str, found: list[Unreadable]) -> None:
for item in found:
EVENTS.emit("unreadable", platform=plat, folder=item.index.label,
path=str(item.path), member=item.member, reason=item.why)
def sources_line(
folders: list[tuple[str, int]], fixed: int, downloaded: int, missing: int,
fetched: str = "downloaded", shared: int = 0,
@@ -7742,26 +8070,71 @@ def _open_local_sources(args: argparse.Namespace) -> None:
sys.exit(1)
def _system_folder(folder: Path) -> str:
"""Why a --from folder is one of the system's own, in words, or ""."""
try:
resolved = folder.resolve()
except OSError:
return ""
text = str(resolved)
if any(text == pseudo or text.startswith(pseudo + "/") for pseudo in PSEUDO_FOLDERS):
return "is a folder the system makes up, it holds no copy"
system_drive = os.environ.get("SystemDrive", "C:") if os.name == "nt" else ""
if resolved == Path(resolved.anchor) and (
os.name != "nt" or resolved.drive.casefold() == system_drive.casefold()):
return "is the root of the whole system"
return ""
def from_folder_problem(label: str) -> tuple[str, str] | None:
"""Why a folder named with --from cannot be read, without reading what
it holds: (reason, words), or None when it can.
reason is the one the error event carries: not_found, not_a_folder (a
file that is no ZIP), unreadable, or system (the root of the system,
/proc, /sys or /dev). words follow the folder as given: "E:\\old does
not exist". Cheap enough for a screen to call before indexing."""
folder = Path(os.path.expanduser(label))
zipped = folder.is_file() and folder.name.casefold().endswith(".zip")
if not (folder.is_dir() or zipped):
if folder.exists():
return "not_a_folder", "is neither a folder nor a ZIP"
return "not_found", "does not exist"
try:
if zipped:
with open(folder, "rb"):
pass
else:
with os.scandir(folder) as listing:
next(listing, None)
except OSError as exc:
return "unreadable", f"could not be read: {unreadable_words(exc)}"
system = "" if zipped else _system_folder(folder)
return ("system", system) if system else None
def _open_from_folders(folders: list[Path]) -> None:
"""Read each folder named with --from, or stop on one that is not there."""
"""Read each folder named with --from, or stop on one that cannot be."""
for folder in folders:
label = str(folder)
folder = Path(os.path.expanduser(label))
zipped = folder.is_file() and folder.name.casefold().endswith(".zip")
if not (folder.is_dir() or zipped):
reason = "not_a_folder" if folder.exists() else "not_found"
what = ("does not exist" if reason == "not_found"
else "is neither a folder nor a ZIP")
problem = from_folder_problem(label)
if problem is not None:
reason, what = problem
message = f"{label} {what}: --from names the folder to copy BIOS files from"
EVENTS.emit("error", stage="from", reason=reason, platform="", message=message)
print(f"Error: {message}.", file=sys.stderr)
sys.exit(1)
index = FolderIndex(folder, label)
index = FolderIndex(Path(os.path.expanduser(label)), label)
LOCAL.folders.append(index)
print(f" Copying BIOS files from: {label}")
if index.truncated:
print(f" Read the first {MAX_SCANNED_FILES:,} files of {label} only. Name "
"the folder that holds the BIOS files to search it all.")
for drive in index.other_drives:
print(f" Did not go into {drive}: it is another drive. Name it with --from "
"to read it too.")
EVENTS.emit("scanned", folder=label, files=index.files, truncated=index.truncated,
other_drives=[str(drive) for drive in index.other_drives])
def _missing_dest(dest: Path, ask: bool, named: str = "named with --dest") -> None:
@@ -8471,6 +8844,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
if bios_index is not None:
bios_indexes.append(bios_index)
copies = plan_local_copies(to_download, bios_path, bios_index, LOCAL.folders)
unread = unreadable_copies(to_download, copies, LOCAL.folders)
hint = [] if LOCAL.pack is not None else bulk_hint(
to_download, manifest,
systems_chosen=bool(selection and selection[0]),
@@ -8495,6 +8869,7 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
chosen=selection[0] if selection else None,
)
emit_copies(plat_name, to_download, copies, bios_index, bios_path, files)
emit_unreadable(plat_name, unread)
total_verified += len(up_to_date)
if args.check:
@@ -8513,6 +8888,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
for line in check_lines(missing, mismatched, files, manifest, bios_path,
args.verbose, up_to_date, bios_index, copies):
print(line)
for line in unreadable_lines(unread, args.verbose):
print(line)
print(" Run the same command without --check to fetch them; "
"a file with a wrong hash is replaced, and the one in place "
f"kept in {BACKUP_DIR} beside the BIOS folder.")
@@ -8546,6 +8923,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
_print_plan([f for f in to_download if id(f) not in copies], bios_path, term,
trees, kept, copy_plan_lines(to_download, copies, bios_index,
bios_path, files, args.verbose, True))
for line in unreadable_lines(unread, args.verbose):
print(line)
for line in hint:
print(line)
continue
@@ -8571,6 +8950,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
files_pending += len(to_download)
continue
EVENTS.emit("download", platform=plat_name, files=len(to_download), bytes=dl_size)
if unread:
print()
for line in unreadable_lines(unread, args.verbose):
print(line)
if copies:
print()
for line in copy_plan_lines(to_download, copies, bios_index, bios_path,
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="7ac3e6030b12bacd262964fea5373f27fb09e62296255bc3c22255a1aaad5fe8"
DEFAULT_INSTALL_SHA256="e0a65cc08918a2f53032f63d08974995542a0beabb7528ac3de35d0fdb698e36"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+4
View File
@@ -912,6 +912,10 @@
"type": "string",
"description": "Why source_commit is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag: comparing it to HEAD says nothing and recaling would repoint its refs at code the build it documents never had."
},
"upstream_frozen": {
"type": "string",
"description": "Why upstream_commit is held on purpose rather than followed: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is judged against its own revision and a bump leaves it, the way pin_frozen holds source_commit."
},
"version_source": {
"type": "object",
"additionalProperties": false,
+88 -5
View File
@@ -19,6 +19,7 @@ import re
import subprocess
import sys
import tempfile
from collections import Counter
from collections.abc import Callable, Iterable, Iterator, Sequence
from dataclasses import asdict, dataclass, field, replace
from datetime import date
@@ -316,6 +317,37 @@ class Citation:
in_comment: bool = False
def _list_segments(items: list) -> list[object]:
"""The field path segment naming each list item.
A name keys its entry. supermodel declares Games.xml twice, once per
build mode, and both notes answered to files[Games.xml].note: the
history read one note's citations against the other's text. A repeated
name takes its build mode, or its index when the mode repeats too.
"""
keys: list[tuple[str | None, str | None]] = []
for item in items:
name = item.get("name") if isinstance(item, dict) else None
mode = item.get("mode") if isinstance(item, dict) else None
keys.append((
None if name is None else str(name),
mode if isinstance(mode, str) else None,
))
names = Counter(name for name, _ in keys if name is not None)
modes = Counter(key for key in keys if key[0] is not None)
segments: list[object] = []
for index, (name, mode) in enumerate(keys):
if name is None:
segments.append(index)
elif names[name] == 1:
segments.append(name)
elif mode and modes[(name, mode)] == 1:
segments.append(f"{name}@{mode}")
else:
segments.append(f"{name}#{index}")
return segments
def _walk_document(
node: object, where: str = ""
) -> Iterator[tuple[str, str, dict | list, object, Any]]:
@@ -336,9 +368,9 @@ def _walk_document(
else:
yield from _walk_document(value, spot)
elif isinstance(node, list):
segments = _list_segments(node)
for index, item in enumerate(node):
segment = item.get("name") if isinstance(item, dict) else None
spot = f"{where}[{segment if segment is not None else index}]"
spot = f"{where}[{segments[index]}]"
if isinstance(item, str):
yield "str", spot, node, index, item
else:
@@ -1104,7 +1136,20 @@ def select_views(
continue
seen.add((repo.host, repo.slug))
views.append(RepoView(repo, pin, head, origin, field))
return _follow_fork_base(_follow_submodule(views, cache_dir, offline), cache_dir, offline)
views = _follow_fork_base(_follow_submodule(views, cache_dir, offline), cache_dir, offline)
return _hold_upstream(profile, views)
def _hold_upstream(profile: dict, views: list[RepoView]) -> list[RepoView]:
"""An upstream tree the port vendors or synced to is judged at that
revision: aps3e carries RPCS3 1f080485, yaps2 PCSX2 342db515."""
if not str(profile.get("upstream_frozen") or "").strip():
return views
return [
replace(view, head=view.pin, origin=f"{view.origin}, held")
if view.field == "upstream" else view
for view in views
]
def _follow_submodule(
@@ -2515,7 +2560,25 @@ def insert_after_line(text: str, key: str, new_line: str) -> str:
def _field_value(line: str) -> str:
return line.split(":", 1)[1].strip().strip('"').strip("'")
"""The value of a `key: value` line as the loader reads it.
etlegacy quotes `(FS_FOpenFileRead \\"pak0.pk3\\")` inside a double-quoted
source_ref: stripping the outer quotes keeps the backslashes, and the
line never equalled the parsed value it was searched for.
"""
raw = line.split(":", 1)[1].strip()
try:
parsed = yaml.safe_load(raw)
except yaml.YAMLError:
parsed = None
if isinstance(parsed, str):
return parsed
return raw.strip('"').strip("'")
def _double_quoted(value: str) -> str:
"""A YAML double-quoted scalar reading back as value."""
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def find_field_line(text: str, key: str, expected: str, start: int = 0) -> int:
@@ -2543,7 +2606,7 @@ def replace_field_line(text: str, key: str, expected: str, value: str,
lines = text.splitlines()
line = lines[index]
indent = line[: len(line) - len(line.lstrip())]
lines[index] = f'{indent}{key}: "{value}"'
lines[index] = f"{indent}{key}: {_double_quoted(value)}"
joined = "\n".join(lines) + ("\n" if text.endswith("\n") else "")
return joined, index
@@ -2631,6 +2694,9 @@ def _prose_moves(
return moves
_RANGE_TOKEN_RE = re.compile(r"\d+(?:-\d+)?")
def _run_after_moves(
parts: Sequence[RefPart],
moves: dict[int, tuple[int, int, str | None]],
@@ -2652,6 +2718,17 @@ def _run_after_moves(
ranges.append(f"{start}" if end == start else f"{start}-{end}")
else:
ranges.append(part.raw)
# The separators stay as written. boom3 writes `libretro.cpp:1635-1643,
# 2384-2405` in a folded scalar, rewritten whole there, and a joined
# `3238-3246,4245-4266` no longer read as the intended document.
written = run.partition(":")[2]
tokens = list(_RANGE_TOKEN_RE.finditer(written))
if len(tokens) == len(parts) and all(
token.group() == part.raw for token, part in zip(tokens, parts)
):
for token, rendered in reversed(list(zip(tokens, ranges))):
written = written[:token.start()] + rendered + written[token.end():]
return f"{new_path or parts[0].path}:{written}"
return f"{new_path or parts[0].path}:{','.join(ranges)}"
@@ -4318,6 +4395,12 @@ def _passed_release(
commit = upstream.tag_commit(repo, version.latest_release, args.cache_dir, args.offline)
if not commit:
return None
if version.tag_commit and version.tag_commit != commit:
# A project can publish a release older than its newest tag:
# pcsx2x6's latest release is v0.2.20, its declared tag v0.2.22.
older = upstream.ancestry(repo, commit, version.tag_commit, args.cache_dir, args.offline)
if older and older[1] == 0:
return None
if commit == pin:
return version.latest_release
relation = upstream.ancestry(repo, commit, pin, args.cache_dir, args.offline)
-1
View File
@@ -963,7 +963,6 @@ SYSTEM_NAMES: dict[str, str] = {
"mega-ld": "Sega Mega LD",
"microdigital-tk85": "Microdigital TK85",
"microsoft-msx2": "MSX2",
"microsoft-xbox-360": "Microsoft Xbox 360",
"midway-mcr": "Midway MCR",
"mips": "MIPS",
"mips64": "MIPS64",
+9 -3
View File
@@ -392,13 +392,19 @@ def _prose_runs(
):
path_start -= 1
path = "." + path
role = _prose_role(line, path_start)
if not role and _prose_external(line, path_start, known):
# A path opening its line reads its role or project word at the
# end of the line before: genesis_plus_gx_expanded wrapped
# `(Batocera` and `package/batocera/...mk:7` one line apart.
lead = line[:path_start]
if number and not lead.strip():
lead = lines[number - 1].rstrip() + " "
role = _prose_role(lead, len(lead))
if not role and _prose_external(lead, len(lead), known):
# Kept with its project word, the source_ref convention: the
# report looks for the project inside the declared trees
# (mupen64plus-ae vendors `rice Config.cpp`) and calls the
# citation EXTERNAL only when none carries it.
before = line[:path_start].rstrip()
before = lead.rstrip()
word = before[max(before.rfind(" "), before.rfind("\t"),
before.rfind("(")) + 1:]
path = f"{word} {path}"
+4 -1
View File
@@ -5102,7 +5102,10 @@ class EventsDescribeTheRun(unittest.TestCase, _SyntheticTree):
"bytes_total": 245}
for n, (dest, size, total) in enumerate(
(("c.bin", 70, 70), ("d.bin", 25, 95), ("b.bin", 150, 245)), 1)]
self.assertEqual([e for e in events if e["event"] != "progress"],
# The last transfer: every byte came over the network.
self.assertEqual([e for e in events if e["event"] == "transfer"][-1],
{"event": "transfer", "bytes_local": 0, "bytes_network": 245})
self.assertEqual([e for e in events if e["event"] not in ("progress", "transfer")],
self._head("install", installer) + [
{"event": "download", "platform": "retroarch", "files": 3, "bytes": 245},
*done,
+375
View File
@@ -13,11 +13,15 @@ from __future__ import annotations
import contextlib
import copy
import errno
import io
import json
import os
import subprocess
import sys
import time
import unittest
import urllib.error
import zipfile
from pathlib import Path
from unittest import mock
@@ -481,6 +485,377 @@ class MisplacedInTheBiosFolder(Scratch):
self.assertNotIn("copied from", proc.stdout)
def _unprivileged(test: unittest.TestCase) -> None:
if os.name != "posix" or os.geteuid() == 0:
test.skipTest("a file of mode 000 is read here anyway")
class ACopyThatFails(FromFolder):
"""A copy found by the plan that cannot be read when it is copied:
it read as "folder not writable", and a download failing after it lost
the first reason."""
def setUp(self):
super().setUp()
self.entry = json.loads(self.list.read_text())["files"][0]
self.source = self.keep("old/scph5501.bin", US)
self.copy = install.FolderIndex(self.old).find(self.entry)
self.assertIsNotNone(self.copy)
self.addCleanup(install.LOCAL.reset)
self.dest.mkdir()
def test_an_unreadable_source_falls_back_and_names_both_reasons(self):
_unprivileged(self)
self.source.chmod(0)
self.addCleanup(self.source.chmod, 0o644)
refused = urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))
with mock.patch.object(install, "_retry_delay", return_value=0), \
mock.patch.object(install.urllib.request, "urlopen", side_effect=refused):
dest, ok, reason = install._download_one(self.entry, self.dest, copy=self.copy)
self.assertFalse(ok)
self.assertTrue(reason.startswith(
f"{self.source.resolve()} could not be read: Permission denied, and "), reason)
self.assertNotIn(install.NOT_WRITABLE, reason)
self.assertIsInstance(reason, install.NetworkReason)
def test_a_read_error_is_the_source_and_not_the_folder(self):
class Failing(io.BytesIO):
def read(self, *_args):
raise OSError(errno.EIO, "Input/output error")
for source, words in (
(lambda: Failing(b""), "Input/output error"),
(mock.Mock(side_effect=PermissionError(errno.EACCES, "Permission denied")),
"Permission denied")):
with self.subTest(words=words):
result = install._write_verified(
self.entry, self.dest, self.dest / "scph5501.bin", source, None,
"the copy in E:\\", named="E:\\bios\\a.bin")
self.assertEqual(result, ("scph5501.bin", False,
f"E:\\bios\\a.bin could not be read: {words}"))
self.assertEqual(list(self.dest.iterdir()), [], "no temporary file is left")
def test_a_run_reading_a_pack_falls_back_to_the_pack_only(self):
_unprivileged(self)
self.source.chmod(0)
self.addCleanup(self.source.chmod, 0o644)
install.LOCAL.pack = mock.Mock(find=mock.Mock(return_value=None))
with mock.patch.object(install.urllib.request, "urlopen",
side_effect=AssertionError("network")):
_dest, ok, reason = install._download_one(self.entry, self.dest, copy=self.copy)
install.LOCAL.pack = None
self.assertFalse(ok)
self.assertEqual(reason, f"{self.source.resolve()} could not be read: "
f"Permission denied, and {install.NOT_IN_PACK}")
def _patched_zip(path: Path, name: str, data: bytes, flags: int = 0,
method: int | None = None) -> Path:
"""A stored ZIP of one member, its headers then given other flags or
another compression method: zipfile writes neither encryption nor
methods it cannot read."""
_zip(path, {})
with zipfile.ZipFile(path, "w", zipfile.ZIP_STORED) as archive:
archive.writestr(name, data)
raw = bytearray(path.read_bytes())
for signature, at in ((b"PK\x03\x04", 6), (b"PK\x01\x02", 8)):
start = raw.find(signature)
raw[start + at] |= flags
if method is not None:
raw[start + at + 2:start + at + 4] = method.to_bytes(2, "little")
path.write_bytes(bytes(raw))
return path
class UnreadableCopies(FromFolder):
"""A copy that could not be read was cached as no match and dropped:
the user saw a file to download that the old card held."""
def setUp(self):
super().setUp()
self.keep("scph5500.bin", JP)
self.keep("pcsx/extra.bin", EXTRA)
self.keep("gba_bios.bin", GBA)
def locked(self, relative: str, data: bytes) -> Path:
_unprivileged(self)
path = self.keep(relative, data)
path.chmod(0)
self.addCleanup(path.chmod, 0o644)
return path
def test_a_file_of_mode_000_is_named_in_every_mode(self):
path = self.locked("psx/scph5501.bin", US).resolve()
self.locked("notes.txt", b"not the size of any file")
line = f"{path} could not be read: Permission denied"
for options in (("--check",), ("--dry-run",), ()):
with self.subTest(options=options):
proc = self.install(*options)
self.assertEqual(proc.returncode, 1 if options != ("--dry-run",) else 0,
proc.stdout + proc.stderr)
self.assertIn(line, proc.stdout)
self.assertNotIn("notes.txt", proc.stdout)
def test_an_unreadable_copy_is_an_event(self):
path = self.locked("psx/scph5501.bin", US).resolve()
proc = self.install("--check", "--events")
events = _events(proc)
for event in events:
self.assertEqual(list(event), ["event", *install.EVENT_KEYS[event["event"]]])
self.assertEqual([e for e in events if e["event"] == "unreadable"], [{
"event": "unreadable", "platform": "retroarch", "folder": str(self.old),
"path": str(path), "member": "", "reason": "Permission denied"}])
kinds = [e["event"] for e in events]
self.assertLess(kinds.index("found"), kinds.index("unreadable"))
def test_a_copy_found_elsewhere_leaves_the_unreadable_one_unnamed(self):
self.locked("psx/scph5501.bin", US)
self.keep("psx/copy.bin", US)
proc = self.install("--check")
self.assertNotIn("could not be read", proc.stdout)
def test_a_folder_of_mode_000_is_named(self):
_unprivileged(self)
folder = self.old / "psx"
folder.mkdir()
folder.chmod(0)
self.addCleanup(folder.chmod, 0o755)
proc = self.install("--check")
self.assertIn(f"{folder.resolve()} could not be read: Permission denied", proc.stdout)
def test_members_that_cannot_be_read_say_why(self):
for flags, method, words in (
(0x1, None, "it is encrypted, a password is needed"),
(0, 6, "it is packed with a compression method this installer "
"cannot read")):
with self.subTest(words=words):
archive = _patched_zip(self.old / "psx.zip", "scph5501.bin", US,
flags, method).resolve()
proc = self.install("--check")
self.assertIn(f"scph5501.bin inside {archive} could not be read: {words}",
proc.stdout)
def test_an_input_output_error_is_named(self):
self.keep("psx/scph5501.bin", US)
index = install.FolderIndex(self.old)
entry = json.loads(self.list.read_text())["files"][0]
failing = OSError(errno.EIO, "Input/output error")
with mock.patch.object(install, "_sha1_file", side_effect=failing):
self.assertIsNone(index.find(entry))
found = install.unreadable_copies([entry], {}, [index])
# scph5500.bin holds another region of the same size: read too.
self.assertEqual([item.line() for item in found], [
f"{(self.old / 'psx' / 'scph5501.bin').resolve()} could not be read: "
"Input/output error",
f"{(self.old / 'scph5500.bin').resolve()} could not be read: "
"Input/output error"])
class WhereTheSearchGoes(FromFolder):
"""--from / walked the whole system, /proc and every drive mounted in
it. The search stays on the drive it starts on, and the folder is
checked before it is read, cheaply enough for a screen to do it."""
def test_a_folder_is_checked_without_being_read(self):
archive = _zip(self.root / "backup.zip", {"a": b"a"})
(self.root / "notes.txt").write_text("x")
self.assertIsNone(install.from_folder_problem(str(self.old)))
self.assertIsNone(install.from_folder_problem(str(archive)))
self.assertEqual(install.from_folder_problem(str(self.root / "nowhere")),
("not_found", "does not exist"))
self.assertEqual(install.from_folder_problem(str(self.root / "notes.txt")),
("not_a_folder", "is neither a folder nor a ZIP"))
if os.name == "posix":
self.assertEqual(install.from_folder_problem("/"),
("system", "is the root of the whole system"))
self.assertEqual(install.from_folder_problem("/proc")[0], "system")
def test_a_folder_that_cannot_be_listed_is_refused(self):
_unprivileged(self)
self.old.chmod(0)
self.addCleanup(self.old.chmod, 0o755)
self.assertEqual(install.from_folder_problem(str(self.old)),
("unreadable", "could not be read: Permission denied"))
proc = self.install("--events")
error = next(e for e in _events(proc) if e["event"] == "error")
self.assertEqual((error["stage"], error["reason"]), ("from", "unreadable"))
self.assertEqual(proc.returncode, 1)
@unittest.skipUnless(os.name == "posix", "a POSIX root")
def test_the_root_of_the_system_is_refused(self):
proc = self.run_installer("--manifest", str(self.list), "--from", "/", "--events")
error = next(e for e in _events(proc) if e["event"] == "error")
self.assertEqual((error["stage"], error["reason"]), ("from", "system"))
self.assertIn("/ is the root of the whole system", proc.stderr)
self.assertEqual(proc.returncode, 1)
def lstat_saying(self, folder: str, **fields):
real = os.lstat
def lstat(path, *args, **kwargs):
held = real(path, *args, **kwargs)
if Path(path).name != folder:
return held
values = {"st_dev": held.st_dev, "st_file_attributes": 0, **fields}
return mock.Mock(**values)
return mock.patch.object(install.os, "lstat", side_effect=lstat)
def test_another_drive_inside_is_named_and_not_entered(self):
self.keep("mnt/scph5501.bin", US)
self.keep("scph5500.bin", JP)
with self.lstat_saying("mnt", st_dev=os.lstat(self.old).st_dev + 1):
index = install.FolderIndex(self.old)
self.assertEqual(index.other_drives, [self.old.resolve() / "mnt"])
self.assertEqual(sorted(p.name for p in index.size), ["scph5500.bin"])
def test_a_junction_is_not_entered(self):
self.keep("junction/scph5501.bin", US)
with self.lstat_saying("junction", st_file_attributes=0x400):
index = install.FolderIndex(self.old)
self.assertEqual(index.size, {})
self.assertEqual(index.other_drives, [])
def test_pseudo_file_systems_are_never_entered(self):
if not install.PSEUDO_FOLDERS:
self.skipTest("no pseudo file system here")
index = install.FolderIndex(self.old)
for pseudo in sorted(install.PSEUDO_FOLDERS):
self.assertFalse(index._enters(Path(pseudo), os.lstat("/").st_dev), pseudo)
def test_a_search_cut_short_is_an_event(self):
for n in range(3):
self.keep(f"f{n}.bin", b"%d" % n)
out = io.StringIO()
install.EVENTS.open(out)
self.addCleanup(setattr, install.EVENTS, "stream", None)
self.addCleanup(install.LOCAL.reset)
with mock.patch.object(install, "MAX_SCANNED_FILES", 2), \
contextlib.redirect_stdout(io.StringIO()) as text:
install._open_from_folders([self.old])
self.assertIn(f"Read the first 2 files of {self.old} only", text.getvalue())
self.assertEqual([json.loads(line) for line in out.getvalue().splitlines()], [{
"event": "scanned", "folder": str(self.old), "files": 2, "truncated": True,
"other_drives": []}])
def test_each_folder_read_is_an_event_before_detect(self):
self.keep("scph5501.bin", US)
events = _events(self.install("--check", "--events"))
kinds = [e["event"] for e in events]
scanned = next(e for e in events if e["event"] == "scanned")
self.assertEqual(scanned, {"event": "scanned", "folder": str(self.old), "files": 1,
"truncated": False, "other_drives": []})
self.assertLess(kinds.index("start"), kinds.index("scanned"))
self.assertLess(kinds.index("scanned"), kinds.index("detect"))
class LocalAndNetworkBytes(unittest.TestCase):
"""The engine knows which bytes came from this machine. The interface
guessed it to show a download speed: the transfer event tells it."""
def test_the_counts_follow_each_source_and_each_rollback(self):
out = io.StringIO()
install.EVENTS.open(out)
self.addCleanup(setattr, install.EVENTS, "stream", None)
term = install.Terminal()
term.live, term.quiet = False, True
with mock.patch.object(install, "EVENT_PROGRESS_INTERVAL", 0):
progress = install.Progress(3, 100, term)
progress.add(10, resumed=True) # a partial copy resumed
progress.add(20) # from the network
progress.add(30, resumed=True, network=False) # a copy found here
progress.add(15, network=False) # the pack
progress.add(-30, resumed=True, network=False)
progress.add(-30) # a download dropped,
progress.forget_resumed(10) # its resumed part too
self.assertEqual((progress.bytes, progress.local), (15, 15))
events = [json.loads(line) for line in out.getvalue().splitlines()]
transfers = [e for e in events if e["event"] == "transfer"]
progresses = [e for e in events if e["event"] == "progress"]
self.assertEqual(len(transfers), len(progresses))
self.assertEqual([(t["bytes_local"], t["bytes_network"]) for t in transfers][:4],
[(10, 0), (10, 20), (40, 20), (55, 20)])
for transfer, update in zip(transfers, progresses):
self.assertEqual(transfer["bytes_local"] + transfer["bytes_network"],
update["bytes_done"])
def test_an_install_from_a_folder_and_the_network_splits_them(self):
with FakeRepository() as repo:
old = repo.root / "old"
payloads = FakeRepository.PAYLOADS
old.mkdir()
(old / "a.bin").write_bytes(payloads["scph5500.bin"][0])
(old / "b.bin").write_bytes(payloads["gba_bios.bin"][0])
proc = repo.run("--from", str(old), "--no-input", "--events")
self.assertEqual(proc.returncode, 0, proc.stderr)
events = _events(proc)
local = len(payloads["scph5500.bin"][0]) + len(payloads["gba_bios.bin"][0])
total = sum(len(p[0]) for p in payloads.values())
kinds = [e["event"] for e in events]
last = len(kinds) - 1 - kinds[::-1].index("transfer")
self.assertEqual(events[last], {"event": "transfer", "bytes_local": local,
"bytes_network": total - local})
self.assertLess(kinds.index("download"), last)
self.assertLess(last, kinds.index("summary"))
class AFolderAroundTheBiosFolder(FromFolder):
"""--from names a folder holding the BIOS folder: an archive this run
replaces was a source of members while it was being written."""
def test_an_archive_being_replaced_gives_no_member(self):
new_set = b"the archive the list installs" * 4
inner = b"a rom inside the old archive" * 3
_zip(self.dest / "set.zip", {"inner.bin": inner})
files = [_entry("set.zip", new_set, ["x"]), _entry("inner.bin", inner, ["x"])]
around = install.FolderIndex(self.root)
copies = install.plan_local_copies(files, self.dest, None, [around])
self.assertEqual(copies, {})
_zip(self.old / "copy.zip", {"inner.bin": inner})
copies = install.plan_local_copies(files, self.dest, None,
[install.FolderIndex(self.root)])
self.assertEqual([copies[id(e)].where() for e in files if id(e) in copies],
["old card/copy.zip, inside as inner.bin"])
class ManySameSizedFiles(FromFolder):
"""A card of 16 KB ROMs and a list of 16 KB entries: each entry sorted
and walked every file of its size."""
def test_each_file_is_read_once_and_the_order_holds(self):
for n in range(3000):
self.keep(f"roms/r{n:04}.bin", b"%016d" % n)
_zip(self.old / f"sets/s{n:04}.zip", {f"m{n}.bin": b"m%015d" % n})
entries = [{"dest": f"e{n}.bin", "size": 16, "sha1": f"{n:040}"}
for n in range(3000)]
wanted = [_entry("late.bin", b"%016d" % 2999, ["x"]),
_entry("m5.bin", b"m%015d" % 5, ["x"]),
_entry("r0007.bin", b"%016d" % 7, ["x"])]
index = install.FolderIndex(self.old)
start = time.monotonic()
copies = install.plan_local_copies(entries + wanted, self.dest, None, [index])
elapsed = time.monotonic() - start
self.assertEqual({e["dest"]: copies[id(e)].where() for e in wanted if id(e) in copies}, {
"late.bin": "roms/r2999.bin",
"m5.bin": "sets/s0005.zip, inside as m5.bin",
"r0007.bin": "roms/r0007.bin",
})
self.assertEqual(len(copies), 3)
self.assertLess(elapsed, 30, f"{elapsed:.1f} s")
def test_a_file_of_the_same_name_comes_first(self):
self.keep("a/other.bin", US)
self.keep("b/scph5501.bin", US)
_zip(self.old / "c.zip", {"x/gba.bin": GBA})
_zip(self.old / "d.zip", {"gba_bios.bin": GBA})
index = install.FolderIndex(self.old)
files = json.loads(self.list.read_text())["files"]
copies = install.plan_local_copies(files, self.dest, None, [index])
found = {e["dest"]: copies[id(e)].where() for e in files if id(e) in copies}
self.assertEqual(found["scph5501.bin"], "b/scph5501.bin")
self.assertEqual(found["gba_bios.bin"], "d.zip, inside as gba_bios.bin")
class TheSummaryLine(unittest.TestCase):
def test_the_parts_in_words(self):
line = install.sources_line([("E:\\", 38)], fixed=0, downloaded=4, missing=2)
+53
View File
@@ -19,6 +19,7 @@ import socket
import subprocess
import sys
import tempfile
import time
import unittest
import zipfile
from pathlib import Path
@@ -243,6 +244,58 @@ class CheckNamesWhyAFileIsRefused(Scratch):
self.assertNotIn("opened at", proc.stdout)
class ArchivesInTheBiosFolder(Scratch):
"""The reason an archive gives is the first archive, in the order the
folder is read, that holds the file or carries its name."""
def zipped(self, relative: str, members: dict[str, bytes]) -> None:
path = self.dest / relative
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w") as archive:
for name, data in members.items():
archive.writestr(name, data)
def test_each_reason_names_the_first_archive_that_answers(self):
self.zipped("a.zip", {"x.bin": b"x"})
self.zipped("x.zip", {"other": b"o"})
self.zipped("b.zip", {"other": b"o"})
self.zipped("c.zip", {"bios/b.bin": b"b"})
self.place("y.7z", b"7z")
self.zipped("sub/z.zip", {"Z.BIN": b"z"})
self.zipped("elsewhere/w.zip", {"w.bin": b"w"})
self.zipped("listed.zip", {"v.bin": b"v"})
entries = {dest: _entry(dest, dest.encode() * 3, ["x"]) for dest in (
"x.bin", "b.bin", "y.bin", "sub/z.bin", "w.bin", "v.bin")}
files = [*entries.values(), _entry("listed.zip", b"zip", ["x"])]
found = install.diagnose(list(entries.values()), [], files, self.dest)
self.assertEqual({dest: found.reasons.get(id(e), "") for dest, e in entries.items()}, {
"x.bin": "still zipped: it is inside a.zip",
"b.bin": "still zipped: b.zip needs extracting",
"y.bin": "still zipped: y.7z needs extracting",
"sub/z.bin": "still zipped: it is inside sub/z.zip",
"w.bin": "",
"v.bin": "",
})
def test_many_archives_and_many_missing_files_take_one_pass(self):
"""1,519 ZIPs and 2,000 missing files took 57 seconds: each missing
file walked every archive of the folder."""
self.dest.mkdir()
for n in range(3000):
with zipfile.ZipFile(self.dest / f"set{n:04}.zip", "w") as archive:
archive.writestr(f"rom{n}.bin", b"x")
(self.dest / f"same{n:04}.bin").write_bytes(b"%016d" % n)
missing = [{"dest": f"file{n}.bin", "size": 16 if n % 2 else 1000 + n,
"sha1": f"{n:040}"} for n in range(3000)]
missing.append(_entry("rom7.bin", b"r", ["x"]))
start = time.monotonic()
found = install.diagnose(missing, [], missing, self.dest)
elapsed = time.monotonic() - start
self.assertEqual(found.reasons, {
id(missing[-1]): "still zipped: it is inside set0007.zip"})
self.assertLess(elapsed, 30, f"{elapsed:.1f} s")
def _pack(directory: Path, name: str, members: dict[str, bytes]) -> Path:
"""A pack as generate_pack writes one: the files and a manifest.json."""
listed = [{"path": path, "sha1": _sha1(data), "md5": hashlib.md5(data).hexdigest(),
+117
View File
@@ -2284,6 +2284,17 @@ class TestLineEdits(unittest.TestCase):
with self.assertRaises(YamlWriteError):
find_field_line(SAMPLE, "source_ref", "a.c:10-12", index + 1)
def test_escaped_quotes_are_matched_as_parsed(self):
# etlegacy writes (FS_FOpenFileRead \\"pak0.pk3\\") inside a
# double-quoted source_ref: the line kept its backslashes and never
# equalled the parsed value, so the recale was refused.
value = 'files.c:5076 (FS_FOpenFileRead "pak0.pk3")'
text = SAMPLE.replace('"b.c:5"', '"files.c:5076 (FS_FOpenFileRead \\"pak0.pk3\\")"')
self.assertEqual(yaml.safe_load(text)["files"][1]["source_ref"], value)
moved = value.replace("5076", "5080")
out, _ = replace_field_line(text, "source_ref", value, moved)
self.assertEqual(yaml.safe_load(out)["files"][1]["source_ref"], moved)
def test_source_ref_outside_files_does_not_shift_the_match(self):
text = SAMPLE.replace(
"files:",
@@ -3276,6 +3287,22 @@ class TestCheckVersion(unittest.TestCase):
self.assertIn("already contains release v3.1.0", warning)
class APassedReleaseIsNewerThanTheTag(unittest.TestCase):
"""pcsx2x6 declares tag v0.2.22 while its latest published release is
v0.2.20: the pin holds v0.2.20 without core_version being older."""
def test_a_release_older_than_the_declared_tag_is_not_passed(self):
from unittest import mock # noqa: PLC0415
version = profile_sync.VersionReport(
"v0.2.22", "v0.2.22", "v0.2.20", "2026-07-25", True, "tag22"
)
args = argparse.Namespace(cache_dir="c", offline=True)
with mock.patch.object(profile_sync.upstream, "tag_commit", return_value="rel20"), \
mock.patch.object(profile_sync.upstream, "ancestry", return_value=(5, 0)):
self.assertIsNone(profile_sync._passed_release(args, "repo", version, "pin"))
class TestAncestry(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@@ -3645,6 +3672,22 @@ class TestCollectCitations(unittest.TestCase):
self.assertEqual(citations[1].parts[0].path, "munt ROMInfo.cpp")
self.assertTrue(profile_sync.is_external_citation("munt ROMInfo.cpp"))
def test_project_word_ending_the_line_before_marks_the_path_external(self):
# genesis_plus_gx_expanded wrapped `(Batocera` and
# `package/batocera/...mk:7` one line apart in its notes, and the
# path was read against the declared repository: 1 to review.
document = {
"notes": "Batocera builds it at a0b5a033\n(Batocera\n"
"package/batocera/emulators/gpgx.mk:7), the same tree.\n"
}
citation = collect_citations(document)[0]
self.assertEqual(
citation.parts[0].path, "Batocera package/batocera/emulators/gpgx.mk"
)
self.assertTrue(profile_sync.is_external_citation(citation.parts[0].path))
sentence = {"notes": "It is read by the file\nsrc/gpgx.c:7 at boot.\n"}
self.assertEqual(collect_citations(sentence)[0].parts[0].path, "src/gpgx.c")
def test_project_word_resolves_inside_a_declared_tree(self):
tree = [
"mupen64plus-video-rice/upstream/src/Config.cpp",
@@ -3914,6 +3957,32 @@ class TestRebaseProse(unittest.TestCase):
"(q.c:9).", yaml.safe_load(content)["quirk_note"]
)
def test_folded_rename_keeps_the_separator_as_written(self):
# boom3 cites libretro.cpp:1635-1643, 2384-2405 in a folded note.
# The rename rewrote the run as one token without the space, and
# the written document no longer matched the intended one.
text = PROSE_SAMPLE.replace(
"files:",
"quirk_note: >\n Passes fs_game\n (q.c:10-12, 30-40). Checked"
" later.\nfiles:",
)
self.path.write_text(text, encoding="utf-8")
parts = [
PartResult(RefPart("q.c", 10, 12, "10-12"), "RENAMED", "src/q.c", 20, 22, []),
PartResult(RefPart("q.c", 30, 40, "30-40"), "RENAMED", "src/q.c", 50, 60, []),
]
applied = rebase_refs(
self.path,
self._report([self._prose_entry("q.c:10-12, 30-40", parts, "quirk_note")]),
)
self.assertEqual(
applied, ["quirk_note: q.c:10-12, 30-40 -> src/q.c:20-22, 50-60"]
)
self.assertIn(
"(src/q.c:20-22, 50-60).",
yaml.safe_load(self.path.read_text())["quirk_note"],
)
def test_parts_disagreeing_on_the_new_file_do_not_move(self):
parts = [
PartResult(RefPart("b.c", 2, 2, "2"), "RENAMED", "src/b.c", 5, 5, []),
@@ -4231,6 +4300,40 @@ class TestRealignProse(unittest.TestCase):
yaml.safe_load(text)["source_commit"], "pin",
)
def test_two_entries_sharing_a_name_keep_their_own_history(self):
# supermodel declares Games.xml once per build mode. Both notes
# answered to files[Games.xml].note, and the history of the libretro
# note decided where the standalone note was written.
sample = (
'emulator: Test\n'
'source: "https://github.com/o/n"\n'
'source_commit: "oldpin"\n'
'files:\n'
' - name: "Games.xml"\n'
' mode: standalone\n'
' note: "Parsed at startup (a.c:10)."\n'
' - name: "Games.xml"\n'
' mode: libretro\n'
' note: "Read through the VFS (b.c:2)."\n'
)
self._git("init", "-q")
self._git("config", "user.email", "t@t")
self._git("config", "user.name", "t")
self._commit(sample, "profile")
# The pin advances and the libretro note is rewritten, uncommitted.
self.path.write_text(
sample.replace('"oldpin"', '"pin"').replace("(b.c:2)", "(b.c:5)"),
encoding="utf-8",
)
self.files[("oldpin", "a.c")] = ["x"] * 9 + ["subject"]
self.files[("pin", "a.c")] = ["x"] * 13 + ["subject"]
self.files[("pin", "b.c")] = ["p"] * 4 + ["read"]
messages = profile_sync.realign_prose(self.path, self.tmp.name, dry_run=True)
self.assertEqual(
messages,
["would recale files[Games.xml@standalone].note: a.c:10 -> a.c:14"],
)
def test_pin_moved_under_committed_text_is_not_guessed(self):
"""A committed pin change under the same text may be a correction.
@@ -4809,6 +4912,20 @@ class UpstreamVendoredAsASubmodule(unittest.TestCase):
self.assertEqual(views[1].head, "nesbox-tip")
class AHeldUpstreamStaysOnItsPin(unittest.TestCase):
"""aps3e vendors RPCS3 1f080485 and yaps2 PCSX2 342db515: a bump moved
upstream_commit to the upstream HEAD neither port carries."""
def test_the_upstream_view_is_read_at_its_pin(self):
views = [
profile_sync.RepoView("fork", "p", "h", "source_commit", "source"),
profile_sync.RepoView("up", "vendored", "tip", "upstream_commit", "upstream"),
]
held = profile_sync._hold_upstream({"upstream_frozen": "vendored tree"}, views)
self.assertEqual((held[0].head, held[1].head), ("h", "vendored"))
self.assertEqual(profile_sync._hold_upstream({}, views), views)
class RealignReadsThePathAsWritten(unittest.TestCase):
"""hatarib cites hatari/src/tos.c, a path of hatariB. When only
upstream_commit moved, realign stripped the hatari/ prefix, read
-1
View File
@@ -37,7 +37,6 @@ UNLINKED: dict[str, str] = {
"dark-forces-ii": "Game Engines > OpenJKDF2",
"fpinball": "Other > future-pinball-fploader",
"fruitmachine": "Other > mfme",
"microsoft-xbox-360": "Other > xenia-edge",
"mysteries-of-the-sith": "Game Engines > OpenJKDF2",
"namco-na1": "Arcade > FBNeo",
"namco-nb1": "Arcade > FBNeo",
+12 -2
View File
@@ -471,6 +471,13 @@ writes, the system folders (`System Volume Information`, `.Spotlight-V100`,
`.Trashes`, `$RECYCLE.BIN`) and a link leading out of the folder are skipped.
`--from` also takes a ZIP of an old BIOS folder.
The search stays on one drive: a drive mounted inside the folder is named and
not entered (give it its own `--from`), and so are `/proc`, `/sys`, `/dev` and
Windows junctions. The root of the system (`/`, `C:\`) is refused. A file that
cannot be read, and has the size of a file still missing, is named with the
reason (`E:\bios\a.bin could not be read: Permission denied`), as is a ZIP
member locked by a password.
The folder is only read: each file is copied, checked against the list's
hashes once written, and the original stays where it is. A file in place with
other contents is kept aside first, as a download would replace it (see
@@ -1178,7 +1185,7 @@ fields, the installer refuses to narrow rather than install everything.
| Code | Meaning |
|------|---------|
| `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 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 `--restore` could not put a file back |
| `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 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, or `--restore` could not put a file back |
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), 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`. The message then starts with `install.py: error:`. 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. Run the same command again to carry on |
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
@@ -1215,14 +1222,17 @@ kind, and each kind has exactly the keys below, in this order. The schema is
|------|------|------|
| `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` |
| `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, or `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one). A folder named with `--from` is not there (`stage` is `from`, `reason` `not_found`, or `not_a_folder` for a file that is no ZIP). `message` says it in words |
| `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, or `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one). 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`). `message` says it in words |
| `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 |
| `checking` | `files_done`, `files_total` | While the files already in the folder are read and hashed, at most every half second and once at the end. `files_total` counts the files found in place |
| `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 |
| `check` | `platform`, `name`, `bios_dir`, `files`, `present`, `missing`, `wrong_hash`, `bytes_needed`, `data_missing`, `not_installable` | The platform's totals. `data_missing` counts data-tree files to fetch, `not_installable` the files the collection cannot supply |
| `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`) |
| `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 |
| `downloaded` | `dest`, `ok`, `reason`, `size`, `files_done`, `files_total`, `bytes_done`, `bytes_total` | Each finished file, downloaded or copied from where a `found` event named it. `reason` says why one failed, and is empty otherwise |
| `summary` | `mode`, `installed`, `up_to_date`, `pending`, `failed`, `skipped`, `left_out`, `kept`, `interrupted` | The outcome. `pending` counts what a next run would still fetch, `failed` lists `{dest, reason}`, `skipped` lists `{reason, label, files, required}` for the files the collection cannot supply, `left_out` lists `{category, label, files, dests}` for what `--no-game-data` left out, and is empty without it. `kept` lists `{folder, files, bytes}`, one per backup folder, for the files in place the run moved aside before replacing them (with `--dry-run`, would move), and is empty when none. Not written for a listing |
| `end` | `code` | Last line, with the exit status the process returns (see [Exit status](#exit-status)) |
+1
View File
@@ -591,6 +591,7 @@ does not look.
| `known_hash_adler32` | expected Adler-32 hash (Dolphin's DSP ROMs). Pair with `adler32_byteswap` when the code hashes 16-bit byte-swapped data |
| `adler32_pairs_with`, `adler32_pairs` | the file hashed together with this one and the combinations the code accepts, this file's value first, in table order: Dolphin warns on a DSP ROM pair outside its `known_roms` even when each file passes alone |
| `pin_frozen` | why `source_commit` is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag |
| `upstream_frozen` | why `upstream_commit` is held: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is read at its own pin and a bump leaves it. Without it, a port that is a GitHub fork of its upstream follows the merge base of the two |
| `prose_written_at` | the pin a reader confirmed the prose citations against. When the profile's history shows a text living under several pins, profile_sync takes this one as the revision the text describes, so a bump is no longer refused for a citation already reread. It never overrides a history with a single candidate |
| `version_source` | for a project published outside any forge, `{url, pattern}`: the page that announces releases and a regular expression whose first group is the latest version. `profile_sync --check-version` compares it with `core_version`, and `check_freshness --profiles` calls the profile stale when they differ. `no_genuine_copy` names an announced version found only rebuilt or re-signed: the profile stays on the newest unmodified build and is not stale until the page announces a later one |
| `upstream_gone` | why the declared upstream can no longer be reached, when that is settled rather than transient. Records the death instead of leaving the profile failing every pass. profile_sync reports it as declared, and reports the contradiction if the forge answers again |