Skip to main content

rustynes_mappers/
nsf.rs

1//! NSF music-file player (v1.1.0 beta.2, Workstream D, T-110-D1; non-60-Hz +
2//! `NSFE` support added in the v2.1.x "Fathom" line).
3//!
4//! Both containers are parsed: the classic `NESM\x1a` header and the extended
5//! chunked `NSFE` (see [`parse_nsfe`]), dispatched by [`parse_nsf`] on the magic.
6//! Expansion-chip audio (VRC6/7, FDS, MMC5, N163,
7//! Sunsoft 5B) declared in the `$07B` bitfield IS synthesized — the NSF
8//! mapper routes the expansion register windows into the existing cartridge
9//! synth cores via [`crate::nsf_expansion`] (v1.7.0 "Forge" G2/G3).
10//!
11//! An `.nsf` file is a ripped NES music engine plus a small header describing
12//! how to drive it: a `load`/`init`/`play` address triplet, a song count, and
13//! optional 4 KiB bank-switching. There is no PPU program — the file is *only*
14//! sound code.
15//!
16//! Rather than invent a bespoke "run init / run play" execution mode in the
17//! core (which would need its own determinism story), this module follows the
18//! Mesen2 / FCEUX / rustico approach: a synthetic 6502 **driver** ("BIOS") is
19//! mapped into otherwise-unused address space, and the standard reset / NMI
20//! vectors are pointed at it. The driver calls `init` once (with the selected
21//! song in A and the NTSC/PAL flag in X), then spins. **Play rate:** at the
22//! standard 60 Hz the driver enables vblank NMI and the PPU's ordinary 60 Hz
23//! vblank NMI calls `play` once per frame (the original, byte-identical path).
24//! At a **non-standard rate** (a PAL 50 Hz tune, or any custom µs divider, on
25//! the NTSC console — see [`Nsf::nonstandard_play_period_cycles`]) the driver
26//! instead disables the APU frame IRQ and arms a mapper **cycle-timer** that
27//! raises a (level-triggered, `$5FF1`-acked) IRQ every `period` CPU cycles; the
28//! IRQ handler calls `play`. Either way it runs through the unchanged
29//! `Nes::run_frame` lockstep loop, so the APU produces audio exactly as it does
30//! for a cartridge and the determinism contract is untouched.
31//!
32//! Scope: the base 2A03 APU, standard `$5FF8-$5FFF` `$8000-$FFFF` 4 KiB
33//! bank-switching, NTSC / PAL / custom play rates, and expansion-chip audio
34//! (VRC6/7, MMC5, N163, Sunsoft 5B, FDS) routed into the existing synth cores.
35//! The FDS-style `$5FF6/$5FF7` RAM banking is deferred (documented).
36
37use crate::cartridge::Mirroring;
38use crate::mapper::{Mapper, MapperCaps, MapperError, MapperFrameEvents};
39use crate::nsf_expansion::NsfExpansion;
40use alloc::{boxed::Box, format, vec, vec::Vec};
41
42/// Header length of a classic NSF file (`NESM` form).
43const NSF_HEADER_LEN: usize = 0x80;
44
45/// `NESM\x1A` magic.
46const NSF_MAGIC: &[u8; 5] = b"NESM\x1A";
47
48/// Address the synthetic driver ("BIOS") is mapped at. `$5000` is in the
49/// `$4020-$5FFF` expansion window that base NSFs never touch (the only thing
50/// down here for a 2A03 tune is the `$5FF8-$5FFF` bank registers).
51const DRIVER_BASE: u16 = 0x5000;
52
53/// Offset of the song-number immediate operand inside the driver image (see the
54/// driver listing in [`NsfMapper::build_driver`]). Re-patched on track change.
55const DRIVER_SONG_OPERAND: usize = 0x06;
56
57/// Entry points within the driver (absolute addresses).
58const DRIVER_INIT_ENTRY: u16 = DRIVER_BASE; // reset vector target
59const DRIVER_NMI_ENTRY: u16 = DRIVER_BASE + 0x15; // NMI vector target
60const DRIVER_IRQ_ENTRY: u16 = DRIVER_BASE + 0x23; // IRQ vector target (RTI stub)
61
62/// Parsed NSF header + program image.
63#[derive(Debug, Clone)]
64pub struct Nsf {
65    /// Total number of songs in the file (1-based count).
66    pub total_songs: u8,
67    /// The 1-based song the file wants to start on.
68    pub starting_song: u8,
69    /// 16-bit load address of the program image.
70    pub load_addr: u16,
71    /// `init` routine address (called once per track with A = song index).
72    pub init_addr: u16,
73    /// `play` routine address (called once per frame).
74    pub play_addr: u16,
75    /// `true` when the file uses `$5FF8-$5FFF` 4 KiB bank switching.
76    pub bankswitched: bool,
77    /// Initial bank register values (`$070-$077` of the header).
78    pub initial_banks: [u8; 8],
79    /// Expansion-chip bitfield (`$07B`). Routed into the matching synth cores
80    /// by the `nsf_expansion` module when any bit is set (v1.7.0 G2/G3).
81    pub expansion: u8,
82    /// `true` when the file declares a PAL or dual-region timing preference.
83    pub pal: bool,
84    /// NTSC play-speed divider (header `$6E-$6F`), in microseconds per `play`
85    /// call. `0` means "use the hardware default" (≈16639 µs ≈ 60.0988 Hz).
86    /// Classic `NESM` only; `NSFe` has no µs word and derives the rate from region.
87    pub play_speed_ntsc: u16,
88    /// PAL play-speed divider (header `$78-$79`), in microseconds per `play`
89    /// call. `0` means the PAL hardware default (≈19997 µs ≈ 50.007 Hz).
90    pub play_speed_pal: u16,
91    /// The program image, padded + 4 KiB-aligned when bank-switched.
92    pub prg: Vec<u8>,
93    /// UTF-8-lossy song / artist / copyright strings (trimmed at the first NUL).
94    pub song_name: Box<str>,
95    /// Artist name.
96    pub artist: Box<str>,
97    /// Copyright holder.
98    pub copyright: Box<str>,
99}
100
101fn read_u16(bytes: &[u8], off: usize) -> u16 {
102    u16::from(bytes[off]) | (u16::from(bytes[off + 1]) << 8)
103}
104
105fn read_string(bytes: &[u8], off: usize) -> Box<str> {
106    let raw = &bytes[off..off + 32];
107    let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
108    alloc::string::String::from_utf8_lossy(&raw[..end])
109        .into_owned()
110        .into_boxed_str()
111}
112
113/// `NSFE` magic (the extended chunked container).
114const NSFE_MAGIC: &[u8; 4] = b"NSFE";
115
116/// Detect the classic `NESM` magic at the start of `bytes`.
117#[must_use]
118pub fn is_nesm(bytes: &[u8]) -> bool {
119    bytes.len() >= NSF_MAGIC.len() && &bytes[0..NSF_MAGIC.len()] == NSF_MAGIC
120}
121
122/// Detect the extended `NSFE` magic at the start of `bytes`.
123#[must_use]
124pub fn is_nsfe(bytes: &[u8]) -> bool {
125    bytes.len() >= NSFE_MAGIC.len() && &bytes[0..NSFE_MAGIC.len()] == NSFE_MAGIC
126}
127
128/// Detect an NSF-family music file (classic `NESM` **or** extended `NSFE`).
129#[must_use]
130pub fn is_nsf(bytes: &[u8]) -> bool {
131    is_nesm(bytes) || is_nsfe(bytes)
132}
133
134/// Parse an NSF-family music file — classic `NESM` **or** extended `NSFE`.
135///
136/// Dispatches on the container magic; both fill the same [`Nsf`]. `NSFe` carries
137/// its play rate through the region flags (no µs divider), so its
138/// [`Nsf::play_speed_ntsc`]/[`Nsf::play_speed_pal`] stay `0` and
139/// [`Nsf::effective_speed_us`] resolves them to the region default.
140///
141/// # Errors
142///
143/// Returns a [`MapperError::Invalid`] when the magic is wrong, the file is
144/// truncated/short, or the header/chunks are internally inconsistent.
145pub fn parse_nsf(bytes: &[u8]) -> Result<Nsf, MapperError> {
146    if is_nsfe(bytes) {
147        return parse_nsfe(bytes);
148    }
149    if bytes.len() < NSF_HEADER_LEN {
150        return Err(MapperError::Invalid(format!(
151            "NSF file is shorter than the {NSF_HEADER_LEN}-byte header ({} bytes)",
152            bytes.len()
153        )));
154    }
155    if !is_nsf(bytes) {
156        return Err(MapperError::Invalid(
157            "NSF magic bytes do not match \"NESM\\x1A\"".into(),
158        ));
159    }
160
161    let total_songs = bytes[0x06];
162    let starting_song = bytes[0x07];
163    let load_addr = read_u16(bytes, 0x08);
164    let init_addr = read_u16(bytes, 0x0A);
165    let play_addr = read_u16(bytes, 0x0C);
166    let mut initial_banks = [0u8; 8];
167    initial_banks.copy_from_slice(&bytes[0x70..0x78]);
168    let bankswitched = initial_banks.iter().any(|&b| b != 0);
169    let expansion = bytes[0x7B];
170    // PAL/NTSC selection byte ($07A): bit0 = PAL, bit1 = dual. Treat either as
171    // "not strictly NTSC" for the init-register flag AND for picking which
172    // play-speed divider drives the (now non-60-Hz-capable) player.
173    let pal = bytes[0x7A] & 0b11 != 0;
174    // Play-speed dividers ($6E-$6F NTSC, $78-$79 PAL), microseconds per `play`.
175    let play_speed_ntsc = read_u16(bytes, 0x6E);
176    let play_speed_pal = read_u16(bytes, 0x78);
177
178    if total_songs == 0 {
179        return Err(MapperError::Invalid("NSF declares zero songs".into()));
180    }
181    if load_addr < 0x6000 {
182        return Err(MapperError::Invalid(format!(
183            "NSF load address ${load_addr:04X} is below $6000"
184        )));
185    }
186
187    let mut prg: Vec<u8> = bytes[NSF_HEADER_LEN..].to_vec();
188
189    if bankswitched {
190        // Pad the front so that bank 0 begins at `load_addr & 0x0FFF`, then
191        // round the whole image up to a 4 KiB bank boundary (rustico/Mesen2).
192        let pad = (load_addr & 0x0FFF) as usize;
193        let mut image = vec![0u8; pad];
194        image.extend_from_slice(&prg);
195        if !image.len().is_multiple_of(0x1000) {
196            image.resize(image.len() + (0x1000 - image.len() % 0x1000), 0);
197        }
198        prg = image;
199    }
200
201    Ok(Nsf {
202        total_songs,
203        starting_song,
204        load_addr,
205        init_addr,
206        play_addr,
207        bankswitched,
208        initial_banks,
209        expansion,
210        pal,
211        play_speed_ntsc,
212        play_speed_pal,
213        prg,
214        song_name: read_string(bytes, 0x0E),
215        artist: read_string(bytes, 0x2E),
216        copyright: read_string(bytes, 0x4E),
217    })
218}
219
220/// NTSC CPU clock (Hz) — the console NSF playback runs on. Used to convert a
221/// play-speed divider (µs) into a CPU-cycle period for the non-60-Hz timer.
222const NTSC_CPU_HZ: u32 = 1_789_773;
223/// CPU cycles in one NTSC PPU frame (89342 dots / 3). The vblank-NMI path calls
224/// `play` once per frame; a divider that resolves to this period IS 60 Hz.
225const NTSC_FRAME_CYCLES: u32 = 29781;
226/// Default NTSC divider when the header word is 0 (`1_000_000 / 60.0988`).
227const NTSC_STD_SPEED_US: u16 = 16639;
228/// Default PAL divider when the header word is 0 (`1_000_000 / 50.007`).
229const PAL_STD_SPEED_US: u16 = 19997;
230
231impl Nsf {
232    /// The effective play-speed divider (µs) this file wants, resolving a `0`
233    /// header word to the region hardware default and picking NTSC vs PAL by the
234    /// region flag.
235    #[must_use]
236    pub const fn effective_speed_us(&self) -> u16 {
237        if self.pal {
238            if self.play_speed_pal == 0 {
239                PAL_STD_SPEED_US
240            } else {
241                self.play_speed_pal
242            }
243        } else if self.play_speed_ntsc == 0 {
244            NTSC_STD_SPEED_US
245        } else {
246            self.play_speed_ntsc
247        }
248    }
249
250    /// The `play` period in NTSC CPU cycles, or `None` when the file plays at
251    /// the standard once-per-NTSC-frame 60 Hz rate (the fast, vblank-NMI-driven
252    /// path). `Some(cycles)` selects the cycle-timer IRQ driver — a PAL tune
253    /// (50 Hz) or any custom divider on the NTSC console.
254    #[must_use]
255    pub fn nonstandard_play_period_cycles(&self) -> Option<u32> {
256        let us = u64::from(self.effective_speed_us());
257        // Max divider (65535 µs) → ~117k cycles, always fits u32.
258        let cycles = u32::try_from(us * u64::from(NTSC_CPU_HZ) / 1_000_000).unwrap_or(u32::MAX);
259        // Within a cycle or two of a full NTSC frame ⇒ treat as plain 60 Hz.
260        (cycles.abs_diff(NTSC_FRAME_CYCLES) > 2).then_some(cycles.max(1))
261    }
262}
263
264/// Parse an extended `NSFE` (chunked) music file into the shared [`Nsf`].
265///
266/// Layout: the 4-byte `NSFE` magic, then a sequence of chunks each prefixed by
267/// a little-endian `u32` size + a 4-byte `FourCC` tag, terminated by the zero-length
268/// `NEND` chunk (or EOF). We consume `INFO` (required, first), `DATA` (the
269/// program image, required), and the optional `BANK` (initial 4 KiB banks) and
270/// `auth` (game / artist / copyright / ripper NUL-separated strings). Unknown
271/// chunks are skipped — including mandatory (uppercase-initial) ones we do not
272/// model, which is the tolerant behaviour real players use for base-2A03 tunes.
273///
274/// # Errors
275///
276/// Returns [`MapperError::Invalid`] on a truncated file, a chunk that runs past
277/// EOF, a missing/short `INFO`, or a missing `DATA`.
278fn parse_nsfe(bytes: &[u8]) -> Result<Nsf, MapperError> {
279    let inval = |m: &str| MapperError::Invalid(alloc::format!("NSFE: {m}"));
280    if !is_nsfe(bytes) {
281        return Err(inval("magic bytes do not match \"NSFE\""));
282    }
283    let mut pos = NSFE_MAGIC.len();
284    let mut info: Option<&[u8]> = None;
285    let mut data: Option<&[u8]> = None;
286    let mut banks = [0u8; 8];
287    let (mut song_name, mut artist, mut copyright) = (
288        Box::<str>::default(),
289        Box::<str>::default(),
290        Box::<str>::default(),
291    );
292
293    while pos + 8 <= bytes.len() {
294        let size = u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]])
295            as usize;
296        let tag = &bytes[pos + 4..pos + 8];
297        let body_start = pos + 8;
298        let body_end = body_start
299            .checked_add(size)
300            .filter(|&e| e <= bytes.len())
301            .ok_or_else(|| inval("chunk size runs past end of file"))?;
302        let body = &bytes[body_start..body_end];
303        match tag {
304            b"NEND" => break,
305            b"INFO" => info = Some(body),
306            b"DATA" => data = Some(body),
307            b"BANK" => {
308                let n = body.len().min(8);
309                banks[..n].copy_from_slice(&body[..n]);
310            }
311            b"auth" => {
312                // Four NUL-terminated UTF-8 strings: game, artist, copyright, ripper.
313                let mut parts = body.split(|&b| b == 0);
314                let take = |p: Option<&[u8]>| -> Box<str> {
315                    alloc::string::String::from_utf8_lossy(p.unwrap_or(&[]))
316                        .into_owned()
317                        .into_boxed_str()
318                };
319                song_name = take(parts.next());
320                artist = take(parts.next());
321                copyright = take(parts.next());
322            }
323            _ => { /* skip unknown / unmodelled chunk */ }
324        }
325        pos = body_end;
326    }
327
328    let info = info.ok_or_else(|| inval("missing required INFO chunk"))?;
329    if info.len() < 8 {
330        return Err(inval("INFO chunk shorter than 8 bytes"));
331    }
332    let prg = data
333        .ok_or_else(|| inval("missing required DATA chunk"))?
334        .to_vec();
335
336    let load_addr = read_u16(info, 0);
337    let init_addr = read_u16(info, 2);
338    let play_addr = read_u16(info, 4);
339    let pal = info[6] & 0b11 != 0;
340    let expansion = info[7];
341    let total_songs = info.get(8).copied().unwrap_or(1).max(1);
342    // NSFe starting track is 0-based; the shared struct stores 1-based.
343    let starting_song = info.get(9).copied().unwrap_or(0).saturating_add(1);
344
345    if load_addr < 0x6000 {
346        return Err(inval("INFO load address is below $6000"));
347    }
348
349    let bankswitched = banks.iter().any(|&b| b != 0);
350    let mut prg = prg;
351    if bankswitched {
352        let pad = (load_addr & 0x0FFF) as usize;
353        let mut image = vec![0u8; pad];
354        image.extend_from_slice(&prg);
355        if !image.len().is_multiple_of(0x1000) {
356            image.resize(image.len() + (0x1000 - image.len() % 0x1000), 0);
357        }
358        prg = image;
359    }
360
361    Ok(Nsf {
362        total_songs,
363        starting_song,
364        load_addr,
365        init_addr,
366        play_addr,
367        bankswitched,
368        initial_banks: banks,
369        expansion,
370        pal,
371        // NSFe has no µs divider — the region flag drives the rate via
372        // `effective_speed_us` (NTSC 60 Hz vs PAL 50 Hz).
373        play_speed_ntsc: 0,
374        play_speed_pal: 0,
375        prg,
376        song_name,
377        artist,
378        copyright,
379    })
380}
381
382/// A synthetic "mapper" that plays an [`Nsf`] through the standard lockstep
383/// engine. See the module docs for the driver mechanism.
384#[allow(clippy::struct_excessive_bools)] // independent state flags, not an FSM
385pub struct NsfMapper {
386    prg: Box<[u8]>,
387    /// 8 KiB of work RAM at `$6000-$7FFF`.
388    wram: Box<[u8; 0x2000]>,
389    /// 4 KiB bank registers for `$8000-$FFFF` (eight slots).
390    banks: [u8; 8],
391    bankswitched: bool,
392    load_addr: u16,
393    init_addr: u16,
394    play_addr: u16,
395    pal: bool,
396    /// Number of songs (1-based count).
397    total_songs: u8,
398    /// Currently-selected song (0-based; what `init` receives in A).
399    current_song: u8,
400    /// The synthetic 6502 driver image served at [`DRIVER_BASE`].
401    driver: [u8; 0x50],
402    /// `Some(period)` selects the **non-60-Hz cycle-timer IRQ** driver: `play`
403    /// is called every `period` CPU cycles (a PAL 50-Hz tune, or any custom
404    /// divider, on the NTSC console). `None` is the standard once-per-vblank
405    /// 60-Hz path (byte-identical to the pre-feature player).
406    play_period_cycles: Option<u32>,
407    /// Free-running CPU-cycle counter toward the next `play` (timer mode only).
408    play_cycle_counter: u32,
409    /// Whether the driver has finished `init` and armed the play-timer (set by
410    /// the `$5FF0` write the timer-mode driver issues after `JSR init`). Gates
411    /// the IRQ so no `play` fires mid-init.
412    timer_enabled: bool,
413    /// Level-triggered play-timer IRQ line (timer mode only), cleared by the
414    /// driver's `$5FF1` acknowledge write.
415    irq_pending: bool,
416    /// Raw `$07B` expansion bitfield (kept for save-state reconstruction).
417    expansion: u8,
418    /// Expansion-audio synth cores, present only when the bitfield requests
419    /// at least one chip (G2/G3). `None` for a base-2A03 NSF, so the common
420    /// path carries no extra state and is byte-identical to before.
421    exp_audio: Option<NsfExpansion>,
422}
423
424impl NsfMapper {
425    /// Build the player from a parsed [`Nsf`], starting on its declared song.
426    #[must_use]
427    pub fn new(nsf: &Nsf) -> Self {
428        // `starting_song` is 1-based; clamp to the valid 0-based range in case a
429        // malformed header declares a start past `total_songs` (which is already
430        // guaranteed non-zero by `parse_nsf`).
431        let start = nsf
432            .starting_song
433            .saturating_sub(1)
434            .min(nsf.total_songs.saturating_sub(1));
435        let mut m = Self {
436            prg: nsf.prg.clone().into_boxed_slice(),
437            wram: Box::new([0u8; 0x2000]),
438            banks: nsf.initial_banks,
439            bankswitched: nsf.bankswitched,
440            load_addr: nsf.load_addr,
441            init_addr: nsf.init_addr,
442            play_addr: nsf.play_addr,
443            pal: nsf.pal,
444            total_songs: nsf.total_songs,
445            current_song: start,
446            driver: [0u8; 0x50],
447            play_period_cycles: nsf.nonstandard_play_period_cycles(),
448            play_cycle_counter: 0,
449            timer_enabled: false,
450            irq_pending: false,
451            expansion: nsf.expansion,
452            exp_audio: NsfExpansion::from_bits(nsf.expansion),
453        };
454        m.build_driver();
455        m
456    }
457
458    /// Number of selectable songs.
459    #[must_use]
460    pub const fn song_count(&self) -> u8 {
461        self.total_songs
462    }
463
464    /// The currently-selected 0-based song.
465    #[must_use]
466    pub const fn current_song(&self) -> u8 {
467        self.current_song
468    }
469
470    /// Select a 0-based song. Clamped to the valid range; re-patches the driver
471    /// so the next reset runs `init` for the new track.
472    pub fn set_song(&mut self, song: u8) {
473        self.current_song = song.min(self.total_songs.saturating_sub(1));
474        self.driver[DRIVER_SONG_OPERAND] = self.current_song;
475    }
476
477    /// Assemble the synthetic 6502 driver. Layout (addresses relative to
478    /// [`DRIVER_BASE`] = `$5000`):
479    ///
480    /// ```text
481    /// INIT ($5000):  SEI; CLD; LDX #$FF; TXS
482    ///                LDA #song; LDX #region; JSR init_addr   ; song operand @ +6
483    ///                LDA #$80; STA $2000                      ; enable vblank NMI
484    ///                CLI; loop: JMP loop                      ; spin; NMI drives play
485    /// NMI  ($5015):  PHA; TXA; PHA; TYA; PHA; JSR play_addr
486    ///                PLA; TAY; PLA; TAX; PLA; RTI
487    /// IRQ  ($5023):  RTI                                      ; base-NSF stub
488    /// ```
489    fn build_driver(&mut self) {
490        let il = (self.init_addr & 0xFF) as u8;
491        let ih = (self.init_addr >> 8) as u8;
492        let pl = (self.play_addr & 0xFF) as u8;
493        let ph = (self.play_addr >> 8) as u8;
494        let region = u8::from(self.pal);
495        let spin = DRIVER_BASE + 0x12;
496        let code: [u8; 0x24] = [
497            0x78, // 5000 SEI
498            0xD8, // 5001 CLD
499            0xA2,
500            0xFF, // 5002 LDX #$FF
501            0x9A, // 5004 TXS
502            0xA9,
503            self.current_song, // 5005 LDA #song   (operand @ 0x06)
504            0xA2,
505            region, // 5007 LDX #region
506            0x20,
507            il,
508            ih, // 5009 JSR init_addr
509            0xA9,
510            0x80, // 500C LDA #$80
511            0x8D,
512            0x00,
513            0x20, // 500E STA $2000
514            0x58, // 5011 CLI
515            0x4C,
516            (spin & 0xFF) as u8,
517            (spin >> 8) as u8, // 5012 JMP spin
518            0x48,              // 5015 PHA
519            0x8A,              // 5016 TXA
520            0x48,              // 5017 PHA
521            0x98,              // 5018 TYA
522            0x48,              // 5019 PHA
523            0x20,
524            pl,
525            ph,   // 501A JSR play_addr
526            0x68, // 501D PLA
527            0xA8, // 501E TAY
528            0x68, // 501F PLA
529            0xAA, // 5020 TAX
530            0x68, // 5021 PLA
531            0x40, // 5022 RTI
532            0x40, // 5023 RTI  (IRQ stub)
533        ];
534        self.driver[..code.len()].copy_from_slice(&code);
535        // Non-60-Hz files replace the standard vblank-NMI image with the
536        // cycle-timer IRQ driver (below).
537        if self.play_period_cycles.is_some() {
538            self.build_timer_driver(il, ih, pl, ph, region);
539        }
540    }
541
542    /// Assemble the **non-60-Hz** driver: instead of enabling vblank NMI, `INIT`
543    /// arms the mapper play-timer (`STA $5FF0`) after `JSR init`, and the `IRQ`
544    /// handler acknowledges the timer (`STA $5FF1`) then calls `play`. The NMI
545    /// entry becomes an `RTI` stub. Vector layout (`DRIVER_NMI_ENTRY` /
546    /// `DRIVER_IRQ_ENTRY`) is unchanged, so `cpu_read` of `$FFFA-$FFFF` and the
547    /// `DRIVER_SONG_OPERAND` (@ +6) stay valid.
548    fn build_timer_driver(&mut self, il: u8, ih: u8, pl: u8, ph: u8, region: u8) {
549        self.driver = [0u8; 0x50];
550        // INIT @ $5000: run the tune's `init`, then jump PAST the fixed NMI
551        // ($5015) / IRQ ($5023) entries to the continuation @ $5034 — which
552        // MUST disable the APU frame-counter IRQ before arming, or that IRQ
553        // shares this handler and drives `play` continuously.
554        let init_block: [u8; 0x0F] = [
555            0x78, // SEI
556            0xD8, // CLD
557            0xA2,
558            0xFF, // LDX #$FF
559            0x9A, // TXS
560            0xA9,
561            self.current_song, // LDA #song   (operand @ 0x06)
562            0xA2,
563            region, // LDX #region
564            0x20,
565            il,
566            ih, // JSR init
567            0x4C,
568            0x34,
569            0x50, // JMP $5034 (continuation)
570        ];
571        self.driver[..init_block.len()].copy_from_slice(&init_block);
572        // NMI stub @ $5015 (never enabled in timer mode; the vector still points
573        // here, so keep a safe RTI).
574        self.driver[0x15] = 0x40; // RTI
575        // IRQ play handler @ $5023: ack the timer ($5FF1), then `play`.
576        let irq_block: [u8; 0x11] = [
577            0x48, // PHA
578            0x8A, // TXA
579            0x48, // PHA
580            0x98, // TYA
581            0x48, // PHA
582            0x8D, 0xF1, 0x5F, // STA $5FF1  (ack timer IRQ)
583            0x20, pl, ph,   // JSR play
584            0x68, // PLA
585            0xA8, // TAY
586            0x68, // PLA
587            0xAA, // TAX
588            0x68, // PLA
589            0x40, // RTI
590        ];
591        self.driver[0x23..0x23 + irq_block.len()].copy_from_slice(&irq_block);
592        // Continuation @ $5034: disable the APU frame-counter IRQ ($4017 = $40,
593        // bit6 inhibit) ONCE (not per-`play`, so the APU frame sequencer isn't
594        // disturbed), arm the play-timer ($5FF0), enable IRQ, spin.
595        let cont_block: [u8; 0x0E] = [
596            0xA9, 0x40, // LDA #$40
597            0x8D, 0x17, 0x40, // STA $4017  (frame IRQ inhibit, 4-step)
598            0xA9, 0x01, // LDA #$01
599            0x8D, 0xF0, 0x5F, // STA $5FF0  (arm play-timer)
600            0x58, // CLI
601            0x4C, 0x3F, 0x50, // JMP $503F (spin: self)
602        ];
603        self.driver[0x34..0x34 + cont_block.len()].copy_from_slice(&cont_block);
604    }
605
606    /// Resolve a `$8000-$FFFF` CPU address to a PRG offset.
607    fn prg_offset(&self, addr: u16) -> Option<usize> {
608        if self.bankswitched {
609            let slot = ((addr - 0x8000) >> 12) as usize; // 0..=7
610            let bank = self.banks[slot] as usize;
611            let off = bank * 0x1000 + (addr as usize & 0x0FFF);
612            (off < self.prg.len()).then_some(off)
613        } else {
614            // Linear image loaded at `load_addr`.
615            let base = self.load_addr as usize;
616            let a = addr as usize;
617            (a >= base)
618                .then(|| a - base)
619                .filter(|&o| o < self.prg.len())
620        }
621    }
622}
623
624impl Mapper for NsfMapper {
625    fn caps(&self) -> MapperCaps {
626        // Base 60-Hz NSF (no expansion audio): no per-cycle hooks, no IRQ, no
627        // synthesis — same as before (`MapperCaps::NONE`). Expansion audio adds
628        // the CPU-cycle clock (oscillators), the frame-event hook (MMC5
629        // envelope/length cadence), and audio mixing. A non-60-Hz file adds the
630        // CPU-cycle clock (to advance the play-timer) + the IRQ source.
631        let timer = self.play_period_cycles.is_some();
632        let exp = self.exp_audio.is_some();
633        MapperCaps {
634            cpu_cycle_hook: exp || timer,
635            audio: exp,
636            frame_event_hook: exp,
637            irq_source: timer,
638        }
639    }
640
641    fn cpu_read(&mut self, addr: u16) -> u8 {
642        // Expansion-audio read ports (N163 data port `$4800-$4FFF`, MMC5
643        // status `$5015`) take precedence over the default mapper read.
644        if let Some(exp) = self.exp_audio.as_mut()
645            && let Some(byte) = exp.cpu_read(addr)
646        {
647            return byte;
648        }
649        match addr {
650            // Synthetic driver image.
651            a if (DRIVER_BASE..DRIVER_BASE + 0x50).contains(&a) => {
652                self.driver[(a - DRIVER_BASE) as usize]
653            }
654            // A non-bankswitched NSF may load its program into `$6000-$7FFF`
655            // (allowed: `load_addr >= 0x6000`). Serve the program there first,
656            // falling back to WRAM only where it doesn't reach (gemini #44).
657            0x6000..=0x7FFF => {
658                if !self.bankswitched
659                    && let Some(off) = self.prg_offset(addr)
660                {
661                    return self.prg[off];
662                }
663                self.wram[(addr - 0x6000) as usize]
664            }
665            // Interrupt vectors point at the driver, overriding any PRG bytes.
666            0xFFFA => (DRIVER_NMI_ENTRY & 0xFF) as u8,
667            0xFFFB => (DRIVER_NMI_ENTRY >> 8) as u8,
668            0xFFFC => (DRIVER_INIT_ENTRY & 0xFF) as u8,
669            0xFFFD => (DRIVER_INIT_ENTRY >> 8) as u8,
670            0xFFFE => (DRIVER_IRQ_ENTRY & 0xFF) as u8,
671            0xFFFF => (DRIVER_IRQ_ENTRY >> 8) as u8,
672            0x8000..=0xFFFF => self.prg_offset(addr).map_or(0, |o| self.prg[o]),
673            _ => 0,
674        }
675    }
676
677    fn cpu_write(&mut self, addr: u16, value: u8) {
678        match addr {
679            // 4 KiB bank registers for $8000-$FFFF (take priority over any
680            // expansion-audio window — base NSFs bank only through here).
681            0x5FF8..=0x5FFF if self.bankswitched => {
682                self.banks[(addr - 0x5FF8) as usize] = value;
683            }
684            // Non-60-Hz play-timer control (the timer-mode driver writes these;
685            // the standard driver never does, and they collide with no
686            // expansion-audio register). `$5FF0` = arm (init finished, start
687            // counting); `$5FF1` = acknowledge the level-triggered timer IRQ.
688            0x5FF0 => {
689                self.timer_enabled = true;
690                self.play_cycle_counter = 0;
691                self.irq_pending = false;
692            }
693            0x5FF1 => self.irq_pending = false,
694            0x6000..=0x7FFF => self.wram[(addr - 0x6000) as usize] = value,
695            _ => {
696                // Route everything else to the expansion-audio chips. For a
697                // base-2A03 NSF (`exp_audio == None`) this is a no-op, so the
698                // behaviour is unchanged.
699                if let Some(exp) = self.exp_audio.as_mut() {
700                    exp.cpu_write(addr, value);
701                }
702            }
703        }
704    }
705
706    fn cpu_read_unmapped(&self, addr: u16) -> bool {
707        // `$6000-$7FFF` is the runtime's 8 KiB WRAM and always mapped, although
708        // `sram()` is empty (nothing here is battery-backed). So this board does
709        // NOT take v2.7.2's "no save RAM -> the window floats" default; it did in
710        // the first cut, and a tune's RAM read the bus latch (PR #550).
711        {
712            // The driver image IS mapped in $4020-$5FFF, so the bus must use our
713            // real bytes there (not open bus). Everything else in that window is
714            // unmapped (open bus) -- including the bank registers $5FF8-$5FFF,
715            // which are write-only: the NSF spec's readable-address list omits
716            // them (v2.9.3; before, they read back as a mapped 0).
717            if !(0x4020..=0x5FFF).contains(&addr) {
718                return false;
719            }
720            if (DRIVER_BASE..DRIVER_BASE + 0x50).contains(&addr) {
721                return false;
722            }
723            // Expansion-audio read ports (N163 `$4800-$4FFF`, MMC5 `$5015`) are
724            // mapped when those chips are present (real bytes, not open bus).
725            if self.exp_audio.is_some() && ((0x4800..=0x4FFF).contains(&addr) || addr == 0x5015) {
726                return false;
727            }
728            true
729        }
730    }
731
732    fn notify_cpu_cycle(&mut self) {
733        if let Some(exp) = self.exp_audio.as_mut() {
734            exp.clock();
735        }
736        // Non-60-Hz play-timer: once armed by the driver's `$5FF0` write, count
737        // CPU cycles and raise the (level-triggered) IRQ line every `period`
738        // cycles. It stays asserted until the driver's `$5FF1` ack, mirroring
739        // the MMC3/FME-7 mapper-IRQ discipline the bus already polls.
740        if let Some(period) = self.play_period_cycles
741            && self.timer_enabled
742        {
743            self.play_cycle_counter += 1;
744            if self.play_cycle_counter >= period {
745                self.play_cycle_counter = 0;
746                self.irq_pending = true;
747            }
748        }
749    }
750
751    fn irq_pending(&self) -> bool {
752        self.irq_pending
753    }
754
755    fn notify_frame_event(&mut self, events: MapperFrameEvents) {
756        if let Some(exp) = self.exp_audio.as_mut() {
757            exp.frame_event(events.quarter, events.half);
758        }
759    }
760
761    fn mix_audio(&mut self) -> i32 {
762        self.exp_audio.as_ref().map_or(0, NsfExpansion::mix)
763    }
764
765    fn ppu_read(&mut self, _addr: u16) -> u8 {
766        // No CHR: NSF files carry no graphics. Reads return open-bus-ish 0.
767        0
768    }
769
770    fn ppu_write(&mut self, _addr: u16, _value: u8) {}
771
772    fn current_mirroring(&self) -> Mirroring {
773        Mirroring::Horizontal
774    }
775
776    fn nsf_song_count(&self) -> u8 {
777        self.total_songs
778    }
779
780    fn nsf_current_song(&self) -> u8 {
781        self.current_song
782    }
783
784    fn nsf_set_song(&mut self, song: u8) -> bool {
785        self.set_song(song);
786        true
787    }
788
789    fn save_state(&self) -> Vec<u8> {
790        // v1: version + song + 8 bank regs + WRAM.
791        // v2 (G2/G3): appends a 1-byte expansion-audio presence tail when
792        // expansion audio is present. A base-2A03 NSF writes v1, so both are
793        // current; which one a file must carry follows from its `$07B` header.
794        let has_exp = self.exp_audio.is_some();
795        let version = if has_exp { 2u8 } else { 1u8 };
796        let mut out = Vec::with_capacity(2 + 8 + self.wram.len() + usize::from(has_exp));
797        out.push(version);
798        out.push(self.current_song);
799        out.extend_from_slice(&self.banks);
800        out.extend_from_slice(self.wram.as_ref());
801        if let Some(exp) = self.exp_audio.as_ref() {
802            exp.save_state(&mut out);
803        }
804        out
805    }
806
807    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
808        let version = data.first().copied().unwrap_or(0);
809        // The version this file writes, from its expansion chips: v1 for a
810        // base-2A03 NSF, v2 with expansion audio. Until v2.9.8 either was
811        // accepted on either file, so a pre-v1.7.0 v1 blob loaded on an
812        // expansion NSF; only the matching one loads now (ADR 0042).
813        let own = if self.exp_audio.is_some() { 2 } else { 1 };
814        if version != own {
815            return Err(MapperError::UnsupportedVersion(version));
816        }
817        let core_len = 2 + 8 + self.wram.len();
818        // v1 must match exactly; v2 carries a 1-byte expansion tail.
819        let expected = core_len + usize::from(version == 2);
820        if data.len() != expected {
821            return Err(MapperError::WrongLength {
822                expected,
823                got: data.len(),
824            });
825        }
826        // Clamp on restore: a corrupt save-state must not feed an out-of-range
827        // song index into `build_driver` (valid round-trips are unaffected, since
828        // `set_song` already keeps `current_song < total_songs`).
829        self.current_song = data[1].min(self.total_songs.saturating_sub(1));
830        self.banks.copy_from_slice(&data[2..10]);
831        self.wram.copy_from_slice(&data[10..core_len]);
832        // The expansion-audio chips are reconstructed from the (immutable)
833        // `$07B` bitfield; the v2 presence byte is self-describing but does
834        // not carry oscillator phase, so we rebuild the chips fresh. Live
835        // synthesis re-converges from the next register write (the correct
836        // behaviour for a paused/restored NSF — see `NsfExpansion::save_state`).
837        self.exp_audio = NsfExpansion::from_bits(self.expansion);
838        // Consume the v2 expansion tail (round-trips with `save_state`). The
839        // byte only carries which chips were present, so we validate it against
840        // the chips rebuilt from `$07B` rather than discarding it; a mismatch
841        // means the tail describes a different chip set than this ROM's header.
842        if version == 2 {
843            let tail = data[core_len];
844            let rebuilt = self
845                .exp_audio
846                .as_ref()
847                .map_or(0, NsfExpansion::presence_bits);
848            if tail != rebuilt {
849                return Err(MapperError::Invalid(format!(
850                    "NSF v2 expansion presence tail {tail:#04x} disagrees with the $07B bitfield {rebuilt:#04x}"
851                )));
852            }
853        }
854        // Non-60-Hz play-timer runtime is not serialized (the sub-period counter
855        // phase is inaudible across a restore, so the save-state format is
856        // unchanged for both standard and timer files). A restored timer file
857        // was already past `init`, so re-arm it; a standard file leaves these
858        // inert (`play_period_cycles` is `None`).
859        self.timer_enabled = self.play_period_cycles.is_some();
860        self.play_cycle_counter = 0;
861        self.irq_pending = false;
862        self.build_driver();
863        Ok(())
864    }
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    /// Build a minimal valid NSF: 1 song, load=$8000, init=$8000, play=$8003,
872    /// non-bankswitched, a few bytes of "program".
873    fn synth_nsf() -> Vec<u8> {
874        let mut f = vec![0u8; NSF_HEADER_LEN];
875        f[0..5].copy_from_slice(NSF_MAGIC);
876        f[0x05] = 1; // version
877        f[0x06] = 3; // total songs
878        f[0x07] = 1; // starting song (1-based)
879        f[0x08] = 0x00;
880        f[0x09] = 0x80; // load $8000
881        f[0x0A] = 0x00;
882        f[0x0B] = 0x80; // init $8000
883        f[0x0C] = 0x03;
884        f[0x0D] = 0x80; // play $8003
885        // program: RTS at init, RTS at play
886        f.extend_from_slice(&[0x60, 0xEA, 0xEA, 0x60]);
887        f
888    }
889
890    #[test]
891    fn parses_header_fields() {
892        let nsf = parse_nsf(&synth_nsf()).expect("valid nsf");
893        assert_eq!(nsf.total_songs, 3);
894        assert_eq!(nsf.starting_song, 1);
895        assert_eq!(nsf.load_addr, 0x8000);
896        assert_eq!(nsf.init_addr, 0x8000);
897        assert_eq!(nsf.play_addr, 0x8003);
898        assert!(!nsf.bankswitched);
899    }
900
901    /// The NSF runtime gives every tune 8 KiB of WRAM at `$6000-$7FFF`, and
902    /// `sram()` stays empty because nothing there is battery-backed. v2.7.2's
903    /// first cut floated the window whenever `sram()` was empty, so a tune's
904    /// RAM reads returned the bus latch (PR #550, Copilot). The iNES sweep in
905    /// `tests/prg_ram_window_open_bus.rs` never builds an NSF, which is why it
906    /// did not see this.
907    #[test]
908    fn wram_window_is_mapped_and_holds_data() {
909        let nsf = parse_nsf(&synth_nsf()).expect("valid nsf");
910        let mut m = NsfMapper::new(&nsf);
911        assert!(m.sram().is_empty(), "NSF WRAM is not save RAM");
912        // Both address bytes fold into the pattern, so a stuck page reads wrong.
913        let pattern = |a: u16| {
914            let [lo, hi] = a.to_le_bytes();
915            lo ^ hi ^ 0x5A
916        };
917        for a in 0x6000u16..=0x7FFF {
918            m.cpu_write(a, pattern(a));
919        }
920        for a in 0x6000u16..=0x7FFF {
921            assert!(!m.cpu_read_unmapped(a), "${a:04X} must stay mapped");
922            assert_eq!(m.cpu_read(a), pattern(a), "${a:04X}");
923        }
924    }
925
926    /// `$5FF8-$5FFF` are write-only bank registers: the NSF spec's list of
927    /// readable addresses (`nesdev_wiki/output/NSF.md`, "Summary of Addresses")
928    /// omits them, and only lists them as writable "if bankswitching is
929    /// enabled". A read there must float (open bus), bankswitched or not.
930    /// Until v2.9.3 the board reported them mapped and returned 0, so the bus
931    /// latched 0 (review thread on #44).
932    #[test]
933    fn bank_register_window_reads_as_open_bus() {
934        let plain = parse_nsf(&synth_nsf()).expect("valid nsf");
935        let mut f = synth_nsf();
936        f[0x70..0x78].copy_from_slice(&[0, 1, 2, 3, 4, 5, 6, 7]);
937        let banked = parse_nsf(&f).expect("valid bankswitched nsf");
938        assert!(banked.bankswitched);
939        for nsf in [&plain, &banked] {
940            let m = NsfMapper::new(nsf);
941            for a in 0x5FF8u16..=0x5FFF {
942                assert!(
943                    m.cpu_read_unmapped(a),
944                    "${a:04X} (banked={})",
945                    nsf.bankswitched
946                );
947            }
948        }
949    }
950
951    #[test]
952    fn rejects_bad_magic() {
953        let mut f = synth_nsf();
954        f[1] = b'X';
955        assert!(parse_nsf(&f).is_err());
956    }
957
958    #[test]
959    fn driver_vectors_point_into_driver() {
960        let nsf = parse_nsf(&synth_nsf()).expect("valid nsf");
961        let mut m = NsfMapper::new(&nsf);
962        // Reset vector -> driver init entry.
963        let lo = m.cpu_read(0xFFFC);
964        let hi = m.cpu_read(0xFFFD);
965        assert_eq!(u16::from(lo) | (u16::from(hi) << 8), DRIVER_INIT_ENTRY);
966        // NMI vector -> driver NMI entry.
967        let lo = m.cpu_read(0xFFFA);
968        let hi = m.cpu_read(0xFFFB);
969        assert_eq!(u16::from(lo) | (u16::from(hi) << 8), DRIVER_NMI_ENTRY);
970        // The driver's JSR init operand encodes init_addr ($8000).
971        assert_eq!(m.cpu_read(DRIVER_BASE + 0x0A), 0x00);
972        assert_eq!(m.cpu_read(DRIVER_BASE + 0x0B), 0x80);
973    }
974
975    #[test]
976    fn track_select_patches_driver_and_clamps() {
977        let nsf = parse_nsf(&synth_nsf()).expect("valid nsf");
978        let mut m = NsfMapper::new(&nsf);
979        m.set_song(2);
980        assert_eq!(m.current_song(), 2);
981        assert_eq!(m.cpu_read(DRIVER_BASE + 0x06), 2);
982        // Clamp: only 3 songs (0..=2), so 9 -> 2.
983        m.set_song(9);
984        assert_eq!(m.current_song(), 2);
985    }
986
987    #[test]
988    fn save_state_round_trips() {
989        let nsf = parse_nsf(&synth_nsf()).expect("valid nsf");
990        let mut m = NsfMapper::new(&nsf);
991        m.set_song(1);
992        m.cpu_write(0x6000, 0xAB);
993        let blob = m.save_state();
994        let mut m2 = NsfMapper::new(&nsf);
995        m2.load_state(&blob).expect("round trip");
996        assert_eq!(m2.current_song(), 1);
997        assert_eq!(m2.cpu_read(0x6000), 0xAB);
998    }
999
1000    #[test]
1001    fn expansion_save_state_round_trips_v2_tail() {
1002        // An NSF declaring VRC6 expansion audio ($07B bit0) emits a v2 blob with
1003        // the 1-byte presence tail; load_state must consume + validate it (round
1004        // trip), not error or leave bytes unread.
1005        let mut f = synth_nsf();
1006        f[0x7B] = 0x01; // EXP_VRC6
1007        let nsf = parse_nsf(&f).expect("valid expansion nsf");
1008        let mut m = NsfMapper::new(&nsf);
1009        assert!(m.exp_audio.is_some(), "VRC6 expansion must be present");
1010        m.set_song(2);
1011        m.cpu_write(0x6000, 0xCD);
1012        let blob = m.save_state();
1013        assert_eq!(blob.first().copied(), Some(2u8), "expansion NSF is v2");
1014        let mut m2 = NsfMapper::new(&nsf);
1015        m2.load_state(&blob).expect("v2 round trip");
1016        assert_eq!(m2.current_song(), 2);
1017        assert_eq!(m2.cpu_read(0x6000), 0xCD);
1018        assert!(
1019            m2.exp_audio.is_some(),
1020            "expansion rebuilt from $07B on load"
1021        );
1022    }
1023
1024    #[test]
1025    fn expansion_load_state_rejects_corrupt_presence_tail() {
1026        // A v2 blob whose presence tail disagrees with the $07B bitfield is a
1027        // corrupt save-state and must be rejected rather than silently accepted.
1028        let mut f = synth_nsf();
1029        f[0x7B] = 0x01; // EXP_VRC6
1030        let nsf = parse_nsf(&f).expect("valid expansion nsf");
1031        let mut m = NsfMapper::new(&nsf);
1032        let mut blob = m.save_state();
1033        *blob.last_mut().expect("tail byte") ^= 0x80; // flip a presence bit
1034        assert!(matches!(m.load_state(&blob), Err(MapperError::Invalid(_))));
1035    }
1036
1037    // ---- non-60-Hz playback (speed-word / cycle-timer IRQ) ----
1038
1039    fn synth_nsf_speed(ntsc_us: u16) -> Vec<u8> {
1040        let mut f = synth_nsf();
1041        f[0x6E] = (ntsc_us & 0xFF) as u8;
1042        f[0x6F] = (ntsc_us >> 8) as u8;
1043        f
1044    }
1045
1046    #[test]
1047    fn parses_and_resolves_speed_words() {
1048        // Explicit NTSC divider is parsed and used.
1049        let nsf = parse_nsf(&synth_nsf_speed(20000)).expect("valid");
1050        assert_eq!(nsf.play_speed_ntsc, 20000);
1051        assert_eq!(nsf.effective_speed_us(), 20000);
1052        // A 0 divider resolves to the NTSC hardware default (60 Hz standard).
1053        let nsf0 = parse_nsf(&synth_nsf_speed(0)).expect("valid");
1054        assert_eq!(nsf0.effective_speed_us(), NTSC_STD_SPEED_US);
1055    }
1056
1057    #[test]
1058    fn standard_rate_is_vblank_driven_timer_none() {
1059        // 0 (default) and the exact NTSC divider both classify as plain 60 Hz.
1060        for us in [0u16, NTSC_STD_SPEED_US] {
1061            let nsf = parse_nsf(&synth_nsf_speed(us)).expect("valid");
1062            assert_eq!(nsf.nonstandard_play_period_cycles(), None, "us={us}");
1063            let m = NsfMapper::new(&nsf);
1064            assert!(m.play_period_cycles.is_none());
1065            // Base 60-Hz NSF keeps the no-hooks capability set (byte-identical).
1066            assert_eq!(m.caps(), MapperCaps::NONE);
1067        }
1068    }
1069
1070    #[test]
1071    fn nonstandard_rate_selects_cycle_timer_driver() {
1072        // A PAL-ish 50-Hz divider (20000 µs) resolves to a non-frame period and
1073        // selects the timer driver (CPU-cycle hook + IRQ source).
1074        let nsf = parse_nsf(&synth_nsf_speed(20000)).expect("valid");
1075        let period = nsf.nonstandard_play_period_cycles().expect("timer mode");
1076        assert!(
1077            period > NTSC_FRAME_CYCLES,
1078            "slower than 60 Hz -> longer period"
1079        );
1080        let mut m = NsfMapper::new(&nsf);
1081        let caps = m.caps();
1082        assert!(caps.irq_source && caps.cpu_cycle_hook);
1083        // NMI vector -> RTI stub; IRQ vector -> play handler (starts with PHA).
1084        assert_eq!(m.cpu_read(DRIVER_NMI_ENTRY), 0x40); // RTI
1085        assert_eq!(m.cpu_read(DRIVER_IRQ_ENTRY), 0x48); // PHA
1086        // INIT jumps past the fixed NMI/IRQ entries to the continuation @ $5034.
1087        assert_eq!(m.cpu_read(DRIVER_BASE + 0x0C), 0x4C); // JMP
1088        assert_eq!(m.cpu_read(DRIVER_BASE + 0x0D), 0x34);
1089        assert_eq!(m.cpu_read(DRIVER_BASE + 0x0E), 0x50);
1090        // The continuation disables the APU frame IRQ (`STA $4017`) BEFORE it
1091        // arms the play-timer (`STA $5FF0`) — the ordering that stops the frame
1092        // IRQ from co-driving `play`.
1093        assert_eq!(m.cpu_read(DRIVER_BASE + 0x36), 0x8D); // STA $4017
1094        assert_eq!(m.cpu_read(DRIVER_BASE + 0x37), 0x17);
1095        assert_eq!(m.cpu_read(DRIVER_BASE + 0x38), 0x40);
1096        assert_eq!(m.cpu_read(DRIVER_BASE + 0x3B), 0x8D); // STA $5FF0 (arm)
1097        assert_eq!(m.cpu_read(DRIVER_BASE + 0x3C), 0xF0);
1098        assert_eq!(m.cpu_read(DRIVER_BASE + 0x3D), 0x5F);
1099    }
1100
1101    #[test]
1102    fn timer_raises_and_acks_irq_only_after_arming() {
1103        let nsf = parse_nsf(&synth_nsf_speed(20000)).expect("valid");
1104        let mut m = NsfMapper::new(&nsf);
1105        let period = m.play_period_cycles.expect("timer mode");
1106        // Before arming: cycles must NOT raise the IRQ (init still running).
1107        for _ in 0..(period + 10) {
1108            m.notify_cpu_cycle();
1109        }
1110        assert!(
1111            !m.irq_pending(),
1112            "timer must stay quiet until $5FF0 arms it"
1113        );
1114        // Arm (the driver's post-init `STA $5FF0`).
1115        m.cpu_write(0x5FF0, 1);
1116        for _ in 0..(period - 1) {
1117            m.notify_cpu_cycle();
1118        }
1119        assert!(!m.irq_pending(), "no IRQ one cycle early");
1120        m.notify_cpu_cycle(); // period-th cycle
1121        assert!(m.irq_pending(), "IRQ fires exactly at the period");
1122        // Level-triggered: the driver's `$5FF1` ack clears it.
1123        m.cpu_write(0x5FF1, 0);
1124        assert!(!m.irq_pending(), "ack clears the level-triggered line");
1125    }
1126
1127    #[test]
1128    fn timer_mode_save_state_round_trips_and_rearms() {
1129        let nsf = parse_nsf(&synth_nsf_speed(20000)).expect("valid");
1130        let mut m = NsfMapper::new(&nsf);
1131        m.cpu_write(0x5FF0, 1); // arm
1132        m.set_song(2);
1133        let blob = m.save_state();
1134        let mut m2 = NsfMapper::new(&nsf);
1135        m2.load_state(&blob).expect("round trip");
1136        assert_eq!(m2.current_song(), 2);
1137        // A restored timer file is re-armed (was past init) and fires again.
1138        let period = m2.play_period_cycles.expect("timer mode");
1139        for _ in 0..period {
1140            m2.notify_cpu_cycle();
1141        }
1142        assert!(m2.irq_pending(), "restored timer re-arms and fires");
1143    }
1144
1145    // ---- NSFe (extended chunked container) ----
1146
1147    /// Build a minimal `NSFe`: INFO (load/init/play $8000/$8000/$8003, 2 songs,
1148    /// optional PAL flag) + a short DATA + an `auth` metadata chunk + NEND.
1149    fn synth_nsfe(pal: bool) -> Vec<u8> {
1150        let mut f = Vec::new();
1151        f.extend_from_slice(b"NSFE");
1152        let chunk = |tag: &[u8; 4], body: &[u8], out: &mut Vec<u8>| {
1153            out.extend_from_slice(&u32::try_from(body.len()).unwrap().to_le_bytes());
1154            out.extend_from_slice(tag);
1155            out.extend_from_slice(body);
1156        };
1157        // INFO: load, init, play, region, expansion, tracks, start(0-based)
1158        let region = u8::from(pal);
1159        chunk(
1160            b"INFO",
1161            &[0x00, 0x80, 0x00, 0x80, 0x03, 0x80, region, 0x00, 2, 1],
1162            &mut f,
1163        );
1164        chunk(b"DATA", &[0x60, 0xEA, 0xEA, 0x60], &mut f);
1165        chunk(b"auth", b"Game Title\0Artist\0(c) 2026\0Ripper", &mut f);
1166        chunk(b"NEND", &[], &mut f);
1167        f
1168    }
1169
1170    #[test]
1171    fn parses_nsfe_info_data_and_auth() {
1172        assert!(is_nsfe(&synth_nsfe(false)));
1173        assert!(is_nsf(&synth_nsfe(false))); // combined detector accepts NSFE
1174        let nsf = parse_nsf(&synth_nsfe(false)).expect("valid nsfe");
1175        assert_eq!(nsf.load_addr, 0x8000);
1176        assert_eq!(nsf.init_addr, 0x8000);
1177        assert_eq!(nsf.play_addr, 0x8003);
1178        assert_eq!(nsf.total_songs, 2);
1179        assert_eq!(nsf.starting_song, 2); // 0-based start 1 -> 1-based 2
1180        assert_eq!(&*nsf.song_name, "Game Title");
1181        assert_eq!(&*nsf.artist, "Artist");
1182        assert_eq!(&*nsf.copyright, "(c) 2026");
1183        assert!(!nsf.pal);
1184        // NTSC NSFe plays at the standard 60 Hz (vblank path).
1185        assert_eq!(nsf.nonstandard_play_period_cycles(), None);
1186    }
1187
1188    #[test]
1189    fn nsfe_pal_region_selects_nonstandard_rate() {
1190        let nsf = parse_nsf(&synth_nsfe(true)).expect("valid pal nsfe");
1191        assert!(nsf.pal);
1192        // PAL on the NTSC console -> 50 Hz -> the cycle-timer driver.
1193        assert_eq!(nsf.effective_speed_us(), PAL_STD_SPEED_US);
1194        assert!(nsf.nonstandard_play_period_cycles().is_some());
1195        assert!(NsfMapper::new(&nsf).caps().irq_source);
1196    }
1197
1198    #[test]
1199    fn nsfe_rejects_missing_info_or_data() {
1200        // NSFE magic + immediate NEND: no INFO, no DATA.
1201        let mut f = Vec::from(*b"NSFE");
1202        f.extend_from_slice(&0u32.to_le_bytes());
1203        f.extend_from_slice(b"NEND");
1204        assert!(matches!(parse_nsf(&f), Err(MapperError::Invalid(_))));
1205    }
1206}