rustynes_mappers/fds.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the per-CRC FDS drive-timing table is derived from puNES (GPL-2.0-or-later), `src/core/fds.c`. See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! Famicom Disk System (FDS) — Stage 1 foundation (v2.2.0).
6//!
7//! This module implements the `.fds` container parser and the FDS RAM-adapter
8//! device (modelled as a [`Mapper`]). Stage 1 covers:
9//!
10//! - **Container parsing** ([`parse_fds`]): the fwNES 16-byte header form
11//! (`"FDS\x1A"` + side count) and the headerless raw form (first side opens
12//! with `\x01*NINTENDO-HVC*`). Each side is a 65500-byte block.
13//! - **Memory map**: 32 KiB PRG-RAM at `$6000-$DFFF` (read/write), an 8 KiB
14//! user-supplied BIOS at `$E000-$FFFF` (read-only), and 8 KiB CHR-RAM.
15//! - **Registers** `$4020-$4026` (write) / `$4030-$4033` (read).
16//! - **Timer IRQ**: a 16-bit down-counter clocked every CPU cycle.
17//! - **Disk read**: a head-position state machine delivering disk bytes at a
18//! ~149-CPU-cycle cadence with the byte-transfer flag (and optional IRQ).
19//!
20//! Stage 2b (v2.2.0) extends Stage 1 with:
21//!
22//! - **Disk write path**: with `$4025` in write mode (bit 2 clear), each
23//! ~149-CPU-cycle byte-transfer tick stores the byte last written to `$4024`
24//! into the inserted side at the head position, advances the head, raises the
25//! byte-transfer flag/IRQ, and marks the disk image **dirty**.
26//! - **Multi-side eject/insert**: the inserted side is now an
27//! `Option<usize>` (`None` = ejected). [`Fds::set_disk_side`] swaps sides; an
28//! eject sets `$4032` bit 0 (disk not inserted) and bit 1 (not ready); an
29//! insert resets the head and opens a short not-ready window.
30//! - **Persistence hooks**: [`Fds::disk_image_bytes`] re-serializes the
31//! (possibly-modified) sides to the headerless `.fds` byte layout, and
32//! [`Fds::disk_is_dirty`] / [`Fds::clear_disk_dirty`] let a host save it back.
33//! - **Per-disk write-protect**: [`Fds::set_write_protected`] toggles the
34//! `$4032` bit-2 write-protect flag (default writable).
35//!
36//! FDS-proper (v1.6.0 Workstream F) extends the drive model, after `puNES`
37//! `fds.c`, while staying cycle-count-based (NOT the v2.0 master-clock axis):
38//!
39//! - **Timed disk-head position**: a motor restart after the cold spin-up
40//! rewinds the belt-driven disk to the disk-start gap and the head must
41//! re-seek to track 0, so a short [`HEAD_RESEEK_CYCLES`] not-ready window
42//! opens (rather than the head teleporting to track 0 instantly). This is
43//! what the BIOS re-read loop observes as the not-ready -> ready transition,
44//! and it closes the **Kid Icarus side-B post-registration** replay.
45//! - **`$4032` drive status / auto-insert**: `$4032.1` (not-ready) is driven by
46//! the spin-up / re-seek windows above, the motor state, and end-of-head, so
47//! the disk re-presents itself to the loader on each re-read without manual
48//! re-insertion (the "auto-insert" behaviour).
49//! - **Per-game CRC quirk table** ([`quirk_for_crc`] / [`FdsQuirk`]): a curated,
50//! growable list keyed off the disk-image CRC-32 for titles whose replay
51//! timing the nominal model does not satisfy (extra re-seek slack today). It
52//! ships **empty** — entries are added only from real, maintainer-measured
53//! dumps; the Kid Icarus side-B fix above is title-independent (the timed
54//! head model) and needs no entry.
55//!
56//! v2.2.0 "Capstone" completes the **medium model**:
57//!
58//! - **Byte-stream disk medium with per-block CRC-16 on write**: reads present a
59//! synthesized wire image (gap / `$80` start mark / block / CRC-16-KERMIT),
60//! and each written block re-emits a fresh CRC-16 over its payload
61//! ([`Fds::resynth_block_crc`]) — modelling the RP2C33 controller's per-block
62//! CRC generator, so the medium stays self-consistent after a BIOS write.
63//! - **Continuous analog head-seek / velocity model** ([`Fds::set_analog_head_seek`]):
64//! an opt-in, default-OFF replacement for the fixed [`HEAD_RESEEK_CYCLES`]
65//! not-ready window. The belt-driven head's rewind time becomes proportional
66//! to the distance it had travelled from the disk-start gap (a constant belt
67//! velocity, [`HEAD_SEEK_BYTES_PER_CYCLE`]), rather than a flat constant. With
68//! the model disabled a non-writing `.fds` run is byte-identical to prior
69//! releases; the new state round-trips the v4 save-state tail.
70//! - **Synthetic write-verify oracle** ([`Fds::medium_write_verify`]): a
71//! BIOS-free walk of the wire image asserting every block's CRC-16 + gap /
72//! mark framing round-trips — the CI-verifiable half of the medium model. The
73//! real-BIOS write-CRC path depends on a copyright FDS BIOS and is validated
74//! only from a local, gitignored dump (see `docs/accuracy-ledger.md`).
75//!
76//! The frontend wiring (BIOS prompt, side-swap keybind, `.fds.sav` file I/O)
77//! is provided by `rustynes-frontend`; this module owns the core API + state.
78//!
79//! References (nesdev wiki, committed under `nesdev_wiki/`):
80//! - `Family_Computer_Disk_System.xhtml` — register map + IRQ + banks.
81//! - `FDS_disk_format.xhtml` / `FDS_file_format.xhtml` — the `.fds` container.
82//! - `FDS_BIOS.xhtml` — the 8 KiB `disksys.rom` BIOS.
83
84#![allow(
85 clippy::cast_possible_truncation,
86 clippy::cast_lossless,
87 // The FDS sound channel's mod counter is a signed 7-bit value whose
88 // hardware semantics are exactly two's-complement wrap; the audio register
89 // packing genuinely round-trips signed<->unsigned bytes.
90 clippy::cast_possible_wrap,
91 clippy::cast_sign_loss,
92 clippy::missing_const_for_fn,
93 clippy::doc_markdown,
94 // The FDS register set is genuinely a bag of independent hardware bits;
95 // packing them into enums would obscure the 1:1 mapping to the documented
96 // $4025/$4030 bit layout.
97 clippy::struct_excessive_bools
98)]
99
100use alloc::{boxed::Box, format, string::String, vec, vec::Vec};
101
102use crate::cartridge::{Mirroring, RomError};
103use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
104
105/// Bytes per disk side in the common `.fds` / fwNES file format (no CRCs, no
106/// gaps). The on-disk physical capacity and the QD (65536) variant are larger;
107/// see `FDS_disk_format.xhtml` §True disk capacity.
108pub const FDS_SIDE_LEN: usize = 65500;
109
110/// QD-file side length (65536). Detected so QD images degrade gracefully to the
111/// 65500-byte read window rather than mis-parsing.
112const QD_SIDE_LEN: usize = 65536;
113
114/// fwNES optional header length.
115const FWNES_HEADER_LEN: usize = 16;
116
117/// PRG-RAM size: 32 KiB mapped at `$6000-$DFFF`.
118const PRG_RAM_LEN: usize = 0x8000;
119
120/// BIOS ROM size: 8 KiB mapped at `$E000-$FFFF`.
121const BIOS_LEN: usize = 0x2000;
122
123/// CHR-RAM size: 8 KiB at PPU `$0000-$1FFF`.
124const CHR_RAM_LEN: usize = 0x2000;
125
126/// Nominal CPU cycles between disk byte transfers.
127///
128/// Real hardware streams bits at roughly 96.4 kHz; with 8 bits + framing this
129/// works out to ~149 CPU cycles (≈1.789773 MHz / 96.4 kHz / 8 ≈ 149) per
130/// transferred byte. Emulators converge on this value (Mesen2, FCEUX). Stage 1
131/// uses the fixed nominal cadence; the BIOS only requires bytes to arrive in
132/// order at a rate it can service.
133pub const DISK_BYTE_CYCLES: u32 = 149;
134
135/// Deterministic "not ready" window (CPU cycles) opened after a side is
136/// inserted. Real hardware spins the disk up + seeks the head, which is not
137/// modelled cycle-exactly here; this short fixed window keeps `$4032` bit 1
138/// set briefly after insert (closer to hardware than an instantly-ready drive)
139/// without introducing any non-deterministic timing. Roughly one byte-transfer
140/// period — enough that BIOS polling sees the not-ready transition.
141pub const INSERT_NOT_READY_CYCLES: u32 = DISK_BYTE_CYCLES;
142
143/// Drive spin-up time (CPU cycles) after the motor turns on: the disk spins up
144/// and the head seeks to the disk start, during which `$4032.1` reports
145/// not-ready. The BIOS reset disk-check waits for the not-ready -> ready
146/// transition, so this must be long enough to be observed across its poll loop
147/// (a few NMI frames). ~50000 cycles ≈ 28 ms, comfortably within one frame of
148/// BIOS polling while still being far shorter than the real ~1 s spin-up so the
149/// boot stays snappy and deterministic.
150pub const MOTOR_SPIN_UP_CYCLES: u32 = 50_000;
151
152/// Head re-seek time (CPU cycles) modelled on a motor restart.
153///
154/// When the motor restarts, the belt-driven drive physically rewinds the disk
155/// back to the disk-start gap (the FDS-proper "timed disk-head position" —
156/// `puNES` `fds.c` rewinds the `disk_position` and waits out a seek before the
157/// first byte streams again, rather than the head teleporting to track 0
158/// instantly).
159///
160/// This window opens on every motor off->on edge after the cold spin-up, while
161/// `$4032.1` reports not-ready, so the BIOS observes the not-ready -> ready
162/// transition before each re-read (the same handshake it waits for at boot).
163/// This is what closes the **Kid Icarus side-B post-registration** replay: the
164/// game stops the motor, writes the save, then re-reads later files — and the
165/// BIOS re-read loop expects the drive to report not-ready while the head
166/// returns. With an instant rewind the loader never sees the transition and the
167/// post-registration screen never streams its blocks.
168///
169/// ~8000 cycles ≈ 4.5 ms — far shorter than the cold spin-up (the disk is
170/// already turning), long enough to be observed across the BIOS poll loop, and
171/// deterministic (no analog seek-time model). Per-game quirks ([`FdsQuirk`])
172/// may extend it for titles whose timing the nominal value does not satisfy.
173pub const HEAD_RESEEK_CYCLES: u32 = 8_000;
174
175/// Belt-driven head-seek velocity for the **continuous analog head-seek model**
176/// (v2.2.0 "Capstone", opt-in — see [`Fds::set_analog_head_seek`]).
177///
178/// The stock model above opens a single *fixed* [`HEAD_RESEEK_CYCLES`] not-ready
179/// window on every motor-restart rewind, regardless of how far into the disk the
180/// head had travelled. Real hardware is a belt-driven linear actuator: the time
181/// to rewind the head back to the disk-start gap is **proportional to the
182/// distance** the head sits from that gap (a constant belt velocity), not a flat
183/// constant. This value expresses that velocity as *wire bytes of head travel
184/// undone per CPU cycle*: the re-seek window becomes
185/// `HEAD_SEEK_SETTLE_CYCLES + pre_rewind_head / HEAD_SEEK_BYTES_PER_CYCLE`,
186/// clamped to [`MOTOR_SPIN_UP_CYCLES`] (a rewind can never take longer than a
187/// cold spin-up). The divisor is calibrated so a full-side rewind (head parked
188/// near the inner track, ≈65500 wire bytes) lands close to the historical fixed
189/// [`HEAD_RESEEK_CYCLES`] window, while a shallow rewind (a few blocks in)
190/// completes proportionally sooner — a genuine continuous position→time map.
191///
192/// The model is **deterministic** (integer distance ÷ integer velocity, no
193/// wall-clock / analog jitter) and **opt-in / default-off**, so a non-writing
194/// `.fds` run with the model disabled is byte-identical to the fixed-window path.
195pub const HEAD_SEEK_BYTES_PER_CYCLE: u32 = 8;
196
197/// Fixed settle time (CPU cycles) added to every continuous head-seek window,
198/// modelling the head-carriage settle + track-0 detent that is independent of
199/// the seek distance. Even a zero-distance re-seek reports not-ready for this
200/// long so the BIOS re-read loop always observes the not-ready -> ready edge.
201pub const HEAD_SEEK_SETTLE_CYCLES: u32 = 512;
202
203/// Per-game FDS timing quirk: a per-CRC drive-timing table derived from puNES's
204/// `src/core/fds.c` per-CRC drive table (GPL-2.0-or-later).
205/// See NOTICE and docs/originality-and-provenance.md (Section 1).
206///
207/// A small, additive set of knobs keyed off the disk-image CRC-32 (see
208/// [`quirk_for_crc`]). Most titles run on the nominal timing and have no entry;
209/// the table exists so a game whose BIOS replay loop needs a different
210/// not-ready cadence (typically extra head-seek slack) can be tuned without
211/// touching the general transfer engine. All fields are deterministic
212/// cycle-count adjustments — never an analog/seek-time model — so the
213/// determinism contract holds.
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct FdsQuirk {
216 /// Extra CPU cycles added to the head re-seek not-ready window
217 /// ([`HEAD_RESEEK_CYCLES`]) on each motor-restart rewind. Tunes titles whose
218 /// BIOS replay loop needs the drive to report not-ready a little longer
219 /// before a re-read.
220 pub extra_reseek_cycles: u32,
221}
222
223impl FdsQuirk {
224 /// The no-adjustment default applied to every title without a table entry.
225 pub const NONE: Self = Self {
226 extra_reseek_cycles: 0,
227 };
228}
229
230/// Look up the per-game [`FdsQuirk`] for a disk image by its CRC-32.
231///
232/// Returns [`FdsQuirk::NONE`] for any disk not in the table — the overwhelming
233/// majority. The table is the FDS analogue of the per-game mirroring / mapper
234/// overrides: a curated, growable list of titles whose drive timing the nominal
235/// model does not satisfy. CRC-32s are over the **headerless** side bytes (what
236/// [`FdsDisk::to_bytes`] produces and [`Fds::new`] hashes), so a fwNES-headered
237/// and a headerless dump of the same disk resolve to the same quirk.
238#[must_use]
239pub fn quirk_for_crc(crc: u32) -> FdsQuirk {
240 // Per-game entries. Each is `(crc32, FdsQuirk { .. })`, the CRC-32 taken
241 // over the headerless side bytes ([`FdsDisk::to_bytes`]).
242 //
243 // NOTE on the canonical Kid Icarus case: the general timed disk-head
244 // position model (the [`HEAD_RESEEK_CYCLES`] re-seek window opened on every
245 // motor-restart rewind) is what actually closes the Kid Icarus side-B
246 // post-registration replay — that fix is title-independent and needs no
247 // table entry. This table is a per-CRC *framework* (of the kind puNES uses) for the
248 // residual minority of titles whose replay loop wants extra not-ready slack
249 // beyond the nominal window.
250 //
251 // The table is intentionally EMPTY. Every concrete CRC-32 entry must be
252 // measured from a real (never-committed) dump and verified against that
253 // title's actual replay loop before it ships — a fabricated/placeholder CRC
254 // key is unacceptable because a real disk that happens to hash to it would
255 // silently receive unverified timing slack. Maintainers add measured
256 // entries here as `(crc32, FdsQuirk { .. })`, the CRC-32 taken over the
257 // headerless side bytes ([`FdsDisk::to_bytes`]). The lookup mechanism +
258 // slack application + save-state independence are exercised by the unit
259 // tests independently of any specific entry, so the framework cannot rot.
260 const TABLE: &[(u32, FdsQuirk)] = &[];
261 let mut i = 0;
262 while i < TABLE.len() {
263 if TABLE[i].0 == crc {
264 return TABLE[i].1;
265 }
266 i += 1;
267 }
268 FdsQuirk::NONE
269}
270
271/// CRC-32/ISO-HDLC (the standard zlib/PNG "CRC-32") over `data`.
272///
273/// `no_std`-friendly (no lookup-table allocation — the polynomial is applied
274/// bitwise); used only at construction time to key the per-game quirk table, so
275/// the per-byte cost is irrelevant to the hot paths. Deterministic by
276/// construction.
277#[must_use]
278pub fn fds_crc32(data: &[u8]) -> u32 {
279 let mut crc: u32 = 0xFFFF_FFFF;
280 for &byte in data {
281 crc ^= u32::from(byte);
282 for _ in 0..8 {
283 let mask = (crc & 1).wrapping_neg();
284 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
285 }
286 }
287 !crc
288}
289
290/// Lead-in gap length (in `$00` bytes) synthesized before the first block of a
291/// side. Hardware uses a long disk-start gap (≈26150-28300 bits ≈ 3300-3500
292/// bytes); the BIOS only requires "enough" zero bytes before the first `$80`
293/// start mark to settle its block-scan loop. A modest lead-in keeps the wire
294/// image small while still giving the loader its expected pre-disk gap.
295const WIRE_LEAD_IN_GAP: usize = 200;
296
297/// Inter-block gap length (in `$00` bytes) synthesized between consecutive
298/// blocks. Hardware uses ≥480 bits (≈60 bytes), 976 bits typical; the loader
299/// accepts a much smaller minimum (a few hundred bits). This value sits
300/// comfortably above the minimum the BIOS needs to re-detect a block start.
301const WIRE_BLOCK_GAP: usize = 100;
302
303/// The FDS block start mark. On the medium each gap is terminated by a single
304/// `1` bit; in byte terms (little-endian) that is `$80`. The BIOS scans the bit
305/// stream for this mark to find the start of every block.
306const WIRE_START_MARK: u8 = 0x80;
307
308/// Synthesized FDS block descriptor: where a block's payload lives in the raw
309/// `.fds` side and where it (and its surrounding gap/mark/CRC) lands in the
310/// synthesized wire image. The mapping lets the write path translate a wire
311/// head position back to a raw side offset so persistence (`disk_image_bytes`)
312/// keeps working.
313#[derive(Debug, Clone, Copy)]
314struct WireBlock {
315 /// Offset of the block's first byte (its block-code byte) in the raw side.
316 raw_start: usize,
317 /// Block payload length in bytes (block-code byte included; CRC excluded —
318 /// it is not stored in the `.fds` form).
319 len: usize,
320 /// Offset in the wire image of this block's first payload byte (i.e. just
321 /// after its `$80` start mark).
322 wire_payload_start: usize,
323}
324
325/// CRC-16/KERMIT (a.k.a. CRC-16/CCITT, reflected, poly 0x8408) over the start
326/// mark + block bytes, matching the FDS RP2C33 block CRC. The BIOS does not
327/// verify it for the standard load path, but synthesizing a correct value keeps
328/// the wire image faithful and avoids tripping `$4030.D4` on stricter loaders.
329fn fds_block_crc(start_mark: u8, block: &[u8]) -> u16 {
330 let mut crc: u16 = 0;
331 let update = |byte: u8, crc: &mut u16| {
332 *crc ^= u16::from(byte);
333 for _ in 0..8 {
334 let carry = *crc & 1 != 0;
335 *crc >>= 1;
336 if carry {
337 *crc ^= 0x8408;
338 }
339 }
340 };
341 update(start_mark, &mut crc);
342 for &b in block {
343 update(b, &mut crc);
344 }
345 crc
346}
347
348/// Walk a raw `.fds` side and return its block descriptors in stream order.
349///
350/// The `.fds` form stores only the block payloads concatenated (no gaps, no
351/// start marks, no CRCs). Blocks are self-describing via their leading
352/// block-code byte and a known/derivable length:
353/// - `$01` disk-info: 56 bytes.
354/// - `$02` file-amount: 2 bytes (byte 1 = file count).
355/// - `$03` file-header: 16 bytes (file size = LE u16 at offset 13).
356/// - `$04` file-data: `1 + size` bytes (size from the preceding header).
357///
358/// Parsing stops at the first byte that is not a valid next block code (the
359/// trailing `$00` padding terminates the walk).
360fn parse_side_blocks(side: &[u8]) -> Vec<(usize, usize)> {
361 let mut blocks = Vec::new();
362 let mut pos = 0usize;
363 let mut pending_file_size: Option<usize> = None;
364 loop {
365 if pos >= side.len() {
366 break;
367 }
368 let code = side[pos];
369 let len = match code {
370 0x01 => 56,
371 0x02 => 2,
372 0x03 => {
373 // File size lives at header offset 13-14 (LE u16); remember it
374 // for the file-data block that must follow.
375 let size = if pos + 15 <= side.len() {
376 usize::from(side[pos + 13]) | (usize::from(side[pos + 14]) << 8)
377 } else {
378 0
379 };
380 pending_file_size = Some(size);
381 16
382 }
383 0x04 => {
384 let size = pending_file_size.take().unwrap_or(0);
385 1 + size
386 }
387 // Any other leading byte (most commonly $00 trailing padding) ends
388 // the structured region of the side.
389 _ => break,
390 };
391 if pos + len > side.len() {
392 // A declared block runs past the end of the side: clamp and stop.
393 blocks.push((pos, side.len() - pos));
394 break;
395 }
396 blocks.push((pos, len));
397 pos += len;
398 }
399 blocks
400}
401
402/// Synthesize the on-disk **wire image** for one raw `.fds` side: the gap /
403/// start-mark / block / CRC structure the BIOS read routine scans for.
404///
405/// The layout is, for each parsed block:
406/// `[gap $00 × G] [$80 start mark] [block bytes] [crc_lo] [crc_hi]`
407/// with a long lead-in gap before block 1 and shorter inter-block gaps, then a
408/// run of trailing `$00` so the wire image always ends in a gap (so the head
409/// sitting at the inner track reads `$00`).
410///
411/// Returns the wire bytes plus the [`WireBlock`] descriptors mapping each
412/// block's wire payload region back to its raw side offset (used by the write
413/// path to persist BIOS-written blocks into the raw `.fds` side).
414fn build_side_wire(side: &[u8]) -> (Vec<u8>, Vec<WireBlock>) {
415 let raw_blocks = parse_side_blocks(side);
416 let mut wire = Vec::with_capacity(side.len() + raw_blocks.len() * 32 + WIRE_LEAD_IN_GAP);
417 let mut map = Vec::with_capacity(raw_blocks.len());
418 for (i, &(raw_start, len)) in raw_blocks.iter().enumerate() {
419 let gap = if i == 0 {
420 WIRE_LEAD_IN_GAP
421 } else {
422 WIRE_BLOCK_GAP
423 };
424 wire.resize(wire.len() + gap, 0x00);
425 wire.push(WIRE_START_MARK);
426 let wire_payload_start = wire.len();
427 let block = &side[raw_start..raw_start + len];
428 wire.extend_from_slice(block);
429 let crc = fds_block_crc(WIRE_START_MARK, block);
430 wire.push((crc & 0xFF) as u8);
431 wire.push((crc >> 8) as u8);
432 map.push(WireBlock {
433 raw_start,
434 len,
435 wire_payload_start,
436 });
437 }
438 // Trailing gap so the head reads `$00` past the last block instead of
439 // running straight off the end (a small, fixed tail is enough).
440 wire.resize(wire.len() + WIRE_BLOCK_GAP, 0x00);
441 (wire, map)
442}
443
444/// A structural fault found by [`Fds::medium_write_verify`] while walking a
445/// synthesized wire image. Used by the **synthetic FDS write-verify oracle**
446/// (v2.2.0 "Capstone"): after driving the register-level write path, the test
447/// re-walks the medium and asserts every block's synthesized CRC-16 and
448/// surrounding gap/mark framing round-trips. This is the CI-verifiable half of
449/// the medium model — it needs **no copyright FDS BIOS** (the real-BIOS write
450/// path is exercised only from a gitignored local dump; see
451/// `docs/accuracy-ledger.md`).
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum FdsMediumError {
454 /// A block's leading `$80` start mark was missing at the expected offset.
455 MissingStartMark {
456 /// Zero-based block index in stream order.
457 block: usize,
458 /// Wire offset the mark was expected at.
459 wire_pos: usize,
460 },
461 /// A block's synthesized CRC-16 does not match a recomputation over its
462 /// payload — i.e. a write did not re-emit a consistent per-block CRC.
463 CrcMismatch {
464 /// Zero-based block index in stream order.
465 block: usize,
466 /// The CRC-16 stored on the wire (little-endian lo/hi).
467 stored: u16,
468 /// The CRC-16 recomputed from the block payload.
469 expected: u16,
470 },
471 /// The block payload ran past the end of the wire image (a truncated wire).
472 Truncated {
473 /// Zero-based block index in stream order.
474 block: usize,
475 },
476 /// The inter-block gap before a block was not all-`$00` (framing corrupted).
477 GapNotZero {
478 /// Zero-based block index in stream order.
479 block: usize,
480 /// Wire offset of the first non-zero gap byte.
481 wire_pos: usize,
482 },
483}
484
485/// A parsed FDS disk image: an ordered list of disk sides.
486#[derive(Debug, Clone)]
487pub struct FdsDisk {
488 /// One entry per disk side; each is exactly [`FDS_SIDE_LEN`] bytes (longer
489 /// raw sides are truncated, shorter ones zero-padded, so the read window is
490 /// always well-defined). Heap-allocated to keep the 64 KiB-per-side payload
491 /// off the stack.
492 sides: Vec<Box<[u8]>>,
493 /// Side count declared by the fwNES header, when present (else derived from
494 /// the file length).
495 declared_side_count: u8,
496}
497
498impl FdsDisk {
499 /// Number of disk sides in this image.
500 #[must_use]
501 pub fn side_count(&self) -> usize {
502 self.sides.len()
503 }
504
505 /// Side count declared by the container header (0 when headerless).
506 #[must_use]
507 pub fn declared_side_count(&self) -> u8 {
508 self.declared_side_count
509 }
510
511 /// Borrow the raw bytes of side `idx` (panics if out of range — callers use
512 /// [`Self::side_count`] to bound).
513 #[must_use]
514 pub fn side(&self, idx: usize) -> &[u8] {
515 &self.sides[idx]
516 }
517
518 /// Mutably borrow the raw bytes of side `idx` (panics if out of range).
519 /// Used by the disk-write path to store bytes at the head position.
520 pub fn side_mut(&mut self, idx: usize) -> &mut [u8] {
521 &mut self.sides[idx]
522 }
523
524 /// Re-serialize every side back to the headerless `.fds` byte layout
525 /// (`side_count` × [`FDS_SIDE_LEN`] bytes, concatenated). This is the form a
526 /// host writes to a side-car `.fds.sav` so the modified disk persists. The
527 /// fwNES 16-byte header is intentionally omitted — the headerless form
528 /// round-trips through [`parse_fds`] (its first side opens with the
529 /// `\x01*NINTENDO-HVC*` disk-info signature).
530 #[must_use]
531 pub fn to_bytes(&self) -> Vec<u8> {
532 let mut out = Vec::with_capacity(self.sides.len() * FDS_SIDE_LEN);
533 for side in &self.sides {
534 out.extend_from_slice(side);
535 }
536 out
537 }
538}
539
540/// Parse a `.fds` / fwNES disk image into an [`FdsDisk`].
541///
542/// Accepts:
543/// - The fwNES container: a 16-byte header (`"FDS\x1A"` + byte4 = side count),
544/// followed by `side_count` × [`FDS_SIDE_LEN`]-byte sides.
545/// - The headerless raw form: 1+ concatenated sides, the first of which opens
546/// with `\x01*NINTENDO-HVC*` (the disk-info block).
547///
548/// QD-style 65536-byte sides are accepted and truncated to the 65500-byte read
549/// window (Stage 1 does not consume CRC/gap bytes).
550///
551/// # Errors
552///
553/// - [`RomError::BadMagic`] if the bytes are neither a fwNES container nor a
554/// recognizable raw disk side.
555/// - [`RomError::Truncated`] if the declared side count would run past the end
556/// of the file, or the file is too short to hold a single side.
557pub fn parse_fds(bytes: &[u8]) -> Result<FdsDisk, RomError> {
558 // fwNES container form.
559 if bytes.len() >= 4 && &bytes[0..4] == b"FDS\x1A" {
560 let declared = bytes.get(4).copied().unwrap_or(0);
561 let body = &bytes[FWNES_HEADER_LEN.min(bytes.len())..];
562 return parse_sides(body, declared, true);
563 }
564
565 // Headerless raw form: must open with the disk-info block signature.
566 if bytes.len() >= 15 && bytes[0] == 0x01 && &bytes[1..15] == b"*NINTENDO-HVC*" {
567 return parse_sides(bytes, 0, false);
568 }
569
570 Err(RomError::BadMagic)
571}
572
573/// Split the side region into fixed-size sides.
574///
575/// `declared` is the fwNES header's side count (0 when headerless / unknown).
576/// When `has_header` is set and `declared` is non-zero, it is authoritative and
577/// the body must be long enough to hold that many sides. Otherwise the side
578/// count is derived from the body length, accepting both the 65500 and QD 65536
579/// stride (whichever divides evenly; 65500 takes precedence).
580fn parse_sides(body: &[u8], declared: u8, has_header: bool) -> Result<FdsDisk, RomError> {
581 if body.len() < FDS_SIDE_LEN {
582 return Err(RomError::Truncated {
583 needed: FDS_SIDE_LEN,
584 got: body.len(),
585 });
586 }
587
588 // Pick the side stride. Prefer 65500 (FDS format); fall back to 65536 (QD)
589 // only when the body length is a clean multiple of it but not of 65500.
590 let stride = if body.len().is_multiple_of(FDS_SIDE_LEN) {
591 FDS_SIDE_LEN
592 } else if body.len().is_multiple_of(QD_SIDE_LEN) {
593 QD_SIDE_LEN
594 } else {
595 // Irregular trailing bytes (some dumps pad). Use the FDS stride and
596 // accept a partial final side via the truncation below.
597 FDS_SIDE_LEN
598 };
599
600 let available_sides = body.len() / stride;
601 let side_count = if has_header && declared != 0 {
602 let n = declared as usize;
603 if body.len() < n * stride {
604 return Err(RomError::Truncated {
605 needed: n * stride,
606 got: body.len(),
607 });
608 }
609 n
610 } else {
611 available_sides.max(1)
612 };
613
614 let mut sides = Vec::with_capacity(side_count);
615 for i in 0..side_count {
616 let start = i * stride;
617 let mut side = vec![0u8; FDS_SIDE_LEN].into_boxed_slice();
618 let end = (start + FDS_SIDE_LEN).min(body.len());
619 let copy_len = end.saturating_sub(start);
620 side[..copy_len].copy_from_slice(&body[start..start + copy_len]);
621 sides.push(side);
622 }
623
624 Ok(FdsDisk {
625 sides,
626 declared_side_count: declared,
627 })
628}
629
630/// FDS sound channel (2C33 audio), modelled per `nesdev_wiki/FDS_audio.xhtml`.
631///
632/// The chip is a wavetable oscillator with a separate frequency-modulation
633/// unit. Both the wave output unit and the modulation unit are clocked every
634/// 16 CPU cycles; the volume and modulation envelopes tick on a programmable
635/// divider derived from `$408A` and the per-envelope speed.
636///
637/// The audio state is **always present** (so the register decoders and the
638/// save-state tail are identical between `mapper-audio` on/off builds); the
639/// synthesis (`clock` / `output`) is only driven when the feature is on.
640#[derive(Clone)]
641pub(crate) struct FdsAudio {
642 // --- Wavetable ($4040-$407F) ---
643 /// 64-entry, 6-bit wavetable RAM (0..=63 per step).
644 wavetable: [u8; 64],
645 /// Wave write-enable / hold ($4089 bit 7). While set the channel output is
646 /// held and the wavetable RAM is CPU-writable.
647 wave_write_enable: bool,
648 /// 24-bit wave phase accumulator. Bits 18..=23 (`>> 18 & 0x3F`) index the
649 /// wavetable; the low 18 bits are the fraction.
650 wave_acc: u32,
651 /// Last wavetable value latched at the output (held while write-enabled).
652 /// Mirrors the `$4096`-readable "current wavetable position" sample.
653 wave_out_latch: u8,
654
655 // --- Frequency ($4082/$4083) ---
656 /// 12-bit wave (carrier) pitch.
657 wave_pitch: u16,
658 /// $4083 bit 7 — disable channel: halts the wave unit + resets its
659 /// accumulator (output holds the `$4040` value).
660 wave_halt: bool,
661 /// $4083 bit 6 — disable volume + mod envelopes (envelopes do not tick).
662 env_halt: bool,
663
664 // --- Volume envelope ($4080, read $4090) ---
665 /// Volume envelope mode: false = envelope on, true = direct gain (disabled).
666 vol_env_disabled: bool,
667 /// Volume envelope direction: true = increase, false = decrease.
668 vol_env_increase: bool,
669 /// Volume envelope speed (0..=63).
670 vol_env_speed: u8,
671 /// Current volume gain (0..=63; clamped to 32 at the output multiply).
672 vol_gain: u8,
673 /// Volume-envelope clock timer (counts down CPU cycles).
674 vol_timer: u32,
675
676 // --- Mod envelope ($4084, read $4092) ---
677 /// Mod envelope mode: false = envelope on, true = direct gain (disabled).
678 mod_env_disabled: bool,
679 /// Mod envelope direction: true = increase, false = decrease.
680 mod_env_increase: bool,
681 /// Mod envelope speed (0..=63).
682 mod_env_speed: u8,
683 /// Current mod gain (0..=63).
684 mod_gain: u8,
685 /// Mod-envelope clock timer (counts down CPU cycles).
686 mod_timer: u32,
687
688 // --- Modulation unit ($4085/$4086/$4087/$4088) ---
689 /// Signed 7-bit mod counter (-64..=63).
690 mod_counter: i8,
691 /// 12-bit mod pitch ($4086/$4087).
692 mod_pitch: u16,
693 /// $4087 bit 7 — mod unit disabled (accumulator held + reset on set,
694 /// table writable via $4088).
695 mod_halt: bool,
696 /// 18-bit mod phase accumulator. Bits 13..=17 are the 5-bit table address;
697 /// bit 12 is the "ghost" bit (each entry steps twice); bits 0..=11 fraction.
698 mod_acc: u32,
699 /// 32-entry, 3-bit modulation table (ring buffer).
700 mod_table: [u8; 32],
701 /// Mod-table write position (advances on each `$4088` write; the low bit is
702 /// ignored when indexing, so the position is a 64-step value here stored as
703 /// the 5-bit entry index used for `$4088` writes).
704 mod_write_pos: u8,
705
706 // --- Master volume / envelope speed ($4089/$408A) ---
707 /// Master volume select (0..=3 -> divisors 2,3,4,5 over the gain).
708 master_volume: u8,
709 /// Master envelope speed multiplier ($408A). 0 disables both envelopes.
710 env_speed_mult: u8,
711
712 /// 16-CPU-cycle prescaler driving the wave + modulation units. Counts up
713 /// from 0; the units tick when it reaches 16 (then resets).
714 cycle_prescaler: u8,
715}
716
717impl Default for FdsAudio {
718 fn default() -> Self {
719 Self {
720 wavetable: [0; 64],
721 wave_write_enable: false,
722 wave_acc: 0,
723 wave_out_latch: 0,
724 wave_pitch: 0,
725 wave_halt: false,
726 env_halt: false,
727 vol_env_disabled: false,
728 vol_env_increase: false,
729 vol_env_speed: 0,
730 vol_gain: 0,
731 vol_timer: 0,
732 mod_env_disabled: false,
733 mod_env_increase: false,
734 mod_env_speed: 0,
735 mod_gain: 0,
736 mod_timer: 0,
737 mod_counter: 0,
738 mod_pitch: 0,
739 mod_halt: false,
740 mod_acc: 0,
741 mod_table: [0; 32],
742 mod_write_pos: 0,
743 master_volume: 0,
744 env_speed_mult: 0xE8, // BIOS power-on value.
745 cycle_prescaler: 0,
746 }
747 }
748}
749
750impl FdsAudio {
751 /// Write a sound register in the `$4040-$408A` window.
752 ///
753 /// Returns nothing; side effects update the channel state. Wavetable RAM
754 /// (`$4040-$407F`) is only mutable while wave-write-enable (`$4089` bit 7)
755 /// is set.
756 pub(crate) fn write_reg(&mut self, addr: u16, value: u8) {
757 match addr {
758 0x4040..=0x407F => {
759 if self.wave_write_enable {
760 self.wavetable[(addr - 0x4040) as usize] = value & 0x3F;
761 }
762 }
763 0x4080 => {
764 // MDVV VVVV: M=mode(1=disabled/direct), D=direction, V=speed.
765 self.vol_env_disabled = value & 0x80 != 0;
766 self.vol_env_increase = value & 0x40 != 0;
767 self.vol_env_speed = value & 0x3F;
768 if self.vol_env_disabled {
769 // Direct gain: the speed bits double as the gain value.
770 self.vol_gain = value & 0x3F;
771 }
772 // Writing resets the envelope clock timer (next tick c cycles on).
773 self.vol_timer = self.vol_env_period();
774 }
775 0x4082 => {
776 self.wave_pitch = (self.wave_pitch & 0x0F00) | u16::from(value);
777 }
778 0x4083 => {
779 // MExx FFFF: M=halt/4x, E=disable envelopes, F=freq hi.
780 self.wave_pitch = (self.wave_pitch & 0x00FF) | (u16::from(value & 0x0F) << 8);
781 let new_halt = value & 0x80 != 0;
782 self.env_halt = value & 0x40 != 0;
783 if new_halt && !self.wave_halt {
784 // Entering halt resets the wave accumulator (wave position
785 // returns to 0 -> outputs the $4040 value).
786 self.wave_acc = 0;
787 }
788 self.wave_halt = new_halt;
789 if self.env_halt {
790 // Bit 6 also resets the envelope timers.
791 self.vol_timer = self.vol_env_period();
792 self.mod_timer = self.mod_env_period();
793 }
794 }
795 0x4084 => {
796 self.mod_env_disabled = value & 0x80 != 0;
797 self.mod_env_increase = value & 0x40 != 0;
798 self.mod_env_speed = value & 0x3F;
799 if self.mod_env_disabled {
800 self.mod_gain = value & 0x3F;
801 }
802 self.mod_timer = self.mod_env_period();
803 }
804 0x4085 => {
805 // Directly set the 7-bit signed mod counter (sign-extend bit 6).
806 let raw = value & 0x7F;
807 self.mod_counter = if raw & 0x40 != 0 {
808 (raw | 0x80) as i8
809 } else {
810 raw as i8
811 };
812 }
813 0x4086 => {
814 self.mod_pitch = (self.mod_pitch & 0x0F00) | u16::from(value);
815 }
816 0x4087 => {
817 // HFxx FFFF: H=reset/disable mod, F=force carry (unused here).
818 self.mod_pitch = (self.mod_pitch & 0x00FF) | (u16::from(value & 0x0F) << 8);
819 let new_halt = value & 0x80 != 0;
820 if new_halt {
821 // Reset the mod accumulator's low 13 bits (fraction + ghost),
822 // keeping the 5-bit table address (bits 13-17) intact.
823 self.mod_acc &= 0x0003_E000;
824 }
825 self.mod_halt = new_halt;
826 }
827 0x4088 => {
828 // Mod table write — only effective while the mod unit is halted.
829 if self.mod_halt {
830 let entry = value & 0x07;
831 let pos = (self.mod_write_pos & 0x1F) as usize;
832 self.mod_table[pos] = entry;
833 self.mod_write_pos = (self.mod_write_pos + 1) & 0x1F;
834 }
835 }
836 0x4089 => {
837 // Wxxx xxVV: W=wave write enable/hold, V=master volume.
838 self.wave_write_enable = value & 0x80 != 0;
839 self.master_volume = value & 0x03;
840 }
841 0x408A => {
842 self.env_speed_mult = value;
843 }
844 _ => {}
845 }
846 }
847
848 /// Read a sound register in the `$4090-$4097` window. Returns `None` for
849 /// addresses the audio unit does not drive (so the caller falls through to
850 /// the device / open-bus behaviour).
851 fn read_reg(&self, addr: u16) -> Option<u8> {
852 match addr {
853 // Current volume gain (bits 5-0); bits 7-6 read as 01 (open bus).
854 0x4090 => Some(0x40 | (self.vol_gain & 0x3F)),
855 // Current mod gain (bits 5-0); bits 7-6 read as 01 (open bus).
856 0x4092 => Some(0x40 | (self.mod_gain & 0x3F)),
857 // Current wavetable value (held output sample); bits 7-6 read as 01.
858 0x4096 => Some(0x40 | (self.wave_out_latch & 0x3F)),
859 // Current mod counter (7-bit) in bits 6-0; bit 7 reads 0.
860 0x4097 => Some((self.mod_counter as u8) & 0x7F),
861 _ => None,
862 }
863 }
864
865 /// CPU clocks between volume-envelope ticks: `c = 8 * (e + 1) * (m + 1)`
866 /// where `e` is the volume envelope speed and `m` the master multiplier
867 /// (`$408A`). Halved (4x faster) when `$4083` bit 7 is set — wait, that is
868 /// the wave-halt path; per the wiki the 4x speed-up is governed by the same
869 /// bit. We keep envelopes at the base rate here (see [`Self::clock_env`]).
870 fn vol_env_period(&self) -> u32 {
871 8 * (u32::from(self.vol_env_speed) + 1) * (u32::from(self.env_speed_mult) + 1)
872 }
873
874 /// CPU clocks between mod-envelope ticks (same shape as the volume one).
875 fn mod_env_period(&self) -> u32 {
876 8 * (u32::from(self.mod_env_speed) + 1) * (u32::from(self.env_speed_mult) + 1)
877 }
878
879 /// The modulated 20-bit wave pitch, per the nesdev `FDS_audio` "Modulation
880 /// unit" pseudo-code:
881 ///
882 /// ```text
883 /// temp = counter * gain;
884 /// if ((temp & 0x0f) && !(temp & 0x800)) temp += 0x20; // round up if +ve
885 /// temp += 0x400;
886 /// temp = (temp >> 4) & 0xff; // drop 4 bits, center at 0x40
887 /// wave_pitch = (pitch * temp) & 0xFFFFF;
888 /// ```
889 #[cfg(feature = "mapper-audio")]
890 fn modulated_pitch(&self) -> u32 {
891 let counter = i32::from(self.mod_counter);
892 let gain = i32::from(self.mod_gain);
893 let mut temp = counter * gain;
894 if (temp & 0x0F) != 0 && (temp & 0x800) == 0 {
895 temp += 0x20;
896 }
897 temp += 0x400;
898 temp = (temp >> 4) & 0xFF;
899 ((u32::from(self.wave_pitch)).wrapping_mul(temp as u32)) & 0x000F_FFFF
900 }
901
902 /// Apply one mod-table step to the signed mod counter, per the 3-bit entry
903 /// table `0,1,2,4,reset,-4,-2,-1` (see the nesdev "Modulation unit" list).
904 #[cfg(feature = "mapper-audio")]
905 fn step_mod_counter(&mut self, entry: u8) {
906 match entry & 0x07 {
907 0 => {}
908 1 => self.mod_counter = self.mod_counter.wrapping_add(1),
909 2 => self.mod_counter = self.mod_counter.wrapping_add(2),
910 3 => self.mod_counter = self.mod_counter.wrapping_add(4),
911 4 => self.mod_counter = 0, // reset
912 5 => self.mod_counter = self.mod_counter.wrapping_sub(4),
913 6 => self.mod_counter = self.mod_counter.wrapping_sub(2),
914 7 => self.mod_counter = self.mod_counter.wrapping_sub(1),
915 _ => unreachable!(),
916 }
917 // The mod counter is a signed 7-bit value: wrap into -64..=63.
918 let mut v = i16::from(self.mod_counter);
919 if v > 63 {
920 v -= 128;
921 } else if v < -64 {
922 v += 128;
923 }
924 self.mod_counter = v as i8;
925 }
926
927 /// Advance the modulation unit one tick (called every 16 CPU cycles). When
928 /// the low 12 bits of the mod accumulator carry out (and the mod pitch is
929 /// non-zero), advance the table address and apply the next entry.
930 #[cfg(feature = "mapper-audio")]
931 fn clock_mod(&mut self) {
932 if self.mod_halt || self.mod_pitch == 0 {
933 return;
934 }
935 let before = self.mod_acc;
936 self.mod_acc = (self.mod_acc + u32::from(self.mod_pitch)) & 0x0003_FFFF;
937 // Carry out of bit 11 (the low 12 bits wrapped past 0xFFF).
938 if (before & 0x0FFF) + u32::from(self.mod_pitch) > 0x0FFF {
939 // The 5-bit table address is bits 13-17; bit 12 is the "ghost" bit
940 // that makes each entry step twice. Index the 32-entry table.
941 let table_index = ((self.mod_acc >> 13) & 0x1F) as usize;
942 let entry = self.mod_table[table_index];
943 self.step_mod_counter(entry);
944 }
945 }
946
947 /// Advance the wave output unit one tick (called every 16 CPU cycles).
948 /// Adds the modulated pitch to the 24-bit wave accumulator and latches the
949 /// new wavetable sample (unless the channel is halted or held).
950 #[cfg(feature = "mapper-audio")]
951 fn clock_wave(&mut self) {
952 if self.wave_halt || self.wave_write_enable {
953 // Halted: hold the $4040 value. Write-enabled: hold current output.
954 if self.wave_halt {
955 self.wave_out_latch = self.wavetable[0] & 0x3F;
956 }
957 return;
958 }
959 let pitch = self.modulated_pitch();
960 self.wave_acc = self.wave_acc.wrapping_add(pitch) & 0x00FF_FFFF;
961 let index = ((self.wave_acc >> 18) & 0x3F) as usize;
962 self.wave_out_latch = self.wavetable[index] & 0x3F;
963 }
964
965 /// Advance a single envelope (volume or mod) by one CPU cycle. Returns the
966 /// updated `(timer, gain)`. The envelope ticks when its timer reaches 0,
967 /// then reloads. Disabled envelopes (mode bit set) or a zero master speed
968 /// hold the gain.
969 #[cfg(feature = "mapper-audio")]
970 fn clock_one_env(timer: &mut u32, gain: &mut u8, disabled: bool, increase: bool, period: u32) {
971 if disabled || period == 0 {
972 return;
973 }
974 if *timer == 0 {
975 *timer = period;
976 }
977 *timer -= 1;
978 if *timer == 0 {
979 *timer = period;
980 if increase {
981 if *gain < 32 {
982 *gain += 1;
983 }
984 } else if *gain > 0 {
985 *gain -= 1;
986 }
987 }
988 }
989
990 /// Tick the volume + mod envelopes for one CPU cycle.
991 #[cfg(feature = "mapper-audio")]
992 fn clock_envelopes(&mut self) {
993 // Envelopes are halted while the waveform is halted or via $4083 bit 6,
994 // and disabled when the master envelope speed ($408A) is 0.
995 if self.env_halt || self.wave_halt || self.env_speed_mult == 0 {
996 return;
997 }
998 let vol_period = self.vol_env_period();
999 Self::clock_one_env(
1000 &mut self.vol_timer,
1001 &mut self.vol_gain,
1002 self.vol_env_disabled,
1003 self.vol_env_increase,
1004 vol_period,
1005 );
1006 let mod_period = self.mod_env_period();
1007 Self::clock_one_env(
1008 &mut self.mod_timer,
1009 &mut self.mod_gain,
1010 self.mod_env_disabled,
1011 self.mod_env_increase,
1012 mod_period,
1013 );
1014 }
1015
1016 /// Advance the whole sound channel by one CPU cycle: tick the envelopes
1017 /// every cycle and the wave + modulation units every 16 CPU cycles.
1018 #[cfg(feature = "mapper-audio")]
1019 pub(crate) fn clock(&mut self) {
1020 self.clock_envelopes();
1021 self.cycle_prescaler += 1;
1022 if self.cycle_prescaler >= 16 {
1023 self.cycle_prescaler = 0;
1024 self.clock_mod();
1025 self.clock_wave();
1026 }
1027 }
1028
1029 /// Current channel output sample as `i16`, scaled for the bus's external
1030 /// mix (`f32::from(sample) / 65536.0`).
1031 ///
1032 /// Output = `wave_sample (0..=63) * volume_gain (clamped to 32) * master`.
1033 /// Per nesdev `FDS_audio` "Mixing", the FDS peak is roughly 2.4x the APU
1034 /// square. We scale so the peak (`63 * 32` at master=full) lands near
1035 /// VRC6's loudness ballpark (`~±15k`) — comfortably under `i16::MAX`.
1036 #[cfg(feature = "mapper-audio")]
1037 pub(crate) fn output(&self) -> i16 {
1038 // Volume gain clamps to 32 at the output multiply (PWM duty 32/32).
1039 let gain = u32::from(self.vol_gain.min(32));
1040 let sample = u32::from(self.wave_out_latch & 0x3F);
1041 // Master volume: 0=full(2/2), 1=2/3, 2=2/4, 3=2/5. Numerator 2,
1042 // denominator (master_volume + 2).
1043 let num = 2u32;
1044 let den = u32::from(self.master_volume) + 2;
1045 // Raw product range: sample(0..=63) * gain(0..=32) = 0..=2016.
1046 let raw = sample * gain;
1047 // Apply master volume.
1048 let scaled = raw * num / den;
1049 // scaled max (master=full) = 2016 * 2 / 2 = 2016. Center bipolar around
1050 // the mid-DAC (wave centered at 31.5 * gain * master). We output the raw
1051 // level minus its midpoint, scaled to use a good chunk of i16 range.
1052 // Midpoint of sample range is 31.5; approximate the DC bias as
1053 // 32 * gain * num / den (sample == 32).
1054 let mid = 32u32 * gain * num / den;
1055 let centered = scaled as i32 - mid as i32;
1056 // Scale factor 7: peak |centered| ~= 31 * 32 ~= 992 -> *7 ~= 6944 per
1057 // full-gain step; the full-scale (gain 32, master full) reaches
1058 // ~±6.9k, in the APU-square-x2.4 ballpark and well under i16::MAX.
1059 (centered * 7) as i16
1060 }
1061
1062 /// Feature-off shim: with `mapper-audio` disabled the synthesizer does
1063 /// not advance, so a clock is a no-op (mirrors the gated path so the
1064 /// shared NSF expansion router can call `clock()` unconditionally).
1065 #[cfg(not(feature = "mapper-audio"))]
1066 #[allow(clippy::needless_pass_by_ref_mut, clippy::unused_self)]
1067 pub(crate) fn clock(&mut self) {}
1068
1069 /// Feature-off shim: silence when `mapper-audio` is disabled.
1070 #[cfg(not(feature = "mapper-audio"))]
1071 #[allow(clippy::unused_self)]
1072 pub(crate) fn output(&self) -> i16 {
1073 0
1074 }
1075
1076 /// Save-state tail (kept lock-step with [`Self::read_tail`]).
1077 fn write_tail(&self, out: &mut Vec<u8>) {
1078 out.extend_from_slice(&self.wavetable);
1079 out.extend_from_slice(&self.mod_table);
1080 out.extend_from_slice(&self.wave_acc.to_le_bytes());
1081 out.extend_from_slice(&self.mod_acc.to_le_bytes());
1082 out.extend_from_slice(&self.wave_pitch.to_le_bytes());
1083 out.extend_from_slice(&self.mod_pitch.to_le_bytes());
1084 out.extend_from_slice(&self.vol_timer.to_le_bytes());
1085 out.extend_from_slice(&self.mod_timer.to_le_bytes());
1086 out.push(self.vol_gain);
1087 out.push(self.mod_gain);
1088 out.push(self.vol_env_speed);
1089 out.push(self.mod_env_speed);
1090 out.push(self.mod_counter as u8);
1091 out.push(self.mod_write_pos);
1092 out.push(self.master_volume);
1093 out.push(self.env_speed_mult);
1094 out.push(self.wave_out_latch);
1095 out.push(self.cycle_prescaler);
1096 // Packed booleans.
1097 let mut flags = 0u16;
1098 flags |= u16::from(self.wave_write_enable);
1099 flags |= u16::from(self.wave_halt) << 1;
1100 flags |= u16::from(self.env_halt) << 2;
1101 flags |= u16::from(self.vol_env_disabled) << 3;
1102 flags |= u16::from(self.vol_env_increase) << 4;
1103 flags |= u16::from(self.mod_env_disabled) << 5;
1104 flags |= u16::from(self.mod_env_increase) << 6;
1105 flags |= u16::from(self.mod_halt) << 7;
1106 out.extend_from_slice(&flags.to_le_bytes());
1107 }
1108
1109 /// Tail size in bytes — see [`Self::write_tail`].
1110 /// 64 (wavetable) + 32 (mod table) + 4 + 4 (accumulators) + 2 + 2 (pitches)
1111 /// + 4 + 4 (timers) + 10 (single bytes incl. prescaler) + 2 (flags) = 134.
1112 const TAIL_LEN: usize = 64 + 32 + 4 + 4 + 2 + 2 + 4 + 4 + 10 + 2;
1113
1114 fn read_tail(&mut self, src: &[u8]) -> Result<(), MapperError> {
1115 if src.len() < Self::TAIL_LEN {
1116 return Err(MapperError::WrongLength {
1117 expected: Self::TAIL_LEN,
1118 got: src.len(),
1119 });
1120 }
1121 // v2.9.0 (re-audit NC-07): the prescaler counts 0..=15 between wave /
1122 // modulation clocks (`clock` increments it, then resets at 16). A raw
1123 // restored value past that overflowed the increment (`0xFF`, a panic
1124 // under overflow checks) or delayed a clock by up to 240 cycles in
1125 // release. Checked before the tail assigns anything. The byte sits
1126 // before the two flag bytes that end the tail.
1127 let prescaler = src[Self::TAIL_LEN - 3];
1128 if prescaler >= 16 {
1129 return Err(MapperError::Invalid(format!(
1130 "FDS audio prescaler {prescaler} is outside 0-15"
1131 )));
1132 }
1133 let mut off = 0;
1134 self.wavetable.copy_from_slice(&src[off..off + 64]);
1135 off += 64;
1136 self.mod_table.copy_from_slice(&src[off..off + 32]);
1137 off += 32;
1138 self.wave_acc = u32::from_le_bytes(src[off..off + 4].try_into().unwrap());
1139 off += 4;
1140 self.mod_acc = u32::from_le_bytes(src[off..off + 4].try_into().unwrap());
1141 off += 4;
1142 self.wave_pitch = u16::from_le_bytes(src[off..off + 2].try_into().unwrap());
1143 off += 2;
1144 self.mod_pitch = u16::from_le_bytes(src[off..off + 2].try_into().unwrap());
1145 off += 2;
1146 self.vol_timer = u32::from_le_bytes(src[off..off + 4].try_into().unwrap());
1147 off += 4;
1148 self.mod_timer = u32::from_le_bytes(src[off..off + 4].try_into().unwrap());
1149 off += 4;
1150 self.vol_gain = src[off];
1151 off += 1;
1152 self.mod_gain = src[off];
1153 off += 1;
1154 self.vol_env_speed = src[off];
1155 off += 1;
1156 self.mod_env_speed = src[off];
1157 off += 1;
1158 self.mod_counter = src[off] as i8;
1159 off += 1;
1160 self.mod_write_pos = src[off] & 0x1F;
1161 off += 1;
1162 self.master_volume = src[off] & 0x03;
1163 off += 1;
1164 self.env_speed_mult = src[off];
1165 off += 1;
1166 self.wave_out_latch = src[off] & 0x3F;
1167 off += 1;
1168 self.cycle_prescaler = src[off];
1169 off += 1;
1170 let flags = u16::from_le_bytes(src[off..off + 2].try_into().unwrap());
1171 self.wave_write_enable = flags & (1 << 0) != 0;
1172 self.wave_halt = flags & (1 << 1) != 0;
1173 self.env_halt = flags & (1 << 2) != 0;
1174 self.vol_env_disabled = flags & (1 << 3) != 0;
1175 self.vol_env_increase = flags & (1 << 4) != 0;
1176 self.mod_env_disabled = flags & (1 << 5) != 0;
1177 self.mod_env_increase = flags & (1 << 6) != 0;
1178 self.mod_halt = flags & (1 << 7) != 0;
1179 Ok(())
1180 }
1181}
1182
1183/// Internal disk-transfer state.
1184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1185enum TransferState {
1186 /// No transfer in progress (motor off, transfer reset, or ejected).
1187 Idle,
1188 /// Streaming bytes from the current side.
1189 Reading,
1190 /// Storing bytes to the current side (`$4025` write mode).
1191 Writing,
1192}
1193
1194/// One record in the optional FDS read-stream trace.
1195///
1196/// A diagnostic for the disk-read / side-swap path — e.g. the Kid Icarus side-B
1197/// ERR.07 stall. Recorded only after [`Fds::enable_trace`]; default builds never
1198/// allocate or record, so the determinism contract is untouched. `kind`: 0 =
1199/// `$4031` read (the disk byte the BIOS consumed), 1 = `$4025` control write, 2 =
1200/// side change. `value` is the byte (or the new side index / `0xFF` for eject).
1201/// `head` is the wire head; `side` is the inserted side (or `-1` ejected);
1202/// `status` is the live `$4030` bits.
1203#[derive(Clone, Copy, Debug)]
1204pub struct FdsTraceRec {
1205 /// Event kind: 0 = `$4031` read, 1 = `$4025` control write, 2 = side change.
1206 pub kind: u8,
1207 /// The byte read/written, or (for a side change) the new side index / `0xFF`.
1208 pub value: u8,
1209 /// Wire head position at the time of the event.
1210 pub head: u32,
1211 /// Inserted side index, or `-1` when ejected.
1212 pub side: i8,
1213 /// Live `$4030` status bits at the time of the event.
1214 pub status: u8,
1215}
1216
1217/// FDS RAM-adapter device, modelled as a [`Mapper`].
1218///
1219/// Owns the PRG-RAM, CHR-RAM, BIOS, the inserted disk image, all register
1220/// state, the timer-IRQ counter, and the disk-read head. Routed by the bus for
1221/// every CPU access in `$4020-$FFFF`; `$4020-$409F` registers are surfaced via
1222/// [`Mapper::cpu_read_unmapped`] returning `false` for that window.
1223pub struct Fds {
1224 // --- Memory ---
1225 prg_ram: Box<[u8]>, // 32 KiB at $6000-$DFFF
1226 chr_ram: Box<[u8]>, // 8 KiB CHR-RAM
1227 bios: Box<[u8]>, // 8 KiB at $E000-$FFFF
1228
1229 // --- Disk ---
1230 disk: FdsDisk,
1231 /// Index of the currently inserted side (0-based), or `None` when ejected.
1232 /// Drives `$4032` bit 0 (disk-not-inserted) when `None`. Default = side 0.
1233 inserted_side: Option<usize>,
1234 /// Read/write head position. This is an offset into the **wire image** of
1235 /// the currently inserted side ([`Fds::wire`]) — the synthesized gap /
1236 /// start-mark / block / CRC stream the BIOS scans — not a raw `.fds` side
1237 /// offset. The write path maps it back to a raw side offset via
1238 /// [`Fds::wire_map`].
1239 head: usize,
1240 /// Synthesized wire image of the currently inserted side (gap / `$80` /
1241 /// block / CRC). Empty when ejected. Rebuilt on every insert/side-swap and
1242 /// after the disk contents change (a BIOS save), so reads always present the
1243 /// hardware wire format the loader expects.
1244 wire: Vec<u8>,
1245 /// Per-block wire→raw mapping for the currently inserted side's [`Fds::wire`].
1246 /// Lets [`Fds::store_byte`] translate the wire head position back into the
1247 /// raw side offset so BIOS-written block payloads persist to `.fds`.
1248 wire_map: Vec<WireBlock>,
1249 /// Whether the drive has completed its spin-up since the last insert. The
1250 /// long spin-up not-ready window opens only on the FIRST motor-on after an
1251 /// insert (the cold spin-up the BIOS reset disk-check waits for). The BIOS
1252 /// briefly toggles the motor off between blocks during a multi-block read;
1253 /// those restarts must NOT re-open the spin-up window (the physical disk
1254 /// keeps spinning), or the `$4032.1` ready check mid-read would spuriously
1255 /// trip the BIOS's disk-error path.
1256 spun_up: bool,
1257 /// Read-path gap-skip state. The RP2C33 controller does not surface the
1258 /// inter-block gap (`$00` run) or the gap-terminating `$80` start mark to the
1259 /// CPU as byte-transfer events — it bit-shifts past them in hardware and only
1260 /// begins raising the transfer flag with the first real block byte. While
1261 /// this flag is set the read engine silently advances the head over gap +
1262 /// mark bytes before delivering data. It is armed whenever a read transfer
1263 /// (re)starts, so the BIOS, which resets the transfer between blocks, always
1264 /// re-syncs to the next block's start mark.
1265 read_skipping_gap: bool,
1266 /// Set on any disk write so a host can persist the modified image. Cleared
1267 /// via [`Fds::clear_disk_dirty`].
1268 disk_dirty: bool,
1269 /// Per-disk write-protect (the `$4032` bit-2 source). Default writable
1270 /// (`false`); [`Fds::set_write_protected`] sets it.
1271 write_protected: bool,
1272 /// Deterministic "not ready" countdown (CPU cycles) after an insert. While
1273 /// non-zero, `$4032` bit 1 stays set and no transfer runs — a minimal stand
1274 /// in for the drive's spin-up/seek, with no analog seek-time model. Also
1275 /// re-opened (for [`HEAD_RESEEK_CYCLES`] + the per-game quirk slack) on each
1276 /// motor-restart rewind so the BIOS re-read loop observes the not-ready ->
1277 /// ready transition (the FDS-proper timed disk-head position).
1278 insert_not_ready: u32,
1279 /// Per-game timing quirk resolved from the disk-image CRC-32 at construction
1280 /// ([`quirk_for_crc`]). [`FdsQuirk::NONE`] for the vast majority of titles.
1281 /// Derived once from immutable inputs, so it is not part of the save-state.
1282 quirk: FdsQuirk,
1283 /// Opt-in **continuous analog head-seek model** (v2.2.0 "Capstone"). When
1284 /// `false` (the default), a motor-restart rewind opens the flat
1285 /// [`HEAD_RESEEK_CYCLES`] not-ready window (byte-identical to prior
1286 /// releases). When `true`, the window is instead the belt-driven
1287 /// distance-proportional seek time computed from [`Self::pre_rewind_head`]
1288 /// via [`HEAD_SEEK_BYTES_PER_CYCLE`] + [`HEAD_SEEK_SETTLE_CYCLES`]. Persisted
1289 /// in the v4 save-state tail; toggled via [`Fds::set_analog_head_seek`].
1290 analog_head_seek: bool,
1291 /// Wire head position captured at the *instant the motor stops* — i.e. how
1292 /// far the belt-driven head had travelled from the disk-start gap before the
1293 /// motor-off rewind snapped [`Self::head`] back to 0. The next motor-on uses
1294 /// this distance to size the continuous re-seek window (a far-out head takes
1295 /// proportionally longer to rewind). Only consulted while
1296 /// [`Self::analog_head_seek`] is set; persisted in the v4 tail.
1297 pre_rewind_head: usize,
1298
1299 // --- Registers ---
1300 /// $4020/$4021 — 16-bit timer IRQ reload value.
1301 timer_reload: u16,
1302 /// Live 16-bit timer counter.
1303 timer_counter: u16,
1304 /// $4022 bit 1 — timer IRQ enabled.
1305 timer_irq_enabled: bool,
1306 /// $4022 bit 0 — timer IRQ repeat (reload on expire).
1307 timer_irq_repeat: bool,
1308 /// $4023 bit 0 — master disk I/O register + timer enable.
1309 disk_io_enabled: bool,
1310 /// $4023 bit 1 — sound I/O enable (latched; audio is Stage 2).
1311 sound_io_enabled: bool,
1312 /// $4024 — last written disk write byte (write path is Stage 2).
1313 write_data: u8,
1314 /// $4025 control register (raw last write, for debug + bit decode).
1315 control: u8,
1316 /// $4026 external connector output latch.
1317 ext_output: u8,
1318
1319 // --- Control-register decoded bits ($4025) ---
1320 transfer_reset: bool, // bit 0 (1: reset transfer timing to initial state)
1321 motor_on: bool, // bit 1 == 0 -> motor running (0: start, 1: stop)
1322 read_mode: bool, // bit 2 (1: read, 0: write)
1323 mirroring: Mirroring, // bit 3 (0: Vertical mirroring, 1: Horizontal mirroring)
1324 crc_control: bool, // bit 5 (must be set for the byte-transfer flag)
1325 crc_enabled: bool, // bit 6 (CRC enable; gates the byte-transfer flag)
1326 irq_on_transfer: bool, // bit 7
1327
1328 // --- Status flags ($4030) ---
1329 timer_irq_flag: bool, // bit 0
1330 byte_transfer_flag: bool, // bit 1
1331 crc_error: bool, // bit 4
1332 end_of_head: bool, // bit 6
1333
1334 // --- Transfer engine ---
1335 transfer: TransferState,
1336 transfer_timer: u32,
1337 /// Byte most recently latched into the read shift register ($4031).
1338 read_data: u8,
1339
1340 // --- IRQ line ---
1341 irq_pending: bool,
1342
1343 // --- Sound channel ($4040-$4097) ---
1344 /// FDS 2C33 sound channel. Always present (register decode + save-state are
1345 /// build-independent); its synthesis is driven only under `mapper-audio`.
1346 audio: FdsAudio,
1347
1348 // --- Diagnostic read-stream trace (runtime opt-in; off by default) ---
1349 /// When set (via [`Fds::enable_trace`]), disk-read / control / side-change
1350 /// events are appended to `trace`. Pure observation — never affects emulation,
1351 /// is not serialized, and defaults off, so the determinism contract holds.
1352 trace_on: bool,
1353 /// Accumulated trace records, drained by [`Fds::take_trace`].
1354 trace: Vec<FdsTraceRec>,
1355}
1356
1357impl Fds {
1358 /// Construct an FDS device from a parsed disk image and an 8 KiB BIOS.
1359 ///
1360 /// # Errors
1361 ///
1362 /// Returns [`RomError::InvalidConfig`] if the BIOS is not exactly 8 KiB.
1363 pub fn new(disk: FdsDisk, bios: &[u8]) -> Result<Self, RomError> {
1364 if bios.len() != BIOS_LEN {
1365 return Err(RomError::InvalidConfig(format!(
1366 "FDS BIOS must be exactly {BIOS_LEN} bytes, got {}",
1367 bios.len()
1368 )));
1369 }
1370 // Synthesize the wire image for the power-on inserted side (side 0).
1371 let (wire, wire_map) = if disk.side_count() > 0 {
1372 build_side_wire(disk.side(0))
1373 } else {
1374 (Vec::new(), Vec::new())
1375 };
1376 // Resolve the per-game timing quirk from the disk-image CRC-32 (over the
1377 // headerless side bytes, matching `disk_image_bytes`).
1378 let quirk = quirk_for_crc(fds_crc32(&disk.to_bytes()));
1379 Ok(Self {
1380 prg_ram: vec![0u8; PRG_RAM_LEN].into_boxed_slice(),
1381 chr_ram: vec![0u8; CHR_RAM_LEN].into_boxed_slice(),
1382 bios: bios.to_vec().into_boxed_slice(),
1383 disk,
1384 inserted_side: Some(0),
1385 head: 0,
1386 wire,
1387 wire_map,
1388 disk_dirty: false,
1389 write_protected: false,
1390 timer_reload: 0,
1391 timer_counter: 0,
1392 timer_irq_enabled: false,
1393 timer_irq_repeat: false,
1394 disk_io_enabled: false,
1395 sound_io_enabled: false,
1396 write_data: 0,
1397 control: 0,
1398 ext_output: 0,
1399 motor_on: false,
1400 transfer_reset: true,
1401 read_mode: false,
1402 crc_control: false,
1403 crc_enabled: false,
1404 mirroring: Mirroring::Horizontal,
1405 irq_on_transfer: false,
1406 timer_irq_flag: false,
1407 byte_transfer_flag: false,
1408 crc_error: false,
1409 end_of_head: false,
1410 transfer: TransferState::Idle,
1411 transfer_timer: 0,
1412 read_data: 0,
1413 irq_pending: false,
1414 read_skipping_gap: true,
1415 spun_up: false,
1416 // The drive starts spun-down; the spin-up not-ready window opens when
1417 // the BIOS first turns the motor on (the motor off->on edge in
1418 // write_control), so the reset disk-check observes the not-ready ->
1419 // ready transition it waits for.
1420 insert_not_ready: 0,
1421 quirk,
1422 // The continuous head-seek model is opt-in (default off) so a
1423 // non-writing `.fds` run stays byte-identical to the fixed-window
1424 // path. `pre_rewind_head` starts at 0 (head parked at disk start).
1425 analog_head_seek: false,
1426 pre_rewind_head: 0,
1427 audio: FdsAudio::default(),
1428 trace_on: false,
1429 trace: Vec::new(),
1430 })
1431 }
1432
1433 /// Re-evaluate whether a disk transfer (read or write) should be running,
1434 /// from the current control bits + inserted state. Called after any `$4025`
1435 /// write and after an insert/eject.
1436 ///
1437 /// The byte-transfer flag is gated (per the Takuika die-scan reference) on
1438 /// **CRC enabled (`$4025.D6`) + `$4025.D5` set**, plus — in read mode —
1439 /// motor on. It is NOT gated on transfer-reset (`$4025.D0`): the BIOS holds
1440 /// reset asserted while it arms CRC/IRQ and then re-syncs between blocks, yet
1441 /// still expects byte-transfer IRQs to flow. Transfer-reset only rewinds the
1442 /// head (handled on its rising edge in `write_control`).
1443 fn update_transfer_state(&mut self) {
1444 let inserted = self.inserted_side.is_some();
1445 let ready = inserted && self.insert_not_ready == 0;
1446 // CRC enable + bit5 are required for the byte-transfer machinery to run
1447 // at all (both read and write modes).
1448 let crc_armed = self.crc_enabled && self.crc_control;
1449 let desired = if !ready || !crc_armed {
1450 TransferState::Idle
1451 } else if self.read_mode {
1452 // Read also requires the motor running.
1453 if self.motor_on {
1454 TransferState::Reading
1455 } else {
1456 TransferState::Idle
1457 }
1458 } else {
1459 // Write mode: motor state is irrelevant.
1460 TransferState::Writing
1461 };
1462 if self.transfer != desired {
1463 self.transfer = desired;
1464 if desired != TransferState::Idle {
1465 // Begin transferring from the current head position. The timer
1466 // is seeded so the first byte lands one cadence later.
1467 self.transfer_timer = DISK_BYTE_CYCLES;
1468 // A (re)started read re-arms the controller's gap-skip so the
1469 // first delivered byte is the next block's first byte (the
1470 // controller hides the gap + $80 start mark from the CPU).
1471 if desired == TransferState::Reading {
1472 self.read_skipping_gap = true;
1473 }
1474 }
1475 }
1476 }
1477
1478 /// Advance the disk-read engine by one byte: latch the next disk byte into
1479 /// the read register, raise the byte-transfer flag (and IRQ if enabled), and
1480 /// move the head forward.
1481 fn deliver_byte(&mut self) {
1482 if self.inserted_side.is_some() {
1483 // When re-syncing to a block, the controller bit-shifts past the
1484 // gap ($00 run) and its terminating $80 start mark in hardware
1485 // without raising a byte-transfer event; the first event delivers
1486 // the byte that follows the mark (the block's first byte).
1487 if self.read_skipping_gap {
1488 while self.head < self.wire.len() && self.wire[self.head] == 0x00 {
1489 self.head += 1;
1490 }
1491 if self.head < self.wire.len() && self.wire[self.head] == WIRE_START_MARK {
1492 self.head += 1;
1493 self.read_skipping_gap = false;
1494 } else if self.head >= self.wire.len() {
1495 // No further start mark before the inner track: the head has
1496 // reached the end of the side. Flag end-of-head and deliver
1497 // $00; the BIOS uses $4030.D6 to detect "no more blocks".
1498 self.read_data = 0;
1499 self.end_of_head = true;
1500 self.byte_transfer_flag = true;
1501 if self.irq_on_transfer {
1502 self.irq_pending = true;
1503 }
1504 return;
1505 } else {
1506 // A non-zero, non-mark byte while skipping (should not occur
1507 // for a well-formed wire image): treat it as data and stop
1508 // skipping so we never stall.
1509 self.read_skipping_gap = false;
1510 }
1511 }
1512 if self.head < self.wire.len() {
1513 self.read_data = self.wire[self.head];
1514 self.head += 1;
1515 } else {
1516 // The head reached the inner track (end of head): flag it and
1517 // deliver $00. The BIOS detects "no more data" via $4030.D6.
1518 self.read_data = 0;
1519 self.end_of_head = true;
1520 }
1521 } else {
1522 self.read_data = 0;
1523 self.end_of_head = true;
1524 }
1525 self.byte_transfer_flag = true;
1526 if self.irq_on_transfer {
1527 self.irq_pending = true;
1528 }
1529 }
1530
1531 /// Rebuild the wire image + block map for the currently inserted side from
1532 /// the raw `.fds` side contents. Called on insert/side-swap and after a BIOS
1533 /// write changes the disk, so reads always present the up-to-date wire form.
1534 fn rebuild_wire(&mut self) {
1535 match self.inserted_side {
1536 Some(idx) if idx < self.disk.side_count() => {
1537 let (wire, map) = build_side_wire(self.disk.side(idx));
1538 self.wire = wire;
1539 self.wire_map = map;
1540 }
1541 _ => {
1542 self.wire.clear();
1543 self.wire_map.clear();
1544 }
1545 }
1546 }
1547
1548 /// Map a wire head position to the raw side offset it corresponds to, when
1549 /// the head sits inside a block's payload region. Returns `None` for gap /
1550 /// start-mark / CRC positions (writes there modify only the synthesized
1551 /// framing, which is regenerated from the raw side, so they are dropped).
1552 fn wire_head_to_raw(&self, wire_pos: usize) -> Option<usize> {
1553 for blk in &self.wire_map {
1554 if wire_pos >= blk.wire_payload_start && wire_pos < blk.wire_payload_start + blk.len {
1555 return Some(blk.raw_start + (wire_pos - blk.wire_payload_start));
1556 }
1557 }
1558 None
1559 }
1560
1561 /// Advance the disk-write engine by one byte: store the byte last written to
1562 /// `$4024` into the inserted side at the head position, mark the image
1563 /// dirty, raise the byte-transfer flag (and IRQ if enabled), and move the
1564 /// head forward. A write-protected disk drops the byte (the medium is not
1565 /// modified) but still advances the transfer machinery so timing-dependent
1566 /// BIOS code is unaffected. CRC/gap bytes are not synthesized (Stage-1
1567 /// simplification) — only the raw `$4024` byte stream lands on the medium.
1568 fn store_byte(&mut self) {
1569 if let Some(idx) = self.inserted_side {
1570 if self.head < self.wire.len() {
1571 if !self.write_protected {
1572 // The BIOS write stream is itself the wire format (gap,
1573 // start mark, block bytes, CRC). Land the byte on the wire
1574 // image, and when it falls inside a block payload, mirror it
1575 // into the raw `.fds` side so the modified disk persists. The
1576 // BIOS writes whole blocks back to the same position it read
1577 // them, so the existing block geometry stays valid.
1578 let raw_off = self.wire_head_to_raw(self.head);
1579 let written_pos = self.head;
1580 self.wire[self.head] = self.write_data;
1581 if let Some(off) = raw_off
1582 && off < self.disk.side(idx).len()
1583 {
1584 self.disk.side_mut(idx)[off] = self.write_data;
1585 // The FDS controller emits a fresh CRC-16 after each
1586 // block it writes; re-synthesize this block's stored CRC
1587 // over its (now-updated) payload so the medium stays
1588 // self-consistent — the synthetic write-verify oracle and
1589 // any $4030.D4-checking loader then see a valid block.
1590 self.resynth_block_crc(written_pos);
1591 }
1592 self.disk_dirty = true;
1593 }
1594 self.head += 1;
1595 } else {
1596 self.end_of_head = true;
1597 }
1598 } else {
1599 self.end_of_head = true;
1600 }
1601 self.byte_transfer_flag = true;
1602 if self.irq_on_transfer {
1603 self.irq_pending = true;
1604 }
1605 }
1606
1607 /// Compute the motor-restart re-seek not-ready window (CPU cycles).
1608 ///
1609 /// With the continuous head-seek model disabled (the default) this is the
1610 /// flat [`HEAD_RESEEK_CYCLES`] plus any per-game quirk slack — byte-identical
1611 /// to prior releases. With the model enabled it is the belt-driven,
1612 /// distance-proportional seek time: a fixed [`HEAD_SEEK_SETTLE_CYCLES`]
1613 /// settle plus the head-travel distance ([`Self::pre_rewind_head`] wire
1614 /// bytes) divided by the belt velocity [`HEAD_SEEK_BYTES_PER_CYCLE`],
1615 /// clamped so a rewind never exceeds a cold [`MOTOR_SPIN_UP_CYCLES`]
1616 /// spin-up. Both paths add the per-game quirk slack and are fully
1617 /// deterministic (integer arithmetic, no analog jitter).
1618 fn reseek_window_cycles(&self) -> u32 {
1619 let base = if self.analog_head_seek {
1620 let travel = (self.pre_rewind_head as u32) / HEAD_SEEK_BYTES_PER_CYCLE;
1621 HEAD_SEEK_SETTLE_CYCLES
1622 .saturating_add(travel)
1623 .min(MOTOR_SPIN_UP_CYCLES)
1624 } else {
1625 HEAD_RESEEK_CYCLES
1626 };
1627 base.saturating_add(self.quirk.extra_reseek_cycles)
1628 }
1629
1630 /// Re-emit the per-block CRC-16 for the block whose payload contains wire
1631 /// offset `wire_pos`, recomputing it over the block's current payload bytes.
1632 ///
1633 /// Real FDS hardware appends a fresh CRC-16 immediately after each block it
1634 /// writes; the RP2C33 controller's CRC generator runs continuously over the
1635 /// block stream and the two CRC bytes it emits close the block. Modelling
1636 /// that keeps the synthesized wire image self-consistent after a BIOS write:
1637 /// once the last payload byte of a block lands, its stored CRC matches a
1638 /// recomputation, so [`Self::medium_write_verify`] (and any stricter loader
1639 /// that checks `$4030.D4`) sees a valid block. Gap / start-mark / CRC
1640 /// positions map to no block payload and are skipped (their framing is
1641 /// regenerated from the raw side on the next [`Self::rebuild_wire`]).
1642 fn resynth_block_crc(&mut self, wire_pos: usize) {
1643 for blk in &self.wire_map {
1644 let payload_end = blk.wire_payload_start + blk.len;
1645 if wire_pos >= blk.wire_payload_start && wire_pos < payload_end {
1646 // The two CRC bytes sit immediately after the payload on the
1647 // wire (build_side_wire lays them out `[block][crc_lo][crc_hi]`).
1648 if payload_end + 1 < self.wire.len() {
1649 let crc = fds_block_crc(
1650 WIRE_START_MARK,
1651 &self.wire[blk.wire_payload_start..payload_end],
1652 );
1653 self.wire[payload_end] = (crc & 0xFF) as u8;
1654 self.wire[payload_end + 1] = (crc >> 8) as u8;
1655 }
1656 return;
1657 }
1658 }
1659 }
1660
1661 /// Walk the synthesized wire image of the currently inserted side and verify
1662 /// every block's gap / start-mark framing and per-block CRC-16 round-trips —
1663 /// the **synthetic FDS write-verify oracle** (v2.2.0 "Capstone").
1664 ///
1665 /// This is deliberately BIOS-free: it validates the emulator's own medium
1666 /// synthesis (gap runs, `$80` start marks, CRC-16/KERMIT block CRCs) so the
1667 /// write path can be exercised and checked entirely in CI without any
1668 /// copyright FDS BIOS. The real-BIOS write path (which recomputes the CRC in
1669 /// its own RAM and streams it to `$4024`) is validated only from a local,
1670 /// gitignored dump — see `docs/accuracy-ledger.md` for the CI-verifiable vs
1671 /// local-only split.
1672 ///
1673 /// # Errors
1674 ///
1675 /// Returns the first `FdsMediumError` encountered (missing start mark,
1676 /// CRC mismatch, truncated block, or a corrupted inter-block gap). Returns
1677 /// `Ok(())` when no side is inserted (nothing to verify).
1678 pub fn medium_write_verify(&self) -> Result<(), FdsMediumError> {
1679 for (block, blk) in self.wire_map.iter().enumerate() {
1680 // The `$80` start mark sits one byte before the payload.
1681 if blk.wire_payload_start == 0
1682 || self.wire[blk.wire_payload_start - 1] != WIRE_START_MARK
1683 {
1684 return Err(FdsMediumError::MissingStartMark {
1685 block,
1686 wire_pos: blk.wire_payload_start.saturating_sub(1),
1687 });
1688 }
1689 let payload_end = blk.wire_payload_start + blk.len;
1690 // Payload + its two CRC bytes must fit inside the wire.
1691 if payload_end + 1 >= self.wire.len() {
1692 return Err(FdsMediumError::Truncated { block });
1693 }
1694 let expected = fds_block_crc(
1695 WIRE_START_MARK,
1696 &self.wire[blk.wire_payload_start..payload_end],
1697 );
1698 let stored =
1699 u16::from(self.wire[payload_end]) | (u16::from(self.wire[payload_end + 1]) << 8);
1700 if stored != expected {
1701 return Err(FdsMediumError::CrcMismatch {
1702 block,
1703 stored,
1704 expected,
1705 });
1706 }
1707 // The gap before this block (from the prior block's CRC end, or the
1708 // start of the wire) must be all `$00`.
1709 let gap_start = if block == 0 {
1710 0
1711 } else {
1712 let prev = &self.wire_map[block - 1];
1713 prev.wire_payload_start + prev.len + 2
1714 };
1715 let mark_pos = blk.wire_payload_start - 1;
1716 for (i, &b) in self.wire[gap_start..mark_pos].iter().enumerate() {
1717 if b != 0x00 {
1718 return Err(FdsMediumError::GapNotZero {
1719 block,
1720 wire_pos: gap_start + i,
1721 });
1722 }
1723 }
1724 }
1725 Ok(())
1726 }
1727
1728 /// Enable or disable the continuous analog head-seek model (default off).
1729 ///
1730 /// Opt-in accuracy feature: when disabled (the default) motor-restart
1731 /// rewinds use the flat [`HEAD_RESEEK_CYCLES`] window, so a non-writing
1732 /// `.fds` run is byte-identical to prior releases. When enabled, the re-seek
1733 /// window scales with head-travel distance (belt velocity). See
1734 /// `Self::reseek_window_cycles`.
1735 pub const fn set_analog_head_seek(&mut self, enabled: bool) {
1736 self.analog_head_seek = enabled;
1737 }
1738
1739 /// Whether the continuous analog head-seek model is currently enabled.
1740 #[must_use]
1741 pub const fn analog_head_seek(&self) -> bool {
1742 self.analog_head_seek
1743 }
1744
1745 /// Acknowledge a pending timer IRQ (clears the timer-IRQ status bit and the
1746 /// shared IRQ line if no disk IRQ remains). Mirrors the three documented ack
1747 /// paths: read `$4030`, write `$4022`, write `$4023`.
1748 fn ack_timer_irq(&mut self) {
1749 self.timer_irq_flag = false;
1750 self.recompute_irq_line();
1751 }
1752
1753 /// Acknowledge a pending disk (byte-transfer) IRQ.
1754 fn ack_disk_irq(&mut self) {
1755 self.byte_transfer_flag = false;
1756 self.recompute_irq_line();
1757 }
1758
1759 /// Recompute the shared IRQ line from the two latched flags.
1760 fn recompute_irq_line(&mut self) {
1761 // The IRQ line is the OR of the timer IRQ and (when armed) the disk
1762 // byte-transfer IRQ. Disk IRQs are only asserted while
1763 // `irq_on_transfer`; once the flag is cleared the line drops.
1764 self.irq_pending = self.timer_irq_flag || (self.irq_on_transfer && self.byte_transfer_flag);
1765 }
1766
1767 /// Decode a `$4025` control write into the individual control bits.
1768 fn write_control(&mut self, value: u8) {
1769 self.trace_event(1, value);
1770 self.control = value;
1771 // $4025 bit layout (per nesdev / Takuika die-scan):
1772 // bit0 = transfer reset (1: reset transfer timing to the initial state)
1773 // bit1 = drive motor (0: start, 1: stop)
1774 // bit2 = transfer mode (1: read, 0: write)
1775 // bit3 = nametable arrangement
1776 // bit5 = CRC transfer control (must be set for byte-transfer)
1777 // bit6 = CRC enable (gates the byte-transfer flag)
1778 // bit7 = byte-transfer IRQ enable
1779 let was_motor_on = self.motor_on;
1780 let was_transfer_reset = self.transfer_reset;
1781 self.transfer_reset = (value & 0x01) != 0; // bit 0
1782 self.motor_on = (value & 0x02) == 0; // bit 1 (0: start, 1: stop)
1783 self.read_mode = (value & 0x04) != 0;
1784 self.crc_control = (value & 0x20) != 0;
1785 self.crc_enabled = (value & 0x40) != 0;
1786 if self.motor_on && !was_motor_on {
1787 if self.spun_up {
1788 // A motor RESTART after the cold spin-up. The belt-driven drive
1789 // has physically rewound the disk to the disk-start gap (handled
1790 // on the preceding motor-off below), and the head must re-seek
1791 // to track 0 before the first block streams again — the
1792 // FDS-proper timed disk-head position. Open a short not-ready
1793 // window (plus any per-game quirk slack) so the BIOS re-read
1794 // loop observes the not-ready -> ready transition it waits for
1795 // on every re-read (e.g. Kid Icarus side-B post-registration).
1796 //
1797 // A restart with the head NOT at the disk start (mid-read motor
1798 // toggles the BIOS does between blocks) keeps streaming where it
1799 // left off and needs no re-seek — the disk never stopped turning
1800 // in that case. We only re-seek when the head sits at the start
1801 // (i.e. a true rewind happened), which is what the post-load
1802 // re-read sequence produces.
1803 if self.head == 0 {
1804 self.insert_not_ready = self.insert_not_ready.max(self.reseek_window_cycles());
1805 }
1806 } else {
1807 // First motor-on since the disk was inserted: the drive spins up
1808 // and the head seeks to the disk start, reporting not-ready
1809 // ($4032.1) for a spin-up period the BIOS reset disk-check waits
1810 // for.
1811 self.insert_not_ready = MOTOR_SPIN_UP_CYCLES;
1812 self.spun_up = true;
1813 }
1814 }
1815 if !self.motor_on && was_motor_on {
1816 // Motor stop: the FDS drive is belt-driven and physically rewinds the
1817 // disk back to the start when the motor is turned off. The next
1818 // motor-on therefore reads from the first block again. This is how the
1819 // BIOS re-reads a side for a subsequent load (e.g. the game proper
1820 // after the licence screen, or a second LoadFiles pass): it stops the
1821 // motor, re-arms, and restarts — expecting block 1 to come back first.
1822 // Without the rewind the head stays parked at the inner track from the
1823 // prior read, so the next read delivers only trailing gap (no block) and
1824 // the load stalls forever. The cold spin-up window is NOT re-opened
1825 // (`spun_up` stays set) — only the disk position rewinds, matching the
1826 // mid-session rewind hardware does without a full spin-up delay.
1827 //
1828 // Capture how far the head had travelled from the disk-start gap
1829 // BEFORE snapping it back, so the continuous head-seek model can size
1830 // the next motor-on's re-seek window by that distance (belt
1831 // velocity). The fixed-window model ignores this field.
1832 self.pre_rewind_head = self.head;
1833 self.head = 0;
1834 self.end_of_head = false;
1835 self.read_skipping_gap = true;
1836 }
1837 // Asserting transfer reset (its rising edge) resets the byte-transfer
1838 // timing and re-arms the read gap-skip so the next delivered byte
1839 // re-syncs to a block start mark. It does NOT rewind the head — the head
1840 // tracks the physical disk rotation, which only returns to the start
1841 // when it reaches the inner track (end of head). The BIOS toggles reset
1842 // between blocks to re-align to each block's start mark; the head must
1843 // keep advancing across the inter-block gap so successive blocks are
1844 // read in order. The flag also does NOT stop byte transfers (they are
1845 // gated by CRC + bit5 + motor, not reset).
1846 if self.transfer_reset && !was_transfer_reset {
1847 self.read_skipping_gap = true;
1848 self.transfer_timer = DISK_BYTE_CYCLES;
1849 }
1850 // $4025 bit 3 = nametable arrangement (FDS naming, nesdev wiki):
1851 // 0: "Horizontal arrangement" == VERTICAL mirroring == $2000 aliases
1852 // $2800 (this codebase's `Mirroring::Vertical`, tables 0/2 -> bank 0).
1853 // 1: "Vertical arrangement" == HORIZONTAL mirroring == $2000 and $2800
1854 // are distinct physical nametables (this codebase's
1855 // `Mirroring::Horizontal`, tables 0/1 -> bank 0, 2/3 -> bank 1).
1856 // The FDS BIOS boot/licence flow loads the KYODAKU approval tilemap to
1857 // $2800 then clears+rebuilds the message nametable at $2000 (with bit 3
1858 // set), relying on $2000 and $2800 being separate banks. The previous
1859 // mapping had these swapped, so clearing $2000 wiped the $2800 licence
1860 // before the BIOS's self-verify read it back, yielding "DISK TROUBLE
1861 // ERR.20" on every real-BIOS boot.
1862 self.mirroring = if (value & 0x08) != 0 {
1863 Mirroring::Horizontal
1864 } else {
1865 Mirroring::Vertical
1866 };
1867 self.irq_on_transfer = (value & 0x80) != 0;
1868 // Writing $4025 acknowledges disk IRQs.
1869 self.ack_disk_irq();
1870 self.update_transfer_state();
1871 }
1872
1873 /// Read of the disk status register `$4030` (acknowledges timer + disk IRQs).
1874 fn read_status_4030(&mut self) -> u8 {
1875 let mut v = 0u8;
1876 if self.timer_irq_flag {
1877 v |= 0x01; // bit 0: timer IRQ
1878 }
1879 if self.byte_transfer_flag {
1880 v |= 0x80; // bit 7: byte-transfer flag (NOT bit 1)
1881 }
1882 if self.crc_error {
1883 v |= 0x10; // bit 4: CRC error
1884 }
1885 if self.end_of_head {
1886 v |= 0x40; // bit 6: end of head
1887 }
1888 // Reading $4030 acknowledges the timer IRQ but, contrary to older docs,
1889 // does NOT clear the byte-transfer flag nor acknowledge its IRQ — only a
1890 // $4024/$4031 service does (Takuika die-scan reference). Leaving the
1891 // byte-transfer flag latched here is what lets the BIOS poll $4030.D7.
1892 self.timer_irq_flag = false;
1893 self.recompute_irq_line();
1894 v
1895 }
1896
1897 /// Read of the read data register `$4031` (consumes the latched disk byte,
1898 /// clears the byte-transfer flag, acknowledges disk IRQs).
1899 fn read_data_4031(&mut self) -> u8 {
1900 let v = self.read_data;
1901 self.trace_event(0, v);
1902 self.byte_transfer_flag = false;
1903 self.recompute_irq_line();
1904 v
1905 }
1906
1907 /// Append one [`FdsTraceRec`] when tracing is enabled (a no-op otherwise, so
1908 /// default builds carry zero overhead beyond a single bool check on the cold
1909 /// register paths). See [`Fds::enable_trace`].
1910 fn trace_event(&mut self, kind: u8, value: u8) {
1911 // Cap the diagnostic buffer so a long session (or tracing accidentally
1912 // left on) can't grow it without bound; the early records are the ones
1913 // that matter for the boot/swap investigations this facility serves.
1914 const MAX_TRACE_RECORDS: usize = 100_000;
1915 if self.trace_on && self.trace.len() < MAX_TRACE_RECORDS {
1916 let status = self.read_status_4030_peek();
1917 self.trace.push(FdsTraceRec {
1918 kind,
1919 value,
1920 head: self.head as u32,
1921 side: self.inserted_side.map_or(-1, |s| s as i8),
1922 status,
1923 });
1924 }
1925 }
1926
1927 /// Non-mutating snapshot of the `$4030` status bits (for the trace record;
1928 /// unlike [`Self::read_status_4030`] it does not acknowledge the timer IRQ).
1929 fn read_status_4030_peek(&self) -> u8 {
1930 let mut v = 0u8;
1931 if self.timer_irq_flag {
1932 v |= 0x01;
1933 }
1934 if self.byte_transfer_flag {
1935 v |= 0x80;
1936 }
1937 if self.crc_error {
1938 v |= 0x10;
1939 }
1940 if self.end_of_head {
1941 v |= 0x40;
1942 }
1943 v
1944 }
1945
1946 /// The per-game timing quirk resolved from the disk CRC-32 at construction
1947 /// ([`quirk_for_crc`]). [`FdsQuirk::NONE`] for titles without a table entry.
1948 #[must_use]
1949 pub fn quirk(&self) -> FdsQuirk {
1950 self.quirk
1951 }
1952
1953 /// Start recording the diagnostic FDS read-stream trace (see [`FdsTraceRec`]).
1954 /// Off by default; recording is pure observation and never affects emulation.
1955 pub fn enable_trace(&mut self) {
1956 self.trace_on = true;
1957 }
1958
1959 /// Drain the accumulated FDS trace records.
1960 pub fn take_trace(&mut self) -> Vec<FdsTraceRec> {
1961 core::mem::take(&mut self.trace)
1962 }
1963
1964 /// Read of the drive status register `$4032`.
1965 ///
1966 /// Models the drive's auto-insert / re-seek presentation to the loader: the
1967 /// not-ready bit (1) is driven by the spin-up / re-seek windows
1968 /// ([`MOTOR_SPIN_UP_CYCLES`] / [`HEAD_RESEEK_CYCLES`] + per-game quirk),
1969 /// the motor state, and end-of-head, so the disk re-presents itself on each
1970 /// re-read with the not-ready -> ready transition the BIOS waits for.
1971 fn read_drive_status_4032(&self) -> u8 {
1972 let inserted = self.inserted_side.is_some();
1973 let mut v = 0u8;
1974 // bit 0: disk flag (0: inserted, 1: not inserted).
1975 if !inserted {
1976 v |= 0x01;
1977 }
1978 // bit 1: ready flag (0: ready, 1: not ready). Set while no disk is
1979 // inserted, during the post-insert not-ready / re-seek window, when the
1980 // motor is stopped, or when the head has reached the inner track (end of
1981 // side).
1982 if !inserted || self.insert_not_ready > 0 || self.end_of_head || !self.motor_on {
1983 v |= 0x02;
1984 }
1985 // bit 2: write-protect (per-disk flag; also forced when ejected, per
1986 // the wiki: "Write protected or disk ejected").
1987 if self.write_protected || !inserted {
1988 v |= 0x04;
1989 }
1990 v
1991 }
1992
1993 /// Read of the external connector input `$4033` (bit 7 = battery good).
1994 fn read_ext_4033(&self) -> u8 {
1995 // Battery good when a disk is present. The low 7 bits read back the
1996 // open-collector $4026 output (Stage 1: pass the latched output through,
1997 // masked to the input bits).
1998 let battery = if self.inserted_side.is_some() {
1999 0x80
2000 } else {
2001 0x00
2002 };
2003 battery | (self.ext_output & 0x7F)
2004 }
2005
2006 /// Insert side `i` (`Some`) or eject (`None`). An insert resets the head to
2007 /// the start of the side and opens a short deterministic not-ready window;
2008 /// an eject stops any transfer. Out-of-range indices are ignored (the
2009 /// caller bounds via [`Mapper::disk_side_count`]). Shared helper behind the
2010 /// [`Mapper::set_disk_side`] trait override.
2011 fn do_set_disk_side(&mut self, side: Option<usize>) {
2012 self.trace_event(2, side.map_or(0xFF, |s| s as u8));
2013 match side {
2014 Some(i) if i < self.disk.side_count() => {
2015 self.inserted_side = Some(i);
2016 self.head = 0;
2017 self.end_of_head = false;
2018 self.insert_not_ready = INSERT_NOT_READY_CYCLES;
2019 // A freshly inserted disk must spin up on the next motor-on.
2020 self.spun_up = false;
2021 self.rebuild_wire();
2022 }
2023 Some(_) => { /* out of range: ignore */ }
2024 None => {
2025 self.inserted_side = None;
2026 self.insert_not_ready = 0;
2027 self.end_of_head = false;
2028 self.spun_up = false;
2029 self.rebuild_wire();
2030 }
2031 }
2032 // Recompute whether a transfer can run given the new inserted state.
2033 self.update_transfer_state();
2034 }
2035
2036 /// Parse the v3 disk tail starting at byte `off` (after the audio tail).
2037 /// `base` is the fixed-prefix length used to validate the total v3 size.
2038 fn load_disk_tail(
2039 &mut self,
2040 data: &[u8],
2041 mut off: usize,
2042 base: usize,
2043 ) -> Result<(), MapperError> {
2044 let saved_sides = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
2045 off += 4;
2046 // Validate the full length now that the side count is known. v4 appends
2047 // the continuous head-seek tail after the v3 fields.
2048 //
2049 // Checked arithmetic (core audit v2.9.2 AUD-01). The count is untrusted
2050 // and `usize` is 32 bits on the wasm32 / armv7 / i686 targets RustyNES
2051 // ships, where `saved_sides * FDS_SIDE_LEN` wraps: a count of 2^30 wraps
2052 // the side region to zero bytes, so a blob with no side data passed the
2053 // exact-length check below and the restore loop then sliced past its
2054 // end (a panic in every profile; in `dev` the overflow check panicked
2055 // first). No separate cap is needed: the blob is already in memory, so
2056 // any count whose length does not fit in `usize` cannot describe it, and
2057 // any count that does fit must match `data.len()` exactly. The bound on
2058 // `saved_sides` is therefore the blob's own length, on every target.
2059 let v4_extra = FDS_V4_TAIL_LEN;
2060 let expected = saved_sides
2061 .checked_mul(FDS_SIDE_LEN)
2062 .and_then(|sides_len| {
2063 sides_len.checked_add(base + FdsAudio::TAIL_LEN + 4 + 4 + 4 + 1 + v4_extra)
2064 })
2065 .ok_or_else(|| {
2066 MapperError::Invalid(format!(
2067 "FDS save-state side count {saved_sides} overflows the address space"
2068 ))
2069 })?;
2070 if data.len() != expected {
2071 return Err(MapperError::WrongLength {
2072 expected,
2073 got: data.len(),
2074 });
2075 }
2076 // Restore the side contents into the matching local sides. A foreign
2077 // blob with a different side count restores only the overlap so we never
2078 // index out of range either way.
2079 let restore = saved_sides.min(self.disk.side_count());
2080 for s in 0..restore {
2081 self.disk
2082 .side_mut(s)
2083 .copy_from_slice(&data[off..off + FDS_SIDE_LEN]);
2084 off += FDS_SIDE_LEN;
2085 }
2086 // Skip any extra saved sides we have no local slot for. Cannot overflow:
2087 // the checked length above proved `saved_sides * FDS_SIDE_LEN` fits
2088 // inside `data.len()`.
2089 off += (saved_sides - restore) * FDS_SIDE_LEN;
2090 let inserted = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
2091 off += 4;
2092 self.inserted_side = if inserted == u32::MAX {
2093 None
2094 } else {
2095 Some((inserted as usize).min(self.disk.side_count().saturating_sub(1)))
2096 };
2097 self.insert_not_ready = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
2098 off += 4;
2099 let disk_flags = data[off];
2100 off += 1;
2101 self.disk_dirty = (disk_flags & 0x01) != 0;
2102 self.write_protected = (disk_flags & 0x02) != 0;
2103 self.spun_up = (disk_flags & 0x04) != 0;
2104 // v4 continuous head-seek tail.
2105 self.analog_head_seek = data[off] != 0;
2106 off += 1;
2107 self.pre_rewind_head = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
2108 // Advance past the u32 we just consumed so `off` keeps reflecting the
2109 // total bytes read — preserving the "offset == consumed" invariant so
2110 // any future tail extension starts from the correct position.
2111 off += 4;
2112 // The decode must have consumed exactly the blob the length check at the
2113 // top validated (`expected == data.len()`); assert the invariant and, in
2114 // doing so, read `off` on every path (no `unused_assignments`).
2115 debug_assert_eq!(
2116 off,
2117 data.len(),
2118 "FDS disk tail consumed byte count must match the validated blob length"
2119 );
2120 Ok(())
2121 }
2122}
2123
2124/// Save-state format version for the FDS device.
2125///
2126/// Only v4 loads since v2.9.8 (ADR 0042); the notes below on how v1-v3 blobs
2127/// loaded describe the behaviour before then.
2128///
2129/// - v1: Stage 1 (memory + disk position + timer/transfer + IRQ). No audio tail.
2130/// - v2: appends the [`FdsAudio`] sound-channel tail ([`FdsAudio::TAIL_LEN`]).
2131/// Strictly additive — v1 blobs load with the audio unit left at default.
2132/// - v3 (Stage 2b): appends a **disk tail** after the audio tail capturing the
2133/// mutable disk contents (so mid-write rewind/save-state round-trips), the
2134/// `inserted_side` (`Option`, encoded as `0xFFFF_FFFF` for ejected),
2135/// `disk_dirty`, `write_protected`, the `insert_not_ready` countdown, and the
2136/// write-vs-read transfer phase. The `inserted_side` field in the v1 section
2137/// is retained (clamped) for back-compat; v3 overwrites it from the tail.
2138/// Loading a v1/v2 blob leaves the disk at its construction contents
2139/// (un-modified), side 0 inserted, not dirty, writable.
2140/// - v4 (Capstone): appends the **continuous head-seek** tail
2141/// ([`FDS_V4_TAIL_LEN`]) after the v3 disk tail — the `analog_head_seek`
2142/// opt-in flag + the `pre_rewind_head` distance. Strictly additive: a v1/v2/v3
2143/// blob restores with the model disabled and `pre_rewind_head` = 0 (the
2144/// byte-identical default).
2145const FDS_SAVE_VERSION: u8 = 4;
2146
2147/// Extra bytes the v4 disk tail appends after the v3 tail: the
2148/// `analog_head_seek` flag (1 byte) + `pre_rewind_head` (u32, 4 bytes).
2149const FDS_V4_TAIL_LEN: usize = 1 + 4;
2150
2151impl Mapper for Fds {
2152 fn sram(&self) -> &[u8] {
2153 &self.prg_ram
2154 }
2155 fn sram_mut(&mut self) -> &mut [u8] {
2156 &mut self.prg_ram
2157 }
2158 // v2.8.0 Phase 4 — CPU-cycle hook + IRQ source + expansion audio
2159 // (the audio hook only exists under the `mapper-audio` feature).
2160 fn caps(&self) -> MapperCaps {
2161 MapperCaps {
2162 cpu_cycle_hook: true,
2163 audio: cfg!(feature = "mapper-audio"),
2164 frame_event_hook: false,
2165 irq_source: true,
2166 }
2167 }
2168
2169 fn cpu_read(&mut self, addr: u16) -> u8 {
2170 match addr {
2171 // Disk registers are read-only at $4030-$4033 and gated on disk I/O
2172 // enable (apart from open-bus behaviour the bus handles elsewhere).
2173 0x4030 => self.read_status_4030(),
2174 0x4031 => self.read_data_4031(),
2175 0x4032 => self.read_drive_status_4032(),
2176 0x4033 => self.read_ext_4033(),
2177 // Sound channel reads: volume/mod gain + internal accumulators.
2178 // When write-protect is active ($4089 bit 7 clear), reading any
2179 // wavetable byte ($4040-$407F) returns the value at the current
2180 // wave position; while write-enabled it reads back the RAM.
2181 0x4040..=0x407F => {
2182 if self.audio.wave_write_enable {
2183 self.audio.wavetable[(addr - 0x4040) as usize]
2184 } else {
2185 self.audio.wave_out_latch & 0x3F
2186 }
2187 }
2188 0x4090..=0x4097 => self.audio.read_reg(addr).unwrap_or(0),
2189 // PRG-RAM at $6000-$DFFF.
2190 0x6000..=0xDFFF => self.prg_ram[(addr - 0x6000) as usize],
2191 // BIOS at $E000-$FFFF.
2192 0xE000..=0xFFFF => self.bios[(addr - 0xE000) as usize],
2193 // Everything else in the device window reads as open bus; the bus's
2194 // floating-latch handling owns the exact value, so return 0 here.
2195 _ => 0,
2196 }
2197 }
2198
2199 fn cpu_write(&mut self, addr: u16, value: u8) {
2200 match addr {
2201 // $4020/$4021 reload value — NOT gated on disk I/O enable.
2202 0x4020 => {
2203 self.timer_reload = (self.timer_reload & 0xFF00) | u16::from(value);
2204 }
2205 0x4021 => {
2206 self.timer_reload = (self.timer_reload & 0x00FF) | (u16::from(value) << 8);
2207 }
2208 // $4022 timer IRQ control — gated on disk I/O enable.
2209 0x4022 => {
2210 if self.disk_io_enabled {
2211 self.timer_irq_repeat = (value & 0x01) != 0;
2212 let enable = (value & 0x02) != 0;
2213 if enable {
2214 // Enabling copies the reload value into the counter.
2215 self.timer_counter = self.timer_reload;
2216 self.timer_irq_enabled = true;
2217 } else {
2218 // Disabling stops the counter and acks pending timer IRQ.
2219 self.timer_irq_enabled = false;
2220 self.ack_timer_irq();
2221 }
2222 }
2223 }
2224 // $4023 master I/O enable.
2225 0x4023 => {
2226 self.disk_io_enabled = (value & 0x01) != 0;
2227 self.sound_io_enabled = (value & 0x02) != 0;
2228 if !self.disk_io_enabled {
2229 // Clearing disk registers immediately stops the timer and
2230 // acks pending timer IRQs (also disables disk IRQs).
2231 self.timer_irq_enabled = false;
2232 self.timer_irq_flag = false;
2233 self.recompute_irq_line();
2234 }
2235 }
2236 // $4024 write data register: the byte to load into the shift
2237 // register on the next byte-transfer tick (stored to the medium in
2238 // write mode by `store_byte`). Writing $4024 acknowledges disk IRQs.
2239 0x4024 => {
2240 self.write_data = value;
2241 self.ack_disk_irq();
2242 }
2243 // $4025 FDS control.
2244 0x4025 => self.write_control(value),
2245 // $4026 external connector output.
2246 0x4026 => self.ext_output = value,
2247 // Sound channel registers ($4040-$408A). Per the wiki these require
2248 // the sound I/O enable bit ($4023 bit 1) to be set to take effect.
2249 0x4040..=0x408A => {
2250 if self.sound_io_enabled {
2251 self.audio.write_reg(addr, value);
2252 }
2253 }
2254 // PRG-RAM at $6000-$DFFF (writable).
2255 0x6000..=0xDFFF => self.prg_ram[(addr - 0x6000) as usize] = value,
2256 // BIOS / unmapped: ignore.
2257 _ => {}
2258 }
2259 }
2260
2261 fn cpu_read_unmapped(&self, addr: u16) -> bool {
2262 // v2.7.2's "no save RAM -> `$6000-$7FFF` floats" default does not apply:
2263 // `sram()` is the 32 KiB PRG-RAM, never empty, so the window stays mapped.
2264 {
2265 // The FDS registers occupy $4020-$409F (the $4040-$4092 sound block is
2266 // Stage 2 but still part of the device). Reporting these as MAPPED routes
2267 // the reads to `cpu_read` instead of the open-bus latch. PRG-RAM and BIOS
2268 // ($6000-$FFFF) are always mapped. Anything else in $40A0-$5FFF is
2269 // genuinely unmapped (open bus).
2270 if (0x4020..=0x409F).contains(&addr) {
2271 return false;
2272 }
2273 (0x40A0..=0x5FFF).contains(&addr)
2274 }
2275 }
2276
2277 fn ppu_read(&mut self, addr: u16) -> u8 {
2278 match addr {
2279 0x0000..=0x1FFF => self.chr_ram[(addr & 0x1FFF) as usize],
2280 _ => 0,
2281 }
2282 }
2283
2284 fn ppu_write(&mut self, addr: u16, value: u8) {
2285 if addr <= 0x1FFF {
2286 self.chr_ram[(addr & 0x1FFF) as usize] = value;
2287 }
2288 }
2289
2290 fn notify_cpu_cycle(&mut self) {
2291 // Sound channel runs every CPU cycle (independent of disk I/O).
2292 #[cfg(feature = "mapper-audio")]
2293 self.audio.clock();
2294
2295 // Timer IRQ: decrement each CPU cycle while enabled.
2296 if self.timer_irq_enabled {
2297 if self.timer_counter == 0 {
2298 // Already at zero: fire and reload/disable.
2299 self.timer_irq_flag = true;
2300 self.irq_pending = true;
2301 if self.timer_irq_repeat {
2302 self.timer_counter = self.timer_reload;
2303 } else {
2304 self.timer_irq_enabled = false;
2305 }
2306 } else {
2307 self.timer_counter -= 1;
2308 if self.timer_counter == 0 {
2309 self.timer_irq_flag = true;
2310 self.irq_pending = true;
2311 if self.timer_irq_repeat {
2312 self.timer_counter = self.timer_reload;
2313 } else {
2314 self.timer_irq_enabled = false;
2315 }
2316 }
2317 }
2318 }
2319
2320 // Post-insert not-ready window: count down, then re-evaluate whether a
2321 // transfer can begin (so a motor-on read/write started during the
2322 // window kicks off once the drive reports ready).
2323 if self.insert_not_ready > 0 {
2324 self.insert_not_ready -= 1;
2325 if self.insert_not_ready == 0 {
2326 self.update_transfer_state();
2327 }
2328 }
2329
2330 // Disk transfer: deliver (read) or store (write) a byte every
2331 // DISK_BYTE_CYCLES cycles.
2332 if self.disk_io_enabled {
2333 match self.transfer {
2334 TransferState::Reading => {
2335 if self.transfer_timer > 0 {
2336 self.transfer_timer -= 1;
2337 }
2338 if self.transfer_timer == 0 {
2339 self.deliver_byte();
2340 self.transfer_timer = DISK_BYTE_CYCLES;
2341 }
2342 }
2343 TransferState::Writing => {
2344 if self.transfer_timer > 0 {
2345 self.transfer_timer -= 1;
2346 }
2347 if self.transfer_timer == 0 {
2348 self.store_byte();
2349 self.transfer_timer = DISK_BYTE_CYCLES;
2350 }
2351 }
2352 TransferState::Idle => {}
2353 }
2354 }
2355 }
2356
2357 fn irq_pending(&self) -> bool {
2358 self.irq_pending
2359 }
2360
2361 fn irq_acknowledge(&mut self) {
2362 // The CPU acks the IRQ line; the underlying status flags are cleared by
2363 // the BIOS reading $4030 / writing $4022/$4023/$4024. Dropping the line
2364 // here keeps it from re-asserting spuriously between those services.
2365 self.irq_pending = false;
2366 }
2367
2368 #[cfg(feature = "mapper-audio")]
2369 fn mix_audio(&mut self) -> i32 {
2370 i32::from(self.audio.output())
2371 }
2372
2373 fn current_mirroring(&self) -> Mirroring {
2374 self.mirroring
2375 }
2376
2377 // --- Stage 2b disk interface (trait overrides) ---
2378
2379 fn disk_side_count(&self) -> usize {
2380 self.disk.side_count()
2381 }
2382
2383 fn inserted_disk_side(&self) -> Option<usize> {
2384 self.inserted_side
2385 }
2386
2387 fn set_disk_side(&mut self, side: Option<usize>) {
2388 self.do_set_disk_side(side);
2389 }
2390
2391 fn enable_fds_trace(&mut self) {
2392 self.enable_trace();
2393 }
2394
2395 fn take_fds_trace(&mut self) -> Vec<FdsTraceRec> {
2396 self.take_trace()
2397 }
2398
2399 fn disk_image_bytes(&self) -> Vec<u8> {
2400 self.disk.to_bytes()
2401 }
2402
2403 fn disk_is_dirty(&self) -> bool {
2404 self.disk_dirty
2405 }
2406
2407 fn clear_disk_dirty(&mut self) {
2408 self.disk_dirty = false;
2409 }
2410
2411 fn set_disk_write_protected(&mut self, protected: bool) {
2412 self.write_protected = protected;
2413 }
2414
2415 fn save_state(&self) -> Vec<u8> {
2416 let mut out = Vec::with_capacity(32 + self.prg_ram.len() + self.chr_ram.len());
2417 out.push(FDS_SAVE_VERSION);
2418 // Memory.
2419 out.extend_from_slice(&self.prg_ram);
2420 out.extend_from_slice(&self.chr_ram);
2421 // Disk position. The v1-shaped `inserted_side` u32 holds the side index
2422 // (0 when ejected) for back-compat; the authoritative Option is in the
2423 // v3 disk tail below.
2424 let legacy_side = self.inserted_side.unwrap_or(0) as u32;
2425 out.extend_from_slice(&legacy_side.to_le_bytes());
2426 out.extend_from_slice(&(self.head as u32).to_le_bytes());
2427 // Registers / timer.
2428 out.extend_from_slice(&self.timer_reload.to_le_bytes());
2429 out.extend_from_slice(&self.timer_counter.to_le_bytes());
2430 out.extend_from_slice(&self.transfer_timer.to_le_bytes());
2431 out.push(self.write_data);
2432 out.push(self.control);
2433 out.push(self.ext_output);
2434 out.push(self.read_data);
2435 // Packed booleans.
2436 let mut flags = 0u16;
2437 flags |= u16::from(self.timer_irq_enabled);
2438 flags |= u16::from(self.timer_irq_repeat) << 1;
2439 flags |= u16::from(self.disk_io_enabled) << 2;
2440 flags |= u16::from(self.sound_io_enabled) << 3;
2441 flags |= u16::from(self.motor_on) << 4;
2442 flags |= u16::from(self.transfer_reset) << 5;
2443 flags |= u16::from(self.read_mode) << 6;
2444 flags |= u16::from(self.irq_on_transfer) << 7;
2445 flags |= u16::from(self.timer_irq_flag) << 8;
2446 flags |= u16::from(self.byte_transfer_flag) << 9;
2447 flags |= u16::from(self.crc_error) << 10;
2448 flags |= u16::from(self.end_of_head) << 11;
2449 flags |= u16::from(self.transfer == TransferState::Reading) << 12;
2450 flags |= u16::from(self.irq_pending) << 13;
2451 // Bit 14 distinguishes a write transfer (bit 12 covers read). v1/v2
2452 // never set bit 14, so they restore as a non-write (Idle/Reading).
2453 flags |= u16::from(self.transfer == TransferState::Writing) << 14;
2454 // Bit 15: read-path gap-skip state. v1/v2 leave it clear, which restores
2455 // as "not skipping" — a mid-block read position, the safe default for a
2456 // legacy blob (a transfer-reset re-arms it anyway).
2457 flags |= u16::from(self.read_skipping_gap) << 15;
2458 out.extend_from_slice(&flags.to_le_bytes());
2459 // v2 audio tail (strictly additive after the v1 section).
2460 self.audio.write_tail(&mut out);
2461 // v3 disk tail: mutable disk contents + insert/write-path state.
2462 out.extend_from_slice(&(self.disk.side_count() as u32).to_le_bytes());
2463 for s in 0..self.disk.side_count() {
2464 out.extend_from_slice(self.disk.side(s));
2465 }
2466 // inserted_side Option: 0xFFFF_FFFF sentinel for ejected.
2467 let inserted = self.inserted_side.map_or(u32::MAX, |i| i as u32);
2468 out.extend_from_slice(&inserted.to_le_bytes());
2469 out.extend_from_slice(&self.insert_not_ready.to_le_bytes());
2470 let mut disk_flags = 0u8;
2471 disk_flags |= u8::from(self.disk_dirty);
2472 disk_flags |= u8::from(self.write_protected) << 1;
2473 disk_flags |= u8::from(self.spun_up) << 2;
2474 out.push(disk_flags);
2475 // v4 tail (Capstone): continuous head-seek model state. Strictly
2476 // additive after the v3 disk tail — v1/v2/v3 blobs restore with the
2477 // model disabled and `pre_rewind_head` = 0 (the byte-identical default).
2478 out.push(u8::from(self.analog_head_seek));
2479 out.extend_from_slice(&(self.pre_rewind_head as u32).to_le_bytes());
2480 out
2481 }
2482
2483 #[allow(clippy::too_many_lines)]
2484 fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
2485 // Device state, then the FdsAudio tail, the disk tail and the
2486 // head-seek tail. Only v4 is read since v2.9.8 (ADR 0042); v1-v3 used
2487 // to load with the later tails at their defaults.
2488 let base = 1 + self.prg_ram.len() + self.chr_ram.len() + 4 + 4 + 2 + 2 + 4 + 4 + 2;
2489 let version = data.first().copied().unwrap_or(0);
2490 if version != FDS_SAVE_VERSION {
2491 return Err(MapperError::UnsupportedVersion(version));
2492 }
2493 // The disk tail is variable-length (it embeds its own side count as the
2494 // first u32), so validate the fixed prefix + audio tail + that count
2495 // here and the full length once the count is known.
2496 let min = base + FdsAudio::TAIL_LEN + 4;
2497 if data.len() < min {
2498 return Err(MapperError::WrongLength {
2499 expected: min,
2500 got: data.len(),
2501 });
2502 }
2503 let mut off = 1;
2504 let pl = self.prg_ram.len();
2505 self.prg_ram.copy_from_slice(&data[off..off + pl]);
2506 off += pl;
2507 let cl = self.chr_ram.len();
2508 self.chr_ram.copy_from_slice(&data[off..off + cl]);
2509 off += cl;
2510
2511 let legacy_inserted = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
2512 off += 4;
2513 let head = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
2514 off += 4;
2515 // Clamp restored positions to valid ranges (a corrupt/foreign blob must
2516 // not be able to drive an out-of-range index into `side()`). The disk
2517 // tail below overwrites it with the authoritative inserted side.
2518 self.inserted_side = Some(legacy_inserted.min(self.disk.side_count().saturating_sub(1)));
2519 // `head` is a wire-image offset; clamp it after the wire image is rebuilt
2520 // at the end of restore (the disk tail may change the inserted side).
2521 self.head = head;
2522
2523 self.timer_reload = u16::from_le_bytes(data[off..off + 2].try_into().unwrap());
2524 off += 2;
2525 self.timer_counter = u16::from_le_bytes(data[off..off + 2].try_into().unwrap());
2526 off += 2;
2527 self.transfer_timer = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
2528 off += 4;
2529 self.write_data = data[off];
2530 off += 1;
2531 self.control = data[off];
2532 off += 1;
2533 self.ext_output = data[off];
2534 off += 1;
2535 self.read_data = data[off];
2536 off += 1;
2537 let flags = u16::from_le_bytes(data[off..off + 2].try_into().unwrap());
2538 self.timer_irq_enabled = (flags & (1 << 0)) != 0;
2539 self.timer_irq_repeat = (flags & (1 << 1)) != 0;
2540 self.disk_io_enabled = (flags & (1 << 2)) != 0;
2541 self.sound_io_enabled = (flags & (1 << 3)) != 0;
2542 self.motor_on = (flags & (1 << 4)) != 0;
2543 self.transfer_reset = (flags & (1 << 5)) != 0;
2544 self.read_mode = (flags & (1 << 6)) != 0;
2545 self.irq_on_transfer = (flags & (1 << 7)) != 0;
2546 self.timer_irq_flag = (flags & (1 << 8)) != 0;
2547 self.byte_transfer_flag = (flags & (1 << 9)) != 0;
2548 self.crc_error = (flags & (1 << 10)) != 0;
2549 self.end_of_head = (flags & (1 << 11)) != 0;
2550 self.transfer = if (flags & (1 << 14)) != 0 {
2551 TransferState::Writing
2552 } else if (flags & (1 << 12)) != 0 {
2553 TransferState::Reading
2554 } else {
2555 TransferState::Idle
2556 };
2557 self.irq_pending = (flags & (1 << 13)) != 0;
2558 self.read_skipping_gap = (flags & (1 << 15)) != 0;
2559 off += 2;
2560 // Re-derive mirroring from the saved control byte for consistency.
2561 // Must match `write_control`'s corrected $4025.D3 mapping: bit 3 = 1 ->
2562 // Horizontal mirroring ($2000 != $2800), bit 3 = 0 -> Vertical.
2563 self.mirroring = if (self.control & 0x08) != 0 {
2564 Mirroring::Horizontal
2565 } else {
2566 Mirroring::Vertical
2567 };
2568 // v2 audio tail.
2569 self.audio.read_tail(&data[off..off + FdsAudio::TAIL_LEN])?;
2570 off += FdsAudio::TAIL_LEN;
2571 // v3 disk tail (mutable disk contents + insert / write-path state) and
2572 // the v4 head-seek tail.
2573 self.load_disk_tail(data, off, base)?;
2574 // Rebuild the wire image from the (possibly modified) inserted side and
2575 // clamp the restored head into it. The wire image is derived state — it
2576 // is reconstructed from the saved raw side contents rather than stored.
2577 self.rebuild_wire();
2578 self.head = self.head.min(self.wire.len());
2579 Ok(())
2580 }
2581
2582 fn debug_info(&self) -> MapperDebugInfo {
2583 MapperDebugInfo {
2584 mapper_id: 20,
2585 name: String::from("FDS (RAM adapter)"),
2586 mirroring: crate::mapper::mirroring_name(self.mirroring),
2587 prg_banks: vec![(String::from("PRG-RAM"), String::from("$6000-$DFFF (32K)"))],
2588 chr_banks: vec![(String::from("CHR-RAM"), String::from("8K"))],
2589 irq_state: vec![
2590 (
2591 String::from("reload"),
2592 format!("{:#06X}", self.timer_reload),
2593 ),
2594 (
2595 String::from("counter"),
2596 format!("{:#06X}", self.timer_counter),
2597 ),
2598 (
2599 String::from("enabled"),
2600 format!("{}", self.timer_irq_enabled),
2601 ),
2602 (String::from("repeat"), format!("{}", self.timer_irq_repeat)),
2603 (String::from("pending"), format!("{}", self.irq_pending)),
2604 ],
2605 extra: vec![
2606 (
2607 String::from("side"),
2608 self.inserted_side
2609 .map_or_else(|| String::from("ejected"), |i| format!("{i}")),
2610 ),
2611 (String::from("sides"), format!("{}", self.disk.side_count())),
2612 (String::from("head"), format!("{}", self.head)),
2613 (String::from("disk_io"), format!("{}", self.disk_io_enabled)),
2614 (String::from("motor"), format!("{}", self.motor_on)),
2615 (String::from("read_mode"), format!("{}", self.read_mode)),
2616 (String::from("dirty"), format!("{}", self.disk_dirty)),
2617 (
2618 String::from("write_protect"),
2619 format!("{}", self.write_protected),
2620 ),
2621 (
2622 String::from("reseek_slack"),
2623 format!("{}", self.quirk.extra_reseek_cycles),
2624 ),
2625 ],
2626 // Cartridge-level metadata is filled by the bus (v1.5.0 I8).
2627 ..Default::default()
2628 }
2629 }
2630}
2631
2632#[cfg(test)]
2633mod tests {
2634 use super::*;
2635
2636 /// Build a synthetic fwNES image with `sides` sides, side `s` filled with a
2637 /// recognizable pattern keyed on the side index.
2638 fn synth_fwnes(sides: u8) -> Vec<u8> {
2639 let mut out = vec![0u8; FWNES_HEADER_LEN + sides as usize * FDS_SIDE_LEN];
2640 out[0..4].copy_from_slice(b"FDS\x1A");
2641 out[4] = sides;
2642 for s in 0..sides as usize {
2643 let base = FWNES_HEADER_LEN + s * FDS_SIDE_LEN;
2644 out[base] = 0x01;
2645 out[base + 1..base + 15].copy_from_slice(b"*NINTENDO-HVC*");
2646 // Distinctive bytes at the start of the data region.
2647 out[base + 16] = s as u8;
2648 out[base + 17] = 0xAA;
2649 out[base + 18] = 0xBB;
2650 }
2651 out
2652 }
2653
2654 fn synth_raw_side() -> Vec<u8> {
2655 let mut out = vec![0u8; FDS_SIDE_LEN];
2656 out[0] = 0x01;
2657 out[1..15].copy_from_slice(b"*NINTENDO-HVC*");
2658 out[15] = 0x42;
2659 out
2660 }
2661
2662 fn dummy_bios() -> Vec<u8> {
2663 // Distinctive ramp so reads can be checked positionally.
2664 (0..BIOS_LEN).map(|i| (i & 0xFF) as u8).collect()
2665 }
2666
2667 fn make_device(sides: u8) -> Fds {
2668 let disk = parse_fds(&synth_fwnes(sides)).unwrap();
2669 Fds::new(disk, &dummy_bios()).unwrap()
2670 }
2671
2672 // --- Parser ---
2673
2674 #[test]
2675 fn parse_fwnes_single_side() {
2676 let disk = parse_fds(&synth_fwnes(1)).unwrap();
2677 assert_eq!(disk.side_count(), 1);
2678 assert_eq!(disk.declared_side_count(), 1);
2679 assert_eq!(disk.side(0)[0], 0x01);
2680 assert_eq!(&disk.side(0)[1..15], b"*NINTENDO-HVC*");
2681 }
2682
2683 #[test]
2684 fn parse_fwnes_multi_side() {
2685 let disk = parse_fds(&synth_fwnes(3)).unwrap();
2686 assert_eq!(disk.side_count(), 3);
2687 assert_eq!(disk.side(0)[16], 0);
2688 assert_eq!(disk.side(1)[16], 1);
2689 assert_eq!(disk.side(2)[16], 2);
2690 }
2691
2692 #[test]
2693 fn parse_headerless_raw_side() {
2694 let disk = parse_fds(&synth_raw_side()).unwrap();
2695 assert_eq!(disk.side_count(), 1);
2696 assert_eq!(disk.declared_side_count(), 0);
2697 assert_eq!(disk.side(0)[15], 0x42);
2698 }
2699
2700 #[test]
2701 fn parse_rejects_garbage() {
2702 let bytes = vec![0xFFu8; 64];
2703 assert!(matches!(parse_fds(&bytes), Err(RomError::BadMagic)));
2704 }
2705
2706 #[test]
2707 fn parse_rejects_truncated() {
2708 // fwNES header claiming 2 sides but only ~1.5 sides of body.
2709 let mut bytes = vec![0u8; FWNES_HEADER_LEN + FDS_SIDE_LEN + FDS_SIDE_LEN / 2];
2710 bytes[0..4].copy_from_slice(b"FDS\x1A");
2711 bytes[4] = 2;
2712 assert!(matches!(parse_fds(&bytes), Err(RomError::Truncated { .. })));
2713 }
2714
2715 #[test]
2716 fn parse_qd_side_truncated_to_read_window() {
2717 // QD form: 65536-byte sides, no header. Must degrade to a 65500 read
2718 // window. Build a raw QD-length side (still opens with the disk-info
2719 // block so the headerless path accepts it).
2720 let mut bytes = vec![0u8; QD_SIDE_LEN];
2721 bytes[0] = 0x01;
2722 bytes[1..15].copy_from_slice(b"*NINTENDO-HVC*");
2723 let disk = parse_fds(&bytes).unwrap();
2724 assert_eq!(disk.side_count(), 1);
2725 assert_eq!(disk.side(0).len(), FDS_SIDE_LEN);
2726 }
2727
2728 #[test]
2729 fn bios_must_be_8k() {
2730 let disk = parse_fds(&synth_fwnes(1)).unwrap();
2731 assert!(matches!(
2732 Fds::new(disk.clone(), &[0u8; 100]),
2733 Err(RomError::InvalidConfig(_))
2734 ));
2735 assert!(Fds::new(disk, &dummy_bios()).is_ok());
2736 }
2737
2738 // --- Memory map ---
2739
2740 #[test]
2741 fn prg_ram_read_write() {
2742 let mut fds = make_device(1);
2743 fds.cpu_write(0x6000, 0x11);
2744 fds.cpu_write(0xDFFF, 0x22);
2745 assert_eq!(fds.cpu_read(0x6000), 0x11);
2746 assert_eq!(fds.cpu_read(0xDFFF), 0x22);
2747 }
2748
2749 #[test]
2750 fn bios_is_read_only() {
2751 let mut fds = make_device(1);
2752 assert_eq!(fds.cpu_read(0xE000), 0x00);
2753 assert_eq!(fds.cpu_read(0xE001), 0x01);
2754 assert_eq!(fds.cpu_read(0xFFFF), (0x1FFF & 0xFF) as u8);
2755 // Writes to BIOS are ignored.
2756 fds.cpu_write(0xE000, 0xFF);
2757 assert_eq!(fds.cpu_read(0xE000), 0x00);
2758 }
2759
2760 #[test]
2761 fn chr_ram_read_write() {
2762 let mut fds = make_device(1);
2763 fds.ppu_write(0x0000, 0x55);
2764 fds.ppu_write(0x1FFF, 0x66);
2765 assert_eq!(fds.ppu_read(0x0000), 0x55);
2766 assert_eq!(fds.ppu_read(0x1FFF), 0x66);
2767 }
2768
2769 #[test]
2770 fn registers_routed_not_open_bus() {
2771 let fds = make_device(1);
2772 // $4020-$409F must report as MAPPED so the bus routes them to cpu_read.
2773 assert!(!fds.cpu_read_unmapped(0x4020));
2774 assert!(!fds.cpu_read_unmapped(0x4030));
2775 assert!(!fds.cpu_read_unmapped(0x409F));
2776 // $40A0-$5FFF is unmapped (open bus).
2777 assert!(fds.cpu_read_unmapped(0x40A0));
2778 assert!(fds.cpu_read_unmapped(0x5000));
2779 // $6000+ is always mapped.
2780 assert!(!fds.cpu_read_unmapped(0x6000));
2781 assert!(!fds.cpu_read_unmapped(0xE000));
2782 }
2783
2784 // --- Timer IRQ ---
2785
2786 fn enable_disk_io(fds: &mut Fds) {
2787 fds.cpu_write(0x4023, 0x01);
2788 // Close the power-on spin-up not-ready window so the transfer-level
2789 // tests (which assume an instantly-ready drive) need not each tick it
2790 // out. The window itself is covered by the insert/ready tests.
2791 fds.insert_not_ready = 0;
2792 }
2793
2794 #[test]
2795 fn timer_fires_at_zero() {
2796 let mut fds = make_device(1);
2797 enable_disk_io(&mut fds);
2798 // Reload = 10, enable, no repeat.
2799 fds.cpu_write(0x4020, 10);
2800 fds.cpu_write(0x4021, 0);
2801 fds.cpu_write(0x4022, 0x02); // enable, repeat off
2802 assert!(!fds.irq_pending());
2803 // Counter starts at 10; needs 10 cycles to reach 0.
2804 for _ in 0..9 {
2805 fds.notify_cpu_cycle();
2806 assert!(!fds.irq_pending(), "should not fire before reaching 0");
2807 }
2808 fds.notify_cpu_cycle();
2809 assert!(fds.irq_pending(), "timer IRQ must fire when counter hits 0");
2810 }
2811
2812 #[test]
2813 fn timer_disabled_when_disk_io_off() {
2814 let mut fds = make_device(1);
2815 // Disk I/O NOT enabled: writing $4022 has no effect.
2816 fds.cpu_write(0x4020, 2);
2817 fds.cpu_write(0x4022, 0x02);
2818 for _ in 0..10 {
2819 fds.notify_cpu_cycle();
2820 }
2821 assert!(
2822 !fds.irq_pending(),
2823 "timer must not run with disk I/O disabled"
2824 );
2825 }
2826
2827 #[test]
2828 fn timer_repeat_reloads() {
2829 let mut fds = make_device(1);
2830 enable_disk_io(&mut fds);
2831 fds.cpu_write(0x4020, 3);
2832 fds.cpu_write(0x4021, 0);
2833 fds.cpu_write(0x4022, 0x03); // enable + repeat
2834 for _ in 0..3 {
2835 fds.notify_cpu_cycle();
2836 }
2837 assert!(fds.irq_pending());
2838 // Ack via $4030 read.
2839 let _ = fds.cpu_read(0x4030);
2840 assert!(!fds.irq_pending());
2841 // Repeat reloaded the counter: fires again after 3 more cycles.
2842 for _ in 0..3 {
2843 fds.notify_cpu_cycle();
2844 }
2845 assert!(fds.irq_pending(), "repeat flag must reload and re-fire");
2846 }
2847
2848 #[test]
2849 fn timer_no_repeat_stops() {
2850 let mut fds = make_device(1);
2851 enable_disk_io(&mut fds);
2852 fds.cpu_write(0x4020, 2);
2853 fds.cpu_write(0x4022, 0x02); // enable, no repeat
2854 for _ in 0..2 {
2855 fds.notify_cpu_cycle();
2856 }
2857 assert!(fds.irq_pending());
2858 let _ = fds.cpu_read(0x4030); // ack
2859 // No repeat: counter is disabled, no further IRQs.
2860 for _ in 0..20 {
2861 fds.notify_cpu_cycle();
2862 }
2863 assert!(!fds.irq_pending());
2864 }
2865
2866 #[test]
2867 fn reload_zero_fires_immediately() {
2868 let mut fds = make_device(1);
2869 enable_disk_io(&mut fds);
2870 fds.cpu_write(0x4020, 0);
2871 fds.cpu_write(0x4021, 0);
2872 fds.cpu_write(0x4022, 0x02); // enable, reload 0
2873 fds.notify_cpu_cycle();
2874 assert!(fds.irq_pending(), "reload 0 fires on the next cycle");
2875 }
2876
2877 #[test]
2878 fn read_4030_acks_timer_irq() {
2879 let mut fds = make_device(1);
2880 enable_disk_io(&mut fds);
2881 fds.cpu_write(0x4020, 1);
2882 fds.cpu_write(0x4022, 0x02);
2883 fds.notify_cpu_cycle();
2884 assert!(fds.irq_pending());
2885 let status = fds.cpu_read(0x4030);
2886 assert_eq!(status & 0x01, 0x01, "timer IRQ bit set in status");
2887 assert!(!fds.irq_pending(), "$4030 read acks timer IRQ");
2888 // Reading again shows the flag already cleared.
2889 assert_eq!(fds.cpu_read(0x4030) & 0x01, 0x00);
2890 }
2891
2892 #[test]
2893 fn write_4022_disable_acks_timer_irq() {
2894 let mut fds = make_device(1);
2895 enable_disk_io(&mut fds);
2896 fds.cpu_write(0x4020, 1);
2897 fds.cpu_write(0x4022, 0x02);
2898 fds.notify_cpu_cycle();
2899 assert!(fds.irq_pending());
2900 fds.cpu_write(0x4022, 0x00); // disable
2901 assert!(!fds.irq_pending(), "$4022 disable acks timer IRQ");
2902 }
2903
2904 #[test]
2905 fn clear_4023_stops_and_acks() {
2906 let mut fds = make_device(1);
2907 enable_disk_io(&mut fds);
2908 fds.cpu_write(0x4020, 1);
2909 fds.cpu_write(0x4022, 0x02);
2910 fds.notify_cpu_cycle();
2911 assert!(fds.irq_pending());
2912 fds.cpu_write(0x4023, 0x00); // clear disk I/O
2913 assert!(!fds.irq_pending(), "$4023 clear acks timer IRQ");
2914 // Timer is stopped: further cycles do nothing.
2915 fds.notify_cpu_cycle();
2916 assert!(!fds.irq_pending());
2917 }
2918
2919 #[test]
2920 fn reload_value_writable_with_disk_io_off() {
2921 let mut fds = make_device(1);
2922 // $4020/$4021 are NOT gated on $4023.0 per nesdev.
2923 fds.cpu_write(0x4020, 0x34);
2924 fds.cpu_write(0x4021, 0x12);
2925 // Now enable disk I/O and the timer; the reload should be $1234.
2926 enable_disk_io(&mut fds);
2927 fds.cpu_write(0x4022, 0x02);
2928 // Counter was loaded with $1234; verify via debug_info.
2929 let info = fds.debug_info();
2930 let counter = info
2931 .irq_state
2932 .iter()
2933 .find(|(k, _)| k == "counter")
2934 .map(|(_, v)| v.clone())
2935 .unwrap();
2936 assert_eq!(counter, "0x1234");
2937 }
2938
2939 // --- Registers ---
2940
2941 #[test]
2942 fn control_sets_mirroring() {
2943 // $4025 bit 3 = nametable arrangement. Corrected to match the nesdev
2944 // FDS register table + real-BIOS boot behaviour: bit 3 = 0 is the
2945 // "Horizontal arrangement" the wiki names, which is VERTICAL mirroring in
2946 // this codebase's enum ($2000 aliases $2800), and bit 3 = 1 is the
2947 // "Vertical arrangement" == HORIZONTAL mirroring ($2000 and $2800 are
2948 // distinct banks). The old assertions had these swapped, which made the
2949 // BIOS licence-screen clear of $2000 wipe the $2800 approval tilemap and
2950 // fail every real-BIOS boot ("DISK TROUBLE ERR.20").
2951 let mut fds = make_device(1);
2952 // bit 3 = 0 -> Vertical mirroring ("Horizontal arrangement").
2953 fds.cpu_write(0x4025, 0b0010_0110); // motor stop, no transfer reset, read
2954 assert_eq!(fds.current_mirroring(), Mirroring::Vertical);
2955 // bit 3 = 1 -> Horizontal mirroring ("Vertical arrangement").
2956 fds.cpu_write(0x4025, 0b0010_1110);
2957 assert_eq!(fds.current_mirroring(), Mirroring::Horizontal);
2958 }
2959
2960 #[test]
2961 fn drive_status_4032() {
2962 let mut fds = make_device(1);
2963 // Disk inserted, motor off -> not-ready set; default disk is writable.
2964 let v = fds.cpu_read(0x4032);
2965 assert_eq!(v & 0x01, 0x00, "disk inserted -> bit 0 clear");
2966 assert_eq!(v & 0x04, 0x00, "stage-2b disks are writable by default");
2967 // Mark read-only -> write-protect bit set.
2968 fds.set_disk_write_protected(true);
2969 assert_eq!(fds.cpu_read(0x4032) & 0x04, 0x04, "write-protect reflected");
2970 fds.set_disk_write_protected(false);
2971 // Start the motor + read mode + transfer: ready clears.
2972 enable_disk_io(&mut fds);
2973 fds.cpu_write(0x4025, 0b1110_0100); // motor on, no reset, read, IRQ-on-xfer
2974 settle_drive(&mut fds); // run out the motor spin-up window
2975 let v = fds.cpu_read(0x4032);
2976 assert_eq!(v & 0x02, 0x00, "motor on + spun-up -> ready");
2977 }
2978
2979 #[test]
2980 fn ext_4033_battery_bit() {
2981 let mut fds = make_device(1);
2982 assert_eq!(fds.cpu_read(0x4033) & 0x80, 0x80, "battery good with disk");
2983 }
2984
2985 // --- Disk read ---
2986
2987 #[test]
2988 fn disk_read_consumes_bytes_in_order() {
2989 let mut fds = make_device(1);
2990 enable_disk_io(&mut fds);
2991 // reset off (bit0=0), motor on (bit1=0), read (bit2=1).
2992 fds.cpu_write(0x4025, 0b0110_0100);
2993 settle_drive(&mut fds);
2994 // The controller bit-shifts past the synthesized lead-in gap + $80 start
2995 // mark in hardware, so the first delivered byte is the block's first
2996 // byte (the disk-info block code $01), then the raw block bytes stream in
2997 // order — exactly what the BIOS load routine expects.
2998 let side: Vec<u8> = fds.disk.side(0)[..4].to_vec();
2999 for (i, expected) in side.iter().enumerate() {
3000 for _ in 0..DISK_BYTE_CYCLES {
3001 fds.notify_cpu_cycle();
3002 }
3003 assert_eq!(fds.cpu_read(0x4030) & 0x80, 0x80, "byte-transfer flag set");
3004 assert_eq!(
3005 fds.cpu_read(0x4031),
3006 *expected,
3007 "wire block byte {i} in order"
3008 );
3009 }
3010 }
3011
3012 #[test]
3013 fn disk_read_irq_on_transfer() {
3014 let mut fds = make_device(1);
3015 enable_disk_io(&mut fds);
3016 // motor on, read, IRQ-on-transfer (bit7).
3017 fds.cpu_write(0x4025, 0b1110_0100);
3018 settle_drive(&mut fds);
3019 for _ in 0..DISK_BYTE_CYCLES {
3020 fds.notify_cpu_cycle();
3021 }
3022 assert!(fds.irq_pending(), "byte-transfer raises IRQ when bit7 set");
3023 // Reading $4031 acks the disk IRQ.
3024 let _ = fds.cpu_read(0x4031);
3025 assert!(!fds.irq_pending());
3026 }
3027
3028 #[test]
3029 fn transfer_reset_rearms_gap_skip() {
3030 let mut fds = make_device(1);
3031 enable_disk_io(&mut fds);
3032 fds.cpu_write(0x4025, 0b0110_0100); // start reading
3033 settle_drive(&mut fds);
3034 for _ in 0..(DISK_BYTE_CYCLES * 3) {
3035 fds.notify_cpu_cycle();
3036 }
3037 assert!(fds.head > 0);
3038 // Asserting transfer reset (bit 0) re-arms the read gap-skip and resets
3039 // the byte-transfer timing, but does NOT rewind the head — the head
3040 // tracks the physical disk rotation (it only returns to the start at the
3041 // inner track / end of head). The BIOS toggles reset between blocks to
3042 // re-sync to each block's start mark while the head keeps advancing.
3043 let head_before = fds.head;
3044 fds.cpu_write(0x4025, 0b0110_0101); // CRC + read + reset asserted
3045 assert_eq!(
3046 fds.head, head_before,
3047 "transfer reset does not rewind the head"
3048 );
3049 assert!(fds.read_skipping_gap, "transfer reset re-arms the gap-skip");
3050 }
3051
3052 // --- Save state ---
3053
3054 #[test]
3055 fn save_state_round_trip() {
3056 let mut fds = make_device(2);
3057 enable_disk_io(&mut fds);
3058 fds.cpu_write(0x6000, 0xAB);
3059 fds.cpu_write(0x7FFF, 0xCD);
3060 fds.ppu_write(0x0100, 0xEF);
3061 fds.cpu_write(0x4020, 0x99);
3062 fds.cpu_write(0x4021, 0x01);
3063 fds.cpu_write(0x4022, 0x03);
3064 fds.cpu_write(0x4025, 0b1110_0100); // motor + read + irq-on-xfer
3065 settle_drive(&mut fds);
3066 for _ in 0..(DISK_BYTE_CYCLES + 5) {
3067 fds.notify_cpu_cycle();
3068 }
3069 let blob = fds.save_state();
3070
3071 // Restore into a fresh device with the same disk + BIOS.
3072 let mut fresh = make_device(2);
3073 fresh.load_state(&blob).unwrap();
3074 assert_eq!(fresh.cpu_read(0x6000), 0xAB);
3075 assert_eq!(fresh.cpu_read(0x7FFF), 0xCD);
3076 assert_eq!(fresh.ppu_read(0x0100), 0xEF);
3077 assert_eq!(fresh.timer_reload, fds.timer_reload);
3078 assert_eq!(fresh.timer_counter, fds.timer_counter);
3079 assert_eq!(fresh.head, fds.head);
3080 assert_eq!(fresh.control, fds.control);
3081 assert_eq!(fresh.current_mirroring(), fds.current_mirroring());
3082 // Re-serialize: byte-identical.
3083 assert_eq!(fresh.save_state(), blob);
3084 }
3085
3086 #[test]
3087 fn load_state_rejects_truncated() {
3088 let mut fds = make_device(1);
3089 assert!(matches!(
3090 fds.load_state(&[FDS_SAVE_VERSION, 0, 0]),
3091 Err(MapperError::WrongLength { .. })
3092 ));
3093 }
3094
3095 /// Core audit v2.9.2 AUD-01. The v3/v4 disk tail declares its own side
3096 /// count as a `u32`, and the loader sizes the tail as `sides * FDS_SIDE_LEN`.
3097 ///
3098 /// On a 64-bit host that product cannot overflow (`u32::MAX * 65500` is
3099 /// about 2.8e14), so a hostile count only makes the expected length absurd
3100 /// and the exact-length check rejects it. On a 32-bit target (`wasm32`,
3101 /// `armv7`, `i686` -- all shipped: the web build, Android, the libretro
3102 /// buildbot) `usize` is 32 bits and the product wraps. `65500 = 4 * 16375`,
3103 /// so a count of `2^30` wraps the side region to exactly zero bytes: a blob
3104 /// carrying NO side data then passes the length check, the restore loop
3105 /// copies `min(2^30, local_sides)` sides out of it, and the first copy
3106 /// slices past the end of the blob -- a panic in every build profile (in
3107 /// a `dev` build the multiplication's overflow check panics first). A save
3108 /// state is untrusted input: a file on disk, or a netplay peer's state.
3109 ///
3110 /// Red on `i686` before the fix; green on 64-bit either way, which is the
3111 /// point of recording it -- the host test suite could not see this.
3112 #[test]
3113 fn load_state_rejects_a_side_count_whose_length_wraps_on_32_bit() {
3114 let mut fds = make_device(2);
3115 let blob = fds.save_state();
3116 assert_eq!(blob[0], 4, "fixture assumes the v4 layout");
3117 let base = 1 + fds.prg_ram.len() + fds.chr_ram.len() + 4 + 4 + 2 + 2 + 4 + 4 + 2;
3118 let sides_at = base + FdsAudio::TAIL_LEN;
3119 // inserted(4) + insert_not_ready(4) + disk_flags(1) + the v4 tail.
3120 let trailer = 4 + 4 + 1 + FDS_V4_TAIL_LEN;
3121 for hostile in [1u32 << 30, (1u32 << 30) + 1, 3u32 << 30, u32::MAX] {
3122 let mut crafted = blob[..sides_at].to_vec();
3123 crafted.extend_from_slice(&hostile.to_le_bytes());
3124 crafted.extend_from_slice(&blob[blob.len() - trailer..]);
3125 assert!(
3126 fds.load_state(&crafted).is_err(),
3127 "side count {hostile:#x} with no side data must be rejected"
3128 );
3129 }
3130 // The legitimate blob still loads after all that.
3131 fds.load_state(&blob).expect("the genuine blob still loads");
3132 }
3133
3134 #[test]
3135 fn load_state_rejects_bad_version() {
3136 let mut fds = make_device(1);
3137 let mut blob = fds.save_state();
3138 blob[0] = 0xFF;
3139 assert!(matches!(
3140 fds.load_state(&blob),
3141 Err(MapperError::UnsupportedVersion(0xFF))
3142 ));
3143 }
3144
3145 // --- FDS audio ($4040-$4097) ---
3146
3147 /// Enable disk + sound I/O ($4023 = 0b11) so the sound registers function.
3148 fn enable_sound_io(fds: &mut Fds) {
3149 fds.cpu_write(0x4023, 0x03);
3150 }
3151
3152 #[test]
3153 fn sound_io_gates_register_writes() {
3154 let mut fds = make_device(1);
3155 // Sound I/O disabled: writes to $4080 etc. are ignored.
3156 fds.cpu_write(0x4080, 0x9F); // direct gain 0x1F, would-be
3157 assert_eq!(fds.audio.vol_gain, 0, "no effect with sound I/O off");
3158 enable_sound_io(&mut fds);
3159 fds.cpu_write(0x4080, 0x9F); // M=1 (direct), gain = 0x1F
3160 assert_eq!(
3161 fds.audio.vol_gain, 0x1F,
3162 "direct gain set with sound I/O on"
3163 );
3164 }
3165
3166 #[test]
3167 fn wavetable_write_gating() {
3168 let mut fds = make_device(1);
3169 enable_sound_io(&mut fds);
3170 // $4089 bit 7 set -> wave RAM is writable + channel held.
3171 fds.cpu_write(0x4089, 0x80);
3172 fds.cpu_write(0x4040, 0x3F);
3173 fds.cpu_write(0x4041, 0x2A);
3174 assert_eq!(fds.audio.wavetable[0], 0x3F);
3175 assert_eq!(fds.audio.wavetable[1], 0x2A);
3176 // While write-enabled, reads return the RAM byte.
3177 assert_eq!(fds.cpu_read(0x4040), 0x3F);
3178 // Disable write-enable; further $4040 writes are ignored.
3179 fds.cpu_write(0x4089, 0x00);
3180 fds.cpu_write(0x4040, 0x11);
3181 assert_eq!(fds.audio.wavetable[0], 0x3F, "RAM write-protected");
3182 // Wave-RAM read while protected returns the current output latch.
3183 fds.audio.wave_out_latch = 0x07;
3184 assert_eq!(fds.cpu_read(0x4040), 0x07);
3185 }
3186
3187 #[test]
3188 #[cfg(feature = "mapper-audio")]
3189 fn wave_accumulator_steps_table_index() {
3190 let mut fds = make_device(1);
3191 enable_sound_io(&mut fds);
3192 // Fill wavetable with index-keyed values while write-enabled.
3193 fds.cpu_write(0x4089, 0x80);
3194 for i in 0..64u16 {
3195 fds.cpu_write(0x4040 + i, (i as u8) & 0x3F);
3196 }
3197 fds.cpu_write(0x4089, 0x00); // disable write -> channel runs.
3198 // Mod gain 0 (direct) -> unmodulated pitch add = P * 64 per wave tick.
3199 fds.cpu_write(0x4084, 0x80); // mod env disabled, gain 0
3200 fds.cpu_write(0x4085, 0x00); // mod counter 0
3201 // Wave pitch P = 0x400 (1024) -> add 1024*64 = 65536 per 16-cycle tick.
3202 fds.cpu_write(0x4082, 0x00);
3203 fds.cpu_write(0x4083, 0x04); // hi nibble 4 -> P = 0x400, not halted
3204 // After 4 wave ticks (64 CPU cycles), acc = 4*65536 = 2^18 -> index 1.
3205 for _ in 0..64 {
3206 fds.notify_cpu_cycle();
3207 }
3208 assert_eq!(fds.audio.wave_acc, 262_144);
3209 assert_eq!(fds.audio.wave_out_latch, 1, "index 1 -> wavetable[1] == 1");
3210 // $4096 reads the held wave value (bits 7-6 read as 01).
3211 assert_eq!(fds.cpu_read(0x4096), 0x40 | 1);
3212 }
3213
3214 #[test]
3215 #[cfg(feature = "mapper-audio")]
3216 fn wave_halt_resets_accumulator_and_holds_4040() {
3217 let mut fds = make_device(1);
3218 enable_sound_io(&mut fds);
3219 fds.cpu_write(0x4089, 0x80);
3220 fds.cpu_write(0x4040, 0x21); // wavetable[0]
3221 fds.cpu_write(0x4089, 0x00);
3222 // Run a bit, then halt via $4083 bit 7.
3223 fds.cpu_write(0x4082, 0x00);
3224 fds.cpu_write(0x4083, 0x04);
3225 for _ in 0..32 {
3226 fds.notify_cpu_cycle();
3227 }
3228 assert!(fds.audio.wave_acc > 0);
3229 fds.cpu_write(0x4083, 0x84); // halt (bit 7), keep freq hi = 4
3230 assert_eq!(fds.audio.wave_acc, 0, "halt resets the wave accumulator");
3231 // While halted, the wave unit holds the $4040 value.
3232 fds.notify_cpu_cycle();
3233 // Drive a full prescaler tick so clock_wave runs once.
3234 for _ in 0..16 {
3235 fds.notify_cpu_cycle();
3236 }
3237 assert_eq!(fds.audio.wave_out_latch, 0x21);
3238 }
3239
3240 #[test]
3241 fn volume_envelope_direct_mode() {
3242 let mut fds = make_device(1);
3243 enable_sound_io(&mut fds);
3244 // M=1 (disabled/direct), gain bits = 0x20 (32).
3245 fds.cpu_write(0x4080, 0x80 | 0x20);
3246 assert_eq!(fds.audio.vol_gain, 32);
3247 // $4090 reads back the gain with bits 7-6 = 01.
3248 assert_eq!(fds.cpu_read(0x4090), 0x40 | 32);
3249 }
3250
3251 #[test]
3252 #[cfg(feature = "mapper-audio")]
3253 fn volume_envelope_auto_increase() {
3254 let mut fds = make_device(1);
3255 enable_sound_io(&mut fds);
3256 // Master env speed small so the period is short and deterministic.
3257 // $408A = 0 disables envelopes, so use 0 multiplier+1 via $408A=0?
3258 // Period c = 8 * (e+1) * (m+1); pick e=0, m=0 -> c = 8.
3259 fds.cpu_write(0x408A, 0x00); // m = 0 -> wait: 0 disables. Use m via... set below.
3260 // m must be non-zero to enable; use $408A = 1 -> m=1, c = 8*1*2 = 16.
3261 fds.cpu_write(0x408A, 0x01);
3262 // Volume envelope ON (M=0), direction increase (D=1), speed e=0.
3263 fds.cpu_write(0x4080, 0x40);
3264 assert_eq!(fds.audio.vol_gain, 0, "starts at 0");
3265 // c = 8 * (0+1) * (1+1) = 16 CPU cycles per envelope tick.
3266 // The $4080 write reset the timer to 16; one tick after 16 cycles.
3267 for _ in 0..16 {
3268 fds.notify_cpu_cycle();
3269 }
3270 assert_eq!(fds.audio.vol_gain, 1, "auto-increase ticks gain up by 1");
3271 for _ in 0..16 {
3272 fds.notify_cpu_cycle();
3273 }
3274 assert_eq!(fds.audio.vol_gain, 2);
3275 }
3276
3277 #[test]
3278 #[cfg(feature = "mapper-audio")]
3279 fn volume_envelope_caps_at_32_on_increase() {
3280 let mut fds = make_device(1);
3281 enable_sound_io(&mut fds);
3282 fds.cpu_write(0x408A, 0x01);
3283 fds.cpu_write(0x4080, 0x40); // ON, increase, speed 0
3284 // Drive many ticks; gain must saturate at 32.
3285 for _ in 0..(16 * 64) {
3286 fds.notify_cpu_cycle();
3287 }
3288 assert_eq!(fds.audio.vol_gain, 32, "auto-increase clamps at 32");
3289 }
3290
3291 #[test]
3292 #[cfg(feature = "mapper-audio")]
3293 fn mod_table_push_and_counter_step() {
3294 let mut fds = make_device(1);
3295 enable_sound_io(&mut fds);
3296 // Mod unit halted ($4087 bit 7) so $4088 pushes table entries.
3297 fds.cpu_write(0x4087, 0x80);
3298 // Push 32 entries: entry value 1 (=> +1 step) everywhere.
3299 for _ in 0..32 {
3300 fds.cpu_write(0x4088, 0x01);
3301 }
3302 assert!(fds.audio.mod_table.iter().all(|&e| e == 1));
3303 // Writing 32 entries wraps the write position back to start.
3304 assert_eq!(fds.audio.mod_write_pos, 0);
3305 // Direct-test the 3-bit step decode against the nesdev table.
3306 fds.audio.mod_counter = 0;
3307 fds.audio.step_mod_counter(3); // +4
3308 assert_eq!(fds.audio.mod_counter, 4);
3309 fds.audio.step_mod_counter(5); // -4
3310 assert_eq!(fds.audio.mod_counter, 0);
3311 fds.audio.step_mod_counter(4); // reset
3312 assert_eq!(fds.audio.mod_counter, 0);
3313 fds.audio.step_mod_counter(7); // -1
3314 assert_eq!(fds.audio.mod_counter, -1);
3315 }
3316
3317 #[test]
3318 #[cfg(feature = "mapper-audio")]
3319 #[allow(clippy::field_reassign_with_default)]
3320 fn mod_pitch_bias_formula() {
3321 // Worked values from the nesdev FDS_audio "Modulation unit" pseudo-code.
3322 let mut a = FdsAudio::default();
3323 // counter = 0, gain = 0: temp=0 -> +0x400 -> >>4 = 0x40 -> *pitch.
3324 a.mod_counter = 0;
3325 a.mod_gain = 0;
3326 a.wave_pitch = 0x100;
3327 // temp = 0x40 (64); wave_pitch = 0x100 * 64 = 0x4000.
3328 assert_eq!(a.modulated_pitch(), 0x4000);
3329 // counter = 1, gain = 16: temp = 16 = 0x10. (0x10 & 0x0F)==0 -> no round.
3330 // temp += 0x400 = 0x410; >>4 = 0x41; & 0xFF = 0x41 (65).
3331 // wave_pitch = 0x100 * 0x41 = 0x4100.
3332 a.mod_counter = 1;
3333 a.mod_gain = 16;
3334 assert_eq!(a.modulated_pitch(), 0x100 * 0x41);
3335 // counter = 1, gain = 1: temp = 1. (1 & 0x0F)!=0 && !(1 & 0x800) -> +0x20
3336 // temp = 0x21; +0x400 = 0x421; >>4 = 0x42; *pitch.
3337 a.mod_counter = 1;
3338 a.mod_gain = 1;
3339 assert_eq!(a.modulated_pitch(), 0x100 * 0x42);
3340 // Negative counter: counter = -1, gain = 16 -> temp = -16 = 0xFFFFFFF0.
3341 // (temp & 0x0F)==0 so no rounding; temp += 0x400 -> 0x3F0; >>4 = 0x3F.
3342 a.mod_counter = -1;
3343 a.mod_gain = 16;
3344 assert_eq!(a.modulated_pitch(), 0x100 * 0x3F);
3345 }
3346
3347 #[test]
3348 #[cfg(feature = "mapper-audio")]
3349 fn master_volume_scaling() {
3350 let mut fds = make_device(1);
3351 enable_sound_io(&mut fds);
3352 // Direct full gain (32), full master (0 -> 2/2).
3353 fds.cpu_write(0x4080, 0x80 | 0x20);
3354 fds.cpu_write(0x4089, 0x00); // master volume = 0 (full)
3355 // Force a known wave sample at the output.
3356 fds.audio.wave_out_latch = 63;
3357 let full = fds.mix_audio();
3358 // master volume = 3 (2/5) should be quieter than full (2/2).
3359 fds.cpu_write(0x4089, 0x03);
3360 let quiet = fds.mix_audio();
3361 assert!(
3362 quiet.unsigned_abs() < full.unsigned_abs(),
3363 "master volume 2/5 ({quiet}) is quieter than full ({full})"
3364 );
3365 // Centered output is positive for a top-of-range sample (63 > midpoint).
3366 assert!(full > 0);
3367 // A mid sample (32) sits near the DC midpoint -> near-zero output.
3368 fds.cpu_write(0x4089, 0x00);
3369 fds.audio.wave_out_latch = 32;
3370 assert_eq!(fds.mix_audio(), 0, "sample 32 is the centered midpoint");
3371 }
3372
3373 #[test]
3374 fn mod_counter_read_4097() {
3375 let mut fds = make_device(1);
3376 enable_sound_io(&mut fds);
3377 fds.cpu_write(0x4085, 0x7F); // -1 (sign-extended 7-bit)
3378 assert_eq!(fds.audio.mod_counter, -1);
3379 // $4097 reads the 7-bit counter in bits 6-0; bit 7 reads 0.
3380 assert_eq!(fds.cpu_read(0x4097), 0x7F);
3381 fds.cpu_write(0x4085, 0x10); // +16
3382 assert_eq!(fds.audio.mod_counter, 16);
3383 assert_eq!(fds.cpu_read(0x4097), 0x10);
3384 }
3385
3386 #[test]
3387 fn audio_save_state_round_trip() {
3388 let mut fds = make_device(2);
3389 enable_sound_io(&mut fds);
3390 // Exercise the full audio surface.
3391 fds.cpu_write(0x4089, 0x80);
3392 for i in 0..64u16 {
3393 fds.cpu_write(0x4040 + i, ((i * 3) as u8) & 0x3F);
3394 }
3395 fds.cpu_write(0x4089, 0x01); // write-disable + master volume 1
3396 fds.cpu_write(0x4080, 0x80 | 0x18); // direct gain 0x18
3397 fds.cpu_write(0x4082, 0x34);
3398 fds.cpu_write(0x4083, 0x05);
3399 fds.cpu_write(0x4087, 0x80); // halt mod -> table writable
3400 for k in 0..32 {
3401 fds.cpu_write(0x4088, (k & 0x07) as u8);
3402 }
3403 fds.cpu_write(0x4086, 0x21);
3404 fds.cpu_write(0x4084, 0x80 | 0x07); // direct mod gain 7
3405 fds.cpu_write(0x4085, 0x05);
3406 fds.cpu_write(0x408A, 0x40);
3407 for _ in 0..100 {
3408 fds.notify_cpu_cycle();
3409 }
3410 let blob = fds.save_state();
3411 assert_eq!(
3412 blob[0], 4,
3413 "FDS save version is 4 (Capstone head-seek tail)"
3414 );
3415
3416 let mut fresh = make_device(2);
3417 fresh.load_state(&blob).unwrap();
3418 assert_eq!(fresh.audio.wavetable, fds.audio.wavetable);
3419 assert_eq!(fresh.audio.mod_table, fds.audio.mod_table);
3420 assert_eq!(fresh.audio.wave_acc, fds.audio.wave_acc);
3421 assert_eq!(fresh.audio.mod_acc, fds.audio.mod_acc);
3422 assert_eq!(fresh.audio.wave_pitch, fds.audio.wave_pitch);
3423 assert_eq!(fresh.audio.mod_pitch, fds.audio.mod_pitch);
3424 assert_eq!(fresh.audio.vol_gain, fds.audio.vol_gain);
3425 assert_eq!(fresh.audio.mod_gain, fds.audio.mod_gain);
3426 assert_eq!(fresh.audio.master_volume, fds.audio.master_volume);
3427 assert_eq!(fresh.audio.env_speed_mult, fds.audio.env_speed_mult);
3428 assert_eq!(fresh.audio.cycle_prescaler, fds.audio.cycle_prescaler);
3429 // Re-serialize: byte-identical.
3430 assert_eq!(fresh.save_state(), blob);
3431 }
3432
3433 /// v2.9.0 re-audit NC-07: the audio `cycle_prescaler` is bounded on
3434 /// restore. It counts CPU cycles 0..=15 between wave/modulation clocks,
3435 /// and `clock` does `+= 1` then compares `>= 16`; restored raw, `0xFF`
3436 /// overflowed that add on the next cycle (a panic under overflow checks,
3437 /// which the re-audit's `fdsprobe` hit at state offset 41,108) and any
3438 /// value 16..=254 delayed one clock by up to 240 cycles in release.
3439 /// Checked through the whole device's `load_state`, at the byte's real
3440 /// position: `TAIL_LEN - 3` in the audio tail (two flag bytes follow).
3441 #[test]
3442 fn a_restored_audio_prescaler_outside_0_to_15_is_rejected() {
3443 let mut fds = make_device(1);
3444 enable_sound_io(&mut fds);
3445 // A distinctive wavetable, so the audio tail can be found in the blob.
3446 fds.cpu_write(0x4089, 0x80);
3447 for i in 0..64u16 {
3448 fds.cpu_write(0x4040 + i, ((i * 5 + 7) as u8) & 0x3F);
3449 }
3450 fds.cpu_write(0x4089, 0x00);
3451 let blob = fds.save_state();
3452 let mut tail = Vec::new();
3453 fds.audio.write_tail(&mut tail);
3454 let hits: Vec<usize> = (0..=blob.len() - tail.len())
3455 .filter(|&o| blob[o..o + tail.len()] == tail[..])
3456 .collect();
3457 assert_eq!(hits.len(), 1, "the audio tail must occur once: {hits:?}");
3458 let at = hits[0] + FdsAudio::TAIL_LEN - 3;
3459 assert_eq!(
3460 blob[at], fds.audio.cycle_prescaler,
3461 "fixture: prescaler offset"
3462 );
3463
3464 for value in [16u8, 0x80, 0xFF] {
3465 let mut bad = blob.clone();
3466 bad[at] = value;
3467 let mut fresh = make_device(1);
3468 assert!(
3469 matches!(fresh.load_state(&bad), Err(MapperError::Invalid(_))),
3470 "prescaler {value:#04x} must be rejected"
3471 );
3472 }
3473 for value in 0..16u8 {
3474 let mut ok = blob.clone();
3475 ok[at] = value;
3476 let mut fresh = make_device(1);
3477 fresh.load_state(&ok).unwrap();
3478 for _ in 0..64 {
3479 fresh.notify_cpu_cycle();
3480 }
3481 }
3482 }
3483
3484 #[test]
3485 #[cfg(not(feature = "mapper-audio"))]
3486 fn mix_audio_silent_without_feature() {
3487 // With `mapper-audio` off, the FDS device must be silent regardless of
3488 // any sound-register programming (byte-identical floor unchanged).
3489 let mut fds = make_device(1);
3490 enable_sound_io(&mut fds);
3491 fds.cpu_write(0x4089, 0x80);
3492 for i in 0..64u16 {
3493 fds.cpu_write(0x4040 + i, (i as u8) & 0x3F);
3494 }
3495 fds.cpu_write(0x4089, 0x00);
3496 fds.cpu_write(0x4080, 0x80 | 0x20); // full direct gain
3497 fds.cpu_write(0x4082, 0xFF);
3498 fds.cpu_write(0x4083, 0x0F);
3499 for _ in 0..1000 {
3500 fds.notify_cpu_cycle();
3501 }
3502 // Goes through the trait default (returns 0) since `mix_audio` is gated.
3503 assert_eq!(Mapper::mix_audio(&mut fds), 0);
3504 }
3505
3506 /// v2.9.8 (ADR 0042): only the current (v4) layout loads. v1 (no audio
3507 /// tail), v2 (no disk tail) and v3 (no head-seek tail) used to load with
3508 /// those parts at their defaults.
3509 #[test]
3510 fn pre_v4_blobs_are_refused() {
3511 let mut fds = make_device(1);
3512 let blob = fds.save_state();
3513 let disk_tail = 4 + FDS_SIDE_LEN + 4 + 4 + 1;
3514 let mut v3 = blob[..blob.len() - FDS_V4_TAIL_LEN].to_vec();
3515 v3[0] = 3;
3516 let mut v2 = v3[..v3.len() - disk_tail].to_vec();
3517 v2[0] = 2;
3518 let mut v1 = v2[..v2.len() - FdsAudio::TAIL_LEN].to_vec();
3519 v1[0] = 1;
3520 for (v, old) in [(1u8, &v1), (2, &v2), (3, &v3)] {
3521 assert!(matches!(
3522 fds.load_state(old),
3523 Err(MapperError::UnsupportedVersion(got)) if got == v
3524 ));
3525 }
3526 fds.load_state(&blob).expect("the current blob loads");
3527 }
3528
3529 // --- Stage 2b: disk write path / eject-insert / persistence ---
3530
3531 /// Drive the device into write mode and store `bytes` over consecutive
3532 /// byte-transfer ticks (one byte per `$4024` write + `DISK_BYTE_CYCLES`).
3533 fn write_bytes(fds: &mut Fds, bytes: &[u8]) {
3534 // reset off (bit0=0), motor on (bit1=0), WRITE mode (bit2=0), CRC
3535 // enable (bit6=1) + bit5 (required for the byte-transfer flag).
3536 fds.cpu_write(0x4025, 0b0110_0000);
3537 // Clear the motor-on spin-up not-ready window so the transfer engine is
3538 // active immediately (the spin-up handshake is covered by its own tests).
3539 fds.insert_not_ready = 0;
3540 fds.update_transfer_state();
3541 for &b in bytes {
3542 fds.cpu_write(0x4024, b); // load the next write byte
3543 for _ in 0..DISK_BYTE_CYCLES {
3544 fds.notify_cpu_cycle();
3545 }
3546 }
3547 }
3548
3549 /// Wire offset of the first block's payload byte for a side whose first
3550 /// block is preceded only by the lead-in gap + start mark (the synth disks
3551 /// here always open with the disk-info block).
3552 const FIRST_BLOCK_WIRE_PAYLOAD: usize = WIRE_LEAD_IN_GAP + 1;
3553
3554 /// Park the head directly at a wire offset (bypassing the read cadence) so a
3555 /// write/read test lands inside a block payload region. `head` is a wire
3556 /// offset since the v2.6.0 wire-format synthesis.
3557 fn seek_head(fds: &mut Fds, wire_off: usize) {
3558 fds.head = wire_off;
3559 }
3560
3561 /// Close the motor-on spin-up not-ready window and re-evaluate the transfer
3562 /// state, so a transfer-level test runs immediately after a motor-on `$4025`
3563 /// write (the spin-up handshake itself is covered by the ready/insert tests).
3564 fn settle_drive(fds: &mut Fds) {
3565 fds.insert_not_ready = 0;
3566 fds.update_transfer_state();
3567 }
3568
3569 #[test]
3570 fn write_mode_round_trips_through_read() {
3571 let mut fds = make_device(1);
3572 enable_disk_io(&mut fds);
3573 // Park the head inside the disk-info block payload (past the synthesized
3574 // lead-in gap + $80 start mark) so the writes mirror into the raw side.
3575 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD);
3576 let payload = [0x11u8, 0x22, 0x33, 0x44, 0x55];
3577 write_bytes(&mut fds, &payload);
3578 // The bytes landed at the start of the disk-info block in the raw side
3579 // (wire payload start maps back to raw offset 0).
3580 assert_eq!(&fds.disk.side(0)[..5], &payload);
3581 // Rewind to the same payload offset and read them back in order through
3582 // the wire image.
3583 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD);
3584 fds.cpu_write(0x4025, 0b0110_0100); // motor on, read mode
3585 for expected in payload {
3586 for _ in 0..DISK_BYTE_CYCLES {
3587 fds.notify_cpu_cycle();
3588 }
3589 assert_eq!(fds.cpu_read(0x4031), expected, "read back written byte");
3590 }
3591 }
3592
3593 #[test]
3594 fn write_sets_and_clears_dirty_flag() {
3595 let mut fds = make_device(1);
3596 enable_disk_io(&mut fds);
3597 assert!(!fds.disk_is_dirty(), "clean at construction");
3598 write_bytes(&mut fds, &[0xAB]);
3599 assert!(fds.disk_is_dirty(), "a write marks the image dirty");
3600 fds.clear_disk_dirty();
3601 assert!(!fds.disk_is_dirty(), "clear_disk_dirty resets it");
3602 }
3603
3604 #[test]
3605 fn disk_image_bytes_reflect_writes() {
3606 let mut fds = make_device(1);
3607 enable_disk_io(&mut fds);
3608 // Park the head inside the disk-info block payload, past the 16-byte
3609 // signature (raw offset 16), so the written bytes don't clobber it
3610 // (keeping the image re-parseable below). The signature is at the start
3611 // of the block payload, so raw offset 16 == wire payload start + 16.
3612 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD + 16);
3613 // Switch to write mode (head stays put) and store a payload.
3614 write_bytes(&mut fds, &[0xDE, 0xAD, 0xBE, 0xEF]);
3615 let bytes = fds.disk_image_bytes();
3616 assert_eq!(bytes.len(), FDS_SIDE_LEN, "single side re-serialized");
3617 assert_eq!(&bytes[16..20], &[0xDE, 0xAD, 0xBE, 0xEF]);
3618 // The disk-info signature is intact, so the image re-parses to an equal
3619 // disk that reflects the writes.
3620 let reparsed = parse_fds(&bytes).unwrap();
3621 assert_eq!(&reparsed.side(0)[16..20], &[0xDE, 0xAD, 0xBE, 0xEF]);
3622 }
3623
3624 #[test]
3625 fn write_protected_disk_does_not_modify_medium() {
3626 let mut fds = make_device(1);
3627 enable_disk_io(&mut fds);
3628 fds.set_disk_write_protected(true);
3629 let before: Vec<u8> = fds.disk.side(0)[..4].to_vec();
3630 write_bytes(&mut fds, &[0xFF, 0xFF, 0xFF, 0xFF]);
3631 assert_eq!(&fds.disk.side(0)[..4], &before[..], "medium unchanged");
3632 assert!(!fds.disk_is_dirty(), "write-protected write is not dirty");
3633 // $4032 bit 2 reflects the protect flag.
3634 assert_eq!(fds.cpu_read(0x4032) & 0x04, 0x04);
3635 }
3636
3637 #[test]
3638 fn eject_sets_not_inserted_and_reads_not_ready() {
3639 let mut fds = make_device(1);
3640 enable_disk_io(&mut fds);
3641 fds.set_disk_side(None); // eject
3642 assert_eq!(fds.inserted_disk_side(), None);
3643 let v = fds.cpu_read(0x4032);
3644 assert_eq!(v & 0x01, 0x01, "ejected -> disk-not-inserted bit set");
3645 assert_eq!(v & 0x02, 0x02, "ejected -> not-ready bit set");
3646 // A read transfer started while ejected delivers no real disk bytes.
3647 fds.cpu_write(0x4025, 0b0110_0100); // motor on, read
3648 for _ in 0..DISK_BYTE_CYCLES {
3649 fds.notify_cpu_cycle();
3650 }
3651 // The transfer is Idle (no insert), so no byte-transfer flag set.
3652 assert_eq!(
3653 fds.cpu_read(0x4030) & 0x80,
3654 0x00,
3655 "no transfer while ejected"
3656 );
3657 }
3658
3659 #[test]
3660 fn insert_side_reads_from_that_side() {
3661 let mut fds = make_device(2);
3662 enable_disk_io(&mut fds);
3663 // Insert side 1 (its data byte at offset 16 is 1, per synth_fwnes).
3664 fds.set_disk_side(Some(1));
3665 assert_eq!(fds.inserted_disk_side(), Some(1));
3666 // Wait out the post-insert not-ready window before reading.
3667 for _ in 0..INSERT_NOT_READY_CYCLES {
3668 fds.notify_cpu_cycle();
3669 }
3670 assert_eq!(
3671 fds.cpu_read(0x4032) & 0x02,
3672 0x02,
3673 "motor off -> still not ready"
3674 );
3675 fds.cpu_write(0x4025, 0b0110_0100); // motor on, read mode
3676 settle_drive(&mut fds); // run out the motor spin-up window
3677 // Park at side 1's first block payload (past the lead-in gap + $80
3678 // mark); the first block byte is 0x01 (the disk-info block code).
3679 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD);
3680 for _ in 0..DISK_BYTE_CYCLES {
3681 fds.notify_cpu_cycle();
3682 }
3683 assert_eq!(
3684 fds.cpu_read(0x4031),
3685 0x01,
3686 "reads side 1's disk-info block code"
3687 );
3688 }
3689
3690 #[test]
3691 fn swap_then_gap_skip_delivers_first_block_byte() {
3692 // Regression for the Kid Icarus side-B ERR.07 stall (T-101-002): after a
3693 // real disk swap the BIOS relies on the controller's gap-skip to re-sync
3694 // to the first block — it does NOT manually seek. This mirrors
3695 // `insert_side_reads_from_that_side` but WITHOUT the manual `seek_head`,
3696 // so the read engine must skip the lead-in gap + $80 mark from head 0 and
3697 // deliver side 1's disk-info block code (0x01) as the first byte.
3698 let mut fds = make_device(2);
3699 enable_disk_io(&mut fds);
3700 fds.set_disk_side(Some(1));
3701 for _ in 0..INSERT_NOT_READY_CYCLES {
3702 fds.notify_cpu_cycle();
3703 }
3704 fds.cpu_write(0x4025, 0b0110_0100); // motor on, read mode
3705 settle_drive(&mut fds); // run out the motor spin-up window
3706 // No seek: the gap-skip must re-sync from head 0 to the first block.
3707 for _ in 0..DISK_BYTE_CYCLES {
3708 fds.notify_cpu_cycle();
3709 }
3710 assert_eq!(
3711 fds.cpu_read(0x4031),
3712 0x01,
3713 "gap-skip after swap must deliver side 1's disk-info block code"
3714 );
3715 }
3716
3717 #[test]
3718 fn multi_side_swap_isolates_writes() {
3719 let mut fds = make_device(2);
3720 enable_disk_io(&mut fds);
3721 // Write into side 0's disk-info block payload.
3722 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD);
3723 write_bytes(&mut fds, &[0xA0, 0xA1]);
3724 assert_eq!(&fds.disk.side(0)[..2], &[0xA0, 0xA1]);
3725 // Swap to side 1, wait ready, write different bytes into its block.
3726 fds.set_disk_side(Some(1));
3727 for _ in 0..INSERT_NOT_READY_CYCLES {
3728 fds.notify_cpu_cycle();
3729 }
3730 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD);
3731 write_bytes(&mut fds, &[0xB0, 0xB1]);
3732 assert_eq!(&fds.disk.side(1)[..2], &[0xB0, 0xB1]);
3733 // Side 0 is untouched by the side-1 writes.
3734 assert_eq!(&fds.disk.side(0)[..2], &[0xA0, 0xA1]);
3735 assert_eq!(fds.disk_side_count(), 2);
3736 }
3737
3738 #[test]
3739 fn insert_opens_not_ready_window() {
3740 let mut fds = make_device(2);
3741 enable_disk_io(&mut fds);
3742 fds.cpu_write(0x4025, 0b0110_0100); // motor on, read
3743 fds.set_disk_side(Some(1)); // insert -> not-ready window opens
3744 // Immediately after insert: not-ready set even though motor is on.
3745 assert_eq!(
3746 fds.cpu_read(0x4032) & 0x02,
3747 0x02,
3748 "not ready right after insert"
3749 );
3750 // Run out the window; with the motor on, ready clears.
3751 for _ in 0..INSERT_NOT_READY_CYCLES {
3752 fds.notify_cpu_cycle();
3753 }
3754 assert_eq!(
3755 fds.cpu_read(0x4032) & 0x02,
3756 0x00,
3757 "ready after window + motor on"
3758 );
3759 }
3760
3761 // --- FDS-proper (v1.6.0 Workstream F): CRC quirk table + timed head ---
3762
3763 #[test]
3764 fn crc32_matches_known_vectors() {
3765 // The standard CRC-32/ISO-HDLC check values.
3766 assert_eq!(fds_crc32(b"123456789"), 0xCBF4_3926);
3767 assert_eq!(fds_crc32(b""), 0x0000_0000);
3768 assert_eq!(fds_crc32(b"a"), 0xE8B7_BE43);
3769 }
3770
3771 #[test]
3772 fn quirk_lookup_returns_none_for_unknown_disk() {
3773 // A synthetic disk's CRC is not in the (real-dump-keyed) table.
3774 let fds = make_device(1);
3775 assert_eq!(fds.quirk(), FdsQuirk::NONE);
3776 // The table ships empty (entries are maintainer-measured from real
3777 // dumps only), so every CRC — synthetic or arbitrary — resolves to NONE
3778 // and no disk receives unverified timing slack.
3779 assert_eq!(quirk_for_crc(0xDEAD_BEEF), FdsQuirk::NONE);
3780 assert_eq!(quirk_for_crc(0x9CC9_C8A0), FdsQuirk::NONE);
3781 assert_eq!(quirk_for_crc(0x0000_0000), FdsQuirk::NONE);
3782 }
3783
3784 #[test]
3785 fn quirk_keys_off_headerless_disk_crc() {
3786 // The quirk CRC is taken over the headerless side bytes, so a fwNES
3787 // dump and a headerless dump of the same disk resolve identically.
3788 let fwnes = parse_fds(&synth_fwnes(1)).unwrap();
3789 let headerless_bytes = fwnes.to_bytes();
3790 let crc = fds_crc32(&headerless_bytes);
3791 assert_eq!(
3792 quirk_for_crc(crc),
3793 quirk_for_crc(fds_crc32(&headerless_bytes))
3794 );
3795 // And it matches what the device computed at construction.
3796 let fds = Fds::new(fwnes, &dummy_bios()).unwrap();
3797 assert_eq!(fds.quirk(), quirk_for_crc(crc));
3798 }
3799
3800 #[test]
3801 fn motor_restart_after_rewind_opens_reseek_window() {
3802 // Timed disk-head position: a motor restart after the cold spin-up,
3803 // with the head rewound to the disk start, must re-open a not-ready
3804 // window while the head re-seeks (rather than reading instantly).
3805 let mut fds = make_device(1);
3806 enable_disk_io(&mut fds);
3807 // Cold spin-up: first motor-on opens the long spin-up window.
3808 fds.cpu_write(0x4025, 0b0110_0100); // motor on, read mode
3809 assert!(fds.insert_not_ready > 0, "cold spin-up window open");
3810 // Run it out -> ready.
3811 for _ in 0..MOTOR_SPIN_UP_CYCLES {
3812 fds.notify_cpu_cycle();
3813 }
3814 assert_eq!(fds.cpu_read(0x4032) & 0x02, 0x00, "ready after spin-up");
3815 // Motor stop rewinds the head to the disk start.
3816 fds.cpu_write(0x4025, 0b0110_0110); // motor off (bit1 set)
3817 assert_eq!(fds.head, 0, "motor-off rewinds head to start");
3818 // Motor restart: the head must re-seek, so not-ready re-opens for the
3819 // re-seek window (no full cold spin-up — the disk is still turning).
3820 fds.cpu_write(0x4025, 0b0110_0100); // motor on
3821 assert_eq!(
3822 fds.insert_not_ready, HEAD_RESEEK_CYCLES,
3823 "re-seek window opens on motor-restart rewind"
3824 );
3825 assert!(
3826 fds.insert_not_ready < MOTOR_SPIN_UP_CYCLES,
3827 "re-seek is shorter than the cold spin-up"
3828 );
3829 assert_eq!(
3830 fds.cpu_read(0x4032) & 0x02,
3831 0x02,
3832 "not-ready during the re-seek"
3833 );
3834 for _ in 0..HEAD_RESEEK_CYCLES {
3835 fds.notify_cpu_cycle();
3836 }
3837 assert_eq!(
3838 fds.cpu_read(0x4032) & 0x02,
3839 0x00,
3840 "ready -> the BIOS re-read loop observes the not-ready->ready edge"
3841 );
3842 }
3843
3844 #[test]
3845 fn mid_read_motor_toggle_does_not_re_seek() {
3846 // A motor toggle while the head is NOT at the disk start (the between-
3847 // blocks toggles the BIOS does mid-read) must NOT open a re-seek window:
3848 // the disk never stopped spinning, so streaming continues seamlessly.
3849 let mut fds = make_device(1);
3850 enable_disk_io(&mut fds);
3851 fds.cpu_write(0x4025, 0b0110_0100); // motor on
3852 settle_drive(&mut fds); // past cold spin-up
3853 // Advance the head off the disk start (simulate a partial read).
3854 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD + 8);
3855 // The BIOS does NOT toggle the motor off here (that would rewind); a
3856 // re-arm via transfer-reset keeps the head where it is. Toggle the motor
3857 // off then on WITHOUT a rewind by forcing the head back after the off
3858 // edge to model "disk kept spinning past the start".
3859 fds.cpu_write(0x4025, 0b0110_0110); // motor off (rewinds head to 0)
3860 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD + 8); // head not at start
3861 fds.insert_not_ready = 0;
3862 fds.cpu_write(0x4025, 0b0110_0100); // motor on, head != 0
3863 assert_eq!(
3864 fds.insert_not_ready, 0,
3865 "no re-seek window when the head is not at the disk start"
3866 );
3867 }
3868
3869 #[test]
3870 fn quirk_is_not_part_of_save_state() {
3871 // The quirk is derived from immutable construction inputs, so it is not
3872 // serialized; a restored device recomputes it from its own disk.
3873 let mut fds = make_device(1);
3874 enable_disk_io(&mut fds);
3875 let blob = fds.save_state();
3876 let mut fresh = make_device(1);
3877 fresh.load_state(&blob).unwrap();
3878 assert_eq!(fresh.quirk(), fds.quirk());
3879 }
3880
3881 #[test]
3882 fn save_state_v3_round_trips_mid_write_dirty_disk() {
3883 let mut fds = make_device(2);
3884 enable_disk_io(&mut fds);
3885 // Partially write inside side 0's first block then capture mid-write
3886 // (transfer still active). Park the head 16 bytes into the disk-info
3887 // block payload (raw offset 16) so the written byte mirrors there
3888 // without clobbering the block-code byte (which would break the wire
3889 // re-parse on restore).
3890 fds.cpu_write(0x4025, 0b0110_0000); // motor on, write mode
3891 settle_drive(&mut fds); // run out the motor spin-up window
3892 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD + 16);
3893 fds.cpu_write(0x4024, 0x77);
3894 for _ in 0..DISK_BYTE_CYCLES {
3895 fds.notify_cpu_cycle();
3896 }
3897 fds.cpu_write(0x4024, 0x88); // queued for the next tick
3898 for _ in 0..(DISK_BYTE_CYCLES / 2) {
3899 fds.notify_cpu_cycle(); // mid-cadence: transfer_timer partway
3900 }
3901 assert!(fds.disk_is_dirty());
3902 assert_eq!(fds.transfer, TransferState::Writing);
3903 let blob = fds.save_state();
3904 assert_eq!(blob[0], 4, "FDS save version bumped to 4 (Capstone)");
3905
3906 let mut fresh = make_device(2);
3907 fresh.load_state(&blob).unwrap();
3908 assert_eq!(fresh.disk.side(0)[16], 0x77, "written byte restored");
3909 assert!(fresh.disk_is_dirty(), "dirty flag restored");
3910 assert_eq!(
3911 fresh.transfer,
3912 TransferState::Writing,
3913 "write phase restored"
3914 );
3915 assert_eq!(fresh.write_data, 0x88);
3916 assert_eq!(fresh.head, fds.head);
3917 assert_eq!(fresh.transfer_timer, fds.transfer_timer);
3918 assert_eq!(fresh.inserted_disk_side(), fds.inserted_disk_side());
3919 // Byte-identical re-serialization.
3920 assert_eq!(fresh.save_state(), blob);
3921 }
3922
3923 #[test]
3924 fn save_state_v3_round_trips_ejected() {
3925 let mut fds = make_device(2);
3926 enable_disk_io(&mut fds);
3927 fds.set_disk_side(None); // eject
3928 let blob = fds.save_state();
3929 let mut fresh = make_device(2);
3930 fresh.load_state(&blob).unwrap();
3931 assert_eq!(fresh.inserted_disk_side(), None, "ejected state restored");
3932 assert_eq!(fresh.save_state(), blob);
3933 }
3934
3935 #[test]
3936 fn save_state_v3_round_trips_write_protect() {
3937 let mut fds = make_device(1);
3938 fds.set_disk_write_protected(true);
3939 let blob = fds.save_state();
3940 let mut fresh = make_device(1);
3941 fresh.load_state(&blob).unwrap();
3942 assert_eq!(
3943 fresh.cpu_read(0x4032) & 0x04,
3944 0x04,
3945 "write-protect restored"
3946 );
3947 }
3948
3949 // --- v2.2.0 "Capstone" medium model ---
3950
3951 #[test]
3952 fn freshly_built_wire_passes_write_verify() {
3953 // The synthesized wire image for a power-on disk must already satisfy the
3954 // oracle: every block has a $80 mark, a correct CRC-16, and all-$00 gaps.
3955 let fds = make_device(1);
3956 fds.medium_write_verify()
3957 .expect("power-on wire image is well-formed");
3958 }
3959
3960 #[test]
3961 fn synthetic_write_verify_crc_and_gap_round_trip() {
3962 // The BIOS-free synthetic write-verify oracle: drive the register-level
3963 // WRITE path over a block payload, then re-walk the medium and assert the
3964 // per-block CRC-16 was re-emitted and the gap/mark framing round-trips —
3965 // no copyright FDS BIOS required (see docs/accuracy-ledger.md).
3966 let mut fds = make_device(1);
3967 enable_disk_io(&mut fds);
3968 // Land inside the disk-info block payload so the writes mirror into the
3969 // raw side and re-synthesize that block's CRC.
3970 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD);
3971 write_bytes(&mut fds, &[0xDE, 0xAD, 0xBE, 0xEF]);
3972 assert!(fds.disk_is_dirty(), "a write dirties the medium");
3973 // The whole medium remains structurally valid after the write.
3974 fds.medium_write_verify()
3975 .expect("write path keeps the medium self-consistent");
3976 // Prove the CRC oracle actually bites: corrupt one payload byte on the
3977 // wire WITHOUT re-synthesizing its CRC and confirm the verifier catches
3978 // the mismatch on the first (disk-info) block.
3979 fds.wire[FIRST_BLOCK_WIRE_PAYLOAD] ^= 0xFF;
3980 match fds.medium_write_verify() {
3981 Err(FdsMediumError::CrcMismatch { block: 0, .. }) => {}
3982 other => panic!("expected a block-0 CRC mismatch, got {other:?}"),
3983 }
3984 }
3985
3986 #[test]
3987 fn resynthesized_block_crc_matches_reference() {
3988 // After a block-payload write, the stored CRC-16 on the wire must equal a
3989 // fresh reference computation over the (updated) payload.
3990 let mut fds = make_device(1);
3991 enable_disk_io(&mut fds);
3992 seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD);
3993 write_bytes(&mut fds, &[0x10, 0x20, 0x30]);
3994 let blk = fds.wire_map[0];
3995 let end = blk.wire_payload_start + blk.len;
3996 let stored = u16::from(fds.wire[end]) | (u16::from(fds.wire[end + 1]) << 8);
3997 let expected = fds_block_crc(WIRE_START_MARK, &fds.wire[blk.wire_payload_start..end]);
3998 assert_eq!(
3999 stored, expected,
4000 "written block re-emits a consistent CRC-16"
4001 );
4002 }
4003
4004 #[test]
4005 fn analog_head_seek_defaults_off_and_matches_fixed_window() {
4006 // With the model OFF (default), a motor-restart re-seek opens the flat
4007 // HEAD_RESEEK_CYCLES window regardless of head distance — byte-identical
4008 // to prior releases.
4009 let mut fds = make_device(1);
4010 assert!(!fds.analog_head_seek(), "model is opt-in / default off");
4011 fds.pre_rewind_head = 40_000; // far-out head; ignored while disabled
4012 assert_eq!(fds.reseek_window_cycles(), HEAD_RESEEK_CYCLES);
4013 }
4014
4015 #[test]
4016 fn analog_head_seek_scales_with_distance() {
4017 // With the model ON, the re-seek window grows with the pre-rewind head
4018 // distance (belt velocity) and clamps at a cold spin-up.
4019 let mut fds = make_device(1);
4020 fds.set_analog_head_seek(true);
4021 fds.pre_rewind_head = 0;
4022 let near = fds.reseek_window_cycles();
4023 assert_eq!(
4024 near, HEAD_SEEK_SETTLE_CYCLES,
4025 "zero-distance = settle floor"
4026 );
4027 fds.pre_rewind_head = 8_000;
4028 let mid = fds.reseek_window_cycles();
4029 assert_eq!(
4030 mid,
4031 HEAD_SEEK_SETTLE_CYCLES + 8_000 / HEAD_SEEK_BYTES_PER_CYCLE
4032 );
4033 assert!(mid > near, "a farther head takes longer to rewind");
4034 // A head parked deep past the whole disk clamps to the spin-up ceiling.
4035 fds.pre_rewind_head = 10_000_000;
4036 assert_eq!(fds.reseek_window_cycles(), MOTOR_SPIN_UP_CYCLES);
4037 }
4038
4039 #[test]
4040 fn v4_save_state_round_trips_analog_head_seek() {
4041 // The v4 tail persists the head-seek model state.
4042 let mut fds = make_device(2);
4043 enable_disk_io(&mut fds);
4044 fds.set_analog_head_seek(true);
4045 fds.pre_rewind_head = 12_345;
4046 let blob = fds.save_state();
4047 assert_eq!(blob[0], 4);
4048 let mut fresh = make_device(2);
4049 fresh.load_state(&blob).unwrap();
4050 assert!(fresh.analog_head_seek(), "v4 restores the opt-in flag");
4051 assert_eq!(
4052 fresh.pre_rewind_head, 12_345,
4053 "v4 restores the seek distance"
4054 );
4055 assert_eq!(fresh.save_state(), blob, "re-serialize is byte-identical");
4056 }
4057}