Skip to main content

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}