Skip to main content

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}