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 5097068a8..9fa7f2882 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 5d7d27a3c..2681c59ce 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,10 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip -# Fetched from services.gradle.org/distributions/gradle-9.8.0-bin.zip.sha256. +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip +# Fetched from services.gradle.org/distributions/gradle-9.8.1-bin.zip.sha256. # `validateDistributionUrl` only checks the URL is well-formed and reachable; # this is what actually pins the BYTES. -distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c +distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/crates/rustynes-apu/src/apu.rs b/crates/rustynes-apu/src/apu.rs index 5d554e1c2..ed58f1d29 100644 --- a/crates/rustynes-apu/src/apu.rs +++ b/crates/rustynes-apu/src/apu.rs @@ -1326,7 +1326,17 @@ impl Apu { if self.audio_prov.is_some() { self.record_mix_armed(mixed, external); } - self.blip.add_sample(mixed); + // The 2A03's own mix is bounded by its DAC tables, so only a mix + // with an expansion-audio sample in it needs `sanitize` (v3.2.0; + // byte-identical: `add_sample(v)` is `add_sample_bounded( + // sanitize(v))`). The value is chosen and the scatter called once, + // so its body is inlined here once. + let value = if external == 0.0 { + mixed + } else { + BlipBuf::sanitize(mixed) + }; + self.blip.add_sample_bounded(value); // Nothing follows the general path's `add_sample` but comments -- // the get/put flip moved to `dmc_tick_end` under M-2 -- so there is // no shared tail to run before returning. Verified by reading it, diff --git a/crates/rustynes-apu/src/blip.rs b/crates/rustynes-apu/src/blip.rs index aece2fa43..0a4db3461 100644 --- a/crates/rustynes-apu/src/blip.rs +++ b/crates/rustynes-apu/src/blip.rs @@ -253,14 +253,33 @@ impl BlipBuf { /// limiter. #[inline] pub fn add_sample(&mut self, value: f32) { - // Defensive saturation. Real mixer output never exceeds ~1.5; the - // clamp catches NaN/Inf from a runaway mapper-audio path so the - // FIR can't propagate non-finite values through the ring. - let value = if value.is_finite() { + self.add_sample_bounded(Self::sanitize(value)); + } + + /// The saturation [`Self::add_sample`] applies: a finite value clamped to + /// `[-4, 4]`, and 0 for NaN / infinity. Real mixer output never exceeds + /// ~1.5; the clamp catches NaN / Inf from a runaway mapper-audio path so + /// the FIR cannot propagate non-finite values through the ring. + /// + /// Separate from the scatter so a caller choosing between a trusted and an + /// untrusted input can pick the VALUE and make one call to + /// [`Self::add_sample_bounded`] (v3.2.0). + #[inline] + #[must_use] + pub fn sanitize(value: f32) -> 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 ]