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}