rustynes_mappers/m085_vrc7.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the VRC7 audio register-write path (the `$9010` address latch / `$9030` data write pair forwarded to the OPLL) and the `$E000` bit-7 silence ("muted") flag were written with Mesen2 (GPL-3.0-or-later) `Vrc7Audio.h` consulted, as the in-file comments state. Classified as derived in v2.7.1 (core audit section 6.2) because the comments cite that source's expressions; the banking and IRQ halves are not covered by this line. See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5
6//! Konami VRC7 (mapper 85) -- banking, the VRC IRQ counter, and the on-cart
7//! YM2413-derivative OPLL FM synthesizer.
8//!
9//! The banking and IRQ halves are ordinary VRC4-family behaviour. The audio
10//! half is a cut-down OPLL: six FM channels driven from a fixed internal
11//! patch ROM plus one user-programmable patch, clocked once every 36 CPU
12//! cycles. The synthesizer itself lives in the shared OPLL core; this module
13//! owns the mapper-side register file ([`Vrc7AudioRegs`]), the `$9010`/`$9030`
14//! address/data port pair, and the `$E000` bit-7 audio-mute line.
15//!
16//! Audio is gated behind the `mapper-audio` Cargo feature (default ON); with
17//! it off the register decoders still latch so save states remain portable
18//! across feature configurations (ADR 0004). See ADR 0006 for the decision
19//! record on landing VRC7 audio.
20//!
21//! See `docs/mappers.md` and `docs/apu-2a03.md` §Expansion-audio levels.
22
23#![allow(
24 clippy::cast_possible_truncation,
25 clippy::cast_lossless,
26 clippy::missing_const_for_fn,
27 clippy::needless_pass_by_ref_mut,
28 clippy::manual_range_patterns,
29 clippy::match_same_arms,
30 clippy::struct_excessive_bools,
31 clippy::doc_markdown,
32 clippy::range_plus_one,
33 clippy::single_match_else,
34 clippy::bool_to_int_with_if,
35 clippy::unnested_or_patterns,
36 clippy::single_match,
37 clippy::doc_lazy_continuation,
38 clippy::too_long_first_doc_paragraph
39)]
40
41use crate::cartridge::Mirroring;
42use crate::mapper::{Mapper, MapperCaps, MapperError};
43use alloc::{boxed::Box, vec::Vec};
44use alloc::{format, vec};
45
46const PRG_BANK_8K: usize = 0x2000;
47const CHR_BANK_1K: usize = 0x0400;
48const CHR_BANK_8K: usize = 0x2000;
49const NAMETABLE_SIZE: usize = 0x0400;
50const NAMETABLE_SIZE_U16: u16 = 0x0400;
51
52/// Version byte this board writes in its mapper save-state section.
53///
54/// **v1** (through v2.3.6) carried banking, IRQ, mirroring, the PRG-RAM
55/// *enable* bit and the *shadow* OPLL register bytes. **v2** (v2.3.7) appends
56/// the live synthesizer, closing the save-state audio-continuity gap recorded
57/// in `docs/accuracy-ledger.md`.
58///
59/// Neither carried the RAM itself. This comment said until v2.9.2 that v1
60/// carried "PRG-RAM"; it carried only the enable bit, and the `.rns`
61/// container has no other section that carries cartridge RAM, so every
62/// save-state load, rewind step, run-ahead frame and netplay rollback kept the
63/// 8 KiB PRG-RAM (and, on a CHR-RAM cartridge such as *Lagrange Point*, the
64/// 8 KiB CHR-RAM) the running game held instead of the saved one (core audit
65/// v2.9.2 AUD-02). **v3** is v1 plus a RAM tail -- the PRG-RAM, then the
66/// CHR-RAM when present -- and **v4** is v2 plus the same RAM tail, placed
67/// after the synthesizer tail so every older offset is unchanged. Two new
68/// numbers rather than one keep the audio tail's presence encoded in the
69/// version, exactly as v1/v2 already do. Since v2.9.8 (ADR 0042) `load_state`
70/// accepts v3 and v4 only; a v1/v2 blob, which it used to load with the RAM
71/// untouched, is refused.
72///
73/// A build without `mapper-audio` has no synthesizer to describe, so it writes
74/// **v3** and, on load, validates a v4 tail's length and ignores its contents.
75/// That keeps the cross-build property this crate's feature documentation
76/// promises — an audio build's save still loads in a no-audio build, and a
77/// no-audio build's save still loads everywhere.
78///
79/// **This constant is the WRITE version only. Never key the accept set on it.**
80/// `load_state` compares against the literals 3 and 4 for that reason: it once
81/// compared against this constant, which differs by build, so the condition
82/// collapsed and a no-audio build rejected the audio build's blob outright —
83/// the precise opposite of the sentence above. What a build can write and what
84/// it must accept are different sets, and only the first varies by feature.
85/// Pinned by `vrc7_load_state_accepts_a_v4_blob_on_every_build`.
86#[cfg(feature = "mapper-audio")]
87const VRC7_SECTION_VERSION: u8 = 4;
88#[cfg(not(feature = "mapper-audio"))]
89const VRC7_SECTION_VERSION: u8 = 3;
90
91/// Bytes the v2 tail adds after the VRAM: `opll_clock_counter` (2),
92/// `last_opll_sample` (2), and the self-versioned OPLL blob.
93const VRC7_V2_TAIL_LEN: usize = 2 + 2 + rustynes_apu::OPLL_SNAPSHOT_LEN;
94
95fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
96 let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
97 let local = (addr as usize) & (NAMETABLE_SIZE - 1);
98 let physical = mirroring.physical_bank(table);
99 physical * NAMETABLE_SIZE + local
100}
101
102/// VRC7 audio register snapshot.
103///
104/// Two latches: the OPLL register address (set by writes to `$9010`)
105/// and the data byte (set by writes to `$9030` after `$9010`). Per
106/// ADR-0004, this is **decoded and latched but not synthesized** in
107/// v0.9.x — the byte stream sits available for a future v1.x OPLL
108/// integration, and save-state round-trip works in both directions
109/// without an audio backend.
110#[derive(Clone)]
111struct Vrc7AudioRegs {
112 /// Last 6-bit register address written to `$9010`. YM2413 has 64
113 /// addressable registers; VRC7 exposes a 6-channel subset.
114 addr_latch: u8,
115 /// Last data byte written to `$9030`. Available for inspection /
116 /// equivalence testing against a future OPLL backend.
117 data_latch: u8,
118 /// 64-entry shadow of the most recent data written to each OPLL
119 /// register address. A future synthesizer reads this on demand
120 /// (e.g. on key-on) to seed channel state without re-running the
121 /// register-write history. Sized at 64 to match the full YM2413
122 /// register space (the chip's 6 channels use $10-$15 / $20-$25 /
123 /// $30-$35; instrument bytes are at $00-$07).
124 regs: [u8; 64],
125 /// Mirror of `$E000` bit 7 (expansion-sound silence). When set, a
126 /// future synthesizer's output is forced to zero; banking + IRQ
127 /// are unaffected.
128 silenced: bool,
129}
130
131impl Default for Vrc7AudioRegs {
132 fn default() -> Self {
133 Self {
134 addr_latch: 0,
135 data_latch: 0,
136 regs: [0u8; 64],
137 silenced: false,
138 }
139 }
140}
141
142/// VRC7 (Mapper 85). Banking + IRQ + (deferred per ADR-0004) FM audio
143/// surface for Lagrange Point.
144pub struct Vrc7 {
145 prg_rom: Box<[u8]>,
146 chr_rom: Box<[u8]>,
147 vram: Box<[u8]>,
148 chr_is_ram: bool,
149
150 /// 8 KiB PRG bank at $8000-$9FFF.
151 prg_0: u8,
152 /// 8 KiB PRG bank at $A000-$BFFF.
153 prg_1: u8,
154 /// 8 KiB PRG bank at $C000-$DFFF.
155 prg_2: u8,
156 /// 1 KiB CHR banks at $0000-$1FFF (one entry per KiB).
157 chr: [u8; 8],
158 mirroring: Mirroring,
159
160 // IRQ counter (identical shape to VRC6's).
161 irq_latch: u8,
162 irq_counter: u8,
163 irq_enabled: bool,
164 irq_enable_after_ack: bool,
165 irq_mode_scanline: bool,
166 irq_prescaler: i32,
167 irq_pending: bool,
168
169 /// PRG-RAM enable (bit 6 of `$E000`). When clear, `$6000-$7FFF`
170 /// reads/writes are ignored.
171 prg_ram_enable: bool,
172
173 /// 8 KiB WRAM at `$6000-$7FFF`. Lagrange Point's boot routine runs a
174 /// write-then-read-back self-test on this region (`STA ($00),Y` /
175 /// `CMP ($00),Y` with `$00/$01 = $6000`); without backing storage the
176 /// read-back always returned 0, the compare failed, and the game
177 /// jumped to its lockup loop at `$EC2F` (blank gray screen — it never
178 /// reaches CHR-RAM / nametable upload). Backed now, mirroring the
179 /// VRC2/VRC4 WRAM fix (T-60-003b).
180 prg_ram: Box<[u8]>,
181
182 /// Audio register surface. Decoded and latched in v0.9.x; not yet
183 /// synthesized (see ADR-0004).
184 audio: Vrc7AudioRegs,
185
186 /// OPLL FM synthesizer. Lives behind the `mapper-audio` feature
187 /// to keep the no_std cross-compile cheap; when the feature is
188 /// off, `mix_audio` returns 0 unconditionally (matching the
189 /// pre-v1.1.0 ADR-0004 deferred state).
190 #[cfg(feature = "mapper-audio")]
191 opll: rustynes_apu::Opll,
192
193 /// CPU-cycle counter for the OPLL native sample rate. NES NTSC
194 /// CPU runs at 1,789,773 Hz; the OPLL native rate is 49,716 Hz.
195 /// `1789773 / 49716 ≈ 35.997` — we tick the OPLL every 36 CPU
196 /// cycles, which is correct to 0.008% (< 1 Hz tuning drift).
197 #[cfg(feature = "mapper-audio")]
198 opll_clock_counter: u16,
199
200 /// Latest OPLL sample. The mapper holds this between OPLL ticks
201 /// (every 36 CPU cycles) so `mix_audio` calls in between return
202 /// the most-recent value. The APU's band-limited synthesis
203 /// handles the rate conversion from OPLL's 49,716 Hz to the
204 /// host sample rate.
205 #[cfg(feature = "mapper-audio")]
206 last_opll_sample: i16,
207}
208
209impl Vrc7 {
210 /// Construct a new VRC7 mapper.
211 ///
212 /// # Errors
213 ///
214 /// Returns [`MapperError::Invalid`] if the PRG-ROM size is not a
215 /// non-zero multiple of 8 KiB or the CHR-ROM size is not a
216 /// multiple of 1 KiB.
217 pub fn new(
218 prg_rom: Box<[u8]>,
219 chr_rom: Box<[u8]>,
220 mirroring: Mirroring,
221 ) -> Result<Self, MapperError> {
222 if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
223 return Err(MapperError::Invalid(format!(
224 "VRC7 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
225 prg_rom.len()
226 )));
227 }
228 let chr_is_ram = chr_rom.is_empty();
229 let chr: Box<[u8]> = if chr_is_ram {
230 vec![0u8; CHR_BANK_8K].into_boxed_slice()
231 } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
232 chr_rom
233 } else {
234 return Err(MapperError::Invalid(format!(
235 "VRC7 CHR-ROM size {} is not a multiple of 1 KiB",
236 chr_rom.len()
237 )));
238 };
239 Ok(Self {
240 prg_rom,
241 chr_rom: chr,
242 vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
243 chr_is_ram,
244 prg_0: 0,
245 prg_1: 0,
246 prg_2: 0,
247 chr: [0; 8],
248 mirroring,
249 irq_latch: 0,
250 irq_counter: 0,
251 irq_enabled: false,
252 irq_enable_after_ack: false,
253 irq_mode_scanline: false,
254 irq_prescaler: 341,
255 irq_pending: false,
256 prg_ram_enable: false,
257 prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
258 audio: Vrc7AudioRegs::default(),
259 #[cfg(feature = "mapper-audio")]
260 opll: rustynes_apu::Opll::new(rustynes_apu::OpllChipType::Vrc7),
261 #[cfg(feature = "mapper-audio")]
262 opll_clock_counter: 0,
263 #[cfg(feature = "mapper-audio")]
264 last_opll_sample: 0,
265 })
266 }
267
268 fn prg_offset(&self, addr: u16) -> usize {
269 let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
270 let last1 = total_8k - 1;
271 let (bank, off_in_8k) = match addr {
272 0x8000..=0x9FFF => (self.prg_0 as usize, addr as usize & 0x1FFF),
273 0xA000..=0xBFFF => (self.prg_1 as usize, addr as usize & 0x1FFF),
274 0xC000..=0xDFFF => (self.prg_2 as usize, addr as usize & 0x1FFF),
275 0xE000..=0xFFFF => (last1, addr as usize & 0x1FFF),
276 _ => return 0,
277 };
278 (bank % total_8k) * PRG_BANK_8K + off_in_8k
279 }
280
281 fn chr_offset(&self, addr: u16) -> usize {
282 let addr = (addr & 0x1FFF) as usize;
283 let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
284 let slot = addr / CHR_BANK_1K;
285 let bank = (self.chr[slot] as usize) % total_1k;
286 bank * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1))
287 }
288
289 fn clock_irq_counter(&mut self) {
290 if self.irq_counter == 0xFF {
291 self.irq_counter = self.irq_latch;
292 self.irq_pending = true;
293 } else {
294 self.irq_counter = self.irq_counter.wrapping_add(1);
295 }
296 }
297
298 /// Decode mirroring from the low 2 bits of `$E000`. Per NESdev
299 /// "VRC7": `00` = vertical, `01` = horizontal, `10` = single-screen
300 /// A, `11` = single-screen B.
301 fn decode_mirroring(value: u8) -> Mirroring {
302 match value & 0x03 {
303 0 => Mirroring::Vertical,
304 1 => Mirroring::Horizontal,
305 2 => Mirroring::SingleScreenA,
306 _ => Mirroring::SingleScreenB,
307 }
308 }
309}
310
311impl Mapper for Vrc7 {
312 fn sram(&self) -> &[u8] {
313 &self.prg_ram
314 }
315 fn sram_mut(&mut self) -> &mut [u8] {
316 &mut self.prg_ram
317 }
318 // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio
319 // (the audio hook only exists under the `mapper-audio` feature).
320 fn caps(&self) -> MapperCaps {
321 MapperCaps {
322 cpu_cycle_hook: true,
323 audio: cfg!(feature = "mapper-audio"),
324 frame_event_hook: false,
325 irq_source: true,
326 }
327 }
328
329 fn cpu_read(&mut self, addr: u16) -> u8 {
330 match addr {
331 0x6000..=0x7FFF => {
332 // 8 KiB WRAM. Backed by storage so Lagrange Point's boot
333 // RAM self-test (write then read-back) succeeds. The
334 // enable bit (`$E000` bit 6) is modelled for completeness
335 // but does not gate the backing store: the game toggles it
336 // around the test, and real VRC7 emulators keep the WRAM
337 // continuously addressable.
338 self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()]
339 }
340 0x8000..=0xFFFF => {
341 let off = self.prg_offset(addr);
342 self.prg_rom[off % self.prg_rom.len()]
343 }
344 _ => 0,
345 }
346 }
347
348 fn cpu_write(&mut self, addr: u16, value: u8) {
349 // VRC7 register decoding tolerates both A3 (`$_008`) and A4
350 // (`$_010`) variants per board revision. The high-nibble
351 // selector picks the register family; within each family the
352 // bank/IRQ/audio variant is chosen by bits 4-5 of the low byte.
353 match addr & 0xF000 {
354 0x6000 | 0x7000 => {
355 // 8 KiB WRAM write (backed; see cpu_read).
356 let len = self.prg_ram.len();
357 self.prg_ram[(addr - 0x6000) as usize % len] = value;
358 }
359 0x8000 => {
360 // $8000 selects PRG bank 0; $8010 / $8008 selects bank 1.
361 if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
362 self.prg_1 = value & 0x3F;
363 } else {
364 self.prg_0 = value & 0x3F;
365 }
366 }
367 0x9000 => {
368 // $9000 (and $9008 mirror) -> PRG bank 2.
369 // $9010 (and $9018 mirror) -> OPLL register address latch.
370 // $9030 (and $9038 mirror) -> OPLL register data write.
371 let sub = addr & 0x0030;
372 if sub == 0x0010 {
373 self.audio.addr_latch = value & 0x3F;
374 } else if sub == 0x0030 {
375 let idx = (self.audio.addr_latch & 0x3F) as usize;
376 self.audio.regs[idx] = value;
377 self.audio.data_latch = value;
378 // Forward to the OPLL synthesizer. The address was
379 // latched on the previous `$9010` write; per
380 // `Vrc7Audio.h` (Mesen2) this is the canonical
381 // shape — `WriteReg($9010, addr); WriteReg($9030, data)`.
382 // The 7-cycle inter-write delay Lagrange Point
383 // observes on real hardware is enforced by the CPU
384 // emitter; the chip latches each independently.
385 #[cfg(feature = "mapper-audio")]
386 self.opll.write_reg(self.audio.addr_latch, value);
387 } else {
388 // $9000 / $9008 / $9020 / $9028 -> PRG bank 2.
389 self.prg_2 = value & 0x3F;
390 }
391 }
392 0xA000 => {
393 // CHR banks 0 / 1.
394 if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
395 self.chr[1] = value;
396 } else {
397 self.chr[0] = value;
398 }
399 }
400 0xB000 => {
401 // CHR banks 2 / 3.
402 if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
403 self.chr[3] = value;
404 } else {
405 self.chr[2] = value;
406 }
407 }
408 0xC000 => {
409 // CHR banks 4 / 5.
410 if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
411 self.chr[5] = value;
412 } else {
413 self.chr[4] = value;
414 }
415 }
416 0xD000 => {
417 // CHR banks 6 / 7.
418 if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
419 self.chr[7] = value;
420 } else {
421 self.chr[6] = value;
422 }
423 }
424 0xE000 => {
425 // $E000: mirroring (bits 1-0), WRAM enable (bit 6),
426 // expansion-sound silence (bit 7).
427 // $E008 / $E010: IRQ latch.
428 if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
429 self.irq_latch = value;
430 } else {
431 self.mirroring = Self::decode_mirroring(value);
432 self.prg_ram_enable = (value & 0x40) != 0;
433 self.audio.silenced = (value & 0x80) != 0;
434 }
435 }
436 0xF000 => {
437 // $F000: IRQ control. $F008/$F010: IRQ acknowledge.
438 if (addr & 0x0010) != 0 || (addr & 0x0008) != 0 {
439 self.irq_pending = false;
440 self.irq_enabled = self.irq_enable_after_ack;
441 } else {
442 self.irq_enable_after_ack = (value & 0x01) != 0;
443 self.irq_enabled = (value & 0x02) != 0;
444 self.irq_mode_scanline = (value & 0x04) == 0;
445 if self.irq_enabled {
446 self.irq_counter = self.irq_latch;
447 self.irq_prescaler = 341;
448 }
449 self.irq_pending = false;
450 }
451 }
452 _ => {}
453 }
454 }
455
456 fn ppu_read(&mut self, addr: u16) -> u8 {
457 let addr = addr & 0x3FFF;
458 match addr {
459 0x0000..=0x1FFF => {
460 let off = self.chr_offset(addr);
461 self.chr_rom[off % self.chr_rom.len()]
462 }
463 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
464 _ => 0,
465 }
466 }
467
468 fn ppu_write(&mut self, addr: u16, value: u8) {
469 let addr = addr & 0x3FFF;
470 match addr {
471 0x0000..=0x1FFF => {
472 if self.chr_is_ram {
473 // Must go through the SAME banked offset `ppu_read` uses
474 // (`chr_offset`), not the raw PPU address — otherwise a
475 // game that banks CHR-RAM (Lagrange Point) writes tiles to
476 // one offset and reads them back from another, leaving the
477 // pattern tables effectively blank.
478 let off = self.chr_offset(addr);
479 let len = self.chr_rom.len();
480 self.chr_rom[off % len] = value;
481 }
482 }
483 0x2000..=0x3EFF => {
484 let off = nametable_offset(addr, self.mirroring) % self.vram.len();
485 self.vram[off] = value;
486 }
487 _ => {}
488 }
489 }
490
491 fn notify_cpu_cycle(&mut self) {
492 // Advance the OPLL synthesizer every 36 CPU cycles, matching
493 // the NES NTSC CPU clock / OPLL native sample rate ratio.
494 // Holds the produced sample in `last_opll_sample` for the
495 // bus's per-APU-sample `mix_audio` calls.
496 #[cfg(feature = "mapper-audio")]
497 {
498 self.opll_clock_counter = self.opll_clock_counter.wrapping_add(1);
499 if self.opll_clock_counter >= 36 {
500 self.opll_clock_counter = 0;
501 self.last_opll_sample = self.opll.calc();
502 }
503 }
504
505 if !self.irq_enabled {
506 return;
507 }
508 if self.irq_mode_scanline {
509 self.irq_prescaler -= 3;
510 if self.irq_prescaler <= 0 {
511 self.irq_prescaler += 341;
512 self.clock_irq_counter();
513 }
514 } else {
515 self.clock_irq_counter();
516 }
517 }
518
519 /// Mix the current OPLL sample into the APU's external-audio
520 /// channel. Returns 0 when the cartridge's expansion-sound
521 /// silence bit (`$E000` bit 7) is set OR the `mapper-audio`
522 /// feature is off; otherwise returns the most-recent OPLL
523 /// sample in the i16 range [-4095, 4095] (the chip's
524 /// 13-bit DAC scaled to 14-bit signed via `<< 1` in the
525 /// `lookup_exp_table` final stage).
526 #[cfg(feature = "mapper-audio")]
527 fn mix_audio(&mut self) -> i32 {
528 if self.audio.silenced {
529 0
530 } else {
531 i32::from(self.last_opll_sample)
532 }
533 }
534
535 fn irq_pending(&self) -> bool {
536 self.irq_pending
537 }
538
539 fn current_mirroring(&self) -> Mirroring {
540 self.mirroring
541 }
542
543 fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
544 let mut info = crate::mapper::MapperDebugInfo {
545 mapper_id: 85,
546 name: "VRC7".into(),
547 mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
548 ..Default::default()
549 };
550 info.prg_banks
551 .push(("PRG0".into(), format!("{:#04x}", self.prg_0)));
552 info.prg_banks
553 .push(("PRG1".into(), format!("{:#04x}", self.prg_1)));
554 info.prg_banks
555 .push(("PRG2".into(), format!("{:#04x}", self.prg_2)));
556 for (i, b) in self.chr.iter().enumerate() {
557 info.chr_banks
558 .push((format!("CHR{i}"), format!("{b:#04x}")));
559 }
560 info.irq_state
561 .push(("latch".into(), format!("{:#04x}", self.irq_latch)));
562 info.irq_state
563 .push(("counter".into(), format!("{:#04x}", self.irq_counter)));
564 info.irq_state
565 .push(("enabled".into(), format!("{}", self.irq_enabled)));
566 info.irq_state
567 .push(("pending".into(), format!("{}", self.irq_pending)));
568 info.extra.push((
569 "audio".into(),
570 "deferred (ADR-0004; mapper 85 audio = silent)".into(),
571 ));
572 info.extra.push((
573 "audio_addr".into(),
574 format!("{:#04x}", self.audio.addr_latch),
575 ));
576 info.extra.push((
577 "audio_data".into(),
578 format!("{:#04x}", self.audio.data_latch),
579 ));
580 info
581 }
582
583 fn save_state(&self) -> Vec<u8> {
584 // v1 layout (audio synthesis deferred per ADR-0004):
585 // version(1)
586 // prg_0 / prg_1 / prg_2 (3)
587 // chr[0..8] (8)
588 // mirroring(1) + prg_ram_enable(1)
589 // irq_latch(1) + irq_counter(1) + irq_enabled(1) +
590 // irq_enable_after_ack(1) + irq_mode_scanline(1) +
591 // irq_prescaler(4 le) + irq_pending(1)
592 // audio addr_latch(1) + data_latch(1) + silenced(1) +
593 // audio.regs[0..64] (64)
594 // vram (2 KiB)
595 //
596 // v2 (v2.3.7) is that commit: it appends, after the VRAM,
597 // opll_clock_counter(2 le) + last_opll_sample(2 le)
598 // + opll blob (OPLL_SNAPSHOT_LEN bytes, self-versioned)
599 // closing the `docs/accuracy-ledger.md` row that recorded the FM
600 // voice resuming from arbitrary envelope + phase state after a
601 // rewind / rollback / TAS restore. `load_state` accepts v3 and v4
602 // only; v1 and v2 blobs are refused since v2.9.8 (ADR 0042).
603 // version(1) + prg(3) + chr(8) + mirroring(1) + prg_ram_enable(1)
604 // + irq_latch(1) + irq_counter(1) + irq_enabled(1)
605 // + irq_enable_after_ack(1) + irq_mode_scanline(1)
606 // + irq_prescaler(4) + irq_pending(1)
607 // + audio addr_latch(1) + data_latch(1) + silenced(1) + regs(64)
608 // = 1 + 3 + 8 + 1 + 1 + 5 + 5 + 67 = 91
609 let scalar_len = 1 + 3 + 8 + 1 + 1 + 10 + 3 + 64;
610 // The v2 tail only exists on a `mapper-audio` build, so only reserve for
611 // it there — a no-audio build would otherwise over-allocate ~1.3 KiB on
612 // every save for a tail it never writes.
613 #[cfg(feature = "mapper-audio")]
614 let mut out = Vec::with_capacity(
615 scalar_len + self.vram.len() + VRC7_V2_TAIL_LEN + self.ram_block_len(),
616 );
617 #[cfg(not(feature = "mapper-audio"))]
618 let mut out = Vec::with_capacity(scalar_len + self.vram.len() + self.ram_block_len());
619 out.push(VRC7_SECTION_VERSION); // version
620 out.push(self.prg_0);
621 out.push(self.prg_1);
622 out.push(self.prg_2);
623 out.extend_from_slice(&self.chr);
624 out.push(self.mirroring as u8);
625 out.push(u8::from(self.prg_ram_enable));
626 out.push(self.irq_latch);
627 out.push(self.irq_counter);
628 out.push(u8::from(self.irq_enabled));
629 out.push(u8::from(self.irq_enable_after_ack));
630 out.push(u8::from(self.irq_mode_scanline));
631 out.extend_from_slice(&self.irq_prescaler.to_le_bytes());
632 out.push(u8::from(self.irq_pending));
633 out.push(self.audio.addr_latch);
634 out.push(self.audio.data_latch);
635 out.push(u8::from(self.audio.silenced));
636 out.extend_from_slice(&self.audio.regs);
637 out.extend_from_slice(&self.vram);
638 // --- v2 tail: the live synthesizer ---
639 #[cfg(feature = "mapper-audio")]
640 {
641 out.extend_from_slice(&self.opll_clock_counter.to_le_bytes());
642 out.extend_from_slice(&self.last_opll_sample.to_le_bytes());
643 out.extend_from_slice(&self.opll.snapshot());
644 }
645 // --- v3/v4 tail: the on-cart RAM, after the synthesizer tail ---
646 out.extend_from_slice(&self.prg_ram);
647 if self.chr_is_ram {
648 out.extend_from_slice(&self.chr_rom);
649 }
650 out
651 }
652
653 fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
654 // version(1) + prg(3) + chr(8) + mirroring(1) + prg_ram_enable(1)
655 // + irq_latch(1) + irq_counter(1) + irq_enabled(1)
656 // + irq_enable_after_ack(1) + irq_mode_scanline(1)
657 // + irq_prescaler(4) + irq_pending(1)
658 // + audio addr_latch(1) + data_latch(1) + silenced(1) + regs(64)
659 // = 1 + 3 + 8 + 1 + 1 + 5 + 5 + 67 = 91
660 let scalar_len = 1 + 3 + 8 + 1 + 1 + 10 + 3 + 64;
661 let core_expected = scalar_len + self.vram.len();
662 if data.len() < core_expected {
663 return Err(MapperError::WrongLength {
664 expected: core_expected,
665 got: data.len(),
666 });
667 }
668 let version = data[0];
669 // Both READABLE versions, spelled as literals — deliberately NOT
670 // `VRC7_SECTION_VERSION`, which is what this WRITES and differs by
671 // build. Keying the accept set on the write version once made the
672 // condition collapse on a no-audio build, so it REJECTED the audio
673 // build's blob outright — the exact opposite of the
674 // validate-then-ignore portability ADR 0004 asks for, and of what the
675 // comment on `VRC7_SECTION_VERSION` claimed. What a build can write and
676 // what it must accept are different sets; only the first varies by
677 // feature. Caught in review by two independent bots.
678 //
679 // v1 and v2 (no RAM tail) are refused since v2.9.8 (ADR 0042); they
680 // used to load with the RAM left as it was.
681 if !matches!(version, 3 | 4) {
682 return Err(MapperError::UnsupportedVersion(version));
683 }
684 // Which optional tails this version carries: v4 has the synthesizer
685 // tail; both have the RAM tail, after it.
686 let has_audio_tail = version == 4;
687 let ram_len = self.ram_block_len();
688 let audio_len = if has_audio_tail { VRC7_V2_TAIL_LEN } else { 0 };
689 // Strict about the whole length, validated before anything is
690 // written.
691 if data.len() != core_expected + audio_len + ram_len {
692 return Err(MapperError::WrongLength {
693 expected: core_expected + audio_len + ram_len,
694 got: data.len(),
695 });
696 }
697
698 // VALIDATE EVERYTHING BEFORE MUTATING ANYTHING.
699 //
700 // The v2 tail introduced a failure that can occur AFTER the core fields
701 // have been assigned, which the v1 layout could not: v1 validated its
702 // whole length and version up front, so once it started writing it could
703 // not fail. A truncated or corrupt v2 tail used to return `Err` with
704 // `prg_0`, `chr`, the IRQ state and 2 KiB of VRAM already overwritten --
705 // a mapper left in a state that is neither the old one nor the new one,
706 // while the caller reports the load as failed and keeps running.
707 //
708 // `Opll::restore` was already atomic internally, which is exactly what
709 // made this easy to miss: the guarantee existed one level down and was
710 // silently discarded one level up. Parse into a temporary here, so this
711 // function has the same all-or-nothing property its own comments claim.
712 // Caught in review; the truncation test missed it because it asserted on
713 // the return value and never on the target.
714 #[cfg(feature = "mapper-audio")]
715 let staged_opll = if has_audio_tail {
716 let tail = &data[core_expected..];
717 if tail.len() < VRC7_V2_TAIL_LEN {
718 return Err(MapperError::WrongLength {
719 expected: core_expected + VRC7_V2_TAIL_LEN,
720 got: data.len(),
721 });
722 }
723 let mut opll = self.opll.clone();
724 // Bounded to the synthesizer's own bytes: a v4 blob continues with
725 // the RAM tail, which is not the OPLL's to read.
726 opll.restore(&tail[4..VRC7_V2_TAIL_LEN])
727 .map_err(|e| MapperError::Invalid(format!("VRC7 OPLL state: {e}")))?;
728 Some((
729 u16::from_le_bytes(tail[0..2].try_into().expect("length checked above")),
730 i16::from_le_bytes(tail[2..4].try_into().expect("length checked above")),
731 opll,
732 ))
733 } else {
734 None
735 };
736 // A no-audio build has no synthesizer to stage into, but must still
737 // reject a truncated tail identically -- the same blob has to be
738 // accepted or refused the same way on every build.
739 #[cfg(not(feature = "mapper-audio"))]
740 // Written as an addition rather than `data.len() - core_expected < ..`:
741 // the subtraction cannot underflow TODAY (the length guard at the top of
742 // this function already proved `data.len() >= core_expected`), but it is
743 // one moved guard away from being able to, and an underflow here would
744 // wrap to a huge value and silently ACCEPT a truncated blob rather than
745 // panicking. Not worth leaving a correctness proof spread across two
746 // distant statements to save an addition.
747 if has_audio_tail && data.len() < core_expected + VRC7_V2_TAIL_LEN {
748 return Err(MapperError::WrongLength {
749 expected: core_expected + VRC7_V2_TAIL_LEN,
750 got: data.len(),
751 });
752 }
753
754 self.prg_0 = data[1];
755 self.prg_1 = data[2];
756 self.prg_2 = data[3];
757 self.chr.copy_from_slice(&data[4..12]);
758 self.mirroring = match data[12] {
759 0 => Mirroring::Horizontal,
760 1 => Mirroring::Vertical,
761 2 => Mirroring::SingleScreenA,
762 3 => Mirroring::SingleScreenB,
763 4 => Mirroring::FourScreen,
764 5 => Mirroring::MapperControlled,
765 other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
766 };
767 self.prg_ram_enable = data[13] != 0;
768 self.irq_latch = data[14];
769 self.irq_counter = data[15];
770 self.irq_enabled = data[16] != 0;
771 self.irq_enable_after_ack = data[17] != 0;
772 self.irq_mode_scanline = data[18] != 0;
773 self.irq_prescaler = i32::from_le_bytes(
774 data[19..23]
775 .try_into()
776 .map_err(|_| MapperError::Invalid("prescaler".into()))?,
777 );
778 self.irq_pending = data[23] != 0;
779 self.audio.addr_latch = data[24];
780 self.audio.data_latch = data[25];
781 self.audio.silenced = data[26] != 0;
782 self.audio.regs.copy_from_slice(&data[27..91]);
783 self.vram.copy_from_slice(&data[91..91 + self.vram.len()]);
784
785 // --- v4 tail: the live synthesizer ---
786 //
787 // A v3 blob (a no-audio build's) has none, and the synthesizer keeps
788 // running from the state it holds. Commit the already-validated one. Infallible by construction:
789 // every way this could fail was exercised above, before the first write.
790 #[cfg(feature = "mapper-audio")]
791 if let Some((counter, sample, opll)) = staged_opll {
792 self.opll_clock_counter = counter;
793 self.last_opll_sample = sample;
794 self.opll = opll;
795 }
796 // --- the RAM tail: the on-cart RAM ---
797 //
798 // The length was proven exact above, before the first write.
799 let ram_off = core_expected + audio_len;
800 let (prg, chr) = data[ram_off..].split_at(self.prg_ram.len());
801 self.prg_ram.copy_from_slice(prg);
802 if self.chr_is_ram {
803 self.chr_rom.copy_from_slice(chr);
804 }
805 Ok(())
806 }
807}
808
809impl Vrc7 {
810 /// Bytes the v3/v4 tail adds: the 8 KiB PRG-RAM, plus the 8 KiB CHR-RAM
811 /// when the cartridge has no CHR-ROM. Derived from the loaded ROM, so a
812 /// save and its load (same ROM, checked by the `.rns` hash tag) agree.
813 fn ram_block_len(&self) -> usize {
814 self.prg_ram.len()
815 + if self.chr_is_ram {
816 self.chr_rom.len()
817 } else {
818 0
819 }
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826
827 fn synth(banks_8k: usize) -> Box<[u8]> {
828 let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
829 for b in 0..banks_8k {
830 v[b * PRG_BANK_8K] = b as u8;
831 }
832 v.into_boxed_slice()
833 }
834
835 fn synth_chr(banks_1k: usize) -> Box<[u8]> {
836 let mut v = vec![0u8; banks_1k * CHR_BANK_1K];
837 for b in 0..banks_1k {
838 v[b * CHR_BANK_1K] = b as u8;
839 }
840 v.into_boxed_slice()
841 }
842
843 fn vrc7_default() -> Vrc7 {
844 // 8 × 8 KiB PRG (bank index byte at offset 0 of each bank to make
845 // the read path observable) + 16 × 1 KiB CHR (likewise).
846 Vrc7::new(synth(8), synth_chr(16), Mirroring::Vertical).unwrap()
847 }
848
849 #[test]
850 fn vrc7_prg_banking_three_switchable_plus_fixed_last() {
851 let mut m = vrc7_default();
852 // $8000 = PRG bank 0 (window $8000-$9FFF). Pick bank 5.
853 m.cpu_write(0x8000, 5);
854 // $8010 = PRG bank 1 ($A000-$BFFF). Pick bank 3.
855 m.cpu_write(0x8010, 3);
856 // $9000 = PRG bank 2 ($C000-$DFFF). Pick bank 7.
857 m.cpu_write(0x9000, 7);
858 // Read at the start of each window returns the synth's bank-index
859 // byte (bank index lives at offset 0 of each 8 KiB bank).
860 assert_eq!(m.cpu_read(0x8000), 5);
861 assert_eq!(m.cpu_read(0xA000), 3);
862 assert_eq!(m.cpu_read(0xC000), 7);
863 // $E000-$FFFF is fixed to the LAST bank (synth has 8 banks → 7).
864 assert_eq!(m.cpu_read(0xE000), 7);
865 }
866
867 #[test]
868 fn vrc7_prg_banking_accepts_a3_a4_mirror() {
869 // $8008 is the A3 mirror of $8010 → both select PRG bank 1.
870 let mut m = vrc7_default();
871 m.cpu_write(0x8008, 4);
872 assert_eq!(m.cpu_read(0xA000), 4);
873 m.cpu_write(0x8010, 2);
874 assert_eq!(m.cpu_read(0xA000), 2);
875 }
876
877 #[test]
878 fn vrc7_chr_banking_all_eight_slots() {
879 // CHR banks 0..=7 are addressable at $A000 / $A010 / $B000 /
880 // $B010 / $C000 / $C010 / $D000 / $D010. Each 1 KiB CHR bank
881 // in the synth ROM carries its bank index at offset 0.
882 let mut m = vrc7_default();
883 let writes = [
884 (0xA000u16, 1u8, 0x0000u16),
885 (0xA010, 2, 0x0400),
886 (0xB000, 3, 0x0800),
887 (0xB010, 4, 0x0C00),
888 (0xC000, 5, 0x1000),
889 (0xC010, 6, 0x1400),
890 (0xD000, 7, 0x1800),
891 (0xD010, 8, 0x1C00),
892 ];
893 for (addr, bank, _) in writes {
894 m.cpu_write(addr, bank);
895 }
896 for (_, bank, ppu_addr) in writes {
897 assert_eq!(m.ppu_read(ppu_addr), bank, "CHR slot for {ppu_addr:#x}");
898 }
899 }
900
901 #[test]
902 fn vrc7_mirroring_decode_from_e000_low_bits() {
903 let mut m = vrc7_default();
904 // 00 = Vertical (the default).
905 m.cpu_write(0xE000, 0b0000_0000);
906 assert_eq!(m.current_mirroring(), Mirroring::Vertical);
907 // 01 = Horizontal.
908 m.cpu_write(0xE000, 0b0000_0001);
909 assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
910 // 10 = SingleScreen A.
911 m.cpu_write(0xE000, 0b0000_0010);
912 assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
913 // 11 = SingleScreen B.
914 m.cpu_write(0xE000, 0b0000_0011);
915 assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
916 }
917
918 #[test]
919 fn vrc7_irq_counter_cycle_mode_pending() {
920 // CPU-cycle mode: counter increments every CPU cycle; on $FF
921 // it reloads from latch and asserts IRQ. Same shape as VRC6.
922 let mut m = vrc7_default();
923 // Latch: 0xFE (so we need only 2 ticks to wrap from 0xFE -> 0xFF -> 0x00 + pending).
924 m.cpu_write(0xE008, 0xFE); // $E008 = IRQ latch
925 // Control: enable + cycle mode (mode bit 2 = 1 means CPU cycle).
926 // Bit 0 = enable_after_ack; bit 1 = enable; bit 2 = mode (1=cycle, 0=scanline).
927 m.cpu_write(0xF000, 0b0000_0110);
928 // After enable, counter = latch = 0xFE. Ticking until pending:
929 // 0xFE -> 0xFF (clock 1), pending fires (clock 2 reloads from latch).
930 m.notify_cpu_cycle();
931 assert!(!m.irq_pending(), "after 1 cycle, counter only at 0xFF");
932 m.notify_cpu_cycle();
933 assert!(m.irq_pending(), "after 2 cycles, pending should be set");
934 }
935
936 #[test]
937 fn vrc7_irq_ack_clears_pending_and_restores_enable_state() {
938 // After IRQ fires, $F010 ack clears pending and restores
939 // enable from enable_after_ack. Match the VRC6 contract.
940 let mut m = vrc7_default();
941 m.cpu_write(0xE008, 0xFE);
942 m.cpu_write(0xF000, 0b0000_0111); // enable_after_ack=1, enable=1, cycle mode
943 m.notify_cpu_cycle();
944 m.notify_cpu_cycle();
945 assert!(m.irq_pending());
946 m.cpu_write(0xF010, 0); // ack
947 assert!(!m.irq_pending());
948 assert!(m.irq_enabled, "enable should be restored from after_ack");
949 }
950
951 #[test]
952 fn vrc7_audio_register_latch_round_trip() {
953 // Per ADR-0004 the synthesizer is deferred, but the register
954 // surface must still latch state cleanly. This test pins the
955 // contract a future v1.x OPLL integration will read from.
956 let mut m = vrc7_default();
957 m.cpu_write(0x9010, 0x10); // OPLL register address = 0x10
958 assert_eq!(m.audio.addr_latch, 0x10);
959 m.cpu_write(0x9030, 0x42); // OPLL data byte
960 assert_eq!(m.audio.data_latch, 0x42);
961 assert_eq!(m.audio.regs[0x10], 0x42);
962 // A second address+data pair: write 0x30 (channel-1 volume +
963 // instrument select) then a different data byte.
964 m.cpu_write(0x9010, 0x30);
965 m.cpu_write(0x9030, 0x5F); // top nibble = inst 5, low nibble = vol 0xF
966 assert_eq!(m.audio.regs[0x30], 0x5F);
967 // Earlier write at 0x10 is preserved (independent slots).
968 assert_eq!(m.audio.regs[0x10], 0x42);
969 }
970
971 #[test]
972 fn vrc7_audio_custom_instrument_bytes_route_to_registers_0_through_7() {
973 // The 8 custom-instrument bytes live at OPLL registers $00-$07.
974 // Confirm they land in the right slots when written through
975 // the $9010 / $9030 protocol.
976 let mut m = vrc7_default();
977 for i in 0..8u8 {
978 m.cpu_write(0x9010, i);
979 m.cpu_write(0x9030, 0xA0 | i); // distinct payload per slot
980 assert_eq!(m.audio.regs[i as usize], 0xA0 | i);
981 }
982 }
983
984 #[test]
985 fn vrc7_mix_audio_silent_with_no_key_on() {
986 // Sprint 1.2 (v1.1.0): OPLL is wired but no channel has been
987 // keyed on — every slot's envelope sits at EG_MUTE, so every
988 // OPLL sample is 0. The mix_audio output should therefore be
989 // 0 across the entire register-surface scan.
990 let mut m = vrc7_default();
991 for reg in 0..=0x35u8 {
992 m.cpu_write(0x9010, reg);
993 m.cpu_write(0x9030, 0x00); // zero-fill — no key-on bits
994 }
995 // Tick the OPLL several times to confirm calc() also returns 0.
996 for _ in 0..200 {
997 m.notify_cpu_cycle();
998 }
999 assert_eq!(
1000 m.mix_audio(),
1001 0,
1002 "VRC7 mix_audio must be silent without key-on; got non-zero"
1003 );
1004 }
1005
1006 #[test]
1007 fn vrc7_mix_audio_silenced_by_e000_bit7() {
1008 // Even with a keyed-on channel, the `$E000` expansion-sound
1009 // silence bit (bit 7) must force mix_audio to 0. Mesen2 calls
1010 // this the "muted" flag in Vrc7Audio.h.
1011 let mut m = vrc7_default();
1012 // Set up channel 0: instrument 1, fnum 256, block 4, key-on,
1013 // max volume (volume bits low = max — OPLL volume is attenuation).
1014 m.cpu_write(0x9010, 0x30); // $30 = inst/volume for ch 0
1015 m.cpu_write(0x9030, 0x10); // inst 1, volume 0 (loudest)
1016 m.cpu_write(0x9010, 0x10); // $10 = fnum low for ch 0
1017 m.cpu_write(0x9030, 0x00);
1018 m.cpu_write(0x9010, 0x20); // $20 = fnum high + block + key for ch 0
1019 m.cpu_write(0x9030, 0x35); // key-on bit set + block + fnum high
1020 // Tick enough cycles for the envelope to clear Damp → Attack.
1021 for _ in 0..16_384 {
1022 m.notify_cpu_cycle();
1023 }
1024 // Now flip the silence bit on `$E000`.
1025 m.cpu_write(0xE000, 0x80);
1026 assert_eq!(
1027 m.mix_audio(),
1028 0,
1029 "silenced VRC7 must mix to 0; got non-zero"
1030 );
1031 // Verify the OPLL still ticks (its internal state advances) —
1032 // re-clear silence and the audio should resume.
1033 m.cpu_write(0xE000, 0x00);
1034 // We don't assert non-zero here because the OPLL might have
1035 // landed on a zero-crossing this exact tick — just confirm
1036 // the silenced gate is the only thing stopping output.
1037 // (The non-zero output is covered by the next test.)
1038 }
1039
1040 #[test]
1041 fn vrc7_opll_register_writes_forwarded_on_data_write() {
1042 // `$9030` data writes must be forwarded to the OPLL's
1043 // register shadow. Verifies the integration point even
1044 // without ticking the synth.
1045 let mut m = vrc7_default();
1046 m.cpu_write(0x9010, 0x20); // address latch = $20
1047 m.cpu_write(0x9030, 0x55); // data write
1048 // Snapshot stores the byte in both the mapper's audio.regs
1049 // (for save-state round-trip) and the OPLL's register shadow.
1050 assert_eq!(m.audio.regs[0x20], 0x55);
1051 #[cfg(feature = "mapper-audio")]
1052 assert_eq!(
1053 m.opll.read_reg(0x20),
1054 0x55,
1055 "OPLL register shadow should mirror $9030 writes"
1056 );
1057 }
1058
1059 #[test]
1060 #[cfg(feature = "mapper-audio")]
1061 fn vrc7_keyed_on_channel_produces_nonzero_mix_within_one_envelope() {
1062 // End-to-end: configure channel 0 with VRC7 patch 1, key on,
1063 // run enough CPU cycles for Damp → Attack to progress past
1064 // EG_MUTE, and observe a non-zero mix_audio sample.
1065 let mut m = vrc7_default();
1066 // Channel 0 setup matching the OPLL unit test's manual setup.
1067 // $30 → bits 3-0 = volume (attenuation), bits 7-4 = instrument
1068 m.cpu_write(0x9010, 0x30);
1069 m.cpu_write(0x9030, 0x10); // inst=1, vol=0
1070 m.cpu_write(0x9010, 0x10);
1071 m.cpu_write(0x9030, 0x80); // fnum low byte
1072 m.cpu_write(0x9010, 0x20);
1073 m.cpu_write(0x9030, 0x35); // key-on + block(2) + fnum high(1)
1074 // Each OPLL sample = 36 CPU cycles. 16,384 CPU cycles = ~455
1075 // OPLL samples = ~9 ms of audio. Damp → Attack happens within
1076 // a few hundred OPLL samples for any non-saturated AR.
1077 // u32: `mix_audio` widened to i32 in v2.2.3 (A1).
1078 let mut peak_abs: u32 = 0;
1079 for _ in 0..16_384 {
1080 m.notify_cpu_cycle();
1081 let s = m.mix_audio();
1082 peak_abs = peak_abs.max(s.unsigned_abs());
1083 }
1084 assert!(
1085 peak_abs > 0,
1086 "expected non-zero VRC7 mix after key-on + 16k cycles; got peak_abs={peak_abs}"
1087 );
1088 }
1089
1090 #[test]
1091 #[cfg(feature = "mapper-audio")]
1092 fn vrc7_opll_ticks_every_36_cpu_cycles() {
1093 // The OPLL is clocked at NES NTSC CPU rate / 36. Verify the
1094 // internal counter rolls over exactly on the 36th call to
1095 // notify_cpu_cycle by watching eg_counter (which advances
1096 // once per OPLL tick inside `update_slots`).
1097 let mut m = vrc7_default();
1098 // No way to read eg_counter through the public API, but we
1099 // CAN read opll_clock_counter via direct field access in
1100 // this module-local test. After 35 cycles, counter = 35;
1101 // after 36, counter resets to 0 and the OPLL has advanced.
1102 for _ in 0..35 {
1103 m.notify_cpu_cycle();
1104 }
1105 assert_eq!(m.opll_clock_counter, 35);
1106 m.notify_cpu_cycle();
1107 assert_eq!(
1108 m.opll_clock_counter, 0,
1109 "counter should reset on 36th cycle"
1110 );
1111 }
1112
1113 #[test]
1114 fn vrc7_save_state_round_trip_preserves_banking_irq_and_audio_latches() {
1115 // v1 round-trip: configure banking, IRQ counter mid-state, and
1116 // audio register latches → save → reload into a fresh mapper
1117 // → all fields match.
1118 let mut m = vrc7_default();
1119 m.cpu_write(0x8000, 5);
1120 m.cpu_write(0x8010, 3);
1121 m.cpu_write(0x9000, 7);
1122 m.cpu_write(0xA000, 1);
1123 m.cpu_write(0xD010, 6);
1124 m.cpu_write(0xE000, 0b1100_0001); // Horizontal + WRAM enable + audio silenced
1125 m.cpu_write(0xE008, 0x80); // IRQ latch
1126 m.cpu_write(0xF000, 0b0000_0011); // enable + scanline mode
1127 // Audio register stream.
1128 m.cpu_write(0x9010, 0x30);
1129 m.cpu_write(0x9030, 0x5F);
1130 let blob = m.save_state();
1131 // v1 through v2.3.6; v2 since v2.3.7, which appends the live OPLL.
1132 // A build without `mapper-audio` has no synthesizer to describe and
1133 // still writes v1 — see `VRC7_SECTION_VERSION`.
1134 assert_eq!(blob[0], VRC7_SECTION_VERSION, "VRC7 save-state version tag");
1135
1136 let mut target = vrc7_default();
1137 target.load_state(&blob).unwrap();
1138 assert_eq!(target.cpu_read(0x8000), 5);
1139 assert_eq!(target.cpu_read(0xA000), 3);
1140 assert_eq!(target.cpu_read(0xC000), 7);
1141 assert_eq!(target.ppu_read(0x0000), 1);
1142 assert_eq!(target.ppu_read(0x1C00), 6);
1143 assert_eq!(target.current_mirroring(), Mirroring::Horizontal);
1144 assert!(target.prg_ram_enable);
1145 assert!(target.audio.silenced);
1146 assert_eq!(target.irq_latch, 0x80);
1147 assert!(target.irq_enabled);
1148 // We wrote 0b0000_0011 → bit 2 (mode) = 0 → scanline mode is on
1149 // (the predicate is `(value & 0x04) == 0`).
1150 assert!(target.irq_mode_scanline);
1151 assert_eq!(target.audio.regs[0x30], 0x5F);
1152 }
1153
1154 #[test]
1155 fn vrc7_save_state_rejects_unknown_version() {
1156 // Pre-v1 there is no VRC7 save-state; a future v1.x bumps to 2.
1157 // Until then, any version != 1 must be rejected cleanly.
1158 let m = vrc7_default();
1159 let mut blob = m.save_state();
1160 blob[0] = 99;
1161 let mut target = vrc7_default();
1162 let err = target.load_state(&blob).expect_err("must reject");
1163 assert!(
1164 matches!(err, MapperError::UnsupportedVersion(99)),
1165 "expected UnsupportedVersion(99), got {err:?}"
1166 );
1167 }
1168
1169 #[test]
1170 fn vrc7_namco163_mapper_audio_off_path_latches_state_but_stays_silent() {
1171 // ADR-0004 invariant: register decoders unconditionally latch
1172 // even when the synthesizer is absent. Confirm latching works
1173 // identically regardless of the `mapper-audio` feature flag
1174 // (the VRC7 surface does not branch on the flag — synthesis
1175 // is just absent in v0.9.x, period).
1176 let mut m = vrc7_default();
1177 m.cpu_write(0x9010, 0x15);
1178 m.cpu_write(0x9030, 0x77);
1179 assert_eq!(m.audio.regs[0x15], 0x77);
1180 // Drive a bunch of CPU cycles → no audio side-effects, but
1181 // IRQ counter is unaffected if not enabled.
1182 for _ in 0..1000 {
1183 m.notify_cpu_cycle();
1184 }
1185 assert_eq!(
1186 m.mix_audio(),
1187 0,
1188 "feature-off path must remain silent (matches feature-on for VRC7 v0.9.x)"
1189 );
1190 }
1191
1192 // -----------------------------------------------------------------------
1193 // Save-state audio continuity (v2.3.7 — closes the accuracy-ledger row)
1194 // -----------------------------------------------------------------------
1195
1196 /// Key a note on channel 0 with a real melodic patch, so the OPLL has
1197 /// non-trivial envelope + phase state to carry.
1198 #[cfg(feature = "mapper-audio")]
1199 fn key_on_channel_0(m: &mut Vrc7) {
1200 // $3x: high nibble = instrument (1 = the first Konami melodic patch),
1201 // low nibble = attenuation (0 = loudest).
1202 m.cpu_write(0x9010, 0x30);
1203 m.cpu_write(0x9030, 0x10);
1204 // $1x: F-number low 8 bits.
1205 m.cpu_write(0x9010, 0x10);
1206 m.cpu_write(0x9030, 0xAD);
1207 // $2x: bit5 sustain, bit4 key-on, bits3-1 block, bit0 F-number bit 8.
1208 m.cpu_write(0x9010, 0x20);
1209 m.cpu_write(0x9030, 0x15);
1210 }
1211
1212 /// Run `n` CPU cycles and return every mixed sample, so two timelines can
1213 /// be compared as a waveform rather than as a single instant.
1214 #[cfg(feature = "mapper-audio")]
1215 fn run_capture(m: &mut Vrc7, n: usize) -> Vec<i32> {
1216 let mut out = Vec::with_capacity(n);
1217 for _ in 0..n {
1218 m.notify_cpu_cycle();
1219 out.push(m.mix_audio());
1220 }
1221 out
1222 }
1223
1224 /// **The test the ledger row existed for.** A restored VRC7 must resume the
1225 /// note that was playing, sample for sample.
1226 ///
1227 /// Before v2.3.7 the section carried only the shadow register bytes, so the
1228 /// restored synthesizer started from its power-on state and this comparison
1229 /// failed on the very first sample after the envelope diverged. Deleting
1230 /// the v2 tail from `save_state` reproduces that failure — the mutation
1231 /// check for this test.
1232 #[cfg(feature = "mapper-audio")]
1233 #[test]
1234 fn vrc7_save_state_carries_the_live_opll_so_audio_resumes_identically() {
1235 let mut source = vrc7_default();
1236 key_on_channel_0(&mut source);
1237 // Advance far enough that the envelope is well past attack and the
1238 // phase accumulators hold values no reset could coincidentally match.
1239 let _ = run_capture(&mut source, 20_000);
1240
1241 let blob = source.save_state();
1242 assert_eq!(blob[0], 4, "a mapper-audio build must write section v4");
1243
1244 let expected = run_capture(&mut source, 4_000);
1245 assert!(
1246 expected.iter().any(|&s| s != 0),
1247 "fixture produced silence — the test would pass vacuously"
1248 );
1249
1250 let mut restored = vrc7_default();
1251 restored.load_state(&blob).expect("v4 blob must load");
1252 let got = run_capture(&mut restored, 4_000);
1253
1254 assert_eq!(
1255 got, expected,
1256 "the restored VRC7 did not resume the note that was playing: the OPLL \
1257 envelope + phase state is not surviving the save state"
1258 );
1259 }
1260
1261 /// v2.9.8 (ADR 0042): v1 (before v2.3.7) and v2 (v2.3.7 through v2.9.1)
1262 /// are refused. Both used to load, the v1 form leaving the synthesizer and
1263 /// both leaving the on-cart RAM as they were.
1264 #[test]
1265 fn vrc7_load_state_refuses_v1_and_v2_blobs() {
1266 let source = vrc7_default();
1267 let core_len = 91 + source.vram.len();
1268 let mut v1 = source.save_state()[..core_len].to_vec();
1269 v1[0] = 1;
1270 let mut v2 = v1.clone();
1271 v2[0] = 2;
1272 v2.resize(v2.len() + VRC7_V2_TAIL_LEN, 0);
1273 let mut target = vrc7_default();
1274 for (v, old) in [(1u8, &v1), (2, &v2)] {
1275 assert!(matches!(
1276 target.load_state(old),
1277 Err(MapperError::UnsupportedVersion(got)) if got == v
1278 ));
1279 }
1280 }
1281
1282 /// **Every build must ACCEPT a v4 blob, including one that cannot write it.**
1283 ///
1284 /// Regression for a defect two review bots caught independently: the accept
1285 /// check once compared against `VRC7_SECTION_VERSION`, which differs by
1286 /// build, so the condition collapsed on a no-audio build and the audio
1287 /// build's blob was rejected outright. That is the exact opposite of the
1288 /// validate-then-ignore portability ADR 0004 asks for, and the opposite of
1289 /// what the constant's own doc comment claimed. (Written against v2 until
1290 /// v2.9.8 retired v2; v4 is the audio build's current form.)
1291 ///
1292 /// The lesson, which is why this test exists rather than a one-line diff:
1293 /// **what a build can WRITE and what it must ACCEPT are different sets, and
1294 /// only the first varies by feature.** Deriving one from the other reads as
1295 /// tidy and silently couples them.
1296 #[test]
1297 fn vrc7_load_state_accepts_a_v4_blob_on_every_build() {
1298 let mut source = vrc7_default();
1299 source.cpu_write(0x8000, 5);
1300
1301 // On a `mapper-audio` build the writer's own blob is v4. A no-audio
1302 // build writes v3 (core + RAM tail), so the v4 shape is built from it
1303 // by inserting a zeroed synthesizer tail between the two -- the load
1304 // path validates that tail's LENGTH on every build and reads its
1305 // CONTENTS only where there is a synthesizer.
1306 #[cfg(feature = "mapper-audio")]
1307 let blob = source.save_state();
1308 #[cfg(not(feature = "mapper-audio"))]
1309 let blob = {
1310 let v3 = source.save_state();
1311 let core_len = 91 + source.vram.len();
1312 let mut b = v3[..core_len].to_vec();
1313 b[0] = 4;
1314 b.resize(b.len() + VRC7_V2_TAIL_LEN, 0);
1315 b.extend_from_slice(&v3[core_len..]);
1316 b
1317 };
1318 assert_eq!(blob[0], 4, "the fixture must be a v4 blob");
1319
1320 let mut target = vrc7_default();
1321 target
1322 .load_state(&blob)
1323 .expect("a v4 blob must load on every build, whether or not it can write one");
1324 assert_eq!(target.prg_0, 5, "the core fields must still round-trip");
1325 }
1326
1327 /// A truncated v4 blob must be rejected, not partially applied. This is
1328 /// untrusted input: a save state is a file on disk.
1329 #[cfg(feature = "mapper-audio")]
1330 #[test]
1331 fn vrc7_load_state_rejects_a_truncated_v4_blob() {
1332 let mut source = vrc7_default();
1333 key_on_channel_0(&mut source);
1334 let _ = run_capture(&mut source, 500);
1335 let blob = source.save_state();
1336
1337 // Give the target DIFFERENT state from the source, so a partial write
1338 // is observable rather than coincidentally identical.
1339 let mut target = vrc7_default();
1340 target.cpu_write(0x8000, 3);
1341 target.cpu_write(0x9000, 6);
1342 let pristine = target.save_state();
1343
1344 let err = target
1345 .load_state(&blob[..blob.len() - 1])
1346 .expect_err("a truncated v4 blob must be rejected");
1347 assert!(
1348 matches!(err, MapperError::WrongLength { .. }),
1349 "expected WrongLength, got {err:?}"
1350 );
1351
1352 // The half this test used to be missing. Returning `Err` is not enough:
1353 // `load_state` assigned the core fields BEFORE validating the v2 tail, so
1354 // a rejected load left the mapper neither in its old state nor the new
1355 // one, while the caller reported failure and kept running. Asserting only
1356 // on the return value cannot see that -- which is why review found it and
1357 // this test did not.
1358 assert_eq!(
1359 target.save_state(),
1360 pristine,
1361 "a rejected load mutated the mapper: load_state is not atomic"
1362 );
1363 }
1364
1365 /// Core audit v2.9.2 AUD-02: the section carries the 8 KiB PRG-RAM and,
1366 /// on a CHR-RAM board, the 8 KiB CHR-RAM. The core-level pin is
1367 /// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`; this
1368 /// one covers the CHR-RAM half on the board that ships with it.
1369 #[test]
1370 fn vrc7_save_state_carries_prg_ram_and_chr_ram() {
1371 let mut source = Vrc7::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
1372 source.cpu_write(0xE000, 0x40); // $E000 bit 6: PRG-RAM enable
1373 source.cpu_write(0x6000, 0x5A);
1374 source.cpu_write(0x7FFF, 0xA5);
1375 source.ppu_write(0x0000, 0x11);
1376 source.ppu_write(0x1FFF, 0x22);
1377 let blob = source.save_state();
1378
1379 let mut target = Vrc7::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
1380 target.load_state(&blob).expect("round-trip");
1381 assert_eq!(target.cpu_read(0x6000), 0x5A);
1382 assert_eq!(target.cpu_read(0x7FFF), 0xA5);
1383 assert_eq!(target.ppu_read(0x0000), 0x11);
1384 assert_eq!(target.ppu_read(0x1FFF), 0x22);
1385 }
1386
1387 /// A v3/v4 blob one byte short (inside the RAM tail) is rejected before
1388 /// anything is written.
1389 #[test]
1390 fn vrc7_truncated_ram_tail_is_rejected_atomically() {
1391 let mut source = vrc7_default();
1392 source.cpu_write(0x8000, 5);
1393 let blob = source.save_state();
1394 let mut target = vrc7_default();
1395 target.cpu_write(0x8000, 3);
1396 let pristine = target.save_state();
1397 let err = target
1398 .load_state(&blob[..blob.len() - 1])
1399 .expect_err("a truncated RAM tail must be rejected");
1400 assert!(matches!(err, MapperError::WrongLength { .. }), "{err:?}");
1401 assert_eq!(
1402 target.save_state(),
1403 pristine,
1404 "a rejected load mutated the mapper"
1405 );
1406 }
1407}