From 56dbe976361a1d1c72701154806003309ee4cf36 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 16:07:51 -0400 Subject: [PATCH] review: v3.2.0 slice code (review-only, never merged) A CodeRabbit review slice of release/v3.2.0-confluence (#604), which is over the 100-file limit. 84 files, byte-identical to that branch. Committed with SKIP=cargo-clippy, by the maintainer's decision of 2026-10-14: a slice of the release's Rust files does not compile on its own, so the workspace clippy hook cannot pass here by construction. Every other hook ran. The complete tree passes clippy and every CI gate on release/v3.2.0-confluence. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/workflows/ci.yml | 11 +- .github/workflows/security.yml | 4 +- .gitignore | 4 + Cargo.lock | 54 +- Cargo.toml | 3 +- android/app/build.gradle.kts | 4 +- .../com/doublegate/rustynes/MainActivity.kt | 118 +++- .../com/doublegate/rustynes/NesSurfaceView.kt | 1 + android/build.gradle.kts | 8 +- android/gradle/wrapper/gradle-wrapper.jar | Bin 47623 -> 48749 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 +- crates/rustynes-apu/src/apu.rs | 12 +- crates/rustynes-apu/src/blip.rs | 37 +- crates/rustynes-apu/src/mixer.rs | 46 +- crates/rustynes-apu/src/noise.rs | 9 +- crates/rustynes-apu/src/pulse.rs | 9 +- crates/rustynes-cheevos/src/client.rs | 42 +- crates/rustynes-cheevos/src/ffi.rs | 5 +- crates/rustynes-cheevos/src/http.rs | 26 +- crates/rustynes-core/src/bus.rs | 109 +++- crates/rustynes-core/src/hardware_options.rs | 3 +- crates/rustynes-core/src/lib.rs | 5 +- crates/rustynes-core/src/nes.rs | 183 ++++++ crates/rustynes-cosim/Cargo.lock | 12 +- crates/rustynes-cosim/Cargo.toml | 2 +- .../src/bin/nes_golden_export.rs | 506 ++++++++++++++- crates/rustynes-cosim/src/lib.rs | 27 +- crates/rustynes-frontend/Cargo.toml | 2 +- crates/rustynes-frontend/src/app.rs | 78 ++- crates/rustynes-frontend/src/atomic_write.rs | 24 +- crates/rustynes-frontend/src/audio.rs | 33 +- crates/rustynes-frontend/src/audio_dsp.rs | 16 +- .../rustynes-frontend/src/battery_policy.rs | 42 +- crates/rustynes-frontend/src/battery_save.rs | 4 +- crates/rustynes-frontend/src/emu.rs | 40 ++ crates/rustynes-frontend/src/emu_thread.rs | 6 + crates/rustynes-frontend/src/i18n.rs | 10 + crates/rustynes-frontend/src/icons.rs | 2 + crates/rustynes-frontend/src/netplay_ui.rs | 28 + .../rustynes-frontend/src/present_buffer.rs | 18 +- .../rustynes-frontend/src/session_policy.rs | 25 +- crates/rustynes-frontend/src/ui_shell.rs | 119 ++++ crates/rustynes-frontend/src/wasm_cheevos.rs | 51 +- crates/rustynes-frontend/src/web_battery.rs | 2 +- crates/rustynes-gamedb/src/lib.rs | 95 ++- .../rustynes-gfx-shaders/src/crt_guest.wgsl | 12 +- .../rustynes-gfx-shaders/src/crt_royale.wgsl | 2 +- crates/rustynes-gfx-shaders/src/lib.rs | 4 +- crates/rustynes-gfx-shaders/src/megatron.wgsl | 2 +- .../src/signal_decode.wgsl | 18 +- crates/rustynes-hdpack/Cargo.toml | 2 +- crates/rustynes-hdpack/src/hdpack.rs | 6 +- crates/rustynes-ios/src/ffi.rs | 13 +- crates/rustynes-ios/src/gfx_metal.rs | 25 +- .../rustynes-libretro/rustynes_libretro.info | 2 +- crates/rustynes-libretro/src/abi_tests.rs | 81 +++ crates/rustynes-libretro/src/lib.rs | 13 +- crates/rustynes-mobile/Cargo.toml | 3 +- crates/rustynes-mobile/src/lib.rs | 150 ++++- crates/rustynes-netplay/src/connection.rs | 10 +- crates/rustynes-netplay/src/mesh_net.rs | 22 +- crates/rustynes-netplay/src/message.rs | 165 ++++- crates/rustynes-netplay/src/relay.rs | 6 + crates/rustynes-netplay/src/session.rs | 138 ++-- crates/rustynes-netplay/src/spectator.rs | 177 +++-- crates/rustynes-ppu/src/ppu.rs | 48 +- crates/rustynes-ppu/src/snapshot.rs | 1 + crates/rustynes-test-harness/Cargo.toml | 2 +- .../golden/epoch_fingerprint.tsv | 4 +- .../rustynes-test-harness/tests/common/mod.rs | 128 +++- .../tests/external_coverage.rs | 165 ++++- .../tests/external_extended.rs | 607 ++++++++++++++++++ .../tests/snapshot_schema_audit.rs | 10 + .../tests/v21_best_effort_sweep.rs | 9 +- fuzz/Cargo.toml | 7 + fuzz/fuzz_targets/movie_import.rs | 66 ++ ios/RustyNES/AppModel.swift | 55 +- ios/RustyNES/EmulatorCore.swift | 11 + ios/project.yml | 2 +- scripts/coverage/coverage.py | 11 +- scripts/perf/ab_check.sh | 17 +- scripts/perf/quiet_ab.sh | 109 ++++ .../release-automation/branch_contained.py | 120 ++++ scripts/release-automation/run_gates.sh | 122 ++++ 84 files changed, 3717 insertions(+), 469 deletions(-) create mode 100644 fuzz/fuzz_targets/movie_import.rs create mode 100755 scripts/perf/quiet_ab.sh create mode 100755 scripts/release-automation/branch_contained.py create mode 100755 scripts/release-automation/run_gates.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c0ac339a..492fc0d97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1042,7 +1042,8 @@ jobs: # v2.9.4 — the eight fuzz targets under fuzz/ were built by nobody: no # workflow mentioned them, so a target could stop compiling and nothing would - # say so. This builds all eight and runs each for a FIXED number of inputs from + # say so. This builds them all (nine since v3.2.0's `movie_import`, the five + # foreign-movie importers) and runs each for a FIXED number of inputs from # a FIXED seed, so a run is reproducible and a failure is a real crash, not a # time-box artifact. It starts from an empty corpus (fuzz/corpus is local). # Measured on the maintainer's machine with the local corpus: ~70 s for all @@ -1051,7 +1052,7 @@ jobs: # libFuzzer needs nightly; the fuzz crate is its own workspace (fuzz/Cargo.toml), # so the project pin in rust-toolchain.toml does not apply to it. fuzz: - name: fuzz (8 targets, fixed seed) + name: fuzz (9 targets, fixed seed) needs: [changes, setup] if: ${{ needs.changes.outputs.code == 'true' && needs.setup.outputs.heavy == 'true' }} runs-on: ubuntu-26.04 @@ -1065,7 +1066,7 @@ jobs: toolchain: nightly apt: "false" cache-key: fuzz - - uses: taiki-e/install-action@v2.87.26 + - uses: taiki-e/install-action@v2.87.27 with: tool: cargo-fuzz - name: Build and run every fuzz target @@ -1082,7 +1083,7 @@ jobs: cargo +nightly fuzz build -O --target x86_64-unknown-linux-gnu targets=$(cargo +nightly fuzz list) n=$(printf '%s\n' "$targets" | grep -c .) - [ "$n" -ge 8 ] || { echo "::error::expected at least 8 fuzz targets, found $n"; exit 1; } + [ "$n" -ge 9 ] || { echo "::error::expected at least 9 fuzz targets, found $n"; exit 1; } # Run EVERY target even after one crashes, so a single run reports # all of them rather than the first (agy on #574). failed="" @@ -1129,7 +1130,7 @@ jobs: with: components: llvm-tools-preview cache-key: coverage - - uses: taiki-e/install-action@v2.87.26 + - uses: taiki-e/install-action@v2.87.27 with: tool: cargo-llvm-cov - run: cargo llvm-cov --workspace --lib --summary-only --fail-under-lines 70 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 42dc496c6..3159fcf6f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -76,7 +76,7 @@ jobs: # Reason 2 was an ACTIVE constraint until the v1.3.0 pin bump (1.86 vs # cargo-audit's 1.88); it is slack today and can bind again after an MSRV # change in either direction, so it is kept rather than deleted as history. - - uses: taiki-e/install-action@v2.87.26 + - uses: taiki-e/install-action@v2.87.27 with: tool: cargo-audit - run: cargo audit @@ -92,7 +92,7 @@ jobs: # Prebuilt binary for the same two reasons as the audit job above: it is # much faster than compiling cargo-deny, and it keeps the security gate # independent of this repo's rustc pin. Policy lives in `deny.toml`. - - uses: taiki-e/install-action@v2.87.26 + - uses: taiki-e/install-action@v2.87.27 with: tool: cargo-deny - run: cargo deny check diff --git a/.gitignore b/.gitignore index 3385cc027..e4662ca2e 100644 --- a/.gitignore +++ b/.gitignore @@ -352,6 +352,10 @@ WARP.md # Local scratch / salvage working dir (data/docs/logs pulled from /tmp triage); not # part of the repo. /salvaged/ +# v3.2.0: an AI agent's /tmp-salvage scratch from the agy/Codex remediation +# branch (session transcripts, a /tmp listing, the author's address). Kept in the +# gitignored salvaged/ above; it must never be committed under this name either. +/salvaged_tmp/ # NOTE: BestEffort boot-smoke ROMs (homebrew/unlicensed/multicart) are no longer # a separate tree — they live under the already-ignored tests/roms/external/ diff --git a/Cargo.lock b/Cargo.lock index 94c71eedd..386a54a70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,9 +608,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" dependencies = [ "find-msvc-tools", "jobserver", @@ -4237,9 +4237,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "3.1.0" +version = "3.2.0" dependencies = [ "android-activity", "android_logger", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "3.1.0" +version = "3.2.0" dependencies = [ "cc", "libc", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4347,7 +4347,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4358,7 +4358,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anstyle", "arboard", @@ -4417,18 +4417,18 @@ dependencies = [ [[package]] name = "rustynes-gamedb" -version = "3.1.0" +version = "3.2.0" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-gfx-shaders" -version = "3.1.0" +version = "3.2.0" [[package]] name = "rustynes-hdpack" -version = "3.1.0" +version = "3.2.0" dependencies = [ "lewton", "png", @@ -4439,7 +4439,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bytemuck", "cpal", @@ -4453,7 +4453,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "3.1.0" +version = "3.2.0" dependencies = [ "libc", "rust-libretro", @@ -4463,7 +4463,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4475,8 +4475,9 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "3.1.0" +version = "3.2.0" dependencies = [ + "getrandom 0.2.17", "rustynes-core", "rustynes-gamedb", "rustynes-hdpack", @@ -4491,7 +4492,7 @@ dependencies = [ [[package]] name = "rustynes-netplay" -version = "3.1.0" +version = "3.2.0" dependencies = [ "futures-util", "js-sys", @@ -4507,7 +4508,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4519,21 +4520,21 @@ dependencies = [ [[package]] name = "rustynes-probe" -version = "3.1.0" +version = "3.2.0" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-ra" -version = "3.1.0" +version = "3.2.0" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "3.1.0" +version = "3.2.0" dependencies = [ "mlua", "piccolo", @@ -4544,7 +4545,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "3.1.0" +version = "3.2.0" dependencies = [ "insta", "png", @@ -5030,7 +5031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -6823,14 +6824,15 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "c2d98561b612a84ae525331a7a094429ef8a5772514ed507ff6db5f54badc971" dependencies = [ "crc32fast", "flate2", "indexmap", "memchr", + "nix 0.31.3", "typed-path", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 00eec722e..1d6bafecd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ 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.1.0" +version = "3.2.0" edition = "2024" # 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 @@ -99,7 +99,6 @@ rustynes-apu = { path = "crates/rustynes-apu", version = "3.0.0", default-featur rustynes-mappers = { path = "crates/rustynes-mappers", version = "3.0.0" } rustynes-netplay = { path = "crates/rustynes-netplay", version = "3.0.0" } rustynes-script = { path = "crates/rustynes-script", version = "3.0.0" } -rustynes-test-harness = { path = "crates/rustynes-test-harness", version = "3.0.0" } # v1.8.0 "Android" — the shared mobile bridge, consumed by rustynes-android (and # v1.9.0's rustynes-ios). Path dep; the version req is a floor. rustynes-mobile = { path = "crates/rustynes-mobile", version = "3.0.0" } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d9410d1fb..98757644e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -65,8 +65,8 @@ android { // `scripts/release-automation/bump_release.py` from now on, starting // with the 3.0.0 cut. versionCode = MAJOR * 10000 + MINOR * 100 + // PATCH, so 20909 still rises past 20004. - versionCode = 30100 - versionName = "3.1.0" + versionCode = 30200 + versionName = "3.2.0" // No abiFilters here — set per buildType so release ships arm64 only // while debug keeps x86_64 for the emulator. // PLAY_BUILD is set per-flavor below (`false` for `foss`, `true` for `play`), diff --git a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt index f687261ad..1b688340d 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt @@ -307,6 +307,18 @@ class MainActivity : AppCompatActivity() { } } + /** v3.2.0: hold `FLAG_KEEP_SCREEN_ON` while a game runs (set from the + * emulation loop on a change of its running state). */ + fun updateScreenWakeLock(running: Boolean) { + runOnUiThread { + if (running) { + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + override fun onPause() { super.onPause() // Stop listening for controller hot-plug while backgrounded. @@ -450,7 +462,7 @@ class MainActivity : AppCompatActivity() { private fun onPauseSaveState() { // v2.7.4 (AND-09): the in-game battery save, synchronously -- the process // may be killed at any point once it is in the background. - emulator.flushBattery() + emulator.flushBattery(this) val ctrl = emulator.controller val sha = emulator.romSha // RetroAchievements progress sidecar (v1.8.6). A no-op when no RA session / @@ -558,11 +570,13 @@ class EmulatorHandle { * land before the controller goes away. A failure is logged and retried by * the next flush; the file keeps its previous contents. */ - fun flushBattery() { - val saver = battery ?: return + fun flushBattery(context: Context) { val ctrl = controller ?: return - runCatching { saver.flushIfChanged { ctrl.batteryRam() } } - .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + battery?.let { saver -> + runCatching { saver.flushIfChanged { ctrl.batteryRam() } } + .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + } + romSha?.let { sha -> FdsSaves.flushIfDirty(context, ctrl, sha) } } /** Lowercase hex SHA-256 of the loaded ROM — the save-state directory key. */ @@ -739,6 +753,59 @@ private class PreparedRom( val batteryNotice: String?, ) + +/** + * v3.2.0 -- an FDS disk's saved image: `filesDir/fds-saves/.fds.sav`, + * the desktop's layout (`EmuCore::bind_fds_save`). FDS games save to the disk + * itself, so the whole image is the save. + * + * The file is keyed by the PRISTINE disk's identity and booted through + * [NesController.bootSavedDisk], which keeps that identity. Loading the saved + * file as the ROM instead (the first v3.2.0 draft) changed the game's identity + * at its first disk write and orphaned its states, RA progress and overrides. + */ +internal object FdsSaves { + /** The bridge's own ROM size limit; a larger file is not a disk image. */ + private const val MAX_BYTES = 16L * 1024 * 1024 + + private fun file(context: Context, sha: String) = + java.io.File(java.io.File(context.filesDir, "fds-saves"), "$sha.fds.sav") + + /** Boot the saved copy if there is one; on any failure keep the pristine disk. */ + fun bootIfSaved(context: Context, ctrl: NesController, sha: String) { + val f = file(context, sha) + if (!f.isFile) return + runCatching { + require(f.length() <= MAX_BYTES) { "saved disk is ${f.length()} bytes" } + ctrl.bootSavedDisk(f.readBytes()) + }.onFailure { android.util.Log.w("RustyNES", "saved FDS disk not used", it) } + } + + /** + * Write the disk if the game changed it, atomically ([android.util.AtomicFile]: + * a crash mid-write keeps the previous file). The dirty flag is cleared only + * after the write lands, so a failure is retried by the next flush. + */ + fun flushIfDirty(context: Context, ctrl: NesController, sha: String) { + if (!ctrl.diskIsDirty()) return + val bytes = ctrl.diskImageBytes() + runCatching { + val f = file(context, sha) + f.parentFile?.mkdirs() + val atomic = android.util.AtomicFile(f) + val out = atomic.startWrite() + try { + out.write(bytes) + atomic.finishWrite(out) + } catch (e: Throwable) { + atomic.failWrite(out) + throw e + } + ctrl.clearDiskDirty() + }.onFailure { android.util.Log.w("RustyNES", "FDS disk save failed", it) } + } +} + /** * The I/O half of loading a ROM (v2.7.4, frontend audit AND-04): build the * controller, key it by SHA-256, load its battery save and the auto-resume @@ -760,6 +827,10 @@ private fun prepareRom(context: Context, bytes: ByteArray, uri: Uri?, name: Stri // (once; never over an existing store; see RomKeyMigration). val sha = ctrl.romIdentity() RomKeyMigration.migrate(context.filesDir, sha256Hex(bytes), sha) + // v3.2.0: an FDS disk boots the copy it saved last session, under the pristine + // disk's identity (so `sha` above keys everything, the save file included). + // Before the battery and the auto-resume state below, which are newer. + FdsSaves.bootIfSaved(context, ctrl, sha) // Load the cartridge's `.sav` before its first frame, and before the auto-resume // state below (a save state is newer and restores cartridge RAM too). val (battery, batteryNotice) = attachBattery(context, ctrl, sha) @@ -804,6 +875,7 @@ private fun prepareRom(context: Context, bytes: ByteArray, uri: Uri?, name: Stri * and never blocks the UI thread. */ private fun publishRom( + context: Context, scope: kotlinx.coroutines.CoroutineScope, emulator: EmulatorHandle, prepared: PreparedRom, @@ -811,14 +883,18 @@ private fun publishRom( ): String { val outgoingCtrl = emulator.controller val outgoingBattery = emulator.battery + val outgoingSha = emulator.romSha emulator.controller = prepared.ctrl emulator.romSha = prepared.sha emulator.romBytes = prepared.bytes emulator.battery = prepared.battery - if (outgoingCtrl != null && outgoingBattery != null) { + if (outgoingCtrl != null) { scope.launch(Dispatchers.IO) { - runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } - .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + if (outgoingBattery != null) { + runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } + .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + } + if (outgoingSha != null) FdsSaves.flushIfDirty(context, outgoingCtrl, outgoingSha) } } // Apply this game's remembered video filter (per-game DB), if any. @@ -1015,6 +1091,11 @@ private class AudioPlayer(sampleRate: Int, private val audioManager: AudioManage * either. Blocks while the ring is full (paces the loop), exactly like [write]. */ fun writeBytes(bytes: ByteArray) { + // Blocking, deliberately (maintainer, 2026-10-14). A v3.2.0 draft wrote + // `write(byte[], ..., WRITE_NON_BLOCKING)`: that overload is for 8/16-bit + // PCM, not this PCM_FLOAT track, it dropped any partial write, and it + // removed the blocking that clocks this loop to the audio device. + // Non-blocking audio needs its own pacing and is a follow-up for a device. if (focusMuted) return // another app holds audio focus (AND-03) if (bytes.isNotEmpty()) { // The samples are little-endian f32 (from `to_le_bytes`); set the buffer @@ -1348,10 +1429,10 @@ private fun EmulatorScreen( // Write the running game's battery save first, so reopening // the same game reads it back with at most the frames run // during this preparation missing, not up to a second's worth. - emulator.flushBattery() + emulator.flushBattery(context) prepareRom(context, read(), uri, name()) } - status = publishRom(scope, emulator, prepared, settings) + status = publishRom(context, scope, emulator, prepared, settings) emulator.showSubScreen = false mediaTick++ recents = withContext(Dispatchers.IO) { RomLibrary.recents(context) } @@ -2244,7 +2325,7 @@ private fun EmulatorScreen( fun closeRom() { raSaveProgress() // v2.7.4 (AND-09): the final battery write before the controller goes. - emulator.flushBattery() + emulator.flushBattery(context) emulator.battery = null val ctrl = emulator.controller if (settings.raEnabled) runCatching { ctrl?.raUnloadGame() } @@ -2751,10 +2832,19 @@ private fun EmulatorScreen( // default-off PGS_ENABLED gate; the increment auto-unlocks at the Console step // target. DISTINCT from RetroAchievements. var turboFrames = 0 + // v3.2.0: keep the screen on while a game is running (a gamepad player + // never touches the screen, so it would otherwise dim and sleep), and let + // it sleep again when paused or closed. Only a change calls through. + var keepScreenOn: Boolean? = null try { while (isActive) { try { val ctrl = emulator.controller + val running = ctrl != null && !emulator.paused + if (running != keepScreenOn) { + keepScreenOn = running + (context as? MainActivity)?.updateScreenWakeLock(running) + } if (ctrl != null && !romLoaded) { romLoaded = true // A fresh controller just appeared: (re-)apply Four Score for the @@ -2867,6 +2957,10 @@ private fun EmulatorScreen( // Hand the raw RGBA frame to the GPU SurfaceView (opt-in path); // no-op when the GPU renderer is off. gpuSurface?.submitFrame(fb) + // Always converted, also on the GPU path: screenshots, clips + // and the external display read this frame. (A v3.2.0 draft + // skipped it when the GPU renderer was on, which left all three + // showing a stale frame; not redone without a device to test it.) packRgbaToArgb(fb, pixels) } // Hot path: drain audio as raw bytes (no per-sample Float boxing) @@ -2987,7 +3081,7 @@ private fun EmulatorScreen( onDispose { // Persist RA progress before tearing down the controller (ROM unload). raSaveProgress() - emulator.flushBattery() + emulator.flushBattery(context) emulator.battery = null emulator.controller = null } diff --git a/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt b/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt index 9adaf5bcf..62f002296 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt @@ -145,6 +145,7 @@ class NesSurfaceView(context: Context) : SurfaceView(context), SurfaceHolder.Cal val w: Worker? synchronized(lock) { surfaceGone = true + pendingSurface = null w = worker w?.running?.set(false) } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 18e992e86..3e85a5bd6 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -52,7 +52,7 @@ plugins { // The Compose compiler plugin tracks the Kotlin line AGP builds against, and // it is the only Kotlin version coordinate this build sets (see the header). // Moved 2.3.10 -> 2.3.21 within the same line at the v2.6.3 refresh. - // v2.6.19: 2.3.21 -> 2.4.20 (Dependabot). The constraint people reach for + // v2.6.19: 2.3.21 -> 2.4.20, v3.2.0: -> 2.4.21 (both Dependabot). The constraint people reach for // here -- "the Compose compiler plugin must equal the Kotlin compiler // version" -- is about Kotlin and is NOT read off AGP's POM coordinate; see // the header. What the POMs do say, checked directly: each @@ -68,12 +68,14 @@ plugins { // back **BUILD SUCCESSFUL in 17m 41s** on run 34694004147 with this plugin // at 2.4.20 and AGP at 9.4.0 -- both `:app:bundleFossRelease` and // `:app:bundlePlayRelease` produced their AAB, the Compose mapping tasks - // ran, and nothing reported a Kotlin/Compose incompatibility. + // ran, and nothing reported a Kotlin/Compose incompatibility. At 2.4.21 + // (AGP 9.4.1) the same job's log reads BUILD SUCCESSFUL in 11m 52s with + // both bundle tasks run (run 38351815675, Dependabot #603). // // Read that job's LOG, never its check mark: it is `continue-on-error: true` // (the name says "best-effort packaging"), so a broken Android build leaves // the PR green. That is the standing hazard here, not this version. - id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false // v1.8.8 "Atlas" (Workstream J): the Baseline Profile Gradle plugin. The plan // named 1.4.1, but that stable line predates AGP 9 and its module-type guard // rejects an AGP-9.x `com.android.application` module ("not a supported android diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 5097068a8d375f5bf0d15693fb5b3615c972a801..9fa7f288283f81eec4952784c271abd7e0938dee 100644 GIT binary patch delta 39361 zcmXVXV{~0@*LBPj+qP}nwryKIjcqkH8Yc}-Y};v*G&yO~*!I`^dA~n<>@oJg>zZ@T zwdPzq`VQ*+4XP;`4QON~LhpYKqH9xuUX!e&lwZ_RWAWc{rak_(t+GK~L5};TPzZze z0Rhb5hm^6~9en*Z9{i;j2r&SA#^wLh^$?7e9AR9N_#81Efd-mUZfK>~u?D zEEVU>F1VJlT>3 zip805hRKLA{PT@B{GqL-rpLnFoAWiERk6xB(C1|#ob@bl0Ww*_N%3+B@(jH`dNECD zgUS~H#qjHc0pTaggDDVGWeASDA5vWj9zo$X{R|rt0zwZmT|%8Wy$hWOxT&fofo&qC zgyw>lofW?ed+lEmnZ>BiVjfIkDvyM(M2>G(c?dWk=;S^OBczHg0^>c^VIbz>7rmnb zhwwa#!sQX!xPmSXg6c(_{vH-J?m*61hJv?$|Nb#WR6lo5VnOt)r_#*iL_i@TGJ zq}yH~s{_w&LDt5trsVwNz?lu*F-mgj-wh zP7CW1)#uVTtwh^FRI&Qfz7PV72savwPAUcDfr9DdR#F44*C7@GFKRtIF!YBW)=fL) z8MQ=g47fWFF{oV-)ckBlhErUmR)Y0@p@dNrHDI;!mhV{0Z)LaNk)@b7s5CQ*04e(C z(5Da9b?zwB!@`&u3iA{P#gW1%YK|m1V^xjKj|6Z`{K`wrBPjaXpYKZIl;r6=FtT;Z zhpMs47z2}t#_hL&%VRWVpdMEwSVF060N0KOL6qlnyF~WKDxUV%z$|yepEbnk94}Y_ z&+Ok{WO{DOoJ90ELMORz=oj35wMqHGu0eQ~g^~;-+oHFQtSW&{J`PHW=wq_uq=-C1RxUF=bul>nMsM;g>%l>skgoii%k@f__+Zg@d?}g-Ge{N~!p|KCB|DgDO zmTvC}1py6_5B)zZHVUJq7ct?cH(-$fP1LX?utk<}vsf!CD4*&zwQUPEGKNMixNicR^yhORA@d(h={1|4pB&2M$y-mZ6$ zL6DXVTwOms$vpR3c)W_skL&ZC70d8uCrU}Fm?YYhJaWD6?8s%I7m$VQ zTD}b(T>M6v!l~l9CE*f^)KX^cVaNyn zQo!>sfFLmsHD^ilwun}*0(D>EpqUoEDby`rApD&4eZjQ>T<`n`HFV*xwhB-X5P2~F zLz*zIGSF>V7yFCr=IVEa&`RuwF$7a`2TZ;?nh<0*r|C^{mry{Bw)|?MS02dcY{K`b z(joJK>mfw!t4duaF5(lfgBTKj`>=hU<}IvDyVrckT<5&Vqrl8ieqODaHEf;+q381eLewa`g?dg_ATh{__f$p$|?RqCT zrf#Y3JIZfuAGWB8DoAK;l9jSe8sd*OtL^!@$nQiRH&T|AI4z@LxyoL5ZS%#T&JrSZ&-_>^xDm&^s zS%9v7%rx2El9FOl{YL8rBQ$Uo__&qgM@(JcyVvnutA@bv7=4{x@Ot;JV3%I^Tld;@L^y7AOWUfgcq+n<%hHrt(s3{Gmq zkLs$V$+tPGN6ZHAnU+pxqLg%IRI+MeAEIYpqs);_a!rIsxW(e|0h~@#I~@#-Y|Z`f zHyp9?+!w8~tiK_uoQZyhOcC4gSN4vRQO>*a$fQd6e#I{5j2Tv?=avM0eNd$12kzu2 za94lfBzq|M;Zz^RzMUuLP?|pE5yb_MIM?A_c5=MSIs8-Aaj$^7`k!dP@5RtjeY3_F zu#ShUa3BbnQE$7kO&26q5SE5}U8@qtbst6c$Sk1~z#Y7Y#`tLLD5IPS!1zl**>JBq zRi(Mj8qph?NBWSJfSBy#FVy`A30!;|xRUcX`=vK0M-uS$Rl22O8svED^f;MEGCk(_Y^XGIs?lcn$(IPmXzNkd4ppd?0~%WC&AW=`83 zj|B|$XI}0u>g&A&fT=}7$IY;-lKbwxiG~ko=KtJz0Yt~wf0EEQrqHtiKjLd0b~0W_ znt7LO2or7Amb#xV42ZA}cyA8qP7IwL@qP$_+A&|hz#}sWfqLVG*fv3@f8rf^rAXuF zgXbQPN#y2&;rPakkumpy?UcnCVs^nr*hIo(3=y>j+Vto!ei$V z1#fHcy_!xX*)!>7679>nSTWffWZ@srg_qws3b86}cT${w7a^BkMUMg5LOY-DtLqS~ z<5d8;Wa7?}_oSMibir=yo4u?Fzm-!Y;} z_B?nE)elhHHERVnrY+R*@=!6qN<8I5LvbNDs_ktB@;9DxbBwX%sXLVR`dFPtC4##h z2l2z+ZGS6X*YXr_8Cw|Uv?o+l`uxsXYHMfZ%F{{omgIUOU2W-XYu{uaPwdOzJ)FH0 zsu`w%!C#y24oC`#$G2WCxJY**0zgKl`xA-6)#sK7Y!amR5NSZ#rK1v40gbG=%PLS+ zV%V7let_mzmM|>=p}mtL-PWy0W&FSJv*s#U!JKg&bBPIDdq|x|V{pc#BZ1E4emn)0 z+zgK(wZt?#wzJxVgV>$gmhvOCP>zFd6F@pW~M!AhxR!2y>3R$h=@gpn40KT zGCa%yJOc~kI*qXMq$-&Z<*kiy+~BA`Omb>JoXSjtNI(39Ka?6$cNIM`!Su`_9QUF< z(G-GuT+*GF#Veo?C*YB|?Iq3zkqzd$bhq96fdSK$_LKhSwo>T8=V-aNR!^8-IDgdo z1fm)c-)PL^a)3|%CV$igv#qi9E=zyvkBg%x?tHEl_M)FmKM>YXe1_ifP$RXcbwOOSykei6>$QVXTFJIpWrvb@#965!Z#;UD&T9Fn zu-^1q?zGufzFQpnAy(Sq(%KzWcuF}rILH93ej#m_i{J1Dl*qS;f0gkDU^l=2 z(Ar3)u3g=(YT^>W7f=VTVmC091{L>J#fVt9_#yFq(X!xCZ&A!LSd7(q8adFuryByE z8h^QG6&h@gBd()Ggc|V?Ykx5ga!BfQ&Gs;nV8g@qIOUkAa%U&Vkv#bkQ!L~K@hZ7_AB}+v_x<$|b=|vm}>6WH} z==u`<1TdB>{^~~vEtAjB`>xgV6>a263hFa;fK3!@^}ISy7QV)5k={bPR0(w%1emE$ zn5}A79ZFqJp%%V@XBAE)NBI~se92DvB;nwTcx^7F1g|Z9;8z^b<{p>tO=yLvsyC69 zHHj*$J~OT!db9{j+&?4h95&KozwOZbd`d$bMcO!{n6$5VgH~DBPk{fbAUs09&cpi@ z90Y_N>i=&_XwqFNXn`hf%k0=uW*tV$whT_luw(1w&I^~NA>u#LV^rLAc%>K7yS=nJ zkd^Wo73e5Ns6Wu~l*1@J2GMrqC{$apU8Q4?~i@g5ORblZzm-)GeM``#)>0 zCEUEM^d^$}N{LK*ONo-U`B*OUlInh`$}My;G5?#!$oxQhJV4)w=2H}BHM3*SX)K)H zo!VbLW<1bn%{^C5+osfOE(v$rp*D*{h3+Dr6_K(oo$a7HpmG>THH+OY;a;Z$V$^HW zX6>W#?`G-krkR&6t4(9y+)8Y=&QnCci^$`E$}^XTa1v3UAeN$6$w3&Y1L<=xIs+yE zkhVzb z(fW7;rzvk;Y8`Kl1dguUCFKgkMT1RCp?8orKp4zv$kz&{L{}Ey{S%Q zshMC=dAJ|wl?l>UfW4Kk-p5RwQd^9AQJ{C&!Sqq$zf{a*0hLg7!qOHI*d^~WP?rc4 zRebOiLz1ufd;bTjgAd#${(qfr*#C99079Ddt45l1QEDupZv}$(6lFOkH*FibJ@q|W zXMyR|0P}1CgJc4AU$WRr6LszrglNV!0M`J1c4UKd&%i9yEn!y7oK|b_O|le+v(d^Zo zPB3;?H#E5dQM!IcezIe35< zbkxD*uT{quN1V?R3hISR_3QB^Y-yX|AYmK#+B8GI3Rj)KrI!R56Qx)xNu%!|Dc^Mp zBsfG%&+M*?2=<5%@{2uEow@d#b{~n48{V5JL^D_TRY#D<3sMw;;7A`+fUd4%Ws`9< zW-YM0p|dM>N^ug+v+K`XWbvK{L^Au(f)}#*AQmGzTXv(4ZAB{;cQzxIB+MdtA*C-@ z3sDZed`e#C>QG+ho2}@pZ-5t%5j}I`0RbFaB%O@RnU-k-Q@IR95(SPp<{v#Ivt;o> znDBneEt+tl3>!7-K!zzU3Jhfmk`d}cE@~hVu28p(=_!gAwl%)2Tf+W1G~SRiI#)3& z58Gn6?a`g~bFKkq%Yaf;an-sc76LcL84AS$&cj#+wJ3XwTh%@6cGQA0W-*s8^H}m? zK-|R$M_~Wp>(G(Zr@$432G3%{NH@Hy`zJC90j|AE#rEs4zj(1Azg#}4Keeu~>s)qH{z08p+c8@^T0oJws9Pq1(qMDVwGRwIwR*GTU_X(OygX3mG^|EM;`J1tC9It z=_Y;u;LMaRu1=CpPp1krGi55H2IC-!E+ zOau8?O{GawY~Z$nM!2>t%nmpD1>FgcE#lu)5c=S(b{i_anK|SN-#5QnOoeUgK~nPj zA~Lq^veXSU2xU!Bm+&moL8Di=?KO6k3$;(MuNHy%H$$Sb#j6|Uwn;q#3Y#DUO2*iw z>&bksb=A@$=RjJKrg4>HJg3saww``)3350}+%vYVv{9Ii zJg>s!BMs znKMq4_H85E>^}UwC$2ZxKF6h{|9LTrBGWXc1IiC)IhV$N^Dbu|$;|p7LCJ8#uj%P{ z=N&b3iB#Z+6_!B-bHTNhFpuTlgfM|(`5JBsU5(j@-M)}J zuX10+h#O_o@xni8c>8z5LEND+oMgl0Ge(;#J#bA^FL~_9L--e%C%V?1pl_}OS zUJjhtzCieKnoWR$+e%n|be2qnCbEr_$MVi2?tT?|0$b$@VU!lV5>n+9)3TGji768J8Lj#3^ z4<}smDa^#AuDK)tJ}n9_Em9<^qs`W!*(iIj1`GZ<54E~=-r6SnnA6km4SW84%Mulm*7plt#tct%?&$21klYdNiYr{Mecd*sjTo2aXCQ-{9EW|8q-dnH{6k|zy#+6 zDKbqd7Tou#ITwtZCjGC(*fFG6a_|h>(%HixdAPjHhelaGPN3wooRA+t(eNF-JqwY%_EtFh{iXqcTUbEqE;I-{mvXzCAdVs$o%d zLwtAp?;1`KXcfJ`{$prd`ZYaCdLWA~VMf*u{i!^pI66n8U!;(+J@{DE9JIXutW)&# z6Bc%0GW+n=`R?xh5MJBZy$G1_B&}wBHLWH{b+{$KJiztmH9*4S9}Pn!CP^-`?@3B7 zpH;^7vQM@iWE5P!*+lbA=}u7mwZn626R62Vu6hbwZf9s>D_5(-)~}w?`}=pbF~l5j zjMB*9bG_99J6uYqGMlm06j`~M)qWy?>X!(tIf&CUevwq|^?5YDEbIP_fKxca3q=}3 zy$ASV0?ytIB*6S~#Hn3lf+$!#4d7r^i5fg%-8syiHPPBcC_l)}I9Z=>2As(JjTt}M zrIWwJnr;iL0#JU6S^0(4#~JmH~hS zS}<&&e#+TOsx9&`H?0k?SPeL*J*_6pBr94o&5zN09b!>U%+80~&^5HD4 z#JF?Ox`TnXWGTy@DXpmefgnoFkPmu#O}Bi%s5Xm?nd#fmJN5+_QtBctjQR%5W{EGo z!0@HFo$1cJv#Gu~=9n9ao?}GsxE;t2h@f1{RaKeb>#;?)cEDEX`oVE?#5^$_b%<>s zNYjFtVhLCe3Kj zNH{&SWa!Hcd<+X-sAUQH$jAROqDI_rS@!=#7xaHUWEVPVdU69?dN4aC&<{%o`vX-W zha0AZ0WVx;34vB6PnIEb$rHImzCwkOWksB$P+2v-Bt3_ntEAo-`ws1iR`_Ko-YEc5 zE2C|}&Mf!_?@6fjg3Cd%(Ys!C?&ocR+vWRt$iv(7jvE^!@vt5M?P8~2*qu&u_oR+J zE_Ej?H+a7eiW;%6pPeI z%RNc)l3YlS{es|x!aYB~;#~mfh_u0CB7h5g4SIc{dJHxPd?5a?{k|7gi5%7BkeNmk zkLQ!B?ZcA8%Y@^KEyp93;5Ch#1;sXek@v={cCtlJ^CL1((g)iaXlFm4q?TmO^{njZ z%r91$n@`-AZ-J25wVPXZte})J66~2Tl+=_!A?WdKbA-XGZQhBO?WlBOpn>Czv$o-W zYbe6XK{mFj)}Ph0TX?KCE%dZYz;E;$&x`=oCjW5Ld%3OsoCBO%-Kir)che>stP8+} zh#$N4wHe+85~k?~5Qk-Bd{;=}J0F5??-X~vRuhsW;5BY&jafsPC%niVr1itZZym}X zndA%cz#zoi1_c8ehxbV3{8mB3Z-ryode6(3DaO=(jLVynbc;`1P6q#I2?uXj9C{3* z5FAaZypjeM8q{5c-}ismOCg^Y7{iY#0|@sxiIYus5?oG6f%f#uU6<-A!{A&CGfZSa zkRJT%Q4Ga*X{cV!E$UTn@bIe>VHH?Kg3PD*+Osg?lKmnZRKj6V*xbS9ZCLF*~Xc-L%2}+vzH2)o<*q$|xPo z%-R(tUX>_!ZVVsb&7vt;i?;^xj{uR!5#BDkezHF;z&jzZoZ%SIy3Ff)gwf{}@v2!D z`9E^$+koe_duS!jkv zb>;F$;jC*s@SKC3v69#mtrF0y$8a`JH|np@b^3m>S4a$aoV3a9(*OuLMAZD?PNqAM z`}((`PP0;%0b#nWI8ioR(oB_2(lZURTgtO14OpR~>iS~iKfE1eFN0N7Xts3W;)e$F zr&9*T%v|kaiSl6#IQUUS{u%7=1sjC0Y3v--RFTxti&jKQoFFWB#;}KS#djpD20fFs zyolTKzWsiaadyR8w%u=;1jaxeaqx)-qY+Q5Z^RY~_I(R%xl@}@&e-4;EGRKk_bIoAfue zKEFP`zz4=3k~4TFtr4*ae?YRm6PViP`~7f`O83(C+{jV3{TpIi7+?o{e~l)COaLPe z>!}s7stTjp?4QxDXD(RNcjLxIwaE4rJGRsV2?Lqk==1-506BZSc$leM%79nnq=Pfj+TvX-4(&#xY9#NkN<7;UxEGZD~6jYrFkOQ{(S;0G(o zF3#MX){GH^MdidFDjO{N3)gmy=`jY)ESR!)j`{?Z?3rG+kGOzsZ+emuWR^HJN&GFbft%l5XdxS40Eb6F zNXe0?BGQE%Ne?9BwziiNRGswD=R^QW$yAZq$bLnv8-00V`n9R82knm;CYq0fA-Bl- zHpkIg&C6%A$qu_$LO9W3{AwfiTcV%e;VXsuXLkezTx=AT42x$?_n{ojB5wqS)zwMT zvo((&ibsY(l3(ObWZ;dHePar_MMlZzj%@jf(8aLKqVZ$_vKXzj)0q450*nqYN(Kt! zkO3RCQxbEuwl2x{5%BibGX#vmcgXcrCM-U{+wFbhMBOuPQ=Qda1^6m>L=q@ zgt_m0F3U1HTKsK6W6=g0hP!Ri!!ojE$ez6@d@vd+i{{9#i~LiOP$uqC8|Lo}_we1( z>O5ZZabAB3HpG!}?bV&j0^3l5>BqFUf25e>QfR~~+0VI?n50m2Rd}qZ&>j)8?TVW_UUV}Y9? zg3(hCii~h+B)b$FoMNl>a(8srwh0HQbpWOBk+sF&X-DHwQbrwCD=Q#EdmSvNSRLQC@whp0y-b}g8=ktA15&8 z?luTtS@wZr<_e`Dg>&c%(+VE+t&k1eYoeSc#i;8+|4EwENgAtg$=VgvvnUeWbkp-T z?iW#ln0)*bUX1{e8-q1Ai{wL6eMm-gx;0^RC;RS_>d=OfLZt<$V@%$W-}oJpH0>4) zJyUt82>PpX_J?*jcxBWdtlWx3@yrqo9yk{%*R6gUASsa^DJu=coS*KGe029a#A)|} zGJ+Szw9SETNH9Iji-@>=SMwHnNqL*vFi)25YKII`dj{ZnnP{$hb1 zudl|2xS#lR8o4Kq88q<2z3tDCd|Cpf90>R;f?1M1_HtW@qb1(qL4H&??A8jfgMd_uPX)7 z79(OoDNF7B^%v6rCT5*U$Co)d@7iJTIZPIHn! zx!VJ1I410gq}LoObyfTjrpb#niRzkOWA}D_BQ?)SGWpE^v@36i-6Q{Zpx&#nujz@x zZa_(czKS7CY{^{S8x8scDT=SM%V8+$;j5lMI#se6m<8Wz*gY}jFZy3pvQ(@PkOc-k2EWa99=^>@&VL4fLVpbb&AQ*d+z7xdK|Wk5;`8UkW&;gvIc^-t3yTg^1;zUlAKcLb*6BC@c7_){v-KlFTJe^~b zon+L@zYlh^IlOQWaeWVN)jCADz_b)jW29MWOrE`t-}#rV`<>X50GSM?3C7Sl?eakC zK~~d`VqO}a9i^}k$nJ`B(kPyiKEiLhC*KaQC+m*2h#ooY)r1%~)RLKF#euw>=i2Aw z&XTloro_&pkarx+@pgoh#7V{Qa@B~m9tyhV5`q?>nY#R?01P&TM=LLlq6H>-t?x0P z(0hvLqY8D7qlo#X>b!{fYh!r24HD8r!LS4lkBm0x{E3__?FEgVxbK`F5F*id@$rhn z@$^Dp;2Nj(P^QAuTuv@@J%C9kD#oP&36}CFn^$l=x!lqrnPWoM8rZ`q?LDJS_Z+@s zEz+?@onPVi_Hm>@o=-Rl?9Tf6f3IsCa1M4oq7o0!I5u;de0~y!6k3IJAyI`%PC#CC zVds}n^}+nG9XvWNqgP075r(4#(T7;6(fH3W=`anU;_lloQu)<1IRL%h8beL-mogVw zxx6n{RLnXG#1FfSF$EtJS_D|Vkq%T&lB&9;g|=zCG(_GJ7&%4mn=EGMpqej*Q-`m+ zD-z?F)Lau4#L#hIFWFl$o#y?Mn0m}PQ`2bkQAK-W%i0fHdy7lEc zcSc@W{L#qJEqAR85P{$d2Qe{IN96kK@?UaK`b)dGHX;_kWbB6Yt{FT8RFLx?U^xcy zPY*8)OjDEsbkRy`+)ea3#ZA)&lQ(lMj*@O{)v|01RliyR@?Vkbk{00oEzERtY*Do%KhrlAM3< z24&ilp~4nC?{jPNoXy(VEu>#w21ES%Hz&P7j1#onJ#!9|I4drl^F)z(>_Vz?t)vij z=>aq9z-K+P7Xs<+!Tr8{QE%9&Q{1OO+BUueyZ6Vdn+NRn?b^@Wr$9KoTQ2&2A=%bF zZfO)-l6yag_L;z#%J4~Rr0+A_G9QnaMfT<&yhDQ65%QBqdi4L1%~B}*6_&iE^K zentcxP0-}ung9WSR79c=dVl0B-T#n z1rPJMJ_Rs`ojS$U)Ek?9wISmT3pi}};cg4ehKyq6$N3bNhGoqfN|Q&zx~)7Sl?1#U z0GZ^1ri|c1BN5zr_u)1AA257UyLFzXvBz;h4niXNqJtV13xV))-eYCJ;C@};LOZvW zx7cu8tLDNA7^-7WcFkJ<_jiKakdzXdo{iw<@_YVprmeuOWW%~Ei6KR?p6c(R@Q`A` z2RbPh6)q~oe;+iasW%>AhOf9H*_85s*u_UNo}de98=Se-`xXvJ=0vms(nfE)N-?W| zPQxN1I>hh#(f6X<=EL3ybPh|}I0Z@@6T70k?Cji9d&Dfyu47(28xgcohMUT{c-5RM zlz>_A=$cM?^i_|1UuKC#NqcwoF@dXseWg`KUdeVm`*1EJAPbq|bs-F4Rg*h5)gnfh z%Y?ednv!3mH6ESg-_{hP^DNI6EuaI)7178CQ(J#8>)` zAe2>aOqcxZYUj4LQkr~)yXO>fv0>ctsfC$uf|dHPuFDqKRAu@(g*Sz+;bkLxR|_Q= zmR?exsUf3DrKH2=D5lUp?b%J3GMO~7{2PUi!wp#*Z~a;IS~FYc3JB%-f9#glcuQxt zd(cvyheP=gwv}BnE^O1CtUDRdmQ*$;jxjwtW8t+VRa%6b&1hj4I%wK8ZdHcWZ&>2X z;Q{{iWaOe5(prMjTIaeu{KylBd4}VC^HsQbJdPEN%flKd>`_(CUNrcD8%v zWP;9QNc5Vzfum!A)v|LUq*Xzx2>Fsa9*M6Up-MydoF&F@NG1<^NGKAA3*x06pT&4? z%PISa@36~L+%_s4c*!7PaUWKe49mW!%W?PezdCf!X1va8Jj#Lce&1WOfD^1v0ymby9<>ow}o6 z%*gv3n+Tm4qiA8Vqe5<|1vmG_gUrh##)LLBhmd3C>=h=d2G?GJ_DtYW@gPJY%2s-d zc`0K3sBwFF8d`9wTL-L>!Xgh#uL>kVU)4Os1~#fX93l$lb|I7-Um)Y8+kgrkr0VqG z?|qXMmD1+48}6Z8BGGJv)YAyRQPIwLItB9wk&{ihIioCC*lYuE~J}ZNrNDCHeh{YtS z3TGwhg~)-jU&cH`nQrh&Lq#O}(cC}i>M%g|dY5O^k18OO4&x46`tPCL7l`dkM&b;t zyOuOETsV^D8{}2=nqhD0g*)UGnydi}Bm}`Yjut*;z& z+7wn`b|{uWR2Rs7r!l8m@4%sH`92oHBYN=g${>*zFy8sTL4_umQq$G5hyi^2WzaI( zCMt(+~qymYi-A^vF%1Vp{o z|NTNhb+pz(zXyL)vUXqV%%Ib37)P#7;~=fUm`nS@?oxGr;_JsH6pvmO+dnL}Tc)N& z8Qb@~>&AJq-5{&Im!8M%7j)5kSlAi#_jdh}48nL2h&gkZ;4J93Mu`0@_WxUC*)X49VoOeo@N>s0IS#T$uddQJ^?$`;+0hv z;SDMP22pn&A_gqqWVSj=l)I_?qFJXfWL#XBX1~g4yv?62&~>eN&|b-m3IV_e|HgJ& zD%G9;n+K2$>@+$;$TTuynNMK*JgG(RUe2{x@XNB7$6Ug6q)>O!GmO(uD)&2HE-4d> z6(xRBQORFrA6T&SGRrK8+R%64ysMo4V?z=SaCJb`*`(T71IBkW=y{1pM5N`3(CK zLxluwR~l+ZfH{?zE;uLGjXwmdDbZb<;w30K?%zX^Ez2#gSFvJ9Ggr>3T8& z9Nm>MBn(wb>ku5$5M&*H!X8dH(f(kgHnkGX_0B9Y>qO4f?0mbmkJKOL9zIen)8-`^ z<#TmeiNq7oh8A$x$GdmjrLU#(-M5Q1k4=9hVmPQ2W(`RVBsCI|2}ay9XaBc54)1a- zddOq+meP|lLX10UMAP-9yr=XiY6tuq7+xLaI%#SjL;AufzmN>3eGgJPGL1`~MTf7Q zE^=q(Vkg_P_jy2(ZRgPL%_EpbMRfy9$1GFKMar|el{Gr*l0{E!p>hP!jZ}0~G++nK z&Pe-${E*irv3ItAj~V;8|K5^3rc#+8PYwbl)qcAeqqObzKt63g5Ve zB|wSp13Ob2oZ1E+`Je^%LI1~-va$x%qkpO0?Ej^91B&9nfB$ufP?0reFt)aR-K>hw zy1R!}O#g+6mkS#DwlNav;U!ccS~>GN(c@r%o_ht{0HsF2ihU)M0kIJBcyQ5qT?p8z zUTleN{f6WpVhXK&ep7lJB1;q2nw+?N_B`VY$r;Px-S^Ig@etIUF|xrvh>NbufWOh6 z{1Hre_{B{bNUNtJ79aMlMVyUO`|#@AeZ#i?{No|HYK)yji}z5QwyR#XcKezQ869d= zLa(hu$i28qxVGTf}O=J^13feuf@@{lV?(4Zf+|)ckZEGIdXBYNuGs` zMhdA8_;)L|Dsy%J1@o}~>wYVg4S^sRMIw6bl-077auw)|tc3wMCi0319G+_WC2HTj ze12CFpA`>f#K$+ta4$?xOS zWI#A~TqVHHc7MO&^P0VfmlVjwYLQ)NEWskMx9|LF;bBX2-U<2UbqGTk9q9e@Kw|O? z$%`r`Ftm9CtV(X1(5G{9{be#sVB{_5wtkJ+;(coKNp+upe*^oAxWS}GPC-#Y9^VDZ zR}Om=wi^|XSzj2b)!X`(gWGnhDI?L#$4IljJ(td;@y+qZ7}VtatNM`TM`kk^iEUI# zFp`-}8pV$}zJQQA zi~f}sL2rQKIl5H)d!-@Oz6Cb3j{{oiZ1H|}HnP)qC6y7h;e~FVZQ!nj-lzuS>K{Lv zR$ZnQ@BORTD64N7Up7~-kDXqhf|y(*`pyj*RoScawqi#VqjM+Lk?w(3HG((v_WZGw z!{E<+gHeEsT%fVztM(T)Qo=E}7&l6AZfRG?zybcOQBpDG4|*qhFgDI4TL_GgZ0TQo zS*wWiOeZG2%iL17eqa#JEkN#fWP_}${o_dCs~6x;P*C^keQ!Pgqf`5wQ?+?2sE}j; zmL%Fknkp>jav$>@6EN(viDM?)n|0+j$sQ$PIkN>Cd~Hs|2&)~@4&yG(Z^cmXpCMUP z@hq~&81^=$bRtx^fhZgOtHu>30dh)Q#yB6T>=x~-q`rw} ze|f?;!z=o;dueamsvLA9aS_TF6`M$g>E~;4X31D4;MX+^dw{_hK{U^(rp{`Mvozi^ z#aij-F38nZ{1&IF`*ZN;ruG{HT;pei;*Te&RWr|YG%^eIAv9y^GZ%xpfOo#~_|8d^ z;Ex2`!U06?W=V3yxB~&>_ybN|tDnVMQz1`bxukM8IQx9F+RQ!DhLLH?sGl-Hsee!x zo8%m37$h6V!|C>VX+GfoD{_p%4H;tT!s^yQ12rrS>e2H;oJ$~;x##PFi4Vm`Al;NZMx8uqJqLVk+H63CG zB6y85;faJ;$8HuLtt&lCr)Fd7?n(hn3FmC#5tyQV&$Fl6Rl;;AL~azrO7_h?&DDVf zkXGu4+b!IqCjYcuLGQ0tz@%5Ze7%AxBVtb1d4oXvv{&zsrI0>R-3R2MYnJra2JM9r zcl(^in+&Qq?eK=kl3}CAU=3=en|&kTkd=K=&E9mMO;u%pLq^F#?t%UX%8m8P5iLj$ z;jVccU_teuAC9CF8CD&IU}&_M;2TvI0G{`pH}bM+pcP)7bRUaRd7nJLB#-+oLHc*( zN6QucapXOw&tNrA9aBvQsz41GqAM&PmGkZ+IWlY*F#E%cON|q7_`x@!;oSk;Lr7Ys zaT0iz^U|WOQOTl?5DQJ0&=dg}*2@k$M70-NEZv(dw-I#tCc0egYu)xyH}3BO0(Fk~ zr`1S1)SZAWM4{ytyj(^L7gX;}m*FCSNKoa*@dhF-8psgUqa9T) zi%549&O2M0?Z|oe)RxjYxRi)!x0Qp0p4ROpzxfh0?k)ro1H&u)E5o#KEKlLRod+lG zM7E{t!ldouvXEFqhSU+vIMQ-HqQL%CM~2Y$H$sMjZpyA;-6+Ye3%ae|j%$@IpwX;T z+?s2|7cj#R5)bRs6*jU4dn6+YI@mE0yz?J3a+x}#(i^n2f!cOHAE+~Dy+T43WW!X@ z)C9aE%&*cZ;&77I3i`av$QFJSQtCZIL4JN40S9w{UB~65eIpJIofpSMMi0-wRhD|NawaYq61cg*IB5UtlS zmBi#+bctd%E%&{L2I(c-x+#7t5~?Jgj8-94eqCOmlMup#qJMGefsXMHoieo`2-$o&Mg5EYP zTHUL&KrFC<J1dE&29SmX_ZVjLjYXza%+w#_6(l+oifoZ4n&VQ*sn}XHPx{oMc)P!{b+>T~) zH*3j2E6*nKwqxZpG{y2+XS+*y5@<(94cf3?5PnHT8Q3seC$0k+j2kq}Cu-mhYL&6k zYoE-!k{%9X3+}AJW<&+Pw2NL^W~jn>$<8+1rTeze(g>Bd_Fh$kfmhPDG{YYIR1lrI zcL#P-(0_c!<7qD4lB))BFLniBpo@EX{GgRA*gjid)Hd$1_YzIXhp{9TQxM*1WAQ z!H3`kkOhO;$XUbofRk`lI(=f`KzCW{GAIaE!Uj$Vs;RO*R)Q3r>XdrgNj$8X#m?`} zf!ch94{`qgLoZYuPcw0R;>JJ5O4H$zsd8}`wO;1XMeMY{5e4lk-Hpg zBt=nzc&mqhn;_dX-|>GMO!v=r%(KTk^zwI7&6PUoJC)8`C+|HOx3gL}4Y0hfoFQ zm7LA31+y&aL5WO<7Tc{_3xbsx15cK6zU5HWUPZ1erYx``6l|JIw`GS+Ce7pPm_Y=xxGieGLXG;)`nD|5x1so4BD1SxZ&wpnI z4)j#qOlu@_&Ad1u1SN;{|qXwx*yRBW{pf)FAQW5_5xM+g!AX z->%Ffvxpk_U6NuRIbc6juyf_Ie@bxsPZed#(`8%<;0yR7r6Zvd?ddMX*VL_>zJ%Y` zO<$(!+}o_7(vBeh&|CVWa_lIRyP09Xqr5(G| z6%O##%7uLuk@)9=6?3KOEF_XcT5tYBuxtVGJ*W7~0A9dfF$gql(+s!OlyKhW5-l44 zZ}4?J@wc=|WH_U7-lLK5cY?Ns?zk?ce^5%_oO5!4qBZ~IKkCVU(oa_aUsL`=3*;07 z-(mWgwnATqZ-lZbE0p(LT7QwIsL(XES&=X*7I^u;;rnXee`lbNxcbpz+JOv@dVv3xBztqa% zqLI93!$^}U+LWhq>wiqSRUP4pS)IG*N;yN=kS}qV5)zKxn@RWbB^Q(?Hb8Q_tgC}P zs|(jwUs`LV4a2q7(mto9dP%?DS|@DA8)wjo^zqD?-O*#E28XP@cHSDc;_P2e8N#l- zCN+Z)e7W+5(m9?O%?$0f((J%;YkM-8u|oFx1%B4S{!>}|XMbDtuDt%)b@NwuTFGP| zU4+j03fUBpjdDkAm29S=nOKq(vc0l9bUVWec({;u^J2%Y(u8F0lxPFpmz`Vf5PN4Q zF<8)*0Viq7T|cqwU%*i1-dCFBZZ-WYz1Oc>Ay_GnCzxGK>12T{H8K4ZEN#j?f|iPQ z*k#!D^0bxgIDh1A-N{m}RnSV(-FDI4`0VMjD+2l2B8zbu$Z_GG}husTz zGkxn%EPpFzz3ZnR%Cu)n(rQJ%hni=@*o%oe(_FOkY7?e0{n;Qrd+J+S4WfH>Hx zA3lp~^kn;Smh%kFTPSsW?WLctOySd~*2AP`kn~X_lWR{~Dch7sSl$UtFFxumT26e_ zl-IHRbeO`&&mzshZzQlEf&N0^V_%75CYT@BrXbtgVX-usnd{V^z!wtEYy zK}MK|sq4&S@+xB1OENjWwf%gO^NEHk&iO89F{{^CxjQZ?jkQ|fIPzugj(j0=^lQz- za@NSVGkZMdK`rvQ?5=S z@8o9*7E{N9<5-0EabB&@8c%M!E$(W>T_)|^X=$*3w$UM~nESLk+*&D^Z_B{S)}r zB0d$1R!!p5H0LBf6KfsEGvhc{#B;IO-sV>Rjp@1174i9o-#J>u@128H#2>iduY4ZQ ze-K@*m+*WMe-_(Tk1u`vs-tb=`0FCR(eU?0d@Ed4#6K7D?Q#6e z#h$Pc^_|66O5+DwV@13e_18DyvnI9oUw2h^R&O@L{+1$s6uTp^=%YA1A&ZMr^(1yJ zH`go+oH^2IZ8q0fM>>~^k*a?D!>Czqu0HellUUlkhA6x99FDF&v)n97Fn?^cEM68U zN&`=BHapKweM%V&$x`JcOb=~7hXWy5UX*ZARu|=_XL=|^Pg#m_xj7_}vH&4z!|PpIO@VWDtrtNxqq;Z3OoENtfRso!pB`$M}jtZ|A%azlOT)73cQ)JyV z;AaYDBpSGCplFa8M&KHE!3)W&P$Yf(U#B>$z?Zgj<$KdB2AQ2rYJZu#VBm-1o5uTq zYb>C3Q;e4R1bHYh?|veeqRK-ORe*>NZ&J9S_p3dy}~ z6S5~3r6<-;N>{8m?0=JS*?Sfz7+t4{56S)B4u6>QKArbo#dj}CUef&sjs(Pf$te! z{76V%b+jnflZduy*zD1(dO{Ls#yBU#mr)avQOylzNRCB)&3`QnB77?(N$-A!SZiBR zvX{h(?I}u*95fdt&w)8=rZ((D^)+laWZx9_tG@Z~q`HS8UBD0FUw(}!>GDl6;^-P1 z4ZOf*clDYgSU4J}Ym||$MVjdv4YbI57KFQL``2*C!?fKKw8=-fbpj^7N}GL$`|9;~ znv&Ikm2R~+D@WiaY<;nz>Y z{&VP$R-MB^&FL@1jzrDYuo-LN|7QNziX3AZGGlEzG4xXvHa#2Dao(VVH+|F0^vw%S z-|~|-yv>__`)RkfE~2*Fue{5vS+a%m7U__e|1VHW2MY+fJl+B04gdhR9Fu{1Gm{`= zGk^SC6-vT(6R}A}VuDiOp$5}Z5}%ss?sU6!c4srYiy=l5f08jlqlrJjA7#8P8l(8& z%gp^c=bn3KzJ34v6~H4bF}yoDY1Q9YJ?;)gj5C0szUfEiq5$Y&(2Iv{N(F8!gZeVn8sG;o=r z=!^8FRy0p~%&=5BuVm$XsDvxHTEHCUsqttTk;h>F7xt%SqPT|ZBcY2lsZ%{$t%;lD zrRuDdVXpGe=C^TYguGjViF_VYxJkz_`JCRAc0UD!{t+W~%8e}(1r+IiK;AMY$O0C> zVCL0F%$CcauyBeSpY+Ht~#{GR*ecb+@( z*b_u_mZg;Gx~s3=vf_%0E+v1sSB-X8_$q=u74;QCEz%zfD|$$adLvr5iuITpR+YGl z%|0dW4fd(Qz41gOUg7Ie!f~~}qQBQ0QTn|hK|?)z*~+f2GXWc0k-_gtgu__ar^e!- ziiH&oXE&UAcEwc=VkWsmOT>cex=>hUs?=h=jlD6YJFGVDk175AYOH^8z46*OD764{w-5$rRaR=v|dzTct8Lebt;HQSkHjIXW@EAhBQ zGE-Tvs&9^II*5S3X3eBgQC(xwh^xUwETj)K=EJOVl9Q&%R6^647EBE*5i~vx2d#xp zOo^FJpK!$ZE{MCROs0RCG>d7@WKa^F##92bel2L&42sDRjjDR%wk>`TnnP}xX470I zXZDUnr!y7l+O{pN6VO_LwtyDOG@m@VXsr@eFcss>4!A-DV$8~*W^3C?m9#{r#k3Sw z>49+_RGnir6HcO9CcAoB0#CX^PO2xbOm)-%TbX~HgtSIMvvISc_rZwo zW3qxF_)m4N5E+@W;lbK6I#Z@I=qx7J#C?gDgC$)vJ#tJh01+#|UO_8M=p6Ean*weo z(YYCKFqYdj!_%78o^@Ix+T9Y1X)%e;%P2KXR-y}-B;knQz)5RN-K_;9=Z2yo{k%Ng zjJdRvT4|mOmFT|QeDBP_=joX-f zYn5nJ(>>j)uErvvsOkv{efL9;o*pgcN%Q8pP>FeZVp@O16GZfMA)k4w9Mmb(cA+}E zfQfc9c?)^q1SO`=hdy@DPC;T9lclP`L6_uF$k=I03VQ?vg~_37=BAT^!v5Q-Tc)jo zy?NPA-7yf=l>=EC>P3RcK6GneOxv$?3F{jE5fW4`HDh)&{3EJ6DJ%|XqKN5q?Mg&- zl4h>;Lmhv+8ZK30l@5Xvl{ECnG$>FHsc{D(#+>l7up%;cERNR|1>`*y4M6mLv|ohv z0j8=`BVveixnS}NGn<;?2v2s>RkTv>_cKsOcx=7WED>vho0NCzfaZvs@jeOd%*?L*L) z)^8@?a|#w7|K$9br$HAVrjH12J~}1$o6;8l7_6YgdJ_={zjdG=AvH7WSR>znJIueR+=6385lP{5ZBd+SD4sAcOi!uWDeNw=fdr69uwY^c8LK`wFoiwfD-8stMwjlb7Vklt&R-rQ^Eq|yna4YA`A|iVlia^>HUi$K;wcRrX@KV&W+G9wn6Yq^ktcb=>CcN zG+dxNOREw+2nCK)*r3GwAhJYXLBn8LHokvmz+@H9;?IR_Q~Gh-BlM_nl!FrpVOyd@ z5WrAKT!d;iw`;X>cTK)1Ux3w!!#n=9RCEa(;$z_^bkG4wESeD zP`O5h3C&>cNOYz5C3=!+ef1QMYhtg;hnS$;G(S(v^b~zPQ*RC1wIW}OEG5x5ko$i^ z;!W3LLaJ;9wWimho^R5#C3KAbhpGOgQkj1`bVUdK=QHQ#P?YFf$bM*P+tmZQQIrty z-=-H!=mq)?nwF@soZH-_{mn@)(RT&h%kbBTGSH>sq?o=b?coJWHHg1L-vhnNP%KU=NK+5kP3I(uek=muRCRy;6PaG4pF&2jcjqOmFRpXY&+!sbbo6a;C;d`z zQ%Ao-VTdQX;%WCTuWl`vtA9hk70|y!-qoTx((X9L0&r4MO@djL!?C}oKM0lmk!jg` z$Cv0&$Y^PHg_WCPknnxgr@%`t7k{C@%JgS?6V$5DkJqI2xw@g&L4SuzL(zY3^}wbc z_zaZS>ZE_tTS8ZF3tw)XOr8H0An#!Q(V|W1RL-=fVDs=ExjjLn;1)TaoCddoS-~q? zki9BV8$>aOJ2i*mBL2FaY%|In7a>)rF}xQG1SvCm82mJ4FwGv8i)EHY`78-)=tx4* z_(pZW$xhL$ALeN|-|OHRP#J&fG?G(BNGH#v7X^PVLv{#*(xIzPo@FSR%aJR?YP47H zGh(vU$+LM5bj5C*q($4cbcJ^EJR#?C#t>6#(8(yJB?^%ii0Xcv7m9=m9?v}$#ZCyM z@#@SKNT|G+Y3ZrqNW28GR>DrX!s$yQs)tPZ77eL}#nd?NFXD+QD>*s$OL_gPH^mB}1cLj5i8G{@lsdkU`>h(O(%?3r4Tx zgS>@nM%1_$@b+#_a}g=wg6O^tiRpw_4CQ$0#_zyt*(Mw6;ZhskX)%m&!S zuW5S|&{P;Hskvoc(>DLs-RrkBHTzq3Z|i8;vU|g(wiYKx(MEEFHSXmz&Oz8BMZ$IJf5;-@$=b@zsLP zH95IJm|Hh`Bfd`N>xHAb3uwr%_4bO``GBZAJ4|2ypv*V&O-%3aY}(S^+P>b?+8afG z*X;==;yR{ZQ>z}~?;hdro^bl+2y=0?C)Aq|u%YM#IG=yV!MC6vhGb1$aV?BN-ngpZ zTaBraKaBhn)_R@%QCcThxJ_K^96&mQdwyIb{3iYcQ$+zO($@to#_fQpD;QX271EZ( zpOX0wkjhvDtP6QW@2qx2a1{Z`7Zu!G0-st%`1S^jBzon zDcus^UD$tx3sIH$UIaU4`TbgK@77R6)kM~2)Va^|7mE2l{-TldCN=k}R#UDo$$Srg zxs*0D%?>M(u5P6=_x`66h2ge>gz_}Ls?aUbl53L2_B75{D#cW@G+)KrV87W*x+Q$q>p-5=BF90 zp&X2Mn|PzxPdWeA@Q6c@pol@S!C+sFgSk{N1+PVPl0f0Fr+jKQuvF1{m0bxgE5Ld>n6oD2RdzbVlF#g__M!JR zV_I0z!T%6;1ExX3xN(@Wc}m|m@>~42(9XXc7)OlDW-|jaU2u|kHVI^tjHx~1$}62i zLHk`kAyWyD<_x5{P?Cj_L(KG=mqUNj3F9O=B-005tQMPWu~;w;7X`ywTy_lj_m?)J(UO(Wd9ZrY%`bRCh?+u5=}gHcOzn9lq|#JGaD5Jdu6qvC7l!G95qc=#vyFey!vXIh`f8Gn zr08oYIyyp61$;$bw=G4_9HHl!e0I0}2qAM0^7HN@@%%leCqddLCFzHQe7{?Ay=oX` z<_Nt8T!H$tq0!@5|3#91JxFe#NigN>ZV9S?BT0Yr6%CC(i2Z*U`we2hbaj&cwOjy} z1N2=Jw7kf+$9Q@@$t-`6_PeDrwi;L-fn^(qbxZbfxJ?Jy{uvw$Xomr9AEJ^GmIEm+ zIqC%-Kz$C#?RF%2`XJ2`#PB@E-E?o7xUX116&p}Dx{HJ}xr=v~ajD@=U2r38(i@OF z$z_97mXXzDl6)ks^JKR!|=iYnbpu4EtVcR2wEq8wy()t_*T}LimgB>6P z#_jNd!qDgwu+>dD>qom0?VblsMPDm>zPSDr*mlZCA^eNZo9Qy5`x%mdxW9wyUpFh z!nf+!CWqe_h+sqS=krS_O`!H|mblU>1Td)ScIuG$h zZanN9qz*UywaI69-GX)apTY7aw(ErpnJe8*kPmcY`ug!Vsw=+!H9{z7&U! znvZ+M<3aB4#F@ja8|Rk`Zha62IBv-YpYfLQzF{7iWOsjtv0?`mEM<00VGTKbusEcK zsD_+Z9wW&%L^i9~^cA}u?&6Fdj1@#(k-OMj80Kq*iEbF-8w2e^8+RaBGuptz&J^D~ zNb|jh)g@T{L&EC(5!3KV{#bpIKRHNejqvS2tVKKr>PPqv`2VLpRN$Yqi7tq)3!VWJWsV+vxOBx|bv~{TV;k zN%W9J$0d4!=p9-vQQg9`sz#|yr1nvyI!8fRfC=zQ{2itB1%UpX0UAZFES`)8i~VMj zC)+oF1kmhBfJ8Pn02WQf=_r+ueQcY|O|d*LYgvDy!^Q?^zDtdQySHe*Pzi4xrOiSg zvM3i55O^!mA^?_P>0PQZH!k2&l%p&~^NoU4qvV_;N{jJqRGNcSj~asx5Wg2=tAT8^ z4CQqV*=YmdcA%v0g%ZV3a$7ghT$SAN`YbK9zDXY2X|%|;fGTWt zwAj{2m9}$eiS0aEYP*Q4Y&uoj4p5El8mhHjPj$B2z^^REXtNr!13cOx$FUK9I#9bC zuKG-1qsUSz{-(D!#m~WGll;O)5oCe3+A@EB5iw?Xcnuu=r9r9y%FBVa6n_uzKMb@T zqtf~(Y%JxZazN%FCXS#C;7K2{{1ljr8dcb zFXMkE`QO+rK@hlBBMRQBO22yTtmcB1=D=$6V6}y?+9i0m7(D}iTV~*|lbBOv9Ful=IDgP4h*b~JmDdTDN+SiRp<7!nqESc27p|#}H$D29yY_N>sLBP`v_ox{AT74Ef(nAbUB;XNZ zxJke?_ynf^P)i30JpQh#lY4qzf6Gb(F%*XXShW|&Uc7DGje@PV4pBVkL02VE_?tVN<32)5jl{YbMl=pki37qz5&?B8pG3geA9feyWG7M zLC-$4-M(G5-O!Ju;;{^anjiKA^|erf8$pKyZffp8xVL8H8~d=&l@V1tfAVTRk7_b; zh{oPQw_7JxH-F~)Bc-TuC$u3eHJZDP-Fm}*%z!cETH#2$;!G;RuoY_0@igx#;oNH; zMM66lfr+_No8S9sa(^1L48r7j=p_ClYV^5O zBQ36kG2A0!>GIdoinR77${MZrVp5*j3~-V2}3)hu&_w$Dpl)9Q2>SQPUi*1oe7qz6RbP~Q#3VI zq&@*qO9u!Fbw9aU0RRBb0h2*c9FtpoE`M7KV3gIBKIhKkpP3Ah5CRSl9o~6IKmrcF zAbvq1_FGk)ejD%weuvetu8S3iM5`&W-QS#A2w}YbH~e6&0%+S2nI#x!?v5 zF_W(|kxBNN8>2CkX?7yHyJ>ea(tj5-oAxCmLqle=sWqNTM`AI9oJ{4{MD|9SVv+dn zrma2Kn7wI(+)VCRV)t${$uy^}#P~MdxQ5Bom)IANB_e%HXP4^Oy54R_-zh_p^uXFw z${g&8$;=B%4Y;hh4+hOP_xHv!spww4o3*{YW-8SliAS_<7Z%SDji=3IJbw~v>PN?> zSfY1N6kFOb>2OT$z91TprY~Z;s`?DOJoCQ(^d@R9XL3~6T<)iGn(iZmDw)>&9~3vJ z%11M)(oa)qnwMO3mY+Q2m2b2C3AuXBUbk;%!RWnuTTId&RYDvUm@I`2vGN_ixYYq;jhubh4)AZ_^wn9&-TjQsC zYVc7VHKJ<_HKGF6t*EZqF>#B2I+vD-7R#BYoziB|c}$gssn;fx5r4g=LCro|B?ng) zO|6e6p;;n1?5Fds4TZ45NFVM%Rdw5`w`q-R`~tdA#>3O5PE_2WOPGvkYJ=>~PwQxL z8Lg!jrdg*NXk8{6>yyLZfMqV#=Mn5HI?&)!4_$hS2DvlTuGi|LjkFnOB4-$fvJIuW zo>pbA3%j*ZyG+r+)PK4(*q1O`lbek!U;^i4Q{`%Q8_j)fn8ce+UJ$FPKgQ z`=jx`pt(QNn~n_!m*x8nH+twYfJi!Ft;IB>`g9Gj^fv00rMELJ{WIOsAl$lzf7-pZ zY2NLlZVEFwQ_<^8?X0bS+Ce+zEUskQxHPxR6gHCx4*sQk-G7C-cbjQBrPi9$J;hHE z>X9{j(JvP5@sKIr1;uJg?0Ow;FpfVm)DFcu(~%@lWYIE%27DA1Ppz4_`FLbdpd>AT zRN_kPk(FXhD~gU*)`=u#Px(U*mhe1mEMCAE8y8#bkjAvKgq!{8W5KQH+_bh~`O0Pw zNfgpuQf6-^8GlU=H?;!|0fpR}Hre#K?VzP-9;kK;-Rh&8=|FDriGLjox}8bwSZC0kaCg)I2VB)V z)``QvkM8!-9RwQV&J6WM(y*g}rq))uBp;v;igiAen^s$P`#>_WFVZ7m{}Ig78%v~2 zKYdi9+#Pfe=IPVM#Y%wTb$1AqksG9lR{O@`0n(8pB|*0KB}RInC4cuoEgbJBP#rj1gL8>U|fFstRQp^{jDes zZkEPAK5v2h!-x}buC+a>L@bjwmy6#=do{faCi+X=kYIlVr`I=s zaeQ$+3~E-g&tPOYI1t%u276319!y6Damj%w`+uV8fyTx0mPEWinj8$K2O?>Fn8BsR zgF6QzxG9~P!KJ~WWC8=z!$DlvVOh(9jRqSw8trz#R#03j7){}JCk3Mb;e9eB7)e4k z`yRt?dO3S|us@j?1Pr8;nUvsNN)ydS*;p~V2zDi7q2S^aYVCP~Z3)XnR&^dQbJRB! zTz|SawRCZOZEqR}QdAwZ8!nHgqUqp3Iz5yMHQ89$D6nW#=>myv62!95NUNT2kkd)@ zcYke?fn)*WslKRr#yeITDF01KJ;bcEhPuB_G27oxUU8zl-Q`+IBWT&0&MS4~>B+9f-(&`hg(HOY}qVu}O0R z!JO^Mh-7;ai{1!S{s@da73tTAB!6=;E%~z;9hCeDz2c)E(|^dyAg}JhJ)kMr>wj91 z_bL!HXYO^G{(b?Xt(kOD6!`@#g%DO;$#m&o9>ae|-2#?KCRdReiH zsktLDG2V1-YYV1;!U$f#VML1@#P=4j;$b5fsI@IEKTjF##cD}JPte1Vhp(Hgt zA4@iRc%kse){^9fgS=RJA`zZbVwGIXG#ELtAy>RB8D%=VdgAHUoPT=QwOr@p8m>oB zNGH~IwzRf(+L|?5#%Mxb(Y}7AhnK;ZyS8s^Snc5zmVMU^rv*P}mNE;d8?WSM(Rh_j z7lje>`50oi4__*mUz@KhWhOTz1O}&`lHPDDZ}##z zyam?0Y}?k&8(Ow|xE<3WdW-uN5_;Zh^}Os9?JSqtW+~E%X#gprv7fuFy34IPtKYk= zx^Ovt2Ob-X4EFRz78T%O(YZ{$XAFiD5B4vG5Er(*&7iw`%p;`w;vfwp4k7- z`DvU52qRBTf`4gfJvE2(^EJF@IuS#;DH`acT~_pSoFjy6F z&nTPyd>vo!<6(Y}i{zha>n?E+zxYfpafZdV;q(%N)_)79=6if|Zhhely#tYW+>E8P zWm~L*!MEnv@o8Oicdm_)s1O>aavz$epvc_d^d(Nl3@d^ zK_(Rdo@|`7W6%fqgFZgMNRlg%&Gncqk`TWu6E)MZ;R3c|ux`lla{dS(^wCWI=meXB zx|sh z@1Moel2&Hod*X;>9{${fM}tBR!$_^?7Tgoe8-Gcakg}i^Q4vJHz*IZw`%#G$r<@-} zUc|`7d^+urtTX1P=jfjV2#*v$ifk=XBn#6Q6wxEGkujKDHtFFnfrw7}1A)O$%6nY` zarS9y_3=}D9QR-wjzh9t5C6U8e)fBlpT8`O{~&(_)i7(XxzSAb4v1Ht2}I--hKW4< z9DfdbZzPs6g~iOAc%pXbg(rTMpZC%I{57Uk!Rn+57nW08+!vHAGF5LSu#tFBe*chc zSbnjR+*o`Cy#`Y?9{xI5lGaUz7Dq!5{|lV55jc|e@HfHO))ze%c=$yS!6IX65Xt8= zHt-cc7IYq34R)NgG%Ex$Lw6iu*(^?K77%pK|Cl!t$8;Te&_**7MD zwLirbh^8zww(=-`)lWg1FP`@c@d0fQp(uWS9Xx}&`Ii`MCV|pbc^xmqcJf&XzkkWU z@&Y|2#StwsNi*+8zXi*{6u&F->7ua){}&u(NGf7+KmWHl<{$VitW@&d$j>J&((<;( z5<8n_5aMw~s8_7%KKy7MJY{Qte|yTW99EOl&nNg<>{7X5PXttivqZA!^;CO6t6jgw zuRO|IMr)N1eDCyjL;0Zsu%H9UwtrtuMfR=OKPXhE>b0Tt-#%;y$GZP@lY)#6oa)q4Hv+?Ln}N~RN$CDo_* zE`>U3qq-D%k=g|P_C-Nt_N6>(Gj^H#!3Y^%u|J5-I*~P-@m_gp^Q$)1F5^3Z4?WSi zjhP;GS&ks-QP`;j+}uxqY8y5xga%YA9))f??NHnGo3!eZm-vCfM1P-0bpu{jtz79* zVY!VZp6aZl+2K>2YNzlF`4ve&`?a06IQDv#+9f@&p73&SHyf%)OaQvDF_HAEKHBM1 zy$Y6`RXyp8i`p%H1}6OEx>3KNN+eL%_|zWxnZPLz#vV1ubix1XS#{!k^0KNDm{NgB zojoyTCX<<=wAojL2!Bagdpl^hO?@pkcIu;_RSU~x)Lu|BwU5ag&CTam!$QSAuC4=z zDY!On449PJn@J043BrR1qbd1I94T>i@CEfASyof^4s)N5C+bEWPtC1EJE1ZyxgETZQ~J1ugwFMa!u)4bn>( zj}ogpQK~05#t;Q-!{MEyG~*b}3~w2wS+!44pzaBpQ~w0bYj}drc}xdluf$qwBp=0! zZpfLnSz3B8`G3Z!Y0C&*jM9qmmS@R5GX7@mFST3Z-@3BJ5Vi`DzEOf8NZY=X96r5MKV z!?7O1On;xjf?vRFuY%VKmyo}`;+61iALDn>?!ow1>z^g(V|CM0V>H~6r5hYi(R;_} zwvf}&?5ZfICp{r|l}jC_V89u0XX*XnX4eDbZ#D$njw+ntheyU=!Ra3iI8Tr#;LOs; z8nSe6xI;`9a*on{PdB?9&F(79@gOxeRJm7F(0}-iWAv$T$jH*CVdt`dv;5pFjdWvv zLkxSs4X5}hDM(|B>IK3^ayPJXq)jA8E{$XSKH~V>vYw3x*IyDKOS(7Q5FX> z7RGrTJudo`Ykfw@Q@blmPt_iWVdlV(*J9Jv)THG~e2V-o{TTn|PVRH$ca76rGW1z2 z)PJ8DgYIC1?vjH!n>0a14Wa7bIIQkEr7O`}h$GKJRZ?at70 z7TiYU0$QwPH8pE_K3$~cM%+G;?X*+NowSQStRbLZ%K=JgnW4M2luzmR5!m7o5#3xL4l2EC~L;dwdy6@NHilJ7;HXEz)4623^M`Q$FDeGPu2J@+j-YaF4# zsV6|aJ%=Gn9L!JZ6y&iN!CcNls|K-Ce9s4;>%)M%A#t_g-P!|O-$YkLBXzQ_w(bbK zE7>!~Q=j4T_9Ju-ZZ*&7F0O?KWO)|CLnQ~YJP*hC2?P?F8gNy)omXdh0e?;yZHsbk zzJM!)_9@mu^U{#J4gkOnSw1)9tmNguG5|~fU(d_(>MXB0#utVIhO-&IaBr1!*C=07 zj>*;uOa@sC!q) zs0+9QhD=u{vmN2>`3c4UWPe7uwoGXp;qGwAlbfMg950Q7L}Yq00aW(#vMJ*?c6cOH_YH02P^re+ajx+9C8QT&wo;_kW9lpD%RHcQ7XDphHk4|6T1UKYpC1QAu#tF<&$ zLd$DaTFbraPA%_N4~ev(g`cFVak@}~lgnUbaJj+f$R{DC1%FKVPSOk>$NeqQ!467s z@=R#c4IVmbHK7 z5~J_OoJWDsPlIlL1+?KcgtgxxqWpoTaRrt0EUMrOXgY5|Cb$VFzMHD}dYZ{Uq*+SQ zSt>#S6{Xqg5t^gk#0nMi=411UG}e&(VS8Qu7(ek?xPPNIe1zuYpyFsADk}LHKI?OZ zMb=Hp@-uet2K4?1>COL`ZvuD0%*Zl8s9A?Xgq@8BN)_@NW@(`6u{-zzUpntTfhw2d zqAI8U=BeP{Haq9lVug_Fek!Zvf0AH<1Z3VA|8vMKABIcQ$dJboaCd@uc2@EW0S_o7 z*eyCF@s&`uof*Ren%Eck4v)QOJ<~>ATuB#1ev;~u~(YCG_Z2z?Y zwxF^0)-^PJORO`--z&1qyl1#Z=r8|3qj7nijKQ`-&JkhDSl~z9k6~HaV8A)A%8(6t z+Rjj!BjCZ-yq&Iq58L}mpbXo?0)G$Ew3)7*z}gx7N_3U$fB~9!VZeDD zMm|9AqyL1NKS1|r`7wG_%g5*?EnlWrw0xa@qos8?4_S$hOaH+QDwpFE5>cpO4J*b; zjI^l$!dT6w`R{lQ}N$sCd3)BmVbT-nHVyWN5}ZJu#kf{Kn_k2BLAZS z*W>(~0IGhUoC&&Z9zn`nELvZ{a z=*^g#Ri9M>Y-oh1bys=ZpQ1lXu>ZYmC=50?rq0$>&RxW+c$OGb1?^eYqL86&?td+1 z^ISKfGcb@(3?#cUs(e*tFwrr!1bz)s4UG$_>f@?$R4p4-=VjF@WR+@lz>xcXmDq7+ z2*Xh|LSAeV{6644o<}OV;3|Y|TlcR7I_WTfDm;PY$i4TdtHaHn2Y7Lnaqm0xg+hE! z+@f8)qT>XsN_D}Qy7+OmA*)&|)qj?(+Nvqli&nzZP^sR9eR9V!)hQ{8x;*5@#jOeN ztW@uI?CPvkS2%a!>bg5e)sWA3iJiu_r4A@FPY4pyZbwP zR&sA{c6QId61k+T^F4Y@iR6|@sUfV1;U+0q`$Q0iC9!qt9v9|iY?-LYd3@n1;{P}5>(%6 zN>Hb6DeO&B*owo%7YQ+o1+QqS?CJ&qMI9pI2f-RY!<|a=hX* zaTAGB=CXv#?8{cJsDwlEX{%-qts#Z!;@h*AC;7JrkCWWnL&FTdmtlUwtCv+fHHxf% zWZH6u84AExPABzTQF32byj2rxlAaoaUKTjqEY!*$LH8SquO1Vn^>H8!%&0+vH`uRW znf!n*{_mj@dI|gH z3w%0e$^k!Qv>QJZ9=0tvg?)KVTZlcQKBY^pTgvVbI;5Fs#XsuhoM#xrB{qzb`Ex7p zYYa+xfgJILj*skRgs3uJ_LrH(qq_wHGft?S(T@Ruk79QV1PIf(z)l*((j=ivDBnUR zwT9H7*^O-VAvF)DbsSZ3BCwR&dZ?m7>8@cm7H+%fR?$p1xZn_~iU_1e^bNzwhXYmP zV0(7)ucN!-|Maq5g+EDL z8t4_zLYUvu*?Yudg5}?7Y+*`UWA5~EHK11K`@NXhG@D4E$*uKkF?*F~&;jZ~yhxJo zivRV%lR2l=#K?@jqzdy31(Bfep|{6UEn0Wv0YF*%Z@h)0q-1a>JRx(LD*fw$j@urVkt!{S+GFzUMrI>1(9wr`Uy=o%nPwp;Lx z28g016AJn^6A=ZC3=XAHwaEtyK7GlPijekg3ev&PB{`>j4?`-Cfx(d(VQfccHU@ixt}Z94Z;wF5Dnsb0hZurV4PU2xFX2YyAA$r7@+(~@ zEvyu0WW?b3jb}PtINPP$;bHNnA>Ey909JByiyQH(+qCmIyN+FN-jOxW;e zRsz4VtPu2BIDNbm5r$hm`HyIMPst+i8yn|)Y$ykyi+c4}frOTu9tGXyJ}Ii{l&<`9 znWR7B=Y3?Z<*2CC*5s57#Y_+)i(LR*^VYq%Pk6A`YU?+L=jziX3gBVJrItu%VwFD? zL0kk)0~w+l7OHXjW_;xqKY8Y(isUU`n}I9Xemy~t=pgDr4CI8yjyRmS^z;>qQ7@fHt2G}O$P`qQi&Lt93LG5q+QdmVGr3%j zh2rn!RY{>I*6qkHUH(`aUm8y?{H@x_^jtr^j)foA@lJdRjSIRC@IzZ@W~>wG27VGb7OO=Py{b^+#`p~gg9hD6bWs_ zUmS2sDLN@b12^&h;pit9UGc2QUB(3)?scMk7#G&XS>cckb8srdDWXl)o&wI!>@S6THWl;TZ9G0j3>zRe+CnB2=g@SBi&XQ2@6)6rqJ8YcKfc}PP>wZjXp=&^@F|oW0-rMe3G{hy0Fqe znd2ra6%@IusFAc|tjx&eLZr0FwM26g9m{)lo$pVGw*ULH_N6+NHC?VctK{KG$E3Zkz&V(!LT z%6)7>g3Fp_Zkv1XfbsR);PyUAQJJM(6%9vU4_lRgVk9zyx}*qnOu1T4ZA~HhfB13D z2OEN4uBZj}USl~~UNpON8Ih;{Rx-K7RN_ zFS!8!T}-*f0eXklva5LxseiNp7nn&gc*}A=KiR{J2=(ey@oJBTW@gTxZ7z(%i(Ndk zrC!Z|0FG3RT{@#)W8GF*6nNsQ;&I$j3;aN;8+0gYzasS_l%|qzoxC(R`P=}Vp$L1&n6NfsBN^0Q7`IEr&Tp>NRhurWx! zVVpnGciIAI+n-HknaQ^FboY$J3Ajhp#|QE#SF6o=c+%UkO@xLWtIEpG3f2C(3uWpA z#754=0RZnY?{;vzH8FxswM^ZJZo`=xJauI?uMPm0)7FyC2SDhE^=|Wz$h36c!qHACud$NEB-3N^sbCGrH0XPT$vDCD zRelJDykWR_%aMOd-($M4L-|evqpX*}M)C0x)|{&ANuA$623xK;k8qUd+)W(3BsyZ~ z4Ih!)QQ5fNwRN#fH`Zv>acxWOI-9EB8y$>gV$>tQXl)?&JMv~n9oRtn z)Z(x*$a+8reVE=qn=YglPQyB|TmzpaDG&Y@8st21h%;g8XOhnvK7pSIkOfBdq&$;& zZWF4)uR7-L8!Ev1JdJpHh+}Ll?qV(Um0EjoXfbs)6Tbg@ z*Fr2gje&;zv!x_o@0!4Ge)$z~f=2nR*7pN<4~;Rr2OkL`8U=P>uf_4v!UL-|CrIO1 zx6+i5tFoHalds;B&X$h0bFiNgK0ZP)SoL=WV*drc#?R~nz353k1b)Obw5Rq*9`gsXUB!nZOi5ZB+pMYlTwh+YC|~( zxIMUYQ9~G&;CT>a7MVU7I??8$FZNx&yzRBcyZTRXZI5rz;acV1v6%gs%Kd>g>4$-u zlcn>NYXSUj59K(-=Tc!O@vRf0u0IPtS@=k;fBgLds7U* zP=kt6LHe;;qH~mDzrYZx6Mkb#q}8T4zUdT#%%DB|FoWwXu5gCchGBguZ8m9u>2?C=DE@&x+oU{pD+@P_I ztb7rUHl`Fi)H2u~I_f3gw;KK#-!i?EQ?xtP=Nap`1Q;IKoIID|f|jpSJ3%I$9OCRGJJY135+${$tCR>rKR zm?Sj2PSAg+#9a-JB>r(l-uQFpV!gP%!r#9}>!GDVFBEff@PH7OU@guoQALRoLd9Cl zBcW4|z0pK@qN%0nF)91e&Mw?jNH1zIL5os_9np{>SDo4aq&Tai4p(OLQ-bQf! z=nsaz`|ku}C#9K#`P5+;n2P7);1-GGq~u$ z8{-RDs90!7TB2xGl-m-{U#KP76q`Kt#koEU#$Eb$BK>+(rosK=e%1g5a}|4lN_L6Z z%qNJvLRK*Uf<+=Oe`d$%;8DxB?bl)Ch*I~YlWhj^ zpmTa--`Sc+&Dt#s+n1`^!Fi^+uzC&qW`Sm=IT%bOc|A41 zu$I*=S)!vsC-mWm18ASIJiWV5$I6@voaJ_m4}YKCEg5LfCMY04;)bKT5`LV@SCBx*3C@SiIL>>BbexDe#$F?Bcu4$62oYfQ4X5qA?V2*^6lAeavQ@ciQKQ`a{n;YpTSo^Y=vzyH%n0-w9gtP!sfKUfvE#u zNh=l9axJ+}E@fu)_EKK0dotoWd!q!1N-jHTIJ)(V4i>liUr3hAp^aJ+j~&!6?Zg;+ z6e%{s$0aL4!ZhyvQa^Y+ft?oBA-zlrth5$$$u+=&jIKCwytM@Jq+S-$NLxame!O`1 z{$P>KG45{vW1)u|Y_HqYrJt`zf(xLKr>$rnqzLn`ty)h-=E{~q-MY?kRQgKuwxUN< zWvXIXMND|-`c&~ zLy6eWOO1MAhQnbx#LaU2a68QAUb-du{>Czli_QaVEarXI&V$M=OnYa$mr$L`?8*r+ z>l^gI`GSw+1O&;}LImT-V%f>O*CSY9fs!`DbU0;$)J9HuV3l;}3?j5n^~t%gJmriS zwglk~h->le?w8X9qtTEX698z#fdrT5qbvP}?2HblvSf*S#*Acl zU{a+3dWY`m^$-MJNs9WL@-7jpN)WhlLk1N=x(z};`Q5h^rN4t*>6z|aWE4S6+mRqU zQVdG+o{M!xB*+S(Wum+c-?c-ZvKv`siAjl8LJvS#}x0KMG>8(%} zJXHAQplV#^Q{;3|BdTiRZEJB(74NJ zuX_mTQRTkP1m1==eux|*Wn{kz+$*I}`#%_@$#4h%js64x(BFe8_5X#R0RM|8gsd0m zJxIqE2`K{qhTH)Fc<%v$_DBHvRj=m-{@u_70I=PICLI2S9ziN?@5U2&yFq9Sc^T~@ zKMeQaF3?@QKKfGJHURB*iwnf9fedx~-EI1(ModVHAMmbieio6F!Cy*y~jj;zVq`mjr7jX-CWK4Mlw{6?7Q@eMh@WLKVAR;*FEE}Id>3tlK}AU zi5uww1vw=5JI|}|4svVa2mZae002+#m9$;_FN6t+DIh1)R6)nMy}K|X-^;iFfDiQc G?0*2IFc^CP delta 38292 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 dFCnI4R f32 { + if value.is_finite() { value.clamp(-4.0, 4.0) } else { 0.0 - }; + } + } + + /// [`Self::add_sample`] for an input the caller knows is finite and within + /// `[-4, 4]` (the 2A03 DAC tables alone): skips the `is_finite` / `clamp` + /// of [`Self::sanitize`], whose result it would leave unchanged, so the + /// output is byte-identical. + #[inline] + pub fn add_sample_bounded(&mut self, value: f32) { let delta = value - self.held_value; self.held_value = value; @@ -290,6 +309,14 @@ impl BlipBuf { // unchanged (`slot += delta * coeff`, one touch per slot, mul // then add — no FMA contraction), so output is bit-identical // to the scalar form. + // + // Measured, keep this shape (v3.2.0): a variant that took ONE + // full-length slice when the window does not wrap and the two + // runs only when it does made `apu_tick_active_frame_with_external` + // 46-48% SLOWER in two A/B runs. That workload scatters on almost + // every cycle (its expansion sample changes each cycle), so the + // scatter's codegen dominates it. Reverting to this form recovered + // it (docs/performance.md, "v3.2.0 APU"). let start = self.head.wrapping_sub(TAPS / 2) & RING_MASK; let first = (RING_SIZE - start).min(TAPS); for (slot, &coeff) in self.delta_ring[start..start + first] diff --git a/crates/rustynes-apu/src/mixer.rs b/crates/rustynes-apu/src/mixer.rs index 290b14121..dc84831e8 100644 --- a/crates/rustynes-apu/src/mixer.rs +++ b/crates/rustynes-apu/src/mixer.rs @@ -125,18 +125,39 @@ impl OnePole { } } - /// Process one sample. - pub fn process(&mut self, x: f32) -> f32 { - let y = if self.is_hpf { - self.coeff * (self.prev_out + x - self.prev_in) - } else { - self.prev_out + self.coeff * (x - self.prev_out) - }; + /// One sample through the filter as a high-pass: + /// `y = coeff * (prev_out + x - prev_in)`. [`Self::process`] picks this or + /// [`Self::process_lpf`] from `is_hpf` per call; [`FilterChain`] knows each + /// stage's kind and calls the right one directly, which drops that branch + /// from every output sample (v3.2.0, byte-identical: the same expression). + #[inline] + pub fn process_hpf(&mut self, x: f32) -> f32 { + let y = self.coeff * (self.prev_out + x - self.prev_in); + self.prev_in = x; + self.prev_out = y; + y + } + + /// One sample through the filter as a low-pass: + /// `y = prev_out + coeff * (x - prev_out)`. See [`Self::process_hpf`]. + #[inline] + pub fn process_lpf(&mut self, x: f32) -> f32 { + let y = self.prev_out + self.coeff * (x - self.prev_out); self.prev_in = x; self.prev_out = y; y } + /// Process one sample, as a high-pass or a low-pass per `is_hpf`. + #[inline] + pub fn process(&mut self, x: f32) -> f32 { + if self.is_hpf { + self.process_hpf(x) + } else { + self.process_lpf(x) + } + } + /// Reset filter state. pub fn reset(&mut self) { self.prev_in = 0.0; @@ -213,11 +234,14 @@ impl FilterChain { } } - /// Process one sample through all three stages. + /// Process one sample through all three stages: two high-passes, then the + /// low-pass. Each stage's kind is fixed at construction, so this calls + /// [`OnePole::process_hpf`] / [`OnePole::process_lpf`] directly. + #[inline] pub fn process(&mut self, x: f32) -> f32 { - let a = self.hp1.process(x); - let b = self.hp2.process(a); - self.lp.process(b) + let a = self.hp1.process_hpf(x); + let b = self.hp2.process_hpf(a); + self.lp.process_lpf(b) } /// Reset filter state. diff --git a/crates/rustynes-apu/src/noise.rs b/crates/rustynes-apu/src/noise.rs index 419f56d81..87dfd7541 100644 --- a/crates/rustynes-apu/src/noise.rs +++ b/crates/rustynes-apu/src/noise.rs @@ -2,8 +2,9 @@ //! //! Per `docs/apu-2a03.md` §Behavior and NESdev wiki "APU Noise" page. //! -//! - Mode 0 (long): feedback = bit 0 XOR bit 1, 15-bit period. -//! - Mode 1 (short): feedback = bit 0 XOR bit 6, 93-bit period. +//! - Mode 0 (long): feedback = bit 0 XOR bit 1, a 32767-step period. +//! - Mode 1 (short): feedback = bit 0 XOR bit 6, a 93- or 31-step period +//! (which one depends on the register's state when the mode is set). use crate::Region; use crate::envelope::Envelope; @@ -24,7 +25,9 @@ pub const PAL_NOISE_PERIODS: [u16; 16] = [ pub struct Noise { /// LFSR (initialized to 1 on power-up; only bottom 15 bits used). pub(crate) lfsr: u16, - /// Mode (false = long / 15-bit, true = short / 6-bit). + /// Mode: false = long (feedback from bit 1, 32767 steps), true = short + /// (feedback from bit 6, 93 or 31 steps depending on where the register + /// was when the mode was set; NESdev wiki "APU Noise"). pub(crate) mode: bool, /// Timer reload (from period table). pub(crate) timer_period: u16, diff --git a/crates/rustynes-apu/src/pulse.rs b/crates/rustynes-apu/src/pulse.rs index 1b6719c8c..708bba996 100644 --- a/crates/rustynes-apu/src/pulse.rs +++ b/crates/rustynes-apu/src/pulse.rs @@ -25,7 +25,9 @@ const DUTY_TABLE: [[u8; 8]; 4] = [ pub struct Pulse { /// Duty selection (0..=3). pub(crate) duty: u8, - /// Step index into the duty table (0..=7). Decremented on each timer underflow. + /// Step index into the duty table (0..=7), advanced on each timer underflow. + /// The 2A03's sequencer counts DOWN from zero (NESdev wiki "APU Pulse"); + /// `DUTY_TABLE` lists its outputs in time order, so the index counts up. pub(crate) step: u8, /// 11-bit timer reload (from `$4002`/`$4003` low+high writes). pub(crate) timer_period: u16, @@ -125,7 +127,8 @@ impl Pulse { pub fn clock_timer(&mut self) { if self.timer == 0 { self.timer = self.timer_period; - // 8-step duty sequencer, decremented (NESdev wiki). + // 8-step duty sequencer (NESdev wiki). The hardware counter runs + // down; `DUTY_TABLE` is in time order, so the index runs up. self.step = (self.step + 1) & 0x07; } else { self.timer -= 1; @@ -188,8 +191,8 @@ impl Pulse { #[must_use] pub fn output(&self) -> u8 { if self.length.count == 0 - || self.muted() || DUTY_TABLE[self.duty as usize][self.step as usize] == 0 + || self.muted() { 0 } else { diff --git a/crates/rustynes-cheevos/src/client.rs b/crates/rustynes-cheevos/src/client.rs index 2dc6b9ad1..a1dc5edf6 100644 --- a/crates/rustynes-cheevos/src/client.rs +++ b/crates/rustynes-cheevos/src/client.rs @@ -275,7 +275,7 @@ pub struct RaGameSummary { } /// A safe, owned snapshot of the logged-in user (subset of `rc_client_user_t`). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct RaUser { /// The name shown to other users. pub display_name: String, @@ -289,6 +289,18 @@ pub struct RaUser { pub score_softcore: u32, } +impl std::fmt::Debug for RaUser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RaUser") + .field("display_name", &self.display_name) + .field("username", &self.username) + .field("token", &"") + .field("score", &self.score) + .field("score_softcore", &self.score_softcore) + .finish() + } +} + // --------------------------------------------------------------------------- // RaClient // --------------------------------------------------------------------------- @@ -745,3 +757,31 @@ impl Drop for RaClient { // `transport` is dropped after this, joining the worker thread. } } + +#[cfg(test)] +mod tests { + use super::RaUser; + + /// v3.2.0: the login token never reaches a `Debug` rendering, so logging a + /// user (or a struct holding one) cannot leak the credential. + #[test] + fn a_users_debug_output_redacts_the_token() { + let user = RaUser { + display_name: "Shown".into(), + username: "acct".into(), + token: "s3cr3t-token-value".into(), + score: 10, + score_softcore: 20, + }; + let shown = format!("{user:?}"); + assert!( + !shown.contains("s3cr3t-token-value"), + "token leaked: {shown}" + ); + assert!(shown.contains("")); + assert!( + shown.contains("acct") && shown.contains("Shown"), + "the rest is kept" + ); + } +} diff --git a/crates/rustynes-cheevos/src/ffi.rs b/crates/rustynes-cheevos/src/ffi.rs index 1ba99f1d8..a9ce0af31 100644 --- a/crates/rustynes-cheevos/src/ffi.rs +++ b/crates/rustynes-cheevos/src/ffi.rs @@ -50,8 +50,9 @@ pub const RC_OK: c_int = 0; pub type rc_client_read_memory_func_t = extern "C" fn(address: u32, buffer: *mut u8, num_bytes: u32, client: *mut rc_client_t) -> u32; -pub type rc_client_server_callback_t = - extern "C" fn(server_response: *const rc_api_server_response_t, callback_data: *mut c_void); +pub type rc_client_server_callback_t = Option< + extern "C" fn(server_response: *const rc_api_server_response_t, callback_data: *mut c_void), +>; pub type rc_client_server_call_t = extern "C" fn( request: *const rc_api_request_t, diff --git a/crates/rustynes-cheevos/src/http.rs b/crates/rustynes-cheevos/src/http.rs index b3ed5e321..54030d813 100644 --- a/crates/rustynes-cheevos/src/http.rs +++ b/crates/rustynes-cheevos/src/http.rs @@ -96,11 +96,21 @@ impl HttpTransport { while let Ok(done) = self.completion_rx.try_recv() { // Rebuild the C callback pointer + data from the carried bits. let cb: ffi::rc_client_server_callback_t = { - // SAFETY: `done.callback` is the exact pointer rcheevos handed - // to the trampoline; transmuting the bits back is sound and it - // is invoked here on the main thread. - unsafe { - std::mem::transmute::(done.callback) + // rcheevos may pass a NULL completion callback; the trampoline + // carried it as 0 (v3.2.0, the callback type is an `Option`), + // and it is skipped below instead of called. + if done.callback == 0 { + None + } else { + // SAFETY: a non-zero `done.callback` is the bits of the exact + // non-null `extern "C" fn` rcheevos handed the trampoline + // (`server_call_trampoline`), so transmuting them back yields + // that pointer; it is invoked here, on the main thread. + unsafe { + std::mem::transmute::( + done.callback, + ) + } } }; let callback_data = done.callback_data as *mut c_void; @@ -112,7 +122,9 @@ impl HttpTransport { }; // SAFETY: `cb` is a valid rcheevos completion callback; `response` // borrows `done.body` which outlives this call. - cb(&raw const response, callback_data); + if let Some(cb_fn) = cb { + cb_fn(&raw const response, callback_data); + } } } } @@ -257,7 +269,7 @@ pub extern "C" fn server_call_trampoline( Some(cstr.to_bytes().to_vec()) }; - let cb_bits = callback as usize; + let cb_bits = callback.map_or(0, |f| f as usize); let data_bits = callback_data as usize; crate::client::with_transport(|t| { diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 7fc22ddfd..721127c2f 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -345,6 +345,11 @@ pub struct SystemBus { /// `notify_frame_event` / `irq_pending`) on boards that don't use /// them. Constant per mapper type; NOT part of the save-state. mapper_caps: rustynes_mappers::MapperCaps, + /// v3.2.0 — [`Mapper::may_unmap_prg_rom`], cached beside + /// [`Self::mapper_caps`] with the same refresh points. When `false` the + /// `$8000-$FFFF` read path skips the per-read `cpu_read_unmapped` + /// dispatch. Constant per mapper type; NOT part of the save-state. + prg_may_unmap: bool, /// The original iNES/NES-2.0 ROM bytes, kept so [`Self::power_cycle`] can /// rebuild the mapper to a true power-on state (fresh bank registers, /// cleared CHR-RAM + volatile PRG-RAM). `None` on the FDS path (which has @@ -949,6 +954,7 @@ impl SystemBus { // v2.8.0 Phase 4 — cache the capability flags once (constant per // mapper type); the per-cycle hot loop reads the copy. let mapper_caps = mapper.caps(); + let prg_may_unmap = mapper.may_unmap_prg_rom(); let mut bus = Self { ram: fresh_ram(), ppu: Ppu::new(region), @@ -956,6 +962,7 @@ impl SystemBus { cart, mapper, mapper_caps, + prg_may_unmap, // Set by `with_sample_rate` (iNES path); stays `None` for FDS. rom_bytes: None, controllers: [Controller::new(); 2], @@ -1295,6 +1302,7 @@ impl SystemBus { // fresh mapper instance (same type, same flags, but keep // the invariant mechanical). self.mapper_caps = self.mapper.caps(); + self.prg_may_unmap = self.mapper.may_unmap_prg_rom(); // v3.1.0: a fresh board starts on its header's MMC3 revision; // the override is configuration and carries over. if self.mmc3_revision_override.is_some() { @@ -1772,21 +1780,14 @@ impl SystemBus { pub fn debug_peek_ppu(&mut self, addr: u16) -> u8 { let addr = addr & 0x3FFF; match addr { - // v3.1.0: on the five boards whose CHR read changes them (MMC2 and - // MMC4 latches, the J.Y. ASIC's read-clocked IRQ, Bandai 96 and - // Nanjing 163; `Mapper::chr_reads_are_pure`) the read is bracketed - // by the board's own save / load, so a debugger panel or an HD-pack - // tile hash no longer changes the game. Until v3.1.0 opening the - // pattern viewer on Punch-Out!! flipped its CHR latch. Free on every - // other board. - 0x0000..=0x1FFF if self.mapper.chr_reads_are_pure() => self.mapper.ppu_read(addr), - 0x0000..=0x1FFF => { - let saved = self.mapper.save_state(); - let v = self.mapper.ppu_read(addr); - let restored = self.mapper.load_state(&saved); - debug_assert!(restored.is_ok(), "a board must reload its own state"); - v - } + // Side-effect-free on every board: a debugger panel or an HD-pack + // tile hash must never change the game (until v3.1.0 opening the + // pattern viewer on Punch-Out!! flipped its CHR latch). Since v3.2.0 + // that is `Mapper::ppu_peek`'s contract -- exactly the byte + // `ppu_read` would return, with no state change -- checked for every + // board by `ppu_peek_matches_a_side_effect_free_ppu_read`, instead of + // a save / `ppu_read` / restore around impure boards here. + 0x0000..=0x1FFF => self.mapper.ppu_peek(addr), 0x2000..=0x3EFF => { let addr = if addr >= 0x3000 && !self.mapper.nametable_unfolded() { addr - 0x1000 @@ -2696,6 +2697,24 @@ impl SystemBus { self.mapper.set_disk_side(side); } + /// Whether the cartridge has a barcode reader (Datach, mapper 157). + #[must_use] + pub fn has_barcode_reader(&self) -> bool { + self.mapper.has_barcode_reader() + } + + /// Present a validated barcode to the cartridge's reader; `false` when + /// the cartridge has none. + pub fn scan_barcode(&mut self, code: &rustynes_mappers::Barcode) -> bool { + self.mapper.scan_barcode(code) + } + + /// Whether a barcode scan is still being presented. + #[must_use] + pub fn barcode_scan_in_progress(&self) -> bool { + self.mapper.barcode_scan_in_progress() + } + /// Number of selectable NSF songs (0 for cartridge / disk builds). #[must_use] pub fn nsf_song_count(&self) -> u8 { @@ -3798,7 +3817,17 @@ impl SystemBus { self.vs_overlay_4017(base) } 0x4020..=0xFFFF => { - if self.mapper.cpu_read_unmapped(addr) { + // `$8000-$FFFF` asks only a board that can leave it undriven + // (`prg_may_unmap`, v3.2.0): PRG-ROM fetches are the hottest + // reads, and almost every board always drives them. A v3.2.0 + // import skipped the question above `$8000` for EVERY board, + // which read ROM where 228 / 235 float the bus + // (`boards_that_unmap_prg_rom_read_open_bus_above_8000`). + debug_assert!( + addr < 0x8000 || self.prg_may_unmap || !self.mapper.cpu_read_unmapped(addr), + "mapper unmaps ${addr:04X} but does not declare may_unmap_prg_rom" + ); + if (addr < 0x8000 || self.prg_may_unmap) && self.mapper.cpu_read_unmapped(addr) { // Unmapped read: nothing drives the bus, so the CPU sees // the floating-latch value (nesdev "Open bus behavior": // "all it sees on its data inputs is whatever was left to @@ -5229,6 +5258,54 @@ mod four_score_tests { ); } + /// iNES image for mapper `mapper` with `prg_16k` x 16 KiB of PRG filled + /// with `$AA` and CHR-RAM. + fn prg_filled_rom(mapper: u8, prg_16k: u8) -> Vec { + let mut rom = Vec::new(); + rom.extend_from_slice(b"NES\x1A"); + rom.push(prg_16k); + rom.push(0); // CHR-RAM + rom.push((mapper & 0x0F) << 4); + rom.push(mapper & 0xF0); + rom.extend_from_slice(&[0u8; 8]); + rom.resize(16 + usize::from(prg_16k) * 0x4000, 0xAA); + rom + } + + /// Two boards can leave `$8000-$FFFF` undriven: *Action 52* (mapper 228) + /// with its absent third chip selected, and the Golden Game multicart + /// (mapper 235) with an empty socket selected. Both must read open bus + /// there, not ROM. Regression for a v3.2.0 import that consulted + /// `cpu_read_unmapped` only below `$8000` (a hot-path shortcut), which made + /// both read the ROM byte (`$AA` here) instead of the floating `$5A`. + #[test] + fn boards_that_unmap_prg_rom_read_open_bus_above_8000() { + // 228: 1.5 MiB = chips 0, 1, 3. `$9000` selects chip 2 (addr bits + // 12-11 = 0b10), the one the image does not carry. + let mut bus = SystemBus::new(&prg_filled_rom(228, 96)).expect("228 parses"); + assert_eq!(bus.raw_cpu_read(0x8000), 0xAA, "fixture: chip 0 is ROM"); + bus.mapper.cpu_write(0x9000, 0x00); + bus.ram[0] = 0x5A; + assert_eq!(bus.raw_cpu_read(0x0000), 0x5A); + assert_eq!( + bus.raw_cpu_read(0x8000), + 0x5A, + "228 absent chip floats the bus" + ); + + // 235: a 1 MiB image fills socket 0 only; `$8100` selects socket 1. + let mut bus = SystemBus::new(&prg_filled_rom(235, 64)).expect("235 parses"); + assert_eq!(bus.raw_cpu_read(0x8000), 0xAA, "fixture: socket 0 is ROM"); + bus.mapper.cpu_write(0x8100, 0x00); + bus.ram[0] = 0x5A; + assert_eq!(bus.raw_cpu_read(0x0000), 0x5A); + assert_eq!( + bus.raw_cpu_read(0xC000), + 0x5A, + "235 empty socket floats the bus" + ); + } + #[test] fn internal_data_bus_round_trips_through_save_state() { // v2.8.0 (libretro audit §2.4). The 2A03's internal data bus is a diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index 95467e71a..cbc30051a 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -96,7 +96,8 @@ const MAX_GENIE_CODES: usize = u8::MAX as usize; /// | 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*) | /// | 3 | v3.1.0 | a DMC load DMA refused by a write takes four cycles; sprite evaluation starts at OAMADDR as of dot 65 and keeps a misaligned OAMADDR when X is in range (AccuracyCoin re-sync to `f5f41dc2`) | -pub const EMULATION_EPOCH: u32 = 3; +/// | 4 | v3.2.0 | mapper 290's 32 KiB PRG mode maps both halves in order (*Asder 20-in-1* boots); a 24C02 keeps its word address across a START (mapper 16, 157); VRC4's IRQ mode bit reads 1 = cycle, 0 = scanline (mappers 21, 23, 25); VRC2 mirroring ignores bit 1, and 23 / 25 submapper 3 are built as VRC2; mapper 71 submapper 0 detects *Fire Hawk*'s `$9000` single-screen register | +pub const EMULATION_EPOCH: u32 = 4; /// Every host-settable option that changes what the emulated console does. /// diff --git a/crates/rustynes-core/src/lib.rs b/crates/rustynes-core/src/lib.rs index 73073b81c..a82f3cb60 100644 --- a/crates/rustynes-core/src/lib.rs +++ b/crates/rustynes-core/src/lib.rs @@ -98,14 +98,15 @@ pub use movie::{ #[cfg(feature = "debug-hooks")] pub use nes::TraceRec; pub use nes::{ - ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL, MAX_CPU_OVERCLOCK, - MAX_EXTRA_SCANLINES, Nes, PowerOnConfig, PowerOnRam, + BarcodeScanError, ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL, + MAX_CPU_OVERCLOCK, MAX_EXTRA_SCANLINES, Nes, PowerOnConfig, PowerOnRam, }; // v2.1.7 P5 — re-export the PPU-side hardware-revision knobs at the core surface // so downstream consumers (frontend, test-harness) depend on `rustynes-core`. pub use rewind::{ REWIND_DEFAULT_KEYFRAME_PERIOD, REWIND_DEFAULT_MAX_BYTES, RewindError, RewindRing, }; +pub use rustynes_mappers::{Barcode, BarcodeError}; pub use rustynes_ppu::{PaletteInit, PpuRevision}; pub use save_state::{ BinReader, BinWriter, FORMAT_VERSION, HEADER_LEN, Header, MAGIC, MIN_FORMAT_VERSION, diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index c578e72a9..d511f2db4 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -151,6 +151,18 @@ pub struct TraceRec { pub cycle: u64, } +/// Why [`Nes::scan_barcode`] refused a scan. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum BarcodeScanError { + /// The string is not a valid 13- or 8-digit JAN / EAN code. + #[error("not a valid JAN/EAN barcode: {0}")] + Malformed(rustynes_mappers::BarcodeError), + /// The cartridge has no barcode reader (only the Datach, mapper 157, + /// does). + #[error("this cartridge has no barcode reader")] + NoReader, +} + /// The largest extra-scanline overclock the core accepts. /// /// [`Nes::set_extra_scanlines`] clamps to it, and a movie's options record @@ -344,6 +356,19 @@ impl Nes { self.bus.mapper.clear_save_data(); } + /// Whether the cartridge's save data may have changed since the last + /// [`Self::clear_save_data_dirty`]; `None` when the board does not track it + /// (the caller then compares bytes). See `Mapper::is_save_data_dirty`. + #[must_use] + pub fn is_save_data_dirty(&self) -> Option { + self.bus.mapper.is_save_data_dirty() + } + + /// Mark the cartridge's save data clean. See `Mapper::clear_save_data_dirty`. + pub fn clear_save_data_dirty(&mut self) { + self.bus.mapper.clear_save_data_dirty(); + } + /// v2.7.3 — whether the cartridge has non-volatile save data: the header /// declares battery-backed PRG-RAM (iNES flags 6 bit 1), or (v2.9.6) the /// board is self-flashable and its flash is the save (GTROM, mapper 111, @@ -1691,6 +1716,49 @@ impl Nes { self.bus.set_disk_side(side); } + // --- Datach barcode reader (mapper 157, v3.2.0) --- + + /// Whether the cartridge has a barcode reader. Only the Bandai Datach + /// Joint ROM System (mapper 157) does; a frontend shows its "scan a + /// barcode" control only when this is `true`. + #[must_use] + pub fn has_barcode_reader(&self) -> bool { + self.bus.has_barcode_reader() + } + + /// Swipe a barcode past the Datach reader. + /// + /// `code` is a JAN / EAN code of 13 or 8 digits (spaces and hyphens are + /// skipped), validated here at the boundary, check digit included. The + /// reader then presents the symbol's bars to the CPU over the following + /// CPU cycles, deterministically: the same scan at the same cycle yields + /// the same frames, and a save state taken mid-scan restores mid-scan. + /// A scan replaces any scan still in flight. + /// + /// A scan is an input that changes emulation, and it is **not** recorded + /// by movies or carried by netplay: callers running either must refuse + /// it rather than desynchronise (the desktop frontend does). + /// + /// # Errors + /// + /// [`BarcodeScanError::Malformed`] for a string that is not a valid + /// 13- or 8-digit code, and [`BarcodeScanError::NoReader`] when the + /// cartridge has no barcode reader. + pub fn scan_barcode(&mut self, code: &str) -> Result<(), BarcodeScanError> { + let code = rustynes_mappers::Barcode::parse(code).map_err(BarcodeScanError::Malformed)?; + if self.bus.scan_barcode(&code) { + Ok(()) + } else { + Err(BarcodeScanError::NoReader) + } + } + + /// Whether a barcode scan is still being presented to the CPU. + #[must_use] + pub fn barcode_scan_in_progress(&self) -> bool { + self.bus.barcode_scan_in_progress() + } + /// Start recording the diagnostic FDS read-stream trace (the `$4031` disk-byte /// stream + `$4025` control writes + side changes). Off by default and /// observation-only — it never affects emulation, so the determinism contract @@ -3488,6 +3556,121 @@ mod tests { bytes } + /// Synthetic MMC5 (mapper 5) image, 32 KiB PRG + 8 KiB CHR. MMC5 powers on + /// in PRG mode 3 with the last 8 KiB bank at `$E000`, so the program lives + /// there. It waits two vblanks (past the NES PPU warm-up, where a + /// `$2001` write is ignored), turns rendering on, arms the scanline IRQ + /// at line 20 (`$5203` / `$5204`) and idles with interrupts enabled. The + /// IRQ handler counts into `$0000` and acknowledges by reading `$5204`. + fn synth_mmc5_scanline_irq() -> Vec { + let mut bytes = Vec::with_capacity(16 + 32 * 1024 + 8 * 1024); + bytes.extend_from_slice(b"NES\x1A"); + bytes.push(2); // 2 x 16 KiB PRG + bytes.push(1); // 1 x 8 KiB CHR + bytes.push(0x50); // flags6: mapper low nibble 5 + bytes.push(0); // flags7 + bytes.extend_from_slice(&[0u8; 8]); + let mut prg = vec![0u8; 32 * 1024]; + let base = 0x6000; // $E000 within the last 8 KiB bank + #[rustfmt::skip] + let reset: [u8; 35] = [ + 0x78, // $E000 SEI + 0xA2, 0xFF, // $E001 LDX #$FF + 0x9A, // $E003 TXS + 0xA9, 0x40, // $E004 LDA #$40 + 0x8D, 0x17, 0x40, // $E006 STA $4017 (inhibit the APU frame IRQ) + 0x2C, 0x02, 0x20, // $E009 BIT $2002 + 0x10, 0xFB, // $E00C BPL $E009 + 0x2C, 0x02, 0x20, // $E00E BIT $2002 + 0x10, 0xFB, // $E011 BPL $E00E + 0xA9, 0x1E, // $E013 LDA #$1E + 0x8D, 0x01, 0x20, // $E015 STA $2001 (rendering on) + 0xA9, 0x14, // $E018 LDA #20 + 0x8D, 0x03, 0x52, // $E01A STA $5203 (compare = line 20) + 0xA9, 0x80, // $E01D LDA #$80 + 0x8D, 0x04, 0x52, // $E01F STA $5204 (IRQ enable) + 0x58, // $E022 CLI + ]; + prg[base..base + reset.len()].copy_from_slice(&reset); + // $E023: JMP $E023 (idle) + prg[base + 0x23..base + 0x26].copy_from_slice(&[0x4C, 0x23, 0xE0]); + // $E030: IRQ handler -- INC $00 ; LDA $5204 (ack) ; RTI + prg[base + 0x30..base + 0x36].copy_from_slice(&[0xE6, 0x00, 0xAD, 0x04, 0x52, 0x40]); + // $E040: NMI handler -- RTI (NMI is never enabled; defensive) + prg[base + 0x40] = 0x40; + let len = prg.len(); + prg[len - 6..].copy_from_slice(&[0x40, 0xE0, 0x00, 0xE0, 0x30, 0xE0]); + bytes.extend_from_slice(&prg); + bytes.extend_from_slice(&[0u8; 8 * 1024]); + bytes + } + + /// The MMC5 scanline IRQ fires through the real `SystemBus`, once per + /// rendered frame. Regression for a v3.2.0 import that moved the + /// scanline detector into `Mmc5::ppu_read` (a nametable-snoop model): + /// the lockstep bus fetches nametables through `peek_nametable`, never + /// `ppu_read`, so the detector never saw a fetch and the IRQ stopped + /// firing in real play (*Castlevania III*, *Uchuu Keibitai SDF* and + /// *Bandit Kings* all went red). The `mmc5` test-ROM gate did not catch + /// it, because none of those ROMs depends on the scanline IRQ. + #[test] + fn mmc5_scanline_irq_fires_through_the_real_bus() { + let mut nes = Nes::from_rom(&synth_mmc5_scanline_irq()).unwrap(); + for _ in 0..10 { + nes.run_frame(); + } + let count = nes.peek(0x0000); + // Rendering is on from about frame 2; one IRQ per frame after that. + assert!( + (5..=10).contains(&count), + "expected one MMC5 scanline IRQ per rendered frame, counted {count}" + ); + } + + /// `Nes::scan_barcode` validates at the boundary, refuses a cartridge + /// with no reader, and a scan is part of the save state: a snapshot taken + /// mid-swipe restores mid-swipe and the two runs stay identical. + #[test] + fn datach_barcode_scan_is_validated_and_survives_a_snapshot() { + let mut nrom = Nes::from_rom(&synth_nrom(32, 8)).unwrap(); + assert!(!nrom.has_barcode_reader()); + assert_eq!( + nrom.scan_barcode("4006381333931"), + Err(BarcodeScanError::NoReader) + ); + + let rom = synth_board_rom(157, SweepHeader::Nes2 { submapper: 0 }, 32, 0); + let mut nes = Nes::from_rom(&rom).unwrap(); + assert!(nes.has_barcode_reader()); + assert_eq!( + nes.scan_barcode("4006381333932"), + Err(BarcodeScanError::Malformed( + rustynes_mappers::BarcodeError::BadCheckDigit { + expected: 1, + found: 2 + } + )) + ); + assert!( + !nes.barcode_scan_in_progress(), + "a refused scan starts nothing" + ); + assert_eq!(nes.scan_barcode("4006381333931"), Ok(())); + assert!(nes.barcode_scan_in_progress()); + nes.run_frame(); + assert!(nes.barcode_scan_in_progress(), "a swipe outlasts a frame"); + let mid = nes.snapshot(); + let mut other = Nes::from_rom(&rom).unwrap(); + other.restore(&mid).unwrap(); + assert!(other.barcode_scan_in_progress()); + for _ in 0..20 { + nes.run_frame(); + other.run_frame(); + } + assert!(!nes.barcode_scan_in_progress(), "the swipe ends"); + assert_eq!(nes.snapshot(), other.snapshot()); + } + /// Synthetic NES 2.0 NROM with console type Vs. System and a byte-13 Vs. /// PPU type (low nibble). fn synth_vs_nrom(vs_ppu_low_nibble: u8) -> Vec { diff --git a/crates/rustynes-cosim/Cargo.lock b/crates/rustynes-cosim/Cargo.lock index 03fa409ad..41d74a111 100644 --- a/crates/rustynes-cosim/Cargo.lock +++ b/crates/rustynes-cosim/Cargo.lock @@ -98,7 +98,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "libm", @@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "lz4_flex", @@ -121,7 +121,7 @@ dependencies = [ [[package]] name = "rustynes-cosim" -version = "3.1.0" +version = "3.2.0" dependencies = [ "rustynes-core", "sha2", @@ -129,7 +129,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "thiserror", @@ -137,7 +137,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "rustynes-apu", @@ -146,7 +146,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "libm", diff --git a/crates/rustynes-cosim/Cargo.toml b/crates/rustynes-cosim/Cargo.toml index ac6875959..0286ca730 100644 --- a/crates/rustynes-cosim/Cargo.toml +++ b/crates/rustynes-cosim/Cargo.toml @@ -8,7 +8,7 @@ description = "RustyNES as a co-simulation oracle for an external HDL device-und # The duplication is PINNED, not merely noticed: `cosim_manifest_audit.rs` in # `rustynes-test-harness` asserts these values still match the workspace's, so # drift fails a test instead of accumulating quietly. -version = "3.1.0" +version = "3.2.0" edition = "2024" rust-version = "1.99" license = "GPL-3.0-or-later" diff --git a/crates/rustynes-cosim/src/bin/nes_golden_export.rs b/crates/rustynes-cosim/src/bin/nes_golden_export.rs index 33f0dc87e..d8feba0c8 100644 --- a/crates/rustynes-cosim/src/bin/nes_golden_export.rs +++ b/crates/rustynes-cosim/src/bin/nes_golden_export.rs @@ -6,8 +6,15 @@ //! nes_golden_export --rom --out [--seed N] [--frames N] //! [--boot-trace START..END] [--irq-trace CAP] //! [--checkpoint-interval N] +//! [--four-score P1:P2:P3:P4] [--vaus POS:FIRE] [--genie CODE]... //! ``` //! +//! The last three (v3.2.0) attach STIMULUS devices for the whole run -- a Four +//! Score with four pads, an Arkanoid paddle on `$4017`, Game Genie codes -- so +//! the `RustyNES_MiSTer` input and cheat gates have a golden to compare against. +//! Each is recorded in the manifest (`four_score`, `vaus`, `genie`) only when +//! given; see `DeviceArgs`. +//! //! Writes, under ``: //! //! | file | format | consumed by | @@ -93,6 +100,218 @@ struct Args { inject_nmi_at: Option, inject_irq_at: Option, inject_hold: u64, + /// v3.2.0 (item 8) -- the input devices and cheat codes attached for the + /// whole run. See [`DeviceArgs`]. + devices: DeviceArgs, + /// v3.2.0 — `--sprite-limit-off`: run with the v3.1.0 "Disable + /// 8-sprite-per-scanline limit" option ON (`Nes::set_sprite_limit_disabled`). + /// + /// The `MiSTer` core gains the same option (its FB-16), and the only way to + /// check a display option is to export the oracle's picture with it set. + /// It is render-only (`docs/ppu-2c02.md`), so every CPU-visible artifact of + /// such a run must be byte-identical to the same run without it; only the + /// framebuffer differs. Recorded in the manifest when set (as + /// `sprite_limit_off = true`), so a golden says which picture it is. + sprite_limit_off: bool, + /// v3.2.0 — `--fb-every-frame`: also write `.index_fb_frames.bin`, + /// the index framebuffer after EACH frame of the run, concatenated + /// (frames x 256 x 240 LE `u16`). The single `index_fb.bin` is the last + /// frame only, so a display option that is right on the last frame and + /// wrong on the others would pass a one-frame gate. + fb_every_frame: bool, +} + +/// v3.2.0 (item 8): the STIMULUS the `RustyNES_MiSTer` input and cheat gates +/// need on the oracle side -- a Four Score with four pads, an Arkanoid (Vaus) +/// paddle on port 2, and Game Genie codes. +/// +/// Every one of these is a console INPUT the emulator already models (`Nes::set_four_score`, +/// `Nes::set_paddle`, `Nes::add_genie_code`); the exporter only had no way to +/// ask for them, so a DUT implementing the same device had nothing to be +/// compared against. They change no emulated behaviour of their own: with none +/// given, every artifact and every manifest line is byte-identical to the +/// exporter before this field existed (the manifest gains lines ONLY when one +/// is set, so the 132 committed manifests stay exactly what they are). +/// +/// Held CONSTANT for the run, deliberately. A frame-windowed schedule like +/// `--press-start` would need the DUT's testbench to reproduce the frame +/// boundaries in CPU cycles; a constant input needs nothing reproduced, and a +/// stimulus ROM that reads the device every frame exercises every bit of it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct DeviceArgs { + /// `--four-score P1:P2:P3:P4` -- the adapter on, and the four pads' button + /// bytes (bit order as `Buttons`: A, B, Select, Start, Up, Down, Left, Right + /// from bit 0). + four_score: Option<[u8; 4]>, + /// `--vaus POS:FIRE` -- an Arkanoid controller on port 2 (`$4017`), its + /// 8-bit knob position and whether the fire button is held. + vaus: Option<(u8, bool)>, + /// `--genie CODE`, repeatable -- 6- or 8-letter Game Genie codes, kept in + /// the order given (the manifest records them in that order). + genie: Vec, +} + +/// Parse `--four-score P1:P2:P3:P4`: four two-digit hex bytes. +/// +/// Exactly two digits each, so `1:2:3:4` is refused rather than read as +/// `01:02:03:04` -- the manifest records the canonical form, and a recipe +/// regenerated from it must reproduce the same argument the export saw. +fn parse_four_score_spec(spec: &str) -> Option<[u8; 4]> { + let parts: Vec<&str> = spec.split(':').collect(); + if parts.len() != 4 { + return None; + } + let mut out = [0u8; 4]; + for (slot, p) in out.iter_mut().zip(&parts) { + if p.len() != 2 { + return None; + } + *slot = u8::from_str_radix(p, 16).ok()?; + } + Some(out) +} + +/// Parse `--vaus POS:FIRE`: a two-digit hex knob position and `0` or `1`. +fn parse_vaus_spec(spec: &str) -> Option<(u8, bool)> { + let (pos, fire) = spec.split_once(':')?; + if pos.len() != 2 { + return None; + } + let pos = u8::from_str_radix(pos, 16).ok()?; + let fire = match fire { + "0" => false, + "1" => true, + _ => return None, + }; + Some((pos, fire)) +} + +/// Refuse a device request that would silently produce a golden describing +/// something other than what was asked. `None` when the request is coherent. +/// +/// Each rule names a combination that would RUN and export a plausible golden: +/// +/// - **Four Score with `--press-start`.** Both write port 1's buttons; the +/// press would overwrite the Four Score's pad 1 for its window and restore +/// zero after it, so the manifest's `four_score` line would be false for part +/// of the run. +/// - **Four Score with `--vaus`.** The `NESdev` "Arkanoid controller" page: "No +/// version is compatible with four player adapters." The oracle would let the +/// paddle take over port 2 regardless, producing a stream no console can. +/// - **Two Game Genie codes at one address.** The oracle keeps one code per +/// address, so the second REPLACES the first; the manifest would list a code +/// that was never active. +/// - **An undecodable code** is refused here rather than at `add_genie_code`, +/// so the message names the flag. +fn device_error(args: &Args) -> Option { + let d = &args.devices; + if d.four_score.is_some() && args.press_start.is_some() { + return Some( + "--four-score and --press-start both drive port 1's buttons; the press \ + would overwrite pad 1 for part of the run" + .to_owned(), + ); + } + if d.four_score.is_some() && d.vaus.is_some() { + return Some( + "--four-score with --vaus: the Arkanoid controller is not compatible with \ + four player adapters (NESdev \"Arkanoid controller\")" + .to_owned(), + ); + } + let mut seen: Vec = Vec::with_capacity(d.genie.len()); + for code in &d.genie { + let gc = match rustynes_core::GenieCode::new(code) { + Ok(gc) => gc, + Err(e) => return Some(format!("--genie {code:?}: {e}")), + }; + if seen.contains(&gc.addr()) { + return Some(format!( + "--genie {code:?} patches ${:04X}, which an earlier --genie already \ + patches; the oracle keeps one code per address, so the earlier one \ + would silently stop applying", + gc.addr() + )); + } + seen.push(gc.addr()); + } + None +} + +/// Attach the requested devices and codes to a freshly constructed oracle. +/// +/// Called before a single cycle runs, so the first strobe of the run already +/// sees them. Infallible once [`device_error`] has passed; the `Result` is the +/// genie decoder's, kept rather than unwrapped so a future path that skips the +/// validation fails with a message instead of a panic. +fn apply_devices(o: &mut Oracle, d: &DeviceArgs) -> Result<(), String> { + if let Some(pads) = d.four_score { + o.nes_mut().set_four_score(true); + for (port, bits) in pads.iter().enumerate() { + o.set_buttons(port, *bits); + } + } + if let Some((pos, fire)) = d.vaus { + // Port index 1 is `$4017`, where the NES (7-pin) Arkanoid controller + // reports -- the Famicom version's `$4016`/`$4017` split is a different + // device and is not what the MiSTer core models. + o.nes_mut().set_paddle(1, pos, fire); + } + for code in &d.genie { + o.nes_mut() + .add_genie_code(code) + .map_err(|e| format!("--genie {code:?}: {e}"))?; + } + Ok(()) +} + +/// Validate the device request, then attach it, or exit with the reason; and +/// set `--sprite-limit-off`. +/// +/// One call in `main`, which sits at the pedantic line limit, for the same +/// reason [`arm_traces`] is one call. Both run before the first cycle. The +/// sprite option is configuration, carried across a power cycle, and +/// render-only, so setting it here changes the picture and nothing else (no +/// work RAM byte, no bus cycle). +fn attach_devices(o: &mut Oracle, args: &Args) { + if args.sprite_limit_off { + o.nes_mut().set_sprite_limit_disabled(true); + } + if let Some(why) = device_error(args) { + eprintln!("{why}"); + usage(); + } + if let Err(e) = apply_devices(o, &args.devices) { + eprintln!("{e}"); + usage(); + } +} + +/// The manifest lines recording the devices, or the empty string when none +/// was attached -- which is what keeps every existing manifest byte-identical. +/// +/// Canonical forms (lower-case two-digit hex, upper-case codes) so the +/// recipe generator in `RustyNES_MiSTer` can turn each line straight back into +/// the flag that produced it. +fn device_block(d: &DeviceArgs) -> String { + // Lines collected and joined rather than `write!`-ed into one `String`, so + // there is no `fmt::Result` to discard (the reason `sha256_hex` is written + // the way it is). + let mut lines: Vec = Vec::new(); + if let Some(p) = d.four_score { + lines.push(format!( + "four_score = {:02x}:{:02x}:{:02x}:{:02x}\n", + p[0], p[1], p[2], p[3] + )); + } + if let Some((pos, fire)) = d.vaus { + lines.push(format!("vaus = {pos:02x}:{}\n", u8::from(fire))); + } + if !d.genie.is_empty() { + let codes: Vec = d.genie.iter().map(|c| c.to_ascii_uppercase()).collect(); + lines.push(format!("genie = {}\n", codes.join(","))); + } + lines.concat() } /// The `--ppu-state-trace` capacity, refused outside a usable range. @@ -128,7 +347,9 @@ fn usage() -> ! { \x20 [--fetch-trace CAP] [--apu-trace CAP] [--checkpoint-interval N]\n\ \x20 [--inject-instructions N] [--inject-hold N]\n\ \x20 [--inject-nmi-at N] [--inject-irq-at N]\n\ - \x20 [--press-start A:B]\n\ + \x20 [--press-start A:B] [--sprite-limit-off] [--fb-every-frame]\n\ + \x20 [--four-score P1:P2:P3:P4] [--vaus POS:FIRE]\n\ + \x20 [--genie CODE]...\n\ \x20 [--ppu-state-trace CAP --pst-frames A:B\n\ \x20 [--pst-scanlines A:B] [--pst-dots A:B]]" ); @@ -185,6 +406,8 @@ fn parse_args() -> Args { let (mut inject_instructions, mut inject_hold) = (0u64, 1u64); let (mut inject_nmi_at, mut inject_irq_at) = (None, None); let mut press_start: Option<(u64, u64)> = None; + let mut devices = DeviceArgs::default(); + let (mut sprite_limit_off, mut fb_every_frame) = (false, false); let mut i = 0; // Every value-taking arm below advances `i` by TWO, not one: this loop has @@ -305,6 +528,36 @@ fn parse_args() -> Args { pst_dots = Some(parse_pair_u16(need(i)).unwrap_or_else(|| usage())); i += 2; } + // v3.2.0 (item 8). A malformed spec EXITS rather than being + // ignored: an unparsed device is a golden of a console with nothing + // plugged in, under a recipe claiming otherwise. + "--four-score" => { + devices.four_score = Some(parse_four_score_spec(need(i)).unwrap_or_else(|| { + eprintln!("--four-score wants P1:P2:P3:P4, four two-digit hex bytes"); + usage() + })); + i += 2; + } + "--vaus" => { + devices.vaus = Some(parse_vaus_spec(need(i)).unwrap_or_else(|| { + eprintln!("--vaus wants POS:FIRE, a two-digit hex position and 0 or 1"); + usage() + })); + i += 2; + } + "--genie" => { + devices.genie.push(need(i).clone()); + i += 2; + } + // The two flags that take no value advance `i` by ONE. + "--sprite-limit-off" => { + sprite_limit_off = true; + i += 1; + } + "--fb-every-frame" => { + fb_every_frame = true; + i += 1; + } "--checkpoint-interval" => { checkpoint_interval = need(i).parse().unwrap_or_else(|_| usage()); if checkpoint_interval == 0 { @@ -350,6 +603,9 @@ fn parse_args() -> Args { inject_nmi_at, inject_irq_at, inject_hold, + devices, + sprite_limit_off, + fb_every_frame, } } @@ -869,6 +1125,36 @@ fn run_frames_with_optional_press( calls } +/// Advance the run one frame at a time, writing the index framebuffer after +/// each frame to `.index_fb_frames.bin` (v3.2.0, `--fb-every-frame`). +/// +/// The same `run_frame()` calls `advance_frames(total)` would make, split at +/// each frame boundary, so the run and every other artifact are unchanged. +/// Refused with `--press-start` rather than combined with it: no recipe needs +/// both, and an unexercised combination is a place a golden goes quietly wrong. +fn run_frames_capturing_each(o: &mut Oracle, args: &Args, base: &Path) -> u64 { + if args.press_start.is_some() { + eprintln!("--fb-every-frame is not supported together with --press-start"); + usage(); + } + let mut calls = 0u64; + let mut bytes = Vec::with_capacity(INDEX_FB_LEN * 2 * args.frames as usize); + for _ in 0..args.frames { + calls += o.advance_frames(1); + let fb = o.nes().index_framebuffer(); + assert_eq!( + fb.len(), + INDEX_FB_LEN, + "unexpected index framebuffer length" + ); + for px in fb { + bytes.extend_from_slice(&px.to_le_bytes()); + } + } + write(&suffixed(base, "index_fb_frames.bin"), &bytes); + calls +} + fn main() { let args = parse_args(); let rom = @@ -880,6 +1166,7 @@ fn main() { std::fs::create_dir_all(&args.out).expect("create out dir"); let mut o = Oracle::new(&rom, args.seed).unwrap_or_else(|e| panic!("parse rom: {e}")); + attach_devices(&mut o, &args); // The power-on work RAM, captured BEFORE a single cycle runs. // @@ -915,6 +1202,8 @@ fn main() { args.inject_irq_at, args.inject_hold, ) + } else if args.fb_every_frame { + run_frames_capturing_each(&mut o, &args, &args.out.join(&stem)) } else { run_frames_with_optional_press(&mut o, u64::from(args.frames), args.press_start) }; @@ -975,7 +1264,9 @@ fn main() { (0, 0) }; - let mode_block = run_mode_block(&args, calls, frames_actual); + // The device lines ride in the mode block: they describe the run, and an + // empty block leaves every pre-v3.2.0 manifest byte-identical. + let mode_block = run_mode_block(&args, calls, frames_actual) + &device_block(&args.devices); let manifest = format!( "rom = {}\n\ @@ -988,7 +1279,8 @@ fn main() { ram_len = {}\n\ ckpt_interval= {}\n\ ckpt_count = {}\n\ - obs_count = {}\n", + obs_count = {}\n\ + {}", args.rom.display(), sha256_hex(&rom), args.seed, @@ -1000,6 +1292,14 @@ fn main() { args.checkpoint_interval, checkpoint_count, observable_count, + // Written only when set, as the device lines are, so every manifest + // from a run without the option stays byte-identical to the + // exporter before it; `RustyNES_MiSTer`'s Makefile looks for `true`. + if args.sprite_limit_off { + "sprite_limit_off = true\n" + } else { + "" + }, ); write(&suffixed(&base, "manifest.txt"), manifest.as_bytes()); println!("done; {cycles} CPU cycles simulated"); @@ -1068,6 +1368,9 @@ mod tests { inject_nmi_at: None, inject_irq_at: None, inject_hold: 1, + devices: super::DeviceArgs::default(), + sprite_limit_off: false, + fb_every_frame: false, }; let mut args = base(); @@ -1186,3 +1489,200 @@ mod tests { ); } } + +/// v3.2.0 (item 8): the device and cheat stimulus flags. +/// +/// The rules are tested through the REAL parsers and validator, and the +/// attach path through a real `Oracle` running a real ROM -- a test that only +/// checked the parsed `DeviceArgs` would pass while `apply_devices` attached +/// nothing, which is the "armed but empty" shape this exporter refuses +/// everywhere else. +#[cfg(test)] +mod device_tests { + use super::{ + Args, DeviceArgs, apply_devices, device_block, device_error, parse_four_score_spec, + parse_vaus_spec, + }; + + fn args(devices: DeviceArgs) -> Args { + Args { + #[cfg(feature = "ppu-state-trace")] + ppu_state_trace: None, + rom: std::path::PathBuf::from("/dev/null"), + out: std::path::PathBuf::from("/tmp"), + seed: 0, + frames: 1, + press_start: None, + boot_trace: None, + irq_trace: None, + fetch_trace: None, + apu_trace: None, + checkpoint_interval: 4096, + inject_instructions: 0, + inject_nmi_at: None, + inject_irq_at: None, + inject_hold: 1, + devices, + sprite_limit_off: false, + fb_every_frame: false, + } + } + + #[test] + fn four_score_spec_takes_exactly_four_two_digit_hex_bytes() { + assert_eq!( + parse_four_score_spec("01:82:44:a8"), + Some([0x01, 0x82, 0x44, 0xA8]) + ); + assert_eq!( + parse_four_score_spec("FF:00:0f:F0"), + Some([0xFF, 0x00, 0x0F, 0xF0]) + ); + for bad in [ + "", + "01:82:44", + "01:82:44:a8:00", + "1:82:44:a8", + "001:82:44:a8", + "0g:82:44:a8", + ] { + assert_eq!(parse_four_score_spec(bad), None, "accepted {bad:?}"); + } + } + + #[test] + fn vaus_spec_takes_a_position_and_a_fire_bit() { + assert_eq!(parse_vaus_spec("a7:1"), Some((0xA7, true))); + assert_eq!(parse_vaus_spec("00:0"), Some((0x00, false))); + for bad in ["", "a7", "a7:2", "a7:", "7:1", "a7:1:0", "zz:1"] { + assert_eq!(parse_vaus_spec(bad), None, "accepted {bad:?}"); + } + } + + #[test] + fn incoherent_device_requests_are_refused() { + let fs = Some([1, 2, 3, 4]); + let mut a = args(DeviceArgs { + four_score: fs, + ..DeviceArgs::default() + }); + assert!(device_error(&a).is_none(), "a Four Score alone is coherent"); + a.press_start = Some((1, 2)); + assert!( + device_error(&a).is_some_and(|e| e.contains("--press-start")), + "Four Score + press-start" + ); + + let both = args(DeviceArgs { + four_score: fs, + vaus: Some((0x80, false)), + genie: Vec::new(), + }); + assert!(device_error(&both).is_some_and(|e| e.contains("four player"))); + + let bad_code = args(DeviceArgs { + genie: vec!["WXIOPO".to_owned()], + ..DeviceArgs::default() + }); + assert!(device_error(&bad_code).is_some_and(|e| e.contains("WXIOPO"))); + + // SXIOPO and SXIOPOZZ decode to the SAME address ($91D9); the second + // would replace the first inside the oracle. + let dup = args(DeviceArgs { + genie: vec!["SXIOPO".to_owned(), "SXIOPOZZ".to_owned()], + ..DeviceArgs::default() + }); + assert!(device_error(&dup).is_some_and(|e| e.contains("$91D9"))); + + let ok = args(DeviceArgs { + genie: vec!["SXIOPO".to_owned(), "YYKPOYZZ".to_owned()], + vaus: Some((0x10, true)), + ..DeviceArgs::default() + }); + assert!(device_error(&ok).is_none()); + } + + #[test] + fn no_device_adds_no_manifest_line_and_each_device_adds_its_own() { + assert_eq!(device_block(&DeviceArgs::default()), ""); + let d = DeviceArgs { + four_score: Some([0x01, 0x82, 0x44, 0xA8]), + vaus: None, + genie: vec!["sxiopo".to_owned(), "YYKPOYZZ".to_owned()], + }; + assert_eq!( + device_block(&d), + "four_score = 01:82:44:a8\ngenie = SXIOPO,YYKPOYZZ\n" + ); + let v = DeviceArgs { + vaus: Some((0xA7, true)), + ..DeviceArgs::default() + }; + assert_eq!(device_block(&v), "vaus = a7:1\n"); + } + + /// A 16 KiB NROM that strobes the pads and stores the first `$4016` and + /// `$4017` reads at `$00`/`$01`, then spins. + fn strobe_rom() -> Vec { + let mut rom = vec![0u8; 16 + 16384 + 8192]; + rom[..4].copy_from_slice(b"NES\x1a"); + rom[4] = 1; + rom[5] = 1; + let code = [ + 0xA9, 0x01, 0x8D, 0x16, 0x40, // LDA #1 ; STA $4016 + 0xA9, 0x00, 0x8D, 0x16, 0x40, // LDA #0 ; STA $4016 + 0xAD, 0x16, 0x40, 0x85, 0x00, // LDA $4016 ; STA $00 + 0xAD, 0x17, 0x40, 0x85, 0x01, // LDA $4017 ; STA $01 + 0x4C, 0x14, 0x80, // JMP $8014 (itself) + ]; + rom[16..16 + code.len()].copy_from_slice(&code); + let v = 16 + 16384 - 6; + rom[v..v + 6].copy_from_slice(&[0x00, 0x80, 0x00, 0x80, 0x00, 0x80]); + rom + } + + fn run(d: &DeviceArgs) -> (u8, u8, rustynes_cosim::Oracle) { + let mut o = rustynes_cosim::Oracle::new(&strobe_rom(), 0).expect("rom"); + apply_devices(&mut o, d).expect("attach"); + o.advance_frames(2); + let ram = o.nes().bus().ram_bytes(); + (ram[0], ram[1], o) + } + + #[test] + fn the_devices_actually_reach_the_emulated_ports() { + // Control: nothing attached. Open bus $40 from the operand's high byte, + // D0 = 0 from two unpressed pads. + let (p1, p2, _) = run(&DeviceArgs::default()); + assert_eq!((p1, p2), (0x40, 0x40)); + + // Four Score: pad 1's A (bit 0) and pad 2's A are the first bits out. + let (p1, p2, o) = run(&DeviceArgs { + four_score: Some([0x01, 0x01, 0x00, 0x00]), + ..DeviceArgs::default() + }); + assert!(o.nes().four_score()); + assert_eq!((p1, p2), (0x41, 0x41)); + + // Vaus on $4017: knob $A7 has its MSb set, which goes out INVERTED on + // D4 (so 0), and fire held is D3 = 1; D0 is not driven. $4016 is + // still the standard pad. + let (p1, p2, _) = run(&DeviceArgs { + vaus: Some((0xA7, true)), + ..DeviceArgs::default() + }); + assert_eq!((p1, p2), (0x40, 0x48)); + let (_, p2, _) = run(&DeviceArgs { + vaus: Some((0x27, false)), + ..DeviceArgs::default() + }); + assert_eq!(p2, 0x50, "MSb clear goes out as D4 = 1"); + + // Game Genie: every code is active, none silently dropped. + let (_, _, o) = run(&DeviceArgs { + genie: vec!["SXIOPO".to_owned(), "YYKPOYZZ".to_owned()], + ..DeviceArgs::default() + }); + assert_eq!(o.nes().genie_codes().count(), 2); + } +} diff --git a/crates/rustynes-cosim/src/lib.rs b/crates/rustynes-cosim/src/lib.rs index f48ddbdbc..c7d408054 100644 --- a/crates/rustynes-cosim/src/lib.rs +++ b/crates/rustynes-cosim/src/lib.rs @@ -697,6 +697,10 @@ macro_rules! oracle { /// `handle` must be a live pointer from [`rn_open`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn rn_run_frame(handle: *mut c_void) -> c_ulonglong { + // No `catch_unwind` here, deliberately (v3.2.0 review). A panic escaping an + // `extern "C"` function aborts the process, which is what a test oracle + // should do: a draft caught it in this one export and returned 0, which the + // co-simulation harness would have read as a cycle count. let o = oracle!(handle, 0); o.nes.run_frame(); o.nes.bus().cycle() @@ -749,10 +753,25 @@ pub unsafe extern "C" fn rn_index_framebuffer( if out.is_null() || cap < fb.len() { return 0; } - // SAFETY: `out` is non-null and the caller guarantees `cap` writable u16s, - // and `cap >= fb.len()` was just checked. The regions cannot overlap: `fb` - // borrows emulator-owned memory, `out` is caller-owned. - unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) }; + // `out` is non-null and the caller guarantees `cap` writable u16s, and + // `cap >= fb.len()` was just checked. The regions cannot overlap: `fb` + // borrows emulator-owned memory, `out` is caller-owned. A C caller may pass + // a buffer that is not 2-byte aligned, which a `u16` copy may not touch + // (v3.2.0), so that case copies the same bytes as bytes. + if (out as usize) & (core::mem::align_of::() - 1) == 0 { + // SAFETY: as above, and `out` is aligned for `u16`. + unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) }; + } else { + // SAFETY: as above; a `u8` copy has no alignment requirement, and + // `size_of_val(fb)` bytes is exactly the `fb.len()` u16s checked to fit. + unsafe { + core::ptr::copy_nonoverlapping( + fb.as_ptr().cast::(), + out.cast::(), + core::mem::size_of_val(fb), + ); + } + } fb.len() } diff --git a/crates/rustynes-frontend/Cargo.toml b/crates/rustynes-frontend/Cargo.toml index f15eed88b..33b3bf0f6 100644 --- a/crates/rustynes-frontend/Cargo.toml +++ b/crates/rustynes-frontend/Cargo.toml @@ -337,7 +337,7 @@ png = "0.18" # so it reuses the already-present flate2 / miniz_oxide rather than pulling in # the AES / zstd / bzip2 extras. The wasm load path receives raw bytes from the # browser file picker, so it never needs zip. -zip = { version = "8", default-features = false, features = ["deflate"] } +zip = { version = "9", default-features = false, features = ["deflate"] } # v1.8.5 — the HD-pack loader + compositor + HD audio moved to `rustynes-hdpack` # (so the mobile bridge can reach it; the core is no_std). The frontend re-exports diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index c60c1cfc7..04d09cec9 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -211,13 +211,19 @@ fn extract_rom_from_zip(zip_bytes: &[u8]) -> Option<(String, Vec)> { const MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024; let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).ok()?; let idx = (0..archive.len()).find(|&i| { + // zip 9: an entry whose name decodes as neither UTF-8 nor CP437 is + // skipped, the same as an entry that cannot be read at all. archive.by_index(i).is_ok_and(|f| { - std::path::Path::new(f.name()).extension().is_some_and(|e| { - e.eq_ignore_ascii_case("nes") - || e.eq_ignore_ascii_case("fds") - || e.eq_ignore_ascii_case("nsf") - || e.eq_ignore_ascii_case("unf") - || e.eq_ignore_ascii_case("unif") + f.name().is_ok_and(|n| { + std::path::Path::new(n.as_ref()) + .extension() + .is_some_and(|e| { + e.eq_ignore_ascii_case("nes") + || e.eq_ignore_ascii_case("fds") + || e.eq_ignore_ascii_case("nsf") + || e.eq_ignore_ascii_case("unf") + || e.eq_ignore_ascii_case("unif") + }) }) }) })?; @@ -225,9 +231,15 @@ fn extract_rom_from_zip(zip_bytes: &[u8]) -> Option<(String, Vec)> { if file.size() > MAX_ENTRY_BYTES { return None; } - let name = std::path::Path::new(file.name()) - .file_name() - .map_or_else(|| "rom".to_string(), |n| n.to_string_lossy().into_owned()); + let name = file + .name() + .ok() + .and_then(|n| { + std::path::Path::new(n.as_ref()) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| "rom".to_string()); let cap = usize::try_from(file.size()).unwrap_or(0); let mut out = Vec::with_capacity(cap); file.take(MAX_ENTRY_BYTES).read_to_end(&mut out).ok()?; @@ -2687,6 +2699,28 @@ impl App { /// v1.8.9 — insert a specific FDS disk side (`Some(i)`) or eject (`None`) — the /// Multi-Disk submenu's direct counterpart to [`Self::cycle_disk_side`] (a /// multi-disk game prompts "insert side N"). An out-of-range side is ignored. + /// v3.2.0 — swipe `code` through the Datach barcode reader and report the + /// outcome on the status line. The core validates the digits again at its + /// boundary; a refusal there (malformed code, no reader) is shown, never + /// silently dropped. Callers have already asked `session_policy`. + fn scan_barcode(&mut self, code: &str) { + let result = { + let mut guard = self.emu.lock(); + let Some(nes) = guard.nes.as_mut() else { + return; + }; + nes.scan_barcode(code) + }; + let message = match result { + Ok(()) => StatusMessage::info(crate::tf!(ShellBarcodeScanned, code)), + Err(rustynes_core::BarcodeScanError::Malformed(e)) => { + StatusMessage::error(crate::ui_shell::barcode_error_text(e)) + } + Err(e) => StatusMessage::error(e.to_string()), + }; + self.ui.set_status(message); + } + fn set_disk_side(&self, side: Option) { #[cfg(not(target_arch = "wasm32"))] self.flush_fds_save(true); @@ -5465,6 +5499,14 @@ impl App { self.set_disk_side(side); } } + MenuAction::ScanBarcode(code) => { + // v3.2.0 — a Datach scan is not in a movie's or netplay's + // input stream, so it is refused (with the reason) while + // either owns the timeline, like a disk swap. + if !self.refuse_timeline_action(TimelineAction::BarcodeScan) { + self.scan_barcode(&code); + } + } MenuAction::Screenshot => { #[cfg(not(target_arch = "wasm32"))] self.take_screenshot(); @@ -7507,7 +7549,14 @@ impl App { let raw_local = self.input.player1(); let turbo_mask = self.turbo_mask(); let turbo_period = self.config.input.turbo_period; - let mut guard = self.emu.lock(); + // v3.2.0: never block the UI thread here. The emulation thread idles + // during netplay, so the core is held only briefly by another thread + // (a present copy); if it is, this tick is skipped and the previous + // frame stays on screen. The rollback session sees that as one slow + // frame. A poisoned lock is recovered, not treated as held. + let Some(mut guard) = self.emu.try_lock() else { + return; + }; let emu = &mut *guard; let Some(nes) = emu.nes.as_mut() else { return; @@ -10678,6 +10727,8 @@ impl ApplicationHandler for App { disk_sides, inserted_disk_side, vs_system, + // v3.2.0 — a Datach cart (shows "Scan Barcode..."). + barcode_reader, mapper_label, region_label, movie_recording, @@ -10701,6 +10752,7 @@ impl ApplicationHandler for App { 0usize, None, false, + false, String::new(), String::new(), rec, @@ -10720,6 +10772,7 @@ impl ApplicationHandler for App { nes.disk_side_count(), nes.inserted_disk_side(), nes.is_vs_system(), + nes.has_barcode_reader(), mapper_name, region.to_string(), rec, @@ -10741,6 +10794,7 @@ impl ApplicationHandler for App { disk_sides, inserted_disk_side, vs_system, + barcode_reader, mapper_label: &mapper_label, region_label: ®ion_label, run_ahead, @@ -13107,6 +13161,10 @@ mod tests { "MenuAction::SetDiskSide(side) =>", "TimelineAction::DiskSwap", ), + ( + "MenuAction::ScanBarcode(code) =>", + "TimelineAction::BarcodeScan", + ), ("MenuAction::LoadState =>", "TimelineAction::LoadState"), ( "MenuAction::LoadStateSlot(slot) =>", diff --git a/crates/rustynes-frontend/src/atomic_write.rs b/crates/rustynes-frontend/src/atomic_write.rs index c798af88c..649e52b86 100644 --- a/crates/rustynes-frontend/src/atomic_write.rs +++ b/crates/rustynes-frontend/src/atomic_write.rs @@ -495,6 +495,14 @@ where /// On failure **before the rename** the scratch file is removed and the existing /// file is left untouched — a stale-but-valid file is the one worth keeping. /// +/// # Permissions (Unix) +/// +/// A file being REPLACED keeps its mode. A file being CREATED is created +/// owner-only, `0o600` (v3.2.0; before, the umask default, typically `0o644`): +/// these are save data, configuration and recordings, which no other user +/// needs to read. A user who wants them shared can widen one, and the widened +/// mode is then kept across every later save. +/// /// # One failure happens AFTER the target is replaced /// /// The parent-directory sync is the last step, and it necessarily runs *after* @@ -589,9 +597,9 @@ where // instead of truncating it (CWE-377). opts.write(true).create_new(true); #[cfg(unix)] - if let Some(mode) = existing_mode { + { use std::os::unix::fs::OpenOptionsExt as _; - opts.mode(mode); + opts.mode(existing_mode.unwrap_or(0o600)); } // Advance past an occupied scratch name, up to `SCRATCH_ATTEMPTS` times. // A crashed run can orphan scratch files, the OS later reuses that pid, @@ -889,6 +897,18 @@ mod tests { assert_eq!(mode, 0o600, "mode was widened to {mode:o}"); } + /// v3.2.0: a file that does not exist yet is created owner-only. + #[cfg(unix)] + #[test] + fn a_new_file_is_created_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + let d = tempdir(); + let p = d.join("new.sav"); + write_atomic(&p, b"fresh").expect("write"); + let mode = fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "a new file landed at {mode:o}"); + } + /// A mode the UMASK would mask must still land exactly. /// /// This exists because the test above does NOT actually pin property 5. diff --git a/crates/rustynes-frontend/src/audio.rs b/crates/rustynes-frontend/src/audio.rs index b6015cbcc..93c38573b 100644 --- a/crates/rustynes-frontend/src/audio.rs +++ b/crates/rustynes-frontend/src/audio.rs @@ -140,18 +140,15 @@ pub enum AudioError { /// atomic bit patterns. Capacity is a power of two; head/tail are /// monotonically increasing (wrapping) indices masked into the slot array. struct Ring { - slots: Box<[AtomicU64]>, + slots: Box<[AtomicU32]>, mask: usize, } -// Each slot packs one f32's bits into the low half of an AtomicU64; using -// 64-bit slots keeps the array uniform on 32-bit wasm targets too. (A plain -// AtomicU32 would also work; the cost difference is noise at 16 K slots.) impl Ring { fn new(capacity_pow2: usize) -> Self { debug_assert!(capacity_pow2.is_power_of_two()); let slots = (0..capacity_pow2) - .map(|_| AtomicU64::new(0)) + .map(|_| AtomicU32::new(0)) .collect::>() .into_boxed_slice(); Self { @@ -165,13 +162,26 @@ impl Ring { } } +/// Cache-line alignment wrapper (64 bytes) to eliminate false sharing +/// between producer and consumer threads on multi-core processors. +#[repr(align(64))] +struct CacheAligned(pub T); + +impl core::ops::Deref for CacheAligned { + type Target = T; + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + /// Shared queue state. struct QueueInner { ring: Ring, /// Consumer (read) index — advanced by the audio callback only. - head: AtomicUsize, + head: CacheAligned, /// Producer (write) index — advanced by the app's produce path only. - tail: AtomicUsize, + tail: CacheAligned, /// Total samples dropped by `push` because the ring was full (overrun — /// the producer ran ahead of the DAC clock) or skipped by a hard /// resync. @@ -275,8 +285,8 @@ impl SampleQueue { Self { inner: Arc::new(QueueInner { ring: Ring::new(cap), - head: AtomicUsize::new(0), - tail: AtomicUsize::new(0), + head: CacheAligned(AtomicUsize::new(0)), + tail: CacheAligned(AtomicUsize::new(0)), overrun_dropped: AtomicU64::new(0), underruns: AtomicU64::new(0), started: AtomicBool::new(false), @@ -472,7 +482,7 @@ impl SampleQueue { let n = samples.len().min(free); for (i, &s) in samples[..n].iter().enumerate() { self.inner.ring.slots[tail.wrapping_add(i) & self.inner.ring.mask] - .store(u64::from(s.to_bits()), Ordering::Relaxed); + .store(s.to_bits(), Ordering::Relaxed); } self.inner .tail @@ -538,9 +548,8 @@ impl SampleQueue { let gain = f32::from_bits(self.inner.gain.load(Ordering::Relaxed) as u32); let n = out.len().min(avail); for (i, o) in out[..n].iter_mut().enumerate() { - #[allow(clippy::cast_possible_truncation)] // low 32 bits hold the f32. let bits = self.inner.ring.slots[head.wrapping_add(i) & self.inner.ring.mask] - .load(Ordering::Relaxed) as u32; + .load(Ordering::Relaxed); *o = f32::from_bits(bits) * gain; } self.inner diff --git a/crates/rustynes-frontend/src/audio_dsp.rs b/crates/rustynes-frontend/src/audio_dsp.rs index 9689ebe45..9b2fc8e3c 100644 --- a/crates/rustynes-frontend/src/audio_dsp.rs +++ b/crates/rustynes-frontend/src/audio_dsp.rs @@ -141,16 +141,24 @@ struct Reverb { allpasses: Vec, } +/// Schroeder's reference comb delays (ms), prime-ish to avoid flutter. +const COMB_MS: [f32; 4] = [29.7, 37.1, 41.1, 43.7]; + +/// The combs' outputs are averaged: `1 / COMB_MS.len()`. With four combs this +/// is 0.25, and multiplying by a power-of-two reciprocal is bit-identical to +/// the division it replaced (v3.2.0); deriving it from the table keeps the two +/// from drifting apart if a comb is added. +#[allow(clippy::cast_precision_loss)] // a handful of combs. +const COMB_GAIN: f32 = 1.0 / COMB_MS.len() as f32; + impl Reverb { /// Build a reverb voiced for `sample_rate`. `room` (0..=1) scales the comb /// feedback (decay time); larger = longer tail. fn new(sample_rate: u32, room: f32) -> Self { let sr = sample_rate.max(1) as f32; - // Schroeder's reference comb delays (ms), prime-ish to avoid flutter. - let comb_ms = [29.7, 37.1, 41.1, 43.7]; let allpass_ms = [5.0, 1.7]; let feedback = Self::room_feedback(room); - let combs = comb_ms + let combs = COMB_MS .iter() .map(|&ms| Comb::new(((ms / 1000.0) * sr) as usize, feedback)) .collect(); @@ -187,7 +195,7 @@ impl Reverb { for c in &mut self.combs { acc += c.process(x); } - acc /= self.combs.len().max(1) as f32; + acc *= COMB_GAIN; for a in &mut self.allpasses { acc = a.process(acc); } diff --git a/crates/rustynes-frontend/src/battery_policy.rs b/crates/rustynes-frontend/src/battery_policy.rs index b5e94dc23..8162b893d 100644 --- a/crates/rustynes-frontend/src/battery_policy.rs +++ b/crates/rustynes-frontend/src/battery_policy.rs @@ -98,16 +98,20 @@ impl BatteryPolicy { /// Whether `live` differs from the last successful write. #[must_use] - pub fn differs(&self, live: &[u8]) -> bool { - live != self.last.as_slice() + pub fn differs(&self, live: &[u8], is_dirty: Option) -> bool { + if is_dirty == Some(false) { + false + } else { + live != self.last.as_slice() + } } /// The bytes to write now, if any: a copy of `live` when a comparison is /// due ([`Self::tick`]) and `live` differs from the last write /// ([`Self::differs`]). The copy lets the caller write without holding /// the emulator. - pub fn due(&mut self, live: &[u8], force: bool) -> Option> { - (self.tick(force) && self.differs(live)).then(|| live.to_vec()) + pub fn due(&mut self, live: &[u8], is_dirty: Option, force: bool) -> Option> { + (self.tick(force) && self.differs(live, is_dirty)).then(|| live.to_vec()) } /// Record the outcome of writing `bytes`: on success they become the @@ -168,30 +172,50 @@ mod tests { let dirty = [1u8; 4]; let mut due = 0; for _ in 0..(3 * CHECK_PERIOD_FRAMES) { - if p.due(&dirty, false).is_some() { + if p.due(&dirty, None, false).is_some() { due += 1; } } assert_eq!(due, 3, "once per period, although always dirty"); - assert_eq!(p.due(&dirty, true), Some(dirty.to_vec()), "force: now"); + assert_eq!( + p.due(&dirty, None, true), + Some(dirty.to_vec()), + "force: now" + ); } #[test] fn a_clean_save_is_never_due() { let mut p = BatteryPolicy::new(vec![7; 4]); - assert!(p.due(&[7; 4], true).is_none()); + assert!(p.due(&[7; 4], None, true).is_none()); + } + + /// v3.2.0: a board that tracks its save data (`Some`) lets the policy + /// skip the byte comparison while it reports clean; untracked (`None`) and + /// dirty (`Some(true)`) boards are compared as before. The skip is only + /// sound because such a board marks every change dirty + /// (`flash_boards_mark_every_save_data_change_dirty` in the mappers). + #[test] + fn a_board_reporting_clean_skips_the_comparison() { + let mut p = BatteryPolicy::new(vec![0; 4]); + assert!( + p.due(&[1; 4], Some(false), true).is_none(), + "clean: skipped" + ); + assert_eq!(p.due(&[1; 4], Some(true), true), Some(vec![1; 4]), "dirty"); + assert_eq!(p.due(&[1; 4], None, true), Some(vec![1; 4]), "untracked"); } #[test] fn a_success_moves_the_baseline_and_a_failure_does_not() { let mut p = BatteryPolicy::new(vec![0; 2]); - let bytes = p.due(&[5, 6], true).unwrap(); + let bytes = p.due(&[5, 6], None, true).unwrap(); assert!(p.written(bytes.clone(), false), "first failure: report"); assert_eq!(p.baseline(), &[0, 0], "baseline kept, so it retries"); assert!(!p.written(bytes.clone(), false), "second failure: quiet"); assert!(!p.written(bytes, true), "success reports nothing"); assert_eq!(p.baseline(), &[5, 6]); - assert!(p.due(&[5, 6], true).is_none(), "and is now clean"); + assert!(p.due(&[5, 6], None, true).is_none(), "and is now clean"); assert!( p.written(vec![1, 1], false), "a new run of failures reports" diff --git a/crates/rustynes-frontend/src/battery_save.rs b/crates/rustynes-frontend/src/battery_save.rs index 011ad9ada..bbcc73ef2 100644 --- a/crates/rustynes-frontend/src/battery_save.rs +++ b/crates/rustynes-frontend/src/battery_save.rs @@ -236,7 +236,9 @@ impl BatterySave { /// thread for as long as the disk took (review on #551). Report the /// outcome with [`Self::written`]. pub fn due_write(&mut self, nes: &Nes, force: bool) -> Option { - let bytes = self.policy.due(nes.save_data(), force)?; + let bytes = self + .policy + .due(nes.save_data(), nes.is_save_data_dirty(), force)?; Some(BatteryWrite { path: self.path.clone(), bytes, diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index 8f2041eff..c0595f990 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -240,6 +240,19 @@ impl EmuHandle { guard } + /// Lock the core if no other thread holds it, without waiting; `None` + /// only when it is held (v3.2.0, the netplay tick, which skips a frame + /// rather than block the UI thread). A poisoned lock is recovered exactly + /// as [`Self::lock`] recovers it -- reporting it as "held" would make that + /// caller skip every frame forever after one panic. + pub fn try_lock(&self) -> Option> { + match self.inner.try_lock() { + Ok(guard) => Some(guard), + Err(std::sync::TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()), + Err(std::sync::TryLockError::WouldBlock) => None, + } + } + /// Lock the core. Poisoning is ignored deliberately: a panic on one /// thread must not wedge the other (the core's state is a plain value; /// the next frame either works or panics identically). @@ -2513,6 +2526,33 @@ pub(crate) fn drive_ra( mod tests { use super::*; + /// v3.2.0: `try_lock` is `None` only while another thread holds the core; + /// a poisoned lock is recovered as `lock` recovers it. Treating poison as + /// "held" made the netplay tick skip every frame forever after one panic. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn try_lock_recovers_a_poisoned_core_and_refuses_only_a_held_one() { + let handle = EmuHandle::new(EmuCore::new()); + assert!(handle.try_lock().is_some(), "free: taken"); + + let held = handle.lock(); + let other = handle.clone(); + let busy = std::thread::spawn(move || other.try_lock().is_none()) + .join() + .unwrap(); + assert!(busy, "held by another thread: None"); + drop(held); + + let poisoner = handle.clone(); + let _ = std::thread::spawn(move || { + let _guard = poisoner.lock(); + panic!("poison the core lock"); + }) + .join(); + assert!(handle.inner.is_poisoned(), "fixture: the lock is poisoned"); + assert!(handle.try_lock().is_some(), "poisoned: recovered, not None"); + } + /// The shared cost model divides the measured cost over `running + 1` /// frames: depth 0 is one frame, depth 2 is three. #[test] diff --git a/crates/rustynes-frontend/src/emu_thread.rs b/crates/rustynes-frontend/src/emu_thread.rs index d69d18376..89337fb63 100644 --- a/crates/rustynes-frontend/src/emu_thread.rs +++ b/crates/rustynes-frontend/src/emu_thread.rs @@ -700,6 +700,12 @@ fn run_loop( } // Idle: no ROM yet, netplay owns the core on the winit thread, or the // user paused emulation from the UX shell. + // + // `netplay_paused` MUST idle this thread: during netplay the winit + // thread advances the core through the rollback session, and two + // threads stepping one core in turn would desync it. A v3.2.0 import + // dropped this term (to stop the UI thread waiting on the lock); that + // goal is kept by the netplay tick's `try_lock` instead. let idle = !control.has_rom.load(Ordering::Acquire) || control.netplay_paused.load(Ordering::Acquire) || control.user_paused.load(Ordering::Acquire); diff --git a/crates/rustynes-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs index 58a53919b..2ace156b8 100644 --- a/crates/rustynes-frontend/src/i18n.rs +++ b/crates/rustynes-frontend/src/i18n.rs @@ -598,6 +598,16 @@ catalog! { ShellSpeedPct => "Speed: {0}%", Some("Velocidad: {0}%"); ShellRegion => "Region: {0}", Some("Región: {0}"); ShellSideN => "Side {0}", Some("Lado {0}"); + ShellScanBarcode => "Scan Barcode...", Some("Escanear código de barras..."); + ShellBarcodeTitle => "Datach Barcode Reader", Some("Lector de códigos de barras Datach"); + ShellBarcodePrompt => "Type the 13- or 8-digit number printed under the card's barcode, then press Scan.", Some("Escribe el número de 13 u 8 dígitos impreso bajo el código de barras de la tarjeta y pulsa Escanear."); + ShellBarcodeScan => "Scan", Some("Escanear"); + ShellBarcodeLocked => "Scanning is disabled while a movie records or plays, or during netplay: neither records a scan.", Some("El escaneo está desactivado mientras se graba o reproduce una película, o durante el juego en red: ninguno registra el escaneo."); + ShellBarcodeNeedDigits => "Enter 13 or 8 digits.", Some("Introduce 13 u 8 dígitos."); + ShellBarcodeNotDigit => "Character {0} ('{1}') is not a digit.", Some("El carácter {0} ('{1}') no es un dígito."); + ShellBarcodeWrongLength => "{0} digits: a JAN/EAN barcode has 13 or 8.", Some("{0} dígitos: un código JAN/EAN tiene 13 u 8."); + ShellBarcodeBadCheck => "The check digit is {0}; these digits need {1}.", Some("El dígito de control es {0}; estos dígitos necesitan {1}."); + ShellBarcodeScanned => "Scanned barcode {0}", Some("Código de barras escaneado: {0}"); ShellShowLagFramesHover => "Show a counter of frames where the game polled no controller (a TAS / debug diagnostic).", Some("Muestra un contador de fotogramas en los que el juego no leyó ningún control (un diagnóstico de TAS / depuración)."); ShellLagCount => "Lag: {0}", Some("Retraso: {0}"); ShellLagHover => "Lag frames since ROM load (no controller polled). Toggle in View -> Show Lag Frames.", Some("Fotogramas de retraso desde que se cargó la ROM (sin leer ningún control). Actívalo en Ver -> Mostrar fotogramas de retraso."); diff --git a/crates/rustynes-frontend/src/icons.rs b/crates/rustynes-frontend/src/icons.rs index c3da33fe8..0bf4a91d9 100644 --- a/crates/rustynes-frontend/src/icons.rs +++ b/crates/rustynes-frontend/src/icons.rs @@ -56,6 +56,8 @@ pub mod glyph { pub const CLOCK_ROTATE_LEFT: char = '\u{f1da}'; /// `floppy-disk` — save state / save-slot actions. pub const FLOPPY_DISK: char = '\u{f0c7}'; + /// `barcode` — v3.2.0, the Datach barcode reader's "Scan Barcode..." item. + pub const BARCODE: char = '\u{f02a}'; /// `download` — load state / import. pub const DOWNLOAD: char = '\u{f019}'; /// `camera`-like image; FA Solid `image` glyph for screenshots. diff --git a/crates/rustynes-frontend/src/netplay_ui.rs b/crates/rustynes-frontend/src/netplay_ui.rs index b7915797d..f52558f00 100644 --- a/crates/rustynes-frontend/src/netplay_ui.rs +++ b/crates/rustynes-frontend/src/netplay_ui.rs @@ -152,6 +152,12 @@ pub struct NetplayUi { /// captured at connect for the handshake + session /// ([`SessionIdentity::of`]). The peer must announce an identical one. identity: SessionIdentity, + /// When the session last confirmed a new frame (v3.2.0 peer liveness): + /// five seconds of stall past it ends the session with a reason. + last_peer_activity: std::time::Instant, + /// The newest confirmed frame seen, so only progress refreshes + /// `last_peer_activity`. + last_confirmed_tracker: u32, /// Cached status for the HUD, refreshed each `tick`. status: NetplayStatus, /// Session config (input delay, rollback window, checksum interval). The @@ -170,6 +176,8 @@ impl Default for NetplayUi { state: NetplayState::Idle, is_host: false, identity: SessionIdentity::new([0u8; 32], [0u8; 32]), + last_peer_activity: std::time::Instant::now(), + last_confirmed_tracker: 0, status: NetplayStatus::default(), config: SessionConfig::default(), spectator_delay_frames: 0, @@ -422,6 +430,8 @@ impl NetplayUi { let transport = conn.into_transport(); let session = RollbackSession::new(self.config, transport, self.identity); self.state = NetplayState::InGame(Box::new(session)); + self.last_peer_activity = std::time::Instant::now(); + self.last_confirmed_tracker = 0; self.status.phase = NetplayPhase::InGame; NetplayTick { active: true, @@ -467,6 +477,15 @@ impl NetplayUi { }; session.add_local_input(local_buttons); + // Peer liveness (v3.2.0): progress is a newly confirmed frame, which + // needs the peer's input. Five seconds without one while the session + // is stalled ends it with a reason instead of freezing silently. + if let Some(confirmed) = session.last_confirmed_frame() + && confirmed > self.last_confirmed_tracker + { + self.last_confirmed_tracker = confirmed; + self.last_peer_activity = std::time::Instant::now(); + } match session.advance(nes) { Ok(AdvanceOutcome { produced_frame, @@ -474,6 +493,15 @@ impl NetplayUi { resimulated_frames, .. }) => { + if !produced_frame + && self.last_peer_activity.elapsed() > std::time::Duration::from_secs(5) + { + self.fail("peer connection lost (no data for several seconds)".to_string()); + return NetplayTick { + active: false, + produced_frame: false, + }; + } self.status.phase = NetplayPhase::InGame; self.status.is_host = self.is_host; self.status.current_frame = session.current_frame(); diff --git a/crates/rustynes-frontend/src/present_buffer.rs b/crates/rustynes-frontend/src/present_buffer.rs index a5524b1ae..35bbd5878 100644 --- a/crates/rustynes-frontend/src/present_buffer.rs +++ b/crates/rustynes-frontend/src/present_buffer.rs @@ -156,9 +156,15 @@ impl PresentBuffer { self.generation.fetch_add(1, Ordering::Relaxed); } - /// Consumer: if a new frame was published since the last call, swap it - /// into the front slot and copy it into `out` (the present-staging buffer - /// the GPU uploads). + /// Consumer: if a new frame was published since the last call, hand it to + /// `out` (the present-staging buffer the GPU uploads). + /// + /// v3.2.0: by SWAPPING buffers, not copying 240 KiB. The fresh frame's + /// buffer moves into `out`, and `out`'s old buffer becomes the new front + /// slot. Nothing reads the front slot: `publish` writes only `back`, and + /// the next `take_into` reads `ready`, which a publish has refilled by + /// then. So the caller always receives the newest frame, and its old + /// buffer is recycled as storage (its contents are never presented). /// /// Returns `Some(stamp)` when `out` was refreshed, where `stamp` is the /// instant the emulation thread **published that frame** — carried per slot @@ -186,9 +192,9 @@ impl PresentBuffer { // Swap front <-> ready so the producer's next `publish` reuses our old // front as its back, and we now own the freshest frame in `ready`. slots.index = Self::pack(ready, front, back); - out.clear(); - out.extend_from_slice(&slots.bufs[ready]); - slots.stamps[ready] + let stamp = slots.stamps[ready]; + std::mem::swap(&mut slots.bufs[ready], out); + stamp } /// True once at least one frame has been published (so the present path diff --git a/crates/rustynes-frontend/src/session_policy.rs b/crates/rustynes-frontend/src/session_policy.rs index 553fbf951..ae722068f 100644 --- a/crates/rustynes-frontend/src/session_policy.rs +++ b/crates/rustynes-frontend/src/session_policy.rs @@ -30,12 +30,15 @@ //! | --- | --- | --- | --- | //! | Reset, Power Cycle | refused | refused | allowed | //! | disk swap / insert / eject | refused | refused | allowed | +//! | Datach barcode scan | refused | refused | allowed | //! | state load | refused | refused | refused | //! //! Reset and Power Cycle stay legal in hardcore because rcheevos treats them //! as legitimate (they are what a real console offers); a state load is the //! one thing hardcore exists to forbid. A disk swap is an ordinary act of -//! play on a real Famicom, so hardcore allows it too. +//! play on a real Famicom, so hardcore allows it too, and so is swiping a +//! card through a Datach (v3.2.0): a scan changes the machine through the +//! cartridge's barcode reader, which neither a movie nor netplay carries. //! //! When more than one owner refuses, the hardcore reason is reported first //! (it is the one the player cannot end on their own), then the movie, then @@ -53,6 +56,8 @@ pub enum TimelineAction { PowerCycle, /// Any FDS disk change: cycle sides, insert a specific side, or eject. DiskSwap, + /// v3.2.0 — a barcode swiped through the Datach reader (mapper 157). + BarcodeScan, /// Restoring a save state (slot, manager, browser grid, or a script). LoadState, } @@ -65,6 +70,7 @@ impl TimelineAction { Self::Reset => "Reset", Self::PowerCycle => "Power Cycle", Self::DiskSwap => "Disk swap", + Self::BarcodeScan => "Barcode scan", Self::LoadState => "Load state", } } @@ -129,10 +135,11 @@ pub const fn refusal(action: TimelineAction, owners: SessionOwners) -> Option), + /// v3.2.0 — swipe this barcode (the digits as typed) through the Datach + /// reader (mapper 157). Validated again at the core boundary. + ScanBarcode(String), /// v1.0.0 — capture a screenshot of the current framebuffer (native). Screenshot, /// v1.0.0 — copy the current framebuffer to the system clipboard (native; @@ -293,6 +296,11 @@ pub struct UiShell { /// Toggled by the freed backtick (`` ` ``) key, which formerly toggled the /// (now-removed) debugger toolbar HUD. pub ra_detail: bool, + /// v3.2.0 — whether the Datach barcode window is open. + pub show_barcode: bool, + /// v3.2.0 — the digits typed into the Datach barcode window. Kept between + /// openings so the same card can be swiped again. + pub barcode_input: String, } /// v1.5.0 "Lens" Workstream I9 — selects which device the Keyboard Shortcuts @@ -366,6 +374,8 @@ impl UiShell { active_slot: 0, shortcuts_device: ShortcutsDevice::default(), ra_detail: false, + show_barcode: false, + barcode_input: String::new(), } } @@ -412,6 +422,9 @@ pub struct ShellFrame<'a> { /// v1.0.0 — whether the loaded game is a Vs. System title (enables the /// "Insert Coin" item). pub vs_system: bool, + /// v3.2.0 — whether the cartridge has a barcode reader (the Datach, + /// mapper 157). Shows the Emulation menu's "Scan Barcode..." item. + pub barcode_reader: bool, /// v1.0.0 — the human-readable mapper name (empty when unavailable). pub mapper_label: &'a str, /// v1.0.0 — the region label (`"NTSC"` / `"PAL"` / `"Dendy"`). @@ -495,6 +508,7 @@ impl UiShell { self.welcome_modal(&ctx, config); about_window(&ctx, &mut self.show_about); self.shortcuts_window(&ctx, config); + self.barcode_window(&ctx, frame, &mut out); #[cfg(not(target_arch = "wasm32"))] crate::about_fx::render(&ctx); @@ -1043,6 +1057,24 @@ impl UiShell { }); }); } + // v3.2.0 — the Datach barcode reader (mapper 157 only). + // Locked like the disk controls: a scan changes the + // machine outside the controller input a movie or a + // netplay session carries. The window says why. + if frame.barcode_reader { + ui.separator(); + if ui + .add_enabled( + disk_interactive, + egui::Button::new(ic(glyph::BARCODE, crate::t!(ShellScanBarcode))), + ) + .on_disabled_hover_text(crate::t!(ShellBarcodeLocked)) + .clicked() + { + self.show_barcode = true; + ui.close(); + } + } }); // ----- View ----- @@ -2440,6 +2472,78 @@ fn about_window(ctx: &egui::Context, open: &mut bool) { crate::about_fx::pump(ctx); } +/// The player-facing reason a typed barcode is not a valid JAN / EAN code, +/// in the current locale (v3.2.0). +#[must_use] +pub fn barcode_error_text(err: rustynes_core::BarcodeError) -> String { + use rustynes_core::BarcodeError as E; + match err { + E::Empty => crate::t!(ShellBarcodeNeedDigits).to_string(), + E::NotADigit { index, found } => crate::tf!(ShellBarcodeNotDigit, index + 1, found), + E::WrongLength(n) => crate::tf!(ShellBarcodeWrongLength, n), + E::BadCheckDigit { expected, found } => crate::tf!(ShellBarcodeBadCheck, found, expected), + // `BarcodeError` is not `#[non_exhaustive]`; this arm is unreachable + // today and keeps the text honest if a variant is added. + #[allow(unreachable_patterns)] + other => other.to_string(), + } +} + +impl UiShell { + /// v3.2.0 — the Datach barcode window: a digits field, a live validity + /// line (the same check the core applies at its boundary), and a Scan + /// button that dispatches [`MenuAction::ScanBarcode`]. Shown only while a + /// cartridge with a reader is loaded. Under a movie or netplay the field + /// stays editable but Scan is disabled, with the reason shown: a scan is + /// recorded by neither, so it would silently desynchronise them. + fn barcode_window( + &mut self, + ctx: &egui::Context, + frame: &ShellFrame<'_>, + out: &mut ShellOutput, + ) { + if !self.show_barcode || !frame.barcode_reader { + return; + } + let locked = frame.movie_recording || frame.movie_playing || frame.netplay_active; + let mut open = self.show_barcode; + let input = &mut self.barcode_input; + egui::Window::new(crate::t!(ShellBarcodeTitle)) + .open(&mut open) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + ui.label(crate::t!(ShellBarcodePrompt)); + let field = ui.add( + egui::TextEdit::singleline(input) + .hint_text("4901234567894") + .char_limit(20), + ); + let parsed = rustynes_core::Barcode::parse(input); + // Say why Scan is disabled once something has been typed. + if let Err(e) = parsed + && !input.trim().is_empty() + { + ui.colored_label(ui.visuals().warn_fg_color, barcode_error_text(e)); + } + if locked { + ui.colored_label(ui.visuals().warn_fg_color, crate::t!(ShellBarcodeLocked)); + } + let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let scan = ui + .add_enabled( + parsed.is_ok() && !locked, + egui::Button::new(crate::t!(ShellBarcodeScan)), + ) + .clicked(); + if (scan || enter) && parsed.is_ok() && !locked { + out.action = Some(MenuAction::ScanBarcode(input.trim().to_string())); + } + }); + self.show_barcode = open; + } +} + /// Render the keyboard-shortcuts window. impl UiShell { /// v1.5.0 "Lens" Workstream I9 — render the Keyboard Shortcuts window from @@ -2703,6 +2807,21 @@ mod tests { assert!(!shell.show_welcome); } + /// v3.2.0 — each way a typed barcode can be wrong has its own visible + /// reason (English, the default locale), with the 1-based character + /// position and the check digit the other digits need. + #[test] + fn every_barcode_refusal_names_its_reason() { + let text = |s: &str| barcode_error_text(rustynes_core::Barcode::parse(s).unwrap_err()); + assert_eq!(text(""), "Enter 13 or 8 digits."); + assert_eq!(text("12a"), "Character 3 ('a') is not a digit."); + assert_eq!(text("1234567"), "7 digits: a JAN/EAN barcode has 13 or 8."); + assert_eq!( + text("4006381333932"), + "The check digit is 2; these digits need 1." + ); + } + #[test] fn status_message_expires() { let msg = StatusMessage::new("x", egui::Color32::WHITE, Duration::from_millis(0)); diff --git a/crates/rustynes-frontend/src/wasm_cheevos.rs b/crates/rustynes-frontend/src/wasm_cheevos.rs index 1bff0464a..023d28dae 100644 --- a/crates/rustynes-frontend/src/wasm_cheevos.rs +++ b/crates/rustynes-frontend/src/wasm_cheevos.rs @@ -80,10 +80,17 @@ extern "C" { #[wasm_bindgen(catch, js_name = ra_load_game)] async fn ra_load_game(rom: &[u8]) -> Result; - /// Drive one frame of achievement logic. `read_byte` is a JS callback that - /// reads one NES CPU-bus byte. Returns a JSON array of this frame's events. + /// Drive one frame of achievement logic. `read_byte` reads one NES CPU-bus + /// byte. Returns a JSON array of this frame's events. + /// + /// `read_byte` is a wasm-bindgen STACK closure (`&mut dyn FnMut`): JS gets + /// a function that is valid only until this call returns, and wasm-bindgen + /// invalidates it then (a later call throws instead of reaching freed + /// memory). That is exactly how `ra_glue.js` uses it -- installed for the + /// one `rc_client_do_frame` and cleared in a `finally` -- so the borrow of + /// the emulator needs no `'static` and no `unsafe` (v3.2.0). #[wasm_bindgen(js_name = ra_do_frame)] - fn ra_do_frame(read_byte: &js_sys::Function) -> String; + fn ra_do_frame(read_byte: &mut dyn FnMut(u32) -> u32) -> String; /// Tear down the client + module. #[wasm_bindgen(js_name = ra_shutdown)] @@ -181,38 +188,20 @@ impl BrowserRaSession { /// events raised this frame (for HUD toasts). No-op (empty) when the module /// is not initialized. /// - /// The `read` closure is wrapped in a JS function for the side module's - /// read-memory trampoline; it is only invoked synchronously inside the - /// `ra_do_frame` call, so the borrow never escapes. + /// `read` reaches the side module's read-memory trampoline as a + /// wasm-bindgen stack closure, valid only for the synchronous + /// `ra_do_frame` call, so the borrow cannot escape -- checked by the + /// compiler and enforced by wasm-bindgen, not argued in a comment. pub fn do_frame(&self, read: &mut dyn FnMut(u16) -> u8) -> Vec { if !self.initialized { return Vec::new(); } - // `Closure::new` requires a `'static` body, but our `read` borrows the - // emulator and only needs to be valid for this synchronous call. - // `ra_do_frame` invokes the JS callback ONLY synchronously while we are - // inside it (the browser is single-threaded and the side module never - // retains the function pointer past the call), then we drop the closure - // before returning. So we erase the borrow's lifetime to `'static`, - // matching the native bridge's `ReadGuard` idiom. - // - // The attribute goes ABOVE the SAFETY note, not between it and the `let`. - // `clippy::undocumented_unsafe_blocks` looks at the line immediately - // preceding the statement, so an attribute sitting there hides the note - // and the lint fires -- measured both ways rather than assumed. (Review - // on #423 suggested the note alone would do; it does, but only in this - // order.) - #[allow(unsafe_code)] // localized lifetime erasure; see SAFETY below - // SAFETY: the erased pointer is only dereferenced by `ra_do_frame` - // synchronously below, and `closure` is dropped at the end of this - // function, so the call can never outlive the real borrow. - let read_static: &mut (dyn FnMut(u16) -> u8 + 'static) = - unsafe { core::mem::transmute(read) }; - let closure = Closure:: u32>::new(move |addr: u32| { - u32::from(read_static((addr & 0xffff) as u16)) - }); - let json = ra_do_frame(closure.as_ref().unchecked_ref()); - drop(closure); + // A stack closure borrowing `read` for this synchronous call only (see + // `ra_do_frame`). Until v3.2.0 this was a `'static` `Closure` over a + // `transmute`d `read`, sound by argument (the callback is used only + // inside the call) but not checkable by the compiler; now it is. + let mut read_u32 = |addr: u32| u32::from(read((addr & 0xffff) as u16)); + let json = ra_do_frame(&mut read_u32); parse_unlock_titles(&json) } diff --git a/crates/rustynes-frontend/src/web_battery.rs b/crates/rustynes-frontend/src/web_battery.rs index 138586e0a..4c9f7c219 100644 --- a/crates/rustynes-frontend/src/web_battery.rs +++ b/crates/rustynes-frontend/src/web_battery.rs @@ -283,7 +283,7 @@ impl WebBattery { let bytes = match in_flight { Some(pending) if force => (live != pending.as_slice()).then(|| live.to_vec())?, Some(_) => return None, - None => policy.due(live, force)?, + None => policy.due(live, nes.is_save_data_dirty(), force)?, }; *in_flight = Some(bytes.clone()); Some(WebBatteryWrite { sha: *sha, bytes }) diff --git a/crates/rustynes-gamedb/src/lib.rs b/crates/rustynes-gamedb/src/lib.rs index 5aa73e16e..29360afe2 100644 --- a/crates/rustynes-gamedb/src/lib.rs +++ b/crates/rustynes-gamedb/src/lib.rs @@ -539,7 +539,7 @@ pub fn apply_header_overrides(bytes: &mut [u8], entry: &GameDbEntry) -> bool { } if let Some(submapper) = entry.submapper - && submapper != 0 + && (submapper != 0 || is_nes2) { // The submapper field only exists in NES 2.0 headers. A correction that // sets a non-zero submapper on an iNES-1.0 ROM (e.g. Seicross, whose @@ -547,6 +547,13 @@ pub fn apply_header_overrides(bytes: &mut [u8], entry: &GameDbEntry) -> bool { // CHR-disable copy-protection variant) must first PROMOTE the header to // NES 2.0 by setting byte-7 bits 2-3 to `10`, otherwise the core's iNES // path forces submapper 0 and the override is silently dropped. + // + // A correction TO submapper 0 is applied only to an NES 2.0 header (an + // iNES-1.0 header already reads as 0, so promoting it would change + // nothing but the header). It can only come from the user overlay: + // `load_time_entry` clears the vendored table's submapper for NES 2.0 + // headers, and the overlay's `0` is a deliberate correction + // (`parse_overlay_row`). Before v3.2.0 such a correction was dropped. if !is_nes2 { bytes[7] = (bytes[7] & 0xF3) | 0x08; // Promotion reinterprets header fields the iNES-1.0 spec leaves @@ -710,15 +717,20 @@ fn ines1_as_nes2(bytes: &[u8], region: Region) -> Option<[u8; 16]> { }; let chr_ram = if h.chr_size == 0 { SHIFT_8K } else { 0 }; - for (byte10, byte11) in [(prg_ram, chr_ram), (0, 0)] { - header[10] = byte10; - header[11] = byte11; - let mut image = bytes.to_vec(); - image[..16].copy_from_slice(&header); - if let Ok(promoted) = parse(&image) - && same_board(&original, &promoted) - { - return Some(header); + // Lowest submapper first, so a board every submapper can express keeps 0. + // See `same_board` for why the submapper itself is not compared. + let mut image = bytes.to_vec(); + for sub in 0..16 { + header[8] = sub << 4; + for (byte10, byte11) in [(prg_ram, chr_ram), (0, 0)] { + header[10] = byte10; + header[11] = byte11; + image[..16].copy_from_slice(&header); + if let Ok(promoted) = parse(&image) + && same_board(&original, &promoted) + { + return Some(header); + } } } None @@ -744,8 +756,15 @@ fn same_board( ), ) -> bool { let ((ca, ma), (cb, mb)) = (a, b); + // No `submapper` comparison, deliberately (v3.2.0). For an iNES 1.0 + // original the parsed submapper is always 0, so against the old sub-0 + // candidate the check could never fail; and some boards read an iNES 1.0 + // header as a NON-zero NES 2.0 submapper -- an iNES 1.0 mapper 6 is FFE + // latch mode 1, which NES 2.0 spells submapper 1 -- so the check refused + // the one candidate that IS the same board. `ines1_as_nes2` therefore + // tries every submapper, lowest first, and the board is identified by + // what it does: the state, the debug description and the sizes below. ca.mapper_id == cb.mapper_id - && ca.submapper == cb.submapper && ca.mirroring == cb.mirroring && ca.console_type == cb.console_type && ca.vs_ppu_type == cb.vs_ppu_type @@ -760,7 +779,7 @@ fn same_board( && ma.sram().len() == mb.sram().len() && ma.save_data().len() == mb.save_data().len() && ma.save_state() == mb.save_state() - && format!("{:?}", ma.debug_info()) == format!("{:?}", mb.debug_info()) + && ma.debug_info() == mb.debug_info() } /// Stage one of the load-time correction path every platform shares (v2.9.8): @@ -967,17 +986,31 @@ pub fn rom_crc32_full(bytes: &[u8]) -> Option { /// IEEE CRC-32 (reflected, polynomial `0xEDB8_8320`) — the zip/PNG CRC, matching /// TetaNES' `compute_crc32`. Table-less; runs once per ROM load. -fn crc32(data: &[u8]) -> u32 { - let mut crc = 0xFFFF_FFFFu32; - for &byte in data { - crc ^= u32::from(byte); - for _ in 0..8 { +const CRC32_TABLE: [u32; 256] = { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + #[allow(clippy::cast_possible_truncation)] + let mut crc = i as u32; + let mut j = 0; + while j < 8 { crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + j += 1; } + table[i] = crc; + i += 1; + } + table +}; + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc = (crc >> 8) ^ CRC32_TABLE[((crc ^ u32::from(byte)) & 0xFF) as usize]; } !crc } @@ -1335,6 +1368,34 @@ mod tests { assert!(!is_multi_region_title("Gyromite (World).nes")); } + /// A correction TO submapper 0 lands on an NES 2.0 header (it can only come + /// from the user overlay, whose `0` is deliberate). Before v3.2.0 any + /// sub-0 correction was skipped, so an overlay could not undo a wrong + /// non-zero submapper in an NES 2.0 dump. + #[test] + fn sub_zero_correction_applies_to_an_nes2_header() { + let mut rom = vec![0u8; 16 + 32 * 1024 + 8 * 1024]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 2; + rom[5] = 1; + rom[6] = 0xB1; // mapper 185, vertical + rom[7] = 0xB8; // NES 2.0 + rom[8] = 0x40; // submapper 4 + let entry = GameDbEntry { + crc: 0, + region: None, + mapper: None, + submapper: Some(0), + mirroring: None, + title: "overlay sub-0 correction".into(), + }; + assert!(apply_header_overrides(&mut rom, &entry)); + assert_eq!(rom[8] >> 4, 0, "submapper corrected to 0"); + assert_eq!(rom[7] & 0x0C, 0x08, "still NES 2.0"); + // Idempotent: applying it again changes nothing. + assert!(!apply_header_overrides(&mut rom, &entry)); + } + #[test] fn submapper_override_promotes_ines1_to_nes2() { // A non-zero submapper correction on an iNES-1.0 ROM must promote the diff --git a/crates/rustynes-gfx-shaders/src/crt_guest.wgsl b/crates/rustynes-gfx-shaders/src/crt_guest.wgsl index a7e9ae7f3..c1c514608 100644 --- a/crates/rustynes-gfx-shaders/src/crt_guest.wgsl +++ b/crates/rustynes-gfx-shaders/src/crt_guest.wgsl @@ -101,11 +101,11 @@ fn glow(suv: vec2) -> vec3 { let tx = 1.0 / 256.0; let ty = 1.0 / 240.0; var acc = vec3(0.0, 0.0, 0.0); - acc = acc + textureSample(nes_tex, nes_smp, suv).rgb * 0.4; - acc = acc + textureSample(nes_tex, nes_smp, suv + vec2( 2.0 * tx, 0.0)).rgb * 0.15; - acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(-2.0 * tx, 0.0)).rgb * 0.15; - acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(0.0, 2.0 * ty)).rgb * 0.15; - acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(0.0, -2.0 * ty)).rgb * 0.15; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb * 0.4; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2( 2.0 * tx, 0.0), 0.0).rgb * 0.15; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(-2.0 * tx, 0.0), 0.0).rgb * 0.15; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(0.0, 2.0 * ty), 0.0).rgb * 0.15; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(0.0, -2.0 * ty), 0.0).rgb * 0.15; return acc; } @@ -129,7 +129,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } let suv = vec2(uv.x * u.crop.z + u.crop.w, uv.y * u.crop.x + u.crop.y); - var rgb = textureSample(nes_tex, nes_smp, suv).rgb; + var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb; rgb = pow(rgb, vec3(2.2)); // to linear // Scanline beam in source-row space. diff --git a/crates/rustynes-gfx-shaders/src/crt_royale.wgsl b/crates/rustynes-gfx-shaders/src/crt_royale.wgsl index 345cb4803..f47b25007 100644 --- a/crates/rustynes-gfx-shaders/src/crt_royale.wgsl +++ b/crates/rustynes-gfx-shaders/src/crt_royale.wgsl @@ -134,7 +134,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { for (var i = -1; i <= 1; i = i + 1) { let ry = centre + f32(i); let sample_v = (ry + 0.5) / rows; - var c = textureSample(nes_tex, nes_smp, vec2(suv.x, sample_v)).rgb; + var c = textureSampleLevel(nes_tex, nes_smp, vec2(suv.x, sample_v), 0.0).rgb; c = pow(c, vec3(gamma_in)); // to linear // Beam brightness scales its own width: brighter rows spread more. let lum = dot(c, vec3(0.299, 0.587, 0.114)); diff --git a/crates/rustynes-gfx-shaders/src/lib.rs b/crates/rustynes-gfx-shaders/src/lib.rs index 0f75b6979..b15c6c858 100644 --- a/crates/rustynes-gfx-shaders/src/lib.rs +++ b/crates/rustynes-gfx-shaders/src/lib.rs @@ -109,7 +109,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } // Overscan crop: remap the visible V (crop.xy) and U (crop.zw) ranges. let suv = vec2(in.uv.x * u.crop.z + u.crop.w, in.uv.y * u.crop.x + u.crop.y); - var rgb = textureSample(nes_tex, nes_smp, suv).rgb; + var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb; // v2.2.8 'Aperture II': do the scanline + mask *darkening* in LINEAR light so a // scanline valley at 50% is 50% of the LINEAR luminance (perceptually correct), @@ -277,7 +277,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let fs = f32(s); let samp_col = col + fs / f32(SUB); let uvx = samp_col * texel; - let src = textureSample(nes_tex, nes_smp, vec2(uvx, suv.y)).rgb; + let src = textureSampleLevel(nes_tex, nes_smp, vec2(uvx, suv.y), 0.0).rgb; let yiq = rgb2yiq(src); // Subcarrier phase at this composite sample. let ph = TAU * (samp_col / f32(SUB) + phase0) ; diff --git a/crates/rustynes-gfx-shaders/src/megatron.wgsl b/crates/rustynes-gfx-shaders/src/megatron.wgsl index 6fd917cb1..4bd781588 100644 --- a/crates/rustynes-gfx-shaders/src/megatron.wgsl +++ b/crates/rustynes-gfx-shaders/src/megatron.wgsl @@ -115,7 +115,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } let suv = vec2(uv.x * u.crop.z + u.crop.w, uv.y * u.crop.x + u.crop.y); - var rgb = textureSample(nes_tex, nes_smp, suv).rgb; + var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb; rgb = pow(rgb, vec3(2.4)); // to linear (Megatron uses ~2.4 EOTF) // Gaussian beam in source-row space, scaled by luminance (thicker beam for diff --git a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl index eae25cd7b..22187b3dc 100644 --- a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl +++ b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl @@ -94,11 +94,7 @@ fn pmod(x: i32, n: i32) -> i32 { // Reconstruct one normalized composite sample for source column `col` at // sub-sample `sub` (0..8), given the packed index texel and the line phase. -fn composite_at(col: i32, sub: i32, row: i32, line_phase: i32) -> f32 { - let dim = textureDimensions(idx_tex); - let cx = clamp(col, 0, i32(dim.x) - 1); - let cy = clamp(row, 0, i32(dim.y) - 1); - let packed = i32(textureLoad(idx_tex, vec2(cx, cy), 0).r); +fn composite_at_packed(packed: i32, col: i32, sub: i32, line_phase: i32) -> f32 { let index = packed & 0x3F; let emphasis = (packed >> 6) & 0x7; @@ -151,7 +147,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let row = i32(floor(suv.y * rows)); // Per-line phase offset gives the 3-phase dot crawl (params.x is the frame // videoPhase; the row parity contributes the per-line shift). Wrapped - // non-negative for the same edge-consistency reason as `composite_at`. + // non-negative for the same edge-consistency reason as `composite_at_packed`'s caller. let line_phase = pmod(i32(round(u.params.x)) + row * 8, 12); // Centre position in absolute sub-samples: 8 per pixel. @@ -184,11 +180,19 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { var i_acc = 0.0; var q_acc = 0.0; var w_sum = 0.0; + var last_col: i32 = -999999; + var packed: i32 = 0; for (var s = -half; s <= half; s = s + 1) { let abs_sub = centre + f32(s); let col = i32(floor(abs_sub / 8.0)); let sub = i32(floor(abs_sub)) % 8; - let comp = composite_at(col, sub, row, line_phase); + if (col != last_col) { + let cx = clamp(col, 0, i32(dim.x) - 1); + let cy = clamp(row, 0, i32(dim.y) - 1); + packed = i32(textureLoad(idx_tex, vec2(cx, cy), 0).r); + last_col = col; + } + let comp = composite_at_packed(packed, col, sub, line_phase); // Triangular low-pass window. let w = 1.0 - abs(f32(s)) / (f32(half) + 1.0); y_acc = y_acc + comp * w; diff --git a/crates/rustynes-hdpack/Cargo.toml b/crates/rustynes-hdpack/Cargo.toml index 65f439a9a..a0a991abf 100644 --- a/crates/rustynes-hdpack/Cargo.toml +++ b/crates/rustynes-hdpack/Cargo.toml @@ -21,7 +21,7 @@ rustynes-ppu = { workspace = true, features = ["hd-pack"] } # Decode replacement tiles (png), load `.zip` packs (deflate only), and decode # HD-audio OGG tracks (lewton is pure-Rust, so it cross-compiles to Android). png = "0.18" -zip = { version = "8", default-features = false, features = ["deflate"] } +zip = { version = "9", default-features = false, features = ["deflate"] } lewton = "0.10" [dev-dependencies] diff --git a/crates/rustynes-hdpack/src/hdpack.rs b/crates/rustynes-hdpack/src/hdpack.rs index a1d87efa1..66bc396db 100644 --- a/crates/rustynes-hdpack/src/hdpack.rs +++ b/crates/rustynes-hdpack/src/hdpack.rs @@ -567,7 +567,9 @@ impl HdPack { // Find the `hires.txt` entry (allow it to live in a subfolder). let hires_name = (0..archive.len()).find_map(|i| { let e = archive.by_index(i).ok()?; - let name = e.name().to_string(); + // zip 9: `name()` fails only for a name that is neither UTF-8 nor + // CP437; such an entry cannot be `hires.txt`, so skip it. + let name = e.name().ok()?.into_owned(); Path::new(&name) .file_name() .is_some_and(|f| f.eq_ignore_ascii_case("hires.txt")) @@ -2812,7 +2814,7 @@ mod tests { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); for i in 0..archive.len() { let mut entry = archive.by_index(i).unwrap(); - let mut out = std::fs::File::create(dir.join(entry.name())).unwrap(); + let mut out = std::fs::File::create(dir.join(entry.name().unwrap().as_ref())).unwrap(); std::io::copy(&mut entry, &mut out).unwrap(); } let one = 64 * 64 * 4; diff --git a/crates/rustynes-ios/src/ffi.rs b/crates/rustynes-ios/src/ffi.rs index 41e8996b3..22d31fcc7 100644 --- a/crates/rustynes-ios/src/ffi.rs +++ b/crates/rustynes-ios/src/ffi.rs @@ -97,15 +97,16 @@ pub unsafe extern "C" fn rustynes_ios_gfx_render(handle: *mut MetalGfx, fb: *con } // SAFETY: live handle (see above). let gfx = unsafe { &mut *handle }; - let dst = gfx.frame_buf_mut(); - if len != dst.len() { + if len != crate::gfx_metal::FRAME_LEN { return; } - // SAFETY: `fb` points to `len` readable bytes (caller contract); `len` - // equals `dst.len()`, so the copy stays in bounds on both sides. + // SAFETY: `fb` points to `len` readable bytes (caller contract), and + // `len` is exactly one frame. The slice only lives for this call: + // `render` hands it to `write_texture`, which copies it into wgpu's + // staging before returning (v3.2.0; this used to copy it into a reused + // `frame_buf` first, one 240 KiB memcpy per frame for nothing). let src = unsafe { core::slice::from_raw_parts(fb, len) }; - dst.copy_from_slice(src); - gfx.render(); + gfx.render(src); }) } diff --git a/crates/rustynes-ios/src/gfx_metal.rs b/crates/rustynes-ios/src/gfx_metal.rs index 1f32d8cfa..7f9bc1f67 100644 --- a/crates/rustynes-ios/src/gfx_metal.rs +++ b/crates/rustynes-ios/src/gfx_metal.rs @@ -29,6 +29,9 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle, UiKitDisplayHandle, U /// NES framebuffer dimensions (must match `rustynes_mobile::{FRAME_WIDTH,_HEIGHT}`). const NES_W: u32 = 256; const NES_H: u32 = 240; +/// One RGBA8 frame, in bytes: what `rustynes_ios_gfx_render` accepts and +/// `MetalGfx::render` uploads. +pub(crate) const FRAME_LEN: usize = (NES_W * NES_H * 4) as usize; /// NES pixel aspect ratio (8:7) → the displayed image is wider than 256:240. const PAR_W: f32 = NES_W as f32 * 8.0 / 7.0; const IMG_ASPECT: f32 = PAR_W / NES_H as f32; @@ -92,10 +95,6 @@ pub struct MetalGfx { hd_bind_group: Option, /// The current HD-pack texture dimensions `(w, h)`, or `(0, 0)` if none. hd_dims: (u32, u32), - /// Reused framebuffer staging buffer — the FFI copies each frame's bytes in - /// place instead of allocating a fresh `Vec` per frame. Length is fixed at - /// `NES_W*NES_H*4`. - frame_buf: Vec, /// The host-owned `UIView` (`MTKView`) pointer the surface was built from. /// The Swift side retains the view for the surface's whole lifetime; we keep /// it only as a lifetime marker so the field drop-order intent is explicit. @@ -456,7 +455,6 @@ impl MetalGfx { hd_texture: None, hd_bind_group: None, hd_dims: (0, 0), - frame_buf: vec![0u8; (NES_W * NES_H * 4) as usize], _view: view, }; gfx.write_uniforms(); @@ -531,12 +529,6 @@ impl MetalGfx { .write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&u)); } - /// The reused frame staging buffer; the FFI copies the incoming RGBA bytes - /// into this (no per-frame `Vec`). Length is fixed at `NES_W*NES_H*4`. - pub fn frame_buf_mut(&mut self) -> &mut [u8] { - &mut self.frame_buf - } - /// Upload one 256×240 palette-index frame (`NES_W*NES_H*2` little-endian `u16` /// bytes — `(emphasis << 6) | colour`) plus the NTSC `phase`, for the Bisqwit /// pass. The host calls this each frame only while the Bisqwit filter is active. @@ -567,9 +559,12 @@ impl MetalGfx { self.write_uniforms(); } - /// Upload the staged 256×240 RGBA frame and present it. Tolerates a transient - /// `Lost`/`Outdated` surface by reconfiguring and skipping the frame. - pub fn render(&mut self) { + /// Upload one 256×240 RGBA frame (`fb`, [`FRAME_LEN`] bytes) and present it. + /// Tolerates a transient `Lost`/`Outdated` surface by reconfiguring and + /// skipping the frame. `fb` is uploaded straight from the caller's buffer + /// (v3.2.0; a reused staging copy before). + pub fn render(&mut self, fb: &[u8]) { + debug_assert_eq!(fb.len(), FRAME_LEN, "one RGBA frame"); // Re-write the active filter's uniform each frame so the normal path is // self-correcting after an HD-pack frame wrote a neutral (plain-blit) // uniform into the shared buffer (v1.9.5). One 64-byte write — negligible. @@ -581,7 +576,7 @@ impl MetalGfx { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - &self.frame_buf, + fb, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(NES_W * 4), diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info index c5c2500c0..847c29def 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.1.0" +display_version = "v3.2.0" categories = "Emulator" # Hardware Information diff --git a/crates/rustynes-libretro/src/abi_tests.rs b/crates/rustynes-libretro/src/abi_tests.rs index a0e17d81c..5076bdaa8 100644 --- a/crates/rustynes-libretro/src/abi_tests.rs +++ b/crates/rustynes-libretro/src/abi_tests.rs @@ -981,6 +981,87 @@ fn a_mid_run_round_trip_serializes_like_a_straight_run() { ); } +/// v3.2.0: states are SLIM (`Nes::snapshot_core_into_slim`): no 240 KiB +/// framebuffer, which the next `retro_run` repaints. Restored into a FRESH +/// instance -- the run-ahead "second instance" shape, and a framebuffer that +/// starts blank -- the next frame's picture and state must equal a straight +/// run's. A pixel the frame did not repaint would stay blank and differ. +#[test] +fn a_slim_state_restored_into_a_fresh_instance_draws_the_same_next_frame() { + let _frontend = frontend(); + KEEP_FRAME.store(true, SeqCst); + assert!(load(NESTEST, true)); + let size = serialize_size(); + for _ in 0..10 { + run_frame(); + } + let mut mid = vec![0_u8; size]; + assert!(serialize(&mut mid)); + run_frame(); + let straight_frame = held(&LAST_FRAME).clone(); + let mut straight = vec![0_u8; size]; + assert!(serialize(&mut straight)); + unload(); + + assert!(load(NESTEST, true)); + assert!( + unserialize(&mid), + "a slim state loads into a fresh instance" + ); + run_frame(); + let restored_frame = held(&LAST_FRAME).clone(); + let mut restored = vec![0_u8; size]; + assert!(serialize(&mut restored)); + unload(); + KEEP_FRAME.store(false, SeqCst); + + assert!(!straight_frame.is_empty(), "fixture: a frame was captured"); + assert!( + restored_frame == straight_frame, + "the frame after a slim restore differs from a straight run's" + ); + assert!(restored == straight, "and so does the state"); + assert!( + size < 100_000, + "states are slim (no framebuffer): {size} bytes" + ); +} + +/// v3.2.0: a FULL state -- with the framebuffer, as v3.1.0's core wrote -- +/// still loads: the PPU section's version byte marks which encoding it is. +/// Built here with `Nes::snapshot_core_into` on the same ROM, restored +/// through `retro_unserialize`, then read back: the state the core holds is +/// the one the full blob described. +#[test] +fn a_full_size_state_still_loads() { + let _frontend = frontend(); + assert!(load(NESTEST, true)); + let size = serialize_size(); + + let mut nes = rustynes_core::Nes::from_rom(NESTEST).expect("nestest parses"); + for _ in 0..10 { + nes.run_frame(); + } + let mut full = Vec::new(); + nes.snapshot_core_into(&mut full); + let mut slim = Vec::new(); + nes.snapshot_core_into_slim(&mut slim); + assert!( + full.len() > slim.len() + 200_000, + "fixture: the full blob carries the framebuffer" + ); + + assert!(unserialize(&full), "a full-size state is accepted"); + let mut back = vec![0_u8; size]; + assert!(serialize(&mut back)); + unload(); + assert_eq!( + &back[..slim.len()], + &slim[..], + "the restored machine is the one the full state described" + ); +} + #[cfg(target_arch = "x86_64")] #[test] fn a_refused_unserialize_logs_the_reason() { diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs index 7cdfadf52..b0e70e94d 100644 --- a/crates/rustynes-libretro/src/lib.rs +++ b/crates/rustynes-libretro/src/lib.rs @@ -38,7 +38,10 @@ //! `retro_get_memory_data`/`_size` pointer API and the modern //! `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` descriptor registration RetroAchievements' //! `rcheevos` prefers. Save states serialize statically sized binary blobs natively -//! through `Nes::snapshot_core_into` (single console) or `VsDualSystem::snapshot_into` +//! through `Nes::snapshot_core_into_slim` (single console; v3.2.0 -- the PPU is +//! encoded without its 240 KiB framebuffer, which the next `retro_run` repaints +//! before anything reads it; a full-size state, framebuffer included, is still +//! accepted) or `VsDualSystem::snapshot_into` //! (dual cabinet) — this is also what RetroArch's own generic rollback netplay and //! movie/rewind features ride on; RustyNES's bespoke `rustynes-netplay` P2P crate is //! intentionally NOT linked into this core, since it would be redundant with (and @@ -1303,15 +1306,15 @@ impl RustyNesLibretro { /// AUD-17). The encoder is the one `on_serialize` calls, so the size is /// unchanged; what changes is that the first `retro_serialize` -- the /// first frame of run-ahead or rollback netplay -- no longer grows the - /// buffer from nothing during gameplay (about 260 KB; 645 KB for a - /// cabinet). The single console also reserves the device headroom, so a + /// buffer from nothing during gameplay (about 260 KB before v3.2.0's slim + /// states, about 16 KB since; 645 KB for a cabinet). The single console also reserves the device headroom, so a /// Zapper attached later does not reallocate it either. Pinned by /// `abi_tests::no_serialize_reallocates_the_buffer_sized_at_load`. fn size_serialize_buffer(&mut self) { use rustynes_core::SAVE_STATE_DEVICE_HEADROOM as HEADROOM; if let Some(nes) = self.nes.as_ref() { self.serialize_buffer.clear(); - nes.snapshot_core_into(&mut self.serialize_buffer); + nes.snapshot_core_into_slim(&mut self.serialize_buffer); self.serialize_size = self.serialize_buffer.len() + HEADROOM; self.serialize_buffer.reserve(HEADROOM); } else if let Some(dual) = self.dual.as_mut() { @@ -2684,7 +2687,7 @@ impl Core for RustyNesLibretro { let capacity = core.serialize_buffer.capacity(); if let Some(nes) = core.nes.as_ref() { core.serialize_buffer.clear(); - nes.snapshot_core_into(&mut core.serialize_buffer); + nes.snapshot_core_into_slim(&mut core.serialize_buffer); } else if let Some(dual) = core.dual.as_mut() { dual.snapshot_into(&mut core.serialize_buffer); } else { diff --git a/crates/rustynes-mobile/Cargo.toml b/crates/rustynes-mobile/Cargo.toml index 0efa62ea2..e302c48ee 100644 --- a/crates/rustynes-mobile/Cargo.toml +++ b/crates/rustynes-mobile/Cargo.toml @@ -71,7 +71,8 @@ uniffi = { version = "0.32", features = ["cli"] } # v1.8.5 — accept still-compressed `.zip` ROMs (extract the first NES entry), the # same convenience the desktop has. `deflate` is the only codec real NES zips use; # default features off keeps bzip2/zstd/aes out of the mobile binary. -zip = { version = "8", default-features = false, features = ["deflate"] } +zip = { version = "9", default-features = false, features = ["deflate"] } +getrandom = "0.2" [dev-dependencies] # v1.8.7 Phase B — the room-code loopback test stands up an in-process WebSocket diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs index abf1b144d..35e382ce0 100644 --- a/crates/rustynes-mobile/src/lib.rs +++ b/crates/rustynes-mobile/src/lib.rs @@ -1722,9 +1722,12 @@ impl NesController { /// v2.9.7 — the FDS disk image as it stands, including anything the game /// has written to it, in the headerless `.fds` layout. The host writes it - /// to its own save file and, on the next launch, loads THAT file as the - /// ROM, which is how a disk game's progress persists (FDS games save to - /// the disk; there is no battery RAM). Empty for a cartridge or NSF. + /// to its own save file (keyed by [`Self::rom_identity`]) and, on the next + /// launch, builds from the pristine image and passes the file to + /// [`Self::boot_saved_disk`], which is how a disk game's progress persists + /// (FDS games save to the disk; there is no battery RAM). Loading the saved + /// file AS the ROM instead would change the game's identity (v3.2.0). + /// Empty for a cartridge or NSF. pub fn disk_image_bytes(&self) -> Vec { self.lock().nes.disk_image_bytes() } @@ -1741,6 +1744,60 @@ impl NesController { self.lock().nes.clear_disk_dirty(); } + /// v3.2.0 — boot the host's SAVED copy of the loaded FDS disk (the + /// [`Self::disk_image_bytes`] it wrote last session) in place of the + /// pristine image, KEEPING the pristine image's identity. + /// + /// This is the desktop's `.fds.sav` design (`boot_saved_disk` in the + /// frontend, NF-17). A host that instead loaded the saved file as the ROM + /// would change [`Self::rom_identity`] at the game's first disk save, + /// because the core hashes the bytes it boots: every store keyed on it -- + /// save states, cheats, RA progress, overrides -- would be orphaned, and + /// the save file itself (keyed by identity) would never be found again. + /// + /// Call it right after building the controller from the pristine image, + /// with the file keyed by that controller's `rom_identity()`. The BIOS + /// and sample rate are the controller's own. + /// + /// # Errors + /// [`MobileError::RomLoad`] if the loaded console is not an FDS disk, or + /// the saved image does not parse; the pristine disk stays loaded, so the + /// host can log it and play on (the bad file is not overwritten until + /// the game writes to the disk again). + pub fn boot_saved_disk(&self, saved: Vec) -> Result<(), MobileError> { + check_rom_size(&saved)?; + let not_disk = || MobileError::RomLoad { + reason: "the loaded game is not an FDS disk".into(), + }; + // What the parse needs, read under the lock; the parse runs with it + // released so the render and audio paths are not held up by it. + let (bios, pristine, sample_rate) = { + let g = self.lock(); + if g.nes.disk_side_count() == 0 { + return Err(not_disk()); + } + let bios = g.fds_bios.clone().ok_or_else(not_disk)?; + (bios, *g.nes.rom_sha256(), g.sample_rate) + }; + let mut nes = Nes::from_disk_with_sample_rate(&saved, &bios, sample_rate).map_err(|e| { + MobileError::RomLoad { + reason: format!("saved FDS disk: {e}"), + } + })?; + nes.set_rom_identity(pristine); + let mut g = self.lock(); + // Install it only over the disk it was read for: a different game + // loaded while the lock was released must not be replaced. + if *g.nes.rom_sha256() != pristine { + return Err(MobileError::RomLoad { + reason: "a different game was loaded meanwhile".into(), + }); + } + g.nes = nes; + drop(g); + Ok(()) + } + // --- NSF music files (v2.9.7 "Tandem", plan item 6) ----------------- /// v2.9.7 — the number of songs in the loaded NSF; 0 for a cartridge or @@ -2946,14 +3003,18 @@ fn decompress_rom(bytes: Vec) -> Vec { let extracted = (|| { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).ok()?; let idx = (0..archive.len()).find(|&i| { + // zip 9: an entry whose name decodes as neither UTF-8 nor CP437 + // is skipped, the same as an unreadable entry. archive.by_index(i).is_ok_and(|e| { - std::path::Path::new(e.name()) - .extension() - .is_some_and(|ext| { - ["nes", "fds", "unf", "unif", "nsf", "nsfe"] - .iter() - .any(|k| ext.eq_ignore_ascii_case(k)) - }) + e.name().is_ok_and(|n| { + std::path::Path::new(n.as_ref()) + .extension() + .is_some_and(|ext| { + ["nes", "fds", "unf", "unif", "nsf", "nsfe"] + .iter() + .any(|k| ext.eq_ignore_ascii_case(k)) + }) + }) }) })?; let e = archive.by_index(idx).ok()?; @@ -2990,10 +3051,12 @@ fn read_bk2_members(bytes: &[u8]) -> Result<(String, String), MobileError> { let idx = (0..archive.len()) .find(|&i| { archive.by_index(i).is_ok_and(|e| { - std::path::Path::new(e.name()) - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.eq_ignore_ascii_case(wanted)) + e.name().is_ok_and(|name| { + std::path::Path::new(name.as_ref()) + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.eq_ignore_ascii_case(wanted)) + }) }) }) .ok_or_else(|| err(format!(".bk2 is missing '{wanted}'")))?; @@ -3023,9 +3086,7 @@ fn composite_hd_locked(g: &mut Inner) -> Vec { return Vec::new(); } // Snapshot the per-pixel tile source, the CHR (0x0000..0x2000), and the frame. - let hd_tiles = g.nes.hd_tile_source().to_vec(); - let framebuffer = g.nes.framebuffer().to_vec(); - let mut chr = vec![0u8; 0x2000]; + let mut chr = [0u8; 0x2000]; for (addr, slot) in (0u16..0x2000).zip(chr.iter_mut()) { *slot = g.nes.peek_ppu(addr); } @@ -3049,7 +3110,9 @@ fn composite_hd_locked(g: &mut Inner) -> Vec { }; #[cfg(test)] injected_frame_fault(); - comp.composite(&framebuffer, &hd_tiles, &watched, |addr| { + let hd_tiles = g.nes.hd_tile_source(); + let framebuffer = g.nes.framebuffer(); + comp.composite(framebuffer, hd_tiles, &watched, |addr| { chr.get((addr & 0x1FFF) as usize).copied().unwrap_or(0) }) .to_vec() @@ -3158,6 +3221,13 @@ fn nat_phase_detail(phase: &NatPhase) -> String { /// deterministically when the session promotes to `InGame`. fn nondeterministic_seed() -> u64 { use std::time::{SystemTime, UNIX_EPOCH}; + // OS randomness first (v3.2.0): this seeds the room code and, through the + // session RNG, the STUN / TURN transaction ids, which should not be + // guessable. The time-and-address mix below is the fallback only. + let mut buf = [0u8; 8]; + if getrandom::getrandom(&mut buf).is_ok() { + return u64::from_le_bytes(buf); + } // Truncating the 128-bit nanosecond count to 64 bits is intentional — this is // a PRNG seed, not a timestamp, so the discarded high bits don't matter. #[allow(clippy::cast_possible_truncation)] @@ -4216,6 +4286,50 @@ mod tests { assert_eq!(ctrl.nsf_song_count(), 0, "a disk is not an NSF"); } + /// v3.2.0: a saved disk boots under the PRISTINE disk's identity, so the + /// stores keyed on it (and the save file itself) survive the game's first + /// disk write; a cartridge refuses it and keeps running. + #[test] + fn a_saved_disk_boots_under_the_pristine_identity() { + let ctrl = NesController::new_with_fds_bios( + synthetic_fds_disk(1), + Some(synthetic_fds_bios()), + DEFAULT_SAMPLE_RATE, + ) + .expect("disk loads"); + let pristine = ctrl.rom_identity(); + let mut saved = ctrl.disk_image_bytes(); + let last = saved.len() - 1; + saved[last] ^= 0x5A; // stands in for the game's own write + assert_ne!( + rom_identity_of_file(saved.clone()).unwrap(), + pristine, + "fixture: the saved bytes hash differently" + ); + + ctrl.boot_saved_disk(saved.clone()) + .expect("the saved disk boots"); + assert_eq!( + ctrl.rom_identity(), + pristine, + "identity is the pristine disk's" + ); + assert_eq!( + ctrl.disk_image_bytes(), + saved, + "and the saved bytes are loaded" + ); + ctrl.step_frame(); + + let cart = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + let before = cart.rom_identity(); + assert!( + cart.boot_saved_disk(saved).is_err(), + "a cartridge refuses it" + ); + assert_eq!(cart.rom_identity(), before, "and keeps running"); + } + /// Plan item 6: the BIOS set on a controller persists across `load_rom`, /// so a host that stored it once can swap disks without re-supplying it. #[test] diff --git a/crates/rustynes-netplay/src/connection.rs b/crates/rustynes-netplay/src/connection.rs index 24dba4602..dbf4da74a 100644 --- a/crates/rustynes-netplay/src/connection.rs +++ b/crates/rustynes-netplay/src/connection.rs @@ -325,6 +325,13 @@ impl Transport for UdpTransport { } } + // No source filter here, deliberately (v3.2.0 review). A filter on + // `self.remote` was tried and removed: it dropped every datagram after a + // NAT rebinding (the peer's public port changes mid-session) and after an + // IPv4 peer reappears as its IPv4-mapped IPv6 form, ending the session, + // while protecting nothing -- a UDP source is trivially spoofed, and the + // protocol has no authentication for it to stand in for. Adoption + // (`set_remote`) is still first-come, so a stray `Sync` cannot re-point it. fn poll(&mut self) -> Vec { self.poll_with_source() .into_iter() @@ -800,7 +807,8 @@ impl NetplayConnection { NetMessage::Input { .. } | NetMessage::InputAck { .. } | NetMessage::Checksum { .. } - | NetMessage::Roster { .. } => {} + | NetMessage::Roster { .. } + | NetMessage::InputBatch { .. } => {} } } diff --git a/crates/rustynes-netplay/src/mesh_net.rs b/crates/rustynes-netplay/src/mesh_net.rs index 1e07ea21d..457ae43c6 100644 --- a/crates/rustynes-netplay/src/mesh_net.rs +++ b/crates/rustynes-netplay/src/mesh_net.rs @@ -403,11 +403,16 @@ impl MeshHost { self.last_roster_sent = Some(now); self.roster_resends -= 1; } - // After the first broadcast, build + return the host's transport. - // (We still want the joiners to have received it; the resends above - // ride out loss, but the host can proceed immediately — its mesh - // transport simply starts sending Inputs, which also doubles as - // liveness for the joiners.) + // v3.2.0: hand off only after the LAST resend. The resends exist to + // ride out a lost roster, and they are sent from this pump; once + // the host has its transport nothing pumps this handshake again. + // Until v3.2.0 it handed off after the first broadcast, so the + // other four were never sent and one lost datagram stranded a + // joiner. Cost: the host starts about + // (ROSTER_RESENDS - 1) x ROSTER_RESEND_INTERVAL = 200 ms later. + if self.roster_resends > 0 { + return Ok(None); + } let socket = self.socket.take().expect("host socket present to hand off"); let peers: Vec = self.joiners.keys().copied().collect(); // We never reach here without at least the resend above; map an I/O @@ -819,8 +824,13 @@ mod tests { let listen = host.local_addr().unwrap(); let mut j = MeshJoiner::connect(loopback(), listen, 1, id).unwrap(); let mut done = false; + let mut j_done = false; for _ in 0..500 { - let _ = j.pump(); + // The joiner hands off once it has the roster; stop pumping it + // then, as a real caller would (the host keeps resending). + if !j_done && let Ok(Some(_)) = j.pump() { + j_done = true; + } if host.pump().expect("host pump").is_some() { done = true; break; diff --git a/crates/rustynes-netplay/src/message.rs b/crates/rustynes-netplay/src/message.rs index 0357043b4..2ac0db651 100644 --- a/crates/rustynes-netplay/src/message.rs +++ b/crates/rustynes-netplay/src/message.rs @@ -176,9 +176,13 @@ impl SessionIdentity { Err(why) => SyncVerdict::Refuse(why), } } else if NetMessage::OLDER_SYNC_MAGICS.contains(&magic) { - // Protocol 6 carries its epoch (same layout as ours), so the reason - // can name it; protocols 4 and 5 have none. - let theirs = (magic == NetMessage::PROTOCOL_6_SYNC_MAGIC).then_some(peer.epoch); + // Protocols 6 and 7 carry their epoch (same layout as ours), so the + // reason can name it; protocols 4 and 5 have none. + let theirs = matches!( + magic, + NetMessage::PROTOCOL_6_SYNC_MAGIC | NetMessage::PROTOCOL_7_SYNC_MAGIC + ) + .then_some(peer.epoch); SyncVerdict::Refuse(IdentityMismatch::Emulator { ours: self.epoch, theirs, @@ -229,8 +233,16 @@ impl SessionIdentity { /// is one of [`NetMessage::OLDER_SYNC_MAGICS`] and it is refused as another /// emulator version, naming its epoch (protocol 6 carries it). /// +/// `8` (v3.2.0): [`NetMessage::InputBatch`] (tag 6) -- a run of consecutive +/// inputs for one player in one datagram, which the retransmit window now +/// sends instead of one `Input` per frame. The `Sync` layout is unchanged +/// (72 bytes), under the magic `"RNE8"`; a v3.1.x peer's `"RNE7"` joins +/// [`NetMessage::OLDER_SYNC_MAGICS`] and is refused as another emulator +/// version, naming its epoch, because it could not decode a batch: its +/// [`from_bytes`] drops tag 6, so every retransmission would be lost. +/// /// [`from_bytes`]: NetMessage::from_bytes -pub const PROTOCOL_VERSION: u32 = 7; +pub const PROTOCOL_VERSION: u32 = 8; /// Messages exchanged between two peers. /// @@ -260,6 +272,25 @@ pub enum NetMessage { input: u8, }, + /// v3.2.0 (protocol 8): consecutive confirmed inputs for one player, the + /// first at `start_frame`, in one datagram. The retransmit window + /// ([`crate::RollbackSession`]) sends its resends this way rather than as + /// one [`Self::Input`] per frame. Each element is folded in exactly as an + /// `Input` for frame `start_frame + i` would be. + /// + /// Wire: tag, `player`, `start_frame` (LE `u32`), a count byte, then + /// `count` input bytes. At most [`Self::MAX_INPUT_BATCH`] inputs; a count + /// of 0, a truncated payload or trailing bytes decode to `None`. + InputBatch { + /// The 0-based player index the inputs belong to (as for `Input`). + player: u8, + /// The frame of `inputs[0]`. + start_frame: u32, + /// The inputs for `start_frame`, `start_frame + 1`, ... (1 to + /// [`Self::MAX_INPUT_BATCH`] of them). + inputs: Vec, + }, + /// Acknowledges receipt of the remote's input up to `frame`. Lets the /// sender stop retransmitting already-confirmed inputs (Stage 2 uses /// this for the sliding-window retransmit cutoff). @@ -336,7 +367,7 @@ impl NetMessage { /// consider the session synced while the v5 side waited for a reply it /// refuses. Every version compares the magic, so a changed one is /// rejected on both sides. - pub const SYNC_MAGIC: u32 = 0x524E_4537; // "RNE7" + pub const SYNC_MAGIC: u32 = 0x524E_4538; // "RNE8" /// `RustyNES`'s own earlier `Sync` magics: protocol 4 (`"RNES"`, v2.5.x to /// v2.9.7), protocol 5 (`"RNE5"`, v2.9.8 and v2.9.9) and protocol 6 @@ -344,10 +375,11 @@ impl NetMessage { /// is refused with a reason ([`SessionIdentity::check_sync`]). Code that /// needs one protocol names its constant below rather than an index, so /// adding a protocol to this list cannot silently re-point a check. - pub const OLDER_SYNC_MAGICS: [u32; 3] = [ + pub const OLDER_SYNC_MAGICS: [u32; 4] = [ Self::PROTOCOL_4_SYNC_MAGIC, Self::PROTOCOL_5_SYNC_MAGIC, Self::PROTOCOL_6_SYNC_MAGIC, + Self::PROTOCOL_7_SYNC_MAGIC, ]; /// Protocol 4's `Sync` magic, `"RNES"` (v2.5.x to v2.9.7): a 36-byte /// payload with no config hash and no epoch. @@ -358,6 +390,13 @@ impl NetMessage { /// Protocol 6's `Sync` magic, `"RNE6"` (v3.0.0 and v3.0.1): today's layout, /// epoch included, so a refusal can name the peer's epoch. pub const PROTOCOL_6_SYNC_MAGIC: u32 = 0x524E_4536; + /// Protocol 7's `Sync` magic, `"RNE7"` (v3.1.x): today's layout, epoch + /// included. Refused because a protocol 7 peer cannot decode + /// [`Self::InputBatch`]. + pub const PROTOCOL_7_SYNC_MAGIC: u32 = 0x524E_4537; + /// The most inputs one [`Self::InputBatch`] carries: what its count byte + /// can express. The session's retransmit window is far smaller. + pub const MAX_INPUT_BATCH: usize = u8::MAX as usize; // Tag bytes for the hand-rolled encoding. const TAG_INPUT: u8 = 0; @@ -366,6 +405,7 @@ impl NetMessage { const TAG_CHECKSUM: u8 = 3; const TAG_QUALITY: u8 = 4; const TAG_ROSTER: u8 = 5; + const TAG_INPUT_BATCH: u8 = 6; // IP-family tags inside a `Roster` entry's address encoding. const IP_V4: u8 = 4; @@ -396,6 +436,23 @@ impl NetMessage { out.extend_from_slice(&frame.to_le_bytes()); out.push(input); } + Self::InputBatch { + player, + start_frame, + ref inputs, + } => { + // The count byte and the payload must agree, so a batch + // longer than the count can express is cut to + // MAX_INPUT_BATCH rather than written whole under a wrapped + // or saturated count (the receiver would mis-frame it). The + // session never builds one that long. + let inputs = &inputs[..inputs.len().min(Self::MAX_INPUT_BATCH)]; + out.push(Self::TAG_INPUT_BATCH); + out.push(player); + out.extend_from_slice(&start_frame.to_le_bytes()); + out.push(u8::try_from(inputs.len()).unwrap_or(u8::MAX)); + out.extend_from_slice(inputs); + } Self::InputAck { frame } => { out.push(Self::TAG_INPUT_ACK); out.extend_from_slice(&frame.to_le_bytes()); @@ -477,6 +534,25 @@ impl NetMessage { } } + /// The body of an [`Self::InputBatch`] (after its tag): player, LE start + /// frame, a count byte, then exactly `count` inputs, at least one. An + /// empty batch carries nothing, and a short or long payload is not this + /// encoding; both are `None`. + fn decode_input_batch(rest: &[u8]) -> Option { + let player = *rest.first()?; + let start_frame = u32::from_le_bytes(rest.get(1..5)?.try_into().ok()?); + let count = usize::from(*rest.get(5)?); + let inputs = rest.get(6..)?; + if count == 0 || inputs.len() != count { + return None; + } + Some(Self::InputBatch { + player, + start_frame, + inputs: inputs.to_vec(), + }) + } + /// Parse a buffer produced by [`Self::to_bytes`]. Returns `None` on a /// malformed / truncated / unknown-tag buffer (the UDP transport drops /// such datagrams rather than panicking). @@ -498,13 +574,14 @@ impl NetMessage { let frame = u32::from_le_bytes(rest.get(0..4)?.try_into().ok()?); Some(Self::InputAck { frame }) } + Self::TAG_INPUT_BATCH => Self::decode_input_batch(rest), Self::TAG_SYNC => { let magic = u32::from_le_bytes(rest.get(0..4)?.try_into().ok()?); match rest.len() { - // Protocols 6 and 7: magic + epoch + two hashes. Exactly + // Protocols 6, 7 and 8: magic + epoch + two hashes. Exactly // that length: a longer payload is a later protocol's, not // this one's with junk on the end. Which protocol it is - // (`"RNE7"` ours, `"RNE6"` v3.0.x) is `check_sync`'s call. + // (`"RNE8"` ours, `"RNE7"` v3.1.x, `"RNE6"` v3.0.x) is `check_sync`'s call. 72 => { let epoch = u32::from_le_bytes(rest.get(4..8)?.try_into().ok()?); let rom_hash: [u8; 32] = rest.get(8..40)?.try_into().ok()?; @@ -759,6 +836,78 @@ mod tests { assert_ne!(NetMessage::SYNC_MAGIC, NetMessage::PROTOCOL_6_SYNC_MAGIC); } + /// v3.2.0 (protocol 8) — a v3.1.x peer's `"RNE7"` `Sync` has our layout, + /// and could even carry our epoch and hashes, but it cannot decode an + /// `InputBatch`, so it is refused as another emulator version, naming + /// the epoch its layout carries. + #[test] + fn a_protocol_7_peer_is_refused_as_another_version_naming_its_epoch() { + let ours = SessionIdentity::new([7u8; 32], [9u8; 32]); + // The same identity as ours, but under protocol 7's magic. + let bytes = NetMessage::Sync { + magic: NetMessage::PROTOCOL_7_SYNC_MAGIC, + identity: ours, + } + .to_bytes(); + let Some(NetMessage::Sync { magic, identity }) = NetMessage::from_bytes(&bytes) else { + panic!("a protocol-7 Sync decodes"); + }; + assert_eq!( + ours.check_sync(magic, &identity), + SyncVerdict::Refuse(IdentityMismatch::Emulator { + ours: ours.epoch, + theirs: Some(ours.epoch), + }) + ); + assert_eq!(NetMessage::SYNC_MAGIC, 0x524E_4538, "\"RNE8\""); + assert_eq!(PROTOCOL_VERSION, 8); + } + + /// v3.2.0 — the `InputBatch` wire form, byte for byte, and its bounds. + #[test] + fn input_batch_encodes_decodes_and_rejects_malformed_payloads() { + let msg = NetMessage::InputBatch { + player: 1, + start_frame: 0x0102_0304, + inputs: vec![0xAA, 0x00, 0x55], + }; + let bytes = msg.to_bytes(); + assert_eq!( + bytes, + [6, 1, 0x04, 0x03, 0x02, 0x01, 3, 0xAA, 0x00, 0x55], + "tag, player, LE frame, count, inputs" + ); + assert_eq!(NetMessage::from_bytes(&bytes), Some(msg)); + + // Truncated anywhere, padded, or empty: refused. + for cut in 1..bytes.len() { + assert_eq!(NetMessage::from_bytes(&bytes[..cut]), None, "cut at {cut}"); + } + let mut long = bytes; + long.push(0); + assert_eq!(NetMessage::from_bytes(&long), None, "trailing byte"); + assert_eq!( + NetMessage::from_bytes(&[6, 0, 0, 0, 0, 0, 0]), + None, + "an empty batch" + ); + + // The largest batch round-trips; a longer one is cut to the cap so + // the count byte and the payload still agree. + let full = NetMessage::InputBatch { + player: 0, + start_frame: 9, + inputs: vec![7; NetMessage::MAX_INPUT_BATCH], + }; + roundtrip(&full); + let over = NetMessage::InputBatch { + player: 0, + start_frame: 9, + inputs: vec![7; NetMessage::MAX_INPUT_BATCH + 10], + }; + assert_eq!(NetMessage::from_bytes(&over.to_bytes()), Some(full)); + } + #[test] fn roster_roundtrips_v4_and_v6() { use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; diff --git a/crates/rustynes-netplay/src/relay.rs b/crates/rustynes-netplay/src/relay.rs index a55748381..cbd8d8402 100644 --- a/crates/rustynes-netplay/src/relay.rs +++ b/crates/rustynes-netplay/src/relay.rs @@ -340,6 +340,12 @@ impl TurnClient { // ── internal encoders ─────────────────────────────────────────────────── fn next_tx(&mut self) -> TransactionId { + // From the seeded PRNG, so a test's explicit seed fixes the ids. A real + // session's seed is OS randomness: the mobile bridge seeds its + // `NatConnect` from `getrandom`, and that RNG seeds this one. (v3.2.0 + // review: drawing from `getrandom` here instead ignored the seed and + // made the netplay crate depend on it unconditionally, breaking the + // wasm build.) let mut tx = [0u8; 12]; for chunk in tx.chunks_mut(4) { let r = self.rng.next_u64().to_le_bytes(); diff --git a/crates/rustynes-netplay/src/session.rs b/crates/rustynes-netplay/src/session.rs index 0a0a049c2..5896e43db 100644 --- a/crates/rustynes-netplay/src/session.rs +++ b/crates/rustynes-netplay/src/session.rs @@ -67,6 +67,9 @@ pub const MAX_PLAYERS: usize = 4; /// normal conditions, with acks flowing, far fewer (the in-flight latency /// window) are resent. const INPUT_RESEND_WINDOW: u32 = 64; +// The whole retransmit window (`INPUT_RESEND_WINDOW + 1` frames) fits one +// protocol-8 `InputBatch`, whose count byte caps it. +const _: () = assert!((INPUT_RESEND_WINDOW as usize) < NetMessage::MAX_INPUT_BATCH); /// How far past this peer's [`current_frame`](RollbackSession::current_frame) /// a remote `Input.frame` / `Checksum.frame` may sit before the message is @@ -264,19 +267,13 @@ pub struct RollbackSession { /// production build would prune; see the Stage 2/3 handoff). history: Vec, - /// Save-state ring: `snapshots[f]` is the emulator state *before* frame - /// `f` was run (so restoring it and running forward reproduces frame f). - /// Indexed by frame; only the trailing `max_rollback_frames + 1` entries - /// are guaranteed present (older are `None` after pruning). - snapshots: Vec>>, - /// Checksums we have *sent* at confirmed frames, kept so a later remote /// `Checksum` for the same frame can be compared and so we don't re-send. local_checksums: Vec>, /// The canonical hash of the deterministic *entering* state of each /// checksummed frame, recorded by `resync` the moment the frame becomes - /// confirmed (its `snapshots[f]` is then a function of real inputs only). + /// confirmed (its entering state is then a function of real inputs only). /// Because all peers reach byte-identical entering state for a confirmed /// frame, these hashes match across peers — the property that makes the /// checksum exchange a sound desync detector. @@ -345,7 +342,6 @@ impl RollbackSession { remote_ack_frame: None, synced: false, history: Vec::new(), - snapshots: Vec::new(), local_checksums: Vec::new(), confirmed_hashes: Vec::new(), remote_checksums: Vec::new(), @@ -508,9 +504,6 @@ impl RollbackSession { if self.history.len() < need { self.history.resize(need, InputSlot::default()); } - if self.snapshots.len() < need { - self.snapshots.resize(need, None); - } if self.local_checksums.len() < need { self.local_checksums.resize(need, None); } @@ -637,13 +630,43 @@ impl RollbackSession { }) } + /// Fold a single remote input into the history, returning the frame it mispredicted (if any). + fn ingest_single_input(&mut self, player: u8, frame: u32, input: u8) -> Option { + let lp = self.local_player(); + // Ignore an out-of-range or self-addressed player index + if player >= self.config.num_players || player == lp { + return None; + } + // Drop a frame past the lookahead window before it can size an allocation. + if frame > self.max_accepted_frame() { + return None; + } + self.ensure_frame(frame); + let slot = &mut self.history[frame as usize]; + let cell = &mut slot.players[player as usize]; + let prev = cell.input; + let already_simulated = slot.simulated; + cell.input = input; + cell.confirmed = true; + // Misprediction: a frame we already ran used a value that + // the just-arrived real input contradicts. Re-running it + // (and everything after) is required. A second copy of an + // identical input (idempotent resend, whether previously a + // prediction or already confirmed) leaves `prev == input`, + // so it is correctly NOT treated as a misprediction. + if already_simulated && prev != input { + Some(frame) + } else { + None + } + } + /// Poll the transport and fold in every message. Returns the earliest /// frame (if any) whose previously-used prediction was contradicted by a /// newly-arrived real remote input. fn ingest(&mut self, nes: &Nes) -> Result, NetplayError> { let mut earliest_mispredict: Option = None; let messages = self.transport.poll(); - let lp = self.local_player(); for msg in messages { match msg { @@ -668,34 +691,23 @@ impl RollbackSession { frame, input, } => { - // Ignore an out-of-range or self-addressed player index - // (a malformed / foreign packet must never corrupt our own - // authored input or index out of bounds). - if player >= self.config.num_players || player == lp { - continue; - } - // Drop a frame past the lookahead window before it can size - // an allocation (AUD-06; see MAX_SESSION_FRAME_LOOKAHEAD). - // A genuine input is resent until acked, so nothing is lost. - if frame > self.max_accepted_frame() { - continue; - } - self.ensure_frame(frame); - let slot = &mut self.history[frame as usize]; - let cell = &mut slot.players[player as usize]; - let prev = cell.input; - let already_simulated = slot.simulated; - cell.input = input; - cell.confirmed = true; - // Misprediction: a frame we already ran used a value that - // the just-arrived real input contradicts. Re-running it - // (and everything after) is required. A second copy of an - // identical input (idempotent resend, whether previously a - // prediction or already confirmed) leaves `prev == input`, - // so it is correctly NOT treated as a misprediction. - if already_simulated && prev != input { + if let Some(mispredict) = self.ingest_single_input(player, frame, input) { earliest_mispredict = - Some(earliest_mispredict.map_or(frame, |e| e.min(frame))); + Some(earliest_mispredict.map_or(mispredict, |e| e.min(mispredict))); + } + } + NetMessage::InputBatch { + player, + start_frame, + inputs, + } => { + for (offset, input) in inputs.into_iter().enumerate() { + #[allow(clippy::cast_possible_truncation)] + let frame = start_frame.saturating_add(offset as u32); + if let Some(mispredict) = self.ingest_single_input(player, frame, input) { + earliest_mispredict = + Some(earliest_mispredict.map_or(mispredict, |e| e.min(mispredict))); + } } } NetMessage::InputAck { frame } => { @@ -782,12 +794,11 @@ impl RollbackSession { let mut count = 0; for f in cp_frame..self.current_frame { - // v2.8.0 Phase 3 — encode straight into the ring slot's reused - // buffer (the old path built a full thumbnail-carrying snapshot - // AND cloned it: two ~320 KiB allocations per replayed frame). - let mut slot = self.snapshots[f as usize].take().unwrap_or_default(); - nes.snapshot_core_into(&mut slot); - self.snapshots[f as usize] = Some(slot); + // v3.2.0 (NET-01): no per-frame snapshot is taken here any more. + // A `snapshots[f]` ring was filled on every replayed frame and + // never read -- rollback restores from its checkpoint -- and was + // never pruned either, so it cost a full core snapshot per + // replayed frame and grew with the session. // A confirmed replay enters `f` (f < boundary) on a canonical // base, so its entering *gameplay* state is canonical and cross- // peer-identical. Record a digest for verification. (We digest the @@ -980,21 +991,45 @@ impl RollbackSession { // First un-acked frame (the remote has everything <= remote_ack_frame). let first_unacked = self.remote_ack_frame.map_or(0, |a| a.saturating_add(1)); let start = first_unacked.max(newest.saturating_sub(INPUT_RESEND_WINDOW)); + if start > newest { + return; + } + // v3.2.0 (protocol 8): one `InputBatch` per run of consecutive + // confirmed (locally authored) frames, instead of one `Input` per + // frame. A frame we did not author ends a run and is skipped, as the + // per-frame loop skipped it; the next confirmed frame starts a new + // run, so every authored frame in the window is still resent. (The + // first form of this batching stopped at the first gap and dropped + // the rest of the window.) The window fits one batch (const assert + // beside INPUT_RESEND_WINDOW). + let mut run_start = start; + let mut run: Vec = Vec::new(); for frame in start..=newest { let idx = frame as usize; if idx >= self.history.len() { break; } let slot = self.history[idx].players[lp_idx]; - // Only resend frames we actually authored (confirmed-local). if slot.confirmed { - self.transport.send(&NetMessage::Input { + if run.is_empty() { + run_start = frame; + } + run.push(slot.input); + } else if !run.is_empty() { + self.transport.send(&NetMessage::InputBatch { player: lp, - frame, - input: slot.input, + start_frame: run_start, + inputs: core::mem::take(&mut run), }); } } + if !run.is_empty() { + self.transport.send(&NetMessage::InputBatch { + player: lp, + start_frame: run_start, + inputs: run, + }); + } } /// Send a `Checksum` for every confirmed checksum-boundary frame whose @@ -1063,12 +1098,11 @@ mod tests { } /// The length every per-frame vector must share (they are grown together - /// by `ensure_frame`); asserting on all six catches a bound that guarded + /// by `ensure_frame`); asserting on all five catches a bound that guarded /// only one call site. - fn per_frame_lens(s: &RollbackSession) -> [usize; 6] { + fn per_frame_lens(s: &RollbackSession) -> [usize; 5] { [ s.history.len(), - s.snapshots.len(), s.local_checksums.len(), s.confirmed_hashes.len(), s.remote_checksums.len(), diff --git a/crates/rustynes-netplay/src/spectator.rs b/crates/rustynes-netplay/src/spectator.rs index 8679f4953..2528f4408 100644 --- a/crates/rustynes-netplay/src/spectator.rs +++ b/crates/rustynes-netplay/src/spectator.rs @@ -373,6 +373,63 @@ impl SpectatorSession { } } + /// Fold one player's input for one frame into the history -- the body of + /// the `Input` arm, shared with `InputBatch` (protocol 8), whose every + /// element is exactly an `Input` for `start_frame + i`. + fn ingest_input(&mut self, player: u8, frame: u32, input: u8) { + // Drop an out-of-range player index. `num_players` is fixed + // by construction (and only ever set by a `Roster` BEFORE + // the first confirmed frame — see the `Roster` arm of `ingest`), + // so a `player` + // beyond it can never become valid: dropping it is correct, + // not merely a best-effort guard, and it keeps a malformed / + // foreign packet from indexing out of bounds or corrupting a + // real player's stream. + if player >= self.config.num_players { + return; + } + // Reject a `frame` that sits implausibly far ahead of the + // frame we are currently confirming. A spectator never shows + // anything beyond the confirmed horizon, so any legitimate + // in-flight `Input.frame` is only a small bounded distance + // ahead. Without this cap a peer-supplied `frame` near + // `u32::MAX` would make `ensure_frame` resize `history` + // unboundedly (an OOM DoS). Dropping it is safe: a real, + // in-window frame is retransmitted by the players' session. + let horizon = self.last_confirmed_frame.map_or(0, |c| c + 1); + if frame > horizon.saturating_add(MAX_SPECTATOR_FRAME_LOOKAHEAD) { + return; + } + // v3.0.0 (T-SPECTATOR-HISTORY): and within the buffer. + // The check above bounds one packet's jump from the + // confirmed horizon, but the horizon itself walks forward + // with every contiguous frame of input, shown or not. + // Without this cap, a peer streaming faster than real + // time, or a stream that never sends a matching `Sync`, + // grew the history without limit. A frame already shown + // is past, and dropped too. + if frame < self.current_frame { + return; + } + if frame - self.current_frame >= self.buffer_cap { + // Dropped, and not resent: remember where playback + // will have to stop (v3.0.0, `stream_lost`). + *self.dropped.entry(frame).or_default() |= 1 << player; + return; + } + // A dropped input that arrives again in the window is + // no longer missing. + if let Some(mask) = self.dropped.get_mut(&frame) { + *mask &= !(1 << player); + if *mask == 0 { + self.dropped.remove(&frame); + } + } + let slot = self.slot_mut(frame); + slot.inputs[player as usize] = input; + slot.arrived |= 1 << player; + } + /// Poll the transport and fold in every relevant message. Receive-only: no /// ack, checksum, or quality reply is ever sent. fn ingest(&mut self) { @@ -397,56 +454,18 @@ impl SpectatorSession { frame, input, } => { - // Drop an out-of-range player index. `num_players` is fixed - // by construction (and only ever set by a `Roster` BEFORE - // the first confirmed frame — see below), so a `player` - // beyond it can never become valid: dropping it is correct, - // not merely a best-effort guard, and it keeps a malformed / - // foreign packet from indexing out of bounds or corrupting a - // real player's stream. - if player >= self.config.num_players { - continue; - } - // Reject a `frame` that sits implausibly far ahead of the - // frame we are currently confirming. A spectator never shows - // anything beyond the confirmed horizon, so any legitimate - // in-flight `Input.frame` is only a small bounded distance - // ahead. Without this cap a peer-supplied `frame` near - // `u32::MAX` would make `ensure_frame` resize `history` - // unboundedly (an OOM DoS). Dropping it is safe: a real, - // in-window frame is retransmitted by the players' session. - let horizon = self.last_confirmed_frame.map_or(0, |c| c + 1); - if frame > horizon.saturating_add(MAX_SPECTATOR_FRAME_LOOKAHEAD) { - continue; - } - // v3.0.0 (T-SPECTATOR-HISTORY): and within the buffer. - // The check above bounds one packet's jump from the - // confirmed horizon, but the horizon itself walks forward - // with every contiguous frame of input, shown or not. - // Without this cap, a peer streaming faster than real - // time, or a stream that never sends a matching `Sync`, - // grew the history without limit. A frame already shown - // is past, and dropped too. - if frame < self.current_frame { - continue; - } - if frame - self.current_frame >= self.buffer_cap { - // Dropped, and not resent: remember where playback - // will have to stop (v3.0.0, `stream_lost`). - *self.dropped.entry(frame).or_default() |= 1 << player; - continue; - } - // A dropped input that arrives again in the window is - // no longer missing. - if let Some(mask) = self.dropped.get_mut(&frame) { - *mask &= !(1 << player); - if *mask == 0 { - self.dropped.remove(&frame); - } + self.ingest_input(player, frame, input); + } + // v3.2.0 (protocol 8): the players' retransmit window sends + // batches; ignoring them (as first written) lost every resend. + NetMessage::InputBatch { + player, + start_frame, + inputs, + } => { + for (offset, input) in (0u32..).zip(inputs) { + self.ingest_input(player, start_frame.saturating_add(offset), input); } - let slot = self.slot_mut(frame); - slot.inputs[player as usize] = input; - slot.arrived |= 1 << player; } NetMessage::Roster { peers } => { // The host's roster tells the spectator how many players to @@ -633,6 +652,66 @@ mod tests { assert_eq!(spec.delay_frames(), DELAY); } + /// v3.2.0 (protocol 8): the players resend inputs as `InputBatch`es, and + /// a spectator folds a batch in exactly as the same inputs sent one by + /// one. Ignoring batches (as first written) lost every retransmission. + #[test] + fn a_batch_is_the_same_as_its_inputs_sent_one_by_one() { + const FRAMES: u32 = 6; + let rom = synth_nrom(); + let hash = SessionIdentity::of(&Nes::from_rom(&rom).unwrap()); + let input = + |player: u8, frame: u32| frame.to_le_bytes()[0].wrapping_mul(37) ^ (player << 3); + let mut runs = Vec::new(); + for batched in [false, true] { + let (link, mut feeder) = MemoryTransport::pair(LinkConditions::PERFECT, 3); + let mut spec = SpectatorSession::new( + SpectatorConfig { + num_players: 2, + delay_frames: 0, + }, + link, + hash, + ); + feeder.send(&NetMessage::Sync { + magic: NetMessage::SYNC_MAGIC, + identity: hash, + }); + for player in 0..2u8 { + if batched { + feeder.send(&NetMessage::InputBatch { + player, + start_frame: 0, + inputs: (0..FRAMES).map(|f| input(player, f)).collect(), + }); + } else { + for frame in 0..FRAMES { + feeder.send(&NetMessage::Input { + player, + frame, + input: input(player, frame), + }); + } + } + } + let mut nes = Nes::from_rom(&rom).unwrap(); + let mut shown = Vec::new(); + for _ in 0..FRAMES + 2 { + let out = spec.advance(&mut nes); + if out.produced_frame { + shown.push(out.frame); + } + } + runs.push((shown, nes.snapshot())); + } + assert_eq!( + runs[1].0, + (0..FRAMES).collect::>(), + "every batched frame shows" + ); + assert_eq!(runs[0], runs[1], "same frames, same state"); + } + /// An absurd `delay_frames` is clamped to `MAX_DELAY_FRAMES`, so it cannot /// push the reveal point past the accept window. #[test] diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 75bd20478..93764046a 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -459,6 +459,9 @@ pub struct Ppu { // === CPU-facing register state === pub(crate) ctrl: PpuCtrl, pub(crate) mask: PpuMask, + /// Cached emphasis LUT offset `(emph << 6)` for fast indexing into [`Self::rgba_lut`]. + /// Maintained by [`Self::update_emph_offset`] on reset, `$2001` writes, and snapshot restore. + pub(crate) cached_emph_offset: usize, /// Two-stage delay pipeline of `mask` consumed exclusively by the /// pre-render dot-339 odd-frame skip check. `mask_for_skip_check` is /// the value seen *this* dot; `mask_skip_pipe1` is the staged value @@ -1512,6 +1515,7 @@ impl Ppu { region, ctrl: PpuCtrl::empty(), mask: PpuMask::empty(), + cached_emph_offset: 0, mask_for_skip_check: PpuMask::empty(), dot0_replaced: false, mask_skip_pipe1: PpuMask::empty(), @@ -2124,6 +2128,15 @@ impl Ppu { &mut self.ciram } + pub(crate) const fn update_emph_offset(&mut self) { + let raw = (self.mask.bits() >> 5) & 0x07; + let emph = match self.region { + PpuRegion::Ntsc => raw, + _ => (raw & 0b100) | ((raw & 0b001) << 1) | ((raw & 0b010) >> 1), + }; + self.cached_emph_offset = (emph as usize) << 6; + } + /// Performs a soft-reset of the PPU (warm boot). Per `docs/ppu-2c02.md`: /// - PPUCTRL := 0 /// - PPUMASK := 0 @@ -2136,6 +2149,7 @@ impl Ppu { pub const fn reset(&mut self) { self.ctrl = PpuCtrl::empty(); self.mask = PpuMask::empty(); + self.update_emph_offset(); self.mask_for_skip_check = PpuMask::empty(); self.mask_skip_pipe1 = PpuMask::empty(); self.prev_rendering_enabled = false; @@ -3127,6 +3141,7 @@ impl Ppu { } let was_rendering = self.mask.rendering_enabled(); self.mask = PpuMask::from_bits_truncate(value); + self.update_emph_offset(); self.arm_oam_corruption_disable(was_rendering); // v2.0 Phase 6 (mc-ppu-subpos): arm the analog `$2001` BG-reload // delay. `self.mask` (and so the sprite-eval / shift / pixel @@ -3627,8 +3642,7 @@ impl Ppu { // no scanline-241 VBL set, no pre-render clear, no OAM-corruption // edge, no PPUDATA state machine in flight, no `$2006` copy-V or // PPUMASK write delay pending, rendering stably enabled. This gate - // detects that regime cheaply and, when the (default-OFF) runtime - // knob is on, dispatches to [`Self::tick_visible_render_fast`], which + // detects that regime cheaply and dispatches to [`Self::tick_visible_render_fast`], which // runs the *identical* helper sequence with the dead branches pruned. // // BYTE-IDENTITY: the fast handler is byte-identical BY CONSTRUCTION — @@ -5111,9 +5125,9 @@ impl Ppu { let mut hd_sprites: [usize; 4] = [0; 4]; #[cfg(feature = "hd-pack")] let mut hd_spr_n: usize = 0; - if self.mask.contains(PpuMask::SHOW_SPRITE) - && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)) - { + let sprites_active = self.mask.contains(PpuMask::SHOW_SPRITE) + && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)); + if sprites_active { for i in 0..self.spr_count as usize { // v2.0 (ppu-sprite-shifter-counter): a sprite emits when its // X-counter is 0 OR it is in the persistent halted state. The @@ -5162,11 +5176,7 @@ impl Ppu { // none of the eight hardware sprites is opaque (they have the higher // OAM indexes, so the lower priority). Never sprite 0, so never a hit. // `spr_extra_count` is 0 unless the option is on. - if spr_idx == 0 - && self.spr_extra_count != 0 - && self.mask.contains(PpuMask::SHOW_SPRITE) - && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)) - { + if spr_idx == 0 && self.spr_extra_count != 0 && sprites_active { for e in 0..usize::from(self.spr_extra_count) { let off = pixel_x.wrapping_sub(u16::from(self.spr_extra_x[e])); if off >= 8 { @@ -5238,7 +5248,8 @@ impl Ppu { let final_idx = self.read_palette(pal_addr) & 0x3F; // Write RGBA8 to framebuffer. - let off = ((pixel_y as usize) * 256 + pixel_x as usize) * 4; + let px = (pixel_y as usize) * 256 + pixel_x as usize; + let off = px * 4; // v2.8.0 Phase 4 — route through the precomputed // `(emphasis << 6) | color` lookup (built from the same pure // `palette_color_to_rgba`, so byte-identical to the old per-pixel @@ -5250,13 +5261,7 @@ impl Ppu { // and GREEN on the PAL 2C07 and the Dendy, bit 6 the reverse (NESdev // "Colour emphasis"), so those two exchange off NTSC. The region is // fixed per console, so the branch is constant. - let raw = (self.mask.bits() >> 5) & 0x07; - let emph = usize::from(if matches!(self.region, PpuRegion::Ntsc) { - raw - } else { - (raw & 0b100) | ((raw & 0b001) << 1) | ((raw & 0b010) >> 1) - }); - let lut_idx = (emph << 6) | usize::from(final_idx); + let lut_idx = self.cached_emph_offset | usize::from(final_idx); let rgba = self.rgba_lut[lut_idx]; self.framebuffer[off..off + 4].copy_from_slice(&rgba); // Parallel palette-index output for the `NES_NTSC` composite filter @@ -5271,7 +5276,7 @@ impl Ppu { // samples simply redistribute. Not worth the correctness hazard of // gating a buffer the NTSC filter, the mobile API, `fast_dotloop_diff` // and a unit test all read. See `docs/performance.md`. - self.index_framebuffer[off >> 2] = lut_idx as u16; + self.index_framebuffer[px] = lut_idx as u16; // v1.2.0 C3 (hd-pack): record the CHR tile that produced this pixel, // mirroring the BG-vs-sprite priority decision above. Output-only; this @@ -5648,7 +5653,9 @@ impl Ppu { // Secondary-OAM clear (cycles 1-64): the bus carries $FF and the // parallel secondary OAM is filled with $FF, 1 byte per 2 dots. self.oam_bus_copybuffer = 0xFF; - self.oam_bus_secondary[((cycle - 1) >> 1) as usize] = 0xFF; + if (cycle & 1) == 0 { + self.oam_bus_secondary[((cycle - 1) >> 1) as usize] = 0xFF; + } return; } if cycle <= 256 { @@ -7076,6 +7083,7 @@ mod tests { let mut p = Ppu::new(region); p.post_reset_mask_remaining = 0; p.mask = mask; // rendering off: the pixel is the backdrop + p.update_emph_offset(); p.palette_ram[palette_index(0x3F00)] = 0x21; p.scanline = 10; p.dot = 20; diff --git a/crates/rustynes-ppu/src/snapshot.rs b/crates/rustynes-ppu/src/snapshot.rs index 5d845a6e1..7ac34105f 100644 --- a/crates/rustynes-ppu/src/snapshot.rs +++ b/crates/rustynes-ppu/src/snapshot.rs @@ -910,6 +910,7 @@ impl Ppu { self.cached_idle_line = false; } self.flags_cached_scanline = i16::MIN; + self.update_emph_offset(); // sanity: the schema-fixed sizes mean we should be at end of input now. if r.pos != data.len() { diff --git a/crates/rustynes-test-harness/Cargo.toml b/crates/rustynes-test-harness/Cargo.toml index a15867af3..d28941fcb 100644 --- a/crates/rustynes-test-harness/Cargo.toml +++ b/crates/rustynes-test-harness/Cargo.toml @@ -79,7 +79,7 @@ png = { version = "0.18", optional = true } # `extract_rom_from_zip`. Native-only (the harness is never compiled for wasm); # `deflate`-only to match the frontend's feature set. `.7z` is handled via the # `7z` CLI (no Rust dep), matching `scripts/coverage/coverage.py`. -zip = { version = "8", default-features = false, features = ["deflate"], optional = true } +zip = { version = "9", default-features = false, features = ["deflate"], optional = true } [dev-dependencies] insta.workspace = true diff --git a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv index b31460365..906e27f84 100644 --- a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv +++ b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv @@ -3,8 +3,8 @@ # RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. `last_release_epoch` is the epoch # the last release shipped, set by hand at each release cut: output may # move only while EMULATION_EPOCH is above it. -# epoch 3 -# last_release_epoch 3 +# epoch 4 +# last_release_epoch 4 # rom frames framebuffer_fnv audio_fnv ram_fnv cpu_cycles accuracycoin/AccuracyCoin.nes 3200 e5edb57f59c38538 47e9857638ba5d28 b93b7feacb471f53 95268158 blargg/apu_mixer/triangle.nes 600 1b6dac56c42f267a 87f291ef58273cac 983e19fe15e197d6 17838587 diff --git a/crates/rustynes-test-harness/tests/common/mod.rs b/crates/rustynes-test-harness/tests/common/mod.rs index 4379fab2c..d7f7f2f45 100644 --- a/crates/rustynes-test-harness/tests/common/mod.rs +++ b/crates/rustynes-test-harness/tests/common/mod.rs @@ -282,7 +282,7 @@ pub mod external { use std::fs; use std::path::PathBuf; - use rustynes_core::{Buttons, Nes}; + use rustynes_core::{Buttons, ConsoleModel, Nes}; use rustynes_test_harness::coverage::{FrameHealth, frame_health}; use sha2::{Digest, Sha256}; @@ -338,6 +338,33 @@ pub mod external { free_run: u32, checkpoints: &'static [u32], }, + /// v3.2.0: arbitrary single-frame presses. `presses` lists + /// `(frame, buttons)`, 0-indexed frames in ascending order, each + /// held for exactly that one frame; every other frame is idle, and + /// the run lasts `frames` frames in all. For a menu that needs more + /// than START (a multicart's game list needs DOWN first: mapper 47's + /// second game is reached that way, which is what exercises its + /// block-select register). + Presses { + presses: &'static [(u32, Buttons)], + frames: u32, + checkpoints: &'static [u32], + }, + /// v3.2.0 — each `(frame, buttons)` in `presses` holds those buttons + /// for `hold` frames from that frame index (0-based), idle otherwise, + /// for `frames` frames; one checkpoint at the end. For games whose + /// menus ignore a one-frame tap (*Fudou Myouou Den*'s START / + /// CONTINUE menu reads the button across several frames) or need a + /// button other than START (*San Guo Zhi*'s questions take A), which + /// no other script can reach. The console RESET button is pressed at + /// the start of each frame index in `resets` (mapper 230's reset- + /// switched multicart). + Held { + presses: &'static [(u32, Buttons)], + hold: u32, + resets: &'static [u32], + frames: u32, + }, } impl InputScript { @@ -347,7 +374,9 @@ pub mod external { #[must_use] pub const fn total_frames(&self) -> u32 { match *self { - Self::IdleOnly { frames } => frames, + Self::IdleOnly { frames } + | Self::Presses { frames, .. } + | Self::Held { frames, .. } => frames, Self::StartTap { idle_pre, idle_post, @@ -487,11 +516,15 @@ pub mod external { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).ok()?; let idx = (0..archive.len()).find(|&i| { archive.by_index(i).is_ok_and(|f| { - std::path::Path::new(f.name()).extension().is_some_and(|e| { - e.eq_ignore_ascii_case("nes") - || e.eq_ignore_ascii_case("fds") - || e.eq_ignore_ascii_case("unf") - || e.eq_ignore_ascii_case("unif") + f.name().is_ok_and(|n| { + std::path::Path::new(n.as_ref()) + .extension() + .is_some_and(|e| { + e.eq_ignore_ascii_case("nes") + || e.eq_ignore_ascii_case("fds") + || e.eq_ignore_ascii_case("unf") + || e.eq_ignore_ascii_case("unif") + }) }) }) })?; @@ -743,8 +776,25 @@ pub mod external { /// /// Panics on a file-read / archive-extract / parse failure (hard-fail in a /// regression harness); a missing FDS BIOS is the one soft case. - #[allow(clippy::too_many_lines)] pub fn run_capture_opt(rom_rel: &str, script: InputScript) -> Option { + run_capture_model(rom_rel, script, ConsoleModel::Nes) + } + + /// Like [`run_capture_opt`], on the given [`ConsoleModel`]. + /// + /// v3.2.0: some dumps write `$2000`/`$2006` before the front-loading NES + /// PPU leaves its power-on warm-up, so on the default [`ConsoleModel::Nes`] + /// those writes are ignored and the game never draws. On a Famicom the PPU + /// is never held in reset and they land. The coverage sweep boots such + /// dumps the way a player must run them, as a Famicom, instead of listing + /// them as blank. The model is applied with a power-cycle, so the warm-up + /// counter starts from the model's own state rather than the NES one. + #[allow(clippy::too_many_lines)] + pub fn run_capture_model( + rom_rel: &str, + script: InputScript, + model: ConsoleModel, + ) -> Option { let path = external_rom_path(rom_rel); // The SHA-256 pins the *staged file* (archive bytes for a `.zip`/`.7z`, // disk bytes for a `.fds`, ROM bytes for a `.nes`) — a stable identity @@ -767,6 +817,10 @@ pub mod external { Load::Ok(nes) => *nes, Load::SkipNoBios => return None, }; + if model != ConsoleModel::Nes { + nes.set_console_model(model); + nes.power_cycle(); + } // Vs. System setup. Vs. arcade carts (iNES mapper 99, mapper 151, or // the NES-2.0/header Vs. console flag) boot to an attract loop and @@ -959,6 +1013,64 @@ pub mod external { maybe_emit(frame_counter, &nes); } } + InputScript::Presses { + presses, + frames, + checkpoints: checkpoint_frames, + } => { + let mut maybe_emit = |frame: u32, nes: &Nes| { + if checkpoint_frames.contains(&frame) { + let fb_hash = fnv1a64(nes.framebuffer()); + best_health = Some(best_of(best_health, frame_health(nes.framebuffer()))); + checkpoints.push(Checkpoint { frame, fb_hash }); + dump_frame(&label_safe, &format!("f{frame}"), nes.framebuffer()); + } + }; + assert!( + presses.windows(2).all(|w| w[0].0 < w[1].0), + "Presses frames must be strictly ascending" + ); + for f in 0..frames { + let held = presses + .iter() + .find(|&&(at, _)| at == f) + .map_or(Buttons::empty(), |&(_, b)| b); + nes.set_buttons(0, held); + pulse_vs_coin!(); + nes.run_frame(); + samples.extend(nes.drain_audio()); + frame_counter += 1; + maybe_emit(frame_counter, &nes); + } + } + InputScript::Held { + presses, + hold, + resets, + frames, + } => { + for f in 0..frames { + if resets.contains(&f) { + nes.reset(); + } + let held = presses + .iter() + .filter(|&&(p, _)| f >= p && f < p + hold) + .fold(Buttons::empty(), |acc, &(_, b)| acc | b); + nes.set_buttons(0, held); + pulse_vs_coin!(); + nes.run_frame(); + samples.extend(nes.drain_audio()); + frame_counter += 1; + } + let fb_hash = fnv1a64(nes.framebuffer()); + best_health = Some(best_of(best_health, frame_health(nes.framebuffer()))); + checkpoints.push(Checkpoint { + frame: frames, + fb_hash, + }); + dump_frame(&label_safe, &format!("f{frames}"), nes.framebuffer()); + } } // Defensive: ensure the framebuffer is the canonical size. diff --git a/crates/rustynes-test-harness/tests/external_coverage.rs b/crates/rustynes-test-harness/tests/external_coverage.rs index d266e25cb..f0a1110b2 100644 --- a/crates/rustynes-test-harness/tests/external_coverage.rs +++ b/crates/rustynes-test-harness/tests/external_coverage.rs @@ -118,7 +118,8 @@ mod common; use std::fs; use std::path::{Path, PathBuf}; -use common::external::{InputScript, run_capture_opt, snapshot_text}; +use common::external::{InputScript, run_capture_model, snapshot_text}; +use rustynes_core::{Buttons, ConsoleModel}; /// Default boot capture for breadth coverage. A discovered ROM's intro /// structure is unknown, so a passive idle frequently lands on a black @@ -169,14 +170,6 @@ const UNSUPPORTED: &[(&str, &str)] = &[ "mapper-146-Sachen-NINA/Lucky 777 (Sachen) [!].zip", "malformed: mapper 146 PRG is a multiple of 32 KiB and this image has 16 KiB", ), - ( - "mapper-072-Jaleco72/Doraemon World 3 by Kiku (Doraemon Hack).nes", - "dirty iNES 1.0 tail (\"DiskDude!\"): the masked header is mapper 8 (FFE), not implemented", - ), - ( - "mapper-072-Jaleco72/Doraemon World 3 by Kiku (Doraemon Hack).zip", - "dirty iNES 1.0 tail (\"DiskDude!\"): the masked header is mapper 8 (FFE), not implemented", - ), ( "vs-system/VS Castlevania Hack.nes", "not an iNES image (no NES<1A> magic)", @@ -194,17 +187,29 @@ const UNSUPPORTED: &[(&str, &str)] = &[ /// unread. v2.9.8 cut the list to 52: the dirty-tail header rule and the game /// database's NES 2.0 guard made seven of them render, two Doraemon hacks moved /// to `UNSUPPORTED` as the mapper 8 images they are, and one documented -/// blank, Famicom Jump II, was added. +/// blank, Famicom Jump II, was added. v3.2.0 cut it to 41: ten of the 52 were booted on +/// the wrong console ([`FAMICOM_WIRED`]), and the Subor cart blanks its own +/// menu at about f375, which the START taps ran past (see `capture_override`). +/// v3.2.0 also implements mapper 8, the FFE Magic Card's GNROM mode, so +/// the two Doraemon World 3 images left `UNSUPPORTED`: they boot to their +/// title and stage-select screens and carry ordinary snapshots. const KNOWN_BLANK: &[&str] = &[ "fds/Akumajou Dracula (Japan) (Rev 2) (Disk Writer).fds", + // Cause measured at v3.2.0: a mis-headered dump. The payload is the VRC4 + // cartridge's (mapper 25; four bytes of a data table differ, the Virtual + // Console port's edit), under a mapper-9 header with the Vs. bit set. It + // draws with the cartridge's header; the real cart is staged under 25. "mapper-009-MMC2/Gradius II (J) (VC).zip", - "mapper-015-Multicart15/Bio Hazard (Unl) [!].nes", "mapper-015-Multicart15/Doraemon (J) (PRG0) [hM15].zip", "mapper-015-Multicart15/Dragon Ball - Shen Long no Nazo (J) [hM15].zip", "mapper-015-Multicart15/Mobile Suit Z Gundam - Hot Scramble (J) [hM15].zip", - "mapper-015-Multicart15/Subor V1.0 (R).nes", - "mapper-015-Multicart15/Xiao Au Jiang Wu (Ch) [!].nes", "mapper-021-VRC2-VRC4/TwinBee Yahoo!! - Over the Sea Music Demo (PD).zip", + // Open at v3.2.0. A converted Waixing .WXN image, routed to 176/2 like its + // two siblings in that directory, which boot. It runs the FS005 copy- + // protection sequence and the decoded stub at $0100 correctly, then crashes + // into data at $FF40 in frame 4 and halts on a JAM. Re-headered as each 176 + // submapper and ten other Waixing mappers it never draws. A bad or + // differently-converted dump is the leading explanation; no second dump. "mapper-030-UNROM512/Chu Liu Xiang (Ch) (Wxn).nes", "mapper-034-BNROM-NINA001/Bio Senshi Dan - Increaser Tono Tatakai (J) [hM34].zip", "mapper-034-BNROM-NINA001/Dragon Ball - Shen Long no Nazo (J) [hM34].zip", @@ -227,7 +232,6 @@ const KNOWN_BLANK: &[&str] = &[ "mapper-099-VsSystem/Tennis (VS) [!].nes", "mapper-099-VsSystem/Wrecking Crew (VS) [!].nes", "mapper-112-NTDEC-Asder/Fighting Hero III (Unl) [!].nes", - "mapper-139-Sachen8259C/Final Combat (Sachen-JAP) [!].zip", "mapper-142-KaiserKS7032/Pipe 5 (Sachen) [!].zip", // Added at v2.9.8 (maintainer decision, 2026-10-01). Unlike the rest, this // is the game behaving as documented, not a defect: with its battery WRAM @@ -237,16 +241,9 @@ const KNOWN_BLANK: &[&str] = &[ "mapper-153-BandaiLZ93D50/Famicom Jump II - Saikyou no 7 Nin (Japan).nes", "mapper-162-WaixingFS304/Chong Wu Jin Hua Shi (Pet Evolve) (ES-1085) (Ch).nes", "mapper-162-WaixingFS304/Shu Ma Bao Bei (Digimon Crystal) (Ch) (Wxn).nes", - "mapper-162-WaixingFS304/Xi You Ji Hou Zhuan (Ch).nes", - "mapper-164-WaixingFinalFantasy/Digital Dragon (Ch) [!].zip", "mapper-177-Hengedianzi/Xing He Zhan Shi (Ch).nes", "mapper-178-WaixingEdu/Xing Ji Zheng Ba (Ch).nes", - "mapper-185-CNROM-Lock/Sansuu 1 Nen - Keisan Game (Japan).zip", - "mapper-185-CNROM-Lock/Sansuu 2 Nen - Keisan Game (Japan).zip", - "mapper-185-CNROM-Lock/Sansuu 3 Nen - Keisan Game (Japan).zip", "mapper-205-BMC-JC016/4-in-1 (K-3131GS, GN-45) [p1][!].zip", - "mapper-227-BMC-1200in1/Biohazard (China) (Unl) (En) (1.0).nes", - "mapper-227-BMC-1200in1/Xiao Ao Jiang Wu (Ch) (Wxn).nes", "vs-system/GVS Balloon Fight (Dual).nes", "vs-system/GVS Balloon Fight.nes", "vs-system/GVS Mahjong.nes", @@ -255,6 +252,36 @@ const KNOWN_BLANK: &[&str] = &[ "vs-system/GVS Wrecking Crew.nes", ]; +/// Staged dumps that boot only with Famicom reset wiring (v3.2.0). +/// +/// Each writes `$2000`, `$2001`, `$2005` or `$2006` within the first ~29,658 +/// CPU cycles after power-on, the window in which a front-loading NES PPU +/// ignores those registers (NESdev "PPU power up state"). The Famicom does +/// not hold its PPU in reset, so on one the writes land and the game draws. +/// These are Famicom-market and Chinese-market carts that were never meant +/// for the NES, so the sweep boots them as a Famicom +/// ([`rustynes_core::ConsoleModel::Famicom`]) instead of listing them blank. +/// Until v3.2.0 all ten sat in [`KNOWN_BLANK`]. +/// +/// The list is a ratchet as well: each entry is ALSO booted on the default +/// NES model, and that boot must still be blank. A dump that starts drawing on +/// the NES model no longer needs Famicom wiring and fails the sweep until it +/// leaves this list. The frontend does not pick the model from the ROM (the +/// design in `ConsoleModel`'s docs: NES 2.0 cannot say which console a cart is +/// for); the player chooses Famicom in the settings. +const FAMICOM_WIRED: &[&str] = &[ + "mapper-015-Multicart15/Bio Hazard (Unl) [!].nes", + "mapper-015-Multicart15/Xiao Au Jiang Wu (Ch) [!].nes", + "mapper-139-Sachen8259C/Final Combat (Sachen-JAP) [!].zip", + "mapper-162-WaixingFS304/Xi You Ji Hou Zhuan (Ch).nes", + "mapper-164-WaixingFinalFantasy/Digital Dragon (Ch) [!].zip", + "mapper-185-CNROM-Lock/Sansuu 1 Nen - Keisan Game (Japan).zip", + "mapper-185-CNROM-Lock/Sansuu 2 Nen - Keisan Game (Japan).zip", + "mapper-185-CNROM-Lock/Sansuu 3 Nen - Keisan Game (Japan).zip", + "mapper-227-BMC-1200in1/Biohazard (China) (Unl) (En) (1.0).nes", + "mapper-227-BMC-1200in1/Xiao Ao Jiang Wu (Ch) (Wxn).nes", +]; + const DEFAULT_CAPTURE: InputScript = InputScript::RepeatStartTap { warmup: 240, period: 150, @@ -283,6 +310,44 @@ fn capture_override(rom_rel: &str) -> Option { const IDLE: InputScript = InputScript::IdleOnly { frames: 700 }; // A few titles need a longer fade to settle on the title screen. const IDLE_LONG: InputScript = InputScript::IdleOnly { frames: 1200 }; + // v3.2.0: the Subor keyboard cart draws its menu by f100 and blanks it at + // about f375 (with no keyboard attached), so neither the START taps nor a + // 700-frame idle ends on it. It was first put in FAMICOM_WIRED, whose + // ratchet refused it: on the NES model it draws the same menu. + const SUBOR_MENU: InputScript = InputScript::Presses { + presses: &[], + frames: 350, + checkpoints: &[100, 350], + }; + // v3.2.0: mapper 230 (BestEffort). It powers on in *Contra*; RESET at f700 + // switches to the multicart chip and its menu, two held DOWNs reach entry + // 5, *Sky Destroyer*, A launches it and START begins a game, in flight by + // f3500. One run covers both chips, the reset toggle and the multicart + // register. Moved here from `external_extended`, which may not hold a + // BestEffort oracle (ADR 0011). + const RESET_22_IN_1: InputScript = InputScript::Held { + presses: &[ + (2300, Buttons::DOWN), + (2400, Buttons::DOWN), + (2600, Buttons::A), + (2900, Buttons::START), + ], + hold: 10, + resets: &[700], + frames: 3500, + }; + // v3.2.0: titles whose own title screen or attract mode draws only after a + // long intro (a story crawl, a slow logo), past both IDLE and IDLE_LONG. + // Each frame count was chosen by looking at the frames around it. + const IDLE_1500: InputScript = InputScript::IdleOnly { frames: 1500 }; + const IDLE_1800: InputScript = InputScript::IdleOnly { frames: 1800 }; + // v3.2.0: Karnov. The default's five START taps PAUSE it in play (the + // capture showed "PAUSE"); one tap starts the game, in play by f700. + const ONE_START: InputScript = InputScript::Presses { + presses: &[(300, Buttons::START)], + frames: 1100, + checkpoints: &[700, 1100], + }; let idle = [ "mapper-000-NROM/Gyromite.nes", "mapper-001-MMC1/Dr. Mario.nes", @@ -297,9 +362,37 @@ fn capture_override(rom_rel: &str) -> Option { "mapper-085-VRC7/Lagrange Point (Japan) (En) (1.01).nes", "mapper-119-TQROM/High Speed (E) [!].nes", "mapper-119-TQROM/Pin Bot (E) [!].nes", + // v3.2.0: the title (or, for Play Action Football, an intro cutscene) + // instead of the menu or select screen the START taps reach. + "mapper-011-ColorDreams/Castle of Deceit (USA) (Unl).nes", + "mapper-118-TxSROM/Goal! Two.nes", + "mapper-118-TxSROM/NES Play Action Football.nes", + "mapper-118-TxSROM/Pro Sport Hockey.nes", + "mapper-140-Jaleco140/Mississippi Satsujin Jiken (Japan) (Rev A).nes", + "mapper-206-Namcot118/Family Circuit (Japan) (Namcot Collection).nes", + ]; + let idle_long = [ + "mapper-001-MMC1/Tecmo Bowl.nes", + // v3.2.0: DW3's stone title (the START taps reach the save menu) and + // Babel no Tou's attract-mode play (they reach the floor select). + "mapper-001-MMC1/Dragon Warrior III.nes", + "mapper-206-Namcot118/Babel no Tou (Japan) (Namcot Collection).nes", ]; - let idle_long = ["mapper-001-MMC1/Tecmo Bowl.nes"]; - if idle.contains(&rom_rel) { + match rom_rel { + // DW4's title logo finishes drawing at about f1700. + "mapper-001-MMC1/Dragon Warrior IV.nes" => return Some(IDLE_1800), + // Armadillo's title follows a story crawl, from about f1400 (the START + // taps reach a "Battle Box not connected" notice). + "mapper-118-TxSROM/Armadillo (Japan).nes" => return Some(IDLE_1500), + "mapper-206-Namcot118/Karnov.nes" => return Some(ONE_START), + _ => {} + } + if rom_rel == "mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1].zip" { + return Some(RESET_22_IN_1); + } + if rom_rel == "mapper-015-Multicart15/Subor V1.0 (R).nes" { + Some(SUBOR_MENU) + } else if idle.contains(&rom_rel) { Some(IDLE) } else if idle_long.contains(&rom_rel) { Some(IDLE_LONG) @@ -646,8 +739,27 @@ fn external_coverage_boot_smoke() { let capture_script = capture_override(rom_rel).unwrap_or(DEFAULT_CAPTURE); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe( move || -> Result, String> { + let famicom = FAMICOM_WIRED.contains(&rom.as_str()); + let model = if famicom { + ConsoleModel::Famicom + } else { + ConsoleModel::Nes + }; + // The FAMICOM_WIRED ratchet: on the NES model the same boot + // must still be blank, or the entry is no longer needed. It runs + // FIRST so the frames `RUSTYNES_DUMP_FRAMES` writes for review + // are the Famicom boot's, which overwrites them. + if famicom { + let nes_boot = run_capture_model(&rom, capture_script, ConsoleModel::Nes) + .expect("a FAMICOM_WIRED entry is a cartridge, not an FDS disk"); + if !nes_boot.best_frame_health.looks_blank() { + return Err("in FAMICOM_WIRED but also draws on the NES model; \ + remove it from the list" + .to_string()); + } + } // FDS disk on a BIOS-less checkout -> clean skip (Ok(None)). - let Some(capture) = run_capture_opt(&rom, capture_script) else { + let Some(capture) = run_capture_model(&rom, capture_script, model) else { return Ok(None); }; @@ -677,7 +789,10 @@ fn external_coverage_boot_smoke() { }; // (2) Baseline snapshot comparison. - let text = snapshot_text(&rom, capture_script, &capture); + let mut text = snapshot_text(&rom, capture_script, &capture); + if famicom { + text.insert_str(0, "console: Famicom (FAMICOM_WIRED)\n"); + } insta::assert_snapshot!(snap.as_str(), text); // Snapshot passed; report the health verdict (if blank). diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 83b1bb3f3..f82d1739a 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -39,6 +39,7 @@ mod common; use common::external::{InputScript, run_capture, snapshot_text}; +use rustynes_core::Buttons; /// Default script: 600 frames idle (no input), 10 s @ NTSC 60 Hz — /// past the title-screen ramp-up for the bulk of these games. @@ -1418,3 +1419,609 @@ fn extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu() { "extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu", ); } + +// ============================================================ +// v3.2.0 — tier promotions under D23 (`T-CURATED-EVIDENCE`) +// ============================================================ + +/// Mapper 47 (NES-QJ, *Super Spike V'Ball + Nintendo World Cup*): DOWN then +/// START on the cartridge's game-select screen picks the SECOND game, which +/// lives in the second 128 KiB block. Reaching it is what exercises the +/// block-select register at `$6000-$7FFF` (writable only while the MMC3's +/// PRG-RAM is enabled and writable, NESdev `INES_Mapper_047`); the default +/// coverage capture only ever starts the first game, block 0. +const M47_SECOND_GAME: InputScript = InputScript::Presses { + presses: &[ + (240, rustynes_core::Buttons::DOWN), + (260, rustynes_core::Buttons::START), + (480, rustynes_core::Buttons::START), + (700, rustynes_core::Buttons::START), + ], + frames: 1000, + checkpoints: &[239, 300, 600, 1000], +}; + +#[test] +fn extended_m47_nintendo_world_cup_block_1() { + check( + "mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U) [!].nes", + M47_SECOND_GAME, + "extended_m47_nintendo_world_cup_block_1", + ); +} + +// ============================================================ +// Mapper 157 — Bandai Datach Joint ROM System (6 ROMs, v3.2.0) +// ============================================================ +// +// Boot baselines: 1200 idle frames (the Datach logo, then each game's own +// title or attract loop; `docs/compatibility.md`, D23). The barcode tests +// below drive a game to a screen waiting for a card, swipe a code past the +// reader through `Nes::scan_barcode`, and pin what the game did with it. + +const DATACH_DIR: &str = "mapper-157-Datach"; + +#[test] +fn extended_m157_battle_rush() { + check( + "mapper-157-Datach/Datach - Battle Rush - Build Up Robot Tournament (Japan).nes", + LONG_IDLE, + "extended_m157_battle_rush", + ); +} + +#[test] +fn extended_m157_dragon_ball_z_gekitou_tenkaichi_budoukai() { + check( + "mapper-157-Datach/Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan).nes", + LONG_IDLE, + "extended_m157_dragon_ball_z_gekitou_tenkaichi_budoukai", + ); +} + +#[test] +fn extended_m157_j_league_super_top_players() { + check( + "mapper-157-Datach/Datach - J.League Super Top Players (Japan).nes", + LONG_IDLE, + "extended_m157_j_league_super_top_players", + ); +} + +#[test] +fn extended_m157_sd_gundam_gundam_wars() { + check( + "mapper-157-Datach/Datach - SD Gundam - Gundam Wars (Japan).nes", + LONG_IDLE, + "extended_m157_sd_gundam_gundam_wars", + ); +} + +#[test] +fn extended_m157_ultraman_club_supokon_fight() { + check( + "mapper-157-Datach/Datach - Ultraman Club - Supokon Fight! (Japan).nes", + LONG_IDLE, + "extended_m157_ultraman_club_supokon_fight", + ); +} + +#[test] +fn extended_m157_yu_yu_hakusho_bakutou_ankoku_bujutsukai() { + check( + "mapper-157-Datach/Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan).nes", + LONG_IDLE, + "extended_m157_yu_yu_hakusho_bakutou_ankoku_bujutsukai", + ); +} + +/// One step of a Datach navigation script: run idle frames, or hold a button +/// for some frames and release it. +#[derive(Clone, Copy)] +enum DatachStep { + Idle(u32), + Hold(rustynes_core::Buttons, u32), +} + +/// Drive `rom` (under `mapper-157-Datach/`) through `steps`, then run +/// `after` frames three ways from the same state: with no scan, after +/// scanning `codes[0]`, and after scanning `codes[1]`. Returns the three +/// final-frame hashes, or `None` (a clean skip) when the dump is not staged. +fn datach_scan_outcomes( + rom: &str, + steps: &[DatachStep], + codes: [&str; 2], + after: u32, +) -> Option<[u64; 3]> { + use common::external::{Load, external_rom_path, load_nes}; + use rustynes_core::Buttons; + let rel = format!("{DATACH_DIR}/{rom}"); + if !external_rom_path(&rel).exists() { + eprintln!("[external_extended] SKIP {rel}: dump not staged"); + return None; + } + let Load::Ok(mut nes) = load_nes(&rel) else { + panic!("{rel} is a cartridge, not a disk"); + }; + assert!(nes.has_barcode_reader(), "mapper 157 has a reader"); + for step in steps { + match *step { + DatachStep::Idle(n) => (0..n).for_each(|_| { + nes.run_frame(); + }), + DatachStep::Hold(b, n) => { + nes.set_buttons(0, b); + (0..n).for_each(|_| { + nes.run_frame(); + }); + nes.set_buttons(0, Buttons::empty()); + } + } + } + let at_prompt = nes.snapshot(); + let mut out = [0u64; 3]; + for (i, code) in [None, Some(codes[0]), Some(codes[1])] + .into_iter() + .enumerate() + { + nes.restore(&at_prompt).expect("restore the prompt state"); + if let Some(code) = code { + nes.scan_barcode(code).expect("a valid code on a Datach"); + assert!(nes.barcode_scan_in_progress()); + } + for _ in 0..after { + nes.run_frame(); + } + assert!(!nes.barcode_scan_in_progress(), "the swipe has ended"); + let label = code.map_or_else(|| "noscan".to_string(), |c| format!("scan{c}")); + common::dump_frame_if_requested("datach", rom, &label, nes.framebuffer()); + out[i] = common::fnv1a64(nes.framebuffer()); + } + Some(out) +} + +/// *Dragon Ball Z: Gekitou Tenkaichi Budoukai*, Karin's room ("さあ おまえの +/// バーコードを みせてみろ!", "now show me your barcode!"). Seen when the +/// expectations were pinned: 4006381333931 brings out Dende ("HPを +/// かんぜんかいふく", full HP recovery), the EAN-8 96385074 Android 18 +/// (HP 96000 / BP 86750 / DP 12750), and with no scan the room keeps waiting. +#[test] +fn extended_m157_dbz_karin_room_reads_a_scanned_card() { + use DatachStep::{Hold, Idle}; + use rustynes_core::Buttons; + let steps = [ + Idle(300), + Hold(Buttons::START, 2), + Idle(300), + Hold(Buttons::START, 2), + Idle(60), + Hold(Buttons::START, 2), + Idle(120), + Hold(Buttons::DOWN, 2), + Idle(30), + Hold(Buttons::START, 2), + Idle(180), + Hold(Buttons::A, 2), + Idle(180), + Hold(Buttons::A, 2), + Idle(180), + Hold(Buttons::A, 2), + Idle(180), + Hold(Buttons::A, 2), + Idle(120), + ]; + let Some([none, ean13, ean8]) = datach_scan_outcomes( + "Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan).nes", + &steps, + ["4006381333931", "96385074"], + 600, + ) else { + return; + }; + assert_ne!(none, ean13, "the EAN-13 scan changed nothing"); + assert_ne!(none, ean8, "the EAN-8 scan changed nothing"); + assert_ne!(ean13, ean8, "two different cards read the same"); + insta::assert_snapshot!( + "extended_m157_dbz_karin_room_reads_a_scanned_card", + format!( + "noscan fb={none:016x}\nscan 4006381333931 fb={ean13:016x}\nscan 96385074 fb={ean8:016x}" + ) + ); +} + +/// *SD Gundam Wars*, Barcode Lab ("バーコード インプット!"). Seen when the +/// expectations were pinned: 4006381333931 is analysed ("バーコード +/// カイセキチュウ") and becomes the unit "ラフレシア" (Rafflesia: mega +/// particle cannon, CP 10, 1 turn); 96385074 becomes the RX-78 Gundam (HP +/// 4030, AP 2720, DP 4420, CP 10). +#[test] +fn extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card() { + use DatachStep::{Hold, Idle}; + use rustynes_core::Buttons; + let steps = [ + Idle(900), + Hold(Buttons::START, 2), + Idle(120), + Hold(Buttons::DOWN, 2), + Idle(20), + Hold(Buttons::DOWN, 2), + Idle(20), + Hold(Buttons::A, 2), + Idle(150), + Hold(Buttons::A, 2), + Idle(300), + Hold(Buttons::A, 2), + Idle(100), + ]; + let Some([none, ean13, ean8]) = datach_scan_outcomes( + "Datach - SD Gundam - Gundam Wars (Japan).nes", + &steps, + ["4006381333931", "96385074"], + 720, + ) else { + return; + }; + assert_ne!(none, ean13, "the EAN-13 scan changed nothing"); + assert_ne!(none, ean8, "the EAN-8 scan changed nothing"); + assert_ne!(ean13, ean8, "two different cards read the same"); + insta::assert_snapshot!( + "extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card", + format!( + "noscan fb={none:016x}\nscan 4006381333931 fb={ean13:016x}\nscan 96385074 fb={ean8:016x}" + ) + ); +} + +// ============================================================ +// v3.2.0 — MMC3-derived families written from their NESdev pages +// (`mmc3_boards_ext.rs`). One GoodNES dump per family, byte-identity boot +// snapshot; the D23 records are in `docs/compatibility.md`. +// ============================================================ + +#[test] +fn extended_m114_aladdin_supergame() { + check( + "mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].zip", + DEFAULT_IDLE, + "extended_m114_aladdin_supergame", + ); +} + +/// Submapper 1's scramble. GoodNES labels this dump mapper 215, the +/// assignment `INES_Mapper_215.md` says was later withdrawn ("Boogerman +/// (Mapper 114 Submapper 1) was originally assigned by CaH4e3 to Mapper +/// 215"); it never writes `$5007`, so it does not boot as 215. The staged +/// copy carries a NES 2.0 header naming 114.1; the bytes after the header, +/// and so `Nes::rom_sha256`, are the GoodNES dump's. +#[test] +fn extended_m114_1_boogerman() { + check( + "mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).nes", + DEFAULT_IDLE, + "extended_m114_1_boogerman", + ); +} + +#[test] +fn extended_m182_super_donkey_kong() { + check( + "mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].zip", + DEFAULT_IDLE, + "extended_m182_super_donkey_kong", + ); +} + +/// Scramble pattern 4 (`$5007` = 4), 256 KiB PRG + 512 KiB CHR. +#[test] +fn extended_m215_mortal_kombat_3_extra_60() { + check( + "mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].zip", + DEFAULT_IDLE, + "extended_m215_mortal_kombat_3_extra_60", + ); +} + +/// Scramble pattern 1 (`$5007` = 1): the 2-in-1 menu. +#[test] +fn extended_m215_super_808() { + check( + "mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].zip", + DEFAULT_IDLE, + "extended_m215_super_808", + ); +} + +/// Submapper 0 wiring (the page's PRG CRC32 `0xC333F621` copy). +#[test] +fn extended_m197_super_fighter_iii() { + check( + "mapper-197-SuperFighter3/Super Fighter III (Unl) [!].zip", + DEFAULT_IDLE, + "extended_m197_super_fighter_iii", + ); +} + +/// Fire Emblem translations hold the Waixing logo (drawn from the CHR-RAM +/// page) until START; a tap at frame 700 reaches the title and a second +/// tap starts the intro. Checkpoints: the title, then the intro. +const M165_TITLE_INTRO: InputScript = InputScript::DoubleStartTap { + idle_pre: 700, + gap: 400, + idle_post: 0, + free_run: 1100, + checkpoints: &[900, 1700, 2100], +}; + +#[test] +fn extended_m165_fire_emblem_ch() { + check( + "mapper-165-FireEmblemCh/Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch).zip", + M165_TITLE_INTRO, + "extended_m165_fire_emblem_ch", + ); +} + +/// Needs the board's work RAM: without it the title is garbage tiles. +#[test] +fn extended_m165_fire_emblem_gaiden_ch() { + check( + "mapper-165-FireEmblemCh/Fire Emblem Gaiden (Ch).zip", + M165_TITLE_INTRO, + "extended_m165_fire_emblem_gaiden_ch", + ); +} + +/// One START at frame 400 starts Green Hill Zone act 1; the f1000 checkpoint +/// is gameplay (Somari, the HUD and the scrolling zone). The game cycles the +/// supervisor through all three chips while it initialises. +const M116_SOMARI_PLAY: InputScript = InputScript::StartTap { + idle_pre: 400, + idle_post: 0, + free_run: 799, + checkpoints: &[1000], +}; + +#[test] +fn extended_m116_somari() { + check( + "mapper-116-SomariW/Somari (SOMARI-W) (Unl) [!].zip", + M116_SOMARI_PLAY, + "extended_m116_somari", + ); +} + +#[test] +fn extended_m116_kart_fighter() { + check( + "mapper-116-SomariW/Kart Fighter (SOMARI-W) (Unl) [!].zip", + DEFAULT_IDLE, + "extended_m116_kart_fighter", + ); +} + +// ============================================================ +// v3.2.0 — boards written from their NESdev pages, each verified on a local +// dump (D23 records in docs/compatibility.md). +// ============================================================ + +#[test] +fn extended_m172_1991_du_ma_racing() { + check( + "mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].zip", + DEFAULT_IDLE, + "extended_m172_1991_du_ma_racing", + ); +} + +#[test] +fn extended_m173_xiao_ma_li() { + check( + "mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].zip", + DEFAULT_IDLE, + "extended_m173_xiao_ma_li", + ); +} + +/// 2400-frame idle: *Alien Syndrome*'s attract demo is in gameplay by then, +/// scrolling under its status bar, which is where mapper 158's per-nametable +/// CIRAM select shows. The 600-frame intro text uses one page only. +const ALIEN_SYNDROME_DEMO: InputScript = InputScript::IdleOnly { frames: 2400 }; + +#[test] +fn extended_m158_alien_syndrome() { + check( + "mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).zip", + ALIEN_SYNDROME_DEMO, + "extended_m158_alien_syndrome", + ); +} + +/// *Fudou Myouou Den*: START held three frames at f1900 (skips the intro to +/// the START / CONTINUE menu) and again at f2100 (starts the game), then play +/// to f3000, in the first stage with the status bar under the playfield. The +/// menu ignores one-frame taps, so `Held`. Gameplay is where mapper +/// 207's CHR A17 nametable select shows: the title screen and the intro draw +/// identically with it forced to page 0, and this frame does not. +const FUDOU_IN_PLAY: InputScript = InputScript::Held { + presses: &[(1900, Buttons::START), (2100, Buttons::START)], + hold: 3, + resets: &[], + frames: 3000, +}; + +#[test] +fn extended_m207_fudou_myouou_den() { + check( + "mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).zip", + FUDOU_IN_PLAY, + "extended_m207_fudou_myouou_den", + ); +} + +/// *San Guo Zhi* (mapper 252): START held at f1500-f3300 passes the intro, +/// the difficulty menu and the first prompt to the scenario map, and A at +/// f3600 answers it, so at f3800 the text box under the map asks the first +/// personality question. That box sits behind a VRC4 scanline-IRQ bank split +/// and is drawn from the two CHR-RAM pages; the portraits above it come from +/// CHR-ROM. The first prompt is not enough: its glyphs are pre-rendered in +/// CHR-ROM pages 6 and 7, so it draws the same with no CHR-RAM at all, and +/// only the questions after it are composed in RAM. +const SAN_GUO_ZHI_QUESTION: InputScript = InputScript::Held { + presses: &[ + (1500, Buttons::START), + (1800, Buttons::START), + (2100, Buttons::START), + (2400, Buttons::START), + (3000, Buttons::START), + (3300, Buttons::START), + (3600, Buttons::A), + ], + hold: 3, + resets: &[], + frames: 3800, +}; + +#[test] +fn extended_m252_san_guo_zhi() { + check( + "mapper-252-WaixingVRC4/San Guo Zhi (Ch).zip", + SAN_GUO_ZHI_QUESTION, + "extended_m252_san_guo_zhi", + ); +} + +// Mapper 230 (*22-in-1 (Reset Based)*) is BestEffort (the maintainer's +// ruling, 2026-10-09: its notes leave the power-on mode and the reset +// behaviour to the model), so it has no byte-identity oracle test here; ADR +// 0011 keeps BestEffort out of this file. Its reset-to-*Sky Destroyer* run is +// `external_coverage`'s capture for that dump (`capture_override`). + +/// *260-in-1* (mapper 235): RIGHT moves the menu cursor to entry 21, "MR. +/// MARIO I", A launches it and START begins a game; phase 1 is in play by +/// f1600. The staged image is the GoodNES `[!]` dump with its header +/// rewritten to NES 2.0: its iNES byte 4 is 0, which iNES cannot use for the +/// 4 MiB it holds (the D23 hash is of the bytes after the header, which are +/// unchanged). +const GOLDEN_GAME_MARIO: InputScript = InputScript::Held { + presses: &[ + (600, Buttons::RIGHT), + (800, Buttons::A), + (1100, Buttons::START), + ], + hold: 6, + resets: &[], + frames: 1600, +}; + +#[test] +fn extended_m235_260_in_1_mario_bros() { + check( + "mapper-235-GoldenGame/260-in-1 [p1][!] (NES 2.0 header, 4 MiB).nes", + GOLDEN_GAME_MARIO, + "extended_m235_260_in_1_mario_bros", + ); +} + +/// FFE Magic Card / Super Magic Card extractions (mappers 6, 8, 17): START +/// at f1000 and f1400, A at f1800, START at f2200, each held six frames, then +/// run to f2600. That reaches play or the first in-game screen on these +/// titles, past the menus the trainers and the card's IRQs drive. +const FFE_INTO_PLAY: InputScript = InputScript::Held { + presses: &[ + (1000, Buttons::START), + (1400, Buttons::START), + (1800, Buttons::A), + (2200, Buttons::START), + ], + hold: 6, + resets: &[], + frames: 2600, +}; + +/// *Wai Wai World* (mapper 6) has a longer intro and character menu: the +/// script answers it with A and START, then holds RIGHT from f3500 to walk +/// into the first stage, in play at f3800. +const FFE_WAI_WAI_WORLD: InputScript = InputScript::Held { + presses: &[ + (1000, Buttons::START), + (1300, Buttons::START), + (1600, Buttons::A), + (1900, Buttons::A), + (2200, Buttons::START), + (2500, Buttons::A), + (2800, Buttons::START), + (3100, Buttons::A), + (3400, Buttons::RIGHT), + (3410, Buttons::RIGHT), + (3420, Buttons::RIGHT), + (3430, Buttons::RIGHT), + (3440, Buttons::RIGHT), + (3450, Buttons::RIGHT), + ], + hold: 10, + resets: &[], + frames: 3800, +}; + +#[test] +fn extended_m006_ganbare_goemon() { + check( + "mapper-006-FFE-MagicCard/Ganbare Goemon! - Karakuri Douchuu (J) [hFFE].zip", + FFE_INTO_PLAY, + "extended_m006_ganbare_goemon", + ); +} + +#[test] +fn extended_m006_cosmo_police_galivan() { + check( + "mapper-006-FFE-MagicCard/Cosmo Police Galivan (J) [hFFE][p1].zip", + FFE_INTO_PLAY, + "extended_m006_cosmo_police_galivan", + ); +} + +#[test] +fn extended_m006_wai_wai_world() { + check( + "mapper-006-FFE-MagicCard/Wai Wai World (J) [hFFE].zip", + FFE_WAI_WAI_WORLD, + "extended_m006_wai_wai_world", + ); +} + +#[test] +fn extended_m008_mermaids_of_atlantis() { + check( + "mapper-008-FFE-GNROM/Mermaids of Atlantis - The Riddle of the Magic Bubble (AVE) [T+Rus_Guyver].zip", + FFE_INTO_PLAY, + "extended_m008_mermaids_of_atlantis", + ); +} + +#[test] +fn extended_m017_kaiketsu_yanchamaru_2() { + check( + "mapper-017-FFE-SuperMagicCard/Kaiketsu Yanchamaru 2 - Karakuri Land (J) [hFFE].zip", + FFE_INTO_PLAY, + "extended_m017_kaiketsu_yanchamaru_2", + ); +} + +#[test] +fn extended_m017_juvei_quest() { + check( + "mapper-017-FFE-SuperMagicCard/Juvei Quest (J) [hFFE].zip", + FFE_INTO_PLAY, + "extended_m017_juvei_quest", + ); +} + +#[test] +fn extended_m017_dragon_ball_z_iii() { + check( + "mapper-017-FFE-SuperMagicCard/Dragon Ball Z III - Ressen Jinzou Ningen (J) [hFFE][p1].zip", + FFE_INTO_PLAY, + "extended_m017_dragon_ball_z_iii", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index e9e61775d..1982b1dd2 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -100,6 +100,10 @@ const CHIPS: &[Chip] = &[ snapshot_src: include_str!("../../rustynes-ppu/src/snapshot.rs"), writer_fns: &[], derived_or_config: &[ + ( + "cached_emph_offset", + "derived: reconstructed during load_state based on current region settings", + ), ( "oam_decay_enabled", "config: the opt-in decay model's enable flag, re-applied by the host on load \ @@ -474,6 +478,12 @@ const CHIPS: &[Chip] = &[ "mapper_caps", "board identity: the mapper's static capability flags", ), + ( + "prg_may_unmap", + "board identity: Mapper::may_unmap_prg_rom, a per-type constant \ + (true only for 228 and 235), cached at construction and at the \ + mapper re-instantiation in load_state, like mapper_caps", + ), ( "rom_bytes", "board identity: the ROM image itself; a save state never carries it", diff --git a/crates/rustynes-test-harness/tests/v21_best_effort_sweep.rs b/crates/rustynes-test-harness/tests/v21_best_effort_sweep.rs index f7a8ce207..4f1c16908 100644 --- a/crates/rustynes-test-harness/tests/v21_best_effort_sweep.rs +++ b/crates/rustynes-test-harness/tests/v21_best_effort_sweep.rs @@ -192,9 +192,16 @@ fn best_effort_set_is_nonempty_and_matches_classifier() { // 191 and 194 arrived, whose NESdev pages give only Disch's notes or // "probably" masks. The submapper-level BestEffort variants (4.3 MC-ACC, // 91.1, 176.2) are not counted here: this sweep classifies submapper 0. + // + // 31 -> 26 in v3.2.0: 47, 121, 154, 191 and 243 moved to Curated under + // D23 (a local dump with its record in `docs/compatibility.md` is + // evidence). Then 26 -> 27: 230 arrived BestEffort (its page is Disch's + // notes and its model chooses the power-on mode and the reset behaviour; + // the maintainer's ruling, 2026-10-09). Every other v3.2.0 family is + // Curated. assert_eq!( ids.len(), - 31, + 27, "BestEffort family count changed to {} — if intentional, update this \ assertion and docs/mappers.md; if not, a tier arm regressed", ids.len() diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index c4796cf9c..ed1265a27 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -85,3 +85,10 @@ path = "fuzz_targets/movie.rs" test = false doc = false bench = false + +[[bin]] +name = "movie_import" +path = "fuzz_targets/movie_import.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/movie_import.rs b/fuzz/fuzz_targets/movie_import.rs new file mode 100644 index 000000000..caef6dc46 --- /dev/null +++ b/fuzz/fuzz_targets/movie_import.rs @@ -0,0 +1,66 @@ +//! Fuzz target for the foreign-movie importers — untrusted **file input**. +//! +//! The frontend imports TAS movies written by other emulators: FCEUX's `.fm2` +//! (text) and `.fcm` (binary), BizHawk's `.bk2` (a header and an input log, +//! both text), Famtasia's `.fmv` and VirtuaNES's `.vmv` (binary). Each is an +//! arbitrary file a user downloads, so each importer must reject a malformed, +//! truncated or hostile file with its typed error, never panic, index out of +//! bounds or allocate without bound. The `movie` target fuzzes only RustyNES's +//! own `.rnm` decoder (`Movie::deserialize`); these five parsers had no target +//! until v3.2.0, though a v2.9.9 probe pushed 200,000 mutated inputs through +//! them. That probe is what this target replaces. +//! +//! The first input byte selects the importer, so one corpus reaches all five: +//! +//! | `data[0] % 5` | importer | rest of the input | +//! |---|---|---| +//! | 0 | `import_fm2` | UTF-8 text (lossy) | +//! | 1 | `import_bk2` | header and input log, split at the first NUL byte | +//! | 2 | `import_fcm` | bytes | +//! | 3 | `import_fmv` | bytes | +//! | 4 | `import_vmv` | bytes | +//! +//! `import_mc2` is left out: it rejects every input without reading it (`.mc2` +//! is a PC Engine format). +//! +//! Any movie that imports is serialized to `.rnm`, as the frontend does next, +//! so the encoder must accept whatever an importer produced. +//! +//! Run with: +//! cargo +nightly fuzz run movie_import +//! +//! Per `docs/testing-strategy.md` §Layer 5. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rustynes_core::{ + bk2_interop::import_bk2, import_fcm, import_fmv, import_vmv, movie_interop::import_fm2, +}; + +/// The importers take the ROM's identity to stamp into the movie; its value +/// does not steer any parse, so a constant keeps the search on the file bytes. +const SHA: [u8; 32] = [0; 32]; + +fuzz_target!(|data: &[u8]| { + let Some((&sel, rest)) = data.split_first() else { + return; + }; + let movie = match sel % 5 { + 0 => import_fm2(&String::from_utf8_lossy(rest), SHA) + .ok() + .map(|(m, _)| m), + 1 => { + let split = rest.iter().position(|&b| b == 0).unwrap_or(rest.len()); + let header = String::from_utf8_lossy(&rest[..split]); + let log = String::from_utf8_lossy(rest.get(split + 1..).unwrap_or(&[])); + import_bk2(&header, &log, SHA).ok().map(|(m, _)| m) + } + 2 => import_fcm(rest, SHA).ok().map(|(m, _)| m), + 3 => import_fmv(rest, SHA).ok().map(|(m, _)| m), + _ => import_vmv(rest, SHA).ok().map(|(m, _)| m), + }; + if let Some(movie) = movie { + let _ = movie.serialize(); + } +}); diff --git a/ios/RustyNES/AppModel.swift b/ios/RustyNES/AppModel.swift index c191cf875..d2f3f1323 100644 --- a/ios/RustyNES/AppModel.swift +++ b/ios/RustyNES/AppModel.swift @@ -340,6 +340,12 @@ final class AppModel: ObservableObject { // goes first: when it cannot move, the old key stays for this session // and nothing else moves, so the stores never split between two keys. let entry = await migrateKey(entry, to: core.romIdentity) + // v3.2.0: an FDS disk boots the copy it saved last session, under the + // pristine disk's identity (`bootSavedDisk`), so `entry.sha` keys + // everything, the save file included. On any failure the pristine disk + // stays loaded. Before the battery and the auto-resume state, which are + // newer. + AppModel.bootSavedDiskIfAny(core, sha: entry.sha) core.isMuted = muted // v2.9.2 (AUD-14): pace the gamepad turbo pulse by emulated frames. // Captures the manager (a plain class), not `self`, so the closure @@ -922,6 +928,45 @@ final class AppModel: ObservableObject { /// Compares the save RAM once a second and writes it on a change. private var batteryTimer: Timer? + /// v3.2.0 -- an FDS disk's saved image, `Application Support/RustyNES/ + /// fds-saves/.fds.sav` (the desktop's layout). FDS games save to + /// the disk itself, so the whole image is the save. Keyed by the PRISTINE + /// disk's identity and booted through `bootSavedDisk`, which keeps it; the + /// first v3.2.0 draft loaded the saved file as the ROM, which changed the + /// game's identity at its first disk write. + static func fdsSaveUrl(forSha sha: String) -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("RustyNES/fds-saves/\(sha).fds.sav") + } + + /// Boot the saved disk copy for `sha`, if there is one; keep the pristine + /// disk on any failure (logged). + static func bootSavedDiskIfAny(_ core: EmulatorCore, sha: String) { + let url = fdsSaveUrl(forSha: sha) + guard FileManager.default.fileExists(atPath: url.path) else { return } + do { + try core.bootSavedDisk(try Data(contentsOf: url)) + } catch { + NSLog("RustyNES: saved FDS disk not used: \(error)") + } + } + + /// Write the running disk if the game changed it, atomically. The dirty flag + /// is cleared only after the write lands, so a failure is retried by the + /// next flush. + private func flushFdsDiskIfDirty() { + guard let core = emulator, let sha = currentEntry?.sha, core.diskIsDirty else { return } + let url = AppModel.fdsSaveUrl(forSha: sha) + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try core.diskImageBytes().write(to: url, options: .atomic) + core.clearDiskDirty() + } catch { + NSLog("RustyNES: FDS disk save failed: \(error)") + } + } + /// Bind `core`'s battery RAM to its `.sav` and load it. Returns a message /// when an existing save could not be used (that session is then not saved, /// so the file is never overwritten). @@ -958,6 +1003,7 @@ final class AppModel: ObservableObject { /// Write the running game's battery save now, and wait for it. private func flushBatteryNow() { + flushFdsDiskIfDirty() guard let saver = battery, let core = emulator else { return } saver.flushNow(core.batteryRam()) } @@ -967,6 +1013,11 @@ final class AppModel: ObservableObject { /// is a few KiB, so it runs synchronously within the task rather than handing /// a mutable task id across threads. private func flushBatteryInBackground() { + if emulator?.diskIsDirty == true { + let task = BackgroundTask(name: "RustyNES FDS save") + flushFdsDiskIfDirty() + task.end() + } guard let saver = battery, let core = emulator else { return } let task = BackgroundTask(name: "RustyNES battery save") defer { task.end() } @@ -1034,7 +1085,9 @@ final class AppModel: ObservableObject { /// interruption is in effect; otherwise pause. (We deliberately declare NO /// background-audio mode, so backgrounding pauses.) private func applyRunState() { - if sceneActive, !menuPaused, !audioInterrupted, !routePaused { + let isRunning = sceneActive && !menuPaused && !audioInterrupted && !routePaused + UIApplication.shared.isIdleTimerDisabled = isRunning + if isRunning { emulator?.resume() } else { emulator?.pause() diff --git a/ios/RustyNES/EmulatorCore.swift b/ios/RustyNES/EmulatorCore.swift index 5f5fa2a28..bfae2a30c 100644 --- a/ios/RustyNES/EmulatorCore.swift +++ b/ios/RustyNES/EmulatorCore.swift @@ -500,6 +500,17 @@ final class EmulatorCore { controller.setDiskSide(side: UInt32(next)) } + /// v3.2.0 -- the FDS disk save (see `AppModel.fdsSaveUrl`). The game writes + /// to the disk itself; the host saves the whole image when it is dirty and + /// boots it next session through `bootSavedDisk`, which keeps the pristine + /// disk's identity. All four are no-ops / empty for a cartridge or NSF. + var diskIsDirty: Bool { controller.diskIsDirty() } + func diskImageBytes() -> Data { controller.diskImageBytes() } + func clearDiskDirty() { controller.clearDiskDirty() } + /// - Throws: `MobileError` if this is not a disk or `saved` does not parse; + /// the pristine disk then stays loaded. + func bootSavedDisk(_ saved: Data) throws { try controller.bootSavedDisk(saved: saved) } + /// NSF songs (0 for a cartridge / disk) and the playing one (0-based). var nsfSongCount: Int { Int(controller.nsfSongCount()) } var nsfCurrentSong: Int { Int(controller.nsfCurrentSong()) } diff --git a/ios/project.yml b/ios/project.yml index 217c22237..a9386c143 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -45,7 +45,7 @@ settings: # 2.0.8 through v2.9.9, since nothing moved it), and moved by # `scripts/release-automation/bump_release.py` from now on, starting with # the 3.0.0 cut. - MARKETING_VERSION: "3.1.0" + MARKETING_VERSION: "3.2.0" # The build (not marketing) number. Each TestFlight upload needs a UNIQUE one; # the fastlane `beta` lane overrides this with the CI run number at build time # (xcargs CURRENT_PROJECT_VERSION), so this checked-in "1" is only the local / diff --git a/scripts/coverage/coverage.py b/scripts/coverage/coverage.py index ef2d56558..043f4b47f 100755 --- a/scripts/coverage/coverage.py +++ b/scripts/coverage/coverage.py @@ -443,8 +443,8 @@ def mapper_dir_name(mapper: int) -> str: # embedded fallback (absorbs categorize_screenshots' static table). # --------------------------------------------------------------------------- # -# Regenerated from tier.rs at v2.9.8 (51 / 109 / 31); used only if that -# file cannot be parsed. +# Regenerated from tier.rs at v3.2.0 (51 / 114 / 26: 47, 121, 154, 191 and +# 243 promoted under D23); used only if that file cannot be parsed. _FALLBACK_CORE = { 0, 1, 2, 3, 4, 5, 7, 9, 10, 11, 13, 16, 18, 19, 21, 22, 23, 24, 25, 26, 32, 33, 34, 48, 64, 65, 66, 67, 68, 69, 70, 71, 73, 75, 78, 80, 82, 85, @@ -458,12 +458,13 @@ def mapper_dir_name(mapper: int) -> str: 156, 162, 163, 164, 176, 177, 178, 180, 185, 189, 192, 193, 195, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221, 225, 226, 227, 228, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 249, - 250, 253, + 250, 253, 47, 121, 154, 157, 191, 243, 114, 116, 165, 182, 197, 215, + 6, 8, 17, 158, 172, 173, 207, 235, 252, } _FALLBACK_BEST_EFFORT = { - 29, 39, 47, 50, 81, 104, 121, 154, 174, 179, 191, 194, 238, 243, 261, + 29, 39, 50, 81, 104, 174, 179, 194, 238, 261, 268, 286, 289, 290, 299, 301, 303, 305, 306, 312, 320, 336, 348, 349, - 366, 513, + 366, 513, 230, } SPECIAL_EXTERNAL = {"fds", "pc10", "vs-system"} diff --git a/scripts/perf/ab_check.sh b/scripts/perf/ab_check.sh index 8958c4300..6d0faa03d 100755 --- a/scripts/perf/ab_check.sh +++ b/scripts/perf/ab_check.sh @@ -41,6 +41,7 @@ # scripts/perf/ab_check.sh --features ppu-idle-line-fast # flag A/B, same tree # scripts/perf/ab_check.sh --bench nes_run_frame_nestest # one workload # scripts/perf/ab_check.sh --target snapshot_restore --bench vs_dual # another bench target +# scripts/perf/ab_check.sh --package rustynes-apu --target apu_throughput # another crate (v3.2.0) # AB_MEASUREMENT_TIME=20 scripts/perf/ab_check.sh # tighter intervals # # CPU pinning (`taskset`) is applied when available: measured on this project's @@ -75,6 +76,9 @@ BENCH_FILTER="" # the dual cabinet's serialize cost (NL-09) but not adjudicate a fix for it: a # serialize change does not move a frame. BENCH_TARGET="full_frame" +# The crate that owns BENCH_TARGET (v3.2.0; `rustynes-core` before, which put +# the chip crates' own benches, such as the APU's, out of reach). +BENCH_PACKAGE="rustynes-core" MEASUREMENT_TIME="${AB_MEASUREMENT_TIME:-10}" WARMUP="${AB_WARMUP_TIME:-2}" @@ -84,6 +88,7 @@ while [[ $# -gt 0 ]]; do --features) FEATURES="$2"; shift 2 ;; --bench) BENCH_FILTER="$2"; shift 2 ;; --target) BENCH_TARGET="$2"; shift 2 ;; + --package) BENCH_PACKAGE="$2"; shift 2 ;; -h|--help) sed -n '2,60p' "${BASH_SOURCE[0]}"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac @@ -143,7 +148,7 @@ bench_args=() bench_args+=(--warm-up-time "${WARMUP}" --measurement-time "${MEASUREMENT_TIME}") echo "==> Adoption A/B (the evidence rule is printed at the end)" -echo " target : ${BENCH_TARGET}" +echo " target : ${BENCH_PACKAGE} / ${BENCH_TARGET}" echo " reference : ${base_sha:0:12} (${BASE_REF})" if [[ -n "${FEATURES}" ]]; then echo " candidate : same tree + features '${FEATURES}'" @@ -159,7 +164,7 @@ echo # reference is the working tree too; only a code A/B needs the worktree. if [[ -n "${FEATURES}" ]]; then echo "==> Benching reference (flag off)" - "${PIN[@]}" cargo bench -p rustynes-core --bench "${BENCH_TARGET}" -- \ + "${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --save-baseline ab_ref >/dev/null else echo "==> Benching reference (${base_sha:0:12}) in a throwaway worktree" @@ -167,7 +172,7 @@ else ( cd "${work}/base" CARGO_TARGET_DIR="${REF_TARGET_DIR}" \ - "${PIN[@]}" cargo bench -p rustynes-core --bench "${BENCH_TARGET}" -- \ + "${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --save-baseline ab_ref ) >/dev/null fi @@ -177,7 +182,7 @@ echo "==> Benching candidate, compared against the reference" echo feat_args=() [[ -n "${FEATURES}" ]] && feat_args+=(--features "${FEATURES}") -"${PIN[@]}" cargo bench -p rustynes-core "${feat_args[@]}" --bench "${BENCH_TARGET}" -- \ +"${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" "${feat_args[@]}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --baseline ab_ref 2>&1 \ | grep -E "^[a-z][a-z0-9_]*|time:|change:|Performance has|No change" \ | sed 's/^/ /' @@ -200,7 +205,7 @@ echo echo "==> Order-bias control: re-benching the REFERENCE against itself, last" echo if [[ -n "${FEATURES}" ]]; then - "${PIN[@]}" cargo bench -p rustynes-core --bench "${BENCH_TARGET}" -- \ + "${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --baseline ab_ref 2>&1 \ | grep -E "^[a-z][a-z0-9_]*|change:|Performance has|No change" \ | sed 's/^/ /' @@ -208,7 +213,7 @@ else ( cd "${work}/base" CARGO_TARGET_DIR="${REF_TARGET_DIR}" \ - "${PIN[@]}" cargo bench -p rustynes-core --bench "${BENCH_TARGET}" -- \ + "${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --baseline ab_ref ) 2>&1 \ | grep -E "^[a-z][a-z0-9_]*|change:|Performance has|No change" \ diff --git a/scripts/perf/quiet_ab.sh b/scripts/perf/quiet_ab.sh new file mode 100755 index 000000000..14d9d194e --- /dev/null +++ b/scripts/perf/quiet_ab.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# quiet_ab.sh — run scripts/perf/ab_check.sh only on a quiet host, and keep +# heavy jobs from starting underneath a measurement. +# +# `ab_check.sh`'s adoption rule needs two independent runs on a QUIET host, and +# it says so, but it does not wait for one. On a desktop that is also building, +# fuzzing or simulating, a run started by hand lands on a busy machine more +# often than not, and a busy-host result is noise that reads as a measurement. +# The v2.9.9 and v3.0.1 campaigns ran their A/Bs through a small queue that +# waits for the load to fall and holds two lock files; this is that queue, +# promoted from session scratch at v3.2.0. +# +# Subcommands: +# +# quiet_ab.sh run LABEL BASE [ab_check args...] +# Wait until no BUSY lock is held and the one-minute load average is below +# QUIET_LOAD (default 1.5), hold the MEASURING lock, and run +# `scripts/perf/ab_check.sh --base BASE [args]` in this checkout. The log, +# headed by the load at the start and the end, goes to LOG_DIR/LABEL.log. +# +# quiet_ab.sh queue FILE +# Pop lines `LABEL BASE [args...]` from FILE one at a time and `run` each, +# until FILE is empty or its first line is STOP. Lines may be appended +# while it runs; `#` lines and blank lines are skipped. Each finished line +# is appended to FILE.done. +# +# quiet_ab.sh heavy CMD [args...] +# Wait until no measurement holds MEASURING, then hold BUSY while CMD runs, +# so no queued A/B starts under it. Wrap ladders, sweeps and test suites +# in this while a queue is running. +# +# State (the two locks and the default logs) lives in PERF_STATE_DIR, default +# ${TMPDIR:-/tmp}/rustynes-perf. Both commands must see the same directory. +# +# What it does not do: make a run trustworthy. A quiet start is necessary, not +# sufficient; read ab_check.sh's order-bias control and its adoption rule +# before the candidate column, and take the second independent run anyway. +set -uo pipefail + +root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "quiet_ab.sh: not inside a git checkout" >&2; exit 2; } +state="${PERF_STATE_DIR:-${TMPDIR:-/tmp}/rustynes-perf}" +log_dir="${LOG_DIR:-$state/logs}" +quiet="${QUIET_LOAD:-1.5}" +busy="$state/BUSY" +measuring="$state/MEASURING" +mkdir -p "$state" "$log_dir" + +load1() { cut -d' ' -f1 /proc/loadavg; } + +run_one() { + local label="$1" base="$2"; shift 2 + local la + while :; do + if [ -e "$busy" ]; then sleep 30; continue; fi + la=$(load1) + if awk -v l="$la" -v q="$quiet" 'BEGIN { exit !(l < q) }'; then break; fi + sleep 60 + done + echo "$label" > "$measuring" + local rc + { + echo "LABEL $label BASE $base ARGS $* TREE $root @ $(git -C "$root" rev-parse --short HEAD)" + echo "START $(date -Is) LOAD1 $la ($(cat /proc/loadavg))" + ( cd "$root" && scripts/perf/ab_check.sh --base "$base" "$@" ) + rc=$? + echo "END $(date -Is) rc=$rc ($(cat /proc/loadavg))" + } > "$log_dir/$label.log" 2>&1 + rm -f "$measuring" + echo "$label done (start load $la), log $log_dir/$label.log" + grep -E "^LABEL|^START|^END|change:|^==>" "$log_dir/$label.log" +} + +case "${1:-}" in + run) + shift + [ $# -ge 2 ] || { echo "usage: quiet_ab.sh run LABEL BASE [ab_check args...]" >&2; exit 2; } + trap 'rm -f "$measuring"' EXIT + run_one "$@" ;; + queue) + q="${2:-}" + [ -f "$q" ] || { echo "usage: quiet_ab.sh queue FILE (FILE must exist)" >&2; exit 2; } + trap 'rm -f "$measuring"' EXIT + while :; do + line=$(grep -v '^[[:space:]]*$' "$q" | grep -v '^#' | head -1) + if [ -z "$line" ] || [[ "$line" == STOP* ]]; then echo "queue empty"; break; fi + # Remove exactly that first occurrence, so lines appended meanwhile stay. + python3 - "$q" "$line" <<'PY' +import sys +path, line = sys.argv[1], sys.argv[2] +lines = open(path).read().splitlines() +lines.remove(line) +open(path, "w").write("\n".join(lines) + ("\n" if lines else "")) +PY + # `line` holds LABEL BASE and flags, which must word-split. + # shellcheck disable=SC2086 + run_one $line + echo "$line" >> "$q.done" + done ;; + heavy) + shift + [ $# -ge 1 ] || { echo "usage: quiet_ab.sh heavy CMD [args...]" >&2; exit 2; } + while [ -e "$measuring" ]; do sleep 20; done + touch "$busy" + trap 'rm -f "$busy"' EXIT + "$@" ;; + *) + sed -n '/^set -uo/q;2,$p' "$0" + exit 2 ;; +esac diff --git a/scripts/release-automation/branch_contained.py b/scripts/release-automation/branch_contained.py new file mode 100755 index 000000000..a9a978836 --- /dev/null +++ b/scripts/release-automation/branch_contained.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Is everything a branch or worktree added already on `main`? + +Run before deleting a branch or removing a worktree after a squash merge. + +Why a line check and not git's own: a squash merge makes a NEW commit, so +`git branch --merged main` never lists the branch and `git branch -d` refuses +it, while `-D` deletes it without looking. Comparing trees fails too, because +`main` has moved on since the merge. What is actually wanted is "every line this +branch added is present in that file on `main`", which survives both. + +It checks three sources of added lines: + +* the branch's committed lines: `git diff
...` (from the merge + base, so lines `main` added meanwhile do not count against the branch); +* in a worktree, its uncommitted lines: `git diff HEAD`; +* in a worktree, its untracked files: each must exist on `main` at the same path. + +A line counts as present when its stripped text occurs anywhere in the same +path on `main`. Lines shorter than 8 characters (braces, `end`, blank-ish +lines) are skipped as uninformative. This is a containment heuristic, not a +proof: a line moved to a different file reads as missing (a false alarm, safe), +and a line that also occurs elsewhere in the file reads as present. Read every +MISSING line before deleting anything. + +The v3.1.0 and v3.2.0 cleanups used an earlier form of this, which checked +only uncommitted lines. It was enough for those worktrees, but it would have +passed a branch with unmerged commits. + +Usage: + branch_contained.py [--branch NAME] [--main main] + +Exit status: 0 when nothing is missing, 1 when something is, 2 on a usage or +git error. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from collections import defaultdict + +MIN_LEN = 8 +SHOW_MISSING = 12 + + +def git(repo: str, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True, check=check) + + +def added_lines(diff: str) -> dict[str, list[str]]: + """Map each `+++ b/` in a unified diff to its added, stripped lines.""" + out: dict[str, list[str]] = defaultdict(list) + cur = None + for line in diff.splitlines(): + if line.startswith("+++ "): + cur = line[6:] if line.startswith("+++ b/") else None + elif line.startswith("+") and cur is not None: + text = line[1:].strip() + if len(text) >= MIN_LEN: + out[cur].append(text) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("path", help="a worktree, or the repository holding the branch") + ap.add_argument("--branch", help="branch to check (default: the one checked out at PATH)") + ap.add_argument("--main", default="main", help="the branch it was merged into (default: main)") + a = ap.parse_args() + + try: + branch = a.branch or git(a.path, "branch", "--show-current").stdout.strip() + if not branch: + print("detached HEAD: pass --branch", file=sys.stderr) + return 2 + committed = git(a.path, "diff", f"{a.main}...{branch}").stdout + checked_out = git(a.path, "branch", "--show-current").stdout.strip() == branch + uncommitted = git(a.path, "diff", "HEAD").stdout if checked_out else "" + untracked = ( + git(a.path, "ls-files", "--others", "--exclude-standard").stdout.split("\n") + if checked_out + else [] + ) + except subprocess.CalledProcessError as e: + print(f"git failed: {' '.join(e.cmd)}\n{e.stderr.strip()}", file=sys.stderr) + return 2 + + lines = added_lines(committed) + for path, extra in added_lines(uncommitted).items(): + lines[path].extend(extra) + + total = missing = 0 + for path, texts in sorted(lines.items()): + on_main = git(a.path, "show", f"{a.main}:{path}", check=False).stdout + for text in texts: + total += 1 + if text not in on_main: + missing += 1 + if missing <= SHOW_MISSING: + print(f" MISSING {path}: {text[:110]}") + if missing > SHOW_MISSING: + print(f" ... and {missing - SHOW_MISSING} more") + + absent = 0 + for path in filter(None, untracked): + if git(a.path, "cat-file", "-e", f"{a.main}:{path}", check=False).returncode != 0: + absent += 1 + print(f" UNTRACKED, NOT ON {a.main}: {path}") + + print( + f"{branch}: {total} added lines checked, {missing} not found on {a.main}; " + f"{len([u for u in untracked if u])} untracked files, {absent} not on {a.main}" + ) + return 0 if missing == 0 and absent == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release-automation/run_gates.sh b/scripts/release-automation/run_gates.sh new file mode 100755 index 000000000..3570f9616 --- /dev/null +++ b/scripts/release-automation/run_gates.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# run_gates.sh — run every Rust gate CI runs, locally, one cargo job at a time, +# and print one PASS/FAIL line per gate. +# +# Why this exists: CI's gate set is spread across `.github/workflows/ci.yml`, +# and the invocations that catch real defects are the ones a habitual local +# `cargo clippy --workspace` never reaches: each feature set of the frontend +# (`--all-targets` lints DEFAULT features only, so a cfg-gated module is unlinted +# until a command names its feature), both wasm32 builds (unreachable from a +# native target), the trace features of `rustynes-core`, the `no_std` thumbv7em +# build, and the cosim crate, which `--workspace` does not include. The list +# below was transcribed from ci.yml for the v3.2.0 cut. When ci.yml gains a +# gate, add it here in the same change. +# +# It never stops early: a red gate is reported and the next one still runs, so +# one invocation gives the whole picture. Each gate's full output goes to its +# own log; the summary names the log of every failure. +# +# Usage: +# scripts/release-automation/run_gates.sh # everything +# scripts/release-automation/run_gates.sh --only clippy # gates whose name matches a regex +# scripts/release-automation/run_gates.sh --no-roms # skip the ROM suites (the slow part) +# GATES_LOG_DIR=/path scripts/release-automation/run_gates.sh +# +# The `test-roms-commercial` gate runs only when local dumps are staged under +# `tests/roms/external/` (gitignored; see AGENTS.md "Never commit commercial +# Nintendo ROMs"). Without them it is reported as SKIP, never as PASS. +# +# Exit status: 0 when every gate that ran passed, 1 otherwise. +set -u + +root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "run_gates.sh: not inside a git checkout" >&2; exit 2; } +cd "$root" || exit 2 + +only="" +roms=1 +while [ $# -gt 0 ]; do + case "$1" in + --only) only="${2:-}"; shift 2 ;; + --no-roms) roms=0; shift ;; + -h|--help) sed -n '2,/^# Exit status/p' "$0"; exit 0 ;; + *) echo "run_gates.sh: unknown argument '$1'" >&2; exit 2 ;; + esac +done + +log_dir="${GATES_LOG_DIR:-${TMPDIR:-/tmp}/rustynes-gates}" +mkdir -p "$log_dir" +echo "HEAD $(git rev-parse --short HEAD) $(git branch --show-current), logs in $log_dir" + +failed=0 +ran=0 +gate() { + local name="$1"; shift + if [ -n "$only" ] && ! [[ "$name" =~ $only ]]; then return; fi + local t0=$SECONDS + "$@" > "$log_dir/$name.log" 2>&1 + local rc=$? + ran=$((ran + 1)) + if [ $rc -eq 0 ]; then + echo "GATE $name: PASS ($((SECONDS - t0))s)" + else + failed=$((failed + 1)) + echo "GATE $name: FAIL rc=$rc ($((SECONDS - t0))s) -- $log_dir/$name.log" + fi +} +skip() { + if [ -n "$only" ] && ! [[ "$1" =~ $only ]]; then return; fi + echo "GATE $1: SKIP ($2)" +} + +cosim=crates/rustynes-cosim/Cargo.toml + +gate fmt cargo fmt --all --check +gate fmt-cosim cargo fmt --manifest-path "$cosim" --check +gate clippy-workspace cargo clippy --workspace --all-targets -- -D warnings +gate clippy-cosim cargo clippy --manifest-path "$cosim" --all-targets --all-features -- -D warnings +for f in cpu-boot-trace irq-timing-trace ppu-state-trace ppu-fetch-trace; do + gate "clippy-core-$f" cargo clippy -p rustynes-core --all-targets --features "$f" -- -D warnings +done +gate clippy-harness-traces cargo clippy -p rustynes-test-harness --all-targets --features cpu-boot-trace,irq-timing-trace,ppu-state-trace -- -D warnings +gate clippy-harness-phi2 cargo clippy -p rustynes-test-harness --all-targets --features test-roms,phi2-write-sweep -- -D warnings +gate clippy-ppu-phi2-pst cargo clippy -p rustynes-ppu --all-targets --features ppu-state-trace,phi2-write-sweep -- -D warnings +gate clippy-frontend-scripting cargo clippy -p rustynes-frontend --all-targets --features scripting -- -D warnings +gate clippy-frontend-scripting-hdpack cargo clippy -p rustynes-frontend --all-targets --features scripting,hd-pack -- -D warnings +gate clippy-frontend-ra cargo clippy -p rustynes-frontend --all-targets --features retroachievements -- -D warnings +gate clippy-frontend-full cargo clippy -p rustynes-frontend --all-targets --features full -- -D warnings +gate clippy-mappers-noaudio cargo clippy -p rustynes-mappers --no-default-features --all-targets -- -D warnings +gate clippy-harness-commercial cargo clippy -p rustynes-test-harness --all-targets --features commercial-roms,debug-hooks -- -D warnings +gate clippy-harness-roms cargo clippy -p rustynes-test-harness --all-targets --features test-roms,commercial-roms -- -D warnings +gate clippy-wasm cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --lib --bins -- -D warnings +gate clippy-wasm-canvas cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --no-default-features --features wasm-canvas --lib --bins -- -D warnings +gate clippy-wasm-browser-cheevos cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --features browser-cheevos --lib --bins -- -D warnings +gate rustdoc env RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps +gate rustdoc-cosim env RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --manifest-path "$cosim" +gate no-std cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-features +gate deny cargo deny check +gate test-workspace cargo test --workspace +gate test-cosim cargo test --manifest-path "$cosim" +gate test-mappers-noaudio cargo test -p rustynes-mappers --no-default-features +gate test-frontend-full cargo test -p rustynes-frontend --lib --features full +gate test-core-features cargo test -p rustynes-core --features debug-hooks,hd-pack +gate test-ppu-features cargo test -p rustynes-ppu --features debug-hooks,hd-pack +gate test-apu-features cargo test -p rustynes-apu --features debug-hooks +gate test-script-features cargo test -p rustynes-script --features script-ipc,script-sqlite +if [ $roms -eq 1 ]; then + gate test-roms cargo test --workspace --features test-roms + if [ -d tests/roms/external ] && [ -n "$(ls -A tests/roms/external 2>/dev/null)" ]; then + gate test-roms-commercial cargo test --workspace --features test-roms,commercial-roms + else + skip test-roms-commercial "no dumps staged under tests/roms/external/" + fi +else + skip test-roms "--no-roms" + skip test-roms-commercial "--no-roms" +fi + +if [ $ran -eq 0 ]; then + echo "run_gates.sh: no gate matched '--only $only'" >&2 + exit 2 +fi +echo "$ran gates ran, $failed failed" +[ $failed -eq 0 ]