diff --git a/.cargo/config.toml b/.cargo/config.toml index 13013b582..d989c50d5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -21,8 +21,9 @@ # handled by the `getrandom` `js`-feature shim in the frontend's wasm32 deps.) rustflags = ["--cfg=web_sys_unstable_apis", "--cfg=getrandom_backend=\"wasm_js\""] -# MSRV-aware (v1.3.0 bumped 1.86 -> 1.96) dependency resolution. The workspace pins -# rust-version = "1.96"; `fallback` makes `cargo update` prefer dependency +# MSRV-aware (v1.3.0 bumped 1.86 -> 1.96, v3.0.1 -> 1.99) dependency resolution. +# Every crate inherits the workspace's rust-version = "1.99" (seven libretro-path +# crates declared "1.96" for one day in v3.0.1); `fallback` makes `cargo update` prefer dependency # versions compatible with that MSRV over the newest available. Without it, a # future `cargo update` could pull a past-MSRV crate (e.g. `image` >= 0.26, a # transitive dep of `arboard`'s clipboard image support, which needs a newer @@ -54,7 +55,7 @@ full-build = "build --release -p rustynes-frontend --features full" # self-heal instead of failing the job. This is repo-wide, so it covers EVERY # cargo invocation: ci.yml, android.yml's `:app:cargoNdkBuild`, pgo.yml, web.yml, # release.yml, and local builds. (Index fetches already use the sparse protocol -# by default on the pinned 1.96 toolchain; these settings harden the crate +# by default on the pinned toolchain; these settings harden the crate # *download* step, which is where the failures occurred.) [net] retry = 10 diff --git a/Cargo.lock b/Cargo.lock index 92b1a5fdb..145f9a33b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,7 +224,7 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -460,7 +460,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -469,7 +469,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -608,9 +608,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.5.1" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "jobserver", @@ -940,7 +940,7 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-audio-toolbox", "objc2-avf-audio", "objc2-core-audio", @@ -1256,7 +1256,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1355,7 +1355,7 @@ dependencies = [ "bytemuck", "egui", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", @@ -1367,9 +1367,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" @@ -1561,9 +1561,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.12.5" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" dependencies = [ "bytemuck", ] @@ -1606,9 +1606,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.3.1" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" dependencies = [ "autocfg", ] @@ -2145,9 +2145,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +checksum = "b67d3d2e287e4b86c10b3f3b641033d1f89b74bdb39d05f34952e2b9a6fe21cd" dependencies = [ "console", "once_cell", @@ -2334,9 +2334,9 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "lazycell" @@ -2357,9 +2357,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libloading" @@ -2500,12 +2500,12 @@ checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" [[package]] name = "mac_address" -version = "1.1.8" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a" dependencies = [ - "nix 0.29.0", - "winapi", + "nix 0.30.1", + "windows-sys 0.61.2", ] [[package]] @@ -2582,9 +2582,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", @@ -2594,9 +2594,9 @@ dependencies = [ [[package]] name = "mlua" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96e5d00f19d8c46c71ceaced99593b90c31c57aa1fe2cb3e93a8b1698eedba9" +checksum = "7a7ae6b65bf54b7fdd4cf5b6f44f60862350ebf3fe3779b3a9cc005e7fa14f81" dependencies = [ "bstr", "either", @@ -2609,9 +2609,9 @@ dependencies = [ [[package]] name = "mlua-sys" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b806d7ade031f5d6607eae3e283fb034cb795a76247dd0d1ba753c8c42debccf" +checksum = "1631cda17bd6486410ab751d91f5fd4484f441589278cdad879eb3afc721a285" dependencies = [ "cc", "cfg-if", @@ -2704,6 +2704,18 @@ name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.13.2", "cfg-if", @@ -2824,9 +2836,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] name = "objc2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" dependencies = [ "objc-sys", "objc2-encode", @@ -2834,9 +2846,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -2850,7 +2862,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "libc", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", @@ -2865,7 +2877,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-graphics", "objc2-foundation 0.3.2", ] @@ -2878,7 +2890,7 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.13.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -2892,7 +2904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -2904,7 +2916,7 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -2916,7 +2928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2927,7 +2939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2940,7 +2952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -2951,7 +2963,7 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2965,7 +2977,7 @@ dependencies = [ "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -2976,7 +2988,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -2988,7 +3000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -3000,7 +3012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-contacts", "objc2-foundation 0.2.2", ] @@ -3021,7 +3033,7 @@ dependencies = [ "block2 0.5.1", "dispatch", "libc", - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -3033,7 +3045,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3055,7 +3067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3066,7 +3078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -3079,7 +3091,7 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3091,7 +3103,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3103,7 +3115,7 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -3115,7 +3127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3128,7 +3140,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3140,7 +3152,7 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", @@ -3160,7 +3172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3172,7 +3184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3184,7 +3196,7 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -3599,9 +3611,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -3932,7 +3944,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -4061,7 +4073,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4278,7 +4290,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "3.0.0" +version = "3.0.1" dependencies = [ "android-activity", "android_logger", @@ -4296,7 +4308,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.0.0" +version = "3.0.1" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4309,7 +4321,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "3.0.0" +version = "3.0.1" dependencies = [ "cc", "libc", @@ -4318,7 +4330,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.0.0" +version = "3.0.1" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4335,7 +4347,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.0.0" +version = "3.0.1" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4346,7 +4358,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "3.0.0" +version = "3.0.1" dependencies = [ "anstyle", "arboard", @@ -4405,18 +4417,18 @@ dependencies = [ [[package]] name = "rustynes-gamedb" -version = "3.0.0" +version = "3.0.1" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-gfx-shaders" -version = "3.0.0" +version = "3.0.1" [[package]] name = "rustynes-hdpack" -version = "3.0.0" +version = "3.0.1" dependencies = [ "lewton", "png", @@ -4427,7 +4439,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "3.0.0" +version = "3.0.1" dependencies = [ "bytemuck", "cpal", @@ -4441,7 +4453,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "3.0.0" +version = "3.0.1" dependencies = [ "libc", "rust-libretro", @@ -4451,7 +4463,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.0.0" +version = "3.0.1" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4463,7 +4475,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "3.0.0" +version = "3.0.1" dependencies = [ "rustynes-core", "rustynes-gamedb", @@ -4479,7 +4491,7 @@ dependencies = [ [[package]] name = "rustynes-netplay" -version = "3.0.0" +version = "3.0.1" dependencies = [ "futures-util", "js-sys", @@ -4495,7 +4507,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.0.0" +version = "3.0.1" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4507,21 +4519,21 @@ dependencies = [ [[package]] name = "rustynes-probe" -version = "3.0.0" +version = "3.0.1" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-ra" -version = "3.0.0" +version = "3.0.1" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "3.0.0" +version = "3.0.1" dependencies = [ "mlua", "piccolo", @@ -4532,7 +4544,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "3.0.0" +version = "3.0.1" dependencies = [ "insta", "png", @@ -5247,9 +5259,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -5640,9 +5652,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.1" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "atomic", "getrandom 0.4.3", @@ -5932,7 +5944,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -6172,7 +6184,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -6587,7 +6599,7 @@ dependencies = [ "libc", "memmap2", "ndk", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", @@ -6731,18 +6743,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "6400fc4bb7426f6faac3fb07566122bb18622e3022bd2c36426a92ed489b1dbe" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "99c27cc7525bad4df59a45e073c45b2c2aa820efb588234cc23a547ec69a34db" dependencies = [ "proc-macro2", "quote", @@ -6772,9 +6784,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 7c74c105c..a46a81c86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,9 +77,13 @@ default-members = ["crates/rustynes-libretro"] # `release-auto.yml` reads the `## [X.Y.Z]` line for BOTH the release body # fallback and the title codename — so the date and quoted codename are load- # bearing, not decoration. -version = "3.0.0" +version = "3.0.1" edition = "2024" -rust-version = "1.96" +# The toolchain is 1.99 (rust-toolchain.toml), and every crate inherits this +# floor, the libretro path included: the libretro buildbot follows the same pin +# (`RUSTUP_TOOLCHAIN` in `.gitlab-ci.yml`, which CI's `libretro-cross` checks +# against rust-toolchain.toml). v3.0.1 held seven crates at "1.96" for a day. +rust-version = "1.99" license = "GPL-3.0-or-later" authors = ["DoubleGate "] repository = "https://github.com/doublegate/RustyNES" diff --git a/crates/rustynes-apu/src/blip.rs b/crates/rustynes-apu/src/blip.rs index a2f5e63fb..aece2fa43 100644 --- a/crates/rustynes-apu/src/blip.rs +++ b/crates/rustynes-apu/src/blip.rs @@ -444,7 +444,7 @@ mod tests { let _ = b.drain_all(); let kept = b.samples.capacity(); let empty = b.drain_all(); - assert!(empty.is_empty()); + assert_eq!(empty, [] as [f32; 0]); assert_eq!(empty.capacity(), 0, "no buffer handed out for nothing"); assert_eq!(b.samples.capacity(), kept, "the kept buffer stays"); } diff --git a/crates/rustynes-apu/src/lib.rs b/crates/rustynes-apu/src/lib.rs index 269925e8b..35c77c504 100644 --- a/crates/rustynes-apu/src/lib.rs +++ b/crates/rustynes-apu/src/lib.rs @@ -95,6 +95,6 @@ mod tests { #[test] fn version_is_non_empty() { - assert!(!version().is_empty()); + assert_ne!(version(), ""); } } diff --git a/crates/rustynes-cheevos/Cargo.toml b/crates/rustynes-cheevos/Cargo.toml index 956f5e451..f886d11ea 100644 --- a/crates/rustynes-cheevos/Cargo.toml +++ b/crates/rustynes-cheevos/Cargo.toml @@ -2,7 +2,7 @@ name = "rustynes-cheevos" version.workspace = true edition = "2024" -rust-version = "1.96" +rust-version.workspace = true license = "GPL-3.0-or-later" authors = ["Parobek "] repository.workspace = true diff --git a/crates/rustynes-cheevos/src/lib.rs b/crates/rustynes-cheevos/src/lib.rs index 4e182b653..cfbd09cea 100644 --- a/crates/rustynes-cheevos/src/lib.rs +++ b/crates/rustynes-cheevos/src/lib.rs @@ -88,8 +88,8 @@ mod smoke { ); // With no game loaded these are empty/zero but must not crash. - assert!(client.achievement_list().is_empty()); - assert!(client.leaderboard_list().is_empty()); + assert_eq!(client.achievement_list(), [] as [client::RaAchievement; 0]); + assert_eq!(client.leaderboard_list(), [] as [client::RaLeaderboard; 0]); assert_eq!(client.user_game_summary(), RaGameSummary::default()); assert!(client.user_info().is_none()); let _ = client.rich_presence(); diff --git a/crates/rustynes-core/benches/snapshot_restore.rs b/crates/rustynes-core/benches/snapshot_restore.rs index 5db1bc3b0..adb4fe273 100644 --- a/crates/rustynes-core/benches/snapshot_restore.rs +++ b/crates/rustynes-core/benches/snapshot_restore.rs @@ -101,8 +101,9 @@ fn bench_rom(c: &mut Criterion, label: &str, rel: &str) { // // Written because the estimate that motivated F19 was unsound: the // framebuffer is 94% of the snapshot BYTES, and that was silently carried - // over into a claim about TIME. A 245 KiB memcpy is ~12-25 us at ordinary - // bandwidth, so if `restore_quiet` costs 121 us the framebuffer cannot be + // over into a claim about TIME. A 245 KiB memcpy is ~12-25 us at an + // ordinary ~10-20 GB/s (245,760 B / 20 GB/s = 12.3 us; / 10 GB/s = + // 24.6 us), so if `restore_quiet` costs 121 us the framebuffer cannot be // 94% of it, and the win could be a fraction of what F19 assumed. Measure // the pair rather than reason about the ratio. c.bench_function(&format!("nes_restore_quiet_slim_{label}"), |b| { @@ -223,7 +224,7 @@ fn flash_board_rom(mapper: u8, battery: bool) -> Vec { rom[5] = 0; // CHR-RAM rom[6] = ((mapper & 0x0F) << 4) | if battery { 0x02 } else { 0 }; rom[7] = mapper & 0xF0; - for bank in rom[16..].chunks_exact_mut(0x4000) { + for bank in rom[16..].as_chunks_mut::<0x4000>().0 { bank[0..3].copy_from_slice(&[0x4C, 0x00, 0xC0]); // JMP $C000 bank[0x3FFA..0x4000].copy_from_slice(&[0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0]); } diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 657b3e836..ae2de7726 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -1260,7 +1260,11 @@ impl SystemBus { // Byte 3 (bits 24-31) — extracted without a truncating cast. s.to_le_bytes()[3] }; - for byte in self.ram.iter_mut() { + // `&mut *self.ram` reborrows the array: iterating `&mut Box<[T; N]>` + // directly needs Rust 1.97+. The crate's floor is 1.99, so either form + // compiles today; the reborrow dates from v3.0.1's one-day split, when + // the libretro buildbot briefly built this crate on 1.96. + for byte in &mut *self.ram { *byte = next(); } self.open_bus = next(); @@ -3342,8 +3346,8 @@ impl SystemBus { /// W3-Stage-1 (`mc-r1-dma-unified`): ONE cycle of the unified DMC/OAM DMA /// engine — a direct port of the `TriCNES` `_6502` per-cycle DMA dispatch - /// table (`crates/rustynes-test-harness/golden/tricnes/tricnes-harness-src/ - /// Emulator.cs` ~4233-4357), the SINGLE driver that standalone DMC, + /// table (the instrumented harness's `Emulator.cs` ~4233-4357; out of the + /// repository since v3.0.1, at `~/reference-oracles/TriCNES-rustynes-harness`), the SINGLE driver that standalone DMC, /// standalone OAM, and the DMC-during-OAM overlap all ride — AT FLOOR /// PARITY for this stage (the structural-equivalence proof; Stage 2 flips /// the one engine to the breakthrough parity). diff --git a/crates/rustynes-core/src/cpu_boot_trace.rs b/crates/rustynes-core/src/cpu_boot_trace.rs index c00a787f0..2b5703d39 100644 --- a/crates/rustynes-core/src/cpu_boot_trace.rs +++ b/crates/rustynes-core/src/cpu_boot_trace.rs @@ -419,7 +419,7 @@ impl CpuBootTrace { } let n = body.len() / RECORD_SIZE; let mut records = Vec::with_capacity(n); - for chunk in body.chunks_exact(RECORD_SIZE) { + for chunk in body.as_chunks::().0 { let rec = CpuBootRecord::from_bytes(chunk) .ok_or_else(|| String::from("CpuBootRecord::from_bytes returned None"))?; records.push(rec); diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index e4c75fc30..4b3bfd374 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -87,10 +87,15 @@ const MAX_GENIE_CODES: usize = u8::MAX as usize; /// from) do not bump it. A release that moved goldens without raising it /// breaks the promise this constant exists to keep. /// -/// It is 1 at v3.0.0, the first release to carry it. Earlier builds have no +/// It was 1 at v3.0.0, the first release to carry it. Earlier builds have no /// epoch, and are refused by the movie format (5) and the protocol (6) /// instead. -pub const EMULATION_EPOCH: u32 = 1; +/// +/// | epoch | release | what moved | +/// | --- | --- | --- | +/// | 1 | v3.0.0 | the MMC3 background-at-`$1000` A12 rule (T-MMC3-BG-A12) | +/// | 2 | v3.0.1 | mapper 45 CHR-RAM unbanked (T-GA23C-CHRRAM; *Famicom Yarou Vol.1*) | +pub const EMULATION_EPOCH: u32 = 2; /// Every host-settable option that changes what the emulated console does. /// @@ -862,7 +867,7 @@ mod tests { #[test] fn differences_name_each_differing_option() { let a = HardwareOptions::default(); - assert!(a.differences(&a).is_empty()); + assert_eq!(a.differences(&a), [] as [&str; 0]); let b = HardwareOptions { oam_decay: true, console_model: ConsoleModel::Famicom, diff --git a/crates/rustynes-core/src/lib.rs b/crates/rustynes-core/src/lib.rs index f57c6d6c0..11aa9bbc2 100644 --- a/crates/rustynes-core/src/lib.rs +++ b/crates/rustynes-core/src/lib.rs @@ -218,6 +218,6 @@ mod tests { #[test] fn version_is_non_empty() { - assert!(!version().is_empty()); + assert_ne!(version(), ""); } } diff --git a/crates/rustynes-core/src/movie.rs b/crates/rustynes-core/src/movie.rs index dfee1cc2f..d368fea7a 100644 --- a/crates/rustynes-core/src/movie.rs +++ b/crates/rustynes-core/src/movie.rs @@ -369,12 +369,10 @@ impl RollingHash { /// zero-padded, which is unambiguous here because every input is a /// fixed-size framebuffer. fn write(&mut self, bytes: &[u8]) { - let mut chunks = bytes.chunks_exact(8); - for c in &mut chunks { - let w = u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]); - self.0 = (self.0 ^ w).wrapping_mul(Self::PRIME); + let (words, rem) = bytes.as_chunks::<8>(); + for w in words { + self.0 = (self.0 ^ u64::from_le_bytes(*w)).wrapping_mul(Self::PRIME); } - let rem = chunks.remainder(); if !rem.is_empty() { let mut buf = [0u8; 8]; buf[..rem.len()].copy_from_slice(rem); diff --git a/crates/rustynes-core/src/movie_interop.rs b/crates/rustynes-core/src/movie_interop.rs index 2484547f0..f6c65b473 100644 --- a/crates/rustynes-core/src/movie_interop.rs +++ b/crates/rustynes-core/src/movie_interop.rs @@ -373,7 +373,7 @@ pub fn export_fm2(movie: &Movie, opts: &Fm2ExportOpts) -> Result"] repository = "https://github.com/doublegate/RustyNES" diff --git a/crates/rustynes-cosim/src/bin/nes_golden_export.rs b/crates/rustynes-cosim/src/bin/nes_golden_export.rs index 3ff7c6102..33f0dc87e 100644 --- a/crates/rustynes-cosim/src/bin/nes_golden_export.rs +++ b/crates/rustynes-cosim/src/bin/nes_golden_export.rs @@ -410,7 +410,6 @@ fn write_irq_artifacts(o: &mut Oracle, base: &Path, interval: u64) -> (usize, us eprintln!(" WARNING: irq trace was armed but returned nothing"); return (0, 0); }; - write(&suffixed(base, "irq.csv"), a.csv.as_bytes()); // Written BEFORE the checkpoints, and outside the `Err` arm below, on // purpose. This is the full-capture stream: it is the only artifact the @@ -427,6 +426,10 @@ fn write_irq_artifacts(o: &mut Oracle, base: &Path, interval: u64) -> (usize, us match a.checkpoints { Ok(ck) => { + // The CSV is written only here, unlike `obs.bin` above: it carries + // no overflow marker, so a CSV from an overflowed trace would be a + // truncated file indistinguishable from a complete one. + write(&suffixed(base, "irq.csv"), a.csv.as_bytes()); write( &suffixed(base, "ckpt.bin"), &rustynes_cosim::checkpoint::to_bytes(&ck), @@ -435,8 +438,19 @@ fn write_irq_artifacts(o: &mut Oracle, base: &Path, interval: u64) -> (usize, us } // Refuse rather than emitting a short stream: a hash over a trace that // dropped records covers fewer cycles than it claims, and the DUT would - // be blamed for our truncation. - Err(e) => panic!(" ERROR: {e}"), + // be blamed for our truncation. First remove any CSV and checkpoints a + // previous run left under this stem (v3.0.1): they look complete, and + // beside this run's `obs.bin` they would read as its output. + Err(e) => { + for stale in [suffixed(base, "irq.csv"), suffixed(base, "ckpt.bin")] { + match std::fs::remove_file(&stale) { + Ok(()) => eprintln!(" removed stale {}", stale.display()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => eprintln!(" WARNING: could not remove {}: {err}", stale.display()), + } + } + panic!(" ERROR: {e}") + } } } @@ -1112,6 +1126,53 @@ mod tests { ); } + /// An overflowed trace must not leave a truncated `irq.csv` behind. + /// + /// The CSV carries no overflow marker (`IrqTrace::to_csv` has no such + /// field), so a CSV written before the refusal below is indistinguishable + /// from a complete one. `obs.bin` is kept on overflow deliberately and says + /// why; the CSV has no such rationale, so it is written only once the + /// checkpoints are known to be good. + #[test] + fn an_overflowed_trace_writes_no_irq_csv() { + // A 16 KiB NROM whose reset vector points at `JMP $8000`. + let mut rom = vec![0u8; 16 + 16384 + 8192]; + rom[..4].copy_from_slice(b"NES\x1a"); + rom[4] = 1; + rom[5] = 1; + rom[16..19].copy_from_slice(&[0x4C, 0x00, 0x80]); + let vec_base = 16 + 16384 - 6; + rom[vec_base..vec_base + 6].copy_from_slice(&[0x00, 0x80, 0x00, 0x80, 0x00, 0x80]); + + let mut o = rustynes_cosim::Oracle::new(&rom, 0).expect("rom"); + o.enable_irq_trace(64); // far below one frame of CPU cycles + o.advance_frames(1); + + let dir = std::env::temp_dir().join(format!( + "rustynes-cosim-overflow-csv-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let base = dir.join("overflow"); + // A previous, SUCCESSFUL run with the same stem (v3.0.1, Copilot on + // #591): its complete-looking CSV and checkpoints must not survive a + // failed rerun, or they read as that run's output. + std::fs::write(suffixed(&base, "irq.csv"), b"stale csv").expect("stale csv"); + std::fs::write(suffixed(&base, "ckpt.bin"), b"stale ckpt").expect("stale ckpt"); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + super::write_irq_artifacts(&mut o, &base, 4096) + })); + let csv_exists = suffixed(&base, "irq.csv").exists(); + let ckpt_exists = suffixed(&base, "ckpt.bin").exists(); + let _ = std::fs::remove_dir_all(&dir); + assert!(result.is_err(), "an overflowed trace must still refuse"); + assert!( + !csv_exists, + "an irq.csv (truncated, or a previous run's) was left on disk" + ); + assert!(!ckpt_exists, "a previous run's ckpt.bin was left on disk"); + } + /// Pinned against an independently-known digest, not against our own output. #[test] fn sha256_matches_the_known_empty_digest() { diff --git a/crates/rustynes-cosim/src/checkpoint.rs b/crates/rustynes-cosim/src/checkpoint.rs index 2245bead0..b29d5d998 100644 --- a/crates/rustynes-cosim/src/checkpoint.rs +++ b/crates/rustynes-cosim/src/checkpoint.rs @@ -174,13 +174,12 @@ impl Observable { pc: r.pc, bus_addr: r.bus_addr, bus_data: r.bus_data, - bus_access: match r.bus_access { - BusAccess::Idle => 0, - BusAccess::Read => 1, - BusAccess::Write => 2, - BusAccess::DmaRead => 3, - BusAccess::DmaWrite => 4, - }, + // Through `access_code`, so the numbering lives in one place. + bus_access: Self::access_code( + matches!(r.bus_access, BusAccess::Idle), + matches!(r.bus_access, BusAccess::Write | BusAccess::DmaWrite), + matches!(r.bus_access, BusAccess::DmaRead | BusAccess::DmaWrite), + ), put_cycle: r.put_cycle_post, nmi_line: r.nmi_line, irq_line_at_low: r.irq_pending_mapper_at_low || r.irq_pending_apu_at_low, @@ -303,8 +302,10 @@ pub fn observables_from_bytes(bytes: &[u8]) -> Result, &'static return Err("observable stream length is not a multiple of 16 bytes"); } bytes - .chunks_exact(ENCODED_LEN) - .map(Observable::decode) + .as_chunks::() + .0 + .iter() + .map(|c| Observable::decode(c)) .collect() } @@ -478,11 +479,16 @@ impl Divergence { /// `None` rather than an assumed `through_cycle + 1`: that answer is only /// right if the run started at cycle 0, and a checkpoint stream carries no /// evidence that it did. + /// + /// Also `None` for an inverted window (`after_cycle > through_cycle`), + /// which no [`Hasher`] stream can produce; [`from_bytes`] refuses the + /// corrupt stream that would, and this guard keeps a hand-built + /// `Divergence` from underflowing. #[must_use] pub const fn window_len(&self) -> Option { match self.after_cycle { None => None, - Some(after) => Some(self.through_cycle - after), + Some(after) => self.through_cycle.checked_sub(after), } } } @@ -604,9 +610,9 @@ pub const fn localisation_is_consistent( ) -> bool { match (comparison, first_difference) { (Comparison::Identical { .. }, None) => true, - // A false positive: the streams agree and the gate says otherwise. - (Comparison::Identical { .. }, Some(_)) => false, // The failure that matters: a real difference reported as agreement. + (Comparison::Identical { .. }, Some(_)) => false, + // A false positive: the streams agree and the gate says otherwise. (Comparison::Diverged(_) | Comparison::Inconclusive { .. }, None) => false, (Comparison::Diverged(d), Some(k)) => d.contains(k), // Honest refusal on a real difference. @@ -638,6 +644,10 @@ pub fn to_bytes(checkpoints: &[Checkpoint]) -> Vec { /// producer was interrupted, and a truncated stream that parses is a truncated /// comparison that passes. /// +/// Also if `through_cycle` does not strictly increase from one record to the +/// next. [`Hasher`] cannot produce such a stream, and [`compare`] given one +/// reports a [`Divergence`] whose window ends before it starts. +/// /// # Panics /// /// Never in practice: the `expect`s convert 8-byte subslices of a @@ -648,13 +658,22 @@ pub fn from_bytes(bytes: &[u8]) -> Result, &'static str> { if !bytes.len().is_multiple_of(16) { return Err("checkpoint stream length is not a multiple of 16 bytes"); } - Ok(bytes - .chunks_exact(16) + let stream: Vec = bytes + .as_chunks::<16>() + .0 + .iter() .map(|c| Checkpoint { through_cycle: u64::from_le_bytes(c[0..8].try_into().expect("8 bytes")), hash: u64::from_le_bytes(c[8..16].try_into().expect("8 bytes")), }) - .collect()) + .collect(); + if stream + .windows(2) + .any(|w| w[1].through_cycle <= w[0].through_cycle) + { + return Err("checkpoint stream through_cycle does not strictly increase"); + } + Ok(stream) } #[cfg(test)] @@ -1005,6 +1024,49 @@ mod tests { assert!(from_bytes(&bytes).is_err()); } + /// A stream whose `through_cycle` does not strictly increase cannot have + /// come from [`Hasher`], and fed to [`compare`] it produces a + /// [`Divergence`] whose `after_cycle` exceeds its `through_cycle`. + #[test] + fn a_non_increasing_serialised_stream_is_rejected() { + let backwards = [ + Checkpoint { + through_cycle: 8191, + hash: 1, + }, + Checkpoint { + through_cycle: 4095, + hash: 2, + }, + ]; + assert!(from_bytes(&to_bytes(&backwards)).is_err()); + let repeated = [ + Checkpoint { + through_cycle: 4095, + hash: 1, + }, + Checkpoint { + through_cycle: 4095, + hash: 2, + }, + ]; + assert!(from_bytes(&to_bytes(&repeated)).is_err()); + } + + /// `window_len` must not underflow on an inverted window, which only a + /// corrupt stream (or a hand-built `Divergence`) can produce. + #[test] + fn an_inverted_window_has_no_length() { + let d = Divergence { + index: 1, + after_cycle: Some(8191), + through_cycle: 4095, + reference_hash: 1, + candidate_hash: 2, + }; + assert_eq!(d.window_len(), None); + } + #[test] #[should_panic(expected = "checkpoint interval must be non-zero")] fn a_zero_interval_is_rejected() { @@ -1328,4 +1390,28 @@ mod tests { assert_eq!(Observable::access_code(false, false, true), 3); assert_eq!(Observable::access_code(false, true, true), 4); } + + /// The projection and [`Observable::access_code`] must agree for every + /// [`BusAccess`] variant. Until v3.0.1 `from_cycle_record` carried its own + /// copy of the numbering, which only `access_codes_are_stable` pinned. + #[test] + fn every_bus_access_projects_to_its_access_code() { + for (access, idle, write, dma, code) in [ + (BusAccess::Idle, true, false, false, 0), + (BusAccess::Read, false, false, false, 1), + (BusAccess::Write, false, true, false, 2), + (BusAccess::DmaRead, false, false, true, 3), + (BusAccess::DmaWrite, false, true, true, 4), + ] { + let mut r = record(7); + r.bus_access = access; + let projected = Observable::from_cycle_record(&r).bus_access; + assert_eq!(projected, code, "{access:?}"); + assert_eq!( + projected, + Observable::access_code(idle, write, dma), + "{access:?}" + ); + } + } } diff --git a/crates/rustynes-cosim/src/lib.rs b/crates/rustynes-cosim/src/lib.rs index cf9726e11..f48ddbdbc 100644 --- a/crates/rustynes-cosim/src/lib.rs +++ b/crates/rustynes-cosim/src/lib.rs @@ -76,7 +76,7 @@ const APU_REC_LEN: usize = 16; /// Upper bound on `--apu-trace`'s capacity, in RECORDS. /// /// One record is one CPU cycle, so this is 8,388,608 cycles -- roughly 281 NTSC -/// frames, and 134 MB once multiplied by [`APU_REC_LEN`]. Comfortably above any +/// frames, and 134 MB once multiplied by `APU_REC_LEN`. Comfortably above any /// legitimate rung-4 stimulus (a 24-frame run wants ~715,000) and bounded. /// /// The bound exists because the capacity is external input that reaches @@ -468,6 +468,14 @@ impl Oracle { /// /// Pinned by `tests::taking_the_csv_first_leaves_no_trace_for_checkpoints`, /// so the hazard is a documented behaviour rather than a surprise. + /// + /// # Panics + /// + /// If `interval` is zero and the trace did not overflow, through + /// `checkpoint::Hasher::new`'s `interval > 0` assert. The trace has + /// already been taken by then, so it is lost with the panic. Callers that + /// take the interval from outside validate it first: the C ABI returns + /// `-6` and `nes_golden_export` rejects `0` on the command line. pub fn take_irq_artifacts(&mut self, interval: u64) -> Option { let trace = self.nes.bus_mut().take_irq_trace()?; let dropped = trace.overflow(); @@ -514,6 +522,13 @@ impl Oracle { /// # Errors /// /// [`CheckpointError::TraceOverflowed`] if the trace dropped any record. + /// + /// # Panics + /// + /// If `interval` is zero and the trace did not overflow, through + /// `checkpoint::Hasher::new`'s `interval > 0` assert. As with + /// [`Self::take_irq_artifacts`], the trace is consumed before the assert + /// fires. pub fn take_checkpoints( &mut self, interval: u64, @@ -863,6 +878,12 @@ pub unsafe extern "C" fn rn_write_cpu_boot_trace( /// records themselves are simply the records, and are worth having for a /// full-capture re-run. /// +/// **Consumes the trace**, as do [`rn_write_checkpoints`] and +/// [`rn_write_irq_trace_csv`]: whichever of the three is called first gets the +/// trace, and the other two return `-4` ("never armed") afterwards. A C +/// testbench can therefore obtain only one of the three artifacts per run; +/// the Rust-side [`Oracle::take_irq_artifacts`] yields all three from one take. +/// /// # Safety /// /// `handle` must come from [`rn_open`] and not have been closed. `path` must be @@ -870,7 +891,9 @@ pub unsafe extern "C" fn rn_write_cpu_boot_trace( #[unsafe(no_mangle)] pub unsafe extern "C" fn rn_write_observables(handle: *mut c_void, path: *const c_char) -> c_int { let oracle = oracle!(handle, -1); - // SAFETY: as above. + // SAFETY: `cstr_to_path` returns `None` for a null `path`; a non-null + // `path` is NUL-terminated and valid for the duration of this call, per + // this function's `# Safety` contract. The borrow ends before it returns. let Some(p) = (unsafe { cstr_to_path(path) }) else { return -2; }; @@ -892,6 +915,10 @@ pub unsafe extern "C" fn rn_write_observables(handle: *mut c_void, path: *const /// divergence -- raise the capacity and re-run), `-6` interval was zero. /// Anything at or below `-100` is `-(100 + errno)` from the write itself. /// +/// **Consumes the trace** (unless `interval` is zero, which returns `-6` +/// before touching it), so a later [`rn_write_observables`] or +/// [`rn_write_irq_trace_csv`] returns `-4` ("never armed"). +/// /// # Safety /// /// `handle` must come from [`rn_open`] and not have been closed. `path` must be @@ -909,7 +936,9 @@ pub unsafe extern "C" fn rn_write_checkpoints( return -6; } let oracle = oracle!(handle, -1); - // SAFETY: as above. + // SAFETY: `cstr_to_path` returns `None` for a null `path`; a non-null + // `path` is NUL-terminated and valid for the duration of this call, per + // this function's `# Safety` contract. The borrow ends before it returns. let Some(path) = (unsafe { cstr_to_path(path) }) else { return -2; }; @@ -925,9 +954,13 @@ pub unsafe extern "C" fn rn_write_checkpoints( /// Write the IRQ/bus trace CSV to `path`. Returns 0 on success, negative on error. /// -/// **Consumes the trace**, so a subsequent [`rn_write_checkpoints`] returns -/// `-4` ("never armed"). A testbench wanting both must write the checkpoints -/// first, or use the Rust-side [`Oracle::take_irq_artifacts`]. +/// **Consumes the trace**, so a subsequent [`rn_write_checkpoints`] or +/// [`rn_write_observables`] returns `-4` ("never armed"). The checkpoint and +/// observable writers consume it too, so calling them first does not help: a +/// testbench gets one of the three per run, and only the Rust-side +/// [`Oracle::take_irq_artifacts`] yields all three from one take. (Until +/// v3.0.1 this said to write the checkpoints first, which leaves no trace for +/// the CSV.) /// /// # Safety /// @@ -935,7 +968,9 @@ pub unsafe extern "C" fn rn_write_checkpoints( #[unsafe(no_mangle)] pub unsafe extern "C" fn rn_write_irq_trace_csv(handle: *mut c_void, path: *const c_char) -> c_int { let o = oracle!(handle, -1); - // SAFETY: as above. + // SAFETY: `cstr_to_path` returns `None` for a null `path`; a non-null + // `path` is NUL-terminated and valid for the duration of this call, per + // this function's `# Safety` contract. The borrow ends before it returns. let Some(p) = (unsafe { cstr_to_path(path) }) else { return -2; }; @@ -1081,7 +1116,7 @@ mod tests { .take_irq_artifacts(checkpoint::DEFAULT_INTERVAL) .expect("armed"); assert!(a.csv.starts_with("cpu_cycle,")); - assert!(!a.checkpoints.expect("no overflow").is_empty()); + assert_ne!(a.checkpoints.expect("no overflow").len(), 0); } /// The `CpuBootTrace` wire format, pinned to a literal. diff --git a/crates/rustynes-cpu/src/cpu.rs b/crates/rustynes-cpu/src/cpu.rs index 17c318734..c361ef560 100644 --- a/crates/rustynes-cpu/src/cpu.rs +++ b/crates/rustynes-cpu/src/cpu.rs @@ -254,7 +254,11 @@ pub struct Cpu { /// taken / page-cross cycles do *not* re-sample IRQ. The branch /// dispatch sets this flag *before* the operand fetch and `step()` /// clears it at the top of every instruction. - /// NMI sampling is unaffected — the quirk is IRQ-only. + /// Since v2.6.7 the same rule defers NMI *dispatch*: while this flag and + /// `skip_irq_sample_q` are both set, `handle_interrupts` freezes the + /// dispatch copy `mc_prev_need_nmi`. The NMI edge latch (`mc_need_nmi`) + /// keeps running every cycle, so an edge is never lost, only recognised + /// after the branch. pub(crate) skip_irq_sample: bool, /// `skip_irq_sample` as it stood on the PREVIOUS cycle. The NMI dispatch /// gate freezes on this rather than on the live flag, because the two @@ -851,9 +855,7 @@ impl Cpu { #[inline(always)] #[allow(clippy::inline_always)] fn implied_dummy_read(&mut self, bus: &mut B) { - { - let _ = self.read1(bus, self.pc); - } + let _ = self.read1(bus, self.pc); } /// Read a byte at `addr` *and* consume one CPU cycle (with bus tick @@ -2858,7 +2860,7 @@ impl Cpu { // W1 (`mc-r1-branch-poll-points`): a page-cross taken branch // polls a SECOND time at C4-start — TriCNES's // `PollInterrupts_CantDisableIRQ` in the BPL microcode - // (`golden/tricnes/tricnes-full-src/Emulator.cs`): if the C2-start + // (`100thCoin/TriCNES` `Emulator.cs` at `94f1b117`): if the C2-start // poll already saw the IRQ this one cannot un-see it (can-SET- // not-clear). `mc_run_irq` is frozen across the branch's // remaining cycles by the `handle_interrupts` early-return, so diff --git a/crates/rustynes-cpu/src/lib.rs b/crates/rustynes-cpu/src/lib.rs index 8b71a63c6..942a9d240 100644 --- a/crates/rustynes-cpu/src/lib.rs +++ b/crates/rustynes-cpu/src/lib.rs @@ -52,6 +52,6 @@ mod tests { #[test] fn version_is_non_empty() { - assert!(!version().is_empty()); + assert_ne!(version(), ""); } } diff --git a/crates/rustynes-cpu/src/snapshot.rs b/crates/rustynes-cpu/src/snapshot.rs index f1b47ef84..fedb438c8 100644 --- a/crates/rustynes-cpu/src/snapshot.rs +++ b/crates/rustynes-cpu/src/snapshot.rs @@ -48,8 +48,10 @@ use crate::status::Status; /// BRANCH, so suppressing the poll needs the flag's value on the previous /// cycle as well as this one. It is genuine emulation state read back on /// the next tick, not derivable from the rest of the blob — a restore that -/// dropped it would resume with the NMI edge detector re-armed a cycle -/// early on any snapshot landing inside a taken branch. Serialized rather +/// dropped it would resume with the NMI dispatch gate (the +/// `mc_prev_need_nmi` copy; the edge latch `mc_need_nmi` runs every cycle +/// regardless) re-opened a cycle early on any snapshot landing inside a +/// taken branch. Serialized rather /// than allowlisted, which `snapshot_schema_audit` says has been the right /// answer every time it has come up. pub const CPU_SNAPSHOT_VERSION: u8 = 4; diff --git a/crates/rustynes-frontend/src/about_fx.rs b/crates/rustynes-frontend/src/about_fx.rs index 3956d5ff7..3c540ce01 100644 --- a/crates/rustynes-frontend/src/about_fx.rs +++ b/crates/rustynes-frontend/src/about_fx.rs @@ -135,7 +135,9 @@ fn decode_png(bytes: &[u8]) -> Option<(Vec, usize, usize)> { let rgba = match info.color_type { png::ColorType::Rgba => buf, png::ColorType::Rgb => buf - .chunks_exact(3) + .as_chunks::<3>() + .0 + .iter() .flat_map(|p| [p[0], p[1], p[2], 0xFF]) .collect(), _ => return None, diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 1af432a37..4ca001d1a 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -1817,11 +1817,21 @@ impl App { // `NES_W*NES_H*4` frame so the next upload uploads black, not nothing. #[cfg(all(not(target_arch = "wasm32"), feature = "emu-thread"))] self.present_buffer.reset(); - self.present_staging.clear(); - self.present_staging.resize((NES_W * NES_H * 4) as usize, 0); + // v3.0.1 — the palette-index half is blanked the same way when an index + // consumer is active, not merely cleared: an empty slice makes `Gfx` + // skip the upload and keep the last game frame in the index texture. + let want_index = self + .gfx + .as_ref() + .is_some_and(|g| g.ntsc_bisqwit_active() || g.shader_stack_needs_index()); + self.present_index_staging.clear(); + Self::blank_present_staging( + &mut self.present_staging, + &mut self.present_index_staging, + want_index, + ); // Drop the sibling presentation buffers too, so a stale frame can't be // re-presented after the ROM is closed. - self.present_index_staging.clear(); #[cfg(feature = "hd-pack")] { self.present_hd_tiles.clear(); @@ -8857,6 +8867,26 @@ impl App { } } + /// Fill the present staging with a BLACK NES frame: a zeroed + /// `NES_W*NES_H*4` RGBA image and, when `want_index` (the true composite + /// `NES_NTSC` filter, or a shader pass that samples the index texture), a + /// full `NES_W*NES_H` palette-index frame of colour `$0F` (black, no + /// emphasis). + /// + /// The index half matters because `Gfx` skips the index upload on a length + /// mismatch and keeps the texture's previous contents. Until v3.0.1 the + /// no-ROM paths refilled only the RGBA half and `close_rom` cleared the + /// index half to empty, so with the Bisqwit filter on, the last game frame + /// stayed on screen after the ROM was closed instead of black. + fn blank_present_staging(rgba: &mut Vec, index: &mut Vec, want_index: bool) { + rgba.clear(); + rgba.resize((NES_W * NES_H * 4) as usize, 0); + if want_index { + index.clear(); + index.resize((NES_W * NES_H) as usize, 0x0F); + } + } + /// v1.1.0 beta.1 (T-110-A1) — select the gfx NTSC post-pass to match the /// `[graphics] ntsc_filter` mode, keeping the two NTSC filters mutually /// exclusive: `"composite-rt"` = the true composite Bisqwit filter, @@ -10926,8 +10956,11 @@ impl ApplicationHandler for App { } } } else { - self.present_staging.clear(); - self.present_staging.resize((NES_W * NES_H * 4) as usize, 0); + Self::blank_present_staging( + &mut self.present_staging, + &mut self.present_index_staging, + want_index, + ); } // `guard` drops here, releasing the emu lock BEFORE the // CPU-heavy composite below. @@ -11328,9 +11361,13 @@ impl ApplicationHandler for App { } } else { // No ROM: present a black NES image (the shell still - // draws on top). - self.present_staging.clear(); - self.present_staging.resize((NES_W * NES_H * 4) as usize, 0); + // draws on top) -- the index half too, when the + // Bisqwit filter or an index-sampling pass is on. + Self::blank_present_staging( + &mut self.present_staging, + &mut self.present_index_staging, + want_index, + ); } } // v1.2.0 C3 — lock dropped: now run the CPU-heavy HD composite @@ -12086,6 +12123,37 @@ mod tests { use super::{apply_load_time_header_overrides, apply_per_game_overlay}; use rustynes_core::Nes; + /// The no-ROM present must stage a BLACK index frame for the Bisqwit + /// filter, not leave the last game's index frame (or an empty slice, which + /// `Gfx` skips, keeping the old texture) in place. + #[test] + fn a_blank_present_stages_a_black_index_frame() { + use crate::gfx::{NES_H, NES_W}; + let n = (NES_W * NES_H) as usize; + // Stale game frame in both halves, as after a ROM has run. + let mut rgba = vec![0xAB; n * 4]; + let mut index = vec![0x21_u16; n]; + super::App::blank_present_staging(&mut rgba, &mut index, true); + assert_eq!(rgba.len(), n * 4); + assert!(rgba.iter().all(|&b| b == 0), "RGBA half is black"); + assert_eq!(index.len(), n, "a full frame, or Gfx skips the upload"); + assert!( + index.iter().all(|&i| i == 0x0F), + "index half is black ($0F)" + ); + + // `close_rom` cleared the index half to EMPTY; that must not survive + // a blank present either. + let mut empty: Vec = Vec::new(); + super::App::blank_present_staging(&mut rgba, &mut empty, true); + assert_eq!(empty.len(), n); + + // Without an index consumer the index half is left alone (zero cost). + let mut untouched: Vec = Vec::new(); + super::App::blank_present_staging(&mut rgba, &mut untouched, false); + assert_eq!(untouched, [] as [u16; 0]); + } + /// The CLI / initial-ROM path must apply the same load-time header /// corrections as the File-menu path. /// @@ -12094,8 +12162,48 @@ mod tests { /// image, so `rustynes ` skipped every mapper / submapper / region fix /// that opening the same ROM from the menu applied. Seicross is the case /// that matters -- it needs submapper 4 to clear its protection loop. + /// + /// Until v3.0.1 the synthetic image never matched a DB row, so the test + /// always took an early return that checked only "the helper is a no-op on + /// an unknown CRC" and never reached the comparison below. The image now + /// carries the real Seicross key: its last four hashed bytes are forged so + /// the header-excluded CRC32 equals the vendored row's. #[test] fn the_startup_path_applies_the_same_header_overrides_as_the_menu_path() { + /// Seicross (Japan) in the vendored table: mapper 185, submapper 4. + const SEICROSS_CRC: u32 = 0x0F05_FF0A; + const POLY: u32 = 0xEDB8_8320; + /// The reflected CRC-32 register after `data`, without the final XOR. + fn crc_reg(mut reg: u32, data: &[u8]) -> u32 { + for &b in data { + reg ^= u32::from(b); + for _ in 0..8 { + reg = if reg & 1 != 0 { + (reg >> 1) ^ POLY + } else { + reg >> 1 + }; + } + } + reg + } + /// Four bytes that, appended to `prefix`, give CRC-32 `target`. CRC is + /// linear, so 4 bytes `w` take register `s` to `step32(s ^ w)`; + /// running the 32 bit-steps backwards from the wanted register gives + /// `s ^ w`. A reverse step is unambiguous because `POLY` has bit 31 + /// set: a set top bit means the low bit shifted out was 1. + fn forge_tail(prefix: &[u8], target: u32) -> [u8; 4] { + let mut reg = !target; + for _ in 0..32 { + reg = if reg & 0x8000_0000 != 0 { + ((reg ^ POLY) << 1) | 1 + } else { + reg << 1 + }; + } + (reg ^ crc_reg(0xFFFF_FFFF, prefix)).to_le_bytes() + } + // Seicross: iNES 1.0, mapper 185, no submapper field of its own. // 32 KiB PRG + 8 KiB CHR so the header-excluded CRC is well defined. let mut rom = vec![0u8; 16 + 0x8000 + 0x2000]; @@ -12104,34 +12212,25 @@ mod tests { rom[5] = 1; // 8 KiB CHR rom[6] = 0x90; // mapper low nibble 9 rom[7] = 0xB0; // mapper high nibble B -> 185 + let end = rom.len(); + let tail = forge_tail(&rom[16..end - 4], SEICROSS_CRC); + rom[end - 4..].copy_from_slice(&tail); let crc = crate::game_db::rom_crc32(&rom).expect("iNES header parses"); - let Some(entry) = crate::game_db::load_time_entry(crc, &rom) else { - // Synthetic bytes will not match a real DB row; the point of the - // test is the CALL, so drive the helper with a known entry instead. - let mut a = rom.clone(); - let mut b = rom.clone(); - let e = crate::game_db::GameDbEntry { - crc, - region: None, - mapper: Some(4), - submapper: Some(4), - mirroring: None, - title: String::new(), - }; - crate::game_db::apply_header_overrides(&mut a, &e); - apply_load_time_header_overrides(&mut b, None); - assert_ne!(a, rom, "premise: the override does change the header"); - assert_eq!( - b, rom, - "no DB row for these synthetic bytes, so the helper is a no-op" - ); - return; - }; + assert_eq!( + crc, SEICROSS_CRC, + "premise: the forged image carries the key" + ); + let entry = crate::game_db::load_time_entry(crc, &rom).expect("Seicross row is listed"); + let mut via_helper = rom.clone(); apply_load_time_header_overrides(&mut via_helper, None); let mut via_direct = rom.clone(); crate::game_db::apply_header_overrides(&mut via_direct, &entry); + assert_ne!( + via_helper, rom, + "the startup helper must rewrite the header (submapper 4)" + ); assert_eq!( via_helper, via_direct, "the startup helper must produce the same header as the DB rewrite" diff --git a/crates/rustynes-frontend/src/audio.rs b/crates/rustynes-frontend/src/audio.rs index 2ba09e3fa..b6015cbcc 100644 --- a/crates/rustynes-frontend/src/audio.rs +++ b/crates/rustynes-frontend/src/audio.rs @@ -1323,7 +1323,7 @@ fn fill>( let v = match c { 0 => l, 1 => r, - _ => 0.5 * (l + r), + _ => f32::midpoint(l, r), }; data[out_idx] = S::from_sample(v); } diff --git a/crates/rustynes-frontend/src/cheats.rs b/crates/rustynes-frontend/src/cheats.rs index 171631cac..5c2357924 100644 --- a/crates/rustynes-frontend/src/cheats.rs +++ b/crates/rustynes-frontend/src/cheats.rs @@ -194,8 +194,8 @@ mod tests { fn load_missing_file_is_empty() { let tmp = TempDir::new().unwrap(); let back = load(tmp.path(), &h(0x00)); - assert!(back.genie.is_empty()); - assert!(back.raw.is_empty()); + assert_eq!(back.genie, [] as [crate::cheats::CheatEntry; 0]); + assert_eq!(back.raw, [] as [crate::cheats::RawCheat; 0]); } #[test] @@ -265,8 +265,8 @@ mod tests { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "this is = = not toml").unwrap(); let back = load(tmp.path(), &h(0x07)); - assert!(back.genie.is_empty()); - assert!(back.raw.is_empty()); + assert_eq!(back.genie, [] as [crate::cheats::CheatEntry; 0]); + assert_eq!(back.raw, [] as [crate::cheats::RawCheat; 0]); } /// Back-compat: a pre-v1.7.0 cheat file (Game Genie only, no `raw` key) @@ -282,7 +282,7 @@ mod tests { assert_eq!(back.genie.len(), 1); assert_eq!(back.genie[0].code, "SXIOPO"); assert!(back.genie[0].enabled); - assert!(back.raw.is_empty()); + assert_eq!(back.raw, [] as [crate::cheats::RawCheat; 0]); } /// A raw cheat with no `compare` key deserializes to `compare: None`. diff --git a/crates/rustynes-frontend/src/cli.rs b/crates/rustynes-frontend/src/cli.rs index 1fb303cb1..a990f1a54 100644 --- a/crates/rustynes-frontend/src/cli.rs +++ b/crates/rustynes-frontend/src/cli.rs @@ -540,7 +540,7 @@ mod tests { "topic id must be lowercase" ); assert!(seen.insert(t.id), "duplicate topic id: {}", t.id); - assert!(!t.title.is_empty()); + assert_ne!(t.title, ""); assert!(!t.body.is_empty(), "empty body for topic {}", t.id); } } diff --git a/crates/rustynes-frontend/src/config.rs b/crates/rustynes-frontend/src/config.rs index a990f2091..a71ed259d 100644 --- a/crates/rustynes-frontend/src/config.rs +++ b/crates/rustynes-frontend/src/config.rs @@ -1190,7 +1190,7 @@ pub fn parse_pal(bytes: &[u8]) -> Option<[[u8; 3]; 64]> { return None; } let mut pal = [[0u8; 3]; 64]; - for (i, chunk) in bytes[..192].chunks_exact(3).enumerate() { + for (i, chunk) in bytes[..192].as_chunks::<3>().0.iter().enumerate() { pal[i] = [chunk[0], chunk[1], chunk[2]]; } Some(pal) @@ -2291,7 +2291,6 @@ fn canonicalize_pad(pad: &PadBindings) -> PadBindings { #[cfg(test)] mod tests { - use super::*; use tempfile::TempDir; diff --git a/crates/rustynes-frontend/src/debugger/access_counter.rs b/crates/rustynes-frontend/src/debugger/access_counter.rs index 5be73619b..ad8170b10 100644 --- a/crates/rustynes-frontend/src/debugger/access_counter.rs +++ b/crates/rustynes-frontend/src/debugger/access_counter.rs @@ -121,7 +121,7 @@ impl MemoryAccessCounter { /// Zero every counter + clear the uninitialized-read state (e.g. on /// reset / power-cycle / a user "clear" click). pub fn reset(&mut self) { - for c in self.counters.iter_mut() { + for c in &mut self.counters { *c = AddressCounters::default(); } self.uninit_total = 0; diff --git a/crates/rustynes-frontend/src/debugger/atlas_panel.rs b/crates/rustynes-frontend/src/debugger/atlas_panel.rs index 2a225ee3c..c736a7119 100644 --- a/crates/rustynes-frontend/src/debugger/atlas_panel.rs +++ b/crates/rustynes-frontend/src/debugger/atlas_panel.rs @@ -891,7 +891,7 @@ mod tests { }; p.clear(); assert_eq!(p.take_watch_request(), None); - assert!(p.status.is_empty()); + assert_eq!(p.status, ""); } /// A ROM change must discard the whole atlas. Two thousand labels describing @@ -907,10 +907,10 @@ mod tests { ..AtlasPanel::default() }; p.clear(); - assert!(p.labels.is_empty()); + assert_eq!(p.labels, [] as [rustynes_probe::atlas::Label; 0]); assert_eq!(p.frames, 0); assert_eq!(p.selected, None); - assert!(p.status.is_empty()); + assert_eq!(p.status, ""); } /// A batch must never spend trials on untouched addresses: they are the bulk diff --git a/crates/rustynes-frontend/src/debugger/badge_cache.rs b/crates/rustynes-frontend/src/debugger/badge_cache.rs index bd92a4342..f686f9e3e 100644 --- a/crates/rustynes-frontend/src/debugger/badge_cache.rs +++ b/crates/rustynes-frontend/src/debugger/badge_cache.rs @@ -194,11 +194,15 @@ fn decode_png(bytes: &[u8]) -> Option { let rgba: Vec = match info.color_type { png::ColorType::Rgba => data.to_vec(), png::ColorType::Rgb => data - .chunks_exact(3) + .as_chunks::<3>() + .0 + .iter() .flat_map(|p| [p[0], p[1], p[2], 0xFF]) .collect(), png::ColorType::GrayscaleAlpha => data - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .flat_map(|p| [p[0], p[0], p[0], p[1]]) .collect(), png::ColorType::Grayscale => data.iter().flat_map(|&g| [g, g, g, 0xFF]).collect(), diff --git a/crates/rustynes-frontend/src/debugger/callstack.rs b/crates/rustynes-frontend/src/debugger/callstack.rs index 980feae17..d4a729d24 100644 --- a/crates/rustynes-frontend/src/debugger/callstack.rs +++ b/crates/rustynes-frontend/src/debugger/callstack.rs @@ -154,8 +154,8 @@ const OP_JMP_ABS: u8 = 0x4C; const OP_JMP_IND: u8 = 0x6C; impl CallstackTracker { - /// The current call stack, outermost first. - #[must_use] + /// The current call stack, outermost first. (No `#[must_use]`: an + /// iterator already carries it.) pub fn frames(&self) -> impl ExactSizeIterator + DoubleEndedIterator { self.stack.iter() } diff --git a/crates/rustynes-frontend/src/debugger/cpu_panel.rs b/crates/rustynes-frontend/src/debugger/cpu_panel.rs index 6d1890880..519ae6621 100644 --- a/crates/rustynes-frontend/src/debugger/cpu_panel.rs +++ b/crates/rustynes-frontend/src/debugger/cpu_panel.rs @@ -496,7 +496,7 @@ mod tests { status.contains("work RAM"), "expected a work-RAM rejection, got {status:?}" ); - assert!(pending.is_empty()); + assert_eq!(pending, [] as [crate::emu::DebugPoke; 0]); } #[test] @@ -507,6 +507,6 @@ mod tests { status.contains("work RAM"), "expected an overrun rejection, got {status:?}" ); - assert!(pending.is_empty()); + assert_eq!(pending, [] as [crate::emu::DebugPoke; 0]); } } diff --git a/crates/rustynes-frontend/src/debugger/divergence_panel.rs b/crates/rustynes-frontend/src/debugger/divergence_panel.rs index 1131cd6b6..cf5605a3e 100644 --- a/crates/rustynes-frontend/src/debugger/divergence_panel.rs +++ b/crates/rustynes-frontend/src/debugger/divergence_panel.rs @@ -527,7 +527,7 @@ mod tests { ); assert!(panel.audio.is_none(), "a located cycle outlived its ROM"); assert!(panel.requested.is_none(), "a queued run outlived its ROM"); - assert!(panel.status.is_empty()); + assert_eq!(panel.status, ""); assert_eq!(panel.addr, 0, "the perturbation target is ROM-bound too"); } } diff --git a/crates/rustynes-frontend/src/debugger/latency_panel.rs b/crates/rustynes-frontend/src/debugger/latency_panel.rs index eb84dad7a..de9fd54f8 100644 --- a/crates/rustynes-frontend/src/debugger/latency_panel.rs +++ b/crates/rustynes-frontend/src/debugger/latency_panel.rs @@ -968,7 +968,7 @@ mod tests { None, "the previous game's run-ahead depth was still queued to apply" ); - assert!(panel.status.is_empty()); + assert_eq!(panel.status, ""); assert!(!panel.measure_requested); } diff --git a/crates/rustynes-frontend/src/debugger/memory_compare_panel.rs b/crates/rustynes-frontend/src/debugger/memory_compare_panel.rs index a79aa58a4..56bebca3f 100644 --- a/crates/rustynes-frontend/src/debugger/memory_compare_panel.rs +++ b/crates/rustynes-frontend/src/debugger/memory_compare_panel.rs @@ -809,7 +809,7 @@ mod tests { #[test] fn empty_freeze_is_empty() { let s = MemoryComparePanelState::default(); - assert!(s.freeze_cheats().is_empty()); + assert_eq!(s.freeze_cheats(), [] as [crate::cheats::RawCheat; 0]); } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/rustynes-frontend/src/debugger/memory_panel.rs b/crates/rustynes-frontend/src/debugger/memory_panel.rs index 4cc7d3fad..b735a9f75 100644 --- a/crates/rustynes-frontend/src/debugger/memory_panel.rs +++ b/crates/rustynes-frontend/src/debugger/memory_panel.rs @@ -523,7 +523,7 @@ mod tests { #[test] fn empty_freeze_is_empty_cheats() { let s = MemoryPanelState::default(); - assert!(s.freeze_cheats().is_empty()); + assert_eq!(s.freeze_cheats(), [] as [crate::cheats::RawCheat; 0]); assert!(!s.wants_access_log()); } diff --git a/crates/rustynes-frontend/src/debugger/mod.rs b/crates/rustynes-frontend/src/debugger/mod.rs index 362d6f74c..52ecb4793 100644 --- a/crates/rustynes-frontend/src/debugger/mod.rs +++ b/crates/rustynes-frontend/src/debugger/mod.rs @@ -394,13 +394,15 @@ pub fn take_detached_size(id: &'static str) -> Option<(u32, u32)> { /// /// - **Main-window pass** (`target == None`): if `id` is docked, render the normal /// [`egui::Window`] seeded with `cfg` (first-open position / size / resizability) -/// plus a small "⧉ Detach" button that adds `id` to `detached`. If `id` is +/// plus a small Detach button (the Font Awesome `icons::glyph::EXPAND` glyph) +/// that adds `id` to `detached`. If `id` is /// detached, render NOTHING here — it now lives in its own OS window, which the /// [`crate::detached::DetachedManager`] created when `App` reconciled the /// `detached` set. /// - **Detached pass** (`target == Some(t)`): render content ONLY when `id == t`, -/// as a [`egui::CentralPanel`] filling that OS window, with a "⧉ Reattach" -/// button (the OS window's close button reattaches too, handled in `App`). +/// as an [`egui::Area`] + central-panel [`egui::Frame`] filling that OS window, +/// with a "Reattach to main window" button (the `icons::glyph::COMPRESS` +/// glyph; the OS window's close button reattaches too, handled in `App`). /// Non-matching panels early-return so one dispatch paints exactly one panel. /// /// **Native-only** detach — on wasm (`target` always `None`, no `detached` inserts) diff --git a/crates/rustynes-frontend/src/debugger/tastudio_panel.rs b/crates/rustynes-frontend/src/debugger/tastudio_panel.rs index 2e9157ebf..25a8b0eeb 100644 --- a/crates/rustynes-frontend/src/debugger/tastudio_panel.rs +++ b/crates/rustynes-frontend/src/debugger/tastudio_panel.rs @@ -633,7 +633,10 @@ mod tests { #[test] fn requests_drain_in_order_then_empty() { let mut s = TasStudioPanelState::default(); - assert!(s.take_requests().is_empty()); + assert_eq!( + s.take_requests(), + [] as [crate::debugger::tastudio_panel::TasRequest; 0] + ); s.emit(TasRequest::Seek(5)); s.emit(TasRequest::DeleteBranch(2)); let drained = s.take_requests(); diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index 6975c43b0..c647993c6 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -190,6 +190,18 @@ const THROTTLE_MIN_SAMPLES: usize = 120; #[cfg(not(target_arch = "wasm32"))] const RUNAHEAD_THROTTLE_RELEASE: f32 = 0.70; +/// The throttle's per-frame-linear cost model: the median produce cost at +/// `running` run-ahead frames, divided over the `running + 1` frames it pays +/// for (F18: +4.49 and +4.21 ms per depth, equal within 6%). +/// +/// One function because the engage cascade and the release arm both need it, +/// and until v3.0.1 each wrote the formula out separately, free to drift. +/// `f64` so both conversions are total and lossless (see the engage arm). +#[cfg(not(target_arch = "wasm32"))] +fn per_frame_cost(produce_p50_ms: f32, running: u32) -> f64 { + f64::from(produce_p50_ms) / (f64::from(running) + 1.0) +} + /// The two must not be swapped back. A minimum-to-report at or above the ring's /// capacity would mean the throttle either never reports or reports only on a /// full ring, and the F27 defect was precisely a confusion between these two @@ -1414,7 +1426,7 @@ impl EmuCore { // configuration that is over budget" is worse than no fallback. // Raised by both reviewers on PR #371. let band = f64::from(engage_band); - let per_frame = f64::from(produce_p50_ms) / (f64::from(running) + 1.0); + let per_frame = per_frame_cost(produce_p50_ms, running); while self.runahead_throttle_steps < bounded { let from = bounded.saturating_sub(self.runahead_throttle_steps); let predicted = per_frame * (f64::from(from) + 1.0); @@ -1474,7 +1486,7 @@ impl EmuCore { // would predict the cost of a SINGLE frame and release into a // configuration that cannot afford it. Fixed in both places at once // rather than only in the code review happened to be looking at. - let per_frame = f64::from(produce_p50_ms) / (f64::from(running) + 1.0); + let per_frame = per_frame_cost(produce_p50_ms, running); let predicted_one_more = per_frame * (f64::from(running) + 2.0); let release_band = f64::from(target) * f64::from(RUNAHEAD_THROTTLE_RELEASE); let release = predicted_one_more < release_band; @@ -2410,6 +2422,15 @@ pub(crate) fn drive_ra( mod tests { use super::*; + /// The shared cost model divides the measured cost over `running + 1` + /// frames: depth 0 is one frame, depth 2 is three. + #[test] + fn per_frame_cost_divides_over_running_plus_one() { + assert!((per_frame_cost(9.0, 0) - 9.0).abs() < 1e-12); + assert!((per_frame_cost(9.0, 2) - 3.0).abs() < 1e-12); + assert!((per_frame_cost(12.0, 3) - 3.0).abs() < 1e-12); + } + // ---- v2.3.3 F21: the run-ahead budget throttle state machine ---------- // // It had no tests at all, through an all-or-nothing implementation and a diff --git a/crates/rustynes-frontend/src/emu_thread.rs b/crates/rustynes-frontend/src/emu_thread.rs index 62f75301a..d69d18376 100644 --- a/crates/rustynes-frontend/src/emu_thread.rs +++ b/crates/rustynes-frontend/src/emu_thread.rs @@ -316,16 +316,23 @@ pub struct EmuControl { /// /// * `tick_lat_ns` — send to receipt, i.e. the winit->emu hop: scheduler /// wake-up latency, the last completely unmeasured step in the chain. - /// * `tick_iv_ns` — the interval between successive SENDS, i.e. how - /// regularly the winit thread asked for a frame, independent of how long - /// the ask took to arrive. + /// * `tick_iv_ns` — the interval between the send stamps of successive + /// DELIVERED ticks, i.e. how regularly the winit thread asked for a frame, + /// independent of how long the ask took to arrive. It is differenced on + /// the receiver, so a tick dropped on the full depth-1 channel + /// ([`Self::tick_dropped`]) is never seen and the next interval spans two + /// sends. It equals the send cadence only while `tick_dropped` is 0; + /// drops were measured at 0 (`run_ahead` 2) and 0-1 (`run_ahead` 0) per + /// 45 s capture (`docs/performance.md`, "Suspect A"). Until v3.0.1 this + /// said "between successive SENDS", which holds only while nothing drops. /// /// Aimed by measurement, not by guess: produce-interval standard deviation /// tracks missed presents at r = 0.937 across eighteen captures, and these /// two plus the existing `produce_cost` are the three terms that make up /// that interval. tick_lat_ns: AtomicU64, - /// Interval between successive tick sends. See [`Self::tick_lat_ns`]. + /// Interval between the send stamps of successive delivered ticks. See + /// [`Self::tick_lat_ns`]. tick_iv_ns: AtomicU64, /// Previous send stamp, for differencing into [`Self::tick_iv_ns`]. Zero /// means "no previous tick", which suppresses the first interval rather diff --git a/crates/rustynes-frontend/src/genie_db.rs b/crates/rustynes-frontend/src/genie_db.rs index d01468250..8f979ff15 100644 --- a/crates/rustynes-frontend/src/genie_db.rs +++ b/crates/rustynes-frontend/src/genie_db.rs @@ -288,7 +288,10 @@ mod tests { #[test] fn lookup_miss_is_empty() { - assert!(codes_for_crc(0xDEAD_BEEF).is_empty()); + assert_eq!( + codes_for_crc(0xDEAD_BEEF), + [] as [crate::genie_db::GenieDbCode; 0] + ); assert_eq!(game_for_crc(0xDEAD_BEEF), None); } @@ -333,7 +336,7 @@ mod tests { Some("Super Mario Bros.") ); // The empty key set matches nothing. - assert!(codes_for_crcs(&[]).is_empty()); + assert_eq!(codes_for_crcs(&[]), [] as [crate::genie_db::GenieDbCode; 0]); assert_eq!(game_for_crcs(&[]), None); // The by-category union covers the same codes as the flat union. let flat = codes_for_crcs(&[0x3337_EC46]); diff --git a/crates/rustynes-frontend/src/genie_encode.rs b/crates/rustynes-frontend/src/genie_encode.rs index a29624ecf..3f8e7443b 100644 --- a/crates/rustynes-frontend/src/genie_encode.rs +++ b/crates/rustynes-frontend/src/genie_encode.rs @@ -79,7 +79,7 @@ pub fn encode_8(addr: u16, data: u8, compare: u8) -> String { /// + (((h3 & 7) << 12) | ((h5 & 7) << 8) | ((h4 & 8) << 8) /// | ((h2 & 7) << 4) | ((h1 & 8) << 4) | (h4 & 7) | (h3 & 8)) /// ``` -fn write_addr_nibbles(hex: &mut [u8], addr: u16) { +const fn write_addr_nibbles(hex: &mut [u8], addr: u16) { let a = addr & 0x7FFF; // the $8000 base is implicit in the encoding. hex[3] |= ((a >> 12) & 7) as u8; // bits 12..15 hex[5] |= ((a >> 8) & 7) as u8; // bits 8..11 diff --git a/crates/rustynes-frontend/src/help_tui.rs b/crates/rustynes-frontend/src/help_tui.rs index 27bad3e73..a65f6ab36 100644 --- a/crates/rustynes-frontend/src/help_tui.rs +++ b/crates/rustynes-frontend/src/help_tui.rs @@ -51,7 +51,7 @@ impl HelpApp { } } - fn current(&self) -> &'static HelpTopic { + const fn current(&self) -> &'static HelpTopic { &HELP_TOPICS[self.selected] } diff --git a/crates/rustynes-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs index a1fefe9d1..fcd3fbfa5 100644 --- a/crates/rustynes-frontend/src/i18n.rs +++ b/crates/rustynes-frontend/src/i18n.rs @@ -917,8 +917,8 @@ mod tests { Key::ButtonOk, Key::StatusIdle, ] { - assert!(!tr_in(Locale::Spanish, key).is_empty()); - assert!(!tr_in(Locale::English, key).is_empty()); + assert_ne!(tr_in(Locale::Spanish, key), ""); + assert_ne!(tr_in(Locale::English, key), ""); } } @@ -967,7 +967,7 @@ mod tests { !english(key).is_empty(), "{key:?} has an empty English string" ); - assert!(!tr_in(Locale::English, key).is_empty()); + assert_ne!(tr_in(Locale::English, key), ""); } } diff --git a/crates/rustynes-frontend/src/icon.rs b/crates/rustynes-frontend/src/icon.rs index 6a618740b..427b2a08c 100644 --- a/crates/rustynes-frontend/src/icon.rs +++ b/crates/rustynes-frontend/src/icon.rs @@ -21,7 +21,9 @@ fn decode() -> Option<(Vec, u32, u32)> { let rgba = match info.color_type { png::ColorType::Rgba => buf, png::ColorType::Rgb => buf - .chunks_exact(3) + .as_chunks::<3>() + .0 + .iter() .flat_map(|p| [p[0], p[1], p[2], 0xFF]) .collect(), // The shipped icon is 8-bit RGBA; anything else is unexpected. diff --git a/crates/rustynes-frontend/src/input_macros.rs b/crates/rustynes-frontend/src/input_macros.rs index 62b6b3384..b15b47718 100644 --- a/crates/rustynes-frontend/src/input_macros.rs +++ b/crates/rustynes-frontend/src/input_macros.rs @@ -170,7 +170,10 @@ mod tests { #[test] fn empty_bank_round_trips() { let bytes = MacroBank::default().serialize(); - assert!(MacroBank::deserialize(&bytes).unwrap().macros.is_empty()); + assert_eq!( + MacroBank::deserialize(&bytes).unwrap().macros, + [] as [crate::input_macros::InputMacro; 0] + ); } #[test] diff --git a/crates/rustynes-frontend/src/movie_srt.rs b/crates/rustynes-frontend/src/movie_srt.rs index 07a289099..96dd815b8 100644 --- a/crates/rustynes-frontend/src/movie_srt.rs +++ b/crates/rustynes-frontend/src/movie_srt.rs @@ -112,10 +112,10 @@ mod tests { #[test] fn empty_markers_make_empty_srt() { let s = markers_to_srt(Vec::<(u64, String)>::new(), NTSC_NUM, NTSC_DEN, 120); - assert!(s.is_empty()); + assert_eq!(s, ""); // Blank labels are skipped too. let s = markers_to_srt(vec![(0u64, " ".to_string())], NTSC_NUM, NTSC_DEN, 120); - assert!(s.is_empty()); + assert_eq!(s, ""); } #[test] diff --git a/crates/rustynes-frontend/src/patch.rs b/crates/rustynes-frontend/src/patch.rs index 0ebc903f4..d9c8e33b1 100644 --- a/crates/rustynes-frontend/src/patch.rs +++ b/crates/rustynes-frontend/src/patch.rs @@ -485,7 +485,7 @@ fn apply_signed_offset(cursor: &mut usize, raw: u64) -> Result<(), PatchError> { } /// Read a little-endian `u32` from a 4-byte slice. -fn read_le_u32(bytes: &[u8]) -> u32 { +const fn read_le_u32(bytes: &[u8]) -> u32 { u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) } diff --git a/crates/rustynes-frontend/src/perf.rs b/crates/rustynes-frontend/src/perf.rs index d878e2067..9051d6b12 100644 --- a/crates/rustynes-frontend/src/perf.rs +++ b/crates/rustynes-frontend/src/perf.rs @@ -1096,7 +1096,7 @@ mod tests { // More than available -> the whole ring. assert_eq!(r.recent(100).len(), 10); // Empty ring -> empty vec. - assert!(SampleRing::default().recent(5).is_empty()); + assert_eq!(SampleRing::default().recent(5), [] as [f32; 0]); } // v1.3.0 Workstream B — the present/produce mismatch diagnostics (the diff --git a/crates/rustynes-frontend/src/shader_pass.rs b/crates/rustynes-frontend/src/shader_pass.rs index 3b726124d..b8acf3f9d 100644 --- a/crates/rustynes-frontend/src/shader_pass.rs +++ b/crates/rustynes-frontend/src/shader_pass.rs @@ -977,7 +977,10 @@ mod tests { #[test] fn ignores_non_pragma_lines() { - assert!(parse_pragma_parameters("let x = 1.0;\n// a comment\n").is_empty()); + assert_eq!( + parse_pragma_parameters("let x = 1.0;\n// a comment\n"), + [] as [crate::shader_pass::ShaderParam; 0] + ); } #[test] @@ -1053,7 +1056,10 @@ mod tests { // The load-bearing invariant at the config level. let cfg = ShaderStackConfig::default(); assert!(!cfg.has_enabled_passes()); - assert!(cfg.effective_passes().is_empty()); + assert_eq!( + cfg.effective_passes(), + [] as [&crate::shader_pass::ShaderPassDesc; 0] + ); } #[test] @@ -1098,7 +1104,10 @@ mod tests { passes: vec![ShaderPassDesc::new("from-a-newer-build")], }; assert!(!cfg.has_enabled_passes()); - assert!(cfg.effective_passes().is_empty()); + assert_eq!( + cfg.effective_passes(), + [] as [&crate::shader_pass::ShaderPassDesc; 0] + ); } #[test] @@ -1129,7 +1138,10 @@ mod tests { // family (the legacy `crt` scanline pass or, since v2.1.9, one of the // marquee CRT-stack / raw-signal passes) and must resolve to a builtin. let presets = ShaderPresetBank::builtins(); - assert!(!presets.is_empty()); + assert_ne!( + presets, + [] as [(std::string::String, crate::shader_pass::ShaderStackConfig); 0] + ); for (name, stack) in presets { assert!(stack.has_enabled_passes(), "{name}"); assert_eq!(stack.passes.len(), 1, "{name} should be a single pass"); diff --git a/crates/rustynes-frontend/src/wasm_io.rs b/crates/rustynes-frontend/src/wasm_io.rs index 21b98799a..665b866f6 100644 --- a/crates/rustynes-frontend/src/wasm_io.rs +++ b/crates/rustynes-frontend/src/wasm_io.rs @@ -149,8 +149,7 @@ pub fn download_bytes(filename: &str, bytes: &[u8]) { pub fn fs_access_supported() -> bool { web_sys::window().is_some_and(|w| { js_sys::Reflect::get(&w, &JsValue::from_str("showSaveFilePicker")) - .ok() - .is_some_and(|f| f.is_function()) + .is_ok_and(|f| f.is_function()) }) } diff --git a/crates/rustynes-frontend/web/Trunk.toml b/crates/rustynes-frontend/web/Trunk.toml index 349c0c3be..3d9a9c321 100644 --- a/crates/rustynes-frontend/web/Trunk.toml +++ b/crates/rustynes-frontend/web/Trunk.toml @@ -35,3 +35,8 @@ port = 8081 # library are released in lockstep and version-check each other. # Check with `grep -A1 'name = "wasm-bindgen"' Cargo.lock`. wasm_bindgen = "0.2.129" +# v3.0.1: wasm-opt (binaryen) pinned too. Unset, trunk 0.21.14 falls back to +# its built-in default (`version_123`, about two years old), so the optimiser +# that shapes the shipped demo was whatever that trunk release happened to +# carry. version_133 is binaryen's newest release (2026-09-21). +wasm_opt = "version_133" diff --git a/crates/rustynes-gamedb/src/lib.rs b/crates/rustynes-gamedb/src/lib.rs index c88ac5ef2..5aa73e16e 100644 --- a/crates/rustynes-gamedb/src/lib.rs +++ b/crates/rustynes-gamedb/src/lib.rs @@ -148,7 +148,29 @@ fn parse_mirroring(token: &str) -> Option { /// PrgRamBanks, Battery, Mirroring, Title` row into a [`GameDbEntry`]. Returns /// `None` for comment / blank / malformed lines. The title is the final field /// and may contain commas (it is split off with `splitn`). +/// +/// This is the **vendored-table** reader: a Mapper column of `0` is read as +/// "unspecified" (see the comment in [`parse_row_from`]). The user overlay is +/// read by [`parse_overlay_row`], which keeps a `0`. fn parse_row(line: &str) -> Option { + parse_row_from(line, true) +} + +/// Parse one row of the **user overlay** (`game_db_user.txt`). +/// +/// Same format as the vendored table, with one difference: a Mapper column of +/// `0` is a real override. The overlay writer ([`serialize_row`]) leaves the +/// column EMPTY for "no override", so a `0` there can only be a deliberate NROM +/// correction saved from the ROM Database panel. Until v3.0.1 the overlay was +/// read through the vendored reader, which dropped that `0`: the override +/// applied in-session and vanished on the next start. +fn parse_overlay_row(line: &str) -> Option { + parse_row_from(line, false) +} + +/// The shared row parser. `zero_mapper_is_unset` is `true` for the vendored +/// table only. +fn parse_row_from(line: &str, zero_mapper_is_unset: bool) -> Option { let line = line.trim(); if line.is_empty() || line.starts_with('#') { return None; @@ -180,7 +202,14 @@ fn parse_row(line: &str) -> Option { // should not have; the first was the mirroring column freezing Wizards & // Warriors (ADR 0031), fixed the same way -- by refusing to apply an override // that cannot be distinguished from "no data". - let mapper = fields[2].parse::().ok().filter(|&m| m != 0); + // + // The rule is the VENDORED table's only. The user overlay has an empty + // marker (its writer leaves the column blank), so a `0` there is a + // deliberate correction and is kept -- see `parse_overlay_row`. + let mapper = fields[2] + .parse::() + .ok() + .filter(|&m| !(zero_mapper_is_unset && m == 0)); let submapper = fields[3].parse::().ok(); let mirroring = parse_mirroring(fields[8]); let title = fields @@ -349,7 +378,7 @@ fn load_overlay() -> Vec { let Ok(text) = std::fs::read_to_string(&path) else { return Vec::new(); }; - let mut rows: Vec = text.lines().filter_map(parse_row).collect(); + let mut rows: Vec = text.lines().filter_map(parse_overlay_row).collect(); rows.sort_unstable_by_key(|e| e.crc); rows.dedup_by_key(|e| e.crc); rows @@ -1387,6 +1416,40 @@ mod tests { assert_eq!(parse_row(&serialize_row(&sparse)), Some(sparse)); } + /// A mapper-0 override saved to the USER OVERLAY must survive a restart. + /// + /// The `0`-means-unspecified rule belongs to the vendored table, whose + /// unfilled rows carry `0`. The overlay writer leaves the column empty for + /// "no override", so an overlay `0` is a deliberate NROM correction. Until + /// v3.0.1 the overlay reader shared the vendored filter, and the override + /// applied in-session (`upsert_user_entry`) then vanished on reload. + #[test] + fn an_overlay_mapper_zero_override_survives_reload() { + let entry = GameDbEntry { + crc: 0x1234_5678, + region: None, + mapper: Some(0), + submapper: None, + mirroring: None, + title: "Homebrew NROM".into(), + }; + let row = serialize_row(&entry); + assert_eq!(parse_overlay_row(&row), Some(entry)); + // The vendored reader keeps its rule: the same text from the vendored + // table is "unspecified". + assert_eq!(parse_row(&row).expect("row parses").mapper, None); + // An empty overlay column is still "no override". + let sparse = GameDbEntry { + crc: 0x1234_5678, + region: None, + mapper: None, + submapper: None, + mirroring: None, + title: "Homebrew NROM".into(), + }; + assert_eq!(parse_overlay_row(&serialize_row(&sparse)), Some(sparse)); + } + #[test] fn header_overrides_noop_for_non_ines() { let mut not_a_rom = b"not a rom".to_vec(); diff --git a/crates/rustynes-gfx-shaders/src/crt_stack.rs b/crates/rustynes-gfx-shaders/src/crt_stack.rs index 9ab451712..8f0f17d38 100644 --- a/crates/rustynes-gfx-shaders/src/crt_stack.rs +++ b/crates/rustynes-gfx-shaders/src/crt_stack.rs @@ -124,8 +124,8 @@ mod tests { fn every_shader_has_nonempty_source() { for s in CrtStackShader::ALL { assert!(!s.wgsl().is_empty(), "{s:?} has empty WGSL"); - assert!(!s.slug().is_empty()); - assert!(!s.display_name().is_empty()); + assert_ne!(s.slug(), ""); + assert_ne!(s.display_name(), ""); } } diff --git a/crates/rustynes-gfx-shaders/src/lib.rs b/crates/rustynes-gfx-shaders/src/lib.rs index b3d40777d..0f75b6979 100644 --- a/crates/rustynes-gfx-shaders/src/lib.rs +++ b/crates/rustynes-gfx-shaders/src/lib.rs @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Provenance: `BISQWIT_WGSL` (`bisqwit.wgsl`) is a generated verbatim copy of the desktop's Bisqwit NES composite-NTSC pass, whose numeric coefficient tables were ported verbatim from Bisqwit's C via Mesen2's `BisqwitNtscFilter` (Mesen2: GPL-3.0-or-later). See docs/originality-and-provenance.md (Section 1) +// and NOTICE for the complete, audited derivation record. //! Shared WGSL presentation-shader sources for the RustyNES wgpu render path. //! //! These `pub const` strings are the single source of truth for the presentation @@ -307,8 +311,12 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { /// The Bisqwit-style composite NES NTSC post-pass. /// -/// An independent implementation of the NES composite signal model documented at -/// the NESdev wiki ("NTSC video"); no third-party emulator code is incorporated. +/// Derived: the numeric coefficient tables were ported verbatim from Bisqwit's C +/// via Mesen2's `BisqwitNtscFilter` (GPL-3.0-or-later), the same derivation +/// `rustynes-frontend`'s `ntsc_bisqwit.rs` records; the two-level composite signal +/// shape is documented at the NESdev wiki ("NTSC video"). This sentence said +/// "independent" until v3.0.1 (T-NTSC-PROVENANCE): it was a self-certification on a +/// generated copy of code already recorded as derived. /// /// Unlike CRT/LMP it samples the **palette-index** framebuffer as an `R16Uint` /// texture (`@group(0) @binding(0) idx_tex`), not the RGBA, plus the per-frame NTSC diff --git a/crates/rustynes-hdpack/src/hdpack.rs b/crates/rustynes-hdpack/src/hdpack.rs index f2462e821..a1d87efa1 100644 --- a/crates/rustynes-hdpack/src/hdpack.rs +++ b/crates/rustynes-hdpack/src/hdpack.rs @@ -953,7 +953,7 @@ fn decode_png_pixels( png::ColorType::Rgba => buf, png::ColorType::Rgb => { let mut out = Vec::with_capacity(rgba_len); - for px in buf.chunks_exact(3) { + for px in buf.as_chunks::<3>().0 { out.extend_from_slice(&[px[0], px[1], px[2], 0xFF]); } out @@ -967,7 +967,7 @@ fn decode_png_pixels( } png::ColorType::GrayscaleAlpha => { let mut out = Vec::with_capacity(rgba_len); - for px in buf.chunks_exact(2) { + for px in buf.as_chunks::<2>().0 { out.extend_from_slice(&[px[0], px[0], px[0], px[1]]); } out @@ -2629,7 +2629,7 @@ fn blend_over( /// it bit-for-bit or no tile matches. fn calculate_hash(key: &[u8]) -> u32 { let mut result: u32 = 0; - for chunk in key.chunks_exact(4) { + for chunk in key.as_chunks::<4>().0 { let val = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); result = result.wrapping_add(val).rotate_left(2); } @@ -3122,7 +3122,7 @@ mod tests { assert_eq!(rule.image, 0, "bitmap index 0 = first "); assert_eq!(rule.x, 16); assert_eq!(rule.y, 0); - assert!(rule.conditions.is_empty()); + assert_eq!(rule.conditions, [] as [usize; 0]); assert_eq!(parsed.image_names, vec!["tiles.png".to_string()]); } @@ -3392,7 +3392,7 @@ mod tests { assert_eq!(parsed.backgrounds[0].y, 0); // No priority field present -> Mesen default priority 10. assert_eq!(parsed.backgrounds[0].priority, 10); - assert!(parsed.backgrounds[0].conditions.is_empty()); + assert_eq!(parsed.backgrounds[0].conditions, [] as [usize; 0]); assert_eq!(parsed.backgrounds[1].priority, 3); assert_eq!(parsed.backgrounds[1].x, 16); assert_eq!(parsed.backgrounds[1].y, 32); diff --git a/crates/rustynes-ios/src/audio_ring.rs b/crates/rustynes-ios/src/audio_ring.rs index beff37d17..fbc17761a 100644 --- a/crates/rustynes-ios/src/audio_ring.rs +++ b/crates/rustynes-ios/src/audio_ring.rs @@ -369,7 +369,7 @@ impl Producer { /// before v2.7.4. (Unreachable with cpal 0.18 on iOS, which always negotiates /// stereo, but a surround route would otherwise play only the left side there.) pub fn fan_out(frame: &mut [f32], l: f32, r: f32) { - let centre = 0.5 * (l + r); + let centre = f32::midpoint(l, r); match frame { [] => {} [c0] => *c0 = centre, diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info index 5f9b248e0..39129e6a2 100644 --- a/crates/rustynes-libretro/rustynes_libretro.info +++ b/crates/rustynes-libretro/rustynes_libretro.info @@ -5,7 +5,7 @@ supported_extensions = "nes|fds|unf|unif" corename = "RustyNES" license = "GPLv3+" permissions = "" -display_version = "v3.0.0" +display_version = "v3.0.1" categories = "Emulator" # Hardware Information diff --git a/crates/rustynes-libretro/src/abi_tests.rs b/crates/rustynes-libretro/src/abi_tests.rs index 40aafd9b8..a0e17d81c 100644 --- a/crates/rustynes-libretro/src/abi_tests.rs +++ b/crates/rustynes-libretro/src/abi_tests.rs @@ -1535,9 +1535,7 @@ fn fds_disk_writes_survive_closing_the_game() { .join(format!("{sha}.fds.sav")); let _ = std::fs::remove_file(&file); let written = |file: &std::path::Path| { - std::fs::read(file) - .ok() - .is_some_and(|b| b.get(15..15 + DISK_MARKER.len()) == Some(DISK_MARKER)) + std::fs::read(file).is_ok_and(|b| b.get(15..15 + DISK_MARKER.len()) == Some(DISK_MARKER)) }; // A clean boot: nothing written yet. @@ -1854,7 +1852,7 @@ fn oracle_frame(mut nes: Nes, frames: u32) -> Vec { nes.run_frame(); } let mut out = nes.framebuffer().to_vec(); - for px in out.chunks_exact_mut(4) { + for px in out.as_chunks_mut::<4>().0 { px.swap(0, 2); } out diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs index 454e8303c..7cdfadf52 100644 --- a/crates/rustynes-libretro/src/lib.rs +++ b/crates/rustynes-libretro/src/lib.rs @@ -1701,7 +1701,7 @@ impl RustyNesLibretro { return; }; self.video_buffer.extend_from_slice(nes.framebuffer()); - for chunk in self.video_buffer.chunks_exact_mut(4) { + for chunk in self.video_buffer.as_chunks_mut::<4>().0 { chunk.swap(0, 2); // RGBA8 → XRGB8888 (in-memory B G R X). } ctx.draw_frame(&self.video_buffer, NES_W as u32, NES_H as u32, NES_W * 4); @@ -3513,7 +3513,7 @@ mod tests { let mut expected = Vec::with_capacity(DUAL_W * NES_H * 4); for y in 0..NES_H { for fb in [dual.main_framebuffer(), dual.sub_framebuffer()] { - for px in fb[y * NES_W * 4..(y + 1) * NES_W * 4].chunks_exact(4) { + for px in fb[y * NES_W * 4..(y + 1) * NES_W * 4].as_chunks::<4>().0 { expected.extend_from_slice(&[px[2], px[1], px[0], px[3]]); } } diff --git a/crates/rustynes-mappers/src/bmc_simple.rs b/crates/rustynes-mappers/src/bmc_simple.rs index 0e03207ba..2704e4492 100644 --- a/crates/rustynes-mappers/src/bmc_simple.rs +++ b/crates/rustynes-mappers/src/bmc_simple.rs @@ -195,12 +195,8 @@ impl SimpleBmc { SimpleBoard::M286 => { let last8 = self.prg_count_8k() - 1; let last_chr2 = (self.chr.len() / CHR_BANK_2K).max(1) - 1; - for s in &mut self.prg8 { - *s = last8; - } - for c in &mut self.chr2 { - *c = last_chr2; - } + self.prg8.fill(last8); + self.chr2.fill(last_chr2); } SimpleBoard::M320 => self.update_m320(), SimpleBoard::M289 => self.update_m289(), diff --git a/crates/rustynes-mappers/src/header.rs b/crates/rustynes-mappers/src/header.rs index 3639e834e..22933862d 100644 --- a/crates/rustynes-mappers/src/header.rs +++ b/crates/rustynes-mappers/src/header.rs @@ -1528,7 +1528,7 @@ mod tests { #[test] fn truncated_header() { - let bytes = [b'N', b'E', b'S']; + let bytes = *b"NES"; assert!(matches!( parse_header(&bytes), Err(RomError::Truncated { needed: 16, got: 3 }) diff --git a/crates/rustynes-mappers/src/homebrew_boards.rs b/crates/rustynes-mappers/src/homebrew_boards.rs index 222ef1aae..9062f6a14 100644 --- a/crates/rustynes-mappers/src/homebrew_boards.rs +++ b/crates/rustynes-mappers/src/homebrew_boards.rs @@ -2230,7 +2230,7 @@ mod tests { #[test] fn m30_non_flashable_board_has_no_flash() { let mut m = Unrom512M30::new(synth_prg_16k(16), &[], false, true, 0, false).unwrap(); - assert!(m.save_data().is_empty()); + assert_eq!(m.save_data(), []); m.cpu_write(0x9555, 0xAA); assert!(m.cpu_read_unmapped(0x6000)); } diff --git a/crates/rustynes-mappers/src/kaiser.rs b/crates/rustynes-mappers/src/kaiser.rs index a2f0594d2..1872d2890 100644 --- a/crates/rustynes-mappers/src/kaiser.rs +++ b/crates/rustynes-mappers/src/kaiser.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -// Provenance: the Kaiser boards are derived from Mesen2 (GPL-3.0-or-later). See docs/originality-and-provenance.md (Section 1) +// Provenance: the Kaiser boards are derived from Mesen2 (GPL-3.0-or-later), `Waixing/Mapper253.h` (the mapper 253 Waixing VRC4 clone). See docs/originality-and-provenance.md (Section 1) // and NOTICE for the complete, audited derivation record. //! Kaiser boards: `KS202` (mapper 56), `KS7017` (142), `KS7031` (303), //! `KS7016` (305), `KS7013B` (306) and relatives. diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 63f81ee39..eefad19d4 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -1611,7 +1611,7 @@ mod tests { #[test] fn version_is_non_empty() { - assert!(!version().is_empty()); + assert_ne!(version(), ""); } #[test] diff --git a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs index 056d7553b..b35ef6792 100644 --- a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs +++ b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -// Provenance: the Sunsoft FME-7 / 5B audio detail is derived from Mesen2 (GPL-3.0-or-later) and cross-referenced with Nestopia UE (GPL-2.0-or-later). See docs/originality-and-provenance.md (Section 1) +// Provenance: the Sunsoft FME-7 / 5B audio detail is derived from Mesen2 (GPL-3.0-or-later), `NesSoundMixer::GetOutputVolume` (the 5B `* 15` output weight) over `Sunsoft5bAudio::_volumeLut`, and cross-referenced with Nestopia UE (GPL-2.0-or-later; no upstream file is recorded). See docs/originality-and-provenance.md (Section 1) // and NOTICE for the complete, audited derivation record. //! Sunsoft FME-7 (mapper 69) -- banking, the CPU-cycle IRQ counter, and the //! on-cart Sunsoft 5B audio chip. @@ -154,8 +154,12 @@ const SUNSOFT5B_DC_BIAS: i32 = 0; /// lets the shape stay pinned by its own unit test while the level is pinned /// by a ROM oracle. /// -/// **Target, calibrated against Mesen2 (the project's accuracy bar) as an -/// oracle rather than against our own prior numbers.** Using the standard +/// **Target, derived from Mesen2 (the project's accuracy bar) rather than from +/// our own prior numbers** (see this file's `Provenance` header and +/// `docs/originality-and-provenance.md` section 1). v2.2.5 reworded this to +/// "calibrated against Mesen2 ... as an oracle", which describes a black-box +/// comparison and so understated a derivation; v3.0.1 restored it +/// (maintainer, 2026-10-06). Using the standard /// blargg nonlinear-mixer approximation (nesdev "APU Mixer"), a full-volume /// 2A03 square is `(95.88 * 5000) / (8128/15 + 100) = 746.9` units, and the 5B /// is summed with weight `* 15` over the documented 5B log-DAC volume table diff --git a/crates/rustynes-mappers/src/mmc3_boards.rs b/crates/rustynes-mappers/src/mmc3_boards.rs index 397b90bde..9d383004e 100644 --- a/crates/rustynes-mappers/src/mmc3_boards.rs +++ b/crates/rustynes-mappers/src/mmc3_boards.rs @@ -226,11 +226,13 @@ fn mirror_bank(bank: usize, count: usize) -> usize { let mut bank = bank & (count.next_power_of_two() - 1); while bank >= count { // Find the doubling stage whose copied region holds `bank`. + // `isolate_lowest_one` is `lowbit(size)` (`size & size.wrapping_neg()`, + // spelled that way until v3.0.1 moved these crates to Rust 1.99). let mut size = count; - while size + (size & size.wrapping_neg()) <= bank { - size += size & size.wrapping_neg(); + while size + size.isolate_lowest_one() <= bank { + size += size.isolate_lowest_one(); } - bank -= size & size.wrapping_neg(); + bank -= size.isolate_lowest_one(); } bank } @@ -469,6 +471,11 @@ impl Mmc3Board { rom((raw & 0xFF) | (a18 << 8)) } Board::M37 => rom((raw & 0x7F) | (((r(0) >> 2) & 1) << 7)), + // T-GA23C-CHRRAM: CHR-RAM is addressed straight from PPU + // A10-A12, bypassing every CHR bank. The mapper 45 page is + // silent on CHR-RAM; mapper 372's page, the GA23C with a + // ROM/RAM switch, documents its RAM as "unbanked". + Board::M45 if self.chr_is_ram => Chr::Rom(usize::from(addr & 0x1FFF)), Board::M45 => { let c = r(2) & 0x0F; let mask = if c >= 7 { 0xFF >> (15 - c) } else { 0 }; @@ -843,7 +850,7 @@ impl Mapper for Mmc3Board { out.push(u8::from(o.is_some())); out.push(o.unwrap_or(0)); } - out.push(self.sticky.map_or(0xFF, |s| s)); + out.push(self.sticky.unwrap_or(0xFF)); out.push(self.dip); out.extend_from_slice(&(core.len() as u32).to_le_bytes()); out.extend_from_slice(&core); diff --git a/crates/rustynes-mappers/src/mmc3_boards_tests.rs b/crates/rustynes-mappers/src/mmc3_boards_tests.rs index fd87f72ed..e8349ba2e 100644 --- a/crates/rustynes-mappers/src/mmc3_boards_tests.rs +++ b/crates/rustynes-mappers/src/mmc3_boards_tests.rs @@ -255,6 +255,45 @@ fn m45_power_on_and_reset_pass_every_mmc3_chr_bit() { check(&mut m, "$6001"); } +/// T-GA23C-CHRRAM. A GA23C board with CHR-RAM addresses it straight from PPU +/// A10-A12: the MMC3's CHR banks and the outer CHR registers do not reach it. +/// The mapper 45 page is silent on CHR-RAM; the GA23C variant that switches +/// to CHR-RAM, mapper 372 (`NES_2_0_Mapper_372.md`), documents that RAM as +/// "unbanked". *Famicom Yarou Vol.1 7-in-1* depends on it: its menu uploads +/// 8 KiB through `$2007` with every CHR register 0, then draws with R0-R5 = +/// 0, 2, 4, 5, 6, 7. That sequence is replayed here; under MMC3 banking the +/// upload lands in banks 0-1 only and banks 2-7 read back as zero. +#[test] +fn m45_chr_ram_is_unbanked() { + let mut m = Mmc3Board::new( + Board::M45, + prg_image(32), + Box::default(), + Mirroring::Vertical, + 0, + 0, + ) + .expect("valid sizes"); + for r in 0..6 { + mmc3_reg(&mut m, r, 0); + } + for addr in 0..0x2000u16 { + m.ppu_write(addr, (addr >> 10) as u8 ^ addr as u8); + } + for (r, v) in [0, 2, 4, 5, 6, 7].into_iter().enumerate() { + mmc3_reg(&mut m, r as u8, v); + } + // Outer registers that would move a banked CHR elsewhere change nothing. + m45_outer(&mut m, [0x10, 0x00, 0x07, 0x00]); + for addr in 0..0x2000u16 { + assert_eq!( + m.ppu_read(addr), + (addr >> 10) as u8 ^ addr as u8, + "CHR-RAM byte ${addr:04X}" + ); + } +} + #[test] fn m45_dip_switch_reads_on_d0() { let mut m = board(Board::M45, 16, 8); diff --git a/crates/rustynes-mappers/src/ntdec.rs b/crates/rustynes-mappers/src/ntdec.rs index ae58091a9..799eb621c 100644 --- a/crates/rustynes-mappers/src/ntdec.rs +++ b/crates/rustynes-mappers/src/ntdec.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later // -// Provenance: the NTDEC boards are derived from Mesen2 (GPL-3.0-or-later). See docs/originality-and-provenance.md (Section 1) +// Provenance: the NTDEC boards are derived from Mesen2 (GPL-3.0-or-later), `Ntdec/NtdecTc112.h` (mapper 193), `Unlicensed/Mapper204.h` (mapper 204) and `Txc/Bmc11160.h` (mapper 299). See docs/originality-and-provenance.md (Section 1) // and NOTICE for the complete, audited derivation record. //! NTDEC boards decoded from the address bus: mappers 63 and 174. //! diff --git a/crates/rustynes-mappers/tests/battery_sram_exposed.rs b/crates/rustynes-mappers/tests/battery_sram_exposed.rs index be60541c2..ef7ebf711 100644 --- a/crates/rustynes-mappers/tests/battery_sram_exposed.rs +++ b/crates/rustynes-mappers/tests/battery_sram_exposed.rs @@ -56,7 +56,7 @@ fn image(mapper: u16, chr_rom: bool) -> Vec { /// Write a 12-bit mapper number into an NES 2.0 header (bytes 6-8), keeping the /// battery and NES 2.0 identifier bits. The sweep patches one template image /// per CHR variant with this instead of rebuilding 384 KiB for every number. -fn set_mapper(h: &mut [u8], mapper: u16) { +const fn set_mapper(h: &mut [u8], mapper: u16) { h[6] = (((mapper & 0x0F) as u8) << 4) | 0b0000_0010; h[7] = ((mapper & 0xF0) as u8) | 0b0000_1000; h[8] = ((mapper >> 8) & 0x0F) as u8; diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs index 057153044..abf1b144d 100644 --- a/crates/rustynes-mobile/src/lib.rs +++ b/crates/rustynes-mobile/src/lib.rs @@ -1841,7 +1841,7 @@ impl NesController { }); } let mut pal = [[0u8; 3]; 64]; - for (i, chunk) in bytes[..192].chunks_exact(3).enumerate() { + for (i, chunk) in bytes[..192].as_chunks::<3>().0.iter().enumerate() { pal[i] = [chunk[0], chunk[1], chunk[2]]; } // v2.9.9 (NF-22) — both consoles of a cabinet. @@ -3688,7 +3688,7 @@ mod tests { let ctrl = NesController::new(battery_nrom(false), DEFAULT_SAMPLE_RATE).expect("load"); assert!(!ctrl.has_battery()); ctrl.step_frame(); - assert!(ctrl.battery_ram().is_empty()); + assert_eq!(ctrl.battery_ram(), [] as [u8; 0]); assert!(matches!( ctrl.load_battery_ram(vec![0; 0x2000]), Err(MobileError::Battery { .. }) @@ -4212,7 +4212,7 @@ mod tests { ctrl.set_disk_side(Some(7)); assert_eq!(ctrl.inserted_disk_side(), None, "out of range is ignored"); assert!(!ctrl.disk_is_dirty()); - assert!(!ctrl.disk_image_bytes().is_empty()); + assert_ne!(ctrl.disk_image_bytes(), [] as [u8; 0]); assert_eq!(ctrl.nsf_song_count(), 0, "a disk is not an NSF"); } @@ -4286,7 +4286,7 @@ mod tests { let single = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); assert!(ctrl.load_state(single.save_state()).is_err()); assert!(!single.is_dual_system()); - assert!(single.sub_framebuffer().is_empty()); + assert_eq!(single.sub_framebuffer(), [] as [u8; 0]); } /// Plan item 7: the features the core scopes out of a cabinet (one @@ -4419,7 +4419,7 @@ mod tests { ); // Recovered once, warned once: later calls are ordinary. ctrl.step_frame(); - assert!(ctrl.drain_warning_codes().is_empty()); + assert_eq!(ctrl.drain_warning_codes(), [] as [HostWarning; 0]); } /// v2.7.4 (review of MOB-03): a panic while a frame runs must not reach the @@ -4539,7 +4539,7 @@ mod tests { let frozen = ctrl.frame(); ctrl.run_frame(); assert_eq!(ctrl.frame(), frozen, "no cycle may run after the panic"); - assert!(ctrl.composite_hd_frame().is_empty()); + assert_eq!(ctrl.composite_hd_frame(), [] as [u8; 0]); assert!(ctrl.drain_warning_codes().is_empty(), "warned once"); // A fresh start thaws it. diff --git a/crates/rustynes-netplay/src/relay.rs b/crates/rustynes-netplay/src/relay.rs index 37ac8d29c..a55748381 100644 --- a/crates/rustynes-netplay/src/relay.rs +++ b/crates/rustynes-netplay/src/relay.rs @@ -804,9 +804,9 @@ fn sha1(data: &[u8]) -> [u8; 20] { msg.push(0); } msg.extend_from_slice(&ml.to_be_bytes()); - for chunk in msg.chunks_exact(64) { + for chunk in msg.as_chunks::<64>().0 { let mut w = [0u32; 80]; - for (i, word) in chunk.chunks_exact(4).enumerate() { + for (i, word) in chunk.as_chunks::<4>().0.iter().enumerate() { w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); } for i in 16..80 { @@ -936,9 +936,9 @@ fn md5(data: &[u8]) -> [u8; 16] { msg.push(0); } msg.extend_from_slice(&ml.to_le_bytes()); - for chunk in msg.chunks_exact(64) { + for chunk in msg.as_chunks::<64>().0 { let mut m = [0u32; 16]; - for (i, word) in chunk.chunks_exact(4).enumerate() { + for (i, word) in chunk.as_chunks::<4>().0.iter().enumerate() { m[i] = u32::from_le_bytes([word[0], word[1], word[2], word[3]]); } let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0); diff --git a/crates/rustynes-netplay/src/signaling.rs b/crates/rustynes-netplay/src/signaling.rs index 93ff6bf80..aaf7c343f 100644 --- a/crates/rustynes-netplay/src/signaling.rs +++ b/crates/rustynes-netplay/src/signaling.rs @@ -1275,7 +1275,7 @@ mod tests { assert_eq!(relay.room_count(), 1); // Peer 2 leaves → room is dropped. let acts = relay.disconnect(2); - assert!(acts.is_empty()); + assert_eq!(acts, [] as [crate::signaling::Action; 0]); assert_eq!(relay.room_count(), 0); } diff --git a/crates/rustynes-netplay/src/transport.rs b/crates/rustynes-netplay/src/transport.rs index 15e92505f..0d1fbb2b7 100644 --- a/crates/rustynes-netplay/src/transport.rs +++ b/crates/rustynes-netplay/src/transport.rs @@ -326,7 +326,7 @@ mod tests { // With zero latency, the message is deliverable on b's next poll. let got = b.poll(); assert_eq!(got, vec![NetMessage::InputAck { frame: 5 }]); - assert!(b.poll().is_empty()); + assert_eq!(b.poll(), [] as [crate::message::NetMessage; 0]); } #[test] @@ -334,9 +334,9 @@ mod tests { let (mut a, mut b) = MemoryTransport::pair(LinkConditions::fixed_latency(3), 7); a.send(&NetMessage::InputAck { frame: 1 }); // Three polls of nothing, then delivery. - assert!(b.poll().is_empty()); - assert!(b.poll().is_empty()); - assert!(b.poll().is_empty()); + assert_eq!(b.poll(), [] as [crate::message::NetMessage; 0]); + assert_eq!(b.poll(), [] as [crate::message::NetMessage; 0]); + assert_eq!(b.poll(), [] as [crate::message::NetMessage; 0]); assert_eq!(b.poll(), vec![NetMessage::InputAck { frame: 1 }]); } diff --git a/crates/rustynes-ppu/src/lib.rs b/crates/rustynes-ppu/src/lib.rs index ba9ba4d0b..d4aed5e7c 100644 --- a/crates/rustynes-ppu/src/lib.rs +++ b/crates/rustynes-ppu/src/lib.rs @@ -104,6 +104,6 @@ mod tests { #[test] fn version_is_non_empty() { - assert!(!version().is_empty()); + assert_ne!(version(), ""); } } diff --git a/crates/rustynes-ppu/src/snapshot.rs b/crates/rustynes-ppu/src/snapshot.rs index d6656f722..3a592309f 100644 --- a/crates/rustynes-ppu/src/snapshot.rs +++ b/crates/rustynes-ppu/src/snapshot.rs @@ -1385,6 +1385,75 @@ mod tests { assert!(t.oam2_fetch_frozen, "freeze flag is NOT the overflow flag"); } + #[test] + fn snapshot_round_trips_the_v6_tail_at_non_default_values() { + // The v6 tail -- per-sprite `spr_halted` plus the six rendering / + // OAM-corruption fields after it -- was round-tripped only at its + // power-on defaults, so a reader that disagreed with the writer about + // ORDER still passed: every field read back the value it already had. + // `snapshot_schema_audit` proves only that each field is written. + // + // So flip ONE field at a time away from its default and compare the + // whole tail after restore. A swapped pair of adjacent bools then + // reads back as two wrong fields instead of two right ones. + type Tail = ([bool; 8], bool, bool, bool, u8, bool, bool); + fn tail(p: &Ppu) -> Tail { + ( + p.spr_halted, + p.prev_rendering_enabled, + p.rendering_enabled_delayed, + p.oam_corruption_pending, + p.oam_corruption_index, + p.oam_corruption_disabled, + p.oam_corruption_disabled_instant, + ) + } + type Setter = fn(&mut Ppu); + let cases: [(&str, Setter); 14] = [ + ("spr_halted[0]", |p| p.spr_halted[0] = false), + ("spr_halted[1]", |p| p.spr_halted[1] = false), + ("spr_halted[2]", |p| p.spr_halted[2] = false), + ("spr_halted[3]", |p| p.spr_halted[3] = false), + ("spr_halted[4]", |p| p.spr_halted[4] = false), + ("spr_halted[5]", |p| p.spr_halted[5] = false), + ("spr_halted[6]", |p| p.spr_halted[6] = false), + ("spr_halted[7]", |p| p.spr_halted[7] = false), + ("prev_rendering_enabled", |p| { + p.prev_rendering_enabled = true; + }), + ("rendering_enabled_delayed", |p| { + p.rendering_enabled_delayed = true; + }), + ("oam_corruption_pending", |p| { + p.oam_corruption_pending = true; + }), + ("oam_corruption_index", |p| p.oam_corruption_index = 0x1F), + ("oam_corruption_disabled", |p| { + p.oam_corruption_disabled = true; + }), + ("oam_corruption_disabled_instant", |p| { + p.oam_corruption_disabled_instant = true; + }), + ]; + let default_tail = tail(&Ppu::new(PpuRegion::Ntsc)); + for (name, set) in cases { + let mut p = Ppu::new(PpuRegion::Ntsc); + set(&mut p); + let expected = tail(&p); + assert_ne!( + expected, default_tail, + "{name}: the case must change something" + ); + let mut q = Ppu::new(PpuRegion::Ntsc); + q.restore(&p.snapshot()).unwrap(); + assert_eq!( + tail(&q), + expected, + "{name} did not survive snapshot/restore" + ); + } + } + #[test] fn snapshot_round_trips_sprite_evaluation_state() { // v8: a snapshot taken with a sprite-evaluation pass in flight (dots diff --git a/crates/rustynes-ppu/src/state_trace.rs b/crates/rustynes-ppu/src/state_trace.rs index fbae4868e..f8c7121b1 100644 --- a/crates/rustynes-ppu/src/state_trace.rs +++ b/crates/rustynes-ppu/src/state_trace.rs @@ -757,7 +757,7 @@ impl PpuStateTrace { } let n = body.len() / RECORD_SIZE; let mut records = Vec::with_capacity(n); - for chunk in body.chunks_exact(RECORD_SIZE) { + for chunk in body.as_chunks::().0 { let rec = PpuStateRecord::from_bytes(chunk) .ok_or_else(|| String::from("PpuStateRecord::from_bytes returned None"))?; records.push(rec); diff --git a/crates/rustynes-probe/src/atlas.rs b/crates/rustynes-probe/src/atlas.rs index 43bbbe2f5..26be0f762 100644 --- a/crates/rustynes-probe/src/atlas.rs +++ b/crates/rustynes-probe/src/atlas.rs @@ -195,7 +195,7 @@ where let n = frames as usize; let mut data = vec![0u8; WRAM_LEN * n]; - for (f, chunk) in frame_major.chunks_exact(WRAM_LEN).enumerate() { + for (f, chunk) in frame_major.as_chunks::().0.iter().enumerate() { for (a, &v) in chunk.iter().enumerate() { data[a * n + f] = v; } diff --git a/crates/rustynes-script/Cargo.toml b/crates/rustynes-script/Cargo.toml index 8602ee69e..1df8691c5 100644 --- a/crates/rustynes-script/Cargo.toml +++ b/crates/rustynes-script/Cargo.toml @@ -2,7 +2,7 @@ name = "rustynes-script" version.workspace = true edition = "2024" -rust-version = "1.96" +rust-version.workspace = true description = "Sandboxed Lua 5.4 scripting engine for the RustyNES emulator (memory/state access, per-frame callbacks)." license.workspace = true authors.workspace = true diff --git a/crates/rustynes-script/src/lib.rs b/crates/rustynes-script/src/lib.rs index e55bf45ac..135bb8cc2 100644 --- a/crates/rustynes-script/src/lib.rs +++ b/crates/rustynes-script/src/lib.rs @@ -1294,7 +1294,7 @@ mod tests { assert_eq!(draws.len(), 2); assert!(matches!(&draws[0], DrawCmd::Text { text, .. } if text == "HP: 3")); // Drained — a second drain is empty. - assert!(eng.drain_controls().is_empty()); + assert_eq!(eng.drain_controls(), [] as [types::ControlCmd; 0]); } /// v2.1.10 "Creator Tools" (B9) — `emu.drawLine` queues a `DrawCmd::Line` @@ -2112,7 +2112,7 @@ mod tests { "error should name the port rule: {err}" ); // Nothing was staged, so a follow-up apply produces no command. - assert!(eng.drain_tas_commands().is_empty()); + assert_eq!(eng.drain_tas_commands(), [] as [types::TasCmd; 0]); let mut eng = ScriptEngine::new().expect("engine"); eng.load( diff --git a/crates/rustynes-script/src/mlua_backend.rs b/crates/rustynes-script/src/mlua_backend.rs index 3c619bbac..83a7d539b 100644 --- a/crates/rustynes-script/src/mlua_backend.rs +++ b/crates/rustynes-script/src/mlua_backend.rs @@ -1759,7 +1759,7 @@ impl VmBackend for MluaBackend { let nes = nes_cell.borrow(); let fb = nes.framebuffer(); let t = lua.create_table_with_capacity(fb.len() / 4, 0)?; - for (i, px) in fb.chunks_exact(4).enumerate() { + for (i, px) in fb.as_chunks::<4>().0.iter().enumerate() { let argb = (u32::from(px[0]) << 24) | (u32::from(px[1]) << 16) | (u32::from(px[2]) << 8) diff --git a/crates/rustynes-test-harness/src/lib.rs b/crates/rustynes-test-harness/src/lib.rs index e7ab4e732..5bcdeefcc 100644 --- a/crates/rustynes-test-harness/src/lib.rs +++ b/crates/rustynes-test-harness/src/lib.rs @@ -57,6 +57,6 @@ mod tests { #[test] fn version_is_non_empty() { - assert!(!version().is_empty()); + assert_ne!(version(), ""); } } diff --git a/crates/rustynes-test-harness/tests/contribution_checklist_audit.rs b/crates/rustynes-test-harness/tests/contribution_checklist_audit.rs index b0dd2062c..e645cedb4 100644 --- a/crates/rustynes-test-harness/tests/contribution_checklist_audit.rs +++ b/crates/rustynes-test-harness/tests/contribution_checklist_audit.rs @@ -520,7 +520,7 @@ fn a_markdown_link_bullet_is_not_a_checkbox() { let md = format!("{GOOD}- [NESDev documentation](https://www.nesdev.org/wiki/)\n"); let items = parse(&md).expect("a link bullet is ordinary prose, not a malformed checkbox"); assert_eq!(items.len(), 2, "the link must not become an item"); - assert!(violations(&items).is_empty()); + assert_eq!(violations(&items), [] as [std::string::String; 0]); } #[test] diff --git a/crates/rustynes-test-harness/tests/mister_source_map_audit.rs b/crates/rustynes-test-harness/tests/mister_source_map_audit.rs index b622ba32d..d2fd1087a 100644 --- a/crates/rustynes-test-harness/tests/mister_source_map_audit.rs +++ b/crates/rustynes-test-harness/tests/mister_source_map_audit.rs @@ -361,7 +361,7 @@ fn a_bare_filename_is_rejected() { "nesdev_wiki/APU_Sweep.xhtml".to_owned(), "docs/apu-2a03.md".to_owned(), ]; - assert!(bare_filenames(&clean).is_empty()); + assert_eq!(bare_filenames(&clean), [] as [&std::string::String; 0]); let dirty = vec![ "nesdev_wiki/APU_Pulse.xhtml".to_owned(), @@ -390,7 +390,7 @@ fn a_citation_with_an_unrecognised_extension_is_reported_not_dropped() { // And a span that is not path-shaped must not be reported as one. let prose = "the `v` register, `$2005`, and `ppu-state-trace`"; - assert!(unrecognised_extensions(prose).is_empty()); + assert_eq!(unrecognised_extensions(prose), [] as [&str; 0]); // A VERSION STRING is the realistic false positive, and these documents are // full of them. `v2.5.3` passes the character set and contains dots, so @@ -403,7 +403,7 @@ fn a_citation_with_an_unrecognised_extension_is_reported_not_dropped() { // both -- the bare-filename blindness again, one extension away. let bare_pdf = "see `board.pdf` for the pinout"; assert_eq!(unrecognised_extensions(bare_pdf), vec!["board.pdf"]); - assert!(cited_paths(bare_pdf).is_empty()); + assert_eq!(cited_paths(bare_pdf), [] as [std::string::String; 0]); let versions = "shipped in `v2.5.3`, built on `v2.4.9`"; assert!( diff --git a/crates/rustynes-test-harness/tests/release_notes_render_audit.rs b/crates/rustynes-test-harness/tests/release_notes_render_audit.rs index 0c08dcbf1..fb5cc993b 100644 --- a/crates/rustynes-test-harness/tests/release_notes_render_audit.rs +++ b/crates/rustynes-test-harness/tests/release_notes_render_audit.rs @@ -110,8 +110,13 @@ fn release_notes_are_not_hard_wrapped() { let dir = repo_root().join(".github/release-notes"); let mut files: Vec = std::fs::read_dir(&dir) .unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) - .flatten() - .map(|e| e.path()) + // Fail closed per entry too. Until v3.0.1 this was `.flatten()`, which + // drops an entry whose metadata cannot be read -- a release note the + // audit then never examines, and never says it skipped. + .map(|e| { + e.unwrap_or_else(|e| panic!("read an entry of {}: {e}", dir.display())) + .path() + }) .filter(|p| { p.extension().is_some_and(|x| x == "md") && p.file_name() @@ -131,7 +136,11 @@ fn release_notes_are_not_hard_wrapped() { let mut findings = Vec::new(); for f in &files { - let text = std::fs::read_to_string(f).unwrap_or_default(); + // A read error (or a non-UTF-8 file) is a failure, not an empty file: + // until v3.0.1 `unwrap_or_default()` turned it into "" -- zero + // paragraphs, zero findings, a pass for a file nobody read. + let text = + std::fs::read_to_string(f).unwrap_or_else(|e| panic!("read {}: {e}", f.display())); let name = f .file_name() .unwrap_or_default() @@ -162,16 +171,37 @@ fn release_notes_are_not_hard_wrapped() { #[test] fn the_paragraph_scanner_recognises_the_shapes_release_notes_use() { // One line per paragraph -- the required form. - assert!(wrapped_paragraphs("A single long paragraph line.\n\nAnother one.\n").is_empty()); + assert_eq!( + wrapped_paragraphs("A single long paragraph line.\n\nAnother one.\n"), + [] as [(usize, std::vec::Vec); 0] + ); // Two lines of one paragraph -- the defect. assert_eq!(wrapped_paragraphs("wrapped here\nand continued\n").len(), 1); // Structure that legitimately occupies several short lines. - assert!(wrapped_paragraphs("| a | b |\n| - | - |\n").is_empty()); - assert!(wrapped_paragraphs("- one\n- two\n").is_empty()); - assert!(wrapped_paragraphs("> quoted\n> more\n").is_empty()); - assert!(wrapped_paragraphs("# head\n## head2\n").is_empty()); + assert_eq!( + wrapped_paragraphs("| a | b |\n| - | - |\n"), + [] as [(usize, std::vec::Vec); 0] + ); + assert_eq!( + wrapped_paragraphs("- one\n- two\n"), + [] as [(usize, std::vec::Vec); 0] + ); + assert_eq!( + wrapped_paragraphs("> quoted\n> more\n"), + [] as [(usize, std::vec::Vec); 0] + ); + assert_eq!( + wrapped_paragraphs("# head\n## head2\n"), + [] as [(usize, std::vec::Vec); 0] + ); // Fenced code keeps its own line structure. - assert!(wrapped_paragraphs("```text\nline one\nline two\n```\n").is_empty()); + assert_eq!( + wrapped_paragraphs("```text\nline one\nline two\n```\n"), + [] as [(usize, std::vec::Vec); 0] + ); // A deliberate hard break (two trailing spaces) is not a wrap. - assert!(wrapped_paragraphs("line one \nline two\n").is_empty()); + assert_eq!( + wrapped_paragraphs("line one \nline two\n"), + [] as [(usize, std::vec::Vec); 0] + ); } diff --git a/crates/rustynes-test-harness/tests/release_state_prose_audit.rs b/crates/rustynes-test-harness/tests/release_state_prose_audit.rs index 1f09ab1a7..7b88be87a 100644 --- a/crates/rustynes-test-harness/tests/release_state_prose_audit.rs +++ b/crates/rustynes-test-harness/tests/release_state_prose_audit.rs @@ -136,8 +136,12 @@ fn versions_near(line: &str, at: usize, window: usize) -> Vec { num.push(bytes[j]); j += 1; } - if triple(&num).is_some() { - out.push(num); + // A version ending a sentence (`v2.4.2.`) carries the full stop; + // without the trim `triple` rejects it and the version is + // silently skipped (fixed in v3.0.1). + let num = num.trim_end_matches('.'); + if triple(num).is_some() { + out.push(num.to_owned()); } i = j; } else { @@ -276,11 +280,23 @@ fn the_current_label_scanner_ignores_prose_quoting_the_defect() { "2.3.5" ); // The historical quotation, which is correct prose and must not be flagged. - assert!(current_labels("v2.3.5, still marked `(current)`").is_empty()); - assert!(current_labels("the table stopped at v2.3.5, still marked `(current)`").is_empty()); + assert_eq!( + current_labels("v2.3.5, still marked `(current)`"), + [] as [(usize, std::string::String); 0] + ); + assert_eq!( + current_labels("the table stopped at v2.3.5, still marked `(current)`"), + [] as [(usize, std::string::String); 0] + ); // A version with no codename, or no label, is not a claim. - assert!(current_labels("**v2.4.5** (current)").is_empty()); - assert!(current_labels(r#"**v2.4.5 "Compass"** shipped"#).is_empty()); + assert_eq!( + current_labels("**v2.4.5** (current)"), + [] as [(usize, std::string::String); 0] + ); + assert_eq!( + current_labels(r#"**v2.4.5 "Compass"** shipped"#), + [] as [(usize, std::string::String); 0] + ); } /// Phrases that name a release as the one currently tagged. @@ -409,9 +425,15 @@ fn the_tag_claim_scanner_reads_backward_and_needs_a_version() { "2.3.9" ); // No version in reach is not a claim about any release. - assert!(tag_claims("its version can lag behind the latest tag").is_empty()); + assert_eq!( + tag_claims("its version can lag behind the latest tag"), + [] as [(usize, std::string::String); 0] + ); // A version far outside the lookback is not the subject of the phrase. - assert!(tag_claims(&format!("v2.3.9{} the current tag", " ".repeat(400))).is_empty()); + assert_eq!( + tag_claims(&format!("v2.3.9{} the current tag", " ".repeat(400))), + [] as [(usize, std::string::String); 0] + ); // Multibyte prose must not panic the backward scan. assert_eq!( tag_claims("— v2.4.6 → “Abacus” — the current tag")[0].1, @@ -533,7 +555,7 @@ fn versions_are_found_only_within_the_window() { assert_eq!(versions_near(line, 0, 60), vec!["1.8.9".to_string()]); // Outside the window, the same version is not attributed to the label. let far = format!("In development{} v1.8.9", " ".repeat(80)); - assert!(versions_near(&far, 0, 60).is_empty()); + assert_eq!(versions_near(&far, 0, 60), [] as [std::string::String; 0]); } #[test] @@ -543,3 +565,17 @@ fn a_multibyte_line_does_not_panic_the_scanner() { let line = "Next up — v9.9.9 → the next thing — really"; assert_eq!(versions_near(line, 0, 60), vec!["9.9.9".to_string()]); } + +#[test] +fn a_version_ending_a_sentence_is_still_found() { + // The scanner collects digits and dots greedily, so `v2.4.2.` read as + // "2.4.2.", which `triple` rejects -- and the version was silently + // skipped, letting non-compliant prose pass the audit. + let line = "Next up — v2.4.2."; + assert_eq!(versions_near(line, 0, 60), vec!["2.4.2".to_string()]); + let line = "Shipped in v2.4.2. Then v2.4.3..."; + assert_eq!( + versions_near(line, 0, 60), + vec!["2.4.2".to_string(), "2.4.3".to_string()] + ); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 0ef487aa4..7b98107ca 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.96.0" +channel = "1.99.0" components = ["rustfmt", "clippy"] # Declare cross-compile targets here so rustup auto-installs them # whenever any cargo invocation reads this file. Both CI jobs that @@ -26,16 +26,18 @@ components = ["rustfmt", "clippy"] # per-job by `.gitlab-ci.yml` instead, keeping that cost where the need # is. See the post-mortem comment at the top of `.gitlab-ci.yml`. # -# BEFORE BUMPING `channel` TO 1.97 OR NEWER, read this. The libretro build -# image injects `-Car=,Clink-arg=...` into EVERY Apple job, and `-C ar` -# — a deprecated no-op for years — became a hard error in Rust 1.97: +# v3.0.1 moved this pin from 1.96.0 to 1.99.0 (the newest stable on +# 2026-10-06). The libretro buildbot follows it: `.gitlab-ci.yml` sets +# `RUSTUP_TOOLCHAIN` to the same value (CI's `libretro-cross` fails if the two +# differ). For one day it did not. The libretro build image used to inject +# `-Car=,Clink-arg=...` into every Apple job, and `-C ar` became a hard +# error in Rust 1.97 (`error: `-C ar`: this option has been removed`), so the +# buildbot was first held on 1.96.0. The image dropped the flag on 2026-09-03 +# (libretro-build-rust `841f3619`), and a branch built on 1.99.0 passed all 15 +# buildbot jobs (pipeline 119614), so the hold was lifted in v3.0.1. # -# error: `-C ar`: this option has been removed -# -# No job trips it at 1.96.0 — all four Apple jobs merely log the warning. -# The moment this pin reaches 1.97+, all four (`libretro-build-osx-x64`, -# `-osx-arm64`, `-ios-arm64`, `-tvos-arm64`) fail together. The fix is two -# lines in each job's `before_script`: +# If a future image brings `-C ar` back, the fix is two lines in each Apple +# job's `before_script`: # # - unset CARGO_ENCODED_RUSTFLAGS # - export RUSTFLAGS="" @@ -43,8 +45,8 @@ components = ["rustfmt", "clippy"] # Cargo takes extra flags from exactly one source, first match wins # (CARGO_ENCODED_RUSTFLAGS -> RUSTFLAGS -> target..rustflags -> # build.rustflags), so that pair wins without needing to know where the -# image sets them. Discarding them is safe: rustc splits `-C` at the FIRST -# `=`, so the whole comma-joined string is consumed as the `ar` value and -# those link arguments have never reached the linker for any core. +# image sets them. Check first what the image passes: since the 2026-09-03 +# change its `-C link-arg` flags are separated properly and DO reach the +# linker, which they never did while they were swallowed as the `ar` value. targets = ["thumbv7em-none-eabihf", "wasm32-unknown-unknown"] profile = "default"