mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
chore: merge main into the launcher fixes
This commit is contained in:
commit
e6ce3085b1
56 files changed
+1627
-118
No files matched your search
@@ -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*
|
||||
BIN
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.
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
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -11,7 +11,7 @@
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
$defaultInstallSha256 = "bcbde4c289b32c02ada78001b75274a87b125c1efd3d8fb1064d7f5482ef3997"
|
||||
$defaultInstallSha256 = "c90676dcc433061bd97aca94930cd7acd74295624840cb5c85a22897f9c1dd12"
|
||||
$maximumInstallerBytes = 2MB
|
||||
$installerExit = 0
|
||||
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
|
||||
|
||||
+452
-69
@@ -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,
|
||||
@@ -7737,26 +8065,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:
|
||||
@@ -8362,6 +8735,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]),
|
||||
@@ -8386,6 +8760,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:
|
||||
@@ -8404,6 +8779,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.")
|
||||
@@ -8437,6 +8814,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
|
||||
@@ -8462,6 +8841,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
@@ -18,7 +18,7 @@ esac
|
||||
TEMP_INSTALLER=""
|
||||
TEMP_DIRECTORY=""
|
||||
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
|
||||
DEFAULT_INSTALL_SHA256="bcbde4c289b32c02ada78001b75274a87b125c1efd3d8fb1064d7f5482ef3997"
|
||||
DEFAULT_INSTALL_SHA256="c90676dcc433061bd97aca94930cd7acd74295624840cb5c85a22897f9c1dd12"
|
||||
MAX_INSTALLER_BYTES=2097152
|
||||
|
||||
cleanup() {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -480,6 +484,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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -465,6 +465,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
|
||||
@@ -1159,7 +1166,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`) |
|
||||
@@ -1192,14 +1199,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)) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user