rustynes_mappers/m069_sunsoft_fme7.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the Sunsoft FME-7 / 5B audio detail is derived from Mesen2 (GPL-3.0-or-later), `NesSoundMixer::GetOutputVolume` (the 5B `* 15` output weight) over `Sunsoft5bAudio::_volumeLut`, and cross-referenced with Nestopia UE (GPL-2.0-or-later; no upstream file is recorded). See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! Sunsoft FME-7 (mapper 69) -- banking, the CPU-cycle IRQ counter, and the
6//! on-cart Sunsoft 5B audio chip.
7//!
8//! The FME-7 is the mapper ASIC; the 5B is the AY-3-8910-derivative sound
9//! chip packaged with it on the Japanese Gimmick! cartridge. This module owns
10//! both, because the 5B is addressed through the same `$C000`/`$E000`
11//! command/parameter port pair the mapper uses.
12//!
13//! The 5B is three square-wave tone channels, a shared 5-bit LFSR noise
14//! generator, and a shared envelope generator, mixed through a *logarithmic*
15//! DAC ([`SUNSOFT5B_LOG_VOL`]) rather than the linear one a naive port would
16//! use. Shape and absolute level are separately pinned: the step law by a
17//! unit test, the level by [`SUNSOFT5B_MIX_SCALE_NUM`] /
18//! [`SUNSOFT5B_MIX_SCALE_DEN`] against the `db_5b` oracle ROM.
19//!
20//! Audio is gated behind the `mapper-audio` Cargo feature (default ON); with
21//! it off the register decoders still latch and the oscillators freeze, so a
22//! save state written by an audio-enabled build still loads (ADR 0004).
23//! [`Sunsoft5BAudio`] is re-used verbatim by the NSF expansion path
24//! (`nsf_expansion.rs`).
25//!
26//! See `docs/mappers.md` and `docs/apu-2a03.md` §Expansion-audio levels.
27
28#![allow(
29 clippy::cast_possible_truncation,
30 clippy::cast_lossless,
31 clippy::missing_const_for_fn,
32 clippy::needless_pass_by_ref_mut,
33 clippy::manual_range_patterns,
34 clippy::match_same_arms,
35 clippy::struct_excessive_bools,
36 clippy::doc_markdown,
37 clippy::range_plus_one,
38 clippy::single_match_else,
39 clippy::bool_to_int_with_if,
40 clippy::unnested_or_patterns,
41 clippy::single_match,
42 clippy::doc_lazy_continuation,
43 clippy::too_long_first_doc_paragraph
44)]
45
46use crate::cartridge::Mirroring;
47use crate::mapper::{Mapper, MapperCaps, MapperError};
48use alloc::{boxed::Box, vec::Vec};
49use alloc::{format, vec};
50
51const PRG_BANK_8K: usize = 0x2000;
52const CHR_BANK_1K: usize = 0x0400;
53const CHR_BANK_8K: usize = 0x2000;
54const NAMETABLE_SIZE: usize = 0x0400;
55const NAMETABLE_SIZE_U16: u16 = 0x0400;
56
57/// Version byte this board writes in its mapper save-state section.
58///
59/// **v1** carried the command latch, the banking, PRG-RAM-enable, mirroring
60/// and IRQ registers, the 8 KiB PRG-RAM and the 2 KiB nametable RAM. **v2**
61/// appended the Sunsoft 5B audio tail ([`Sunsoft5BAudio::TAIL_LEN`] bytes).
62/// Neither carried the 8 KiB CHR-RAM of a cartridge with no CHR-ROM, and the
63/// `.rns` container has no other section that carries cartridge RAM -- so
64/// every save-state load, rewind step, run-ahead frame and netplay rollback
65/// kept the running game's CHR-RAM instead of the saved one (the v2.9.2
66/// cartridge-RAM sweep; the same omission core audit AUD-02 found on the
67/// Konami VRC boards). **v3** (v2.9.2) appends that CHR-RAM after the audio
68/// tail. Since v2.9.8 (ADR 0042) `load_state` reads v3 only; a v1/v2 blob,
69/// which it used to load with the CHR-RAM untouched, is refused.
70const FME7_SECTION_VERSION: u8 = 3;
71
72fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
73 let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
74 let local = (addr as usize) & (NAMETABLE_SIZE - 1);
75 let physical = mirroring.physical_bank(table);
76 physical * NAMETABLE_SIZE + local
77}
78
79/// 16-entry logarithmic volume DAC, ~3 dB per 4-bit step (= 1.5 dB per
80/// 5-bit step in the underlying chip). Peak chosen so that three channels
81/// summed at maximum volume stay comfortably inside the `i16` headroom the
82/// APU mixer expects.
83///
84/// This table is the DAC **shape** only — each step is `1.1885^2 ≈ 1.4126x`,
85/// the +1.5 dB×2 logarithmic law; `LUT[12] = 668`, `LUT[15] = 1882`,
86/// cross-checked against Mesen2's `Sunsoft5bAudio::_volumeLut` `[63, 177]` and
87/// tetanes. The absolute mixer **level** lives in
88/// [`SUNSOFT5B_MIX_SCALE_NUM`], deliberately separate so each can be pinned by
89/// its own oracle: the shape by
90/// `sunsoft5b_volume_dac_follows_logarithmic_step_law` (a unit test on these
91/// ratios), the level by `level_db_5b` (the `db_5b` comparison ROM).
92///
93/// Our entries are a finer scaling of the same law than Mesen2's `uint8_t`
94/// table, which truncates hard at the bottom (its `LUT[1]` is `1`). Keeping the
95/// finer table preserves the step ratios that the unit test asserts.
96///
97/// HISTORY (v2.1.6 → v2.2.3): the absolute level used to be an explicit,
98/// documented gap — not because the value was unknown but because
99/// `Mapper::mix_audio` returned `i16` and the correct value does not fit. A1
100/// widened that return to `i32` and calibrated the level; see
101/// [`SUNSOFT5B_MIX_SCALE_NUM`] and `docs/accuracy-ledger.md`.
102///
103/// Per the NESdev "Sunsoft 5B audio" page, the chip's DAC has a **1.5 dB step
104/// on the 5-bit signal** — "some emulator implementations based on the
105/// AY-3-8910 instead treat it as a 4-bit signal with a 3 dB per step curve",
106/// which the wiki flags as the *approximation*, not the exact behavior.
107///
108/// A channel driven by its **fixed 4-bit volume** register genuinely steps
109/// 3 dB (it selects every other level of the 5-bit DAC), so this 16-entry
110/// table — the 4-bit projection — is exact for fixed-volume tones. A channel
111/// driven by the **envelope generator** produces the full 5-bit level and must
112/// use the 32-entry [`SUNSOFT5B_LOG_VOL32`] table for the true 1.5 dB/step
113/// resolution (v2.2.7 "Timbre II": before, the envelope was reduced to 4-bit
114/// via `e >> 1`, collapsing it onto this coarser 3 dB curve — the approximation
115/// the wiki names; nestopia and rustico use the exact 5-bit DAC, which this now
116/// matches). The odd entries of `SUNSOFT5B_LOG_VOL32` equal this table exactly.
117#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
118const SUNSOFT5B_LOG_VOL: [i32; 16] = [
119 0, 15, 21, 30, 42, 59, 84, 119, 168, 237, 335, 473, 668, 944, 1333, 1882,
120];
121
122/// Exact **5-bit, 1.5 dB/step** logarithmic DAC (v2.2.7 "Timbre II") — the
123/// NESdev-authoritative Sunsoft-5B envelope curve, matching nestopia / rustico.
124/// Indexed by the envelope generator's full 5-bit output (0..=31). Each step is
125/// `×1.1885` (= +1.5 dB); the finest quantization is the same 1882-scaled law as
126/// [`SUNSOFT5B_LOG_VOL`], so the two never drift. Per the wiki, envelope levels
127/// `e=0` and `e=1` both map to silence. The **odd** entries reproduce the 4-bit
128/// [`SUNSOFT5B_LOG_VOL`] table exactly (`LOG_VOL32[2v+1] == LOG_VOL[v]`), so a
129/// fixed-volume channel and an envelope channel resting on the same level agree
130/// to the bit — guaranteed by the `log_vol32_odd_entries_match_4bit` unit test.
131#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
132const SUNSOFT5B_LOG_VOL32: [i32; 32] = [
133 0, 0, 13, 15, 18, 21, 25, 30, 35, 42, 50, 59, 71, 84, 100, 119, 141, 168, 199, 237, 282, 335,
134 398, 473, 562, 668, 794, 944, 1122, 1333, 1584, 1882,
135];
136
137/// Mixed centering bias: subtracted from the scaled linear sum before emitting
138/// the i32 sample. We use a *constant zero* — the APU mixer's chained
139/// high-pass filters (90 Hz / 440 Hz, see `rustynes-apu::mixer::OnePole`)
140/// remove any steady DC component downstream, and the 5B's linear sum
141/// can swing from 0 (all channels muted) up to ~104 k (three channels at
142/// peak volume + tone high, post-[`SUNSOFT5B_MIX_SCALE_NUM`]). Keeping the
143/// constant named here makes a future numerical bias easy to add if
144/// AccuracyCoin's mixed-output tests ever ask for it.
145#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
146const SUNSOFT5B_DC_BIAS: i32 = 0;
147
148/// v2.2.3 (A1) — absolute mixer level for the 5B, as a rational
149/// `NUM / DEN = 2549 / 138 ≈ 18.471`.
150///
151/// [`SUNSOFT5B_LOG_VOL`] carries the DAC *shape* (the +1.5 dB x2 law); this
152/// carries the *level*, the same split `VRC6_MIX_SCALE` /
153/// `NAMCO163_MIX_SCALE` / the MMC5 `650/40` pair use. Separating them is what
154/// lets the shape stay pinned by its own unit test while the level is pinned
155/// by a ROM oracle.
156///
157/// **Target, derived from Mesen2 (the project's accuracy bar) rather than from
158/// our own prior numbers** (see this file's `Provenance` header and
159/// `docs/originality-and-provenance.md` section 1). v2.2.5 reworded this to
160/// "calibrated against Mesen2 ... as an oracle", which describes a black-box
161/// comparison and so understated a derivation; v3.0.1 restored it
162/// (maintainer, 2026-10-06). Using the standard
163/// blargg nonlinear-mixer approximation (nesdev "APU Mixer"), a full-volume
164/// 2A03 square is `(95.88 * 5000) / (8128/15 + 100) = 746.9` units, and the 5B
165/// is summed with weight `* 15` over the documented 5B log-DAC volume table
166/// (`= (uint8_t)1.1885^(2i)`, so `LUT[12] = 63`, `LUT[15] = 177`). The
167/// `db_5b` ROM compares a **volume-12** 5B square against that square:
168///
169/// ```text
170/// volume 12: 63 * 15 / 746.9 = 1.265x <- the db_5b oracle target
171/// volume 15: 177 * 15 / 746.9 = 3.554x <- full-scale, the i16 blocker
172/// ```
173///
174/// This independently reproduces the ~1.27x / ~3.56x figures the accuracy
175/// ledger recorded when the calibration was deferred. The NESdev wiki and the
176/// in-repo technical references describe the chip but pin no absolute level —
177/// expansion-audio levels are a mixer convention, not a hardware spec, which
178/// is why the reference emulator is the oracle here.
179///
180/// The scale itself is measured, not computed: with the shape table above and
181/// the bus's `/65536` contract, `db_5b` measured `0.0685x` before this change,
182/// so `1.2652 / 0.0685 = 18.471`. That is the same measure-then-fix method
183/// `NAMCO163_MIX_SCALE` used for its ~12 dB correction.
184///
185/// **This is why `Mapper::mix_audio` had to widen to `i32` first.** A
186/// volume-15 tone now reaches `1882 * 18.471 = 34,761` — already past
187/// `i16::MAX` for ONE channel — and three simultaneous full-volume tones
188/// (Gimmick!, Hebereke) reach ~104 k, 3.2x over. The level could not be
189/// corrected while the return type was `i16`; that, not the arithmetic, was
190/// the actual blocker.
191#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
192pub(crate) const SUNSOFT5B_MIX_SCALE_NUM: i32 = 2549;
193/// Denominator of [`SUNSOFT5B_MIX_SCALE_NUM`].
194#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
195pub(crate) const SUNSOFT5B_MIX_SCALE_DEN: i32 = 138;
196
197/// One of the 5B's three square-wave tone channels.
198///
199/// The chip toggles the output level every `16 * TP` CPU cycles (TP = the
200/// 12-bit period from registers `$00/$01` for channel A, etc.). Per wiki,
201/// a `TP` of 0 behaves identically to `TP` of 1, so the divide path uses
202/// `max(TP, 1)` to avoid both a divide-by-zero and a degenerate "always
203/// toggling" case. None of the 5B's generators can be halted — disabling
204/// a channel in the mixer only mutes its output, the internal counters
205/// keep running.
206#[derive(Clone, Default)]
207struct Sunsoft5BTone {
208 /// 12-bit reload period.
209 period: u16,
210 /// Internal half-period countdown in CPU clocks (counts down from
211 /// `16 * period`; on hitting 0 the level toggles and the counter
212 /// reloads).
213 counter: u32,
214 /// Current square-wave output level (0 or 1).
215 level: u8,
216}
217
218#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
219impl Sunsoft5BTone {
220 /// Effective half-period, in CPU clocks (`max(period, 1) * 16`).
221 fn half_period(&self) -> u32 {
222 u32::from(self.period.max(1)) * 16
223 }
224
225 /// One CPU cycle. Counters always run, even when the channel is
226 /// muted by the mixer register.
227 fn clock(&mut self) {
228 if self.counter == 0 {
229 self.counter = self.half_period();
230 self.level ^= 1;
231 } else {
232 self.counter -= 1;
233 }
234 }
235}
236
237/// 17-bit LFSR noise generator with taps at bits 16 and 13 (per the AY-
238/// 3-8910 datasheet, as cited on the NESdev wiki).
239#[derive(Clone)]
240struct Sunsoft5BNoise {
241 /// 5-bit period reload (`$06`).
242 period: u8,
243 /// Half-period countdown in CPU clocks.
244 counter: u32,
245 /// 17-bit LFSR state; output is bit 0.
246 lfsr: u32,
247}
248
249impl Default for Sunsoft5BNoise {
250 fn default() -> Self {
251 // The AY's LFSR powers up with all bits set; if it ever reached 0
252 // it would lock up (no taps could ever flip a bit back in).
253 Self {
254 period: 0,
255 counter: 0,
256 lfsr: 0x1FFFF,
257 }
258 }
259}
260
261#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
262impl Sunsoft5BNoise {
263 fn half_period(&self) -> u32 {
264 u32::from(self.period.max(1)) * 16
265 }
266
267 fn clock(&mut self) {
268 if self.counter == 0 {
269 self.counter = self.half_period();
270 // 17-bit LFSR, taps at bits 16 and 13 (XOR). Shift right,
271 // feed the XOR back into bit 16.
272 let fb = ((self.lfsr >> 16) ^ (self.lfsr >> 13)) & 1;
273 self.lfsr = (self.lfsr >> 1) | (fb << 16);
274 self.lfsr &= 0x1FFFF;
275 } else {
276 self.counter -= 1;
277 }
278 }
279
280 /// Current noise output bit (0 or 1).
281 fn level(&self) -> u8 {
282 (self.lfsr & 1) as u8
283 }
284}
285
286/// Envelope generator: 16-bit period, 32-step output, 10 distinct shapes.
287///
288/// Writing the shape register (`$0D`) **restarts** the envelope from its
289/// shape-determined starting position. The wiki gives the shapes in
290/// terms of four bits `CAaH` (continue/attack/alternate/hold); we
291/// implement them as a small state machine — `attack` chooses the
292/// starting direction, `alternate` flips it after each ramp, `continue`
293/// gates whether to keep going past the first ramp, and `hold` freezes
294/// (with `attack XOR alternate` deciding the held value).
295#[derive(Clone, Default)]
296struct Sunsoft5BEnvelope {
297 /// 16-bit reload period.
298 period: u16,
299 /// Half-step countdown in CPU clocks (the wiki gives step frequency
300 /// `clock / (16 * period)`).
301 counter: u32,
302 /// Shape register value (`$0D`). Only the low 4 bits matter.
303 shape: u8,
304 /// Current 5-bit envelope level (0..=31).
305 level: u8,
306 /// Internal direction: +1 for rising, -1 for falling.
307 rising: bool,
308 /// Set once the envelope has completed its first ramp and decided to
309 /// hold (per `continue=0` or `hold=1` after the first ramp/alternate).
310 holding: bool,
311}
312
313#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
314impl Sunsoft5BEnvelope {
315 /// Effective step interval in CPU clocks.
316 fn step_period(&self) -> u32 {
317 u32::from(self.period.max(1)) * 16
318 }
319
320 /// Write `$0D` — latches the shape AND restarts the envelope.
321 fn write_shape(&mut self, value: u8) {
322 self.shape = value & 0x0F;
323 // Attack bit (bit 2) sets the initial direction. When attack=1,
324 // start at 0 going up; when attack=0, start at 31 going down.
325 let attack = (self.shape & 0x04) != 0;
326 self.rising = attack;
327 self.level = if attack { 0 } else { 31 };
328 self.counter = self.step_period();
329 self.holding = false;
330 }
331
332 /// One CPU cycle. Runs forever (cannot be halted) but emits silence
333 /// while `holding == true` and `continue == 0`.
334 fn clock(&mut self) {
335 if self.counter == 0 {
336 self.counter = self.step_period();
337 self.step();
338 } else {
339 self.counter -= 1;
340 }
341 }
342
343 fn step(&mut self) {
344 if self.holding {
345 return;
346 }
347 if self.rising {
348 if self.level < 31 {
349 self.level += 1;
350 return;
351 }
352 } else if self.level > 0 {
353 self.level -= 1;
354 return;
355 }
356 // We reached the end of a ramp. Decide what to do based on the
357 // four shape bits. Per the wiki:
358 // continue=0 (bit 3): the envelope holds at 0 regardless of the
359 // other bits after one ramp.
360 // hold=1 (bit 0): hold at the current value (possibly flipped
361 // by alternate).
362 // alternate=1 (bit 1): reverse direction every ramp.
363 let cont = (self.shape & 0x08) != 0;
364 let alternate = (self.shape & 0x02) != 0;
365 let hold = (self.shape & 0x01) != 0;
366 if !cont {
367 self.level = 0;
368 self.holding = true;
369 return;
370 }
371 if hold {
372 if alternate {
373 // /\___ etc.: flip the final level once.
374 self.level = if self.rising { 0 } else { 31 };
375 }
376 self.holding = true;
377 return;
378 }
379 if alternate {
380 self.rising = !self.rising;
381 } else {
382 // Pure sawtooth: snap back to the starting level.
383 self.level = if self.rising { 0 } else { 31 };
384 }
385 }
386
387 /// Current 5-bit envelope output (0..=31).
388 const fn output(&self) -> u8 {
389 self.level
390 }
391}
392
393/// 5B audio chip state: 16-byte register file, 3 tone channels, noise
394/// generator, envelope generator, plus the address-latch byte that the
395/// `$C000-$DFFF` writes use to select the next `$E000-$FFFF` data target.
396#[derive(Clone, Default)]
397pub(crate) struct Sunsoft5BAudio {
398 /// Latched 4-bit register index from the most recent `$C000-$DFFF`
399 /// write. Bits 7-4 of the high-byte are silently ignored (per the
400 /// NESdev wiki: writes with bits 7-4 nonzero are inhibited; we model
401 /// only the inhibit-on-high-bits case by masking to 4 bits, since no
402 /// known software relies on the high bits).
403 addr_latch: u8,
404 /// Raw 16-byte register file (mostly for save-state round-trip and
405 /// debug inspection — the live state lives in the channel structs).
406 regs: [u8; 16],
407 tone_a: Sunsoft5BTone,
408 tone_b: Sunsoft5BTone,
409 tone_c: Sunsoft5BTone,
410 noise: Sunsoft5BNoise,
411 envelope: Sunsoft5BEnvelope,
412}
413
414#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]
415impl Sunsoft5BAudio {
416 /// Raw value of one of the 16 PSG registers (`$00-$0F`), for the debug
417 /// window. Read-only — no side effects, unlike a real `$E000` access.
418 pub(crate) fn reg(&self, idx: usize) -> u8 {
419 self.regs[idx & 0x0F]
420 }
421
422 /// Current 16-bit envelope period (`$0B` low | `$0C` high), for debug.
423 pub(crate) const fn envelope_period(&self) -> u16 {
424 self.envelope.period
425 }
426
427 /// Current 5-bit envelope output level (0..=31), for debug.
428 pub(crate) fn envelope_output(&self) -> u8 {
429 self.envelope.output()
430 }
431
432 pub(crate) fn write_addr(&mut self, value: u8) {
433 // Per the wiki, writes with the high nibble nonzero are inhibited.
434 // The simplest faithful model is to mask the latch to 4 bits and
435 // accept the next data write unconditionally — no known software
436 // depends on the inhibit path.
437 self.addr_latch = value & 0x0F;
438 }
439
440 pub(crate) fn write_data(&mut self, value: u8) {
441 let idx = self.addr_latch as usize;
442 self.regs[idx] = value;
443 match idx {
444 0x00 => self.tone_a.period = (self.tone_a.period & 0x0F00) | u16::from(value),
445 0x01 => {
446 self.tone_a.period = (self.tone_a.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
447 }
448 0x02 => self.tone_b.period = (self.tone_b.period & 0x0F00) | u16::from(value),
449 0x03 => {
450 self.tone_b.period = (self.tone_b.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
451 }
452 0x04 => self.tone_c.period = (self.tone_c.period & 0x0F00) | u16::from(value),
453 0x05 => {
454 self.tone_c.period = (self.tone_c.period & 0x00FF) | (u16::from(value & 0x0F) << 8);
455 }
456 0x06 => self.noise.period = value & 0x1F,
457 0x07 => { /* mixer; consulted live in `mix_audio`. */ }
458 0x08 | 0x09 | 0x0A => { /* per-channel volume; consulted live. */ }
459 0x0B => {
460 self.envelope.period = (self.envelope.period & 0xFF00) | u16::from(value);
461 }
462 0x0C => {
463 self.envelope.period = (self.envelope.period & 0x00FF) | (u16::from(value) << 8);
464 }
465 0x0D => self.envelope.write_shape(value),
466 // $0E/$0F = I/O ports A/B. Unused on the NES (the cart never
467 // wires them out). We latch the byte for save-state round-trip
468 // and otherwise ignore.
469 _ => {}
470 }
471 }
472
473 /// Mixer register: bits are `--CBAcca`, 0 = enable / 1 = disable.
474 /// Bits 5/3/1 are noise enables for channels C/B/A respectively;
475 /// bits 4/2/0 are tone enables for channels c/b/a (same lettering).
476 const fn tone_enabled(&self, ch: u8) -> bool {
477 let mixer = self.regs[0x07];
478 // 0 = enable, 1 = disable. Tone bits = 0, 2, 4 for A/B/C.
479 (mixer >> (ch * 2)) & 1 == 0
480 }
481
482 const fn noise_enabled(&self, ch: u8) -> bool {
483 let mixer = self.regs[0x07];
484 // Noise bits = 1, 3, 5 for A/B/C.
485 (mixer >> (ch * 2 + 1)) & 1 == 0
486 }
487
488 /// Resolve channel `ch`'s DAC amplitude (0/1/2 for A/B/C), honoring the
489 /// per-channel envelope-mode bit.
490 ///
491 /// Fixed-volume mode indexes the 4-bit [`SUNSOFT5B_LOG_VOL`] table (exact
492 /// 3 dB/step for a 4-bit register). Envelope mode indexes the full 5-bit
493 /// [`SUNSOFT5B_LOG_VOL32`] table for the NESdev-exact **1.5 dB/step**
494 /// resolution (v2.2.7 "Timbre II"; previously the 5-bit envelope was
495 /// truncated to 4-bit via `>> 1`, i.e. the wiki-named 3 dB approximation).
496 /// `env=0`/`env=1` both map to silence; `env=31` is full scale.
497 fn amplitude(&self, ch: u8) -> i32 {
498 let reg = self.regs[0x08 + ch as usize];
499 if reg & 0x10 != 0 {
500 SUNSOFT5B_LOG_VOL32[self.envelope.output() as usize & 0x1F]
501 } else {
502 SUNSOFT5B_LOG_VOL[(reg & 0x0F) as usize]
503 }
504 }
505
506 /// Advance every internal generator by one CPU cycle. Per the wiki,
507 /// "none of the various generators can be halted" — they run whenever
508 /// the chip is clocked, regardless of mixer/enable state.
509 #[cfg(feature = "mapper-audio")]
510 pub(crate) fn clock(&mut self) {
511 self.tone_a.clock();
512 self.tone_b.clock();
513 self.tone_c.clock();
514 self.noise.clock();
515 self.envelope.clock();
516 }
517
518 /// Linear-summed audio output, scaled to ~i16 with the same headroom
519 /// VRC6 leaves for the APU mixer.
520 #[cfg(feature = "mapper-audio")]
521 pub(crate) fn mix(&self) -> i32 {
522 let mut sum: i32 = 0;
523 for (ch, tone) in [&self.tone_a, &self.tone_b, &self.tone_c]
524 .iter()
525 .enumerate()
526 {
527 let ch = ch as u8;
528 // Per wiki: "If both bits are 1 [disable + disable], the
529 // channel outputs a constant signal at the specified volume.
530 // If both bits are 0, the result is the logical and of noise
531 // and tone." Equivalent: emit when (tone_enabled => square
532 // high) AND (noise_enabled => noise high), defaulting either
533 // factor to "1" when its source is disabled.
534 let tone_factor = !self.tone_enabled(ch) || tone.level != 0;
535 let noise_factor = !self.noise_enabled(ch) || self.noise.level() != 0;
536 if tone_factor && noise_factor {
537 sum += self.amplitude(ch);
538 }
539 }
540 // Scale the shape table to the hardware-relative level (see
541 // `SUNSOFT5B_MIX_SCALE_NUM`), then centre on zero so the BLEP buffer
542 // doesn't see a steady DC offset for an idle
543 // (all-channels-on-with-fixed-volume) cartridge. No cast: v2.2.3
544 // widened `Mapper::mix_audio` to i32 precisely so the 5B's full
545 // three-channel swing (~104 k) is representable rather than clamped.
546 // The multiply precedes the divide so the integer division loses at
547 // most 1 part in ~12,000 on a volume-12 tone.
548 sum * SUNSOFT5B_MIX_SCALE_NUM / SUNSOFT5B_MIX_SCALE_DEN - SUNSOFT5B_DC_BIAS
549 }
550
551 /// Feature-off shim: the generators do not advance with `mapper-audio`
552 /// disabled (mirrors the gated path so the shared NSF expansion router
553 /// can clock unconditionally).
554 #[cfg(not(feature = "mapper-audio"))]
555 #[allow(clippy::needless_pass_by_ref_mut, clippy::unused_self)]
556 pub(crate) fn clock(&mut self) {}
557
558 /// Feature-off shim: silence when `mapper-audio` is disabled.
559 #[cfg(not(feature = "mapper-audio"))]
560 #[allow(clippy::unused_self)]
561 pub(crate) fn mix(&self) -> i32 {
562 0
563 }
564
565 /// Serialize the live audio state. 21-byte tail:
566 /// addr_latch(1) + regs[16](16) + tone_a/b/c counter+level(3*5=15) +
567 /// noise counter+lfsr(4+1+... wait that's bigger).
568 ///
569 /// Tail layout (kept in lock-step with `read_tail`):
570 /// addr_latch : 1
571 /// regs : 16
572 /// tone_a.counter : 4 (u32 LE)
573 /// tone_a.level : 1
574 /// tone_b.counter : 4
575 /// tone_b.level : 1
576 /// tone_c.counter : 4
577 /// tone_c.level : 1
578 /// noise.counter : 4
579 /// noise.lfsr : 4 (u32 LE, only low 17 bits used)
580 /// envelope.counter : 4
581 /// envelope.level : 1
582 /// envelope.rising : 1 (bool)
583 /// envelope.holding : 1 (bool)
584 /// -- 51 bytes total --
585 /// (Channel period/shape state is reconstructible from `regs`; we
586 /// don't serialize the period/shape fields separately.)
587 fn write_tail(&self, out: &mut Vec<u8>) {
588 out.push(self.addr_latch);
589 out.extend_from_slice(&self.regs);
590 for t in [&self.tone_a, &self.tone_b, &self.tone_c] {
591 out.extend_from_slice(&t.counter.to_le_bytes());
592 out.push(t.level);
593 }
594 out.extend_from_slice(&self.noise.counter.to_le_bytes());
595 out.extend_from_slice(&self.noise.lfsr.to_le_bytes());
596 out.extend_from_slice(&self.envelope.counter.to_le_bytes());
597 out.push(self.envelope.level);
598 out.push(u8::from(self.envelope.rising));
599 out.push(u8::from(self.envelope.holding));
600 }
601
602 /// Tail size in bytes — see `write_tail`.
603 const TAIL_LEN: usize = 1 + 16 + 3 * 5 + 4 + 4 + 4 + 1 + 1 + 1;
604
605 fn read_tail(&mut self, src: &[u8]) -> Result<(), MapperError> {
606 if src.len() < Self::TAIL_LEN {
607 return Err(MapperError::WrongLength {
608 expected: Self::TAIL_LEN,
609 got: src.len(),
610 });
611 }
612 self.addr_latch = src[0] & 0x0F;
613 self.regs.copy_from_slice(&src[1..17]);
614 let mut cur = 17usize;
615 for t in [&mut self.tone_a, &mut self.tone_b, &mut self.tone_c] {
616 t.counter = u32::from_le_bytes([src[cur], src[cur + 1], src[cur + 2], src[cur + 3]]);
617 t.level = src[cur + 4] & 1;
618 cur += 5;
619 }
620 self.noise.counter =
621 u32::from_le_bytes([src[cur], src[cur + 1], src[cur + 2], src[cur + 3]]);
622 cur += 4;
623 self.noise.lfsr =
624 u32::from_le_bytes([src[cur], src[cur + 1], src[cur + 2], src[cur + 3]]) & 0x1FFFF;
625 if self.noise.lfsr == 0 {
626 // Guard against a lock-up (LFSR with all zeros has no way out).
627 self.noise.lfsr = 0x1FFFF;
628 }
629 cur += 4;
630 self.envelope.counter =
631 u32::from_le_bytes([src[cur], src[cur + 1], src[cur + 2], src[cur + 3]]);
632 cur += 4;
633 self.envelope.level = src[cur] & 0x1F;
634 self.envelope.rising = src[cur + 1] != 0;
635 self.envelope.holding = src[cur + 2] != 0;
636 // Reconstruct live period/shape state from the register file.
637 self.tone_a.period = u16::from(self.regs[0x00]) | (u16::from(self.regs[0x01] & 0x0F) << 8);
638 self.tone_b.period = u16::from(self.regs[0x02]) | (u16::from(self.regs[0x03] & 0x0F) << 8);
639 self.tone_c.period = u16::from(self.regs[0x04]) | (u16::from(self.regs[0x05] & 0x0F) << 8);
640 self.noise.period = self.regs[0x06] & 0x1F;
641 self.envelope.period = u16::from(self.regs[0x0B]) | (u16::from(self.regs[0x0C]) << 8);
642 self.envelope.shape = self.regs[0x0D] & 0x0F;
643 Ok(())
644 }
645}
646
647/// Sunsoft FME-7 (Mapper 69). Bank-switching, CPU-cycle IRQ, and (gated
648/// behind `mapper-audio`) the on-cart Sunsoft 5B audio chip.
649pub struct Fme7 {
650 prg_rom: Box<[u8]>,
651 chr_rom: Box<[u8]>,
652 prg_ram: Box<[u8]>,
653 vram: Box<[u8]>,
654 chr_is_ram: bool,
655 cmd: u8,
656 chr: [u8; 8],
657 prg_banks: [u8; 4], // $6000, $8000, $A000, $C000 (E000 fixed)
658 prg_ram_enabled: bool,
659 prg_ram_select: bool,
660 mirroring: Mirroring,
661
662 irq_counter: u16,
663 irq_enabled: bool,
664 irq_counter_enabled: bool,
665 irq_pending: bool,
666
667 /// Sunsoft 5B audio extension state. Live regardless of the
668 /// `mapper-audio` feature — the register decoders always latch into
669 /// `regs` (so save states stay round-trippable across builds), but
670 /// `clock()` / `mix()` are only called when the feature is on.
671 audio: Sunsoft5BAudio,
672}
673
674impl Fme7 {
675 /// Construct a new FME-7 mapper.
676 ///
677 /// # Errors
678 ///
679 /// Returns [`MapperError::Invalid`] on size mismatch.
680 pub fn new(
681 prg_rom: Box<[u8]>,
682 chr_rom: Box<[u8]>,
683 mirroring: Mirroring,
684 ) -> Result<Self, MapperError> {
685 if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
686 return Err(MapperError::Invalid(format!(
687 "FME-7 PRG-ROM size {} is not a non-zero multiple of 8 KiB",
688 prg_rom.len()
689 )));
690 }
691 let chr_is_ram = chr_rom.is_empty();
692 let chr: Box<[u8]> = if chr_is_ram {
693 vec![0u8; CHR_BANK_8K].into_boxed_slice()
694 } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
695 chr_rom
696 } else {
697 return Err(MapperError::Invalid(format!(
698 "FME-7 CHR-ROM size {} is not a multiple of 1 KiB",
699 chr_rom.len()
700 )));
701 };
702 Ok(Self {
703 prg_rom,
704 chr_rom: chr,
705 prg_ram: vec![0u8; 8 * 1024].into_boxed_slice(),
706 vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
707 chr_is_ram,
708 cmd: 0,
709 chr: [0; 8],
710 prg_banks: [0; 4],
711 prg_ram_enabled: false,
712 prg_ram_select: true,
713 mirroring,
714 irq_counter: 0,
715 irq_enabled: false,
716 irq_counter_enabled: false,
717 irq_pending: false,
718 audio: Sunsoft5BAudio::default(),
719 })
720 }
721
722 fn prg_8k(&self, idx: usize) -> usize {
723 let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
724 (self.prg_banks[idx] as usize) % total_8k
725 }
726}
727
728impl Mapper for Fme7 {
729 fn sram(&self) -> &[u8] {
730 &self.prg_ram
731 }
732 fn sram_mut(&mut self) -> &mut [u8] {
733 &mut self.prg_ram
734 }
735 // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio
736 // (the audio hook only exists under the `mapper-audio` feature).
737 fn caps(&self) -> MapperCaps {
738 MapperCaps {
739 cpu_cycle_hook: true,
740 audio: cfg!(feature = "mapper-audio"),
741 frame_event_hook: false,
742 irq_source: true,
743 }
744 }
745
746 /// A2: the RAM-selected-but-DISABLED `$6000-$7FFF` window floats.
747 ///
748 /// Command `$8` carries RAM-enable in bit 7 and RAM-select in bit 6. Three
749 /// of the four states already worked: both set maps PRG-RAM, and bit 6
750 /// clear maps a PRG-ROM bank (regardless of bit 7). The fourth --
751 /// **selected but not enabled** (bit 6 = 1, bit 7 = 0) -- drives *neither*
752 /// chip on real hardware: the RAM is addressed but its enable is deasserted,
753 /// and the ROM is deselected. The CPU databus is left floating, so the read
754 /// returns the open-bus latch.
755 ///
756 /// Previously this state fell through to the PRG-ROM bank, returning that
757 /// bank's tag byte. Holy Mapperel's `M69_*` WRAM sub-check requires the
758 /// floating read to be `>= 3` (a `$7F`-class open-bus byte); it read the
759 /// bank tag `1` instead and set `MAPTEST_WRAMEN`, which is the entire
760 /// `1000` WRAM nibble those two ROMs reported (`docs/accuracy-ledger.md`).
761 ///
762 /// Routed through `cpu_read_unmapped` rather than by returning a guessed
763 /// byte from `cpu_read`: that is the trait's existing contract for "not
764 /// wired to mapper-resident memory", and it makes the bus preserve the real
765 /// latch instead of clobbering it -- which is what open bus actually is.
766 fn cpu_read_unmapped(&self, addr: u16) -> bool {
767 if matches!(addr, 0x6000..=0x7FFF) {
768 return self.prg_ram_select && !self.prg_ram_enabled;
769 }
770 // Everything else keeps the stock behaviour: `$4020-$5FFF` unmapped
771 // (the FME-7 has no registers down there -- its IRQ control lives at
772 // `$8000`/`$A000`), `$8000-$FFFF` always mapped PRG-ROM.
773 addr < 0x6000
774 }
775
776 fn cpu_read(&mut self, addr: u16) -> u8 {
777 match addr {
778 0x6000..=0x7FFF => {
779 if self.prg_ram_select && self.prg_ram_enabled {
780 return self.prg_ram[(addr - 0x6000) as usize % self.prg_ram.len()];
781 }
782 let bank = self.prg_8k(0);
783 self.prg_rom[(bank * PRG_BANK_8K + (addr as usize - 0x6000)) % self.prg_rom.len()]
784 }
785 0x8000..=0x9FFF => {
786 let off = self.prg_8k(1) * PRG_BANK_8K + (addr as usize - 0x8000);
787 self.prg_rom[off % self.prg_rom.len()]
788 }
789 0xA000..=0xBFFF => {
790 let off = self.prg_8k(2) * PRG_BANK_8K + (addr as usize - 0xA000);
791 self.prg_rom[off % self.prg_rom.len()]
792 }
793 0xC000..=0xDFFF => {
794 let off = self.prg_8k(3) * PRG_BANK_8K + (addr as usize - 0xC000);
795 self.prg_rom[off % self.prg_rom.len()]
796 }
797 0xE000..=0xFFFF => {
798 let total_8k = (self.prg_rom.len() / PRG_BANK_8K).max(1);
799 let last = total_8k - 1;
800 self.prg_rom[(last * PRG_BANK_8K + (addr as usize - 0xE000)) % self.prg_rom.len()]
801 }
802 _ => 0,
803 }
804 }
805
806 fn cpu_write(&mut self, addr: u16, value: u8) {
807 match addr {
808 0x6000..=0x7FFF => {
809 if self.prg_ram_select && self.prg_ram_enabled {
810 let off = (addr - 0x6000) as usize % self.prg_ram.len();
811 self.prg_ram[off] = value;
812 }
813 }
814 0x8000..=0x9FFF => self.cmd = value & 0x0F,
815 0xA000..=0xBFFF => match self.cmd {
816 0..=7 => self.chr[self.cmd as usize] = value,
817 8 => {
818 self.prg_ram_enabled = (value & 0x80) != 0;
819 self.prg_ram_select = (value & 0x40) != 0;
820 self.prg_banks[0] = value & 0x3F;
821 }
822 9..=11 => self.prg_banks[(self.cmd - 8) as usize] = value & 0x3F,
823 12 => {
824 self.mirroring = match value & 0x03 {
825 0 => Mirroring::Vertical,
826 1 => Mirroring::Horizontal,
827 2 => Mirroring::SingleScreenA,
828 _ => Mirroring::SingleScreenB,
829 };
830 }
831 13 => {
832 self.irq_enabled = (value & 0x01) != 0;
833 self.irq_counter_enabled = (value & 0x80) != 0;
834 self.irq_pending = false;
835 }
836 14 => self.irq_counter = (self.irq_counter & 0xFF00) | u16::from(value),
837 15 => self.irq_counter = (self.irq_counter & 0x00FF) | (u16::from(value) << 8),
838 _ => {}
839 },
840 // Sunsoft 5B audio: $C000-$DFFF latches the register address;
841 // $E000-$FFFF writes data to the latched register. Mapper-audio
842 // OFF builds still latch state (so the save-state path is
843 // round-trippable) but never advance the oscillators.
844 0xC000..=0xDFFF => self.audio.write_addr(value),
845 0xE000..=0xFFFF => self.audio.write_data(value),
846 _ => {}
847 }
848 }
849
850 fn ppu_read(&mut self, addr: u16) -> u8 {
851 let addr = addr & 0x3FFF;
852 match addr {
853 0x0000..=0x1FFF => {
854 let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
855 let slot = addr as usize / CHR_BANK_1K;
856 let bank = (self.chr[slot] as usize) % total_1k;
857 let off = bank * CHR_BANK_1K + (addr as usize & (CHR_BANK_1K - 1));
858 self.chr_rom[off % self.chr_rom.len()]
859 }
860 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
861 _ => 0,
862 }
863 }
864
865 fn ppu_write(&mut self, addr: u16, value: u8) {
866 let addr = addr & 0x3FFF;
867 match addr {
868 0x0000..=0x1FFF => {
869 if self.chr_is_ram {
870 let len = self.chr_rom.len();
871 self.chr_rom[addr as usize % len] = value;
872 }
873 }
874 0x2000..=0x3EFF => {
875 let off = nametable_offset(addr, self.mirroring) % self.vram.len();
876 self.vram[off] = value;
877 }
878 _ => {}
879 }
880 }
881
882 fn notify_cpu_cycle(&mut self) {
883 // Sunsoft 5B audio runs every CPU cycle, regardless of IRQ state.
884 // None of the 5B's internal generators can be halted, so we always
885 // tick when the feature is on.
886 #[cfg(feature = "mapper-audio")]
887 self.audio.clock();
888
889 if self.irq_counter_enabled {
890 self.irq_counter = self.irq_counter.wrapping_sub(1);
891 if self.irq_counter == 0xFFFF && self.irq_enabled {
892 self.irq_pending = true;
893 }
894 }
895 }
896
897 #[cfg(feature = "mapper-audio")]
898 fn mix_audio(&mut self) -> i32 {
899 self.audio.mix()
900 }
901
902 fn irq_pending(&self) -> bool {
903 self.irq_pending
904 }
905
906 fn current_mirroring(&self) -> Mirroring {
907 self.mirroring
908 }
909
910 fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
911 let mut info = crate::mapper::MapperDebugInfo {
912 mapper_id: 69,
913 name: "Sunsoft FME-7".into(),
914 mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
915 ..Default::default()
916 };
917 for (i, b) in self.prg_banks.iter().enumerate() {
918 info.prg_banks
919 .push((format!("PRG{i}"), format!("{b:#04x}")));
920 }
921 for (i, b) in self.chr.iter().enumerate() {
922 info.chr_banks
923 .push((format!("CHR{i}"), format!("{b:#04x}")));
924 }
925 info.irq_state
926 .push(("counter".into(), format!("{:#06x}", self.irq_counter)));
927 info.irq_state
928 .push(("enabled".into(), format!("{}", self.irq_enabled)));
929 info.irq_state
930 .push(("counting".into(), format!("{}", self.irq_counter_enabled)));
931 info.irq_state
932 .push(("pending".into(), format!("{}", self.irq_pending)));
933 info.extra
934 .push(("cmd".into(), format!("{:#04x}", self.cmd)));
935 info.extra.push((
936 "prg_ram".into(),
937 format!("en={} sel={}", self.prg_ram_enabled, self.prg_ram_select),
938 ));
939 // v2.2.3 — surface the Sunsoft 5B audio register file. The 5B is the
940 // only part of this board with no other debug window, and its state is
941 // exactly what you need to answer "why is this cart silent?" — the
942 // mixer/enable byte ($07) and the three volume bytes ($08-$0A, bit 4 =
943 // envelope mode) decide whether anything sounds at all.
944 #[cfg(feature = "mapper-audio")]
945 {
946 let a = &self.audio;
947 info.extra
948 .push(("5b_mixer($07)".into(), format!("{:#04x}", a.reg(0x07))));
949 info.extra.push((
950 "5b_vol(A,B,C)".into(),
951 format!(
952 "{:#04x} {:#04x} {:#04x}",
953 a.reg(0x08),
954 a.reg(0x09),
955 a.reg(0x0A)
956 ),
957 ));
958 info.extra.push((
959 "5b_env".into(),
960 format!(
961 "period={:#06x} shape={:#04x} out={}",
962 a.envelope_period(),
963 a.reg(0x0D),
964 a.envelope_output()
965 ),
966 ));
967 info.extra.push(("5b_mix".into(), format!("{}", a.mix())));
968 }
969 info
970 }
971
972 fn save_state(&self) -> Vec<u8> {
973 // v2: appends the Sunsoft 5B audio state at the end. Per ADR-0003:
974 // strictly additive, so v1 readers tolerate the tail (older builds
975 // skip-on-read since the tag is consumed at the section length).
976 // Tail size = Sunsoft5BAudio::TAIL_LEN (51 bytes).
977 // v3: appends the cartridge CHR-RAM after the audio tail; see
978 // `FME7_SECTION_VERSION`.
979 let mut out = Vec::with_capacity(
980 40 + self.prg_ram.len()
981 + self.vram.len()
982 + Sunsoft5BAudio::TAIL_LEN
983 + self.chr_ram_tail_len(),
984 );
985 out.push(FME7_SECTION_VERSION);
986 out.push(self.cmd);
987 out.extend_from_slice(&self.chr);
988 out.extend_from_slice(&self.prg_banks);
989 out.push(u8::from(self.prg_ram_enabled));
990 out.push(u8::from(self.prg_ram_select));
991 out.push(self.mirroring as u8);
992 out.extend_from_slice(&self.irq_counter.to_le_bytes());
993 out.push(u8::from(self.irq_enabled));
994 out.push(u8::from(self.irq_counter_enabled));
995 out.push(u8::from(self.irq_pending));
996 out.extend_from_slice(&self.prg_ram);
997 out.extend_from_slice(&self.vram);
998 // v2 audio tail.
999 self.audio.write_tail(&mut out);
1000 // v3 CHR-RAM tail.
1001 if self.chr_is_ram {
1002 out.extend_from_slice(&self.chr_rom);
1003 }
1004 out
1005 }
1006
1007 fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
1008 let scalar_len = 1 + 1 + 8 + 4 + 1 + 1 + 1 + 2 + 1 + 1 + 1;
1009 let core_expected = scalar_len + self.prg_ram.len() + self.vram.len();
1010 if data.len() < core_expected {
1011 return Err(MapperError::WrongLength {
1012 expected: core_expected,
1013 got: data.len(),
1014 });
1015 }
1016 let version = data[0];
1017 // Only the current version is read (v2.9.8, ADR 0042). v1 (no audio
1018 // tail) and v2 (no CHR-RAM tail, short audio tail tolerated) used to
1019 // load with those parts left as they were.
1020 if version != FME7_SECTION_VERSION {
1021 return Err(MapperError::UnsupportedVersion(version));
1022 }
1023 // Strict: core + the full audio tail + the CHR-RAM tail, exactly.
1024 // Validated before the first field is written.
1025 let expected = core_expected + Sunsoft5BAudio::TAIL_LEN + self.chr_ram_tail_len();
1026 if data.len() != expected {
1027 return Err(MapperError::WrongLength {
1028 expected,
1029 got: data.len(),
1030 });
1031 }
1032 self.cmd = data[1];
1033 self.chr.copy_from_slice(&data[2..10]);
1034 self.prg_banks.copy_from_slice(&data[10..14]);
1035 self.prg_ram_enabled = data[14] != 0;
1036 self.prg_ram_select = data[15] != 0;
1037 self.mirroring = match data[16] {
1038 0 => Mirroring::Horizontal,
1039 1 => Mirroring::Vertical,
1040 2 => Mirroring::SingleScreenA,
1041 3 => Mirroring::SingleScreenB,
1042 4 => Mirroring::FourScreen,
1043 5 => Mirroring::MapperControlled,
1044 other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
1045 };
1046 self.irq_counter = u16::from_le_bytes(
1047 data[17..19]
1048 .try_into()
1049 .map_err(|_| MapperError::Invalid("irq_counter".into()))?,
1050 );
1051 self.irq_enabled = data[19] != 0;
1052 self.irq_counter_enabled = data[20] != 0;
1053 self.irq_pending = data[21] != 0;
1054 let mut cur = 22usize;
1055 self.prg_ram
1056 .copy_from_slice(&data[cur..cur + self.prg_ram.len()]);
1057 cur += self.prg_ram.len();
1058 self.vram.copy_from_slice(&data[cur..cur + self.vram.len()]);
1059 cur += self.vram.len();
1060
1061 // v2 tail: audio state.
1062 self.audio
1063 .read_tail(&data[cur..cur + Sunsoft5BAudio::TAIL_LEN])?;
1064 // v3 CHR-RAM tail, exactly sized by the check above.
1065 if self.chr_is_ram {
1066 self.chr_rom
1067 .copy_from_slice(&data[cur + Sunsoft5BAudio::TAIL_LEN..]);
1068 }
1069 Ok(())
1070 }
1071}
1072
1073impl Fme7 {
1074 /// Bytes the v3 tail adds: the 8 KiB CHR-RAM when the cartridge has no
1075 /// CHR-ROM, else nothing. Derived from the loaded ROM, so a save and its
1076 /// load (same ROM, checked by the `.rns` hash tag) agree.
1077 fn chr_ram_tail_len(&self) -> usize {
1078 if self.chr_is_ram {
1079 self.chr_rom.len()
1080 } else {
1081 0
1082 }
1083 }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089
1090 fn synth(banks_8k: usize) -> Box<[u8]> {
1091 let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
1092 for b in 0..banks_8k {
1093 v[b * PRG_BANK_8K] = b as u8;
1094 }
1095 v.into_boxed_slice()
1096 }
1097
1098 fn synth_chr(banks_1k: usize) -> Box<[u8]> {
1099 let mut v = vec![0u8; banks_1k * CHR_BANK_1K];
1100 for b in 0..banks_1k {
1101 v[b * CHR_BANK_1K] = b as u8;
1102 }
1103 v.into_boxed_slice()
1104 }
1105
1106 #[test]
1107 fn fme7_basic_banking() {
1108 let mut m = Fme7::new(synth(16), synth_chr(8), Mirroring::Vertical).unwrap();
1109 // cmd=9 -> writes prg_banks[1] (the $8000-$9FFF window).
1110 m.cpu_write(0x8000, 9);
1111 m.cpu_write(0xA000, 5);
1112 // Read at $8000 should now be bank 5 (offset 0 == bank index byte).
1113 assert_eq!(m.cpu_read(0x8000), 5);
1114 // cmd=10 -> prg_banks[2] ($A000-$BFFF).
1115 m.cpu_write(0x8000, 10);
1116 m.cpu_write(0xA000, 7);
1117 assert_eq!(m.cpu_read(0xA000), 7);
1118 }
1119
1120 fn fme7_audio_write(m: &mut Fme7, reg: u8, value: u8) {
1121 m.cpu_write(0xC000, reg);
1122 m.cpu_write(0xE000, value);
1123 }
1124
1125 #[test]
1126 fn sunsoft5b_register_address_latch_round_trip() {
1127 // The address latch is the gateway for every audio write; it must
1128 // round-trip distinctly from the data path. After latching $0B
1129 // (envelope period low), a subsequent data write should target
1130 // $0B specifically.
1131 let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
1132 m.cpu_write(0xC000, 0x0B);
1133 assert_eq!(m.audio.addr_latch, 0x0B);
1134 // Bits 7-4 of the address byte are ignored (masked to 4 bits).
1135 m.cpu_write(0xC100, 0xF7);
1136 assert_eq!(m.audio.addr_latch, 0x07);
1137 // A data write at $E000-$FFFF goes to the latched register.
1138 m.cpu_write(0xE800, 0xAB);
1139 assert_eq!(m.audio.regs[0x07], 0xAB);
1140 }
1141
1142 #[test]
1143 fn sunsoft5b_channel_period_decodes_into_internal_state() {
1144 // Channel A period: TP = ($01 & 0x0F) << 8 | $00. Confirm the
1145 // 12-bit period composes correctly from the two writes, and that
1146 // bits 7-4 of $01 are masked off.
1147 let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
1148 fme7_audio_write(&mut m, 0x00, 0x34);
1149 fme7_audio_write(&mut m, 0x01, 0xF7); // upper nibble (7) used; F is ignored.
1150 assert_eq!(m.audio.tone_a.period, 0x0734);
1151
1152 // Channel B / C similarly.
1153 fme7_audio_write(&mut m, 0x02, 0x12);
1154 fme7_audio_write(&mut m, 0x03, 0x03);
1155 assert_eq!(m.audio.tone_b.period, 0x0312);
1156 fme7_audio_write(&mut m, 0x04, 0xFF);
1157 fme7_audio_write(&mut m, 0x05, 0x0F);
1158 assert_eq!(m.audio.tone_c.period, 0x0FFF);
1159 }
1160
1161 #[test]
1162 fn sunsoft5b_tone_toggles_every_16_times_period_cycles() {
1163 // Per NESdev wiki: the square wave toggles every 16 CPU clocks per
1164 // period count. With TP = 5, we expect a toggle every 80 cycles.
1165 // Drive the chip through clock() directly to isolate the tone path
1166 // from the rest of the mapper.
1167 let mut t = Sunsoft5BTone {
1168 period: 5,
1169 ..Sunsoft5BTone::default()
1170 };
1171 // First clock fires immediately (counter starts at 0) and reloads.
1172 // Count toggles across 800 cycles.
1173 let mut toggles = 0u32;
1174 let mut last = t.level;
1175 for _ in 0..800 {
1176 t.clock();
1177 if t.level != last {
1178 toggles += 1;
1179 last = t.level;
1180 }
1181 }
1182 // 800 cycles / 80 per toggle = 10 toggles. Allow ±1 for the
1183 // counter-starts-at-zero start-up edge.
1184 assert!(
1185 (9..=11).contains(&toggles),
1186 "tone toggle count {toggles} not in 9..=11"
1187 );
1188 }
1189
1190 #[test]
1191 fn sunsoft5b_volume_scale_zero_silent_max_peak() {
1192 // Volume 0 must produce silence; volume 15 must produce the peak
1193 // entry of the log-DAC table. These bracket the per-channel
1194 // contribution range.
1195 assert_eq!(SUNSOFT5B_LOG_VOL[0], 0);
1196 assert!(SUNSOFT5B_LOG_VOL[15] > SUNSOFT5B_LOG_VOL[14]);
1197 // amplitude() applies the envelope-mode select bit and returns the DAC
1198 // value (not the 4-bit index).
1199 let mut a = Sunsoft5BAudio::default();
1200 a.regs[0x08] = 0x0F; // fixed volume = 15.
1201 assert_eq!(a.amplitude(0), SUNSOFT5B_LOG_VOL[15]);
1202 a.regs[0x08] = 0x00; // fixed volume = 0.
1203 assert_eq!(a.amplitude(0), 0);
1204 }
1205
1206 #[test]
1207 fn sunsoft5b_envelope_mode_routes_envelope_into_channel() {
1208 // Setting bit 4 of $08/$09/$0A switches that channel from fixed volume
1209 // to envelope mode. In envelope mode (v2.2.7 "Timbre II") the FULL 5-bit
1210 // envelope level indexes the exact 1.5 dB/step SUNSOFT5B_LOG_VOL32 DAC —
1211 // no longer truncated to 4-bit via `>> 1`.
1212 let mut a = Sunsoft5BAudio::default();
1213 a.regs[0x08] = 0x10; // envelope mode, fixed-volume bits ignored.
1214 a.envelope.level = 31; // full scale.
1215 assert_eq!(a.amplitude(0), SUNSOFT5B_LOG_VOL32[31]);
1216 a.envelope.level = 6; // an even (previously-truncated) level.
1217 assert_eq!(a.amplitude(0), SUNSOFT5B_LOG_VOL32[6]);
1218 a.envelope.level = 1;
1219 assert_eq!(a.amplitude(0), 0); // env 0 and 1 both -> silence.
1220 a.envelope.level = 0;
1221 assert_eq!(a.amplitude(0), 0);
1222 // Switching back to fixed mode honors $08 bits 3-0 again (4-bit DAC).
1223 a.regs[0x08] = 0x07;
1224 assert_eq!(a.amplitude(0), SUNSOFT5B_LOG_VOL[7]);
1225 }
1226
1227 #[test]
1228 fn log_vol32_odd_entries_match_4bit() {
1229 // The 4-bit fixed-volume table is exactly the odd levels of the 5-bit
1230 // envelope DAC, so a fixed-volume channel and an envelope channel
1231 // resting on the same level agree to the bit.
1232 for v in 0..16 {
1233 assert_eq!(SUNSOFT5B_LOG_VOL32[2 * v + 1], SUNSOFT5B_LOG_VOL[v]);
1234 }
1235 // Envelope levels 0 and 1 are both silence (per the NESdev wiki).
1236 assert_eq!(SUNSOFT5B_LOG_VOL32[0], 0);
1237 assert_eq!(SUNSOFT5B_LOG_VOL32[1], 0);
1238 // A strictly increasing log ramp above the silent floor.
1239 for e in 3..32 {
1240 assert!(SUNSOFT5B_LOG_VOL32[e] > SUNSOFT5B_LOG_VOL32[e - 1]);
1241 }
1242 }
1243
1244 #[cfg(feature = "mapper-audio")]
1245 #[test]
1246 fn sunsoft5b_mix_output_sign_silent_vs_active() {
1247 // With every channel muted (mixer = 0xFF disables both tone and
1248 // noise on A/B/C; volumes don't matter), the linear sum is 0 and
1249 // the mix output sits at -DC_BIAS (centered). With one channel
1250 // unmuted at max volume and the square wave high, the sum exceeds
1251 // the bias and the mix is positive.
1252 let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
1253 fme7_audio_write(&mut m, 0x07, 0x3F); // bits 0..=5 all set => all disabled.
1254 // Volumes irrelevant when channels are muted.
1255 let silent = m.mix_audio();
1256 assert_eq!(silent, -SUNSOFT5B_DC_BIAS);
1257
1258 // Enable tone A only at max volume, then force the square level high
1259 // by ticking once with period = 0 (the chip wraps period=0 to 1).
1260 fme7_audio_write(&mut m, 0x07, 0b0011_1110); // tone A enabled (bit 0 = 0).
1261 fme7_audio_write(&mut m, 0x08, 0x0F); // channel A volume = 15.
1262 // Manually toggle the tone level so we hit the "high" half-cycle.
1263 m.audio.tone_a.level = 1;
1264 let active = m.mix_audio();
1265 assert!(
1266 active > 0,
1267 "active mix output should be positive, got {active}"
1268 );
1269 }
1270
1271 #[test]
1272 fn sunsoft5b_save_state_v2_round_trips_audio() {
1273 // Round-trip an FME-7 with a non-trivial audio register file. The
1274 // load_state path reconstructs the live period/shape state from the
1275 // serialized register file, so verifying via `audio.tone_a.period`
1276 // exercises both the regs blob and the reconstruction path.
1277 let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
1278 fme7_audio_write(&mut m, 0x00, 0x55);
1279 fme7_audio_write(&mut m, 0x01, 0x06);
1280 fme7_audio_write(&mut m, 0x08, 0x0F);
1281 fme7_audio_write(&mut m, 0x07, 0x36); // a few tone/noise enables.
1282 fme7_audio_write(&mut m, 0x0D, 0x0E); // envelope shape -> restart.
1283 let blob = m.save_state();
1284 assert_eq!(
1285 blob[0], FME7_SECTION_VERSION,
1286 "save_state writes the current version"
1287 );
1288
1289 let mut m2 = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
1290 m2.load_state(&blob).expect("round-trip");
1291 assert_eq!(m2.audio.tone_a.period, 0x0655);
1292 assert_eq!(m2.audio.regs[0x07], 0x36);
1293 assert_eq!(m2.audio.regs[0x08], 0x0F);
1294 assert_eq!(m2.audio.envelope.shape, 0x0E);
1295 }
1296
1297 /// v2.9.8 (ADR 0042): only the current (v3) layout loads. A v1 blob (no
1298 /// audio tail) and a v2 blob (no CHR-RAM tail, written through v2.9.1)
1299 /// used to load with those parts left as they were.
1300 #[test]
1301 fn fme7_pre_v3_blobs_are_refused() {
1302 let m = Fme7::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
1303 let mut v2 = m.save_state();
1304 v2.truncate(v2.len() - m.chr_rom.len());
1305 v2[0] = 2;
1306 let mut v1 = v2.clone();
1307 v1.truncate(v1.len() - Sunsoft5BAudio::TAIL_LEN);
1308 v1[0] = 1;
1309 let mut m2 = Fme7::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
1310 for (v, old) in [(1u8, &v1), (2, &v2)] {
1311 assert!(matches!(
1312 m2.load_state(old),
1313 Err(MapperError::UnsupportedVersion(got)) if got == v
1314 ));
1315 }
1316 }
1317
1318 /// v2.9.2 cartridge-RAM sweep: the section carries the 8 KiB CHR-RAM of
1319 /// a board with no CHR-ROM. The whole-machine pin is
1320 /// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`.
1321 #[test]
1322 fn fme7_save_state_carries_chr_ram() {
1323 let mut m = Fme7::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
1324 m.chr_rom[0x0000] = 0x11;
1325 m.chr_rom[0x1FFF] = 0x22;
1326 let blob = m.save_state();
1327 let mut m2 = Fme7::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
1328 m2.load_state(&blob).expect("round-trip");
1329 assert_eq!(m2.chr_rom[0x0000], 0x11);
1330 assert_eq!(m2.chr_rom[0x1FFF], 0x22);
1331 }
1332
1333 /// A v3 blob one byte short (inside the CHR-RAM tail) is rejected before
1334 /// any state changes.
1335 #[test]
1336 fn fme7_truncated_chr_ram_tail_is_rejected() {
1337 let mut m = Fme7::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
1338 m.cpu_write(0x8000, 9);
1339 m.cpu_write(0xA000, 5);
1340 let blob = m.save_state();
1341 let mut m2 = Fme7::new(synth(8), Box::new([]), Mirroring::Vertical).unwrap();
1342 let err = m2
1343 .load_state(&blob[..blob.len() - 1])
1344 .expect_err("a truncated v3 blob must be rejected");
1345 assert!(matches!(err, MapperError::WrongLength { .. }), "{err:?}");
1346 assert_eq!(m2.prg_banks[1], 0, "untouched");
1347 }
1348
1349 #[test]
1350 fn sunsoft5b_mapper_audio_off_path_latches_state_but_stays_silent() {
1351 // When the `mapper-audio` feature is OFF, the register decoder still
1352 // latches every write (so save-state round-trip stays correct) but
1353 // the oscillators never advance and `mix_audio` returns 0.
1354 //
1355 // We can't toggle the cargo feature from inside a test, but we CAN
1356 // assert the two halves of this contract directly:
1357 // 1. The register latch path is unconditional (this test runs
1358 // regardless of the feature flag).
1359 // 2. The oscillator clock path is gated — verified by the absence
1360 // of `audio.clock()` calls in `notify_cpu_cycle` when the
1361 // feature is off (compile-time `#[cfg(...)]`).
1362 // To exercise (1), write to every register and confirm `regs` and
1363 // the derived period fields are populated. To exercise (2)'s
1364 // observable effect, freeze the counters by NOT calling notify and
1365 // confirm the level state stays at zero.
1366 let mut m = Fme7::new(synth(8), synth_chr(8), Mirroring::Vertical).unwrap();
1367 for r in 0u8..=0x0F {
1368 fme7_audio_write(&mut m, r, r.wrapping_mul(0x11));
1369 }
1370 assert_eq!(m.audio.regs[0x00], 0x00);
1371 assert_eq!(m.audio.regs[0x0F], 0xFF);
1372 // Without any clock() calls, the tone level remains at default 0.
1373 assert_eq!(m.audio.tone_a.level, 0);
1374 assert_eq!(m.audio.tone_b.level, 0);
1375 assert_eq!(m.audio.tone_c.level, 0);
1376 }
1377
1378 #[test]
1379 #[cfg(feature = "mapper-audio")]
1380 fn sunsoft5b_volume_dac_follows_logarithmic_step_law() {
1381 // The DAC SHAPE criterion. (The absolute LEVEL is a separate concern
1382 // with its own oracle — `level_db_5b` against the `db_5b` ROM, wired in
1383 // v2.2.3 A1; it used to be an i16-headroom deferral.) The 5B volume DAC
1384 // is logarithmic, ~+3 dB
1385 // (×1.1885² ≈ ×1.4125) per 4-bit step, matching Mesen2's
1386 // `Sunsoft5bAudio` `_volumeLut` (LUT[12]=63, LUT[15]=177) and tetanes.
1387 assert_eq!(SUNSOFT5B_LOG_VOL[0], 0, "silence at volume 0");
1388 // Shape parity with Mesen2's table (floor(10^(0.15*i))) at the two
1389 // survey-relevant points.
1390 assert_eq!(SUNSOFT5B_LOG_VOL[12], 668);
1391 assert_eq!(SUNSOFT5B_LOG_VOL[15], 1882);
1392 // Each non-zero step multiplies by ~1.4125 (the +1.5 dB × 2 law).
1393 for v in 2..16usize {
1394 let ratio = f64::from(SUNSOFT5B_LOG_VOL[v]) / f64::from(SUNSOFT5B_LOG_VOL[v - 1]);
1395 assert!(
1396 (ratio - 1.4125).abs() < 0.06,
1397 "5B DAC step {v}: ratio {ratio:.4} not ~1.4125 (logarithmic law violated)"
1398 );
1399 }
1400 // vol-15 is ~2.82× vol-12 (three +3 dB steps = ×1.4125^3 ≈ 2.818),
1401 // the ~9 dB the `db_5b` ROM's vol-12 choice sits below full volume.
1402 let v15_v12 = f64::from(SUNSOFT5B_LOG_VOL[15]) / f64::from(SUNSOFT5B_LOG_VOL[12]);
1403 assert!(
1404 (v15_v12 - 2.818).abs() < 0.05,
1405 "vol-15/vol-12 ratio {v15_v12:.4} not ~2.818"
1406 );
1407 }
1408}