From 2a2e12a1365f8098055cd46308d1fd3caf932615 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 04:57:41 -0400 Subject: [PATCH 1/5] review(v3.1.0): slice A, crates and build files (do not merge) Review-only slice of #594 for CodeRabbit, which skips PRs over 100 files. Every code and build path the release changes (54), each equal to the release head 5f7eac66. Closed unmerged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- Cargo.lock | 38 +- Cargo.toml | 2 +- android/app/build.gradle.kts | 4 +- crates/rustynes-android/src/gfx.rs | 6 +- crates/rustynes-core/src/bus.rs | 276 ++++++++++++- crates/rustynes-core/src/bus_snapshot.rs | 53 ++- crates/rustynes-core/src/hardware_options.rs | 68 +++- crates/rustynes-core/src/lib.rs | 2 +- crates/rustynes-core/src/movie.rs | 47 ++- crates/rustynes-core/src/nes.rs | 134 ++++++- crates/rustynes-core/src/vs_dualsystem.rs | 159 +++++++- crates/rustynes-cosim/Cargo.lock | 12 +- crates/rustynes-cosim/Cargo.toml | 2 +- crates/rustynes-frontend/src/app.rs | 82 ++-- crates/rustynes-frontend/src/config.rs | 53 ++- crates/rustynes-frontend/src/crt.rs | 4 + .../src/debugger/settings_panel.rs | 74 +++- crates/rustynes-frontend/src/emu.rs | 154 +++++++- crates/rustynes-frontend/src/i18n.rs | 12 +- crates/rustynes-frontend/src/netplay_ui.rs | 13 +- crates/rustynes-frontend/src/ntsc.rs | 4 +- crates/rustynes-frontend/src/runahead.rs | 185 ++++++++- crates/rustynes-frontend/src/shader_pass.rs | 14 +- crates/rustynes-frontend/src/wasm.rs | 9 +- .../src/signal_decode.wgsl | 25 +- crates/rustynes-mappers/src/m004_mmc3.rs | 91 +++-- crates/rustynes-mappers/src/m009_mmc2.rs | 6 + crates/rustynes-mappers/src/m010_mmc4.rs | 6 + crates/rustynes-mappers/src/m035_jy_asic.rs | 6 + crates/rustynes-mappers/src/m096_bandai96.rs | 6 + crates/rustynes-mappers/src/m163_nanjing.rs | 6 + crates/rustynes-mappers/src/mapper.rs | 77 ++++ crates/rustynes-netplay/src/message.rs | 88 ++++- crates/rustynes-ppu/src/bus.rs | 10 + crates/rustynes-ppu/src/emphasis.rs | 7 +- crates/rustynes-ppu/src/ppu.rs | 365 +++++++++++++++++- crates/rustynes-ppu/src/snapshot.rs | 28 +- .../src/accuracy_coin.rs | 2 +- .../src/accuracy_coin_catalog.rs | 48 ++- .../src/bin/accuracycoin_status.rs | 4 +- crates/rustynes-test-harness/src/lib.rs | 3 +- .../rustynes-test-harness/src/nes_runner.rs | 22 +- .../tests/accuracycoin.rs | 14 +- .../tests/accuracycoin_mirror.rs | 8 +- .../tests/cpu_overclock.rs | 258 +++++++++++++ .../tests/epoch_fingerprint.rs | 316 +++++++++++++++ crates/rustynes-test-harness/tests/mmc3.rs | 64 ++- .../tests/roster_boards.rs | 13 +- .../tests/snapshot_schema_audit.rs | 29 ++ .../tests/sprite_limit.rs | 211 ++++++++++ .../tests/vs_dualsystem_rewind.rs | 234 +++++++++++ ios/project.yml | 2 +- scripts/accuracycoin-build/derive_indices.py | 78 +++- scripts/accuracycoin-build/extract_catalog.py | 136 ++++++- 54 files changed, 3361 insertions(+), 209 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/cpu_overclock.rs create mode 100644 crates/rustynes-test-harness/tests/epoch_fingerprint.rs create mode 100644 crates/rustynes-test-harness/tests/sprite_limit.rs create mode 100644 crates/rustynes-test-harness/tests/vs_dualsystem_rewind.rs diff --git a/Cargo.lock b/Cargo.lock index 145f9a33b..94c71eedd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "3.0.1" +version = "3.1.0" dependencies = [ "android-activity", "android_logger", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "3.0.1" +version = "3.1.0" dependencies = [ "cc", "libc", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4347,7 +4347,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4358,7 +4358,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "3.0.1" +version = "3.1.0" dependencies = [ "anstyle", "arboard", @@ -4417,18 +4417,18 @@ dependencies = [ [[package]] name = "rustynes-gamedb" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-gfx-shaders" -version = "3.0.1" +version = "3.1.0" [[package]] name = "rustynes-hdpack" -version = "3.0.1" +version = "3.1.0" dependencies = [ "lewton", "png", @@ -4439,7 +4439,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bytemuck", "cpal", @@ -4453,7 +4453,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "3.0.1" +version = "3.1.0" dependencies = [ "libc", "rust-libretro", @@ -4463,7 +4463,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4475,7 +4475,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-core", "rustynes-gamedb", @@ -4491,7 +4491,7 @@ dependencies = [ [[package]] name = "rustynes-netplay" -version = "3.0.1" +version = "3.1.0" dependencies = [ "futures-util", "js-sys", @@ -4507,7 +4507,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4519,21 +4519,21 @@ dependencies = [ [[package]] name = "rustynes-probe" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-ra" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "3.0.1" +version = "3.1.0" dependencies = [ "mlua", "piccolo", @@ -4544,7 +4544,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "3.0.1" +version = "3.1.0" dependencies = [ "insta", "png", diff --git a/Cargo.toml b/Cargo.toml index a46a81c86..00eec722e 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.0.1" +version = "3.1.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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4a0187b71..d9410d1fb 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 = 30001 - versionName = "3.0.1" + versionCode = 30100 + versionName = "3.1.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/crates/rustynes-android/src/gfx.rs b/crates/rustynes-android/src/gfx.rs index 01cbd17ec..273676017 100644 --- a/crates/rustynes-android/src/gfx.rs +++ b/crates/rustynes-android/src/gfx.rs @@ -14,8 +14,10 @@ //! frontend (the `rustynes-gfx-shaders` crate), so the on-screen filter look matches //! across platforms: the `params` uniform selects None / Scanlines / CRT on one //! pipeline (None = a plain letterboxed blit), and a second pipeline runs the -//! LMP88959 NTSC pass when `filter == 3`. Only the Bisqwit NTSC pass (which needs the -//! `R16Uint` palette-index texture from the bridge) remains a follow-up. +//! LMP88959 NTSC pass when `filter == 3`. A third pipeline runs the Bisqwit composite +//! NTSC pass when `filter == 4`, reading the `R16Uint` palette-index texture the +//! bridge supplies (`index_texture`, `bisqwit_pipeline`). This line called that pass +//! a follow-up until v3.1.0, long after it landed. use ndk::native_window::NativeWindow; use raw_window_handle::{AndroidDisplayHandle, HasWindowHandle, RawDisplayHandle}; diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index ae2de7726..2692d7aa2 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -578,6 +578,50 @@ pub struct SystemBus { cpu_div_cached: u8, ppu_div_cached: u8, + /// v3.1.0 (`T-CPU-OVERCLOCK`): the CPU-multiplier overclock, `1..=4` + /// ([`crate::MAX_CPU_OVERCLOCK`]); `1` is stock. Configuration, like the + /// extra-scanline overclock: not part of the save-state, re-applied by + /// the host (and by a movie's or netplay session's options record). + cpu_overclock: u8, + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`): a forced MMC3 IRQ revision, or `None` + /// for the header's. Configuration, re-applied to every mapper the bus + /// builds (a power cycle rebuilds it); only mapper 4 acts on it. + mmc3_revision_override: Option, + /// The master clocks the CPU cycle in progress takes under the overclock, + /// [`overclock_cycle_len`] of `overclock_phase`; `cpu_div_cached` at + /// `x1`. This is what [`Bus::cpu_divider`] returns. It changes only at a + /// cycle's END (`cpu_clock_apu_dmc`), because the CPU reads the divider + /// twice per cycle, once for each half, and both reads must agree. + /// + /// Until the v3.1.0 review (#594) this was `cpu_div_cached / k`, rounded + /// down: exact on NTSC (12 divides by 2, 3 and 4) and wrong elsewhere, + /// `x3` on PAL (16) running 3.2x and `x4` on Dendy (15) 5x, so a movie + /// that recorded "x4" did not get four times the CPU. + cpu_div_effective: u8, + /// Which of the `k` CPU cycles of the current stock cycle is in progress, + /// `0..cpu_overclock`. + /// + /// Under the overclock the APU, the DMC, the mappers' CPU-cycle hooks and + /// the PPU's open-bus / post-reset timers stay at the STOCK rate, so a + /// game gets more CPU time per frame without a pitch change, a faster + /// tempo or cycle-timed mapper IRQs firing early. Every `k` CPU cycles + /// make one stock cycle exactly: cycle `i` of the group lasts + /// [`overclock_cycle_len`] master clocks, which sum to `cpu_div_cached` + /// over the group (PAL x3: 5, 5, 6), and the stock step runs on the last. + /// Always 0 at `x1`, where every CPU cycle is a stock step. In the BUS + /// save-state section: run-ahead restores mid-run. + overclock_phase: u8, + /// The stock-rate domain's own cycle counter under the overclock, handed + /// to the APU in place of the CPU cycle counter (the APU derives its + /// put/get phase from it). Re-based to [`Self::cycle`] when the overclock + /// is switched on, so the phase continues; unused at `x1`. In the BUS + /// save-state section. + apu_cycle: u64, + /// Whether the cycle in progress is a stock step (set in `cpu_clock`, + /// read by `cpu_clock_apu_dmc` at the same cycle's end). Always `true` at + /// `x1`. Transient: a snapshot is taken between cycles. + stock_step: bool, + /// External CPU data bus latch: last value driven onto the bus /// by ANY device (CPU, DMC DMA, OAM DMA conflict reads). /// @@ -624,6 +668,21 @@ pub struct SystemBus { /// re-read or the actual sample fetch. Read and written by the unified /// DMA engine (`unified_dma_cycle_impl`). dmc_halt: bool, + /// v3.1.0 (`AccuracyCoin` "DMA Landing on Write", test 9): a pending LOAD + /// DMC DMA reached the get half on which it would have entered, but that + /// cycle was a CPU WRITE, and RDY cannot halt a write. The load then + /// enters on the very next read whichever half it is, so a load refused + /// by one write takes four cycles (`[Put (halt)] [Get] [Put] [Get]`) + /// instead of being deferred a second cycle to its get half and taking + /// three. Without this latch the CPU ran one real cycle the hardware + /// spends halted. Set in [`Bus::write`], consumed by the DMC entry in + /// `unified_dma_cycle_impl`, and cleared by the next CPU read either way. + /// + /// Written from the test ROM's own description (`TEST_DMALandingOnWrite` + /// test 9 and its cycle comments) and pinned by a black-box per-cycle + /// comparison against `TriCNES`'s output at the test's `STA $5000`. No + /// emulator source was consulted. + dmc_load_write_delayed: bool, /// W3-Stage-1 (`mc-r1-dma-unified`): the unified engine's OAM-DMA-active /// flag (`TriCNES` `DoOAMDMA` once latched). The 513/514 length is EMERGENT /// from `uni_oam_halt`/`uni_oam_aligned` + the per-cycle dispatch — no @@ -955,6 +1014,12 @@ impl SystemBus { ppu_clock: 0, cpu_div_cached, ppu_div_cached, + cpu_overclock: 1, + mmc3_revision_override: None, + cpu_div_effective: cpu_div_cached, + overclock_phase: 0, + apu_cycle: 0, + stock_step: true, open_bus: 0, internal_data_bus: 0, last_read_addr: 0, @@ -966,6 +1031,7 @@ impl SystemBus { uni_oam_addr: 0, cpu_2a03_revision: Cpu2A03Revision::default(), dmc_halt: false, + dmc_load_write_delayed: false, genie_codes: BTreeMap::new(), #[cfg(feature = "irq-timing-trace")] irq_snapshot_apu_at_low: false, @@ -1174,11 +1240,18 @@ impl SystemBus { // CPU/PPU phase into the "new" boot, diverging timing-sensitive games // from frame 0. Mirrors the `with_sample_rate` initial values. self.ppu_clock = 0; + // The stock-rate domain restarts with the clock; the multiplier itself + // is configuration and survives the power cycle. + self.overclock_phase = 0; + self.cpu_div_effective = overclock_cycle_len(self.cpu_div_cached, self.cpu_overclock, 0); + self.apu_cycle = 0; + self.stock_step = true; self.dma_byte = 0; self.dma_page = 0; self.last_read_addr = 0; self.in_dmc_dma = false; self.dmc_halt = false; + self.dmc_load_write_delayed = false; self.controller_write_pending = 0; self.controller_write_value = 0; // v2.9.8 — the ports' last-read stamps are bus cycles of the OLD @@ -1222,6 +1295,12 @@ impl SystemBus { // fresh mapper instance (same type, same flags, but keep // the invariant mechanical). self.mapper_caps = self.mapper.caps(); + // 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() { + self.mapper + .set_mmc3_revision_override(self.mmc3_revision_override); + } } self.rom_bytes = Some(bytes); } @@ -1338,6 +1417,60 @@ impl SystemBus { self.ppu.extra_scanlines() } + /// v3.1.0 (`T-CPU-OVERCLOCK`) — set the CPU-multiplier overclock. The + /// caller ([`crate::Nes::set_cpu_overclock`]) has already clamped `k` to + /// `1..=MAX_CPU_OVERCLOCK`. Switching it ON re-bases the stock-rate + /// domain's cycle counter on the CPU's, so the APU's put/get phase + /// continues across the switch; switching it OFF hands the APU the CPU + /// counter again, as stock does. + pub const fn set_cpu_overclock(&mut self, k: u8) { + if k == self.cpu_overclock { + return; + } + if self.cpu_overclock == 1 { + self.apu_cycle = self.cycle; + } + self.cpu_overclock = k; + self.overclock_phase = 0; + self.cpu_div_effective = overclock_cycle_len(self.cpu_div_cached, k, 0); + self.stock_step = true; + } + + /// v3.1.0 — the CPU-multiplier overclock (`1` = stock). + #[must_use] + pub const fn cpu_overclock(&self) -> u8 { + self.cpu_overclock + } + + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`) — force the MMC3 IRQ revision, or + /// `None` for the header's. Returns whether the board applied it (only an + /// MMC3, mapper 4, does); the setting is kept either way. + pub fn set_mmc3_revision_override( + &mut self, + revision: Option, + ) -> bool { + self.mmc3_revision_override = revision; + self.mapper.set_mmc3_revision_override(revision) + } + + /// v3.1.0 — the forced MMC3 IRQ revision (`None` = the header's). + #[must_use] + pub const fn mmc3_revision_override(&self) -> Option { + self.mmc3_revision_override + } + + /// v3.1.0 (`T-SPRITE-LIMIT`) — draw the sprites beyond the eighth on a + /// scanline (forwarded to the PPU). + pub const fn set_sprite_limit_disabled(&mut self, disabled: bool) { + self.ppu.set_sprite_limit_disabled(disabled); + } + + /// v3.1.0 — whether the sprites beyond the eighth are drawn. + #[must_use] + pub const fn sprite_limit_disabled(&self) -> bool { + self.ppu.sprite_limit_disabled() + } + /// v2.1.8 A1 — enable/disable the specialized visible-scanline fast dot /// path. `false` (default) is byte-identical to a build without it. See /// [`rustynes_ppu::Ppu::set_fast_dotloop`]. @@ -1639,7 +1772,21 @@ impl SystemBus { pub fn debug_peek_ppu(&mut self, addr: u16) -> u8 { let addr = addr & 0x3FFF; match addr { - 0x0000..=0x1FFF => self.mapper.ppu_read(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 + } 0x2000..=0x3EFF => { let addr = if addr >= 0x3000 && !self.mapper.nametable_unfolded() { addr - 0x1000 @@ -2769,6 +2916,9 @@ impl SystemBus { // the engine feature is off) so the BUS section layout is // identical across feature builds. dmc_halt: self.dmc_halt, + dmc_load_write_delayed: self.dmc_load_write_delayed, + overclock_phase: self.overclock_phase, + apu_cycle: self.apu_cycle, uni_oam_active: self.uni_oam_active, uni_oam_halt: self.uni_oam_halt, uni_oam_aligned: self.uni_oam_aligned, @@ -2800,6 +2950,24 @@ impl SystemBus { // same inactive state the clear imposed -- but a restored blob // reproduces them EXACTLY instead of by assumption. self.dmc_halt = s.dmc_halt; + self.dmc_load_write_delayed = s.dmc_load_write_delayed; + // The overclock's stock-rate position. The multiplier itself is + // configuration (re-applied by the host); a phase the current + // multiplier could not have produced is clamped to its last cycle, so + // a stock step still comes due. The cycle length follows the phase. + let last = self.cpu_overclock.saturating_sub(1); + self.overclock_phase = if s.overclock_phase > last { + last + } else { + s.overclock_phase + }; + self.cpu_div_effective = overclock_cycle_len( + self.cpu_div_cached, + self.cpu_overclock, + self.overclock_phase, + ); + self.apu_cycle = s.apu_cycle; + self.stock_step = true; self.uni_oam_active = s.uni_oam_active; self.uni_oam_halt = s.uni_oam_halt; self.uni_oam_aligned = s.uni_oam_aligned; @@ -3434,10 +3602,13 @@ impl SystemBus { // the original activation). let dmc_serviceable = self.apu.dmc_dma_serviceable(); if self.apu.dmc_dma_pending() && dmc_serviceable && !self.in_dmc_dma { - let defer_load = self.apu.dmc_dma_is_load() && dmc_noop_half; + // A load refused by a write enters here regardless of the half. + let defer_load = + self.apu.dmc_dma_is_load() && dmc_noop_half && !self.dmc_load_write_delayed; if !defer_load { self.in_dmc_dma = true; self.dmc_halt = true; + self.dmc_load_write_delayed = false; self.capture_deferred_dma_replay(); } } @@ -3736,6 +3907,10 @@ impl PpuBus for PpuBusAdapter<'_> { fn ppu_read(&mut self, addr: u16) -> u8 { self.mapper.ppu_read(addr & 0x1FFF) } + + fn chr_reads_are_pure(&self) -> bool { + self.mapper.chr_reads_are_pure() + } fn ppu_read_sprite(&mut self, addr: u16) -> u8 { self.mapper.ppu_read_sprite(addr & 0x1FFF) } @@ -3830,7 +4005,7 @@ impl SystemBus { /// the default `mix_audio` (0.0 after the f32 conversion — identical), /// and boards without the frame hook have the default no-op. Skipping /// both saves two virtual calls + an f32 divide per CPU cycle. - fn apu_advance_one(&mut self) { + fn apu_advance_one(&mut self, apu_cycle: u64) { // `Mapper::mix_audio` returns i32 (widened from i16 in v2.2.3 so the // Sunsoft 5B's ~3.6x full-volume level is representable); scale it to // about the APU mixer's own [-0.5, 0.5] range. `as f32` rather than @@ -3847,7 +4022,9 @@ impl SystemBus { // bus cycle counter (incremented earlier in this same `cpu_clock`) // instead of letting it keep an independent `+= 1` mirror (the // one-clock collapse, promoted to the only path in v2.0.0 beta.4). - self.apu.set_canonical_cycle(self.cycle); + // v3.1.0: `apu_cycle` is that same counter at `x1`, and the stock-rate + // domain's own counter under the CPU overclock. + self.apu.set_canonical_cycle(apu_cycle); self.apu.tick_with_external(mapper_sample); if self.mapper_caps.frame_event_hook { let ev = self.apu.last_frame_events(); @@ -4188,10 +4365,26 @@ impl Bus for SystemBus { /// [`Bus::cpu_clock`]; Phase 3 will split the drain out of `cpu_read`). /// Phase 1 delegates to the legacy path so the contract compiles. fn read(&mut self, addr: u16) -> u8 { + // A CPU read cycle ran, so any write-refused load either entered on + // the DMA cycles before it (clearing the latch there) or was not + // serviceable; the latch spans exactly one write-to-read boundary. + self.dmc_load_write_delayed = false; self.cpu_read(addr) } fn write(&mut self, addr: u16, value: u8) { + // RDY cannot halt a write. A pending load that would have entered on + // this cycle (the get half: the access-point label is `!put_cycle`, + // as in `unified_dma_cycle_impl`) is refused, and enters on the next + // read without the get-half deferral (`dmc_load_write_delayed`). + if self.apu.dmc_dma_pending() + && self.apu.dmc_dma_is_load() + && self.apu.dmc_dma_serviceable() + && !self.in_dmc_dma + && !self.apu.put_cycle() + { + self.dmc_load_write_delayed = true; + } self.cpu_write(addr, value); } @@ -4200,7 +4393,8 @@ impl Bus for SystemBus { /// Drives the CPU loop's `master_clock` advance + read/write split so the /// CPU<->PPU phase is 3:1 NTSC, 3.2:1 PAL, 3:1 Dendy. fn cpu_divider(&self) -> u64 { - u64::from(self.cpu_div_cached) + // The overclocked CPU cycle length; `cpu_div_cached` at `x1`. + u64::from(self.cpu_div_effective) } /// R1 double catch-up: tick whole PPU dots while @@ -4298,6 +4492,24 @@ impl Bus for SystemBus { } } self.cycle = self.cycle.wrapping_add(1); + // v3.1.0 (`T-CPU-OVERCLOCK`): under the overclock only some CPU cycles + // are stock steps, and everything below this point that measures + // console time (the PPU's decay / post-reset timers, the mappers' + // M2-cycle IRQ counters, the APU) runs on those only. At `x1` the + // branch is never taken: every cycle is a stock step and the APU gets + // the CPU counter, exactly as before. + let apu_cycle = if self.cpu_overclock == 1 { + self.cycle + } else { + // The stock step is the LAST cycle of each group of `k`; the phase + // itself advances at this cycle's end (`cpu_clock_apu_dmc`). + self.stock_step = self.overclock_phase + 1 >= self.cpu_overclock; + if !self.stock_step { + return; + } + self.apu_cycle = self.apu_cycle.wrapping_add(1); + self.apu_cycle + }; self.ppu.on_cpu_cycle(); // v2.8.0 Phase 4 — skip the virtual dispatch on boards whose // `notify_cpu_cycle` is the default no-op (capability-flag cache). @@ -4307,7 +4519,7 @@ impl Bus for SystemBus { // F-2: `apu_advance_one` (start) ticks the whole APU EXCEPT the DMC // byte-timer (gated out by `dmc_driven_externally`); the DMC is ticked // at end-of-cycle by `cpu_clock_apu_dmc`. - self.apu_advance_one(); + self.apu_advance_one(apu_cycle); // (W2 $2007 Stress) The deferred $2007 render-buffer reload is now // PPU-dot-scheduled and consumed inside `Ppu::tick` — the prior // per-CPU-cycle `apply_pending_render_buffer` hook here was quantized @@ -4321,6 +4533,23 @@ impl Bus for SystemBus { // matching Mesen's `ProcessCpuClock` at `StartCpuCycle`. So the END-of-cycle // DMC tick is a no-op here. fn cpu_clock_apu_dmc(&mut self) { + // v3.1.0: the overclock's phase moves to the next cycle HERE, after + // both of this cycle's `cpu_divider` reads (the CPU calls this once + // per cycle, DMA cycles included), so the next cycle's length is in + // place before its first half. + if self.cpu_overclock > 1 { + self.overclock_phase = (self.overclock_phase + 1) % self.cpu_overclock; + self.cpu_div_effective = overclock_cycle_len( + self.cpu_div_cached, + self.cpu_overclock, + self.overclock_phase, + ); + } + // v3.1.0: the DMC end-of-cycle half belongs to the stock step its + // start half ran in (always `true` at `x1`). + if !self.stock_step { + return; + } // v2.0 Program M (M-1): clock the DMC byte-timer + arm the reload HERE at // end-of-cycle (after the CPU's bus access), the references' within-cycle // order. When the flag is OFF the byte-timer stays at cycle-start (above, @@ -4398,6 +4627,8 @@ impl Bus for SystemBus { && self.apu.dmc_dma_is_load() && lands_on_noop_half && !self.in_dmc_dma + // A load refused by a write may not be deferred again. + && !self.dmc_load_write_delayed } } @@ -4537,6 +4768,21 @@ impl Bus for SystemBus { } } +/// v3.1.0 (`T-CPU-OVERCLOCK`): the master clocks CPU cycle `phase` of a +/// stock cycle takes at overclock `k`, for a region whose stock CPU cycle is +/// `div` master clocks. The lengths of phases `0..k` sum to exactly `div` +/// (they are the differences of `phase * div / k`), so `k` CPU cycles always +/// fill one stock cycle and the multiplier is exact on every region: NTSC +/// (12) gives 6/6, 4/4/4 and 3/3/3/3; PAL (16) 8/8, 5/5/6 and 4/4/4/4; +/// Dendy (15) 7/8, 5/5/5 and 3/4/4/4. At `k = 1` it is `div`. +const fn overclock_cycle_len(div: u8, k: u8, phase: u8) -> u8 { + let (div, k, phase) = (div as u16, k as u16, phase as u16); + // `k >= 1` by construction (`set_cpu_overclock` clamps); at most 16 * 4. + #[allow(clippy::cast_possible_truncation)] // a part of `div`, at most 16 + let len = (((phase + 1) * div) / k - (phase * div) / k) as u8; + len +} + #[cfg(test)] mod four_score_tests { use super::*; @@ -4719,10 +4965,20 @@ mod four_score_tests { // first unassigned tag is 10. let bus = test_bus(); let mut bad = crate::bus_snapshot::encode_bus(&bus); - // The two device tags sit 25 bytes before the end with both ports - // empty: mirroring override (1) + controller-run tail (22) + - // internal bus (1) follow them, and port 1's tag is the second. - let port0_tag = bad.len() - 1 - 22 - 1 - 2; + // The two device tags sit before a fixed tail with both ports empty: + // mirroring override (1) + controller-run tail (22) + internal bus + // (1) + the version-3 fields (DMC write-refusal latch 1, overclock + // debt 1, `apu_cycle` 8) follow them, and port 1's tag is the second. + // + // v3.1.0: the version-3 bytes were missing from this sum for one + // commit. With only the 1-byte latch appended the window still read + // `[0, 0]` (the mirroring byte and port 1's tag), and the 10 written + // into the mirroring byte was refused for ITS own reason, so the + // test passed while testing something else. The assertion below + // that the window holds the two empty tags is what caught it once + // the overclock added 9 more bytes. + let tail = 1 + 22 + 1 + (1 + 1 + 8); + let port0_tag = bad.len() - tail - 2; assert_eq!(&bad[port0_tag..port0_tag + 2], &[0, 0]); bad[port0_tag] = 10; assert!(matches!( diff --git a/crates/rustynes-core/src/bus_snapshot.rs b/crates/rustynes-core/src/bus_snapshot.rs index 919c968b2..7b387b04c 100644 --- a/crates/rustynes-core/src/bus_snapshot.rs +++ b/crates/rustynes-core/src/bus_snapshot.rs @@ -18,6 +18,23 @@ //! the OAM-DMA owed-cycle counter and byte index (the unified engine's //! length is emergent), and `dma_mc_consumed` (structurally zero since //! v2.0.0, and decoded as zero regardless since v2.7.0). +//! +//! # Version 3 (v3.1.0) +//! +//! Appends, after the internal data bus: +//! +//! - the DMC load-DMA write-refusal latch (`dmc_load_write_delayed`, one +//! byte). It outlives an instruction, because the refusing write is the +//! last cycle of a store and the latch is consumed by the next opcode +//! fetch, so a snapshot at that boundary without it would restore a +//! three-cycle load where the machine was owed a four-cycle one; +//! - the CPU overclock's stock-rate position (`overclock_phase`, one byte, +//! and `apu_cycle`, `u64`): under the overclock the APU and the mappers' +//! cycle hooks advance on only some CPU cycles, and run-ahead restores in +//! the middle of that pattern. +//! +//! Version 2 is refused rather than read with a default, as the version-2 +//! rules above require. use crate::bus::SystemBus; use crate::controller::Controller; @@ -30,9 +47,9 @@ use alloc::vec::Vec; /// Schema version for the BUS section payload. /// -/// 2 since v2.9.8 (ADR 0042): see the module docs for what changed. A -/// version-1 section is refused with [`SnapshotError::VersionMismatch`]. -pub const BUS_SECTION_VERSION: u8 = 2; +/// 3 since v3.1.0, 2 since v2.9.8 (ADR 0042): see the module docs for what +/// changed. An older section is refused with [`SnapshotError::VersionMismatch`]. +pub const BUS_SECTION_VERSION: u8 = 3; /// Largest encoding of one port's expansion device in the BUS section. /// @@ -134,6 +151,11 @@ pub fn encode_bus(bus: &SystemBus) -> Vec { // `open_bus`, because a DMC DMA fetch drives only the external bus, and a // `$4015` read takes bit 5 from this one. w.u8(s.internal_data_bus); + // v3.1.0 (version 3): the DMC load-DMA write-refusal latch. + w.u8(u8::from(s.dmc_load_write_delayed)); + // v3.1.0 (version 3): the CPU overclock's stock-rate position. + w.u8(s.overclock_phase); + w.u64(s.apu_cycle); w.into_vec() } @@ -447,6 +469,18 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> } bus.set_four_score_pending(fs); let internal_data_bus = r.u8()?; + let dmc_load_write_delayed = r.bool()?; + let overclock_phase = r.u8()?; + // A debt is below one stock CPU cycle (16 master clocks on PAL, the + // longest); anything larger is a corrupt file, refused here rather than + // clamped later. + if overclock_phase >= crate::MAX_CPU_OVERCLOCK { + return Err(SnapshotError::SectionInvalid { + tag: "BUS ".into(), + reason: format!("CPU-overclock phase {overclock_phase} out of range"), + }); + } + let apu_cycle = r.u64()?; if r.remaining() != 0 { return Err(SnapshotError::SectionInvalid { tag: "BUS ".into(), @@ -469,6 +503,9 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> four_score_idx, four_score_sig, dmc_halt, + dmc_load_write_delayed, + overclock_phase, + apu_cycle, uni_oam_active, uni_oam_halt, uni_oam_aligned, @@ -533,6 +570,16 @@ pub struct BusMiscState { /// W3-Stage-4 (2026-06-10): the DMC-DMA halt latch (a DMC DMA is /// pending/halted and waiting for its GET slot). pub dmc_halt: bool, + /// v3.1.0 (BUS version 3): a pending load DMC DMA was refused by a CPU + /// write and enters on the next read whichever half it is. + pub dmc_load_write_delayed: bool, + /// v3.1.0 (BUS version 3): which of the `k` CPU cycles of the current + /// stock cycle is in progress under the CPU overclock, `0..k` (always 0 + /// at `x1`); refused at or above `MAX_CPU_OVERCLOCK`. + pub overclock_phase: u8, + /// v3.1.0 (BUS version 3): the stock-rate domain's cycle counter under + /// the CPU overclock (unused at `x1`). + pub apu_cycle: u64, /// W3-Stage-4: unified DMA engine (`mc-r1-dma-unified`) — OAM DMA active /// (`TriCNES` `DoOAMDMA`). pub uni_oam_active: bool, diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index 4b3bfd374..f87db2155 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -95,7 +95,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*) | -pub const EMULATION_EPOCH: u32 = 2; +/// | 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; /// Every host-settable option that changes what the emulated console does. /// @@ -115,6 +116,10 @@ pub const EMULATION_EPOCH: u32 = 2; /// option is then not a break. #[derive(Clone, Debug, Eq, PartialEq, Hash)] #[non_exhaustive] +// Four independent switches (OAM decay, Four Score, the Zapper light model, +// the sprite limit), each mirroring one `Nes` setter one-to-one; no two are +// states of one thing, so an enum or bitflags would only obscure the mapping. +#[allow(clippy::struct_excessive_bools)] pub struct HardwareOptions { /// Which console's reset wiring is modelled ([`Nes::set_console_model`]). pub console_model: ConsoleModel, @@ -130,6 +135,15 @@ pub struct HardwareOptions { pub power_up_palette: PaletteInit, /// The extra-vblank-scanline overclock ([`Nes::set_extra_scanlines`]). pub extra_scanlines: u16, + /// The CPU-multiplier overclock, `1..=MAX_CPU_OVERCLOCK` + /// ([`Nes::set_cpu_overclock`]); `1` is stock. v3.1.0. + pub cpu_overclock: u8, + /// Draw the sprites beyond the eighth on a scanline + /// ([`Nes::set_sprite_limit_disabled`]); render-only. v3.1.0. + pub sprite_limit_disabled: bool, + /// A forced MMC3 IRQ revision ([`Nes::set_mmc3_revision_override`]); + /// `None` = the header's. v3.1.0. + pub mmc3_revision: Option, /// Whether the Four Score adapter is plugged in ([`Nes::set_four_score`]). /// It changes `$4016` / `$4017` reads 9-24 even with players 3/4 idle. pub four_score: bool, @@ -160,6 +174,9 @@ impl Default for HardwareOptions { power_on_ram: PowerOnRam::default(), power_up_palette: PaletteInit::default(), extra_scanlines: 0, + cpu_overclock: 1, + sprite_limit_disabled: false, + mmc3_revision: None, four_score: false, // The core's own default since v2.2.x (`zapper_temporal_light` // is on in a freshly-built `Nes`); the stock machine, not `false`. @@ -184,6 +201,9 @@ impl HardwareOptions { power_on_ram: nes.power_on_ram(), power_up_palette: nes.power_up_palette(), extra_scanlines: nes.extra_scanlines(), + cpu_overclock: nes.cpu_overclock(), + sprite_limit_disabled: nes.sprite_limit_disabled(), + mmc3_revision: nes.mmc3_revision_override(), four_score: nes.four_score(), zapper_temporal_light: nes.zapper_temporal_light(), vs_dip: nes.vs_dip(), @@ -239,6 +259,15 @@ impl HardwareOptions { if nes.extra_scanlines() != self.extra_scanlines { nes.set_extra_scanlines(self.extra_scanlines); } + if nes.cpu_overclock() != self.cpu_overclock { + nes.set_cpu_overclock(self.cpu_overclock); + } + if nes.sprite_limit_disabled() != self.sprite_limit_disabled { + nes.set_sprite_limit_disabled(self.sprite_limit_disabled); + } + if nes.mmc3_revision_override() != self.mmc3_revision { + nes.set_mmc3_revision_override(self.mmc3_revision); + } if nes.four_score() != self.four_score { nes.set_four_score(self.four_score); } @@ -306,7 +335,10 @@ impl HardwareOptions { /// /// Layout (all little-endian): console model, PPU revision, 2A03 /// revision, OAM decay, power-on RAM kind + `u64` payload, power-up - /// palette, extra scanlines (`u16`), Four Score, Zapper light model, Vs. + /// palette, extra scanlines (`u16`), CPU overclock, the sprite-limit flag + /// and the MMC3 revision override (`0` = header, `1` = Sharp, `2` = the + /// alternate; all three since `.rnm` format 6 and netplay protocol 7, + /// v3.1.0), Four Score, Zapper light model, Vs. /// DIP, Vs. PPU type (`0xFF` = the header's), mirroring override (`0` = /// none, else variant + 1), then a code count and each Game Genie code as /// a length byte plus ASCII. Every enum is an explicit byte, never a @@ -338,6 +370,13 @@ impl HardwareOptions { PaletteInit::Blargg => 1, }); w.u16(self.extra_scanlines); + w.u8(self.cpu_overclock); + w.u8(u8::from(self.sprite_limit_disabled)); + w.u8(match self.mmc3_revision { + None => 0, + Some(rustynes_mappers::Mmc3Revision::Sharp) => 1, + Some(rustynes_mappers::Mmc3Revision::Nec) => 2, + }); w.u8(u8::from(self.four_score)); w.u8(u8::from(self.zapper_temporal_light)); w.u8(self.vs_dip); @@ -401,6 +440,19 @@ impl HardwareOptions { if extra_scanlines > crate::nes::MAX_EXTRA_SCANLINES { return Err("extra-scanline overclock is above the core's maximum"); } + // The core clamps the multiplier to 1..=MAX_CPU_OVERCLOCK, so any other + // byte could not replay as written. + let cpu_overclock = byte(r)?; + if cpu_overclock == 0 || cpu_overclock > crate::nes::MAX_CPU_OVERCLOCK { + return Err("CPU overclock is outside 1..=MAX_CPU_OVERCLOCK"); + } + let sprite_limit_disabled = flag(r, "sprite-limit flag is not 0 or 1")?; + let mmc3_revision = match byte(r)? { + 0 => None, + 1 => Some(rustynes_mappers::Mmc3Revision::Sharp), + 2 => Some(rustynes_mappers::Mmc3Revision::Nec), + _ => return Err("unknown MMC3 revision byte"), + }; let four_score = flag(r, "Four Score flag is not 0 or 1")?; let zapper_temporal_light = flag(r, "Zapper light flag is not 0 or 1")?; let vs_dip = byte(r)?; @@ -435,6 +487,9 @@ impl HardwareOptions { power_on_ram, power_up_palette, extra_scanlines, + cpu_overclock, + sprite_limit_disabled, + mmc3_revision, four_score, zapper_temporal_light, vs_dip, @@ -479,6 +534,12 @@ impl HardwareOptions { self.extra_scanlines != other.extra_scanlines, "overclock scanlines", ); + check(self.cpu_overclock != other.cpu_overclock, "CPU overclock"); + check( + self.sprite_limit_disabled != other.sprite_limit_disabled, + "sprite limit", + ); + check(self.mmc3_revision != other.mmc3_revision, "MMC3 revision"); check(self.four_score != other.four_score, "Four Score"); check( self.zapper_temporal_light != other.zapper_temporal_light, @@ -773,6 +834,9 @@ mod tests { power_on_ram: PowerOnRam::Seeded(0x0123_4567_89AB_CDEF), power_up_palette: PaletteInit::Blargg, extra_scanlines: 40, + cpu_overclock: 3, + sprite_limit_disabled: true, + mmc3_revision: Some(rustynes_mappers::Mmc3Revision::Nec), four_score: true, zapper_temporal_light: false, vs_dip: 0xA5, diff --git a/crates/rustynes-core/src/lib.rs b/crates/rustynes-core/src/lib.rs index 11aa9bbc2..73073b81c 100644 --- a/crates/rustynes-core/src/lib.rs +++ b/crates/rustynes-core/src/lib.rs @@ -98,7 +98,7 @@ pub use movie::{ #[cfg(feature = "debug-hooks")] pub use nes::TraceRec; pub use nes::{ - ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL, + 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 diff --git a/crates/rustynes-core/src/movie.rs b/crates/rustynes-core/src/movie.rs index d368fea7a..e3b6d1a47 100644 --- a/crates/rustynes-core/src/movie.rs +++ b/crates/rustynes-core/src/movie.rs @@ -93,12 +93,20 @@ pub const MOVIE_MAGIC: &[u8; 8] = b"RNESMOV1"; /// `$1000` differently (T-MMC3-BG-A12), and a format-4 movie does not say /// which behaviour it assumes, so v4 is refused. A v5 movie from another /// epoch is refused with [`MovieError::EpochMismatch`]. -pub const MOVIE_FORMAT_VERSION: u16 = 5; - -/// The oldest container version this build replays: v5, the first to record -/// the emulation epoch (v4 identified the board, v3 the options). Older -/// movies fail with [`MovieError::FormatTooOld`]. -pub const MIN_MOVIE_FORMAT_VERSION: u16 = 5; +/// - **v6 (v3.1.0)**: the [`crate::HardwareOptions`] record gains the +/// CPU-multiplier overclock and the sprite-limit option (`T-CPU-OVERCLOCK`, +/// `T-SPRITE-LIMIT`). A v5 options record is one field shorter and would +/// decode as garbage, so v5 is refused. No replayable movie is lost: every +/// v5 movie was recorded under epoch 1 or 2, which v3.1.0 (epoch 3) refuses +/// anyway. +pub const MOVIE_FORMAT_VERSION: u16 = 6; + +/// The oldest container version this build replays: v6. +/// +/// v6 is the first whose options record carries the CPU overclock and the +/// sprite-limit option (v5 first recorded the emulation epoch, v4 the board, +/// v3 the options). Older movies fail with [`MovieError::FormatTooOld`]. +pub const MIN_MOVIE_FORMAT_VERSION: u16 = 6; /// Peek a `.rnm` blob's header to learn its recording epoch. /// @@ -2391,7 +2399,32 @@ mod tests { bytes[8..10].copy_from_slice(&4u16.to_le_bytes()); assert!(matches!( Movie::deserialize(&bytes), - Err(MovieError::FormatTooOld { got: 4, min: 5 }) + Err(MovieError::FormatTooOld { + got: 4, + min: MIN_MOVIE_FORMAT_VERSION + }) + )); + } + + /// v3.1.0: a format-5 movie (v3.0.x) carries the options record without + /// the CPU overclock and the sprite-limit option, so it is refused as too + /// old rather than decoded one field short. + #[test] + fn a_format_5_movie_is_refused_as_too_old() { + assert_eq!(MIN_MOVIE_FORMAT_VERSION, 6); + let mut bytes = Movie::new( + Region::Ntsc, + [0; 32], + crate::HardwareOptions::default(), + None, + StartPoint::PowerOn, + vec![], + ) + .serialize(); + bytes[8..10].copy_from_slice(&5u16.to_le_bytes()); + assert!(matches!( + Movie::deserialize(&bytes), + Err(MovieError::FormatTooOld { got: 5, min: 6 }) )); } diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 062b9b7f6..56fb9d9b4 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -159,6 +159,16 @@ pub struct TraceRec { /// host and every file format meets the same bound. pub const MAX_EXTRA_SCANLINES: u16 = 80; +/// The largest CPU-multiplier overclock the core accepts (v3.1.0, +/// `T-CPU-OVERCLOCK`). +/// +/// [`Nes::set_cpu_overclock`] clamps to `1..=MAX_CPU_OVERCLOCK`, and a movie's +/// options record outside that range is refused. The bound is the master +/// clock: the CPU divider must stay at least 3 for the read / write split of +/// a cycle (NTSC 12 / 4 = 3), and `x4` is already four times the CPU time a +/// frame has. +pub const MAX_CPU_OVERCLOCK: u8 = 4; + /// Top-level NES emulator handle. /// /// Owns the CPU, PPU, mapper, RAM, and controller stub. Construct via @@ -719,7 +729,11 @@ impl Nes { // Hard cap: at NTSC the frame budget is 29,780.5 CPU cycles. Run // up to 5x that before bailing — gives breathing room for late // VBL detection or DMA-stall heavy frames before declaring "stuck". + // + // v3.1.0: scaled by the CPU overclock, which puts up to + // MAX_CPU_OVERCLOCK times as many CPU cycles into one frame. const MAX_CYCLES_PER_FRAME: u64 = 150_000; + let max_cycles = MAX_CYCLES_PER_FRAME * u64::from(self.bus.cpu_overclock()); let start = self.bus.cycle(); // v2.3.7 "Overtone" — anchor this frame's mix trace. The trace is // per-frame (the index IS the cycle offset from here); the REGISTER @@ -765,7 +779,7 @@ impl Nes { if self.cpu.is_jammed() { break; } - if self.bus.cycle().wrapping_sub(start) > MAX_CYCLES_PER_FRAME { + if self.bus.cycle().wrapping_sub(start) > max_cycles { break; } #[cfg(feature = "debug-hooks")] @@ -3010,7 +3024,8 @@ impl Nes { /// default) is **byte-identical** to stock NES timing — `AccuracyCoin`, the /// commercial oracle, and nestest (which never set it) are unaffected. /// **Off by default**; a frontend config knob, not part of the save-state. - /// Distinct from the CPU-multiplier overclock (a v2.0 timebase item). + /// Distinct from the CPU-multiplier overclock ([`Self::set_cpu_overclock`], + /// v3.1.0), which shortens the CPU cycle instead of lengthening the frame. /// /// Clamped to [`MAX_EXTRA_SCANLINES`] (v2.9.9, NC-11): the cap used to /// live only in the desktop frontend, so a movie's options record could @@ -3025,6 +3040,92 @@ impl Nes { self.bus.set_extra_scanlines(lines); } + /// v3.1.0 (`T-CPU-OVERCLOCK`, FE-01) — set the CPU-multiplier overclock: + /// the CPU runs `k` times faster against an unchanged PPU, so a game gets + /// `k` times the CPU time per frame. + /// + /// It divides the region's master-clock CPU divider (NTSC 12 -> 6 / 4 / 3; + /// PAL 16 and Dendy 15 round down, so `x3` on PAL is x3.2). The APU, the + /// DMC, every mapper's CPU-cycle hook (the VRC / FME-7 / N163 IRQ + /// counters) and the PPU's open-bus and post-reset timers stay at the + /// STOCK rate, so the pitch, the music tempo and the cycle-timed raster + /// IRQs are unchanged: what speeds up is the game's own code. DMA follows + /// the APU's get/put phase, so a DMA takes about `k` times as many CPU + /// cycles and the same real time. + /// + /// `1` (the default) is stock and byte-identical to a build without the + /// option. `0` is treated as `1`, and values above [`MAX_CPU_OVERCLOCK`] + /// clamp to it. Configuration, not save-state: the host re-applies it, + /// and movies and netplay carry it in [`crate::HardwareOptions`]. Not + /// hardware behaviour: no real console runs its CPU faster than its APU. + pub const fn set_cpu_overclock(&mut self, k: u8) { + let k = if k == 0 { + 1 + } else if k > MAX_CPU_OVERCLOCK { + MAX_CPU_OVERCLOCK + } else { + k + }; + self.bus.set_cpu_overclock(k); + } + + /// v3.1.0 — the CPU-multiplier overclock (`1` = stock). + #[must_use] + pub const fn cpu_overclock(&self) -> u8 { + self.bus.cpu_overclock() + } + + /// v3.1.0 (`T-SPRITE-LIMIT`, FE-02) — draw the sprites beyond the eighth + /// on a scanline, removing sprite flicker. + /// + /// **Render-only.** Sprite evaluation, secondary OAM, the overflow flag, + /// sprite-0 hit and every real sprite fetch (with its A12 edges, which an + /// MMC3 counts) are exactly stock, so the game sees no difference; only + /// the picture does. The extra sprites draw behind all eight hardware ones. + /// On the boards whose CHR reads have an effect (MMC2, MMC4, the J.Y. + /// ASIC, Bandai 96, Nanjing 163; `Mapper::chr_reads_are_pure`) the option + /// draws eight, as stock, because the extra pattern reads would change + /// emulation there. + /// + /// Off by default. Configuration, not save-state; carried across a power + /// cycle, and in movies and netplay by [`crate::HardwareOptions`] (the + /// picture differs, so a movie's frame hashes and a netplay peer's desync + /// checks depend on it). + pub const fn set_sprite_limit_disabled(&mut self, disabled: bool) { + self.bus.set_sprite_limit_disabled(disabled); + } + + /// v3.1.0 — whether the sprites beyond the eighth are drawn. + #[must_use] + pub const fn sprite_limit_disabled(&self) -> bool { + self.bus.sprite_limit_disabled() + } + + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`, ACC-13) — run an MMC3 (mapper 4) under + /// a chosen IRQ revision, or `None` for the one its header selects. + /// + /// The MMC3's two IRQ behaviours are mutually exclusive: the Sharp MMC3B + /// / MMC3C (the default) asserts IRQ when the counter is reloaded to 0, the + /// MMC3A and non-Sharp MMC3B (`Mmc3Revision::Nec`) only on a 1 -> 0 + /// decrement. A NES 2.0 header can say which (submapper 4); an iNES 1.0 + /// dump cannot, so this override is how a player runs a game, or blargg's + /// `mmc3_test_2/6-MMC3_alt`, on the other chip. The default (`None`) is + /// unchanged. Configuration, re-applied when a power cycle rebuilds the + /// board, and carried in [`crate::HardwareOptions`]. Returns whether the + /// board is one that applies it. + pub fn set_mmc3_revision_override( + &mut self, + revision: Option, + ) -> bool { + self.bus.set_mmc3_revision_override(revision) + } + + /// v3.1.0 — the forced MMC3 IRQ revision (`None` = the header's). + #[must_use] + pub const fn mmc3_revision_override(&self) -> Option { + self.bus.mmc3_revision_override() + } + /// v1.7.0 F3 — the configured extra-scanline overclock count (`0` = stock). #[must_use] pub const fn extra_scanlines(&self) -> u16 { @@ -4278,6 +4379,35 @@ mod tests { (Some(RamSite::PrgWindow), sweep_diff(&want, &got)) } + /// v3.1.0 — `debug_peek_ppu` is the debugger's and the HD-pack + /// compositor's "side-effect-free" CHR read. On the five boards whose CHR + /// reads change them (`Mapper::chr_reads_are_pure`), it used to flip the + /// board: reading all of CHR on MMC2 or MMC4 (the pattern viewer does) + /// passes tiles `$FD`/`$FE` and switches a CHR latch, so opening a debugger + /// panel, or hashing a tile for an HD pack, changed the game. Every board's + /// whole-machine state must be unchanged by a full sweep of peeks. + #[test] + fn debug_peek_ppu_changes_no_board() { + // The five impure families' ids, and MMC3 (4) as a pure control. + for id in [9u16, 10, 35, 90, 96, 163, 209, 211, 4] { + let rom = synth_board_rom(id, SweepHeader::Ines1, 128, 128); + let Ok(mut nes) = Nes::from_rom(&rom) else { + panic!("mapper {id} builds"); + }; + nes.run_frame(); + let before = nes.snapshot(); + for a in 0u16..0x2000 { + let _ = nes.bus.debug_peek_ppu(a); + } + let after = nes.snapshot(); + assert!( + before == after, + "mapper {id}: peeking CHR changed the machine ({} differing snapshot bytes)", + before.iter().zip(&after).filter(|(a, b)| a != b).count() + ); + } + } + /// CHR-RAM round trip through the whole-machine snapshot, through PPU /// `$0000-$1FFF` (there is no raw CHR accessor on `Mapper`). Returns /// `None` for the site when fewer than 1 KiB of the pattern reads back, diff --git a/crates/rustynes-core/src/vs_dualsystem.rs b/crates/rustynes-core/src/vs_dualsystem.rs index 97c57ef8f..9425cc7fc 100644 --- a/crates/rustynes-core/src/vs_dualsystem.rs +++ b/crates/rustynes-core/src/vs_dualsystem.rs @@ -64,6 +64,7 @@ use alloc::boxed::Box; use alloc::vec::Vec; use crate::nes::Nes; +use crate::rewind::{REWIND_DEFAULT_KEYFRAME_PERIOD, REWIND_DEFAULT_MAX_BYTES, RewindRing}; use crate::save_state::SnapshotError; use rustynes_mappers::RomError; @@ -97,6 +98,25 @@ pub struct VsDualSystem { /// two blocks cannot be encoded straight into the container; they pass /// through this instead. Not state: never serialized, never compared. block_scratch: Vec, + /// v3.1.0 (`T-PS-dual-runahead`, ADR 0032 amendment of 2026-10-07) — the + /// cabinet's rewind ring, holding whole-cabinet `RVSD` containers. + /// `None` (the default) until a frontend opts in with + /// [`Self::enable_rewind`]. + /// + /// The ring lives on the cabinet, not on either console, because the unit + /// of rewind is the cabinet: the two consoles share a 2 KiB WRAM and drive + /// each other's `/IRQ`, so stepping one back without the other produces a + /// cabinet from two timelines. Each console's own [`Nes`] ring stays + /// disabled. Not state: never serialized, never compared. + rewind: Option, + /// When `false`, [`Self::run_frame`] skips the rewind capture. Run-ahead + /// clears it across its hidden and visible frames, so the ring holds the + /// persistent timeline only (the same contract as + /// [`Nes::set_rewind_capture`]). + rewind_capture_enabled: bool, + /// Reused buffer for the per-frame rewind capture (a cabinet container is + /// about 520 KB before the ring compresses it). + rewind_snap_buf: Vec, } impl VsDualSystem { @@ -151,6 +171,9 @@ impl VsDualSystem { sub_bit1: false, comms_scratch: Vec::new(), block_scratch: Vec::new(), + rewind: None, + rewind_capture_enabled: true, + rewind_snap_buf: Vec::new(), }; dual.wire(); dual @@ -264,6 +287,10 @@ impl VsDualSystem { // underlying `Nes` (none today) never observe a stale latch. let _ = self.main.bus_mut().take_frame_complete(); let _ = self.sub.bus_mut().take_frame_complete(); + // v3.1.0 — push the completed frame into the cabinet's rewind ring. + if self.rewind.is_some() && self.rewind_capture_enabled { + self.rewind_capture(); + } } /// The main console's 256x240 RGBA8 framebuffer (the left screen). @@ -368,6 +395,8 @@ impl VsDualSystem { self.comms_scratch.clear(); self.block_scratch.clear(); self.wire(); + // The ring describes the timeline the power cycle just ended. + self.rewind_clear(); } /// Serialize the dual system: a versioned container nesting the two @@ -434,7 +463,31 @@ impl VsDualSystem { /// Returns [`SnapshotError`] on a bad container or when either nested /// console snapshot fails to restore. Both consoles are then unchanged /// (since v2.9.0; before it a rejected sub block left main restored). + /// + /// A successful restore empties the cabinet's rewind ring (v3.1.0): the + /// entries describe the timeline the restore replaced, as + /// [`Nes::restore`] treats its own ring. pub fn restore(&mut self, data: &[u8]) -> Result<(), SnapshotError> { + self.restore_inner(data, false)?; + self.rewind_clear(); + Ok(()) + } + + /// v3.1.0 (`T-PS-dual-runahead`) — [`Self::restore`] without its + /// side effects: the cabinet's rewind ring is kept, and each console is + /// restored with [`Nes::restore_quiet`] (no timeline-generation bump, no + /// rewind clear). For restores that return to the cabinet's own + /// timeline rather than replace it: run-ahead's rollback and a rewind + /// step. Same container, same all-or-nothing contract. + /// + /// # Errors + /// + /// As [`Self::restore`]. + pub fn restore_quiet(&mut self, data: &[u8]) -> Result<(), SnapshotError> { + self.restore_inner(data, true) + } + + fn restore_inner(&mut self, data: &[u8], quiet: bool) -> Result<(), SnapshotError> { // A malformed dual container reports as an unsupported format with // the container version we could read (0 when even the header is // short) — the closest fit among the existing error variants until @@ -488,8 +541,15 @@ impl VsDualSystem { // restores are. let mut main_backup = Vec::new(); self.main.snapshot_core_into(&mut main_backup); - self.main.restore(main_block)?; - if let Err(e) = self.sub.restore(sub_block) { + let restore = |nes: &mut Nes, block: &[u8]| { + if quiet { + nes.restore_quiet(block) + } else { + nes.restore(block) + } + }; + restore(&mut self.main, main_block)?; + if let Err(e) = restore(&mut self.sub, sub_block) { let rolled_back = self.main.restore_quiet(&main_backup); debug_assert!( rolled_back.is_ok(), @@ -520,6 +580,101 @@ impl VsDualSystem { self.main.bus_mut().set_vs_dual_wram(wram); Ok(()) } + + /// v3.1.0 (`T-PS-dual-runahead`) — enable the cabinet's rewind ring with + /// the default byte budget and keyframe period. + pub fn enable_rewind(&mut self) { + self.enable_rewind_with(REWIND_DEFAULT_MAX_BYTES, REWIND_DEFAULT_KEYFRAME_PERIOD); + } + + /// Enable the cabinet's rewind ring with an explicit byte budget and + /// keyframe period. Replaces (and so empties) any ring already enabled. + pub fn enable_rewind_with(&mut self, max_bytes: usize, keyframe_period: u32) { + self.rewind = Some(RewindRing::new(max_bytes, keyframe_period)); + } + + /// Disable the cabinet's rewind ring and free its memory. + pub fn disable_rewind(&mut self) { + self.rewind = None; + self.rewind_snap_buf = Vec::new(); + } + + /// `true` if the cabinet's rewind ring is enabled. + #[must_use] + pub const fn rewind_enabled(&self) -> bool { + self.rewind.is_some() + } + + /// Number of buffered rewind entries (0 when rewind is disabled). + #[must_use] + pub fn rewind_len(&self) -> usize { + self.rewind.as_ref().map_or(0, RewindRing::len) + } + + /// Turn the per-frame rewind capture in [`Self::run_frame`] on or off. + /// Run-ahead turns it off across its hidden and visible frames, which + /// are not the cabinet's timeline. + pub const fn set_rewind_capture(&mut self, enabled: bool) { + self.rewind_capture_enabled = enabled; + } + + /// `true` while [`Self::run_frame`] captures into the rewind ring. + #[must_use] + pub const fn rewind_capture_enabled(&self) -> bool { + self.rewind_capture_enabled + } + + /// Push the cabinet's current state into the rewind ring, keyed by the + /// main console's frame. [`Self::run_frame`] calls this after every + /// frame while capture is on; a no-op while rewind is disabled. + /// + /// Each entry is a whole `RVSD` container ([`Self::snapshot_into`]): + /// both consoles, framebuffers included, and the bit-1 latch. Unlike the + /// single console's ring, which stores SLIM entries and re-renders the + /// picture after a step back, the cabinet keeps the framebuffers in the + /// entry. That is what makes a step back exact for BOTH screens without + /// running the cabinet forward and back: a re-render would run the + /// five-cycle soft lockstep for a frame and restore again, twice the + /// work of a single console. The cost is two 245,760-byte framebuffers + /// per entry, which the ring's XOR delta and LZ4 reduce to the pixels + /// that changed since the last keyframe. + pub fn rewind_capture(&mut self) { + if self.rewind.is_none() { + return; + } + let frame = self.main.frame(); + let mut buf = core::mem::take(&mut self.rewind_snap_buf); + self.snapshot_into(&mut buf); + if let Some(ring) = &mut self.rewind { + ring.push(frame, &buf); + } + self.rewind_snap_buf = buf; + } + + /// Pop the most recent rewind entry and restore the cabinet to it, both + /// framebuffers included. Returns `true` on success and `false` when + /// the ring is empty, rewind is disabled, or the entry fails to decode + /// or restore (the cabinet is then unchanged, per [`Self::restore`]'s + /// all-or-nothing contract). + /// + /// The restore is quiet ([`Self::restore_quiet`]): the ring survives, + /// since the user is mid-rewind. + pub fn rewind_step_back(&mut self) -> bool { + let Some(ring) = self.rewind.as_mut() else { + return false; + }; + let Some(Ok(bytes)) = ring.pop_back() else { + return false; + }; + self.restore_quiet(&bytes).is_ok() + } + + /// Drop every buffered rewind entry (the ring stays enabled). + pub fn rewind_clear(&mut self) { + if let Some(ring) = &mut self.rewind { + ring.clear(); + } + } } /// The top-level emulator: one standard console, or a Vs. `DualSystem` pair. diff --git a/crates/rustynes-cosim/Cargo.lock b/crates/rustynes-cosim/Cargo.lock index 170bc24e2..03fa409ad 100644 --- a/crates/rustynes-cosim/Cargo.lock +++ b/crates/rustynes-cosim/Cargo.lock @@ -98,7 +98,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "libm", @@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "lz4_flex", @@ -121,7 +121,7 @@ dependencies = [ [[package]] name = "rustynes-cosim" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-core", "sha2", @@ -129,7 +129,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "thiserror", @@ -137,7 +137,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "rustynes-apu", @@ -146,7 +146,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "libm", diff --git a/crates/rustynes-cosim/Cargo.toml b/crates/rustynes-cosim/Cargo.toml index 392909764..ac6875959 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.0.1" +version = "3.1.0" edition = "2024" rust-version = "1.99" license = "GPL-3.0-or-later" diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 4ca001d1a..c60c1cfc7 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -432,6 +432,26 @@ fn configure_game_db_and_patch_startup_rom( apply_load_time_header_overrides(rom_bytes, Some(rom_path)); } +/// The rewind ring's byte budget and keyframe period from `[rewind]`, or +/// `None` when rewind is off. One definition for every console and cabinet +/// the frontend enables rewind on (v3.1.0; it was written out at three sites +/// before the cabinet made it four). +/// +/// The budget is `max_seconds` of 60 fps frames at about 200 KiB each, at +/// least one second's worth, and never above +/// [`rustynes_core::REWIND_DEFAULT_MAX_BYTES`]; the ring's delta encoding +/// stores far less per frame than that, so the cap is what binds in practice. +fn rewind_budget(config: &Config) -> Option<(usize, u32)> { + if !config.rewind.enabled { + return None; + } + let max_bytes = ((config.rewind.max_seconds as usize) * 60).max(60) * 200 * 1024; + Some(( + max_bytes.min(rustynes_core::REWIND_DEFAULT_MAX_BYTES), + config.rewind.keyframe_period.max(1), + )) +} + /// v2.9.8 — every config-derived setting the frontend pushes into a console, /// applied in one call to a console that has not run since it was built or /// power-cycled. @@ -538,6 +558,9 @@ fn push_ppu_hardware_config(config: &crate::config::Config, nes: &mut Nes) { // pushing it here is purely about honouring the user's escape hatch. // Default on. nes.set_fast_dotloop(config.emulation.fast_dotloop); + // v3.1.0 — the MMC3 IRQ revision override (`auto` leaves the header's). + // A board, not a timing knob: only mapper 4 acts on it. + nes.set_mmc3_revision_override(config.emulation.mmc3_irq_revision.to_core()); } /// v2.9.8 — the `[emulation] famicom_console` choice as a @@ -1675,6 +1698,9 @@ impl App { /// configuration ([`configure_console`]), before the cabinet is installed. /// Until v2.9.8 a cabinet got neither: the load paths applied both to the /// probe console, which a cabinet discards. + /// + /// v3.1.0 (`T-PS-dual-runahead`) — and the cabinet's rewind ring, from the + /// same `[rewind]` settings a single console gets ([`rewind_budget`]). fn build_dual_cabinet( &self, nes: &Nes, @@ -1693,6 +1719,9 @@ impl App { Self::apply_game_db(console, bytes); configure_console(&self.config, console); } + if let Some((max_bytes, keyframe_period)) = rewind_budget(&self.config) { + vs.enable_rewind_with(max_bytes, keyframe_period); + } Some(Box::new(vs)) } Err(e) => { @@ -2006,13 +2035,8 @@ impl App { // in `install_nes_wasm` (v2.9.7) with the same `build_dual_cabinet`. #[cfg(not(target_arch = "wasm32"))] let dual_cabinet = self.cabinet_for_image(&nes, &bytes, sample_rate); - if self.config.rewind.enabled { - let max_bytes: usize = - ((self.config.rewind.max_seconds as usize) * 60).max(60) * 200 * 1024; - nes.enable_rewind_with( - max_bytes.min(rustynes_core::REWIND_DEFAULT_MAX_BYTES), - self.config.rewind.keyframe_period.max(1), - ); + if let Some((max_bytes, keyframe_period)) = rewind_budget(&self.config) { + nes.enable_rewind_with(max_bytes, keyframe_period); } // v1.7.0 — arm the Four Score 4-player adapter per config. Off by // default, so `$4016`/`$4017` reads stay byte-identical to two @@ -2112,6 +2136,8 @@ impl App { // take the lock until they are in place. The overclock applies at // the top of each produced frame (v2.9.7). emu.overclock_scanlines = self.config.enhancements.overclock_scanlines; + emu.cpu_overclock = self.config.enhancements.cpu_overclock; + emu.disable_sprite_limit = self.config.enhancements.disable_sprite_limit; #[cfg(not(target_arch = "wasm32"))] if let Some(raw) = raw_cheats { emu.raw_cheats = raw; @@ -6835,7 +6861,14 @@ impl App { /// (v2.9.8), and a Power Cycle leaves it alone (it is not console state). fn apply_overclock(&self) { let lines = self.config.enhancements.overclock_scanlines; - self.emu.lock().overclock_scanlines = lines; + let cpu = self.config.enhancements.cpu_overclock; + let sprites = self.config.enhancements.disable_sprite_limit; + let mut emu = self.emu.lock(); + emu.overclock_scanlines = lines; + // v3.1.0 — the CPU-multiplier overclock and the sprite-limit option + // ride the same push. + emu.cpu_overclock = cpu; + emu.disable_sprite_limit = sprites; } /// v2.1.7 P5 — push the opt-in PPU hardware-revision + power-on knobs from @@ -9687,15 +9720,8 @@ impl App { let dual_cabinet = self.cabinet_for_image(&nes, &self.rom_bytes, sample_rate); #[cfg(target_arch = "wasm32")] let _ = sample_rate; - if self.config.rewind.enabled { - // 60 fps × max_seconds × ~120 KiB/snapshot keyframe ≈ ~7 MiB - // before delta compression; we cap at 32 MiB by default. - let max_bytes: usize = - ((self.config.rewind.max_seconds as usize) * 60).max(60) * 200 * 1024; - nes.enable_rewind_with( - max_bytes.min(rustynes_core::REWIND_DEFAULT_MAX_BYTES), - self.config.rewind.keyframe_period.max(1), - ); + if let Some((max_bytes, keyframe_period)) = rewind_budget(&self.config) { + nes.enable_rewind_with(max_bytes, keyframe_period); } // v1.7.0 — arm the Four Score 4-player adapter per config (off by // default; two-controller path stays byte-identical when off). @@ -9741,6 +9767,8 @@ impl App { // v2.9.8 — the overclock lives on `EmuCore` (applied at the top of // each produced frame); set before the console is installed. emu.overclock_scanlines = self.config.enhancements.overclock_scanlines; + emu.cpu_overclock = self.config.enhancements.cpu_overclock; + emu.disable_sprite_limit = self.config.enhancements.disable_sprite_limit; // Capture the cartridge's nominal frame duration — consults the // cartridge region (NTSC: ~16.64 ms, PAL/Dendy: ~20 ms). emu.frame_duration = nes.frame_duration(); @@ -11932,20 +11960,22 @@ impl ApplicationHandler for App { } if settings.rewind_enabled { let mut guard = self.emu.lock(); + let budget = rewind_budget(&self.config); if let Some(nes) = guard.nes.as_mut() { - if self.config.rewind.enabled { - let max_bytes: usize = ((self.config.rewind.max_seconds as usize) * 60) - .max(60) - * 200 - * 1024; - nes.enable_rewind_with( - max_bytes.min(rustynes_core::REWIND_DEFAULT_MAX_BYTES), - self.config.rewind.keyframe_period.max(1), - ); + if let Some((max_bytes, keyframe_period)) = budget { + nes.enable_rewind_with(max_bytes, keyframe_period); } else { nes.disable_rewind(); } } + // v3.1.0 — a loaded cabinet follows the same setting. + if let Some(dual) = guard.dual.as_mut() { + if let Some((max_bytes, keyframe_period)) = budget { + dual.enable_rewind_with(max_bytes, keyframe_period); + } else { + dual.disable_rewind(); + } + } } // v2.8.0 Phase 2 — re-resolve the pacing regime live when // the user changed `pacing_mode` in the settings panel. An diff --git a/crates/rustynes-frontend/src/config.rs b/crates/rustynes-frontend/src/config.rs index a71ed259d..7d590d9ef 100644 --- a/crates/rustynes-frontend/src/config.rs +++ b/crates/rustynes-frontend/src/config.rs @@ -1790,9 +1790,12 @@ pub struct Config { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct EnhancementsConfig { /// Disable the hardware 8-sprite-per-scanline limit (removes sprite - /// flicker). Off by default = accurate hardware behaviour. **Staged**: the - /// current cycle-accurate core has no no-sprite-limit hook, so this is - /// persisted + surfaced but inert until the v2.0 core pass (ADR 0002). + /// flicker). Off by default = accurate hardware behaviour. v3.1.0 + /// (`T-SPRITE-LIMIT`): applied to the core (`Nes::set_sprite_limit_disabled`) + /// from the next frame. Render-only: evaluation, the overflow flag and the + /// sprite fetches stay exact, so the game sees no difference. Like + /// `cpu_overclock` a movie records it and netplay peers must match. Until + /// v3.1.0 it was persisted and shown but nothing read it. #[serde(default)] pub disable_sprite_limit: bool, /// Optional overclock: extra emulated PPU scanlines inserted in the @@ -1804,6 +1807,15 @@ pub struct EnhancementsConfig { /// persisted and shown but nothing read it. #[serde(default)] pub overclock_scanlines: u16, + /// v3.1.0 (`T-CPU-OVERCLOCK`): the CPU-multiplier overclock, `2..=4` for + /// x2 to x4; `0` (the default) and `1` are stock. The CPU runs that many + /// times faster against the same picture and sound + /// (`Nes::set_cpu_overclock`). Unlike `overclock_scanlines` it is not + /// held at stock under a movie or netplay: a movie records it and replays + /// with it, and netplay peers must match (both through the core's + /// `HardwareOptions`). + #[serde(default)] + pub cpu_overclock: u8, } /// v2.1.4 F2.3 — the `[emulation]` section: optional **accuracy** toggles. @@ -1875,6 +1887,15 @@ pub struct EmulationConfig { #[serde(default)] pub famicom_console: bool, + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`) — which MMC3 IRQ revision mapper-4 + /// games run on: `auto` (the default; the header decides, Sharp unless a + /// NES 2.0 header says submapper 4), `sharp`, or `alternate` (the MMC3A + /// and non-Sharp MMC3B). An iNES 1.0 dump cannot name its chip, so this is + /// how to run one on the other. Pushed into the core via + /// `Nes::set_mmc3_revision_override` on ROM load and power cycle. + #[serde(default)] + pub mmc3_irq_revision: Mmc3IrqRevision, + /// v2.1.8 A1 / v2.2.3 — use the specialized visible-scanline fast dot path /// (`Nes::set_fast_dotloop`). **On by default**, and unlike every other /// field here it is **not an accuracy knob**: the fast path runs the same @@ -1895,6 +1916,31 @@ pub struct EmulationConfig { pub fast_dotloop: bool, } +/// v3.1.0 — the `[emulation] mmc3_irq_revision` choice. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum Mmc3IrqRevision { + /// The cartridge header decides (Sharp unless NES 2.0 submapper 4). + #[default] + Auto, + /// The Sharp MMC3B / MMC3C: a latch of 0 fires every scanline. + Sharp, + /// The MMC3A and non-Sharp MMC3B: a latch of 0 stops IRQs. + Alternate, +} + +impl Mmc3IrqRevision { + /// The core override this choice selects. + #[must_use] + pub const fn to_core(self) -> Option { + match self { + Self::Auto => None, + Self::Sharp => Some(rustynes_core::rustynes_mappers::Mmc3Revision::Sharp), + Self::Alternate => Some(rustynes_core::rustynes_mappers::Mmc3Revision::Nec), + } + } +} + /// Serde + [`Default`] value for [`EmulationConfig::fast_dotloop`] — `true`. /// /// A named function because `bool`'s `Default` is `false`, which would both @@ -1916,6 +1962,7 @@ impl Default for EmulationConfig { randomize_power_on_ram: false, power_on_ram_seed: 0, famicom_console: false, + mmc3_irq_revision: Mmc3IrqRevision::Auto, fast_dotloop: default_fast_dotloop(), } } diff --git a/crates/rustynes-frontend/src/crt.rs b/crates/rustynes-frontend/src/crt.rs index dcd5e9ce1..084636664 100644 --- a/crates/rustynes-frontend/src/crt.rs +++ b/crates/rustynes-frontend/src/crt.rs @@ -109,6 +109,10 @@ pub const SIGNAL_DECODE_STACK_PARAMS: &str = concat!( "// #pragma parameter brightness \"Brightness\" 1.0 0.5 1.5 0.02\n", "// #pragma parameter contrast \"Contrast\" 1.0 0.5 1.5 0.02\n", "// #pragma parameter hue \"Hue (radians)\" 0.0 -1.0 1.0 0.02\n", + // v3.1.0 (`T-COMPOSITE-ARTIFACTS`): the documented differential phase + // distortion, degrees of hue rotation per palette row. 0 = off; NESdev + // estimates 2.5 for a 2C02E and 5 for a 2C02G. + "// #pragma parameter diff_phase \"Differential phase (deg/row; 2.5 = 2C02E, 5 = 2C02G)\" 0.0 0.0 10.0 0.5\n", ); /// CRT / scanline post-process filter. diff --git a/crates/rustynes-frontend/src/debugger/settings_panel.rs b/crates/rustynes-frontend/src/debugger/settings_panel.rs index 77bc60652..efce27458 100644 --- a/crates/rustynes-frontend/src/debugger/settings_panel.rs +++ b/crates/rustynes-frontend/src/debugger/settings_panel.rs @@ -1856,6 +1856,34 @@ pub fn advanced_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi save_config(config); } + // v3.1.0 — the MMC3 IRQ revision for mapper-4 games (`auto` = the header). + // Like the console model it takes effect at the next ROM load or power + // cycle, through the same push. + { + use crate::config::Mmc3IrqRevision as R; + let label = |r: R| match r { + R::Auto => crate::t!(SetMmc3RevAuto).to_string(), + R::Sharp => crate::t!(SetMmc3RevSharp).to_string(), + R::Alternate => crate::t!(SetMmc3RevAlternate).to_string(), + }; + let before = config.emulation.mmc3_irq_revision; + ui.horizontal(|ui| { + ui.label(crate::t!(SetMmc3Revision)) + .on_hover_text(crate::t!(SetMmc3RevisionHover)); + egui::ComboBox::from_id_salt("emu-mmc3-revision") + .selected_text(label(before)) + .show_ui(ui, |ui| { + for r in [R::Auto, R::Sharp, R::Alternate] { + ui.selectable_value(&mut config.emulation.mmc3_irq_revision, r, label(r)); + } + }); + }); + if config.emulation.mmc3_irq_revision != before { + state.apply.console_model = true; + save_config(config); + } + } + // v2.2.3 — the specialized PPU fast dot path. NOT an accuracy toggle: both // paths emit the identical framebuffer/audio/cycle count (pinned every frame // by `fast_dotloop_diff`), so this is a performance selector with an escape @@ -1931,13 +1959,17 @@ fn enhancements_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi .show(ui, |ui| { ui.weak(crate::t!(SetEnhancementsNote)); - let mut changed = false; - changed |= ui + // v3.1.0: live. The checkbox pushes through the same apply flag as + // the overclocks (`App::apply_overclock`). + let mut changed = ui .checkbox( &mut config.enhancements.disable_sprite_limit, crate::t!(SetDisableSpriteLimit), ) .changed(); + if changed { + state.apply.overclock = true; + } ui.indent("enh-sprite-note", |ui| { ui.weak(crate::t!(SetEnhSpriteInert)); }); @@ -1960,6 +1992,42 @@ fn enhancements_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi ui.weak(crate::t!(SetEnhOverclockNote)); }); + // v3.1.0 (`T-CPU-OVERCLOCK`): the CPU-multiplier overclock. 0 and 1 + // both mean stock; the combo writes 0 for stock so an untouched + // config stays at its default. + ui.horizontal(|ui| { + ui.label(crate::t!(SetCpuOverclock)); + // Clamped as the core clamps it, so a hand-edited config + // shows the multiplier that actually runs. + let current = config + .enhancements + .cpu_overclock + .clamp(1, rustynes_core::MAX_CPU_OVERCLOCK); + let label = |k: u8| { + if k == 1 { + crate::t!(SetCpuOverclockOff).to_string() + } else { + format!("x{k}") + } + }; + let mut chosen = current; + egui::ComboBox::from_id_salt("enh-cpu-overclock") + .selected_text(label(current)) + .show_ui(ui, |ui| { + for k in 1..=rustynes_core::MAX_CPU_OVERCLOCK { + ui.selectable_value(&mut chosen, k, label(k)); + } + }); + if chosen != current { + config.enhancements.cpu_overclock = if chosen == 1 { 0 } else { chosen }; + state.apply.overclock = true; + changed = true; + } + }); + ui.indent("enh-cpu-overclock-note", |ui| { + ui.weak(crate::t!(SetEnhCpuOverclockNote)); + }); + // The max-rewind window cross-links the Rewind group above (the // enhancement-adjacent third knob), surfaced here for grouping. ui.separator(); @@ -2019,9 +2087,11 @@ mod tests { let mut state = SettingsPanelState::default(); let mut config = Config::default(); config.enhancements.overclock_scanlines = 40; + config.enhancements.cpu_overclock = 3; config.emulation.famicom_console = true; reset_advanced(&mut state, &mut config); assert_eq!(config.enhancements.overclock_scanlines, 0); + assert_eq!(config.enhancements.cpu_overclock, 0, "back to stock"); assert!(!config.emulation.famicom_console, "back to the NES model"); let apply = state.take_apply(); assert!(apply.overclock, "the reset overclock reaches the core"); diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index c647993c6..5db894a6f 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -505,6 +505,11 @@ impl core::fmt::Display for RestoreStateError { } /// The emulation core: the per-frame produce state extracted from `App`. +// The produce loop's state bag: its flags (HD capture, the write lock, the +// sprite-limit option, the run-ahead throttle) are unrelated switches read in +// different places, not states of one thing. (v3.1.0's `disable_sprite_limit` +// made the `hd-pack` build count four.) +#[allow(clippy::struct_excessive_bools)] pub struct EmuCore { /// The running single-console emulator (None until a single-console ROM is /// loaded, or while a Vs. `DualSystem` cabinet is loaded — see [`Self::dual`]). @@ -601,6 +606,15 @@ pub struct EmuCore { /// `0` (the default) is stock timing, byte-identical to a core that never /// heard of the setting. pub overclock_scanlines: u16, + /// v3.1.0 — the configured CPU-multiplier overclock (`[enhancements] + /// cpu_overclock`, pushed by `App` beside `overclock_scanlines`). Applied + /// at the top of every produced frame while no movie is recording or + /// playing; during a movie the movie's own options hold the console (its + /// recorded multiplier). `0` and `1` are stock. + pub cpu_overclock: u8, + /// v3.1.0 — the configured sprite-limit option (`[enhancements] + /// disable_sprite_limit`), applied like `cpu_overclock`. + pub disable_sprite_limit: bool, /// Vs. System coin-hold countdown (frames until `clear_coin`). pub vs_coin_frames: u8, /// Per-region frame duration (NTSC ~16.639 ms, PAL/Dendy ~19.997 ms). @@ -1089,6 +1103,8 @@ impl EmuCore { debug_pokes: Vec::new(), writes_locked: false, overclock_scanlines: 0, + cpu_overclock: 0, + disable_sprite_limit: false, vs_coin_frames: 0, frame_duration: rustynes_core::FRAME_DURATION_NTSC, speed: 1.0, @@ -1594,13 +1610,14 @@ impl EmuCore { // v2.1.2 F2.1 — a loaded Vs. `DualSystem` cabinet takes a parallel, much // simpler produce path: step both consoles, harvest both framebuffers, // push the MAIN console's audio. The advanced single-`Nes` features - // (run-ahead, rewind, TAS, breakpoints, HD-pack, A/V record) are scoped - // out in dual mode (ADR 0032) — `dual` and `nes` are mutually exclusive, - // so the whole single path below is dead when a cabinet is loaded. + // (TAS, breakpoints, HD-pack, A/V record) are scoped out in dual mode + // (ADR 0032) — `dual` and `nes` are mutually exclusive, so the whole + // single path below is dead when a cabinet is loaded. Rewind and + // run-ahead are not, since v3.1.0 (the ADR's 2026-10-07 amendment). // v2.9.7 "Tandem" — the wasm-winit frontend takes this path too (it // builds the cabinet on its load path and presents both screens). if self.dual.is_some() { - self.produce_dual_frame(sinks); + self.produce_dual_frame(inputs, sinks); return fx; } let hardcore_blocked = inputs.hardcore_blocked; @@ -1635,6 +1652,18 @@ impl EmuCore { if nes.extra_scanlines() != extra_lines { nes.set_extra_scanlines(extra_lines); } + // v3.1.0 — the CPU overclock applies only outside a movie session: a + // recording captures what the console runs with when it starts, and a + // playing movie holds the console to its own options every frame. + if self.movie.mode() == crate::movie_ui::MovieMode::Idle { + let want = self.cpu_overclock.max(1); + if nes.cpu_overclock() != want { + nes.set_cpu_overclock(want); + } + if nes.sprite_limit_disabled() != self.disable_sprite_limit { + nes.set_sprite_limit_disabled(self.disable_sprite_limit); + } + } // v2.7.0 — RetroAchievements hardcore mode disables rewind (already // folded into `inputs.rewind_held` by `App`). let rewinding = inputs.rewind_held; @@ -1871,8 +1900,18 @@ impl EmuCore { /// layout without holding the emu lock), and pushes the MAIN console's audio /// to the sink. The SUB console's audio is drained and discarded so its APU /// sample buffer cannot grow without bound. This path deliberately omits the - /// single-`Nes` machinery (run-ahead / rewind / TAS / breakpoints / HD-pack / - /// A/V record), which is scoped out in dual mode (ADR 0032). + /// single-`Nes` machinery (TAS / breakpoints / HD-pack / A/V record), which is + /// scoped out in dual mode (ADR 0032). + /// + /// v3.1.0 (`T-PS-dual-runahead`, the ADR's 2026-10-07 amendment) — rewind + /// and run-ahead work here too, on the whole cabinet. With the rewind key + /// held, the cabinet steps back one frame through its own ring + /// ([`rustynes_core::VsDualSystem::rewind_step_back`]) and presents both + /// restored screens; no audio is pushed, as on the single path. With + /// run-ahead on (native only, as on the single path), the cabinet runs + /// the persistent frame plus `n` frames ahead + /// ([`crate::runahead::RunAhead::run_cabinet_ahead`]), presents the + /// visible frame's two screens and main audio, and rolls back. /// /// v2.9.7 "Tandem" — no longer native-only: the wasm-winit frontend runs a /// cabinet too. The only platform difference is the audio sink: native @@ -1884,7 +1923,26 @@ impl EmuCore { target_arch = "wasm32", allow(unused_variables, clippy::needless_pass_by_ref_mut) )] - fn produce_dual_frame(&mut self, sinks: &mut FrameSinks<'_>) { + fn produce_dual_frame(&mut self, inputs: &FrameInputs, sinks: &mut FrameSinks<'_>) { + // Resolved before `dual` is borrowed; 0 = a plain frame. + #[cfg(not(target_arch = "wasm32"))] + let run_ahead_n = self.effective_run_ahead(inputs.run_ahead); + // RetroAchievements hardcore never reaches a cabinet (RA is scoped out + // of dual mode), and `App` folds it into `rewind_held` regardless. + if inputs.rewind_held { + let Some(dual) = self.dual.as_mut() else { + return; + }; + // A failed step (empty ring, rewind off) leaves the cabinet where + // it is; the screens are re-presented either way. + let _ = dual.rewind_step_back(); + self.present_fb.clear(); + self.present_fb.extend_from_slice(dual.main_framebuffer()); + self.present_fb_sub.clear(); + self.present_fb_sub + .extend_from_slice(dual.sub_framebuffer()); + return; + } // v2.5.0 / F2.1 — Vs. System coin latch: a coin-insert holds the acceptor // for a few frames, then auto-clears (uniform with the single path). let clear_coin = self.vs_coin_frames > 0 && { @@ -1897,13 +1955,24 @@ impl EmuCore { if clear_coin { dual.clear_coin(); } - dual.run_frame(); + #[cfg(not(target_arch = "wasm32"))] + let ran_ahead = run_ahead_n > 0 && { + self.runahead.run_cabinet_ahead(dual, run_ahead_n); + true + }; + #[cfg(target_arch = "wasm32")] + let ran_ahead = false; + if !ran_ahead { + dual.run_frame(); + } self.present_fb.clear(); self.present_fb.extend_from_slice(dual.main_framebuffer()); self.present_fb_sub.clear(); self.present_fb_sub .extend_from_slice(dual.sub_framebuffer()); // Push the MAIN console's audio; the frontend presents one audio stream. + // Under run-ahead this is the visible frame's audio, harvested before + // the rollback below, exactly as the single path does. #[cfg(not(target_arch = "wasm32"))] if let Some(audio) = sinks.audio.as_mut() { let target = ((u64::from(audio.sample_rate()) / 50) as usize).max(1024); @@ -1924,6 +1993,10 @@ impl EmuCore { self.audio_buf.resize(1024, 0.0); } while dual.sub_mut().drain_audio_into(&mut self.audio_buf) == self.audio_buf.len() {} + #[cfg(not(target_arch = "wasm32"))] + if ran_ahead { + self.runahead.finish_cabinet(dual); + } } /// v1.6.0 "Studio" Workstream G — feed this frame's produced framebuffer @@ -2721,6 +2794,71 @@ mod tests { ); } + /// v3.1.0 (`T-PS-dual-runahead`) — rewind and run-ahead reach a cabinet + /// through the real produce path, not only through `VsDualSystem` and + /// `RunAhead` directly. Holding rewind steps the cabinet back and presents + /// BOTH restored screens; a frame with run-ahead 1 leaves the cabinet one + /// frame on and presents the screens of the frame after that. Until + /// v3.1.0 `produce_dual_frame` ignored both inputs. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_cabinet_rewinds_and_runs_ahead_through_the_produce_path() { + let rom = crate::runahead::tests::flashing_cabinet(); + let mut sinks = FrameSinks { + audio: None, + #[cfg(feature = "retroachievements")] + ra: None, + }; + let plain = quiet_inputs(); + let mut core = EmuCore::new(); + let mut cabinet = rustynes_core::VsDualSystem::from_rom(&rom).unwrap(); + cabinet.enable_rewind(); + core.set_dual(Box::new(cabinet)); + let presented = |core: &EmuCore| (core.present_fb.clone(), core.present_fb_sub.clone()); + let mut shown = Vec::new(); + for _ in 0..12 { + core.produce_one_frame(&plain, &mut sinks); + shown.push(presented(&core)); + } + assert_ne!(shown[10], shown[11], "the stimulus changes every frame"); + + let mut rewind = quiet_inputs(); + rewind.rewind_held = true; + // The newest entry is the frame on screen; each further step goes back one. + core.produce_one_frame(&rewind, &mut sinks); + assert_eq!(presented(&core), shown[11]); + core.produce_one_frame(&rewind, &mut sinks); + assert_eq!( + presented(&core), + shown[10], + "both screens of the frame before" + ); + core.produce_one_frame(&rewind, &mut sinks); + assert_eq!(presented(&core), shown[9]); + + let mut ahead = quiet_inputs(); + ahead.run_ahead = 1; + let before = core.dual.as_ref().unwrap().snapshot(); + core.produce_one_frame(&ahead, &mut sinks); + let mut probe = rustynes_core::VsDualSystem::from_rom(&rom).unwrap(); + probe.restore(&before).unwrap(); + probe.run_frame(); + assert_eq!( + core.dual.as_ref().unwrap().snapshot(), + probe.snapshot(), + "the cabinet itself advanced exactly one frame" + ); + probe.run_frame(); + assert_eq!( + presented(&core), + ( + probe.main_framebuffer().to_vec(), + probe.sub_framebuffer().to_vec() + ), + "the presented screens are the frame after" + ); + } + /// v2.9.7 (`T-PS-dual-savestate`) — a Vs. `DualSystem` cabinet saves and /// restores both consoles, byte for byte. A single-console blob and a /// cabinet blob are each refused by the other kind, and nothing restores diff --git a/crates/rustynes-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs index fcd3fbfa5..58a53919b 100644 --- a/crates/rustynes-frontend/src/i18n.rs +++ b/crates/rustynes-frontend/src/i18n.rs @@ -720,10 +720,18 @@ catalog! { SetAccuracy => "Accuracy", Some("Precisión"); SetOamDecay => "OAM decay (accuracy)", Some("Degradación de OAM (precisión)"); SetFamicomConsole => "Famicom console (PPU leaves reset early)", Some("Consola Famicom (la PPU sale antes del reinicio)"); + SetMmc3Revision => "MMC3 IRQ revision", Some("Revisión de IRQ del MMC3"); + SetMmc3RevAuto => "Auto (from the ROM)", Some("Automática (según la ROM)"); + SetMmc3RevSharp => "Sharp (MMC3B/C)", Some("Sharp (MMC3B/C)"); + SetMmc3RevAlternate => "Alternate (MMC3A, NEC MMC3B)", Some("Alternativa (MMC3A, MMC3B de NEC)"); + SetMmc3RevisionHover => "Which MMC3 chip mapper-4 games run on. The two differ only when a game sets the IRQ latch to 0. Auto uses the ROM header, which says Sharp unless it is a NES 2.0 header naming the alternate chip. Takes effect from the next power cycle or ROM load.", Some("En qué chip MMC3 se ejecutan los juegos del mapper 4. Los dos solo difieren cuando un juego pone el latch de IRQ a 0. Automática usa la cabecera de la ROM, que indica Sharp salvo que sea una cabecera NES 2.0 que nombre el chip alternativo. Surte efecto desde el siguiente apagado y encendido o carga de ROM."); SetFastDotPath => "Fast PPU dot path (performance, not accuracy)", Some("Ruta rápida de puntos de la PPU (rendimiento, no precisión)"); SetEnhancements => "Enhancements (non-accuracy)", Some("Mejoras (ajenas a la precisión)"); SetDisableSpriteLimit => "Disable 8-sprite-per-scanline limit (reduces flicker)", Some("Desactivar el límite de 8 sprites por línea (reduce el parpadeo)"); SetOverclock => "Overclock (extra scanlines)", Some("Overclock (líneas extra)"); + SetCpuOverclock => "CPU overclock", Some("Overclock de la CPU"); + SetCpuOverclockOff => "Off (x1)", Some("Desactivado (x1)"); + SetEnhCpuOverclockNote => "Runs the game's CPU 2 to 4 times faster against the same picture and sound, which removes slowdown. Movies record it and replay with it; netplay players must all use the same setting.", Some("Ejecuta la CPU del juego de 2 a 4 veces más rápido con la misma imagen y el mismo sonido, lo que elimina la ralentización. Las películas lo graban y lo reproducen; en el juego en red todos los jugadores deben usar el mismo ajuste."); SetMaxRewind => "Max rewind (seconds)", Some("Rebobinado máximo (segundos)"); SetMaxRewindNote => "(also in Rewind; restart to resize the buffer)", Some("(también en Rebobinado; reinicia para redimensionar el búfer)"); SetConfirmReset => "Confirm reset {0}?", Some("¿Confirmar restablecimiento de {0}?"); @@ -748,8 +756,8 @@ catalog! { SetOamDecayHover => "Model the 2C02's dynamic OAM losing un-refreshed sprite rows to a garbage pattern when rendering stays off (à la Mesen2). NTSC/Dendy only. Off is byte-identical to today's core.", Some("Modela cómo la OAM dinámica de la 2C02 pierde las filas de sprites no refrescadas y las convierte en un patrón basura cuando el renderizado permanece desactivado (como Mesen2). Solo NTSC/Dendy. Desactivado es idéntico byte a byte al núcleo actual."); SetFamicomConsoleHover => "Model the Famicom's reset wiring instead of the front-loading NES's: the PPU is never held in reset, so it is past its ~29,658-cycle warm-up when the game starts, and the Reset button reaches only the CPU. Some Famicom carts (the 999-in-1 multicart) need it. Takes full effect from the next power cycle or ROM load. Off is byte-identical to today's core.", Some("Modela el cableado de reinicio de la Famicom en lugar del de la NES de carga frontal: la PPU nunca se mantiene en reinicio, así que ya ha terminado su calentamiento de ~29.658 ciclos cuando empieza el juego, y el botón Reset solo llega a la CPU. Algunos cartuchos de Famicom (el multicartucho 999-in-1) lo necesitan. Surte pleno efecto desde el siguiente apagado y encendido o carga de ROM. Desactivado es idéntico byte a byte al núcleo actual."); SetFastDotPathHover =>"Run the specialized straight-line handler for undisturbed visible background dots. Emits the identical frame either way (verified bit-for-bit every frame) and is ~11% faster on rendering-heavy games. Leave on unless you are diagnosing a suspected PPU difference.", Some("Ejecuta el manejador especializado en línea recta para los puntos de fondo visibles sin alteraciones. Produce el mismo fotograma en ambos casos (verificado bit a bit en cada fotograma) y es ~11% más rápido en juegos con mucho renderizado. Déjalo activado salvo que estés diagnosticando una posible diferencia de la PPU."); - SetEnhancementsNote => "Off-by-default enhancement modes. These are NEVER applied while accuracy tests / TAS replay / netplay run.", Some("Modos de mejora desactivados por defecto. NUNCA se aplican durante pruebas de precisión / reproducción de TAS / juego en red."); - SetEnhSpriteInert => "Experimental: staged for the v2.0 core pass (currently inert).", Some("Experimental: preparado para la fase del núcleo v2.0 (actualmente sin efecto)."); + SetEnhancementsNote => "Off-by-default enhancement modes. Accuracy tests never use them. Each one below says how it treats movies and netplay: some are recorded and must match between players, others are ignored there.", Some("Modos de mejora desactivados por defecto. Las pruebas de precisión nunca los usan. Cada uno indica abajo cómo trata las películas y el juego en red: algunos se graban y deben coincidir entre jugadores, otros se ignoran allí."); + SetEnhSpriteInert => "Draws the sprites a scanline drops past eight, behind the eight the console shows. The game sees nothing different. Not on MMC2, MMC4 or a few boards whose pattern reads change the cartridge. Movies record it; netplay players must match.", Some("Dibuja los sprites que una línea descarta a partir del octavo, detrás de los ocho que muestra la consola. El juego no nota ninguna diferencia. No funciona en MMC2, MMC4 ni en algunas placas cuyas lecturas de patrones cambian el cartucho. Las películas lo graban; en el juego en red todos los jugadores deben coincidir."); SetEnhOverclockNote => "Adds idle scanlines after the visible frame to reduce slowdown in some games. Changes timing, so it is ignored while recording or playing a movie and during netplay.", Some("Agrega líneas de barrido inactivas después del fotograma visible para reducir la ralentización en algunos juegos. Cambia la temporización, por lo que se ignora al grabar o reproducir una película y durante el juego en red."); } diff --git a/crates/rustynes-frontend/src/netplay_ui.rs b/crates/rustynes-frontend/src/netplay_ui.rs index fad69423e..b7915797d 100644 --- a/crates/rustynes-frontend/src/netplay_ui.rs +++ b/crates/rustynes-frontend/src/netplay_ui.rs @@ -238,11 +238,14 @@ impl NetplayUi { /// /// `num_players` (2..=4) selects how many players the session runs; 3-4 /// players enable the Four Score adapter. It is clamped into `2..=4`. The - /// multi-joiner UDP handshake (a host adopting several joiners + assigning - /// each a player index) is a follow-up — the N-player rollback core + - /// determinism proof live in `rustynes-netplay`; the native UDP layer currently - /// completes the first joiner's handshake. The selected `num_players` is - /// still recorded so the session + Four Score wiring is in place. + /// multi-joiner UDP handshake (a host adopting several joiners and + /// assigning each a player index) exists in `rustynes_netplay::mesh_net` + /// since v2.6.0 (`MeshHost`, `UdpMeshTransport`), with the N-player + /// rollback core and its determinism proof; this desktop path does not use + /// it yet and completes the first joiner's handshake only. Wiring it is the + /// v3.1 → v4.0 line plan's "native 3-4 players" (v3.4.0). The selected + /// `num_players` is still recorded so the session + Four Score wiring is in + /// place. /// /// The host no longer needs to pre-enter the joiner's address — it just /// shares its own listening `IP:port` and the joiner dials in (see diff --git a/crates/rustynes-frontend/src/ntsc.rs b/crates/rustynes-frontend/src/ntsc.rs index b9fea1f6a..cc0ec5113 100644 --- a/crates/rustynes-frontend/src/ntsc.rs +++ b/crates/rustynes-frontend/src/ntsc.rs @@ -14,7 +14,9 @@ //! coarse Blargg trick). //! //! Not a bit-exact port of `nes_ntsc`. Marked `ntsc-simple` in the config -//! to set expectations. A full NES_NTSC port is a v1.1 follow-up. +//! to set expectations. The signal-accurate filters are the other rungs of +//! the ladder this one starts: the LMP88959 decode and the Bisqwit per-dot +//! composite (`CompositeRt`), both in the shared shader stack since v2.1.2. //! //! Performance: 5 texture taps per surface pixel; on a 768x720 window //! that's ~2.8M taps/frame, well below GPU memory-bandwidth limits. diff --git a/crates/rustynes-frontend/src/runahead.rs b/crates/rustynes-frontend/src/runahead.rs index dfb55300f..a014e6c8c 100644 --- a/crates/rustynes-frontend/src/runahead.rs +++ b/crates/rustynes-frontend/src/runahead.rs @@ -29,7 +29,7 @@ //! so consecutive cycles produce the contiguous stream `N+1, N+2, …` — no //! gaps, no overlaps, just shifted by the same `N` frames as the video. -use rustynes_core::Nes; +use rustynes_core::{Nes, VsDualSystem}; /// Scratch state for the run-ahead cycle (reused buffers — no per-frame /// allocation in steady state). @@ -118,6 +118,57 @@ impl RunAhead { nes.set_rewind_capture(true); } + /// v3.1.0 (`T-PS-dual-runahead`, ADR 0032 amendment of 2026-10-07) — + /// [`Self::run_frame_ahead`] for a Vs. `DualSystem` cabinet. The same + /// cycle on the whole cabinet: the persistent frame, a snapshot of BOTH + /// consoles and the latch wiring them (the `RVSD` container), `n - 1` + /// hidden frames, and the visible one. On return the cabinet holds the + /// visible frame's two framebuffers and the main console's un-drained + /// audio; the caller harvests them, then MUST call + /// [`Self::finish_cabinet`]. + /// + /// The unit is the cabinet and never one console: the two share a WRAM + /// and drive each other's `/IRQ`, so running one ahead alone would + /// present a future the partner never reached. + pub fn run_cabinet_ahead(&mut self, dual: &mut VsDualSystem, n: u32) { + debug_assert!(n >= 1); + // The persistent frame: the cabinet's timeline, captured by its + // rewind ring as a plain frame would be. + dual.run_frame(); + self.discard_cabinet_audio(dual); + dual.snapshot_into(&mut self.snap_buf); + // Hidden + visible frames are off-timeline: no rewind capture. + dual.set_rewind_capture(false); + for _ in 1..n { + dual.run_frame(); + self.discard_cabinet_audio(dual); + } + dual.run_frame(); + } + + /// Phase B of [`Self::run_cabinet_ahead`]: roll the cabinet back to the + /// persistent frame and re-enable its rewind capture. The rollback is + /// [`VsDualSystem::restore_quiet`], which keeps the cabinet's rewind + /// ring. The cabinet has no pixel- or audio-provenance stores to carry + /// around it: the debugger is scoped out of dual mode (ADR 0032). + /// + /// # Panics + /// + /// Panics if the snapshot fails to restore, which a container produced + /// by `snapshot_into` on the same cabinet cannot do. + pub fn finish_cabinet(&mut self, dual: &mut VsDualSystem) { + dual.restore_quiet(&self.snap_buf) + .expect("run-ahead cabinet snapshot round-trips on the same cabinet"); + dual.set_rewind_capture(true); + } + + /// Drain and discard both consoles' audio from a hidden cabinet frame. + fn discard_cabinet_audio(&mut self, dual: &mut VsDualSystem) { + let (main, sub) = dual.split_mut(); + self.discard_audio(main); + self.discard_audio(sub); + } + /// Drain and discard whatever audio the last frame synthesized. fn discard_audio(&mut self, nes: &mut Nes) { // Generously sized: one NTSC frame at 192 kHz is ~3200 samples. @@ -129,7 +180,7 @@ impl RunAhead { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use rustynes_core::Buttons; use std::path::PathBuf; @@ -217,6 +268,136 @@ mod tests { } } + /// v3.1.0 (`T-PS-dual-runahead`) — a synthetic Vs. `DualSystem` cart whose + /// two screens change colour every frame and differ from each other: each + /// console's NMI writes a frame counter into the backdrop entry `$3F00` + /// (eight colours, `$10-$17` on the main and `$20-$27` on the sub, none of + /// them black), with background rendering on over blank CHR-RAM. The same cart as `rustynes-test-harness`'s + /// `vs_dualsystem_rewind.rs`, which explains why the protocol cart there + /// (rendering off, one unchanging colour) is blind to a framebuffer + /// comparison. + pub fn flashing_cabinet() -> Vec { + #[rustfmt::skip] + let program = |base: u8| -> Vec { + vec![ + 0x78, 0xD8, 0xA2, 0xFF, 0x9A, // SEI CLD LDX #$FF TXS + 0x2C, 0x02, 0x20, 0x10, 0xFB, // vblank 1 + 0x2C, 0x02, 0x20, 0x10, 0xFB, // vblank 2 + 0xA9, 0x0A, 0x8D, 0x01, 0x20, // $2001 = $0A (background on) + 0xA9, 0x80, 0x8D, 0x00, 0x20, // $2000 = $80 (NMI on) + 0x4C, 0x19, 0x80, // JMP $8019 + // NMI at $801C + 0xE6, 0x00, 0xA5, 0x00, // INC $00, LDA $00 + 0x29, 0x07, 0x09, base, 0xEA, // AND #$07 ORA #base NOP + 0xA2, 0x3F, 0x8E, 0x06, 0x20, // $2006 = $3F + 0xA2, 0x00, 0x8E, 0x06, 0x20, // $2006 = $00 + 0x8D, 0x07, 0x20, // $3F00 = A + 0x8E, 0x06, 0x20, 0x8E, 0x06, 0x20, // v = $0000 + 0x40, // RTI (also the IRQ vector) + ] + }; + let mut rom = vec![0u8; 16 + 0x10000]; + rom[0..4].copy_from_slice(b"NES\x1a"); + rom[4] = 0x04; // 64 KiB PRG + rom[6] = 0x30; // mapper 99 + rom[7] = 0x69; // NES 2.0, Vs. System + rom[11] = 0x07; // 8 KiB CHR-RAM + rom[13] = 0x50; // Vs. hardware type 5: DualSystem + for (half, base) in [(0usize, 0x10u8), (0x8000, 0x20)] { + let code = program(base); + rom[16 + half..16 + half + code.len()].copy_from_slice(&code); + rom[16 + half + 0x7FFA..16 + half + 0x8000] + .copy_from_slice(&[0x1C, 0x80, 0x00, 0x80, 0x38, 0x80]); + } + rom + } + + fn cabinet() -> VsDualSystem { + match rustynes_core::Emu::from_rom(&flashing_cabinet()).expect("cart parses") { + rustynes_core::Emu::Dual(d) => *d, + rustynes_core::Emu::Single(_) => panic!("a DualSystem cart builds a cabinet"), + } + } + + /// v3.1.0 (`T-PS-dual-runahead`, ADR 0032's 2026-10-07 gate: "run-ahead + /// gives the same output as a run without it") — the cabinet form of + /// [`runahead_persistent_timeline_matches_plain_run`], at depths 1 and 2. + /// The persistent cabinet is byte-identical to a plain one after every + /// cycle, both screens of the visible frame are the plain run's frame `n` + /// later, and the rewind ring holds persistent frames only. + #[test] + fn cabinet_runahead_matches_a_plain_run_on_both_screens() { + for n in [1u32, 2] { + let mut ahead = cabinet(); + let mut plain = cabinet(); + ahead.enable_rewind(); + plain.enable_rewind(); + let mut ra = RunAhead::default(); + let mut discard = vec![0.0f32; 8192]; + let mut drain = |d: &mut VsDualSystem| { + let (main, sub) = d.split_mut(); + let _ = main.drain_audio_into(&mut discard); + let _ = sub.drain_audio_into(&mut discard); + }; + for f in 0..20u32 { + for d in [&mut ahead, &mut plain] { + d.set_buttons(0, buttons_for(f)); + d.run_frame(); + drain(d); + } + } + for f in 20..50u32 { + let input = buttons_for(f); + ahead.set_buttons(0, input); + ra.run_cabinet_ahead(&mut ahead, n); + let visible = ( + ahead.main_framebuffer().to_vec(), + ahead.sub_framebuffer().to_vec(), + ); + drain(&mut ahead); + ra.finish_cabinet(&mut ahead); + + plain.set_buttons(0, input); + plain.run_frame(); + drain(&mut plain); + assert_eq!( + ahead.snapshot(), + plain.snapshot(), + "n={n}: the persistent cabinet diverged from the plain one at {f}" + ); + + // The plain timeline `n` frames on, with the input held. + let mut probe = cabinet(); + probe.restore(&plain.snapshot()).expect("own snapshot"); + for _ in 0..n { + probe.set_buttons(0, input); + probe.run_frame(); + } + assert_eq!( + visible.0.as_slice(), + probe.main_framebuffer(), + "n={n}: the visible main screen at {f} is not the plain run {n} ahead" + ); + assert_eq!( + visible.1.as_slice(), + probe.sub_framebuffer(), + "n={n}: the visible sub screen at {f} is not the plain run {n} ahead" + ); + assert_ne!( + visible.0.as_slice(), + ahead.main_framebuffer(), + "the stimulus must tell the visible frame from the persistent one" + ); + } + assert_eq!( + ahead.rewind_len(), + plain.rewind_len(), + "n={n}: the ring holds the persistent frames and nothing else" + ); + assert!(ahead.rewind_capture_enabled(), "capture is back on"); + } + } + /// v1.0.0 (UX3 BUG-3) — Game Genie codes are a runtime PRG-read overlay /// that lives OUTSIDE the save-state, so the run-ahead snapshot/restore /// (`snapshot_core_into` + `restore_quiet`, run every visible frame) must diff --git a/crates/rustynes-frontend/src/shader_pass.rs b/crates/rustynes-frontend/src/shader_pass.rs index b8acf3f9d..0cb1eafe9 100644 --- a/crates/rustynes-frontend/src/shader_pass.rs +++ b/crates/rustynes-frontend/src/shader_pass.rs @@ -727,6 +727,8 @@ impl ShaderStack { u[12] = pv(2, 1.0); // knobs.x = brightness u[13] = pv(3, 1.0); // knobs.y = contrast u[14] = pv(4, 0.0); // knobs.z = hue + // v3.1.0: knobs.w = differential phase, degrees -> radians. + u[15] = pv(5, 0.0).to_radians(); } BuiltinPass::Ntsc => {} } @@ -1021,12 +1023,22 @@ mod tests { ("crt-royale", 7usize), ("crt-guest", 7), ("megatron", 7), - ("signal-decode", 5), + ("signal-decode", 6), ] { let p = BuiltinPass::from_id(id).unwrap_or_else(|| panic!("{id} must resolve")); assert_eq!(p.id(), id); assert_eq!(p.params().len(), min_params, "{id} param count"); } + // v3.1.0 (`T-COMPOSITE-ARTIFACTS`): the sixth signal-decode knob is the + // differential phase, and it defaults to OFF, so a saved stack decodes + // exactly as it did. + let params = BuiltinPass::SignalDecode.params(); + let dp = params.last().expect("six params"); + assert_eq!(dp.name, "diff_phase"); + assert!( + dp.default.abs() < f32::EPSILON, + "differential phase is off by default" + ); } #[test] diff --git a/crates/rustynes-frontend/src/wasm.rs b/crates/rustynes-frontend/src/wasm.rs index c5ccaeab2..191440068 100644 --- a/crates/rustynes-frontend/src/wasm.rs +++ b/crates/rustynes-frontend/src/wasm.rs @@ -15,9 +15,12 @@ //! path: the PPU framebuffer is already RGBA8 256x240, which is //! byte-identical to the canvas `ImageData` format, so a direct //! `put_image_data` blit gets a WORKING browser emulator NOW. The -//! winit/wgpu unification (so the egui debugger overlay + NTSC -//! filter work on web too) is a follow-up sprint (1.4). Audio + -//! `IndexedDB` save state are also follow-ups. +//! winit/wgpu unification, so the egui shell and the NTSC filter work +//! on the web too, is now the default `wasm-winit` build; this module +//! is the lightweight `wasm-canvas` embed beside it. Audio +//! (`crate::wasm_audio`, Sprint 1.4c) and `IndexedDB` save states +//! (v1.4.0 E2) reach this path too. (Until v3.1.0 this paragraph still +//! called all three follow-ups.) //! //! See `docs/audit/v1.3-sprint-1.3-wasm-canvas-mvp-2026-05-24.md`. diff --git a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl index e89d71921..eae25cd7b 100644 --- a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl +++ b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl @@ -27,7 +27,8 @@ // rect, crop as in CRT_WGSL. // params: (x = video phase / line offset, y = saturation, z = sharpness 0..1, // w = source rows, default 240) -// knobs : (x = brightness, y = contrast, z = hue radians, w unused) +// knobs : (x = brightness, y = contrast, z = hue radians, +// w = differential phase, radians per palette row; v3.1.0, 0 = off) // // Presentation only — reads the index framebuffer, never the core state. @@ -205,6 +206,28 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { var i = i_acc * inv * sat; var q = q_acc * inv * sat; + // v3.1.0 (`T-COMPOSITE-ARTIFACTS`, ACC-02): differential phase distortion, + // opt-in. NESdev "NTSC video": the PPU's output impedance depends on the + // signal level, which delays the chroma phase more at higher levels, so the + // hue rotates "about 2.5 degrees (2C02E) or 5 degrees (2C02G) ... for each + // row of the palette". Modelled as that rotation for the centre pixel's + // palette row (0-3). A DELAY subtracts from the recovered phase, hence the + // minus sign. Greys carry no chroma, so they are unaffected; `knobs.w` = 0 + // (every host's default) is the previous output exactly. + if (u.knobs.w != 0.0) { + let ccx = clamp(i32(floor(fcol)), 0, i32(dim.x) - 1); + let ccy = clamp(row, 0, i32(dim.y) - 1); + let cpk = i32(textureLoad(idx_tex, vec2(ccx, ccy), 0).r); + let prow = f32(((cpk & 0x3F) >> 4) & 0x3); + let dpa = -u.knobs.w * prow; + let ca = cos(dpa); + let sa = sin(dpa); + let ir = i * ca - q * sa; + let qr = i * sa + q * ca; + i = ir; + q = qr; + } + // Hue rotate. let ct = cos(hue); let st = sin(hue); diff --git a/crates/rustynes-mappers/src/m004_mmc3.rs b/crates/rustynes-mappers/src/m004_mmc3.rs index 0b68cc26a..3e853936a 100644 --- a/crates/rustynes-mappers/src/m004_mmc3.rs +++ b/crates/rustynes-mappers/src/m004_mmc3.rs @@ -42,17 +42,21 @@ //! sprites @ `$0000`) places the edge at the END of the previous //! scanline's sprite fetches (Wario's Woods relies on this). //! -//! On each filtered rising edge: -//! - if `counter == 0` OR `irq_reload_pending`: counter = `irq_reload_value`; -//! pending cleared; **Sharp** revision additionally asserts IRQ if the -//! reload value was 0 (from a non-zero counter); **NEC** does not. -//! - else: counter -= 1; if post-decrement counter == 0 AND IRQ enabled, -//! assert IRQ line. +//! On each filtered rising edge (`clock_irq`): +//! - a `$C001` reload (`irq_reload_pending`): counter = `irq_reload_value`, +//! flag cleared; BOTH revisions assert if the new value is 0 and IRQs are +//! enabled (v3.1.0; the alternate one used not to, see `clock_irq`); +//! - else if `counter == 0`: counter = `irq_reload_value`; only **Sharp** +//! asserts if the new value is 0, so a latch of 0 fires every scanline on +//! Sharp and stops on the alternate chip; +//! - else: counter -= 1; if it reached 0 and IRQs are enabled, assert. //! //! Default revision is **Sharp** per project policy (Star Trek: 25th -//! Anniversary requires it); NES 2.0 submapper 1 selects MMC3B (NEC), -//! submapper 2 selects MMC3C (Sharp behavior + minor differences not -//! distinguished here). +//! Anniversary requires it). NES 2.0 submapper 4 selects the alternate +//! behaviour of the MMC3A and non-Sharp MMC3B ([`Mmc3Revision::Nec`]), 1 the +//! MMC6 (v2.9.6 corrected both; this paragraph said "submapper 1 selects +//! MMC3B (NEC)" until v3.1.0). For an iNES 1.0 dump, which cannot say, +//! `Mapper::set_mmc3_revision_override` selects it (v3.1.0). #![allow( clippy::cast_possible_truncation, @@ -97,7 +101,7 @@ const MMC6_RAM: usize = 0x0400; /// and the NES 2.0 submapper for it is 4, not 1 (`NES_2_0_submappers.md`). /// The behaviour of each variant was always right; only the labels were /// wrong, together with the submapper mapping that followed them. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum Mmc3Revision { /// Sharp MMC3B and MMC3C: reloading the IRQ counter to 0 asserts IRQ if /// IRQs are enabled, so a latch of 0 fires every scanline. Default. @@ -203,6 +207,11 @@ pub struct Mmc3 { cpu_cycle: u64, revision: Mmc3Revision, + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`): the revision the cartridge header + /// selected, which `revision` returns to when an override is cleared. + /// Board identity, fixed at construction; not save-state (a state carries + /// the live `revision`). + header_revision: Mmc3Revision, variant: Mmc3Variant, /// MMC6: `$8000` bit 5, the PRG-RAM enable. mmc6_ram_enabled: bool, @@ -321,6 +330,7 @@ impl Mmc3 { a12_low_cycle: 0, cpu_cycle: 0, revision, + header_revision: revision, variant: Mmc3Variant::Standard, mmc6_ram_enabled: false, mmc6_protect: 0, @@ -533,7 +543,18 @@ impl Mmc3 { /// 3. Otherwise: decrement. /// /// After any of the three, Sharp asserts when the counter is zero and - /// IRQs are enabled; NEC only after path 3. + /// IRQs are enabled. The alternate revision (`Nec`) asserts after path 3, + /// and after path 1 when the reloaded value is 0, but never after path 2. + /// + /// **Changed in v3.1.0 (`T-MMC3-NEC-OVERRIDE`).** The alternate revision + /// asserted only after path 3. `MMC3.md`: "The 'alternate revision' checks + /// the IRQ counter transition 1→0, whether from decrementing or + /// reloading", and "writing to $C001 with $C000 still at $00 will result in + /// another single IRQ being generated". blargg's `mmc3_test_2/6-MMC3_alt` + /// says the same ("IRQ should be set when reloading due to clear, even if + /// counter was already 0") and could not run until v3.1.0 added a way to + /// select this revision for an iNES 1.0 ROM; it failed there on exactly + /// this, and passes now. The Sharp path is unchanged. /// /// **Changed in v2.9.9 (T-ORACLE-001).** Path 1 used to assert only when /// the `$C001` write had cleared a non-zero counter (a latch named @@ -546,13 +567,12 @@ impl Mmc3 { fn clock_irq(&mut self) -> bool { let mut would_assert = false; if self.irq_reload_pending { - // Path 1: explicit $C001 reload. + // Path 1: explicit $C001 reload. Both revisions assert when the + // reloaded value is 0 (for the alternate one, the single IRQ a + // $C001 write with $C000 = $00 produces). self.irq_counter = self.irq_reload_value; self.irq_reload_pending = false; - if self.irq_enabled - && self.irq_counter == 0 - && matches!(self.revision, Mmc3Revision::Sharp) - { + if self.irq_enabled && self.irq_counter == 0 { would_assert = true; } } else if self.irq_counter == 0 { @@ -577,6 +597,14 @@ impl Mmc3 { } impl Mapper for Mmc3 { + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`): `Some` forces the IRQ revision, `None` + /// returns to the header's. Applies to mapper 4 itself; the MMC3-derived + /// boards that embed this core keep their own revision. + fn set_mmc3_revision_override(&mut self, revision: Option) -> bool { + self.revision = revision.unwrap_or(self.header_revision); + true + } + fn sram(&self) -> &[u8] { &self.prg_ram } @@ -1349,11 +1377,15 @@ mod tests { ); } - /// NEC (Rev B) does NOT assert on reload-to-0 even on the natural - /// was_zero path. Mutually exclusive with the Sharp behavior tested - /// above. + /// NEC (the "alternate" revision) asserts once on a `$C001` reload to 0, + /// and never on the natural `was_zero` reload. `MMC3.md`: it "generates + /// only a single IRQ when `$C000` is `$00`", and "writing to `$C001` with + /// `$C000` still at `$00` will result in another single IRQ"; blargg's + /// `6-MMC3_alt` fails with "IRQ should be set when reloading due to + /// clear" otherwise. v3.1.0 corrected this: until then the test pinned + /// NEC as silent on both paths. #[test] - fn nec_does_not_assert_on_reload_to_zero() { + fn nec_asserts_once_on_a_c001_reload_to_zero_and_not_after() { let mut m = Mmc3::new( synth_prg(8), synth_chr(8), @@ -1362,8 +1394,6 @@ mod tests { Mmc3Revision::Nec, ) .unwrap(); - // Same "non-zero clear" setup as the Sharp test, but on NEC the - // reload-to-0 should NOT assert. m.cpu_write(0xC000, 1); m.cpu_write(0xC001, 0); m.cpu_write(0xE001, 0); @@ -1372,13 +1402,18 @@ mod tests { m.cpu_write(0xC001, 0); a12_rise(&mut m); assert_eq!(m.irq_counter, 0); - assert!( - !m.irq_pending(), - "NEC suppresses Sharp's reload-to-0 assertion" - ); - // Even the natural was_zero path doesn't assert on NEC. + assert!(m.irq_pending(), "the $C001 reload to 0 asserts on NEC too"); + // Acknowledge, then the natural was_zero reload stays silent. + m.cpu_write(0xE000, 0); + m.cpu_write(0xE001, 0); + assert!(!m.irq_pending()); + a12_rise(&mut m); + a12_rise(&mut m); + assert!(!m.irq_pending(), "NEC: the was_zero reload to 0 is silent"); + // A second $C001 write with $C000 still 0: another single IRQ. + m.cpu_write(0xC001, 0); a12_rise(&mut m); - assert!(!m.irq_pending(), "NEC: was_zero reload-to-0 also silent"); + assert!(m.irq_pending(), "each $C001 write gives one more IRQ"); } /// T-41-005 — reversed pattern-table layout (`PPUCTRL` bit 4 set, diff --git a/crates/rustynes-mappers/src/m009_mmc2.rs b/crates/rustynes-mappers/src/m009_mmc2.rs index 606c42dc8..2ca72d159 100644 --- a/crates/rustynes-mappers/src/m009_mmc2.rs +++ b/crates/rustynes-mappers/src/m009_mmc2.rs @@ -173,6 +173,12 @@ impl Mmc2 { } impl Mapper for Mmc2 { + /// v3.1.0: not pure -- a read of tile $FD/$FE switches its CHR latch, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/m010_mmc4.rs b/crates/rustynes-mappers/src/m010_mmc4.rs index 4cc8e5532..7a0a771ae 100644 --- a/crates/rustynes-mappers/src/m010_mmc4.rs +++ b/crates/rustynes-mappers/src/m010_mmc4.rs @@ -166,6 +166,12 @@ impl Mmc4 { } impl Mapper for Mmc4 { + /// v3.1.0: not pure -- a read of tile $FD/$FE switches its CHR latch, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + fn sram(&self) -> &[u8] { &self.prg_ram } diff --git a/crates/rustynes-mappers/src/m035_jy_asic.rs b/crates/rustynes-mappers/src/m035_jy_asic.rs index ed6ab0153..0f0c73a00 100644 --- a/crates/rustynes-mappers/src/m035_jy_asic.rs +++ b/crates/rustynes-mappers/src/m035_jy_asic.rs @@ -580,6 +580,12 @@ impl JyAsic { } impl Mapper for JyAsic { + /// v3.1.0: not pure -- PPU reads clock its IRQ counter, and mapper 209 latches CHR on reads, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + // CPU-cycle hook (for the CPU-clock IRQ source) + IRQ source. No audio. fn caps(&self) -> MapperCaps { MapperCaps::CYCLE_IRQ diff --git a/crates/rustynes-mappers/src/m096_bandai96.rs b/crates/rustynes-mappers/src/m096_bandai96.rs index 8004e11fa..e059a9926 100644 --- a/crates/rustynes-mappers/src/m096_bandai96.rs +++ b/crates/rustynes-mappers/src/m096_bandai96.rs @@ -115,6 +115,12 @@ impl Bandai96 { } impl Mapper for Bandai96 { + /// v3.1.0: not pure -- its inner CHR bank follows the last PPU address read, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + fn caps(&self) -> MapperCaps { MapperCaps::NONE } diff --git a/crates/rustynes-mappers/src/m163_nanjing.rs b/crates/rustynes-mappers/src/m163_nanjing.rs index 17e98234f..b6925e3e1 100644 --- a/crates/rustynes-mappers/src/m163_nanjing.rs +++ b/crates/rustynes-mappers/src/m163_nanjing.rs @@ -125,6 +125,12 @@ impl Nanjing163 { } impl Mapper for Nanjing163 { + /// v3.1.0: not pure -- it latches PPU A13 from reads, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + fn sram(&self) -> &[u8] { &self.wram } diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 9667074a1..e71c6c487 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -325,6 +325,31 @@ pub trait Mapper: Send { None } + /// v3.1.0 (`T-SPRITE-LIMIT`) — whether [`Self::ppu_read`] and + /// [`Self::ppu_read_sprite`] on `$0000-$1FFF` change nothing but return a + /// byte. `true` (the default) lets the PPU's "disable sprite limit" option + /// make extra, display-only pattern reads on this board. + /// + /// Override to `false` for any board whose CHR read has an effect: a latch + /// that switches banks on a tile (MMC2, MMC4), an IRQ counter clocked by + /// reads (the J.Y. ASIC), or address bits latched from the read (Bandai + /// 96, Nanjing 163). `every_board_that_claims_pure_chr_reads_has_them` + /// checks the claim against `save_state` for every mapper id, so a new + /// impure board that keeps the default fails a test rather than letting + /// the option change emulation. + fn chr_reads_are_pure(&self) -> bool { + true + } + + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`, ACC-13) — force an MMC3's IRQ revision + /// (`Some`), or return to the one its header selected (`None`). Returns + /// whether this board is an MMC3 that applied it; every other board + /// ignores it (the default). Lets an iNES 1.0 dump, which cannot name its + /// MMC3 revision, run under the alternate (`Nec`) behaviour. + fn set_mmc3_revision_override(&mut self, _revision: Option) -> bool { + false + } + /// Write a byte to the PPU address space `$0000-$3FFF`. fn ppu_write(&mut self, addr: u16, value: u8); @@ -806,6 +831,58 @@ mod caps_tests { mapper.caps() } + /// v3.1.0 (`T-SPRITE-LIMIT`): every board that reports + /// `chr_reads_are_pure` really has pure CHR reads. The PPU's "disable + /// sprite limit" option makes extra pattern reads exactly where this is + /// `true`, so a wrong `true` would let a display option change emulation. + /// + /// For every mapper id the parser builds from a synthetic ROM (iNES ids + /// 0-255 and NES 2.0 ids 256-4095), read all of CHR through both entry + /// points and require `save_state` to be unchanged. The five boards that + /// report `false` were found by a scan of every `ppu_read` body for writes + /// to `self` (v3.1.0); this test is what keeps a sixth from keeping the + /// default. It cannot see state a board leaves out of `save_state`, which + /// would already be a save-state defect of its own. + #[test] + fn every_board_that_claims_pure_chr_reads_has_them() { + fn nes2_rom(id: u16) -> Vec { + let [lo, hi] = id.to_le_bytes(); + let mut rom = synth_rom(lo); + rom[7] = (rom[7] & 0xF0) | 0x08; // NES 2.0 identifier + rom[8] = hi & 0x0F; // mapper bits 8-11 + rom + } + let mut checked = 0usize; + let mut impure = Vec::new(); + for id in 0u16..4096 { + let rom = u8::try_from(id).map_or_else(|_| nes2_rom(id), synth_rom); + let Ok((_cart, mut mapper)) = parse(&rom) else { + continue; + }; + if !mapper.chr_reads_are_pure() { + impure.push(id); + continue; + } + let before = mapper.save_state(); + for addr in 0..0x2000u16 { + let _ = mapper.ppu_read(addr); + let _ = mapper.ppu_read_sprite(addr); + } + assert_eq!( + before, + mapper.save_state(), + "mapper {id} reports pure CHR reads, but reading CHR changed its state" + ); + checked += 1; + } + assert!(checked > 150, "only {checked} boards were checked"); + assert_eq!( + impure, + vec![9, 10, 35, 90, 96, 163, 209, 211], + "the impure set moved" + ); + } + /// v2.8.0 Phase 4 — the capability-flag contract for the key families. /// A flag may be `false` ONLY when the mapper does not override the /// corresponding default no-op; these spot checks pin the mechanical diff --git a/crates/rustynes-netplay/src/message.rs b/crates/rustynes-netplay/src/message.rs index d9a2576db..0357043b4 100644 --- a/crates/rustynes-netplay/src/message.rs +++ b/crates/rustynes-netplay/src/message.rs @@ -176,9 +176,12 @@ 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); SyncVerdict::Refuse(IdentityMismatch::Emulator { ours: self.epoch, - theirs: None, + theirs, }) } else { SyncVerdict::Ignore @@ -216,8 +219,18 @@ impl SessionIdentity { /// ([`SessionIdentity::check_sync`]). A v5 peer ignores our magic, as v4 /// ignored v5's, so on its side the session still times out. /// +/// `7` (v3.1.0): the `Sync` LAYOUT is protocol 6's (72 bytes), under the +/// magic `"RNE7"`. What changed is the configuration hash's input: the +/// [`rustynes_core::HardwareOptions`] encoding gained the CPU-multiplier +/// overclock and the sprite-limit option (`T-CPU-OVERCLOCK`, +/// `T-SPRITE-LIMIT`), so two peers with identical settings on v3.0.x and +/// v3.1.0 hash them differently. Without a new magic a v3.0.x peer would be +/// refused as "settings differ", a wrong reason; under `"RNE7"` its `"RNE6"` +/// is one of [`NetMessage::OLDER_SYNC_MAGICS`] and it is refused as another +/// emulator version, naming its epoch (protocol 6 carries it). +/// /// [`from_bytes`]: NetMessage::from_bytes -pub const PROTOCOL_VERSION: u32 = 6; +pub const PROTOCOL_VERSION: u32 = 7; /// Messages exchanged between two peers. /// @@ -323,13 +336,28 @@ 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_4536; // "RNE6" + pub const SYNC_MAGIC: u32 = 0x524E_4537; // "RNE7" /// `RustyNES`'s own earlier `Sync` magics: protocol 4 (`"RNES"`, v2.5.x to - /// v2.9.7) and protocol 5 (`"RNE5"`, v2.9.8 and v2.9.9). Recognised so a - /// peer on an older version is refused with a reason - /// ([`SessionIdentity::check_sync`]). - pub const OLDER_SYNC_MAGICS: [u32; 2] = [0x524E_4553, 0x524E_4535]; + /// v2.9.7), protocol 5 (`"RNE5"`, v2.9.8 and v2.9.9) and protocol 6 + /// (`"RNE6"`, v3.0.0 and v3.0.1). Recognised so a peer on an older version + /// 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] = [ + Self::PROTOCOL_4_SYNC_MAGIC, + Self::PROTOCOL_5_SYNC_MAGIC, + Self::PROTOCOL_6_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. + pub const PROTOCOL_4_SYNC_MAGIC: u32 = 0x524E_4553; + /// Protocol 5's `Sync` magic, `"RNE5"` (v2.9.8 and v2.9.9): a 68-byte + /// payload with both hashes and no epoch. + pub const PROTOCOL_5_SYNC_MAGIC: u32 = 0x524E_4535; + /// 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; // Tag bytes for the hand-rolled encoding. const TAG_INPUT: u8 = 0; @@ -473,9 +501,10 @@ impl NetMessage { Self::TAG_SYNC => { let magic = u32::from_le_bytes(rest.get(0..4)?.try_into().ok()?); match rest.len() { - // Protocol 6: magic + epoch + two hashes. Exactly that - // length: a longer payload is a later protocol's, not this - // one's with junk on the end. + // Protocols 6 and 7: 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. 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()?; @@ -493,7 +522,7 @@ impl NetMessage { // own length AND under its own magic, so the handshake can // refuse it with a reason (`check_sync`). Its hashes are // never compared; the epoch field is meaningless (0). - 68 if magic == Self::OLDER_SYNC_MAGICS[1] => Some(Self::Sync { + 68 if magic == Self::PROTOCOL_5_SYNC_MAGIC => Some(Self::Sync { magic, identity: SessionIdentity { epoch: 0, @@ -501,7 +530,7 @@ impl NetMessage { config_hash: rest.get(36..68)?.try_into().ok()?, }, }), - 36 if magic == Self::OLDER_SYNC_MAGICS[0] => Some(Self::Sync { + 36 if magic == Self::PROTOCOL_4_SYNC_MAGIC => Some(Self::Sync { magic, identity: SessionIdentity { epoch: 0, @@ -671,8 +700,8 @@ mod tests { fn an_older_rustynes_sync_is_refused_with_a_reason() { let ours = SessionIdentity::new([7u8; 32], [9u8; 32]); for (magic, payload) in [ - (NetMessage::OLDER_SYNC_MAGICS[1], 68usize), // protocol 5 - (NetMessage::OLDER_SYNC_MAGICS[0], 36usize), // protocol 4 + (NetMessage::PROTOCOL_5_SYNC_MAGIC, 68usize), + (NetMessage::PROTOCOL_4_SYNC_MAGIC, 36usize), ] { let mut bytes = vec![NetMessage::TAG_SYNC]; bytes.extend_from_slice(&magic.to_le_bytes()); @@ -699,6 +728,37 @@ mod tests { assert_eq!(ours.check_sync(0xDEAD_BEEF, &ours), SyncVerdict::Ignore); } + /// v3.1.0 (protocol 7) — a v3.0.x peer's `"RNE6"` `Sync` has OUR length + /// (72 bytes) but hashes its options with the v3.0.x encoding, so even + /// with identical settings its configuration hash differs from ours. It + /// must be refused as another emulator version, naming its epoch (the + /// layout carries one), not as a settings mismatch and not ignored. + #[test] + fn a_protocol_6_peer_is_refused_as_another_version_naming_its_epoch() { + let ours = SessionIdentity::new([7u8; 32], [9u8; 32]); + let theirs = SessionIdentity { + epoch: 2, + rom_hash: [7u8; 32], + config_hash: [1u8; 32], + }; + let bytes = NetMessage::Sync { + magic: NetMessage::PROTOCOL_6_SYNC_MAGIC, + identity: theirs, + } + .to_bytes(); + let Some(NetMessage::Sync { magic, identity }) = NetMessage::from_bytes(&bytes) else { + panic!("a protocol-6 Sync decodes"); + }; + assert_eq!( + ours.check_sync(magic, &identity), + SyncVerdict::Refuse(IdentityMismatch::Emulator { + ours: ours.epoch, + theirs: Some(2), + }) + ); + assert_ne!(NetMessage::SYNC_MAGIC, NetMessage::PROTOCOL_6_SYNC_MAGIC); + } + #[test] fn roster_roundtrips_v4_and_v6() { use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; diff --git a/crates/rustynes-ppu/src/bus.rs b/crates/rustynes-ppu/src/bus.rs index bd6ed93b2..8cfafb876 100644 --- a/crates/rustynes-ppu/src/bus.rs +++ b/crates/rustynes-ppu/src/bus.rs @@ -42,6 +42,16 @@ pub trait PpuBus { None } + /// v3.1.0 (`T-SPRITE-LIMIT`) — whether a CHR read through + /// [`Self::ppu_read`] / [`Self::ppu_read_sprite`] has no effect beyond + /// returning the byte. The "disable sprite limit" option makes extra, + /// display-only pattern reads, and only where this is `true`, so the + /// option can never change emulation. Default `true`; the core forwards the + /// mapper's answer (`Mapper::chr_reads_are_pure`). + fn chr_reads_are_pure(&self) -> bool { + true + } + /// Write a byte at `addr`. fn ppu_write(&mut self, addr: u16, value: u8); diff --git a/crates/rustynes-ppu/src/emphasis.rs b/crates/rustynes-ppu/src/emphasis.rs index c0511672e..7ba62a0f1 100644 --- a/crates/rustynes-ppu/src/emphasis.rs +++ b/crates/rustynes-ppu/src/emphasis.rs @@ -59,8 +59,11 @@ //! two cannot drift. The `MiSTer` core ships the same resulting colours, checked //! entry by entry by its `palette-gate`. //! -//! Not modelled, by choice: the page's differential phase distortion, colour -//! artifacts between pixels, and the PAL/Dendy swap of the red and green bits. +//! Not modelled, by choice: the page's differential phase distortion and +//! colour artifacts between pixels. The PAL/Dendy swap of the red and green +//! bits is modelled since v3.1.0 (`T-PAL-EMPHASIS`), where it belongs: in the +//! PPU's emphasis index (`Ppu::emit_pixel`), not in this table, which is +//! indexed by the physical tint. /// The page's terminated levels in volts: `[plain, attenuated][low, high][row]`. const LEVELS: [[[f64; 4]; 2]; 2] = [ diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 190dca3bb..2427f7965 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -35,6 +35,11 @@ pub const FRAMEBUFFER_LEN: usize = SCREEN_WIDTH * SCREEN_HEIGHT * 4; /// [`Ppu::index_framebuffer`] (one `u16` per pixel). pub const FRAMEBUFFER_PIXELS: usize = SCREEN_WIDTH * SCREEN_HEIGHT; +/// v3.1.0 (`T-SPRITE-LIMIT`) — the most sprites the "disable sprite limit" +/// option can add to one scanline: all 64 OAM entries minus the eight the +/// hardware draws. +pub const MAX_EXTRA_SPRITES: usize = 64 - 8; + /// v1.2.0 beta.2 (Workstream C3) — per-pixel HD-pack tile-source record. /// /// One entry per visible pixel (parallel to [`Ppu::index_framebuffer`]), @@ -1105,10 +1110,30 @@ pub struct Ppu { /// without altering the visible image. **Off by default (`0`)**; the /// `advance_dot` insertion path is entirely guarded by `extra_scanlines != 0`, /// so at the default this field changes nothing and the frame is - /// byte-identical to stock. Distinct from the CPU-multiplier overclock (a - /// v2.0 timebase item). A frontend config knob, NOT part of the save-state - /// (re-applied by the frontend on restore, like `region` / `active_palette`). + /// byte-identical to stock. Distinct from the CPU-multiplier overclock + /// (`Nes::set_cpu_overclock`, v3.1.0, in the bus). A frontend config knob, + /// NOT part of the save-state (re-applied by the frontend on restore, like + /// `region` / `active_palette`). pub(crate) extra_scanlines: u16, + /// v3.1.0 (`T-SPRITE-LIMIT`, FE-02): draw the sprites beyond the eighth on + /// a scanline. **Render-only**: sprite evaluation, secondary OAM, the + /// overflow flag, sprite-0 hit and every real sprite fetch (with its A12 + /// edges) are untouched. The extra sprites' patterns are read after the + /// eight real fetches, through [`PpuBus::chr_reads_are_pure`] boards only, + /// with no A12 notification, and they draw behind all eight hardware + /// sprites (a higher OAM index is a lower priority). Off by default; + /// configuration, carried across a power cycle by + /// [`Self::adopt_settings_from`] and in movies / netplay by the core's + /// `HardwareOptions`. + pub(crate) sprite_limit_disabled: bool, + /// v3.1.0 — the extra sprites fetched for the next scanline (snapshot v13): + /// how many, and for each the h-flip-applied pattern bytes, attributes and + /// X. Always 0 while `sprite_limit_disabled` is off. + pub(crate) spr_extra_count: u8, + pub(crate) spr_extra_lo: [u8; MAX_EXTRA_SPRITES], + pub(crate) spr_extra_hi: [u8; MAX_EXTRA_SPRITES], + pub(crate) spr_extra_attr: [u8; MAX_EXTRA_SPRITES], + pub(crate) spr_extra_x: [u8; MAX_EXTRA_SPRITES], /// v1.7.0 F3 — countdown of extra blank scanlines remaining for the CURRENT /// frame's vblank insertion. Loaded from [`Self::extra_scanlines`] when the /// PPU reaches the insertion point and decremented one extra line at a time. @@ -1635,6 +1660,12 @@ impl Ppu { dot_counter: 0, frame_ntsc_phase: 0, extra_scanlines: 0, + sprite_limit_disabled: false, + spr_extra_count: 0, + spr_extra_lo: [0; MAX_EXTRA_SPRITES], + spr_extra_hi: [0; MAX_EXTRA_SPRITES], + spr_extra_attr: [0; MAX_EXTRA_SPRITES], + spr_extra_x: [0; MAX_EXTRA_SPRITES], extra_lines_remaining: 0, // v2.2.3 performance pass: promoted to the default (was `false` // through v2.2.2). Byte-identical to the exact path by @@ -1742,6 +1773,30 @@ impl Ppu { self.rebuild_rgba_lut(); } + /// v3.1.0 (`T-SPRITE-LIMIT`) — draw the sprites beyond the eighth on a + /// scanline (`true`), or not (`false`, the default and the hardware). See + /// the field for what stays exact. Turning it off drops any extras already + /// fetched, so the next scanline draws exactly eight. + pub const fn set_sprite_limit_disabled(&mut self, disabled: bool) { + self.sprite_limit_disabled = disabled; + if !disabled { + self.spr_extra_count = 0; + } + } + + /// v3.1.0 — whether the sprites beyond the eighth are drawn. + #[must_use] + pub const fn sprite_limit_disabled(&self) -> bool { + self.sprite_limit_disabled + } + + /// v3.1.0 — how many sprites beyond the eighth are fetched for the next + /// scanline (`0` unless [`Self::sprite_limit_disabled`]). + #[must_use] + pub const fn extra_sprite_count(&self) -> u8 { + self.spr_extra_count + } + /// v1.7.0 "Forge" Workstream F3 — set the number of EXTRA blank vblank /// scanlines to insert per frame (the PPU extra-scanlines overclock). /// @@ -1749,7 +1804,7 @@ impl Ppu { /// that never calls this. A non-zero value lengthens vblank by that many /// idle scanlines each frame (more CPU run-time, no visible change), at the /// existing dot resolution. Off by default; a frontend config knob, not part - /// of the save-state. Distinct from the CPU-multiplier overclock (v2.0). + /// of the save-state. Distinct from the CPU-multiplier overclock (v3.1.0). /// /// Changing the count cancels any in-flight insertion for the current /// frame: the per-frame countdown (`extra_lines_remaining`) is @@ -1809,7 +1864,8 @@ impl Ppu { /// # What is carried, and what is not /// /// Carried: [`Self::custom_palette`] (the lookup table is rebuilt to - /// honour it), [`Self::extra_scanlines`], [`Self::fast_dotloop`] and + /// honour it), [`Self::extra_scanlines`], [`Self::sprite_limit_disabled`], + /// [`Self::fast_dotloop`] and /// [`Self::oam_decay_enabled`]. The decay switch goes through /// [`Self::set_oam_decay`], exactly as a host enabling it on a fresh /// console would, so the result is what a fresh boot with the setting @@ -1831,6 +1887,7 @@ impl Ppu { self.custom_palette = prev.custom_palette; self.rebuild_rgba_lut(); self.set_extra_scanlines(prev.extra_scanlines); + self.sprite_limit_disabled = prev.sprite_limit_disabled; self.fast_dotloop = prev.fast_dotloop; self.set_oam_decay(prev.oam_decay_enabled); } @@ -5101,6 +5158,32 @@ impl Ppu { break; } } + // v3.1.0 (`T-SPRITE-LIMIT`): the sprites beyond the eighth, only where + // 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)) + { + 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 { + continue; + } + let bit = 7 - off; + let lo = (self.spr_extra_lo[e] >> bit) & 1; + let hi = (self.spr_extra_hi[e] >> bit) & 1; + let val = (hi << 1) | lo; + if val != 0 { + spr_idx = val; + spr_pal = self.spr_extra_attr[e] & 0x03; + spr_priority_front = (self.spr_extra_attr[e] & 0x20) == 0; + break; + } + } + } // Combine BG + sprite per priority. // @@ -5162,7 +5245,17 @@ impl Ppu { // call for both the 2C02 composite default and the Vs./PC10 RGB // palettes) and store all four bytes with one bounds-checked slice // copy instead of four indexed stores. - let emph = usize::from((self.mask.bits() >> 5) & 0x07); + // v3.1.0 (`T-PAL-EMPHASIS`): the index is the PHYSICAL tint (bit 0 + // red, bit 1 green, bit 2 blue). PPUMASK bit 5 is red on the NTSC 2C02 + // 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 rgba = self.rgba_lut[lut_idx]; self.framebuffer[off..off + 4].copy_from_slice(&rgba); @@ -5762,6 +5855,23 @@ impl Ppu { } } 65..=256 => { + if self.dot == 65 { + // v3.1.0: OAMADDR AT TICK 65 sets where evaluation + // starts (nesdev "PPU registers" -> OAMADDR, "Values + // during rendering"), so re-seed `(n, m)` here. The dot-0 + // capture above stays as the reset value, but a `$2003` + // write (or a rendering-time `$2004` bump) during the + // dots 1-64 clear must still move the start. Until + // v3.1.0 only the dot-0 value counted, which was + // invisible while every test wrote `$2003` before dot 0: + // AccuracyCoin f5f41dc2 moved its "Misaligned OAM + // behavior" write to dots 28-29 of scanline 0 (one + // `JSR`/`RTS` pair later) and test 3 failed, evaluating + // from the stale address. The OAM-bus model above has + // always seeded at cycle 65. + self.sprite_eval_n = (self.oam_addr >> 2) & 0x3F; + self.sprite_eval_m = self.oam_addr & 0x03; + } if !self.sprite_eval_done { let next_line: i16 = if self.scanline == self.region.prerender_line() { -1 @@ -5881,7 +5991,23 @@ impl Ppu { // Finished this sprite. found was already // incremented when the y-byte landed. self.sprite_eval_copying = false; - self.sprite_eval_m = 0; + // v3.1.0: the fourth byte copied is the X position, and + // the PPU range-tests it exactly as it tests Y. Out of + // range: OAMADDR += 1 then &= $FC, re-aligning. IN range: + // only += 1, so a misaligned start STAYS misaligned + // (AccuracyCoin "Misaligned OAM behavior" tests 4-7, the + // "+4* behavior ... Only +1 with the X Position" rule, + // stated in the ROM's comments). `m` already holds the + // += 1 (the `m == 4` wrap above covers the aligned + // case, where both rules agree), so only the + // out-of-range case clears it. Until v3.1.0 this cleared + // `m` unconditionally; the ROM's pre-f5f41dc2 fail path + // returned into the test body without popping its return + // address, which recorded that failure as a pass. + let x_row = next_line - (latch as i16); + if !(x_row >= 0 && x_row < sprite_height) { + self.sprite_eval_m = 0; + } // Under feature: the m==4 wrap above already // advanced n once. Don't double-increment. // Under legacy: m never wrapped, so n advances @@ -5937,10 +6063,15 @@ impl Ppu { { self.sprite_eval_m += 1; if self.sprite_eval_m == 4 { - // Wrapped past end of sprite — already - // "copied" the whole sprite from its - // misaligned start. Advance n, reset m. - self.sprite_eval_copying = false; + // The Y byte was the LAST byte of slot `n` + // (evaluation started at m = 3). OAMADDR steps + // on into slot n + 1 and the copy continues: + // the PPU copies four bytes whatever the + // alignment. Until v3.1.0 this ended the copy + // here, putting one byte in secondary OAM + // instead of four (AccuracyCoin "Misaligned OAM + // behavior" test 7, offset 3; masked like test + // 6 by the ROM's pre-f5f41dc2 fail path). self.sprite_eval_m = 0; if self.sprite_eval_n == 63 { self.sprite_eval_done = true; @@ -6304,6 +6435,92 @@ impl Ppu { } } // Else: shift regs already cleared in tick_sprite_eval_per_dot. + + // v3.1.0 (`T-SPRITE-LIMIT`): after the eighth REAL fetch, the extra + // sprites for the same line. A no-op unless the option is on. + if slot == 7 { + self.fetch_extra_sprites(bus, next_line, sprite_height); + } + } + + /// v3.1.0 (`T-SPRITE-LIMIT`, FE-02): collect and fetch the sprites beyond + /// the eighth for the next scanline, for display only. + /// + /// Runs once per line, after the eighth real sprite fetch, and only when + /// the option is on, evaluation found eight (so the hardware dropped + /// some), the line is visible, and the board's CHR reads are pure + /// ([`PpuBus::chr_reads_are_pure`]: MMC2 / MMC4 latch on CHR reads, the + /// J.Y. ASIC clocks an IRQ on them, and two boards latch address bits, so + /// on those the option draws eight as stock). The fetch calls neither + /// `observe_a12_addr` nor anything else a mapper can see beyond the read, + /// so A12, mapper IRQs and every emulated byte stay exactly stock. + /// + /// Which sprites: an aligned walk of primary OAM from entry 0, skipping + /// the first eight in range (the ones the hardware draws when evaluation + /// starts at OAMADDR 0, as it does on every normally rendered line). A line + /// whose evaluation starts misaligned (a mid-frame `$2003` write, a test + /// construction) can draw a slightly different set; it is a display + /// enhancement, not hardware behaviour. + fn fetch_extra_sprites(&mut self, bus: &mut B, next_line: i16, height: i16) { + self.spr_extra_count = 0; + if !self.sprite_limit_disabled + || self.spr_count < 8 + || !(0..240).contains(&self.scanline) + || !bus.chr_reads_are_pure() + { + return; + } + let mut in_range = 0usize; + for n in 0..64usize { + let y = i16::from(self.oam[n * 4]); + let row = next_line.wrapping_sub(y); + if row < 0 || row >= height { + continue; + } + in_range += 1; + if in_range <= 8 { + continue; + } + let count = usize::from(self.spr_extra_count); + if count == MAX_EXTRA_SPRITES { + break; + } + let tile = self.oam[n * 4 + 1]; + let attr = self.oam[n * 4 + 2] & 0xE3; + let x = self.oam[n * 4 + 3]; + let flip_v = attr & 0x80 != 0; + #[allow(clippy::cast_sign_loss)] // `row` is in 0..height, checked above + let mut r = row as u16; + let (table, tile_idx) = if height == 16 { + if flip_v { + r = 15 - r; + } + let base = tile & 0xFE; + let idx = if r >= 8 { base.wrapping_add(1) } else { base }; + r &= 7; + (u16::from(tile & 0x01) << 12, idx) + } else { + if flip_v { + r = 7 - r; + } + ( + u16::from(self.ctrl.contains(PpuCtrl::SPRITE_PATTERN_HIGH)) << 12, + tile, + ) + }; + let addr = table | (u16::from(tile_idx) << 4) | r; + let mut lo = bus.ppu_read_sprite(addr); + let mut hi = bus.ppu_read_sprite(addr | 0x08); + if attr & 0x40 != 0 { + lo = reverse_bits(lo); + hi = reverse_bits(hi); + } + self.spr_extra_lo[count] = lo; + self.spr_extra_hi[count] = hi; + self.spr_extra_attr[count] = attr; + self.spr_extra_x[count] = x; + self.spr_extra_count += 1; + } } fn advance_dot(&mut self) { @@ -6595,6 +6812,81 @@ mod tests { ); } + /// v3.1.0 — the three misaligned-evaluation rules the `AccuracyCoin` + /// `f5f41dc2` re-sync exposed, each pinned on its own so a regression + /// names the rule it broke: + /// + /// 1. evaluation starts at OAMADDR **as of dot 65**, not dot 0 (nesdev + /// "PPU registers" -> OAMADDR), so a write during the clear counts; + /// 2. an in-range Y copies **four** bytes whatever the alignment, also from + /// `m = 3`, where the copy crosses into slot `n + 1`; + /// 3. the fourth byte (X) is range-tested: in range, OAMADDR only steps by + /// one and stays misaligned; out of range, it steps and ANDs with `$FC`. + /// + /// Each assertion failed against the pre-v3.1.0 FSM (dot-0 seed, a + /// one-byte copy from `m = 3`, an unconditional realign). + #[test] + fn misaligned_oam_eval_starts_at_dot_65_copies_four_bytes_and_tests_x() { + /// A PPU on scanline 10 whose dot-0 reset has already run with + /// OAMADDR 0, so only a later seed can pick up `oam_addr`. + fn ppu_after_dot0(oam_addr: u8, oam: &[(usize, u8)]) -> Ppu { + let mut ppu = Ppu::new(PpuRegion::Ntsc); + ppu.mask = PpuMask::SHOW_SPRITE; + ppu.scanline = 10; + ppu.oam.fill(0xFF); + for &(i, v) in oam { + ppu.oam[i] = v; + } + ppu.oam_addr = 0; + ppu.dot = 0; + ppu.tick_sprite_eval_per_dot(); + // The write lands during the clear, after the dot-0 reset. + ppu.oam_addr = oam_addr; + ppu + } + fn run_dots(ppu: &mut Ppu, from: u16, to: u16) { + for d in from..=to { + ppu.dot = d; + ppu.tick_sprite_eval_per_dot(); + } + } + + // (1) Seed at dot 65: OAMADDR 2 written after dot 0. Y at OAM[2] is in + // range for scanline 10 (Y = 8), and the walk must start there. + let mut ppu = ppu_after_dot0(0x02, &[(2, 8), (3, 0x11), (4, 0x22), (5, 0x33)]); + run_dots(&mut ppu, 65, 66); + assert_eq!( + ppu.secondary_oam[0], 8, + "evaluation must read its first Y from OAMADDR as of dot 65 (OAM[2])" + ); + + // (2) Four bytes from m = 3: OAM[3] is Y, OAM[4..=6] belong to slot 1. + let mut ppu = ppu_after_dot0(0x03, &[(3, 8), (4, 0xA1), (5, 0xA2), (6, 0xA3)]); + run_dots(&mut ppu, 65, 72); + assert_eq!( + ppu.secondary_oam[..4], + [8, 0xA1, 0xA2, 0xA3], + "a misaligned in-range sprite copies four bytes, across the slot edge" + ); + + // (3) X range test, from OAMADDR 1: Y = OAM[1], X = OAM[4]. + let x_case = |x: u8| { + let mut ppu = ppu_after_dot0(0x01, &[(1, 8), (2, 0x11), (3, 0x22), (4, x)]); + run_dots(&mut ppu, 65, 72); + u16::from(ppu.sprite_eval_n) * 4 + u16::from(ppu.sprite_eval_m) + }; + assert_eq!( + x_case(8), + 0x05, + "X in range: OAMADDR += 1 only, so the walk stays misaligned at $05" + ); + assert_eq!( + x_case(0xF0), + 0x04, + "X out of range: OAMADDR += 1 then & $FC, realigning to $04" + ); + } + /// v2.6.18 — the depth-2 rendering-gate pipeline must SHIFT, not freeze. /// /// Drives the named pair directly rather than `tick`, so it needs no bus @@ -6756,6 +7048,57 @@ mod tests { (ppu, TestBus::new()) } + /// v3.1.0 (`T-PAL-EMPHASIS`, ACC-01): on the PAL (2C07) and Dendy PPUs + /// PPUMASK bits 5 and 6 swap meaning. `NESdev` "Colour emphasis": "Bit 5 + /// emphasizes red on the NTSC PPU, and green on the PAL & Dendy PPUs. Bit 6 + /// emphasizes green on the NTSC PPU, and red on the PAL & Dendy PPUs. Bit 7 + /// emphasizes blue on the NTSC, PAL, & Dendy PPUs." The emphasis index the + /// renderer and the composite filters receive is the PHYSICAL tint (bit 0 + /// red, bit 1 green, bit 2 blue), so on PAL / Dendy it is the mask's bits + /// with 5 and 6 exchanged. + #[test] + fn pal_and_dendy_swap_the_red_and_green_emphasis_bits() { + let cases = [ + (PpuMask::EMPHASIZE_RED, 0b001u16, 0b010u16), + (PpuMask::EMPHASIZE_GREEN, 0b010, 0b001), + (PpuMask::EMPHASIZE_BLUE, 0b100, 0b100), + ( + PpuMask::EMPHASIZE_RED | PpuMask::EMPHASIZE_BLUE, + 0b101, + 0b110, + ), + ]; + for region in [PpuRegion::Ntsc, PpuRegion::Pal, PpuRegion::Dendy] { + for (mask, ntsc, swapped) in cases { + let mut p = Ppu::new(region); + p.post_reset_mask_remaining = 0; + p.mask = mask; // rendering off: the pixel is the backdrop + p.palette_ram[palette_index(0x3F00)] = 0x21; + p.scanline = 10; + p.dot = 20; + p.emit_pixel(); + let got = p.index_framebuffer[10 * 256 + 19]; + let want_emph = if region == PpuRegion::Ntsc { + ntsc + } else { + swapped + }; + assert_eq!( + got, + (want_emph << 6) | 0x21, + "{region:?}, mask {:#04x}: emphasis index", + mask.bits() + ); + let off = (10usize * 256 + 19) * 4; + assert_eq!( + &p.framebuffer[off..off + 4], + &p.rgba_lut[usize::from(got)], + "{region:?}: the RGBA pixel follows the same index" + ); + } + } + } + // F1.1 (Fathom accuracy remediation) — palette backdrop-override. // When rendering is disabled and the VRAM address `v` points into palette // space ($3F00-$3FFF), the palette's shared address input is driven by `v`, diff --git a/crates/rustynes-ppu/src/snapshot.rs b/crates/rustynes-ppu/src/snapshot.rs index 3a592309f..5d845a6e1 100644 --- a/crates/rustynes-ppu/src/snapshot.rs +++ b/crates/rustynes-ppu/src/snapshot.rs @@ -163,7 +163,15 @@ use crate::registers::{PpuCtrl, PpuMask, PpuStatus}; /// `spr_rearm_deferred` it is live only across the frame boundary, where /// run-ahead and save states snapshot; dropping it would let a restored /// frame raise A12 at a dot 0 the skip removed, which an MMC3 counts. -pub const PPU_SNAPSHOT_VERSION: u8 = 12; +/// - v13 (v3.1.0, `T-SPRITE-LIMIT`): appends the extra sprites the "disable +/// sprite limit" option fetched for the next scanline: a count (1 byte, +/// `0..=MAX_EXTRA_SPRITES`) and four 56-byte arrays (pattern low, pattern +/// high, attributes, X). Render-only state, but it decides the next +/// scanline's picture, and a snapshot can fall between the fetch (dots +/// 257-320) and that line, so it is carried rather than dropped, the rule +/// `snapshot_schema_audit.rs` exists for. All zero while the option is off. +/// No upconvert: v3.1.0 states are refused by BUS section 3 regardless. +pub const PPU_SNAPSHOT_VERSION: u8 = 13; /// v2.3.3 — high bit of the version byte, marking a **slim** snapshot: every /// field except the 245,760-byte framebuffer. @@ -628,6 +636,13 @@ impl Ppu { // the skip, consumed at that dot), live across the frame boundary. w.u8(u8::from(self.dot0_replaced)); + // v13 tail — the sprite-limit option's extra sprites for the next line. + w.u8(self.spr_extra_count); + w.bytes(&self.spr_extra_lo); + w.bytes(&self.spr_extra_hi); + w.bytes(&self.spr_extra_attr); + w.bytes(&self.spr_extra_x); + w.buf } @@ -870,6 +885,17 @@ impl Ppu { // v12: the odd-frame skip replaced scanline 0's dot 0. self.dot0_replaced = r.u8()? != 0; + // v13: the sprite-limit option's extra sprites for the next line. + self.spr_extra_count = bounded( + "spr_extra_count", + r.u8()?, + u8::try_from(crate::ppu::MAX_EXTRA_SPRITES).unwrap_or(u8::MAX), + )?; + r.bytes_into(&mut self.spr_extra_lo)?; + r.bytes_into(&mut self.spr_extra_hi)?; + r.bytes_into(&mut self.spr_extra_attr)?; + r.bytes_into(&mut self.spr_extra_x)?; + // Derived-cache fixup: the scanline-classification cache // is a pure function of `scanline` + `region`, so it is recomputed rather // than carried. Resetting the key to the `Ppu::new` sentinel forces the diff --git a/crates/rustynes-test-harness/src/accuracy_coin.rs b/crates/rustynes-test-harness/src/accuracy_coin.rs index 82be73b77..f89a2bb6e 100644 --- a/crates/rustynes-test-harness/src/accuracy_coin.rs +++ b/crates/rustynes-test-harness/src/accuracy_coin.rs @@ -262,7 +262,7 @@ pub fn run_battery_with_budget(max_frames: u64) -> BatteryResult { /// for backward-compatibility (and as a cross-check), but new /// diagnostic tooling should prefer the RAM-direct path because it /// (a) is independent of the result-grid display layout, (b) decodes -/// per-test names + error codes, and (c) covers all 144 tests rather +/// per-test names + error codes, and (c) covers all 146 tests rather /// than the subset visible on the summary screen. /// /// # Panics diff --git a/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs b/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs index af4ef50d6..093abe9e1 100644 --- a/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs +++ b/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs @@ -3,17 +3,18 @@ //! Vendored from upstream `100thCoin/AccuracyCoin` (MIT licensed). The //! list mirrors `AccuracyCoin.asm`'s 22 `Suite_*` pages: each page //! contributes a header string + a sequence of `table "name", $FF, -//! result_addr, run_addr` macro entries. Total: 149 entries across 22 -//! suites. +//! result_addr, run_addr` macro entries (and, since upstream `f5f41dc2`, the +//! byte-saving `tblf1` / `tblf2` variants of it). Total: 151 entries across +//! 22 suites. //! //! ## Source of truth //! //! The authoritative list lives next to the ROM at -//! `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` as a 149-line +//! `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` as a 151-line //! `(suitenameresult_addr)` file extracted from upstream -//! `AccuracyCoin.asm` by the recipe documented inline in -//! `tests/roms/AccuracyCoin/README.md` (walk each `Suite_*`/`table` block, -//! resolving `result_symbol` to its `result_X = $ADDR` definition). +//! `AccuracyCoin.asm` by `scripts/accuracycoin-build/extract_catalog.py` +//! (walk each `Suite_*` block's row macros, resolving `result_symbol` to its +//! `result_X = $ADDR` definition). //! This module embeds that file via `include_str!` and parses it //! lazily so the in-code catalog cannot drift from the on-disk source. //! @@ -75,7 +76,8 @@ pub struct CatalogEntry { /// pointer's high byte against `3` and branching past the test when it matches. /// Five catalog rows (the whole `Power On State` suite: `PPU Reset Flag`, /// `CPU RAM`, `CPU Registers`, `PPU RAM`, `Palette RAM`) point here, so the -/// catalog's 149 rows carry **144 scored results and one shared scratch byte**. +/// catalog's 151 rows carry **146 scored results and one shared scratch byte** +/// (149 and 144 before the v3.1.0 re-sync to upstream `f5f41dc2`). /// /// ## Why this constant had to exist /// @@ -88,7 +90,8 @@ pub struct CatalogEntry { /// read as 149 of 149 and the other as 144 of 144 — from one ROM, with nothing /// wrong in between. /// -/// Excluding the sentinel makes the count **144 in both windows**. It changes no +/// Excluding the sentinel made the count **144 in both windows** (146 since +/// v3.1.0). It changes no /// verdict about any real test, and it removes five rows from every "entry for /// entry" claim that were never entries. pub const RESULT_DRAW_TEST: u16 = 0x03FF; @@ -105,7 +108,7 @@ impl CatalogEntry { } } -/// Number of catalog rows that carry a real result: 149 rows, 144 scored. +/// Number of catalog rows that carry a real result: 151 rows, 146 scored. /// /// # Panics /// @@ -203,7 +206,7 @@ impl TestStatus { } } -/// Return the catalog of all 149 AccuracyCoin tests, in `TableTable` +/// Return the catalog of all 151 AccuracyCoin tests, in `TableTable` /// order. /// /// The result is built once (on first call) and cached for the @@ -247,7 +250,7 @@ pub fn catalog() -> &'static [CatalogEntry] { /// Look up a catalog entry by zero-based `TableTable` index. /// -/// Returns `None` if `index >= 149`. +/// Returns `None` if `index >= 151`. #[must_use] pub fn entry(index: usize) -> Option<&'static CatalogEntry> { catalog().get(index) @@ -269,7 +272,7 @@ pub fn suite_size(suite: &str) -> usize { catalog().iter().filter(|e| e.suite == suite).count() } -/// Decode the 149-entry result vector by reading each catalog entry's +/// Decode the 151-entry result vector by reading each catalog entry's /// [`CatalogEntry::result_addr`] from `ram` (which must be the NES's /// 2 KiB CPU RAM borrowed via `Nes::bus().ram_bytes()`). /// @@ -291,13 +294,13 @@ pub fn decode_results(ram: &[u8]) -> Option> { /// Aggregated counts derived from a decoded results vector. /// -/// **Every field counts SCORED rows only**, so `total` is 144 rather than the -/// catalog's 149 and `not_run` excludes the five `Power On State` rows that +/// **Every field counts SCORED rows only**, so `total` is 146 rather than the +/// catalog's 151 and `not_run` excludes the five `Power On State` rows that /// share [`RESULT_DRAW_TEST`]. [`failing_tests`] uses the same set, so the /// counts here and the named list there cannot disagree. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct RamResultSummary { - /// Total number of catalog entries (always 149 if the catalog is + /// Total number of catalog entries (always 151 if the catalog is /// fully loaded). pub total: u32, /// Tests that wrote `$01` (clean pass). @@ -345,8 +348,8 @@ impl RamResultSummary { #[must_use] /// Summarise a decoded vector, counting **scored rows only**. /// -/// The five rows sharing [`RESULT_DRAW_TEST`] are excluded, so `total` is 144 -/// rather than the catalog's 149. Including them made every count a function of +/// The five rows sharing [`RESULT_DRAW_TEST`] are excluded, so `total` is 146 +/// rather than the catalog's 151. Including them made every count a function of /// when the run was sampled — see the constant's rustdoc for the measurement. pub fn summarise(statuses: &[TestStatus]) -> RamResultSummary { let mut s = RamResultSummary { @@ -439,8 +442,8 @@ mod tests { use super::*; #[test] - fn catalog_has_exactly_149_entries() { - assert_eq!(catalog().len(), 149, "AccuracyCoin catalog size drifted"); + fn catalog_has_exactly_151_entries() { + assert_eq!(catalog().len(), 151, "AccuracyCoin catalog size drifted"); } #[test] @@ -465,6 +468,11 @@ mod tests { assert!(names.contains(&"$03 SLO indirect,X")); assert!(names.contains(&"Internal Data Bus")); assert!(names.contains(&"$2007 Stress Test")); + // v3.1.0 (upstream f5f41dc2): the two new `CPU Behavior 2` tests, and a + // row rebuilt from a `tblf1` token, spelled as the ROM prints it. + assert!(names.contains(&"DMA Landing on Write")); + assert!(names.contains(&"DMC Reload Timing")); + assert!(names.contains(&"$0B ANC immediate")); } #[test] @@ -562,7 +570,7 @@ mod tests { ram[e0.result_addr as usize] = 0x01; ram[e1.result_addr as usize] = (3 << 2) | 0x02; // fail code 3 let statuses = decode_results(&ram).expect("decode"); - assert_eq!(statuses.len(), 149); + assert_eq!(statuses.len(), 151); assert_eq!(statuses[0], TestStatus::Pass); assert_eq!(statuses[1], TestStatus::Fail(3)); // The five Power On State tests share $03FF (left at 0x00). diff --git a/crates/rustynes-test-harness/src/bin/accuracycoin_status.rs b/crates/rustynes-test-harness/src/bin/accuracycoin_status.rs index 1a47a9f84..d75a0d6ae 100644 --- a/crates/rustynes-test-harness/src/bin/accuracycoin_status.rs +++ b/crates/rustynes-test-harness/src/bin/accuracycoin_status.rs @@ -44,8 +44,8 @@ use rustynes_test_harness::accuracy_coin_catalog::{ /// `$6000-$61FF`, and the `MiSTer` core persists the whole `$6000-$7FFF` PRG-RAM /// window, so a hardware `.sav` is 8 KiB whose first 512 bytes are the vector. /// -/// Every one of the catalog's 149 result addresses falls inside `$0300-$04FF` -/// (`$03FF`-`$0495`, checked against `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv`), +/// Every one of the catalog's 151 result addresses falls inside `$0300-$04FF` +/// (`$03FF`-`$0497`, checked against `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv`), /// which is what makes the lift below lossless rather than a subset. const MIRROR_LEN: usize = 0x0200; const MIRROR_VECTOR_BASE: usize = 0x0300; diff --git a/crates/rustynes-test-harness/src/lib.rs b/crates/rustynes-test-harness/src/lib.rs index 5bcdeefcc..c81989bd2 100644 --- a/crates/rustynes-test-harness/src/lib.rs +++ b/crates/rustynes-test-harness/src/lib.rs @@ -29,7 +29,8 @@ pub mod coverage; pub use blargg::{BlarggBus, BlarggResult, run_blargg_until_complete}; pub use nes_runner::{ CodeTestResult, CodeVerdict, NesTestResult, ScreenTestResult, ScreenVerdict, run_nes_blargg, - run_nes_blargg_pal, run_nes_blargg_reset, run_nes_result_code, run_nes_screen, + run_nes_blargg_pal, run_nes_blargg_reset, run_nes_blargg_with, run_nes_result_code, + run_nes_screen, }; pub use nestest::{LogLine, NestestBus, NestestRunner, format_log_line, parse_log_line}; diff --git a/crates/rustynes-test-harness/src/nes_runner.rs b/crates/rustynes-test-harness/src/nes_runner.rs index 876070413..8e23e412f 100644 --- a/crates/rustynes-test-harness/src/nes_runner.rs +++ b/crates/rustynes-test-harness/src/nes_runner.rs @@ -54,7 +54,23 @@ fn read_message(nes: &mut Nes) -> String { /// /// Returns the underlying [`RomError`] if the bytes don't parse. pub fn run_nes_blargg(rom_bytes: &[u8], max_frames: u64) -> Result { - run_nes_blargg_inner(rom_bytes, max_frames, false) + run_nes_blargg_inner(rom_bytes, max_frames, false, &|_| {}) +} + +/// [`run_nes_blargg`] with `configure` applied before the first frame. +/// +/// v3.1.0: for running a ROM under an option, such as the sprite-limit option +/// against the sprite-overflow suite. +/// +/// # Errors +/// +/// Returns the underlying [`RomError`] if the bytes don't parse. +pub fn run_nes_blargg_with( + rom_bytes: &[u8], + max_frames: u64, + configure: &dyn Fn(&mut Nes), +) -> Result { + run_nes_blargg_inner(rom_bytes, max_frames, false, configure) } /// Run a blargg-style ROM but **force the PAL region** before booting. @@ -75,7 +91,7 @@ pub fn run_nes_blargg(rom_bytes: &[u8], max_frames: u64) -> Result Result { - run_nes_blargg_inner(rom_bytes, max_frames, true) + run_nes_blargg_inner(rom_bytes, max_frames, true, &|_| {}) } /// Rewrite a throwaway copy of `rom_bytes` to force **PAL region** selection. @@ -281,12 +297,14 @@ fn run_nes_blargg_inner( rom_bytes: &[u8], max_frames: u64, force_pal: bool, + configure: &dyn Fn(&mut Nes), ) -> Result { // `owned` holds the PAL-stamped copy (if any) for the borrow's lifetime; // when absent (NTSC, or a sub-16-byte buffer) we run the original bytes. let owned = force_pal.then(|| pal_forced_copy(rom_bytes)).flatten(); let bytes: &[u8] = owned.as_deref().unwrap_or(rom_bytes); let mut nes = Nes::from_rom(bytes)?; + configure(&mut nes); let magic = [b'D', b'E', b'B', 0]; let mut started = false; let mut frames = 0u64; diff --git a/crates/rustynes-test-harness/tests/accuracycoin.rs b/crates/rustynes-test-harness/tests/accuracycoin.rs index 98310abab..959ee9892 100644 --- a/crates/rustynes-test-harness/tests/accuracycoin.rs +++ b/crates/rustynes-test-harness/tests/accuracycoin.rs @@ -77,17 +77,27 @@ const MIN_PASS_RATE: f64 = 0.60; /// the authority and this prose must agree with it; the drift mechanism was /// that the list was emptied and the sentence above it was not. /// +/// **v3.1.0 re-sync to upstream `f5f41dc2`: 146 of 146.** The catalog grew +/// 144 -> 146 scored rows (`DMA Landing on Write`, `DMC Reload Timing`). +/// The new ROM failed two tests, both fixed in the same change: `DMA Landing +/// on Write` (a load DMA refused by a write took three cycles, not four) and +/// `Misaligned OAM behavior`, which the old ROM had recorded as a PASS while +/// tests 3, 6 and 7 failed: its fail path returned into the test body +/// without popping the return address (upstream `adacbc23`), so every +/// failure after test 2 fell through to the pass at the end. +/// /// Asserted alongside the known-failing set so that a battery which /// *under-executes* (early bail, skipped suite, decoder that stops /// assigning cells) fails as loudly as one that regresses — an empty /// failing list is not by itself evidence of success. Re-bless this /// together with `docs/STATUS.md` if an upstream ROM update changes the /// catalog. -const EXPECTED_PASS_COUNT: u32 = 144; +const EXPECTED_PASS_COUNT: u32 = 146; /// The `AccuracyCoin` tests this build is known to fail, pinned BY NAME. /// -/// **Empty as of v2.6.18** — the battery reads 144/144. The list stays, and +/// **Empty as of v2.6.18** — the battery reads 144/144 (146/146 since the +/// v3.1.0 re-sync). The list stays, and /// stays documented, because it is the mechanism that makes a future gap /// explicit rather than absorbed into a lowered count. /// diff --git a/crates/rustynes-test-harness/tests/accuracycoin_mirror.rs b/crates/rustynes-test-harness/tests/accuracycoin_mirror.rs index 05f33cd07..3a546e82b 100644 --- a/crates/rustynes-test-harness/tests/accuracycoin_mirror.rs +++ b/crates/rustynes-test-harness/tests/accuracycoin_mirror.rs @@ -27,7 +27,7 @@ //! and four things are checked: //! //! 1. the raw `$0300-$04FF` window is byte-identical between the two ROMs; -//! 2. the decoded 149-entry status vector is identical entry for entry; +//! 2. the decoded 151-entry status vector is identical entry for entry; //! 3. the mirror at `$6000-$61FF` reproduces the patched run's own live window; //! 4. the mirror is not vacuous. //! @@ -197,12 +197,12 @@ fn end_to_end_through_the_comparator(oracle_ram: &[u8], hardware_shaped_sav: &[u and the mirrored save.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); // And the coverage is full, read off the comparator's own sentence rather - // than inferred from its exit code. 144 scored rows, all executed on both + // than inferred from its exit code. 146 scored rows, all executed on both // sides -- the catalog's other five share upstream's omit-sentinel // `result_DrawTest` and are excluded by the catalog, not waived here. assert!( - stdout.contains("144 of 144 scored entries executed on both sides"), - "the comparator's coverage sentence is not the expected 144 of 144.\nstdout:\n{stdout}" + stdout.contains("146 of 146 scored entries executed on both sides"), + "the comparator's coverage sentence is not the expected 146 of 146.\nstdout:\n{stdout}" ); assert!( stdout.contains("(0 on neither"), diff --git a/crates/rustynes-test-harness/tests/cpu_overclock.rs b/crates/rustynes-test-harness/tests/cpu_overclock.rs new file mode 100644 index 000000000..bbba2c0e7 --- /dev/null +++ b/crates/rustynes-test-harness/tests/cpu_overclock.rs @@ -0,0 +1,258 @@ +//! `T-CPU-OVERCLOCK` (v3.1.0, FE-01, D22): the CPU-multiplier overclock. +//! +//! `Nes::set_cpu_overclock(k)` runs the CPU `k` times faster against an +//! unchanged PPU, by dividing the region's master-clock CPU divider, while the +//! APU, the DMC and every mapper's CPU-cycle hook stay at the stock rate, so +//! a game gets `k` times the CPU time per frame with its pitch, its tempo and +//! its cycle-timed mapper IRQs unchanged. Off by default (`1`), and `1` is +//! byte-identical to stock (pinned separately by the epoch fingerprint gate, +//! whose seven ROMs all run at `1`). +//! +//! What these tests pin: +//! * the default and the clamp (`1..=4`); +//! * the multiplier reaches the core: CPU cycles per frame scale by `k`; +//! * the APU stays at the stock rate: the same number of audio samples per +//! frame at every `k`; +//! * it rides in [`HardwareOptions`] (capture, encode, decode, and a named +//! difference), which is what refuses a movie or a netplay peer recorded +//! with another value; +//! * a snapshot taken mid-run under the overclock restores to the same +//! continuation, which is what run-ahead and rewind rely on. + +#![cfg(feature = "test-roms")] + +mod common; + +use std::fs; + +use common::{fnv1a64, rom_path}; +use rustynes_core::save_state::{BinReader, BinWriter}; +use rustynes_core::{HardwareOptions, Nes}; + +/// Committed ROMs only. This file first named `nes-test-roms/ny2011` and +/// `nes-test-roms/apu_mixer/square.nes`, which live in the gitignored local +/// aggregate: every test here passed on the development host and failed in +/// CI's clean checkout (v3.1.0's release PR, #594). `blargg/apu_mixer/` is the +/// tracked copy (`square.nes` byte-identical). +const ROM: &str = "blargg/apu_mixer/triangle.nes"; +/// Audible from about frame 120. A silent window (`ny2011` is silent for its +/// first 300 frames) made the snapshot test below blind to the APU's phase: a +/// restore that dropped the stock-rate position passed it. +const AUDIBLE_ROM: &str = "blargg/apu_mixer/square.nes"; + +fn boot_rom(rom: &str) -> Nes { + let path = rom_path(rom); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + Nes::from_rom(&bytes).unwrap_or_else(|e| panic!("parse {rom}: {e}")) +} + +fn boot() -> Nes { + boot_rom(ROM) +} + +/// CPU cycles and audio samples over `frames` frames at overclock `k`. +fn measure(k: u8, frames: u32) -> (u64, usize) { + let mut nes = boot(); + nes.set_cpu_overclock(k); + // Settle past power-on first, so the frame boundary is a steady one. + for _ in 0..30 { + nes.run_frame(); + let _ = nes.drain_audio(); + } + let start = nes.cycle(); + let mut samples = 0; + for _ in 0..frames { + nes.run_frame(); + samples += nes.drain_audio().len(); + } + (nes.cycle() - start, samples) +} + +#[test] +fn the_default_is_stock_and_the_multiplier_is_clamped() { + let mut nes = boot(); + assert_eq!(nes.cpu_overclock(), 1, "off by default"); + nes.set_cpu_overclock(0); + assert_eq!(nes.cpu_overclock(), 1, "0 means stock, not a stopped CPU"); + nes.set_cpu_overclock(9); + assert_eq!( + nes.cpu_overclock(), + rustynes_core::MAX_CPU_OVERCLOCK, + "clamped to the maximum" + ); +} + +#[test] +fn cpu_cycles_per_frame_scale_with_the_multiplier() { + let frames = 60; + let (stock, _) = measure(1, frames); + for k in 2..=rustynes_core::MAX_CPU_OVERCLOCK { + let (cycles, _) = measure(k, frames); + #[allow(clippy::cast_precision_loss)] + let ratio = cycles as f64 / stock as f64; + let want = f64::from(k); + assert!( + (ratio - want).abs() < 0.01, + "x{k}: {cycles} CPU cycles over {frames} frames against {stock} stock \ + is a ratio of {ratio:.4}, not {want}" + ); + } +} + +#[test] +fn the_apu_stays_at_the_stock_rate() { + let frames = 60; + let (_, stock) = measure(1, frames); + for k in 2..=rustynes_core::MAX_CPU_OVERCLOCK { + let (_, samples) = measure(k, frames); + // One sample of slack per frame for where the frame boundary falls + // inside a resampler step. + assert!( + samples.abs_diff(stock) <= frames as usize, + "x{k}: {samples} audio samples over {frames} frames against {stock} \ + stock; an APU clocked with the CPU would produce about {k} times as many" + ); + } +} + +#[test] +fn the_multiplier_rides_in_hardware_options() { + let mut nes = boot(); + nes.set_cpu_overclock(3); + let opts = HardwareOptions::capture(&nes); + assert_eq!(opts.cpu_overclock, 3); + + let mut w = BinWriter::with_capacity(64); + opts.write_to(&mut w); + let bytes = w.into_vec(); + let back = HardwareOptions::read_from(&mut BinReader::new(&bytes)).expect("decodes"); + assert_eq!(back, opts, "encode/decode round trip"); + + assert_eq!(HardwareOptions::default().cpu_overclock, 1); + // Against the same machine captured at stock, so only the multiplier + // differs (the default's `vs_ppu_type` is `None`, a capture's is not). + let stock = HardwareOptions::capture(&boot()); + assert_eq!(stock.cpu_overclock, 1); + assert_eq!( + stock.differences(&opts), + vec!["CPU overclock"], + "a movie or peer on another value is refused with the option named" + ); + + let mut other = boot(); + opts.apply_live(&mut other).expect("apply"); + assert_eq!(other.cpu_overclock(), 3, "apply reaches the core"); +} + +#[test] +fn an_out_of_range_multiplier_in_a_file_is_refused() { + let mut opts = HardwareOptions::default(); + opts.cpu_overclock = 2; + let mut w = BinWriter::with_capacity(64); + opts.write_to(&mut w); + let mut bytes = w.into_vec(); + let at = bytes + .iter() + .position(|&b| b == 2) + .expect("the multiplier byte is in the encoding"); + bytes[at] = rustynes_core::MAX_CPU_OVERCLOCK + 1; + assert!( + HardwareOptions::read_from(&mut BinReader::new(&bytes)).is_err(), + "a multiplier the core would clamp cannot replay as written" + ); +} + +#[test] +fn a_snapshot_under_the_overclock_restores_to_the_same_continuation() { + let mut nes = boot_rom(AUDIBLE_ROM); + nes.set_cpu_overclock(3); + for _ in 0..150 { + nes.run_frame(); + } + // Audio already produced is host-side output, not machine state: drain + // it so both runs below start from an empty buffer. + let _ = nes.drain_audio(); + let snap = nes.snapshot(); + let run = |nes: &mut Nes| { + let mut h = Vec::new(); + for _ in 0..20 { + nes.run_frame(); + h.extend_from_slice(&fnv1a64(nes.framebuffer()).to_le_bytes()); + for s in nes.drain_audio() { + h.extend_from_slice(&s.to_le_bytes()); + } + } + (fnv1a64(&h), nes.cycle()) + }; + // The window must be audible, or the APU's phase is not observed. + { + let mut probe = boot_rom(AUDIBLE_ROM); + probe.set_cpu_overclock(3); + for _ in 0..150 { + probe.run_frame(); + } + let _ = probe.drain_audio(); + probe.run_frame(); + let peak = probe.drain_audio().iter().fold(0f32, |m, s| m.max(s.abs())); + assert!(peak > 0.01, "the snapshot window is silent (peak {peak})"); + } + let first = run(&mut nes); + nes.restore(&snap).expect("own snapshot restores"); + let second = run(&mut nes); + assert_eq!( + first, second, + "a restore under the overclock must replay the same frames, samples and cycles" + ); +} + +/// A minimal NES 2.0 NROM cart whose CPU spins in a `JMP` loop, with the +/// CPU/PPU timing byte (header byte 12) set: 0 NTSC, 1 PAL, 3 Dendy. +fn spin_rom(timing: u8) -> Vec { + let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 1; // 16 KiB PRG + rom[5] = 1; // 8 KiB CHR + rom[7] = 0x08; // NES 2.0 + rom[12] = timing; + rom[16..19].copy_from_slice(&[0x4C, 0x00, 0xC0]); // $C000: JMP $C000 + let reset = 16 + (0xFFFC - 0xC000); + rom[reset..reset + 2].copy_from_slice(&[0x00, 0xC0]); + rom +} + +/// CPU cycles over `frames` frames of `spin_rom(timing)` at overclock `k`. +fn spin_cycles(timing: u8, k: u8, frames: u32) -> u64 { + let mut nes = Nes::from_rom(&spin_rom(timing)).expect("synthetic cart parses"); + nes.set_cpu_overclock(k); + for _ in 0..10 { + nes.run_frame(); + } + let start = nes.cycle(); + for _ in 0..frames { + nes.run_frame(); + } + nes.cycle() - start +} + +/// v3.1.0 review (#594): the multiplier is EXACT on every region. The CPU +/// divider was `div / k` rounded down, which only NTSC's 12 survives: PAL's +/// 16 at `x3` gave 5 (3.2x) and Dendy's 15 at `x4` gave 3 (5x), so a movie +/// recording "x4" did not get four times the CPU. Every `k` CPU cycles now +/// take exactly one stock cycle (alternating lengths), which this pins on all +/// three regions to within the frame-boundary jitter of a few cycles. +#[test] +fn the_multiplier_is_exact_on_pal_and_dendy_too() { + let frames = 60; + for (timing, region) in [(0u8, "NTSC"), (1, "PAL"), (3, "Dendy")] { + let stock = spin_cycles(timing, 1, frames); + for k in 2..=rustynes_core::MAX_CPU_OVERCLOCK { + let cycles = spin_cycles(timing, k, frames); + #[allow(clippy::cast_precision_loss)] + let ratio = cycles as f64 / stock as f64; + assert!( + (ratio - f64::from(k)).abs() < 0.001, + "{region} x{k}: {cycles} CPU cycles against {stock} stock is x{ratio:.4}, not x{k}" + ); + } + } +} diff --git a/crates/rustynes-test-harness/tests/epoch_fingerprint.rs b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs new file mode 100644 index 000000000..fc49d66c7 --- /dev/null +++ b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs @@ -0,0 +1,316 @@ +//! `T-EPOCH-FINGERPRINT` (v3.1.0, CI-02): the emulation epoch is enforced by +//! a test, not by memory. +//! +//! [`rustynes_core::EMULATION_EPOCH`] tells a movie or a netplay peer which +//! emulator *behaviour* it was made under (ADR 0045). Its rule is "raise it in +//! the same change as anything that alters a frame, a sample or a bus cycle +//! against the last release", and until v3.1.0 that rule was enforced by hand +//! only: a change that moved output and forgot the epoch shipped movies that +//! replay wrongly and netplay sessions that desync, with nothing saying why. +//! +//! This test runs a fixed panel of committed test ROMs and fingerprints what +//! each one produces: every frame's framebuffer, every audio sample, the end +//! RAM and the total CPU cycle count. The fingerprints are committed in +//! `golden/epoch_fingerprint.tsv` together with the epoch they belong to. +//! +//! The table also records `last_release_epoch`, the epoch the last release +//! shipped (set by hand at each release cut, like the release anchors): +//! +//! * Fingerprints match the table: pass (once the table names the current +//! epoch). +//! * A fingerprint moved while `EMULATION_EPOCH` still equals the last +//! release's: **fail**. Raise the epoch (adding its row to the table in +//! `hardware_options.rs`), then re-bless. +//! * A fingerprint moved after the epoch was already raised this release: fail +//! until re-blessed. A second behaviour change in one release needs a +//! re-bless, not a second rise, because the rule is relative to the last +//! release (ADR 0045). +//! +//! Re-bless with `RUSTYNES_BLESS_EPOCH_FINGERPRINT=1`. **Blessing refuses to +//! write moved fingerprints while the epoch equals the last release's** -- +//! that refusal is the gate; a bless that accepted them would make the rule +//! optional again. +//! +//! The panel is chosen for reach, one subsystem per ROM, so a behaviour change +//! anywhere in the core is likely to move at least one fingerprint. It is not +//! a proof that every change is caught: a change that alters no output on +//! these seven ROMs passes, which is also when the epoch rule does not demand +//! a rise for them. The commercial snapshot suites (`external_*`) remain the +//! wider net, and the release checklist still names the rule. + +#![cfg(feature = "test-roms")] + +mod common; + +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; + +use common::{fnv1a64, rom_path}; +use rustynes_core::{Buttons, EMULATION_EPOCH, Nes}; + +/// One panel entry: a ROM, how long to run it, and whether to press START +/// after the boot frames (`AccuracyCoin`'s "run every test" menu entry). +struct Probe { + rom: &'static str, + frames: u32, + press_start: bool, + /// What it is there to catch, for the failure message. + reach: &'static str, +} + +const PANEL: &[Probe] = &[ + Probe { + rom: "accuracycoin/AccuracyCoin.nes", + frames: 3200, + press_start: true, + reach: "the whole AccuracyCoin battery: CPU, PPU, APU, DMA, bus", + }, + Probe { + rom: "blargg/apu_mixer/triangle.nes", + frames: 600, + press_start: false, + reach: "a continuous tone through the mixer (audio)", + }, + Probe { + rom: "blargg/sprite_overflow_tests/3.Timing.nes", + frames: 300, + press_start: false, + reach: "sprite evaluation and overflow timing", + }, + Probe { + rom: "nes-test-roms/dmc_tests/latency.nes", + frames: 240, + press_start: false, + reach: "DMC fetch latency (audio)", + }, + Probe { + rom: "blargg/mmc3_test_2/4-scanline_timing.nes", + frames: 120, + press_start: false, + reach: "MMC3 IRQ timing", + }, + Probe { + rom: "nes-test-roms/sprdma_and_dmc_dma/sprdma_and_dmc_dma.nes", + frames: 120, + press_start: false, + reach: "OAM and DMC DMA overlap", + }, + Probe { + rom: "assorted/flowing_palette.nes", + frames: 120, + press_start: false, + reach: "palette and emphasis output", + }, +]; + +// Every ROM above is COMMITTED. The panel first named four from the +// gitignored local aggregate `tests/roms/nes-test-roms/` (ny2011, spritecans, +// and the aggregate's copies of `4-scanline_timing` and `flowing_palette`), +// so it passed on the development host and could not run in CI's clean +// checkout (v3.1.0's release PR, #594). The two copies are byte-identical to +// the tracked files they now name; `ny2011` and `spritecans` have no tracked +// copy and were replaced by the nearest committed stimulus. + +/// Frames to let a ROM boot before `AccuracyCoin`'s START press. +const BOOT_FRAMES: u32 = 300; +/// Frames START is held. +const START_FRAMES: u32 = 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Fingerprint { + rom: String, + frames: u32, + framebuffer: u64, + audio: u64, + ram: u64, + cycles: u64, +} + +impl Fingerprint { + fn row(&self) -> String { + format!( + "{}\t{}\t{:016x}\t{:016x}\t{:016x}\t{}", + self.rom, self.frames, self.framebuffer, self.audio, self.ram, self.cycles + ) + } + + fn parse(line: &str) -> Option { + let c: Vec<&str> = line.split('\t').collect(); + if c.len() != 6 { + return None; + } + Some(Self { + rom: c[0].to_string(), + frames: c[1].parse().ok()?, + framebuffer: u64::from_str_radix(c[2], 16).ok()?, + audio: u64::from_str_radix(c[3], 16).ok()?, + ram: u64::from_str_radix(c[4], 16).ok()?, + cycles: c[5].parse().ok()?, + }) + } +} + +/// Run one probe and fingerprint it. Every frame's framebuffer is folded in, +/// not only the last, so a transient difference cannot hide behind a +/// converged final frame. +fn measure(p: &Probe) -> Fingerprint { + let path = rom_path(p.rom); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let mut nes = Nes::from_rom(&bytes).unwrap_or_else(|e| panic!("parse {}: {e}", p.rom)); + let mut frame_hashes: Vec = Vec::with_capacity(p.frames as usize * 8); + let mut audio: Vec = Vec::new(); + let mut step = |nes: &mut Nes| { + nes.run_frame(); + frame_hashes.extend_from_slice(&fnv1a64(nes.framebuffer()).to_le_bytes()); + for s in nes.drain_audio() { + audio.extend_from_slice(&s.to_le_bytes()); + } + }; + let run = if p.press_start { + for _ in 0..BOOT_FRAMES { + step(&mut nes); + } + nes.set_buttons(0, Buttons::START); + for _ in 0..START_FRAMES { + step(&mut nes); + } + nes.set_buttons(0, Buttons::empty()); + BOOT_FRAMES + START_FRAMES + } else { + 0 + }; + for _ in run..p.frames { + step(&mut nes); + } + Fingerprint { + rom: p.rom.to_string(), + frames: p.frames, + framebuffer: fnv1a64(&frame_hashes), + audio: fnv1a64(&audio), + ram: fnv1a64(nes.bus().ram_bytes()), + cycles: nes.cycle(), + } +} + +fn table_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("golden/epoch_fingerprint.tsv") +} + +/// The committed table: the epoch its rows describe, the epoch of the last +/// release, and the rows. +struct Table { + epoch: u32, + last_release_epoch: u32, + rows: Vec, +} + +fn read_table() -> Option { + let text = fs::read_to_string(table_path()).ok()?; + let (mut epoch, mut last) = (None, None); + let mut rows = Vec::new(); + for line in text.lines() { + if let Some(e) = line.strip_prefix("# epoch\t") { + epoch = e.trim().parse().ok(); + } else if let Some(e) = line.strip_prefix("# last_release_epoch\t") { + last = e.trim().parse().ok(); + } else if !line.starts_with('#') && !line.trim().is_empty() { + rows.push( + Fingerprint::parse(line) + .unwrap_or_else(|| panic!("malformed epoch_fingerprint.tsv row: {line:?}")), + ); + } + } + Some(Table { + epoch: epoch.expect("epoch_fingerprint.tsv has no `# epoch` line"), + last_release_epoch: last.expect("epoch_fingerprint.tsv has no `# last_release_epoch` line"), + rows, + }) +} + +fn render_table(last_release_epoch: u32, rows: &[Fingerprint]) -> String { + let mut out = String::from( + "# T-EPOCH-FINGERPRINT: what the panel in tests/epoch_fingerprint.rs produces\n\ + # under the epoch below. Generated; re-bless with\n\ + # RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. `last_release_epoch` is the epoch\n\ + # the last release shipped, set by hand at each release cut: output may\n\ + # move only while EMULATION_EPOCH is above it.\n", + ); + let _ = writeln!(out, "# epoch\t{EMULATION_EPOCH}"); + let _ = writeln!(out, "# last_release_epoch\t{last_release_epoch}"); + out.push_str("# rom\tframes\tframebuffer_fnv\taudio_fnv\tram_fnv\tcpu_cycles\n"); + for r in rows { + out.push_str(&r.row()); + out.push('\n'); + } + out +} + +#[test] +fn output_moves_only_with_the_emulation_epoch() { + let now: Vec = PANEL.iter().map(measure).collect(); + let bless = std::env::var_os("RUSTYNES_BLESS_EPOCH_FINGERPRINT").is_some(); + let table = read_table().expect( + "golden/epoch_fingerprint.tsv is missing or unreadable; it needs a \ + `# last_release_epoch` line written by hand before the first bless", + ); + assert!( + table.last_release_epoch <= EMULATION_EPOCH, + "last_release_epoch {} is above EMULATION_EPOCH {EMULATION_EPOCH}", + table.last_release_epoch + ); + + let moved: Vec = now + .iter() + .zip(PANEL) + .filter(|(f, _)| !table.rows.contains(f)) + .map(|(f, p)| format!(" {} ({}): now {}", f.rom, p.reach, f.row())) + .collect(); + // The rule (ADR 0045): output may differ from the LAST RELEASE only under + // a raised epoch. Within one release, a second change after the rise + // needs a re-bless, not a second rise. + let raised = EMULATION_EPOCH > table.last_release_epoch; + + assert!( + moved.is_empty() || raised, + "emulated output changed but EMULATION_EPOCH is still {EMULATION_EPOCH}, \ + the epoch the last release shipped.\n\ + Raise it in crates/rustynes-core/src/hardware_options.rs (with a row \ + in its table saying what moved), then re-bless this table with \ + RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. Moved:\n{}", + moved.join("\n") + ); + + if bless { + fs::write(table_path(), render_table(table.last_release_epoch, &now)) + .expect("write epoch_fingerprint.tsv"); + eprintln!( + "blessed epoch_fingerprint.tsv at epoch {EMULATION_EPOCH} ({} rows moved)", + moved.len() + ); + return; + } + + assert!( + moved.is_empty(), + "emulated output changed under epoch {EMULATION_EPOCH}, which is already \ + raised above the last release's {}; re-bless with \ + RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. Moved:\n{}", + table.last_release_epoch, + moved.join("\n") + ); + assert_eq!( + table.epoch, EMULATION_EPOCH, + "EMULATION_EPOCH is {EMULATION_EPOCH} but the fingerprint table records {}; \ + re-bless with RUSTYNES_BLESS_EPOCH_FINGERPRINT=1 so the table names the \ + epoch its hashes belong to", + table.epoch + ); + assert_eq!( + table.rows.len(), + PANEL.len(), + "the table has {} rows for a panel of {}; re-bless after changing the panel", + table.rows.len(), + PANEL.len() + ); +} diff --git a/crates/rustynes-test-harness/tests/mmc3.rs b/crates/rustynes-test-harness/tests/mmc3.rs index 464af2742..e2e0e2982 100644 --- a/crates/rustynes-test-harness/tests/mmc3.rs +++ b/crates/rustynes-test-harness/tests/mmc3.rs @@ -129,8 +129,70 @@ fn mmc3_test_2_5_mmc3() { assert_eq!(s, 0, "mmc3_test_2 5-MMC3 failed: {m}"); } +/// v3.1.0 (`T-MMC3-NEC-OVERRIDE`, ACC-13): sub-ROM 6 tests the alternate +/// IRQ behaviour (MMC3A / non-Sharp MMC3B), which the default (Sharp) fails by +/// design. Under the override it passes; and sub-ROM 5 (Sharp-only) then +/// fails, which proves the override changed the behaviour rather than +/// leaving both on the default. #[test] -#[ignore = "by-design fail: sub-ROM 6 is NEC rev B; project defaults to Sharp rev A (sub-ROM 5)"] +fn mmc3_test_2_6_passes_under_the_alternate_revision_override() { + use rustynes_core::rustynes_mappers::Mmc3Revision; + let run_with = |rom: &str| { + let path = rom_path(rom); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + rustynes_test_harness::run_nes_blargg_with(&bytes, 600, &|nes| { + assert!( + nes.set_mmc3_revision_override(Some(Mmc3Revision::Nec)), + "a mapper-4 board applies the override" + ); + }) + .expect("runs") + }; + let alt = run_with("blargg/mmc3_test_2/6-MMC3_alt.nes"); + assert_eq!( + alt.status, 0, + "6-MMC3_alt under the override: {}", + alt.message + ); + let sharp = run_with("blargg/mmc3_test_2/5-MMC3.nes"); + assert_ne!( + sharp.status, 0, + "5-MMC3 (Sharp only) passed under the alternate override, so the override did nothing" + ); +} + +/// The override is configuration: a power cycle rebuilds the board from its +/// header and the override is re-applied; clearing it returns the header's. +#[test] +fn the_mmc3_override_survives_a_power_cycle_and_clears() { + use rustynes_core::rustynes_mappers::Mmc3Revision; + let path = rom_path("blargg/mmc3_test_2/5-MMC3.nes"); + let bytes = std::fs::read(&path).expect("read"); + let mut nes = rustynes_core::Nes::from_rom(&bytes).expect("parse"); + assert!( + nes.mapper_info().name.contains("Sharp"), + "{}", + nes.mapper_info().name + ); + nes.set_mmc3_revision_override(Some(Mmc3Revision::Nec)); + assert!(nes.mapper_info().name.contains("Nec")); + nes.power_cycle(); + assert!( + nes.mapper_info().name.contains("Nec"), + "the rebuilt board kept the override: {}", + nes.mapper_info().name + ); + nes.set_mmc3_revision_override(None); + assert!( + nes.mapper_info().name.contains("Sharp"), + "back to the header's" + ); +} + +#[test] +#[ignore = "by-design fail at the default: sub-ROM 6 is the alternate MMC3 IRQ revision; \ + the default is Sharp (sub-ROM 5). Runs under the override in \ + mmc3_test_2_6_passes_under_the_alternate_revision_override"] fn mmc3_test_2_6_mmc3_alt_strict() { let (s, m, _) = run("blargg/mmc3_test_2/6-MMC3_alt.nes", 600); assert_eq!(s, 0, "mmc3_test_2 6-MMC3_alt: {m}"); diff --git a/crates/rustynes-test-harness/tests/roster_boards.rs b/crates/rustynes-test-harness/tests/roster_boards.rs index c65f39b6a..2b7a2b5d3 100644 --- a/crates/rustynes-test-harness/tests/roster_boards.rs +++ b/crates/rustynes-test-harness/tests/roster_boards.rs @@ -610,6 +610,13 @@ fn gtrom_flash_is_a_battery_save() { /// disables IRQ", `NES_2_0_submappers.md`); submapper 0 is the Sharp one, which /// fires every scanline at a latch of 0. Until v2.9.6 the two were swapped with /// submapper 1. +/// +/// v3.1.0: "disables" is the submapper table's shorthand. `MMC3.md` is exact: +/// the NEC part "generates only a single IRQ when `$C000` is `$00`", and +/// "writing to `$C001` with `$C000` still at `$00` will result in another +/// single IRQ" (blargg's `6-MMC3_alt`: "IRQ should be set when reloading due +/// to clear"). The arm sequence writes `$C001` once, so NEC gives exactly one +/// IRQ and then none; before v3.1.0 this test asserted zero. #[test] fn mmc3_submapper_4_is_nec_and_0_is_sharp() { let arm = [ @@ -632,7 +639,11 @@ fn mmc3_submapper_4_is_nec_and_0_is_sharp() { .run(0) .1 }; - assert_eq!(run(4), 0, "NEC: a latch of 0 stops IRQs"); + assert_eq!( + run(4), + 1, + "NEC: a latch of 0 gives the one IRQ the $C001 write produces, then stops" + ); assert!(run(0) > 0, "Sharp: a latch of 0 fires every clock"); } diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index f5f2d114f..92c741260 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -145,6 +145,12 @@ const CHIPS: &[Chip] = &[ "config: the overclock amount; the in-flight countdown \ `extra_lines_remaining` IS serialized (v4 tail)", ), + ( + "sprite_limit_disabled", + "config: the v3.1.0 sprite-limit option, re-applied by the host, carried in \ + `HardwareOptions` and across a power cycle; the extra sprites it fetched \ + for the next line (`spr_extra_*`) ARE serialized (v13 tail)", + ), ( "fast_dotloop", "config: runtime performance knob (v2.1.8 A1); selects a code path, holds no state", @@ -480,6 +486,29 @@ const CHIPS: &[Chip] = &[ "ppu_div_cached", "derived: the region's PPU master-clock divider, cached at construction", ), + ( + "cpu_div_effective", + "derived: `cpu_div_cached / cpu_overclock`, recomputed when the overclock is set", + ), + ( + "stock_step", + "transient: set in `cpu_clock` and read by `cpu_clock_apu_dmc` within ONE CPU \ + cycle; a snapshot falls between cycles, and restore sets it `true`. The \ + overclock's persistent position (`overclock_phase`, `apu_cycle`) IS \ + serialized (BUS version 3)", + ), + ( + "cpu_overclock", + "config: the v3.1.0 CPU-multiplier overclock, re-applied by the host and \ + carried in `HardwareOptions`, like the extra-scanline overclock", + ), + ( + "mmc3_revision_override", + "config: the v3.1.0 MMC3 IRQ-revision setting, re-applied by the host and \ + carried in `HardwareOptions`; the bus keeps it only to re-apply it to the \ + mapper a power cycle rebuilds. A restore loads into the live mapper, whose \ + revision the override already set", + ), // --- Opt-in hardware knobs, re-applied by the host on load. ( "power_on_ram", diff --git a/crates/rustynes-test-harness/tests/sprite_limit.rs b/crates/rustynes-test-harness/tests/sprite_limit.rs new file mode 100644 index 000000000..82e91919d --- /dev/null +++ b/crates/rustynes-test-harness/tests/sprite_limit.rs @@ -0,0 +1,211 @@ +//! `T-SPRITE-LIMIT` (v3.1.0, FE-02, D22): "disable sprite limit". +//! +//! `Nes::set_sprite_limit_disabled(true)` draws the sprites beyond the eighth +//! on a scanline. It is render-only, and these tests pin that both ways: +//! +//! * **the game sees nothing.** With the option on, every CPU cycle, every +//! work-RAM byte and every audio sample matches the option off, frame by +//! frame, and blargg's five sprite-overflow ROMs (which read the overflow +//! flag that evaluation sets) still pass; +//! * **the picture does change.** On a ROM built here that keeps sixteen +//! opaque sprites on one line, the framebuffers differ: without that the +//! first test would pass for an option that does nothing. +//! +//! No committed ROM could serve as that stimulus, measured: `spritecans` +//! never puts more than seven sprites on a line, and `NEStress` crowds 62 +//! onto lines 1-8 but with a transparent tile, so both drew identically with +//! the option on (and the extra sprites WERE fetched on `NEStress`, 54 of +//! them, which is how the blind spot was told apart from a broken feature). +//! +//! Plus the carriage in `HardwareOptions` and a mid-frame snapshot round trip +//! (the extra sprites for the next line are PPU snapshot v13 state). + +#![cfg(feature = "test-roms")] + +mod common; + +use std::fs; + +use common::{fnv1a64, rom_path}; +use rustynes_core::{HardwareOptions, Nes}; + +/// A 16 KiB NROM program that keeps sixteen opaque 8x8 sprites on scanline +/// 101 (`Y = 100`, `X = 0, 16, .., 240`, tile 1, palette `$3F11 = $30`), +/// re-sent by OAM DMA every frame with sprites shown and the background off. +/// The hardware draws the left eight; the option adds the right eight. +fn crowded_rom() -> Vec { + #[rustfmt::skip] + let code: [u8; 99] = [ + 0x78, 0xD8, 0xA2, 0xFF, 0x9A, // SEI CLD LDX #$FF TXS + 0x2C, 0x02, 0x20, 0x10, 0xFB, // vblank 1 + 0x2C, 0x02, 0x20, 0x10, 0xFB, // vblank 2 + 0xA9, 0xFF, 0xA2, 0x00, // LDA #$FF LDX #0 + 0x9D, 0x00, 0x02, 0xE8, 0xD0, 0xFA, // fill $0200-$02FF with $FF + 0xA2, 0x00, 0xA0, 0x00, // LDX #0 LDY #0 + 0xA9, 0x64, 0x9D, 0x00, 0x02, // Y = 100 + 0xA9, 0x01, 0x9D, 0x01, 0x02, // tile 1 + 0xA9, 0x00, 0x9D, 0x02, 0x02, // attributes 0 + 0x98, 0x9D, 0x03, 0x02, // X = Y register + 0x18, 0x69, 0x10, 0xA8, // Y register += 16 + 0x8A, 0x18, 0x69, 0x04, 0xAA, // X register += 4 + 0xE0, 0x40, 0xD0, 0xE0, // 16 sprites + 0xA9, 0x3F, 0x8D, 0x06, 0x20, // $2006 = $3F + 0xA9, 0x11, 0x8D, 0x06, 0x20, // $2006 = $11 + 0xA9, 0x30, 0x8D, 0x07, 0x20, // $3F11 = $30 + 0x2C, 0x02, 0x20, 0x10, 0xFB, // loop: wait for vblank + 0xA9, 0x00, 0x8D, 0x03, 0x20, // OAMADDR = 0 + 0xA9, 0x02, 0x8D, 0x14, 0x40, // OAM DMA from $0200 + 0xA9, 0x14, 0x8D, 0x01, 0x20, // show sprites (and the left column) + 0x4C, 0x4C, 0xC0, // JMP loop ($C04C) + ]; + let mut rom = vec![b'N', b'E', b'S', 0x1A, 1, 1, 0, 0]; + rom.resize(16, 0); + let mut prg = vec![0xEAu8; 16 * 1024]; + prg[..code.len()].copy_from_slice(&code); + // NMI / RESET / IRQ vectors all point at $C000. + prg[0x3FFA..].copy_from_slice(&[0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0]); + rom.extend_from_slice(&prg); + let mut chr = vec![0u8; 8 * 1024]; + // Tile 1: low plane all set, high plane clear = colour 1, opaque. + chr[0x10..0x18].fill(0xFF); + rom.extend_from_slice(&chr); + rom +} + +fn boot(rom: &str) -> Nes { + if rom == CROWDED { + return Nes::from_rom(&crowded_rom()).expect("the built ROM parses"); + } + let path = rom_path(rom); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + Nes::from_rom(&bytes).unwrap_or_else(|e| panic!("parse {rom}: {e}")) +} + +/// Marker for [`crowded_rom`] in [`boot`]. +const CROWDED: &str = ""; + +/// Per frame: (framebuffer hash, RAM hash, audio hash, CPU cycle). +fn trace(disabled: bool, frames: u32) -> Vec<(u64, u64, u64, u64)> { + let mut nes = boot(CROWDED); + nes.set_sprite_limit_disabled(disabled); + (0..frames) + .map(|_| { + nes.run_frame(); + let mut audio = Vec::new(); + for s in nes.drain_audio() { + audio.extend_from_slice(&s.to_le_bytes()); + } + ( + fnv1a64(nes.framebuffer()), + fnv1a64(nes.bus().ram_bytes()), + fnv1a64(&audio), + nes.cycle(), + ) + }) + .collect() +} + +#[test] +fn off_by_default() { + assert!(!boot(CROWDED).sprite_limit_disabled()); +} + +#[test] +fn the_option_changes_the_picture_and_nothing_the_game_can_see() { + let frames = 300; + let stock = trace(false, frames); + let all = trace(true, frames); + let mut differing_frames = 0; + for (f, (a, b)) in stock.iter().zip(&all).enumerate() { + assert_eq!( + (a.1, a.2, a.3), + (b.1, b.2, b.3), + "frame {f}: RAM, audio or CPU cycle moved with the sprite-limit option on; \ + it must be render-only" + ); + if a.0 != b.0 { + differing_frames += 1; + } + } + assert!( + differing_frames > frames / 2, + "only {differing_frames} of {frames} frames drew differently; \ + the option is not reaching the picture" + ); +} + +#[test] +fn the_sprite_overflow_suite_passes_with_the_option_on() { + for rom in [ + "1.Basics", + "2.Details", + "3.Timing", + "4.Obscure", + "5.Emulator", + ] { + let path = rom_path(&format!("blargg/sprite_overflow_tests/{rom}.nes")); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let r = rustynes_test_harness::run_nes_blargg_with(&bytes, 600, &|nes| { + nes.set_sprite_limit_disabled(true); + }) + .expect("runs"); + assert_eq!(r.status, 0, "{rom} with the option on: {}", r.message); + } +} + +#[test] +fn the_option_rides_in_hardware_options() { + let mut nes = boot(CROWDED); + let stock = HardwareOptions::capture(&nes); + nes.set_sprite_limit_disabled(true); + let opts = HardwareOptions::capture(&nes); + assert!(opts.sprite_limit_disabled); + let back = HardwareOptions::read_from(&mut rustynes_core::BinReader::new(&opts.to_bytes())) + .expect("decodes"); + assert_eq!(back, opts); + assert_eq!(stock.differences(&opts), vec!["sprite limit"]); + let mut other = boot(CROWDED); + opts.apply_live(&mut other).expect("apply"); + assert!(other.sprite_limit_disabled()); +} + +#[test] +fn a_mid_frame_snapshot_keeps_the_next_lines_extra_sprites() { + let mut nes = boot(CROWDED); + nes.set_sprite_limit_disabled(true); + for _ in 0..120 { + nes.run_frame(); + } + // Into the visible frame, between a line's sprite fetch and the next + // line's pixels. + while nes.bus().ppu().scanline() < 100 { + nes.step_instruction(); + } + // Snapshot where line 101's extra sprites are already fetched (the + // fetch runs at dots 257-320 of line 100), or the test is blind. + while nes.bus().ppu().extra_sprite_count() == 0 { + nes.step_instruction(); + } + assert_eq!(nes.bus().ppu().extra_sprite_count(), 8); + let _ = nes.drain_audio(); + let snap = nes.snapshot(); + let run = |nes: &mut Nes| { + let mut h = Vec::new(); + for _ in 0..3 { + nes.run_frame(); + h.extend_from_slice(&fnv1a64(nes.framebuffer()).to_le_bytes()); + } + fnv1a64(&h) + }; + let first = run(&mut nes); + nes.restore(&snap).expect("own snapshot restores"); + assert!( + nes.sprite_limit_disabled(), + "configuration survives a restore" + ); + let second = run(&mut nes); + assert_eq!( + first, second, + "a restore mid-frame must redraw the same frames" + ); +} diff --git a/crates/rustynes-test-harness/tests/vs_dualsystem_rewind.rs b/crates/rustynes-test-harness/tests/vs_dualsystem_rewind.rs new file mode 100644 index 000000000..75731c805 --- /dev/null +++ b/crates/rustynes-test-harness/tests/vs_dualsystem_rewind.rs @@ -0,0 +1,234 @@ +//! `T-PS-dual-runahead` (v3.1.0, FE-09, D27): rewind in Vs. `DualSystem` mode. +//! +//! ADR 0032's 2026-10-07 amendment lifts rewind and run-ahead into the +//! two-console cabinet, built on the `RVSD` container that already snapshots +//! both consoles and the latch wiring them. Its gate, pinned here for rewind +//! (run-ahead is pinned in `rustynes-frontend`, where it lives): +//! +//! * a rewind across a cabinet frame restores BOTH framebuffers +//! byte-identically; +//! * the cabinet then replays the same frames it produced the first time. +//! +//! The stimulus is a synthetic `DualSystem` cart built for the purpose. The +//! protocol cart in `vs_dualsystem_synth.rs` leaves the PPU off, so both of +//! its screens are one unchanging colour and a framebuffer comparison against +//! it would pass whatever a restore did to the pictures. This cart enables +//! background rendering and, in each console's NMI, writes a per-frame counter +//! into the backdrop entry `$3F00`. Its CHR-RAM is blank, so every pixel shows +//! the backdrop: each screen changes colour every frame, and the two screens +//! differ (the main cycles `$10-$17`, the sub `$20-$27`), so a restore that +//! swapped, dropped or staled either framebuffer is visible. The eight colours +//! avoid the palette's blacks (`$xD-$xF`): a full 64-entry counter walks +//! through three of them in a row, and consecutive frames are then identical. + +use rustynes_core::{Emu, VsDualSystem}; + +/// FNV-1a 64, as the other harness tests hash framebuffers. +fn fnv(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, &b| { + (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3) + }) +} + +/// One console's program: wait for the PPU to warm up, enable background +/// rendering and the NMI, then spin. The NMI increments `$00` and writes +/// `base | ($00 & 7)` into `$3F00`. +#[rustfmt::skip] +fn program(base: u8) -> Vec { + vec![ + /* 8000 */ 0x78, // SEI + /* 8001 */ 0xD8, // CLD + /* 8002 */ 0xA2, 0xFF, // LDX #$FF + /* 8004 */ 0x9A, // TXS + /* 8005 */ 0x2C, 0x02, 0x20, // BIT $2002 (first vblank) + /* 8008 */ 0x10, 0xFB, // BPL $8005 + /* 800A */ 0x2C, 0x02, 0x20, // BIT $2002 (second vblank) + /* 800D */ 0x10, 0xFB, // BPL $800A + /* 800F */ 0xA9, 0x0A, // LDA #$0A (background on) + /* 8011 */ 0x8D, 0x01, 0x20, // STA $2001 + /* 8014 */ 0xA9, 0x80, // LDA #$80 (NMI on) + /* 8016 */ 0x8D, 0x00, 0x20, // STA $2000 + /* 8019 */ 0x4C, 0x19, 0x80, // JMP $8019 + // NMI at $801C. + /* 801C */ 0xE6, 0x00, // INC $00 + /* 801E */ 0xA5, 0x00, // LDA $00 + /* 8020 */ 0x29, 0x07, // AND #$07 + /* 8022 */ 0x09, base, // ORA #base + /* 8024 */ 0xEA, // NOP (keeps the layout below) + /* 8025 */ 0xA2, 0x3F, // LDX #$3F + /* 8027 */ 0x8E, 0x06, 0x20, // STX $2006 + /* 802A */ 0xA2, 0x00, // LDX #$00 + /* 802C */ 0x8E, 0x06, 0x20, // STX $2006 + /* 802F */ 0x8D, 0x07, 0x20, // STA $2007 ($3F00 = the backdrop) + /* 8032 */ 0x8E, 0x06, 0x20, // STX $2006 (v back to $0000) + /* 8035 */ 0x8E, 0x06, 0x20, // STX $2006 + /* 8038 */ 0x40, // RTI (also the IRQ vector) + ] +} + +/// A NES 2.0 `DualSystem` cart (mapper 99, Vs. hardware type 5), 64 KiB PRG: +/// the main program in the first half, the sub program in the second, each +/// with its own vectors. CHR-RAM, left blank. +fn build_flashing_cabinet() -> Vec { + let mut rom = vec![0u8; 16 + 0x10000]; + rom[0..4].copy_from_slice(b"NES\x1a"); + rom[4] = 0x04; // 4 x 16 KiB PRG + rom[6] = 0x30; // mapper 99, low nibble + rom[7] = 0x69; // mapper 99 high nibble | NES 2.0 | Vs. System + rom[11] = 0x07; // 8 KiB CHR-RAM + rom[13] = 0x50; // Vs. hardware type 5 (DualSystem), PPU type 0 + let prg = &mut rom[16..]; + for (half, base) in [(0usize, 0x10u8), (0x8000, 0x20)] { + let code = program(base); + prg[half..half + code.len()].copy_from_slice(&code); + // NMI = $801C, RESET = $8000, IRQ = $8038 (an RTI). + prg[half + 0x7FFA..half + 0x8000].copy_from_slice(&[0x1C, 0x80, 0x00, 0x80, 0x38, 0x80]); + } + rom +} + +fn cabinet() -> VsDualSystem { + match Emu::from_rom(&build_flashing_cabinet()).expect("the synthetic cart parses") { + Emu::Dual(d) => *d, + Emu::Single(_) => panic!("Vs. hardware type 5 must build a cabinet"), + } +} + +/// Both screens' hashes, main first. +fn screens(dual: &VsDualSystem) -> (u64, u64) { + (fnv(dual.main_framebuffer()), fnv(dual.sub_framebuffer())) +} + +#[test] +fn the_stimulus_changes_both_screens_every_frame_and_they_differ() { + let mut dual = cabinet(); + for _ in 0..10 { + dual.run_frame(); + } + let mut seen = Vec::new(); + for _ in 0..4 { + dual.run_frame(); + let s = screens(&dual); + assert_ne!(s.0, s.1, "the two screens show different colours"); + seen.push(s); + } + for pair in seen.windows(2) { + assert_ne!(pair[0].0, pair[1].0, "the main screen changes every frame"); + assert_ne!(pair[0].1, pair[1].1, "the sub screen changes every frame"); + } +} + +#[test] +fn rewind_is_off_by_default_and_a_step_back_without_it_changes_nothing() { + let mut dual = cabinet(); + assert!(!dual.rewind_enabled()); + for _ in 0..5 { + dual.run_frame(); + } + let before = dual.snapshot(); + assert!(!dual.rewind_step_back(), "nothing to step back to"); + assert_eq!(dual.snapshot(), before, "the cabinet is untouched"); + assert_eq!(dual.rewind_len(), 0); +} + +#[test] +fn a_rewind_across_cabinet_frames_restores_both_framebuffers_exactly() { + let mut dual = cabinet(); + dual.enable_rewind_with(rustynes_core::REWIND_DEFAULT_MAX_BYTES, 4); + for _ in 0..10 { + dual.run_frame(); + } + // Record what frames 11..=20 looked like, and the whole state after each. + let mut history = Vec::new(); + for _ in 0..10 { + dual.run_frame(); + history.push((screens(&dual), dual.snapshot(), dual.main().frame())); + } + assert_eq!(dual.rewind_len(), 20, "one entry per frame"); + + // The first step back pops the newest entry: the frame on screen now. + assert!(dual.rewind_step_back()); + let (now_screens, now_state, _) = &history[9]; + assert_eq!(screens(&dual), *now_screens); + assert_eq!(dual.snapshot(), *now_state); + + // Each further step lands on the frame before, both screens and the + // whole cabinet byte for byte, across keyframes and deltas alike. + for back in (0..9).rev() { + assert!(dual.rewind_step_back(), "entry for history[{back}]"); + let (want_screens, want_state, want_frame) = &history[back]; + assert_eq!(dual.main().frame(), *want_frame); + assert_eq!( + screens(&dual), + *want_screens, + "both framebuffers after stepping back to history[{back}]" + ); + assert_eq!( + dual.snapshot(), + *want_state, + "the whole cabinet after stepping back to history[{back}]" + ); + } +} + +#[test] +fn play_resumed_after_a_rewind_replays_the_same_frames() { + let mut dual = cabinet(); + dual.enable_rewind_with(rustynes_core::REWIND_DEFAULT_MAX_BYTES, 4); + for _ in 0..12 { + dual.run_frame(); + } + let mut first = Vec::new(); + for _ in 0..6 { + dual.run_frame(); + first.push(screens(&dual)); + } + // Back to the frame before the six: the newest entry, then six more. + for _ in 0..7 { + assert!(dual.rewind_step_back()); + } + let mut second = Vec::new(); + for _ in 0..6 { + dual.run_frame(); + second.push(screens(&dual)); + } + assert_eq!( + first, second, + "the replay matches the original run, both screens" + ); +} + +#[test] +fn capture_off_frames_stay_out_of_the_ring() { + let mut dual = cabinet(); + dual.enable_rewind(); + dual.run_frame(); + dual.set_rewind_capture(false); + assert!(!dual.rewind_capture_enabled()); + dual.run_frame(); + dual.run_frame(); + dual.set_rewind_capture(true); + assert_eq!( + dual.rewind_len(), + 1, + "only the frame captured with capture on" + ); +} + +#[test] +fn a_loud_restore_and_a_power_cycle_empty_the_ring_and_a_quiet_one_keeps_it() { + let mut dual = cabinet(); + dual.enable_rewind(); + for _ in 0..3 { + dual.run_frame(); + } + let snap = dual.snapshot(); + dual.restore_quiet(&snap).expect("own snapshot"); + assert_eq!(dual.rewind_len(), 3, "a quiet restore keeps the ring"); + dual.restore(&snap).expect("own snapshot"); + assert_eq!(dual.rewind_len(), 0, "a loaded state replaces the timeline"); + dual.run_frame(); + dual.power_cycle(); + assert_eq!(dual.rewind_len(), 0, "a power cycle ends the timeline"); + assert!(dual.rewind_enabled(), "but rewind stays on"); +} diff --git a/ios/project.yml b/ios/project.yml index cd5f3c977..217c22237 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.0.1" + MARKETING_VERSION: "3.1.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/accuracycoin-build/derive_indices.py b/scripts/accuracycoin-build/derive_indices.py index df2e5af9d..b6ff314d9 100644 --- a/scripts/accuracycoin-build/derive_indices.py +++ b/scripts/accuracycoin-build/derive_indices.py @@ -25,11 +25,28 @@ from __future__ import annotations import argparse +import importlib.util import re import sys from pathlib import Path +def _extract_catalog(): + """Load the sibling `extract_catalog.py`, which owns the row grammar. + + Since upstream `f5f41dc2` a suite's rows come in three macros (`table`, + `tblf1`, `tblf2`), and the last two name a test by a one-byte token the + ROM expands from `PrintTextSpecialStrings`. Two copies of that grammar + would drift apart exactly as the hand-kept suite map did, so this script + reuses the extractor's patterns and token table rather than its own. + """ + path = Path(__file__).resolve().with_name("extract_catalog.py") + spec = importlib.util.spec_from_file_location("extract_catalog", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + # Upstream's "this test is not in the all-test-result-table" marker. Five # entries share it, so it is not an address and must never be used as a key. DRAW_TEST_LABEL = "result_DrawTest" @@ -60,7 +77,15 @@ def parse(asm: str): if not order: sys.exit("derive_indices: TableTable parsed to nothing -- refusing to guess") - # --- each suite's own `table` lines give the test order ----------------- + # --- each suite's own row lines give the test order -------------------- + ec = _extract_catalog() + words = ec.special_words(asm) + + def word(token: str) -> str: + if token not in words: + sys.exit(f"derive_indices: row uses {token}, which has no definition") + return words[token] + bodies: dict[str, list[tuple[str, str]]] = {} cur: str | None = None for ln in lines: @@ -78,6 +103,22 @@ def parse(asm: str): m = re.match(r'\s*table\s+"([^"]+)"\s*,\s*\$FF\s*,\s*(result_[A-Za-z0-9_]+)', ln) if m and cur: bodies[cur].append((m.group(1), m.group(2))) + continue + m = ec.RE_TBLF1_ROW.match(ln) + if m and cur: + bodies[cur].append((f"{m.group(1)} {word(m.group(2))}", m.group(3))) + continue + m = ec.RE_TBLF2_ROW.match(ln) + if m and cur: + bodies[cur].append( + (f"{m.group(1)} {word(m.group(2))}{m.group(3)}", m.group(4)) + ) + continue + # FAIL CLOSED: a row-shaped macro no pattern read would shift every + # later test index in its suite, and a sub-test ROM built from a + # shifted index runs the wrong test and writes a plausible byte. + if cur and ec.RE_ANY_ROW.match(ln): + sys.exit(f"derive_indices: {cur} has a row this script cannot read: {ln.strip()}") # --- result label -> address ------------------------------------------- results: dict[str, int] = {} @@ -106,14 +147,43 @@ def _recorded_validations() -> list[str]: """Recorded (suite, test, name) rows as `--validate` specs, or [].""" if not PROVENANCE_TSV.is_file(): return [] + # Columns are found by NAME from the generator's schema line + # (`# romenc_suiteenc_testresult_addrentry...`). Until + # v3.1.0 they were taken by position as (rom, suite, test, name), which + # went stale when `subtest_identify` added `result_addr` as column 3: every + # validation then compared a test name against an address and failed, so + # this script refused to run at all. + text = PROVENANCE_TSV.read_text(encoding="utf-8").splitlines() + names = ["rom", "enc_suite", "enc_test", "entry"] + for line in text: + head = line.lstrip("#").strip().split("\t") + if line.startswith("#") and head and head[0] == "rom" and "entry" in head: + names = head + try: + i_suite, i_test, i_name = ( + names.index("enc_suite"), names.index("enc_test"), names.index("entry") + ) + except ValueError: + sys.exit(f"derive_indices: {PROVENANCE_TSV.name} schema lacks enc_suite/enc_test/entry") + # A `legacy-unrecorded` row's indices were encoded against an upstream + # source nobody wrote down -- `implied-dummy-reads` is suite 19 test 1 in + # a build whose suite order predates the reorder -- so it is evidence + # about THAT source, not this one, and validating against it fails for a + # reason unrelated to the parser. Only rows with a recorded build commit + # are hand-checked answers. + i_commit = names.index("upstream_commit") if "upstream_commit" in names else None out = [] - for line in PROVENANCE_TSV.read_text(encoding="utf-8").splitlines(): + for line in text: if not line.strip() or line.lstrip().startswith("#"): continue cols = line.split("\t") - if len(cols) < 4: + if len(cols) <= max(i_suite, i_test, i_name): + continue + if i_commit is not None and ( + len(cols) <= i_commit or cols[i_commit].strip().startswith("legacy") + ): continue - _rom, suite, test, name = cols[0], cols[1], cols[2], cols[3] + suite, test, name = cols[i_suite], cols[i_test], cols[i_name] if suite.strip().isdigit() and test.strip().isdigit(): out.append(f"{suite.strip()}:{test.strip()}:{name.strip()}") return out diff --git a/scripts/accuracycoin-build/extract_catalog.py b/scripts/accuracycoin-build/extract_catalog.py index 045d44824..765a61acc 100755 --- a/scripts/accuracycoin-build/extract_catalog.py +++ b/scripts/accuracycoin-build/extract_catalog.py @@ -17,6 +17,16 @@ * Each `Suite_X:` block opens with `.byte "Display Name", $FF` and then carries `table "test name", $FF, result_symbol, TEST_entrypoint` rows until a bare `.byte $FF` terminator. + * Since upstream `f5f41dc2` (2026-10) the unofficial-opcode suites use two + byte-saving variants. `tblf1 "prefix", str_X, $FF, result, TEST` stores a + one-byte token for a common word, and `tblf2 "prefix", str_X, ",X", $FF, + result, TEST` appends a suffix after it. The ROM prints the token from + `PrintTextSpecialStrings` (`" indirect"`, `" zeropage"`, ...), indexed by + the token's low two bits, so the name is rebuilt from that table, not + from a word list kept here. + * Any other row-shaped line inside a suite aborts the run. A new macro + must fail loudly: this script once returned 85 of 151 rows without + complaint, because it only checked that the total was non-zero. * `result_symbol` resolves through a top-level `result_X = $ADDR` definition. @@ -40,6 +50,48 @@ r'^\s*table\s+"([^"]*)"\s*,\s*\$FF\s*,\s*(result_[A-Za-z0-9_]+)\s*,', re.M ) RE_SUITE_HEADER = re.compile(r'^\s*\.byte\s+"([^"]*)"\s*,\s*\$FF', re.M) +# `tblf1 "$07 SLO", str_ZeroPage, $FF, result_X, TEST_X` +RE_TBLF1_ROW = re.compile( + r'^\s*tblf1\s+"([^"]*)"\s*,\s*(str_[A-Za-z0-9_]+)\s*,\s*\$FF\s*,' + r"\s*(result_[A-Za-z0-9_]+)\s*,", + re.M, +) +# `tblf2 "$03 SLO", str_Indirect, ",X", $FF, result_X, TEST_X` +RE_TBLF2_ROW = re.compile( + r'^\s*tblf2\s+"([^"]*)"\s*,\s*(str_[A-Za-z0-9_]+)\s*,\s*"([^"]*)"\s*,' + r"\s*\$FF\s*,\s*(result_[A-Za-z0-9_]+)\s*,", + re.M, +) +# Any line opening with a row-like macro (`table`, `tblf1`, `tblfN`, ...). +# Used only to prove every such line was read by one of the patterns above. +RE_ANY_ROW = re.compile(r'^\s*(t[a-z]*[0-9]*)\s+"', re.M) +RE_TOKEN_DEF = re.compile(r"^(str_[A-Za-z0-9_]+)\s*=\s*\$([0-9A-Fa-f]+)", re.M) +RE_SPECIAL_STRINGS = re.compile( + r'^PrintTextSpecialStrings:\s*\n((?:\s*\.byte\s+"[^"]*"\s*\n?)+)', re.M +) + + +def special_words(asm: str) -> dict[str, str]: + """Map each `str_X` token to the word the ROM prints for it. + + The ROM's `PrintTextSpecialString` takes the token's low two bits as an + index into `PrintTextSpecialStrings`. Each entry carries a leading space + that separates it from the prefix; `parse` re-adds it as one space. + """ + tokens = {m.group(1): int(m.group(2), 16) for m in RE_TOKEN_DEF.finditer(asm)} + if not tokens: + return {} + block = RE_SPECIAL_STRINGS.search(asm) + if not block: + raise SystemExit("`str_X` tokens are defined but no PrintTextSpecialStrings table exists") + words = re.findall(r'"([^"]*)"', block.group(1)) + out = {} + for name, value in tokens.items(): + idx = value & 0x3 + if idx >= len(words): + raise SystemExit(f"{name} = ${value:02X} indexes past PrintTextSpecialStrings") + out[name] = words[idx].strip() + return out def parse(asm: str) -> list[tuple[str, str, int]]: @@ -55,6 +107,13 @@ def parse(asm: str) -> list[tuple[str, str, int]]: if not order: raise SystemExit("TableTable block contained no `.word Suite_*` rows") + words = special_words(asm) + + def word(token: str) -> str: + if token not in words: + raise SystemExit(f"row uses {token}, which has no `{token} = $NN` definition") + return words[token] + rows: list[tuple[str, str, int]] = [] for label in order: start = asm.find(f"\n{label}:") @@ -70,7 +129,30 @@ def parse(asm: str) -> list[tuple[str, str, int]]: raise SystemExit(f"{label} has no `.byte \"name\", $FF` display header") suite = header.group(1) - for name, symbol in RE_TABLE_ROW.findall(block): + found: list[tuple[int, str, str]] = [] + for m in RE_TABLE_ROW.finditer(block): + found.append((m.start(), m.group(1), m.group(2))) + for m in RE_TBLF1_ROW.finditer(block): + found.append((m.start(), f"{m.group(1)} {word(m.group(2))}", m.group(3))) + for m in RE_TBLF2_ROW.finditer(block): + found.append( + (m.start(), f"{m.group(1)} {word(m.group(2))}{m.group(3)}", m.group(4)) + ) + found.sort() + # Every row-shaped line must have been read by exactly one pattern. + candidates = [m for m in RE_ANY_ROW.finditer(block)] + if len(candidates) != len(found): + parsed = {pos for pos, _, _ in found} + missed = [ + block[m.start() : block.find("\n", m.start())].strip() + for m in candidates + if m.start() not in parsed + ] + raise SystemExit(f"{label} has row lines this script cannot read: {missed}") + if not found: + raise SystemExit(f"{label} yielded zero rows") + + for _, name, symbol in found: if symbol not in results: raise SystemExit( f'{label} / "{name}" references {symbol}, which has no ' @@ -107,6 +189,13 @@ def render(rows: list[tuple[str, str, int]]) -> str: \t.byte "Second Page", $FF \ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma \t.byte $FF + +str_Indirect = $F0 +str_ZeroPage = $F1 + +PrintTextSpecialStrings: +\t.byte " indirect" +\t.byte " zeropage" """ @@ -142,7 +231,50 @@ def self_test() -> int: assert "result_Alpha" in {m.group(1) for m in RE_RESULT_DEF.finditer(SELF_TEST_ASM)} assert len(RE_RESULT_DEF.findall(SELF_TEST_ASM)) == 3 - print("extract_catalog self-test: ok (4 cases)") + # The compressed row macros (upstream 03757ce8 / f5f41dc2, 2026-10-07): + # `tblf1` and `tblf2` substitute a one-byte token for a common word, and + # `tblf2` appends a suffix. The name is rebuilt exactly as the ROM shows it. + compressed = SELF_TEST_ASM.replace( + '\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma', + '\ttblf2 "$03 SLO", str_Indirect, ",X", $FF, result_Gamma, TEST_Gamma\n' + '\ttblf1 "$07 SLO", str_ZeroPage, $FF, result_Alpha, TEST_Alpha', + ) + rows = parse(compressed) + assert rows[:2] == [ + ("Second Page", "$03 SLO indirect,X", 0x0403), + ("Second Page", "$07 SLO zeropage", 0x0401), + ], rows + + # FAIL CLOSED on a row shape the parser does not know: a macro it cannot + # read must abort, never vanish. Until v3.1.0 this script dropped all 66 + # compressed rows silently, because only "zero rows in total" was checked. + unknown = SELF_TEST_ASM.replace("\ttable \"Gamma Test\"", "\ttblf9 \"Gamma Test\"") + try: + parse(unknown) + except SystemExit as exc: + assert "tblf9" in str(exc), exc + else: + raise AssertionError("an unknown row macro was dropped silently") + + # ...and a suite that yields no rows at all must abort. + empty = SELF_TEST_ASM.replace('\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma\n', "") + try: + parse(empty) + except SystemExit as exc: + assert "Suite_Second" in str(exc), exc + else: + raise AssertionError("a suite with zero rows was accepted") + + # An unknown word token must abort rather than guess a word. + badtok = compressed.replace(", str_ZeroPage,", ", str_Mystery,") + try: + parse(badtok) + except SystemExit as exc: + assert "str_Mystery" in str(exc), exc + else: + raise AssertionError("an unknown word token was accepted") + + print("extract_catalog self-test: ok (8 cases)") return 0 From 57eb466e43d35d180e33f5286bc4c79ca78994f7 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 06:10:13 -0400 Subject: [PATCH 2/5] review(v3.1.0): sync slice A to release head 8af50dc0 (do not merge) Brings this review-only slice up to #594 at 8af50dc0; every path in the slice equals the release head (git diff --quiet checked). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/bus.rs | 8 ++ crates/rustynes-core/src/nes.rs | 16 ++-- crates/rustynes-frontend/src/emu.rs | 53 +++++++++++++ crates/rustynes-ppu/src/ppu.rs | 5 +- .../src/accuracy_coin_catalog.rs | 5 +- .../tests/epoch_fingerprint.rs | 77 ++++++++++++++++--- crates/rustynes-test-harness/tests/mmc3.rs | 35 +++++++++ .../tests/snapshot_schema_audit.rs | 4 +- scripts/accuracycoin-build/extract_catalog.py | 27 ++++++- 9 files changed, 208 insertions(+), 22 deletions(-) diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 2692d7aa2..da2c079ba 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -3202,6 +3202,14 @@ impl SystemBus { if !saw_map { return Err(SnapshotError::MissingSection("MAP ".into())); } + // v3.1.0 (PR #594 review): the MMC3's MAP section carries its LIVE + // IRQ revision, which is configuration rather than console state, so + // re-apply the configured override (`None` = the header's). Without + // this a state saved under the override and loaded without it kept + // running the alternate revision while `mmc3_revision_override()` + // reported `None`, and the reverse. A no-op on every other board. + self.mapper + .set_mmc3_revision_override(self.mmc3_revision_override); // RW-0 fix: under R1, `dmc_driven_externally` is NOT serialized (it is // build configuration, not emulated state), so after `apu.restore` it // reverts to the `Apu::new` default (`false`), which STOPS `put_cycle` diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 56fb9d9b4..c578e72a9 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -3044,8 +3044,11 @@ impl Nes { /// the CPU runs `k` times faster against an unchanged PPU, so a game gets /// `k` times the CPU time per frame. /// - /// It divides the region's master-clock CPU divider (NTSC 12 -> 6 / 4 / 3; - /// PAL 16 and Dendy 15 round down, so `x3` on PAL is x3.2). The APU, the + /// It splits the region's master-clock CPU divider into `k` cycles whose + /// lengths sum to exactly one stock cycle (NTSC 12 -> 6 / 4 / 3; PAL 16 at + /// `x3` is 5, 5, 6 and Dendy 15 at `x4` is 3, 4, 4, 4), so the multiplier is + /// exact on every region. Until the v3.1.0 review it divided with integer + /// division, which made PAL `x3` run x3.2 and Dendy `x4` x5. The APU, the /// DMC, every mapper's CPU-cycle hook (the VRC / FME-7 / N163 IRQ /// counters) and the PPU's open-bus and post-reset timers stay at the /// STOCK rate, so the pitch, the music tempo and the cycle-timed raster @@ -3105,9 +3108,12 @@ impl Nes { /// a chosen IRQ revision, or `None` for the one its header selects. /// /// The MMC3's two IRQ behaviours are mutually exclusive: the Sharp MMC3B - /// / MMC3C (the default) asserts IRQ when the counter is reloaded to 0, the - /// MMC3A and non-Sharp MMC3B (`Mmc3Revision::Nec`) only on a 1 -> 0 - /// decrement. A NES 2.0 header can say which (submapper 4); an iNES 1.0 + /// / MMC3C (the default) asserts IRQ whenever a clock leaves the counter at + /// 0 with IRQs enabled, so a latch of 0 fires every scanline. The MMC3A + /// and non-Sharp MMC3B (`Mmc3Revision::Nec`) assert on a 1 -> 0 decrement + /// and on a `$C001` reload to 0 (one IRQ per `$C001` write while `$C000` + /// is 0, even if the counter was already 0), but not when the counter, + /// already 0, reloads 0 by itself. A NES 2.0 header can say which (submapper 4); an iNES 1.0 /// dump cannot, so this override is how a player runs a game, or blargg's /// `mmc3_test_2/6-MMC3_alt`, on the other chip. The default (`None`) is /// unchanged. Configuration, re-applied when a power cycle rebuilds the diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index 5db894a6f..8f2041eff 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -1949,9 +1949,27 @@ impl EmuCore { self.vs_coin_frames -= 1; self.vs_coin_frames == 0 }; + // v3.1.0 — the CPU overclock and the sprite-limit option reach BOTH + // consoles, as `produce_frame` applies them to the one. The cabinet + // locksteps its consoles by CPU cycle count (`VsDualSystem::run_frame`), + // so the same multiplier on both keeps that gap meaningful. No movie + // or netplay session runs on a cabinet (ADR 0032), so the single + // path's movie-idle condition has no counterpart here. + let want_cpu = self.cpu_overclock.max(1); + let want_sprites = self.disable_sprite_limit; let Some(dual) = self.dual.as_mut() else { return; }; + let apply = |nes: &mut rustynes_core::Nes| { + if nes.cpu_overclock() != want_cpu { + nes.set_cpu_overclock(want_cpu); + } + if nes.sprite_limit_disabled() != want_sprites { + nes.set_sprite_limit_disabled(want_sprites); + } + }; + apply(dual.main_mut()); + apply(dual.sub_mut()); if clear_coin { dual.clear_coin(); } @@ -2859,6 +2877,41 @@ mod tests { ); } + /// v3.1.0 (PR #594 review) — the CPU overclock and the sprite-limit option + /// reach BOTH cabinet consoles. `produce_dual_frame` applied neither, so on + /// a Vs. `DualSystem` cabinet both settings silently did nothing. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_cabinet_runs_both_consoles_with_the_enhancement_options() { + let rom = crate::runahead::tests::flashing_cabinet(); + let mut sinks = FrameSinks { + audio: None, + #[cfg(feature = "retroachievements")] + ra: None, + }; + let mut core = EmuCore::new(); + core.set_dual(Box::new( + rustynes_core::VsDualSystem::from_rom(&rom).unwrap(), + )); + core.cpu_overclock = 3; + core.disable_sprite_limit = true; + core.produce_one_frame(&quiet_inputs(), &mut sinks); + let dual = core.dual.as_ref().unwrap(); + for (name, nes) in [("main", dual.main()), ("sub", dual.sub())] { + assert_eq!(nes.cpu_overclock(), 3, "{name} console overclock"); + assert!(nes.sprite_limit_disabled(), "{name} console sprite limit"); + } + // Back to stock: `0` and `1` both mean x1. + core.cpu_overclock = 0; + core.disable_sprite_limit = false; + core.produce_one_frame(&quiet_inputs(), &mut sinks); + let dual = core.dual.as_ref().unwrap(); + for nes in [dual.main(), dual.sub()] { + assert_eq!(nes.cpu_overclock(), 1); + assert!(!nes.sprite_limit_disabled()); + } + } + /// v2.9.7 (`T-PS-dual-savestate`) — a Vs. `DualSystem` cabinet saves and /// restores both consoles, byte for byte. A single-console blob and a /// cabinet blob are each refused by the other kind, and nothing restores diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 2427f7965..75bd20478 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -6460,7 +6460,10 @@ impl Ppu { /// starts at OAMADDR 0, as it does on every normally rendered line). A line /// whose evaluation starts misaligned (a mid-frame `$2003` write, a test /// construction) can draw a slightly different set; it is a display - /// enhancement, not hardware behaviour. + /// enhancement, not hardware behaviour. The walk reads `oam` directly and + /// deliberately bypasses the optional OAM-decay read hook, so drawing the + /// extra sprites can never refresh a decaying DRAM row the game could + /// later observe. fn fetch_extra_sprites(&mut self, bus: &mut B, next_line: i16, height: i16) { self.spr_extra_count = 0; if !self.sprite_limit_disabled diff --git a/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs b/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs index 093abe9e1..0bdbe4c14 100644 --- a/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs +++ b/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs @@ -300,8 +300,9 @@ pub fn decode_results(ram: &[u8]) -> Option> { /// counts here and the named list there cannot disagree. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct RamResultSummary { - /// Total number of catalog entries (always 151 if the catalog is - /// fully loaded). + /// Number of SCORED catalog entries, [`scored_len`]: 146 at upstream + /// `f5f41dc2`. The catalog holds 151 rows; the five sentinel rows that + /// share [`RESULT_DRAW_TEST`] are not scored and not counted here. pub total: u32, /// Tests that wrote `$01` (clean pass). pub pass: u32, diff --git a/crates/rustynes-test-harness/tests/epoch_fingerprint.rs b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs index fc49d66c7..d448217a1 100644 --- a/crates/rustynes-test-harness/tests/epoch_fingerprint.rs +++ b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs @@ -246,6 +246,50 @@ fn render_table(last_release_epoch: u32, rows: &[Fingerprint]) -> String { out } +/// How one panel probe compares with the committed table. +#[derive(Debug, PartialEq, Eq)] +enum Verdict { + /// The table holds this exact row. + Unchanged, + /// The table holds this ROM at this frame count with different output: + /// emulation moved, which needs a raised epoch. + Changed, + /// The table has no row for this ROM at this frame count: a probe added + /// to the panel, blessable at any epoch. + New, +} + +fn classify(now: &Fingerprint, rows: &[Fingerprint]) -> Verdict { + if rows.contains(now) { + Verdict::Unchanged + } else if rows + .iter() + .any(|r| r.rom == now.rom && r.frames == now.frames) + { + Verdict::Changed + } else { + Verdict::New + } +} + +#[test] +fn a_new_probe_is_not_a_moved_output() { + let row = |rom: &str, frames: u32, fb: u64| Fingerprint { + rom: rom.into(), + frames, + framebuffer: fb, + audio: 1, + ram: 2, + cycles: 3, + }; + let table = [row("a.nes", 60, 7)]; + assert_eq!(classify(&row("a.nes", 60, 7), &table), Verdict::Unchanged); + assert_eq!(classify(&row("a.nes", 60, 8), &table), Verdict::Changed); + assert_eq!(classify(&row("b.nes", 60, 7), &table), Verdict::New); + // A new frame count for a known ROM is a new probe, not a moved one. + assert_eq!(classify(&row("a.nes", 90, 8), &table), Verdict::New); +} + #[test] fn output_moves_only_with_the_emulation_epoch() { let now: Vec = PANEL.iter().map(measure).collect(); @@ -260,15 +304,23 @@ fn output_moves_only_with_the_emulation_epoch() { table.last_release_epoch ); - let moved: Vec = now - .iter() - .zip(PANEL) - .filter(|(f, _)| !table.rows.contains(f)) - .map(|(f, p)| format!(" {} ({}): now {}", f.rom, p.reach, f.row())) - .collect(); + let mut moved: Vec = Vec::new(); + let mut added: Vec = Vec::new(); + for (f, p) in now.iter().zip(PANEL) { + let line = format!(" {} ({}): now {}", f.rom, p.reach, f.row()); + match classify(f, &table.rows) { + Verdict::Unchanged => {} + Verdict::Changed => moved.push(line), + Verdict::New => added.push(line), + } + } // The rule (ADR 0045): output may differ from the LAST RELEASE only under // a raised epoch. Within one release, a second change after the rise - // needs a re-bless, not a second rise. + // needs a re-bless, not a second rise. A probe the table has never seen + // (a ROM, or a frame count, added to the panel) is not a moved output, so + // it can be blessed at the released epoch (PR #594 review: until then + // adding a probe after a release demanded an epoch rise, and v3.1.0's + // panel change had to lower `last_release_epoch` by hand to bless one). let raised = EMULATION_EPOCH > table.last_release_epoch; assert!( @@ -285,12 +337,19 @@ fn output_moves_only_with_the_emulation_epoch() { fs::write(table_path(), render_table(table.last_release_epoch, &now)) .expect("write epoch_fingerprint.tsv"); eprintln!( - "blessed epoch_fingerprint.tsv at epoch {EMULATION_EPOCH} ({} rows moved)", - moved.len() + "blessed epoch_fingerprint.tsv at epoch {EMULATION_EPOCH} ({} rows moved, {} new)", + moved.len(), + added.len() ); return; } + assert!( + added.is_empty(), + "the panel has probes the table does not record; bless them with \ + RUSTYNES_BLESS_EPOCH_FINGERPRINT=1 (no epoch rise needed). New:\n{}", + added.join("\n") + ); assert!( moved.is_empty(), "emulated output changed under epoch {EMULATION_EPOCH}, which is already \ diff --git a/crates/rustynes-test-harness/tests/mmc3.rs b/crates/rustynes-test-harness/tests/mmc3.rs index e2e0e2982..6c16484ce 100644 --- a/crates/rustynes-test-harness/tests/mmc3.rs +++ b/crates/rustynes-test-harness/tests/mmc3.rs @@ -189,6 +189,41 @@ fn the_mmc3_override_survives_a_power_cycle_and_clears() { ); } +/// v3.1.0 (PR #594 review): the override is configuration, so restoring a +/// state never changes which revision runs. The MMC3's live revision travels +/// in its MAP section, and until this fix a restore installed the SAVED one: +/// a state saved under the override and loaded without it kept running the +/// alternate revision while `mmc3_revision_override()` reported `None`, and +/// the reverse. +#[test] +fn a_restore_keeps_the_configured_mmc3_revision() { + use rustynes_core::rustynes_mappers::Mmc3Revision; + let path = rom_path("blargg/mmc3_test_2/5-MMC3.nes"); + let bytes = std::fs::read(&path).expect("read"); + let mut nes = rustynes_core::Nes::from_rom(&bytes).expect("parse"); + + nes.set_mmc3_revision_override(Some(Mmc3Revision::Nec)); + let under_override = nes.snapshot(); + nes.set_mmc3_revision_override(None); + let at_header = nes.snapshot(); + + nes.restore(&under_override).expect("restore"); + assert_eq!(nes.mmc3_revision_override(), None); + assert!( + nes.mapper_info().name.contains("Sharp"), + "a state saved under the override must not bring it back: {}", + nes.mapper_info().name + ); + + nes.set_mmc3_revision_override(Some(Mmc3Revision::Nec)); + nes.restore(&at_header).expect("restore"); + assert!( + nes.mapper_info().name.contains("Nec"), + "a state saved at the header's revision must not drop the override: {}", + nes.mapper_info().name + ); +} + #[test] #[ignore = "by-design fail at the default: sub-ROM 6 is the alternate MMC3 IRQ revision; \ the default is Sharp (sub-ROM 5). Runs under the override in \ diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index 92c741260..e9e61775d 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -488,7 +488,9 @@ const CHIPS: &[Chip] = &[ ), ( "cpu_div_effective", - "derived: `cpu_div_cached / cpu_overclock`, recomputed when the overclock is set", + "derived: `overclock_cycle_len(cpu_div_cached, cpu_overclock, overclock_phase)`, \ + the phase's share of one stock cycle (the `k` shares sum to the divider), \ + recomputed whenever the overclock or the phase changes", ), ( "stock_step", diff --git a/scripts/accuracycoin-build/extract_catalog.py b/scripts/accuracycoin-build/extract_catalog.py index 765a61acc..b264df538 100755 --- a/scripts/accuracycoin-build/extract_catalog.py +++ b/scripts/accuracycoin-build/extract_catalog.py @@ -62,9 +62,14 @@ r"\s*\$FF\s*,\s*(result_[A-Za-z0-9_]+)\s*,", re.M, ) -# Any line opening with a row-like macro (`table`, `tblf1`, `tblfN`, ...). -# Used only to prove every such line was read by one of the patterns above. -RE_ANY_ROW = re.compile(r'^\s*(t[a-z]*[0-9]*)\s+"', re.M) +# Any line opening with a row macro (`table`, `tblf1`, `tblfN`, ...), whatever +# its first argument is. Used only to prove every such line was read by one of +# the patterns above. Keyed on the macro FAMILY rather than on a quoted first +# argument (PR #594 review): a future `tblf3 str_Name, $FF, result_X, ...` row +# would have slipped past a quote-anchored check and silently shifted every +# later index. `table`/`tbl` because the 6502 mnemonics that start with `t` +# (`tax`, `tay`, `tsx`, `txa`, `txs`, `tya`) must not match. +RE_ANY_ROW = re.compile(r"^\s*(table|tbl[a-z0-9]*)\s+\S", re.M) RE_TOKEN_DEF = re.compile(r"^(str_[A-Za-z0-9_]+)\s*=\s*\$([0-9A-Fa-f]+)", re.M) RE_SPECIAL_STRINGS = re.compile( r'^PrintTextSpecialStrings:\s*\n((?:\s*\.byte\s+"[^"]*"\s*\n?)+)', re.M @@ -256,6 +261,20 @@ def self_test() -> int: else: raise AssertionError("an unknown row macro was dropped silently") + # ...including one whose FIRST argument is a token rather than a string, + # which the detector used to require (PR #594 review). + tokenfirst = SELF_TEST_ASM.replace( + '\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma', + '\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma\n' + "\ttblf3 str_ZeroPage, $FF, result_Alpha, TEST_Alpha", + ) + try: + parse(tokenfirst) + except SystemExit as exc: + assert "tblf3" in str(exc), exc + else: + raise AssertionError("a token-first row macro was dropped silently") + # ...and a suite that yields no rows at all must abort. empty = SELF_TEST_ASM.replace('\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma\n', "") try: @@ -274,7 +293,7 @@ def self_test() -> int: else: raise AssertionError("an unknown word token was accepted") - print("extract_catalog self-test: ok (8 cases)") + print("extract_catalog self-test: ok (9 cases)") return 0 From 8beb98469b91d43a5dffaad79660fcfcdbba2da1 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 06:27:47 -0400 Subject: [PATCH 3/5] review(v3.1.0): sync slice A to release head c749c438 (do not merge) Brings this review-only slice up to #594 at c749c438; every path in the slice equals the release head (git diff --quiet checked). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/hardware_options.rs | 5 ++- .../tests/cpu_overclock.rs | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index f87db2155..95467e71a 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -136,7 +136,10 @@ pub struct HardwareOptions { /// The extra-vblank-scanline overclock ([`Nes::set_extra_scanlines`]). pub extra_scanlines: u16, /// The CPU-multiplier overclock, `1..=MAX_CPU_OVERCLOCK` - /// ([`Nes::set_cpu_overclock`]); `1` is stock. v3.1.0. + /// ([`Nes::set_cpu_overclock`]); `1` is stock. v3.1.0. A value outside + /// that range never reaches the core as written: decoding a record + /// refuses it, and applying one clamps it (`0` to `1`, anything above to + /// the maximum), as `Nes::set_cpu_overclock` does. pub cpu_overclock: u8, /// Draw the sprites beyond the eighth on a scanline /// ([`Nes::set_sprite_limit_disabled`]); render-only. v3.1.0. diff --git a/crates/rustynes-test-harness/tests/cpu_overclock.rs b/crates/rustynes-test-harness/tests/cpu_overclock.rs index bbba2c0e7..457131599 100644 --- a/crates/rustynes-test-harness/tests/cpu_overclock.rs +++ b/crates/rustynes-test-harness/tests/cpu_overclock.rs @@ -256,3 +256,43 @@ fn the_multiplier_is_exact_on_pal_and_dendy_too() { } } } + +/// Switching the overclock back OFF after a long run is an ordinary frame. +/// +/// Under the overclock the APU runs on its own stock-rate counter +/// (`apu_cycle`) while the CPU's counter (`cycle`) runs `k` times faster, so +/// by the time the player switches back to `x1` the two are tens of millions +/// of cycles apart, and `x1` hands the APU the CPU's counter again. A review +/// of the release PR (#594) read that as a catch-up loop that would stall the +/// emulator and flood the audio buffer. It is not one: the APU takes the +/// counter by assignment (`Apu::set_canonical_cycle`) and uses it for its +/// put/get parity and a pending IRQ-flag clear, never as a distance to +/// cover. This pins that: after 600 frames at `x4`, each of the next frames +/// at `x1` produces the stock number of CPU cycles and audio samples. +#[test] +fn switching_back_to_stock_after_a_long_overclock_is_an_ordinary_frame() { + let (stock_cycles, stock_samples) = measure(1, 10); + let mut nes = boot_rom(AUDIBLE_ROM); + nes.set_cpu_overclock(4); + for _ in 0..600 { + nes.run_frame(); + let _ = nes.drain_audio(); + } + nes.set_cpu_overclock(1); + for frame in 0..10 { + let start = nes.cycle(); + nes.run_frame(); + let cycles = nes.cycle() - start; + let samples = nes.drain_audio().len(); + assert!( + cycles.abs_diff(stock_cycles / 10) <= 2, + "frame {frame} after switching back: {cycles} CPU cycles, stock is {}", + stock_cycles / 10 + ); + assert!( + samples.abs_diff(stock_samples / 10) <= 2, + "frame {frame} after switching back: {samples} audio samples, stock is {}", + stock_samples / 10 + ); + } +} From f6726adb41da599faa9edb5ad8fd9a05e936b5db Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 06:45:17 -0400 Subject: [PATCH 4/5] review(v3.1.0): sync slice A to release head c95a8d10 (do not merge) Brings this review-only slice up to #594 at c95a8d10; every path in the slice equals the release head (git diff --quiet checked). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../tests/release_notes_render_audit.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/rustynes-test-harness/tests/release_notes_render_audit.rs b/crates/rustynes-test-harness/tests/release_notes_render_audit.rs index fb5cc993b..4ba13d164 100644 --- a/crates/rustynes-test-harness/tests/release_notes_render_audit.rs +++ b/crates/rustynes-test-harness/tests/release_notes_render_audit.rs @@ -205,3 +205,71 @@ fn the_paragraph_scanner_recognises_the_shapes_release_notes_use() { [] as [(usize, std::vec::Vec); 0] ); } + +/// The drafting placeholders this project writes while a release is being +/// assembled (`LADDER-FILL`, `BITSTREAM-FILL`: an upper-case word ending in +/// `-FILL`). Returns each one with its 1-based line number. +fn fill_placeholders(text: &str) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, line) in text.lines().enumerate() { + for token in line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) { + if let Some(stem) = token.strip_suffix("-FILL") + && !stem.is_empty() + && stem + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') + { + out.push((i + 1, token.to_string())); + } + } + } + out +} + +/// No drafting placeholder reaches a release body or the CHANGELOG. +/// +/// v3.1.0's release PR (#594) shipped `- The MiSTer core: LADDER-FILL.` in +/// its CHANGELOG section; a reviewer found it, no gate did. The release body +/// is built from `.github/release-notes/vX.Y.Z.md`, or from the CHANGELOG +/// section when there is none, so both are checked, every version. +#[test] +fn no_fill_placeholder_reaches_a_release_body() { + let root = repo_root(); + let mut files = vec![root.join("CHANGELOG.md")]; + let dir = root.join(".github/release-notes"); + for e in std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) { + let p = e + .unwrap_or_else(|e| panic!("read an entry of {}: {e}", dir.display())) + .path(); + if p.extension().is_some_and(|x| x == "md") { + files.push(p); + } + } + assert!(files.len() > 20, "only {} files found", files.len()); + let mut findings = Vec::new(); + for f in &files { + let text = + std::fs::read_to_string(f).unwrap_or_else(|e| panic!("read {}: {e}", f.display())); + for (line, token) in fill_placeholders(&text) { + findings.push(format!(" {}:{line} {token}", f.display())); + } + } + assert!( + findings.is_empty(), + "drafting placeholders left in release text:\n{}", + findings.join("\n") + ); +} + +#[test] +fn the_placeholder_scanner_finds_the_shapes_this_project_writes() { + let text = "- The MiSTer core: LADDER-FILL.\nbitstreams: BITSTREAM-FILL, then\n\ + a pre-fill note, a Fill-in, X-FILLER and -FILL are not placeholders"; + assert_eq!( + fill_placeholders(text), + vec![ + (1, "LADDER-FILL".to_string()), + (2, "BITSTREAM-FILL".to_string()) + ] + ); +} From 2863c5c66c98efbe1aec5cca2e7b78d3dbdafd48 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 07:52:02 -0400 Subject: [PATCH 5/5] review(v3.1.0): sync slice A to release head 72747541 (do not merge) Brings this review-only slice up to #594 at 72747541; every path in the slice equals the release head (git diff --quiet checked). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/bus.rs | 2 +- crates/rustynes-core/src/bus_snapshot.rs | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index da2c079ba..7fc22ddfd 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -4976,7 +4976,7 @@ mod four_score_tests { // The two device tags sit before a fixed tail with both ports empty: // mirroring override (1) + controller-run tail (22) + internal bus // (1) + the version-3 fields (DMC write-refusal latch 1, overclock - // debt 1, `apu_cycle` 8) follow them, and port 1's tag is the second. + // phase 1, `apu_cycle` 8) follow them, and port 1's tag is the second. // // v3.1.0: the version-3 bytes were missing from this sum for one // commit. With only the 1-byte latch appended the window still read diff --git a/crates/rustynes-core/src/bus_snapshot.rs b/crates/rustynes-core/src/bus_snapshot.rs index 7b387b04c..39d599f18 100644 --- a/crates/rustynes-core/src/bus_snapshot.rs +++ b/crates/rustynes-core/src/bus_snapshot.rs @@ -471,9 +471,11 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> let internal_data_bus = r.u8()?; let dmc_load_write_delayed = r.bool()?; let overclock_phase = r.u8()?; - // A debt is below one stock CPU cycle (16 master clocks on PAL, the - // longest); anything larger is a corrupt file, refused here rather than - // clamped later. + // The phase indexes the overclocked cycles of one stock cycle, so it is + // below the largest multiplier; anything larger is a corrupt file, + // refused here rather than clamped later. (A phase valid for `x4` but not + // for the multiplier the restoring host runs is clamped to its last cycle + // by `restore`.) if overclock_phase >= crate::MAX_CPU_OVERCLOCK { return Err(SnapshotError::SectionInvalid { tag: "BUS ".into(),