Skip to main content

rustynes_mappers/
m035_jy_asic.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//
3// Provenance: the JY Company ASIC register decode is derived from Mesen2 (GPL-3.0-or-later), `JyCompany`, alongside the NESdev "J.Y. Company ASIC" documentation. See docs/originality-and-provenance.md (Section 1)
4// and NOTICE for the complete, audited derivation record.
5//! J.Y. Company ASIC (iNES mappers 90 / 209 / 211) implementation.
6//!
7//! 晶太 (J.Y. Company)'s proprietary ASIC backs their later single-game
8//! cartridges and most of their multicarts. It exposes a very flexible banking
9//! surface: four PRG modes (32/16/8 KiB + an 8 KiB mode with the low 7 bank
10//! bits reversed), four CHR modes (8/4/2/1 KiB granularity), an MMC4-like
11//! automatic CHR-latch, optional ROM nametables / extended (per-1 KiB) CIRAM
12//! mirroring, a hardware multiplier, and a configurable prescaler+counter IRQ
13//! with four selectable clock sources.
14//!
15//! The three iNES mappers share one silicon implementation and differ only in
16//! how the ROM-nametable / extended-mirroring feature is wired:
17//!
18//! - **209** is the standard implementation; the feature is register-enabled.
19//! - **090** has the feature inhibited via a board jumper — it behaves as
20//!   though `$D000` bit 5 (ROM nametables) and `$D001` bit 3 (extended
21//!   mirroring) were always clear, so it always uses the simple `$D001` MM
22//!   mirroring select.
23//! - **211** behaves as though the feature were always enabled. No such PCB
24//!   exists in hardware; the mapper number predates the discovery of `$D001`
25//!   bit 3 and is a duplicate of 209 with correct emulation.
26//!
27//! This port follows the nesdev "J.Y. Company ASIC" page
28//! (`nesdev_wiki/J_Y__Company_ASIC.xhtml`) and the Mesen2 `JyCompany`
29//! implementation (`Mesen2/Core/NES/Mappers/JyCompany/JyCompany.h`).
30//!
31//! # Registers
32//!
33//! The wiki documents per-block address masks (`$5xxx`/`$8xxx`/`$Dxxx` are
34//! `$F803`-masked, `$9xxx`/`$Axxx`/`$Bxxx` are `$F807`-masked, `$Cxxx` is
35//! `$F007`-masked). The "Mask" column below is the mask this port actually
36//! decodes, which follows **Mesen2** rather than the per-block wiki masks:
37//! the `$5000-$5FFF` window decodes with `$F803`, and the entire
38//! `$8000-$FFFF` register space decodes with a single `$F007` (Mesen2's
39//! `WriteRegister` `switch(addr & 0xF007)`). `$F007` keeps A0-A2 and A12-A15
40//! and discards A3-A11 — so, unlike the wiki's `$F803`/`$F807`, it does NOT
41//! mask A11 (`$8800` still writes the `$8000` register). Because A2 survives
42//! the mask, the eight-address banking blocks (PRG `$8000-$8007`, CHR
43//! `$9000-$9007`/`$A000-$A007`, NT `$B000-$B007`) list all eight cases; the
44//! PRG bank index then drops A2 via `& 0x03` so `$8004-$8007` alias
45//! `$8000-$8003`. The `$C000-$C007` IRQ block uses all eight addresses as
46//! distinct registers. The `$D000` mode block acts only on `$D000-$D003`
47//! (Mesen2 lists no `$D004-$D007` cases), so those four addresses are inert.
48//! This matches Mesen2 bit-for-bit; no known game
49//! depends on the stricter wiki decode, so the unified mask is the accuracy
50//! reference here.
51//!
52//! | Range          | Mask    | Purpose                                       |
53//! |----------------|---------|-----------------------------------------------|
54//! | `$5000`        | `$F803` | Jumper/dip read (we return 0)                 |
55//! | `$5800/$5801`  | `$F803` | Hardware multiplier operands / result         |
56//! | `$5803`        | `$F803` | Test/accumulator register (read/write)         |
57//! | `$8000-$8007`  | `$F007` | PRG bank registers (7-bit; `$8004-7` alias `$8000-3`) |
58//! | `$9000-$9007`  | `$F007` | CHR bank LSB registers                          |
59//! | `$A000-$A007`  | `$F007` | CHR bank MSB registers                          |
60//! | `$B000-$B003`  | `$F007` | Nametable bank LSB registers                    |
61//! | `$B004-$B007`  | `$F007` | Nametable bank MSB registers                    |
62//! | `$C000-$C007`  | `$F007` | IRQ control / prescaler / counter / XOR        |
63//! | `$D000-$D003`  | `$F007` | Mode / mirroring / PPU-config / outer bank (`$D004-7` inert) |
64//!
65//! # IRQ
66//!
67//! A prescaler clocks an 8-bit counter. The clock source (`$C001` bits 0-1) is
68//! one of: CPU M2 rise, PPU A12 rise, PPU render reads, or CPU writes. The
69//! direction (`$C001` bits 6-7) selects increment (1), decrement (2), or
70//! disabled (0/3). The prescaler mask (`$C001` bit 2) is `$FF` or `$07`. When
71//! the masked prescaler wraps, the counter is clocked; when the counter wraps
72//! ($FF->$00 up, or $00->$FF down) an IRQ is asserted (if enabled). Disabling
73//! acknowledges the IRQ, inhibits counting, and resets the prescaler to zero.
74//! `$C004`/`$C005` set the prescaler/counter (XORed with `$C006` first).
75
76#![allow(
77    clippy::cast_possible_truncation,
78    clippy::cast_lossless,
79    clippy::match_same_arms,
80    clippy::doc_markdown,
81    clippy::struct_excessive_bools,
82    clippy::similar_names,
83    clippy::missing_const_for_fn,
84    clippy::too_many_lines
85)]
86
87use crate::cartridge::Mirroring;
88use crate::mapper::{Mapper, MapperCaps, MapperError};
89use alloc::{boxed::Box, format, vec, vec::Vec};
90
91const PRG_BANK_8K: usize = 0x2000;
92const CHR_BANK_1K: usize = 0x0400;
93const NAMETABLE_SIZE: usize = 0x0400;
94const NAMETABLE_SIZE_U16: u16 = 0x0400;
95
96const SAVE_STATE_VERSION: u8 = 1;
97
98/// Which iNES mapper number wired the ASIC. Selects the ROM-nametable /
99/// extended-mirroring policy and the mapper 209 MMC4 auto-latch behaviour.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum JyBoard {
102    /// iNES mapper 90: ROM nametables / extended mirroring jumper-inhibited.
103    M90,
104    /// iNES mapper 209: standard implementation (feature register-enabled).
105    M209,
106    /// iNES mapper 211: feature always enabled (a 209 duplicate).
107    M211,
108    /// iNES mapper 35: the J.Y. Company single-game "extended" board
109    /// (`YY840820C` / `J.Y.061`). Same silicon as 209 — the ROM-nametable /
110    /// extended-mirroring feature is register-enabled — so it shares the 209
111    /// policy (incl. the MMC4-like CHR auto-latch). (v1.6.0 Workstream E.)
112    M35,
113}
114
115impl JyBoard {
116    const fn mapper_id(self) -> u16 {
117        match self {
118            Self::M90 => 90,
119            Self::M209 => 209,
120            Self::M211 => 211,
121            Self::M35 => 35,
122        }
123    }
124}
125
126/// IRQ clock source from `$C001` bits 0-1.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum IrqSource {
129    /// CPU M2 rise (every CPU cycle).
130    CpuClock,
131    /// PPU A12 rising edge (unfiltered).
132    PpuA12Rise,
133    /// PPU render reads.
134    PpuRead,
135    /// CPU writes.
136    CpuWrite,
137}
138
139impl IrqSource {
140    const fn from_bits(bits: u8) -> Self {
141        match bits & 0x03 {
142            0 => Self::CpuClock,
143            1 => Self::PpuA12Rise,
144            2 => Self::PpuRead,
145            _ => Self::CpuWrite,
146        }
147    }
148
149    const fn to_bits(self) -> u8 {
150        match self {
151            Self::CpuClock => 0,
152            Self::PpuA12Rise => 1,
153            Self::PpuRead => 2,
154            Self::CpuWrite => 3,
155        }
156    }
157}
158
159/// J.Y. Company ASIC mapper (iNES 90 / 209 / 211).
160pub struct JyAsic {
161    board: JyBoard,
162    prg_rom: Box<[u8]>,
163    chr: Box<[u8]>,
164    chr_is_ram: bool,
165
166    // --- Banking registers ---
167    prg_regs: [u8; 4],      // $8000-$8003 (7-bit)
168    chr_low_regs: [u8; 8],  // $9000-$9007
169    chr_high_regs: [u8; 8], // $A000-$A007
170    nt_low_regs: [u8; 4],   // $B000-$B003
171    nt_high_regs: [u8; 4],  // $B004-$B007
172
173    chr_latch: [u8; 2], // MMC4-like CHR latch selectors (window 0 / 1)
174
175    // --- Mode register $D000 ---
176    prg_mode: u8,              // bits 0-2
177    enable_prg_at_6000: bool,  // bit 7
178    chr_mode: u8,              // bits 3-4
179    advanced_nt_control: bool, // bit 5 (ROM nametables)
180    nt_global: bool,           // bit 6 (ROM nametables for all)
181
182    // --- $D001 / $D002 / $D003 ---
183    mirroring_reg: u8,        // $D001 bits 0-1
184    extended_mirroring: bool, // $D001 bit 3
185    nt_ram_select_bit: u8,    // $D002 bit 7
186    chr_block_mode: bool,     // derived from $D003 bit 5 (== 0 enables block mode)
187    chr_block: u8,            // outer CHR block from $D003
188    mirror_chr: bool,         // $D003 bit 7
189
190    // --- IRQ ---
191    irq_enabled: bool,
192    irq_source: IrqSource,
193    irq_count_direction: u8, // 0/3 disabled, 1 up, 2 down
194    irq_small_prescaler: bool,
195    irq_prescaler: u8,
196    irq_counter: u8,
197    irq_xor: u8,
198    irq_funky_reg: u8, // $C007 (unknown mode; stored for round-trip)
199    irq_pending: bool,
200
201    // --- Misc registers ---
202    mul1: u8,
203    mul2: u8,
204    test_reg: u8,
205
206    last_ppu_addr: u16,
207}
208
209impl JyAsic {
210    /// Construct a new J.Y. Company ASIC mapper for `board`.
211    ///
212    /// `prg_rom` must be a non-zero multiple of 8 KiB. CHR-ROM (when present)
213    /// must be a multiple of 1 KiB; if absent, 256 KiB of CHR-RAM is allocated
214    /// (the ASIC's max addressable CHR window) so games that bank CHR-RAM work.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`MapperError::Invalid`] on a PRG/CHR size mismatch.
219    pub fn new(
220        prg_rom: Box<[u8]>,
221        chr_rom: Box<[u8]>,
222        mirroring: Mirroring,
223        board: JyBoard,
224    ) -> Result<Self, MapperError> {
225        if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
226            return Err(MapperError::Invalid(format!(
227                "JY ASIC PRG-ROM size {} is not a non-zero multiple of 8 KiB",
228                prg_rom.len()
229            )));
230        }
231        let chr_is_ram = chr_rom.is_empty();
232        let chr: Box<[u8]> = if chr_is_ram {
233            // 256 KiB CHR-RAM covers the largest CHR window the ASIC selects.
234            vec![0u8; 256 * CHR_BANK_1K].into_boxed_slice()
235        } else if chr_rom.len().is_multiple_of(CHR_BANK_1K) {
236            chr_rom
237        } else {
238            return Err(MapperError::Invalid(format!(
239                "JY ASIC CHR-ROM size {} is not a multiple of 1 KiB",
240                chr_rom.len()
241            )));
242        };
243        // The header mirroring seeds `$D001`; the ASIC overrides it at runtime.
244        let mirroring_reg = match mirroring {
245            Mirroring::Horizontal => 1,
246            Mirroring::SingleScreenA => 2,
247            Mirroring::SingleScreenB => 3,
248            // Vertical (and any odd header value) -> 0.
249            _ => 0,
250        };
251        Ok(Self {
252            board,
253            prg_rom,
254            chr,
255            chr_is_ram,
256            prg_regs: [0; 4],
257            chr_low_regs: [0; 8],
258            chr_high_regs: [0; 8],
259            nt_low_regs: [0; 4],
260            nt_high_regs: [0; 4],
261            // Mesen seeds the latch to {0, 4} so the two 4 KiB windows index the
262            // distinct low/high CHR register groups before any MMC4 fetch.
263            chr_latch: [0, 4],
264            prg_mode: 0,
265            enable_prg_at_6000: false,
266            chr_mode: 0,
267            advanced_nt_control: false,
268            nt_global: false,
269            mirroring_reg,
270            extended_mirroring: false,
271            nt_ram_select_bit: 0,
272            chr_block_mode: false,
273            chr_block: 0,
274            mirror_chr: false,
275            irq_enabled: false,
276            irq_source: IrqSource::CpuClock,
277            irq_count_direction: 0,
278            irq_small_prescaler: false,
279            irq_prescaler: 0,
280            irq_counter: 0,
281            irq_xor: 0,
282            irq_funky_reg: 0,
283            irq_pending: false,
284            mul1: 0,
285            mul2: 0,
286            test_reg: 0,
287            last_ppu_addr: 0,
288        })
289    }
290
291    /// Whether the board exposes ROM nametables / extended mirroring.
292    ///
293    /// Mapper 211 forces it on; mapper 90 forces it off; mapper 209 follows the
294    /// `$D000` bit 5 / `$D001` bit 3 registers.
295    const fn nt_control_active(&self) -> bool {
296        match self.board {
297            JyBoard::M211 => true,
298            JyBoard::M90 => false,
299            JyBoard::M209 | JyBoard::M35 => self.advanced_nt_control || self.extended_mirroring,
300        }
301    }
302
303    /// Apply the PRG bank-number reversal used by PRG mode 3 (`$D000` bits
304    /// 0-1 == 3).
305    ///
306    /// The NESdev wiki / Disch's JY-ASIC writeup describe this as "bank
307    /// numbers bits 0-6 reversed". Following that documentation we reverse the
308    /// three outer bit pairs (0<->6, 1<->5, 2<->4) and notably do **not** carry
309    /// bit 3 through: a literal "reverse a 7-bit field" would leave the centre
310    /// bit (3) in place, but Disch's writeup does not preserve it, so we drop it
311    /// to match the documented hardware bit-for-bit (no known game distinguishes
312    /// the two; the JY ASIC is BestEffort tier). If a future test ROM proves bit
313    /// 3 must be preserved, OR `reg & 0x08` back into the result here.
314    ///
315    /// Provenance: `invert_prg_bits` is derived from Mesen2's `InvertPrgBits`
316    /// (GPL-3.0-or-later); the register map is documented on the NESdev wiki.
317    /// See NOTICE and docs/originality-and-provenance.md (Section 1).
318    const fn invert_prg_bits(reg: u8, invert: bool) -> u8 {
319        if invert {
320            (reg & 0x01) << 6
321                | (reg & 0x02) << 4
322                | (reg & 0x04) << 2
323                | (reg & 0x10) >> 2
324                | (reg & 0x20) >> 4
325                | (reg & 0x40) >> 6
326        } else {
327            reg
328        }
329    }
330
331    /// Resolve an 8 KiB PRG bank index for the given CPU window.
332    ///
333    /// `window` is the 8 KiB slot 0..=3 (`$8000`/`$A000`/`$C000`/`$E000`).
334    fn prg_bank_8k(&self, window: usize) -> usize {
335        let invert = (self.prg_mode & 0x03) == 0x03;
336        let r: [usize; 4] = [
337            Self::invert_prg_bits(self.prg_regs[0], invert) as usize,
338            Self::invert_prg_bits(self.prg_regs[1], invert) as usize,
339            Self::invert_prg_bits(self.prg_regs[2], invert) as usize,
340            Self::invert_prg_bits(self.prg_regs[3], invert) as usize,
341        ];
342        let last_switchable = (self.prg_mode & 0x04) != 0;
343        match self.prg_mode & 0x03 {
344            // 32 KiB: one bank across all four windows.
345            0 => {
346                let base = if last_switchable { r[3] * 4 } else { 0x3C };
347                base + window
348            }
349            // 16 KiB: r1<<1 for the low half, r3 (or fixed $3E) for the high.
350            1 => {
351                if window < 2 {
352                    (r[1] << 1) + window
353                } else {
354                    let base = if last_switchable { r[3] << 1 } else { 0x3E };
355                    base + (window - 2)
356                }
357            }
358            // 8 KiB (modes 2 and 3): each window has its own register; the last
359            // window is fixed to $3F unless made switchable.
360            _ => match window {
361                0 => r[0],
362                1 => r[1],
363                2 => r[2],
364                _ => {
365                    if last_switchable {
366                        r[3]
367                    } else {
368                        0x3F
369                    }
370                }
371            },
372        }
373    }
374
375    /// Resolve the 8 KiB PRG bank mapped at `$6000-$7FFF` (only when `$D000`
376    /// bit 7 enables it).
377    fn prg_bank_6000(&self) -> usize {
378        let invert = (self.prg_mode & 0x03) == 0x03;
379        let r3 = Self::invert_prg_bits(self.prg_regs[3], invert) as usize;
380        match self.prg_mode & 0x03 {
381            0 => r3 * 4 + 3,
382            1 => (r3 << 1) | 1,
383            _ => r3,
384        }
385    }
386
387    fn prg_offset(&self, addr: u16) -> usize {
388        let total = (self.prg_rom.len() / PRG_BANK_8K).max(1);
389        let bank = if (0x6000..0x8000).contains(&addr) {
390            self.prg_bank_6000()
391        } else {
392            let window = ((addr >> 13) & 0x03) as usize; // ($8000..) / 0x2000
393            self.prg_bank_8k(window)
394        };
395        (bank % total) * PRG_BANK_8K + (addr as usize & 0x1FFF)
396    }
397
398    /// Resolve the effective CHR register value for index 0..=7, applying the
399    /// CHR-block (outer-bank) mode and `mirror_chr` aliasing.
400    fn chr_reg(&self, index: usize) -> u32 {
401        // mirror_chr aliases the high half of the CHR window onto the low half
402        // in 2/1 KiB modes (Mesen: chrMode >= 2 && mirrorChr && index 2/3).
403        let index = if self.chr_mode >= 2 && self.mirror_chr && (index == 2 || index == 3) {
404            index - 2
405        } else {
406            index
407        };
408        if self.chr_block_mode {
409            let (mask, shift): (u32, u32) = match self.chr_mode {
410                0 => (0x1F, 5),
411                1 => (0x3F, 6),
412                2 => (0x7F, 7),
413                _ => (0xFF, 8),
414            };
415            (self.chr_low_regs[index] as u32 & mask) | ((self.chr_block as u32) << shift)
416        } else {
417            self.chr_low_regs[index] as u32 | ((self.chr_high_regs[index] as u32) << 8)
418        }
419    }
420
421    /// Resolve a physical CHR offset for a PPU pattern-table address.
422    fn chr_offset(&self, addr: u16) -> usize {
423        let addr = (addr & 0x1FFF) as usize;
424        let total_1k = (self.chr.len() / CHR_BANK_1K).max(1);
425        let slot_1k = addr / CHR_BANK_1K; // 0..=7
426        let bank_1k: usize = match self.chr_mode {
427            // 8 KiB: reg 0 selects the whole window.
428            0 => (self.chr_reg(0) as usize) * 8 + slot_1k,
429            // 4 KiB: two windows, MMC4 latch picks the register for each.
430            1 => {
431                let reg = if addr < 0x1000 {
432                    self.chr_latch[0] as usize
433                } else {
434                    self.chr_latch[1] as usize
435                };
436                (self.chr_reg(reg) as usize) * 4 + (slot_1k & 0x03)
437            }
438            // 2 KiB: regs 0/2/4/6 select four 2 KiB windows.
439            2 => {
440                let reg = (slot_1k & !1) & 0x07;
441                (self.chr_reg(reg) as usize) * 2 + (slot_1k & 0x01)
442            }
443            // 1 KiB: each slot has its own register.
444            _ => self.chr_reg(slot_1k) as usize,
445        };
446        (bank_1k % total_1k) * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1))
447    }
448
449    fn nametable_offset(&self, addr: u16) -> usize {
450        let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
451        let local = (addr as usize) & (NAMETABLE_SIZE - 1);
452        let physical = self.ciram_bank_for(table);
453        physical * NAMETABLE_SIZE + local
454    }
455
456    /// Resolve a logical nametable index 0..=3 to a physical CIRAM bank (0 or 1).
457    ///
458    /// When the ROM-nametable / Extended-Mirroring feature is active
459    /// ([`Self::nt_control_active`]), each nametable picks its CIRAM page
460    /// independently from `$B00x` bit 0 (Extended Mirroring, `$D001` bit 3).
461    /// This mirrors Mesen2's `UpdateMirroringState`, which calls
462    /// `SetNametable(i, _ntLowRegs[i] & 0x01)` for each of the four tables
463    /// whenever advanced NT control is on (and the PCB is not mapper 90).
464    /// Otherwise the simple `$D001` MM register selects the layout.
465    const fn ciram_bank_for(&self, table: u8) -> usize {
466        if self.nt_control_active() {
467            (self.nt_low_regs[table as usize & 0x03] & 0x01) as usize
468        } else {
469            Self::physical_bank_for(table, self.mirroring_reg)
470        }
471    }
472
473    /// Resolve a logical nametable index 0..=3 to a physical CIRAM bank (0 or 1)
474    /// using the `$D001` MM register directly (the ASIC's MM encoding differs
475    /// from the header [`Mirroring`] enum).
476    const fn physical_bank_for(table: u8, mirroring_reg: u8) -> usize {
477        match mirroring_reg & 0x03 {
478            // 0: Vertical (tables 0/2 -> A, 1/3 -> B).
479            0 => table as usize & 1,
480            // 1: Horizontal (tables 0/1 -> A, 2/3 -> B).
481            1 => (table >> 1) as usize & 1,
482            // 2: one-screen page 0.
483            2 => 0,
484            // 3: one-screen page 1.
485            _ => 1,
486        }
487    }
488
489    /// Update the MMC4-like CHR latch on mapper 209 when a pattern fetch hits a
490    /// sentinel address ($x FD8-$x FDF / $x FE8-$x FEF).
491    fn update_chr_latch_209(&mut self, addr: u16) {
492        if !matches!(self.board, JyBoard::M209 | JyBoard::M35) {
493            return;
494        }
495        match addr & 0x2FF8 {
496            0x0FD8 | 0x0FE8 => {
497                // Mesen: chrLatch[addr>>12] = (addr>>4) & ((addr>>10 & 4) | 2)
498                let idx = (addr >> 12) as usize & 0x01;
499                self.chr_latch[idx] = ((addr >> 4) as u8) & (((addr >> 10) as u8 & 0x04) | 0x02);
500            }
501            _ => {}
502        }
503    }
504
505    /// Whether the given nametable index ($2000-$2FFF / index 0..=3) reads from
506    /// ROM (CHR) rather than CIRAM, under the current `$D000`/`$D001`/`$D002`
507    /// configuration. Only meaningful when [`Self::nt_control_active`].
508    fn nt_index_is_rom(&self, nt_index: usize) -> bool {
509        if self.extended_mirroring {
510            // Extended mirroring uses CIRAM banks only (bit 0 of $B00x).
511            return false;
512        }
513        if !self.advanced_nt_control && self.board != JyBoard::M211 {
514            return false;
515        }
516        if self.nt_global {
517            // ROM nametables for all four nametables.
518            true
519        } else {
520            // Per-nametable: ROM when $B00x bit 7 differs from $D002 bit 7.
521            (self.nt_low_regs[nt_index] & 0x80) != (self.nt_ram_select_bit & 0x80)
522        }
523    }
524
525    /// Read a CHR/ROM nametable byte for an address in $2000-$2FFF.
526    fn nt_rom_byte(&self, addr: u16) -> u8 {
527        let nt_index = ((addr & 0x0FFF) / NAMETABLE_SIZE_U16) as usize & 0x03;
528        let page =
529            self.nt_low_regs[nt_index] as usize | ((self.nt_high_regs[nt_index] as usize) << 8);
530        let off = page * NAMETABLE_SIZE + (addr as usize & 0x3FF);
531        if off < self.chr.len() {
532            self.chr[off]
533        } else {
534            0
535        }
536    }
537
538    /// Tick the IRQ prescaler+counter once for the active clock source.
539    fn tick_irq(&mut self) {
540        if self.irq_count_direction != 0x01 && self.irq_count_direction != 0x02 {
541            return; // counting disabled (directions 0 and 3)
542        }
543        let mask: u8 = if self.irq_small_prescaler { 0x07 } else { 0xFF };
544        let mut prescaler = self.irq_prescaler & mask;
545        let mut clock_counter = false;
546        if self.irq_count_direction == 0x01 {
547            prescaler = prescaler.wrapping_add(1);
548            if (prescaler & mask) == 0 {
549                clock_counter = true;
550            }
551        } else {
552            prescaler = prescaler.wrapping_sub(1);
553            if (prescaler & mask) == mask {
554                clock_counter = true;
555            }
556        }
557        self.irq_prescaler = (self.irq_prescaler & !mask) | (prescaler & mask);
558
559        if clock_counter {
560            if self.irq_count_direction == 0x01 {
561                self.irq_counter = self.irq_counter.wrapping_add(1);
562                if self.irq_counter == 0 && self.irq_enabled {
563                    self.irq_pending = true;
564                }
565            } else {
566                self.irq_counter = self.irq_counter.wrapping_sub(1);
567                if self.irq_counter == 0xFF && self.irq_enabled {
568                    self.irq_pending = true;
569                }
570            }
571        }
572    }
573
574    /// Disable + acknowledge the IRQ, inhibit counting, and reset the prescaler.
575    fn irq_disable(&mut self) {
576        self.irq_enabled = false;
577        self.irq_pending = false;
578        self.irq_prescaler = 0;
579    }
580}
581
582impl Mapper for JyAsic {
583    /// v3.1.0: not pure -- PPU reads clock its IRQ counter, and mapper 209 latches CHR on reads, so the PPU's display-only
584    /// "disable sprite limit" reads are not made on this board.
585    fn chr_reads_are_pure(&self) -> bool {
586        false
587    }
588
589    // CPU-cycle hook (for the CPU-clock IRQ source) + IRQ source. No audio.
590    fn caps(&self) -> MapperCaps {
591        MapperCaps::CYCLE_IRQ
592    }
593
594    fn cpu_read(&mut self, addr: u16) -> u8 {
595        match addr {
596            0x5000..=0x5FFF => match addr & 0xF803 {
597                // Jumper/dip switches — return 0 (no multicart selection).
598                0x5000 => 0,
599                0x5800 => (self.mul1 as u16 * self.mul2 as u16) as u8,
600                0x5801 => ((self.mul1 as u16 * self.mul2 as u16) >> 8) as u8,
601                0x5803 => self.test_reg,
602                _ => 0,
603            },
604            0x6000..=0x7FFF if self.enable_prg_at_6000 => {
605                let off = self.prg_offset(addr);
606                self.prg_rom[off % self.prg_rom.len()]
607            }
608            0x8000..=0xFFFF => {
609                let off = self.prg_offset(addr);
610                self.prg_rom[off % self.prg_rom.len()]
611            }
612            _ => 0,
613        }
614    }
615
616    fn cpu_read_unmapped(&self, addr: u16) -> bool {
617        match addr {
618            // The $5000-$5FFF register window is mapped (multiplier / test / dip).
619            0x5000..=0x5FFF => false,
620            // $6000-$7FFF only maps when PRG is routed there.
621            0x6000..=0x7FFF => !self.enable_prg_at_6000,
622            // The rest of $4020-$5FFF is unmapped (open bus).
623            _ => (0x4020..0x5000).contains(&addr),
624        }
625    }
626
627    fn cpu_write(&mut self, addr: u16, value: u8) {
628        if addr < 0x8000 {
629            match addr & 0xF803 {
630                0x5800 => self.mul1 = value,
631                0x5801 => self.mul2 = value,
632                0x5803 => self.test_reg = value,
633                _ => {}
634            }
635        } else {
636            // Mesen2 decodes the whole $8000-$FFFF register space with a single
637            // $F007 mask (`switch(addr & 0xF007)`), not the wiki's per-block
638            // $F803/$F807. $F007 keeps A0-A2 and A12-A15 but discards A3-A11,
639            // so A11 is NOT masked (unlike the wiki's $F803 — $8800 still hits
640            // the $8000 register). A2 is kept by the mask, so register blocks
641            // that span eight addresses (PRG $8000-$8007, the CHR/NT $9/$A/$B
642            // blocks) must list all eight; the PRG bank index then drops A2 via
643            // `& 0x03` so $8004-$8007 alias $8000-$8003 exactly as Mesen2 does.
644            // (The $C000 IRQ and $D000 mode blocks intentionally only act on
645            // $x000-$x003 — Mesen2 lists no $x004-$x007 cases there either, so
646            // those addresses are inert.) See the module-level register table.
647            match addr & 0xF007 {
648                // PRG bank registers (7-bit). $8004-$8007 alias $8000-$8003
649                // (A2 dropped by the `& 0x03` index), matching Mesen2's eight
650                // explicit $8000-$8007 cases.
651                0x8000..=0x8007 => self.prg_regs[(addr & 0x03) as usize] = value & 0x7F,
652                // CHR LSB registers.
653                0x9000..=0x9007 => self.chr_low_regs[(addr & 0x07) as usize] = value,
654                // CHR MSB registers.
655                0xA000..=0xA007 => self.chr_high_regs[(addr & 0x07) as usize] = value,
656                // Nametable bank LSB.
657                0xB000..=0xB003 => self.nt_low_regs[(addr & 0x03) as usize] = value,
658                // Nametable bank MSB.
659                0xB004..=0xB007 => self.nt_high_regs[(addr & 0x03) as usize] = value,
660                // IRQ enable.
661                0xC000 => {
662                    if value & 0x01 != 0 {
663                        self.irq_enabled = true;
664                    } else {
665                        self.irq_disable();
666                    }
667                }
668                // IRQ mode/flags.
669                0xC001 => {
670                    self.irq_count_direction = (value >> 6) & 0x03;
671                    self.irq_small_prescaler = (value & 0x04) != 0;
672                    self.irq_source = IrqSource::from_bits(value);
673                }
674                // IRQ disable (acknowledge).
675                0xC002 => self.irq_disable(),
676                // IRQ enable.
677                0xC003 => self.irq_enabled = true,
678                // Set prescaler (XORed with $C006).
679                0xC004 => self.irq_prescaler = value ^ self.irq_xor,
680                // Set counter (XORed with $C006).
681                0xC005 => self.irq_counter = value ^ self.irq_xor,
682                // Set XOR value.
683                0xC006 => self.irq_xor = value,
684                // Unknown mode configuration ($C007).
685                0xC007 => self.irq_funky_reg = value,
686                // Mode select.
687                0xD000 => {
688                    self.prg_mode = value & 0x07;
689                    self.chr_mode = (value >> 3) & 0x03;
690                    self.advanced_nt_control = (value & 0x20) != 0;
691                    self.nt_global = (value & 0x40) != 0;
692                    self.enable_prg_at_6000 = (value & 0x80) != 0;
693                }
694                // Mirroring select.
695                0xD001 => {
696                    self.mirroring_reg = value & 0x03;
697                    self.extended_mirroring = (value & 0x08) != 0;
698                }
699                // PPU address-space config.
700                0xD002 => self.nt_ram_select_bit = value & 0x80,
701                // Outer bank select / CHR block / MMC4 mode.
702                0xD003 => {
703                    self.mirror_chr = (value & 0x80) != 0;
704                    self.chr_block_mode = (value & 0x20) == 0;
705                    self.chr_block = ((value & 0x18) >> 2) | (value & 0x01);
706                }
707                _ => {}
708            }
709        }
710        // A CPU write also clocks the IRQ when the CPU-write source is active.
711        if self.irq_source == IrqSource::CpuWrite {
712            self.tick_irq();
713        }
714    }
715
716    fn ppu_read(&mut self, addr: u16) -> u8 {
717        // The bus masks pattern-table reads to `$0000-$1FFF` before they reach
718        // here; nametable bytes are routed through `nametable_fetch` /
719        // `nametable_address` instead (the PPU owns CIRAM), so this only
720        // services CHR.
721        let addr = addr & 0x1FFF;
722        // PPU-read IRQ source ticks on render reads (pattern fetches).
723        if self.irq_source == IrqSource::PpuRead {
724            self.tick_irq();
725        }
726        self.update_chr_latch_209(addr);
727        let off = self.chr_offset(addr);
728        self.chr[off % self.chr.len()]
729    }
730
731    fn ppu_write(&mut self, addr: u16, value: u8) {
732        // Pattern-table writes only (CHR-RAM). Nametable writes arrive via
733        // `nametable_write`; the bus never routes `$2000+` to `ppu_write`.
734        let addr = addr & 0x1FFF;
735        if self.chr_is_ram {
736            let off = self.chr_offset(addr);
737            let len = self.chr.len();
738            self.chr[off % len] = value;
739        }
740    }
741
742    fn nametable_fetch(&mut self, addr: u16) -> Option<u8> {
743        // Serve a ROM (CHR) nametable byte when ROM nametables are active for
744        // this table; otherwise return `None` so the PPU reads CIRAM (banked
745        // via `nametable_address`).
746        let masked = addr & 0x2FFF;
747        let nt_index = ((masked - 0x2000) / NAMETABLE_SIZE_U16) as usize & 0x03;
748        if self.nt_control_active() && self.nt_index_is_rom(nt_index) {
749            Some(self.nt_rom_byte(masked))
750        } else {
751            None
752        }
753    }
754
755    fn nametable_write(&mut self, addr: u16, _value: u8) -> bool {
756        // ROM nametables are not writable: absorb (drop) the write so the PPU
757        // does not fall through to CIRAM. CIRAM-backed tables return `false`
758        // so the PPU performs its normal banked write.
759        let masked = addr & 0x2FFF;
760        let nt_index = ((masked - 0x2000) / NAMETABLE_SIZE_U16) as usize & 0x03;
761        self.nt_control_active() && self.nt_index_is_rom(nt_index)
762    }
763
764    fn notify_a12(&mut self, level: bool) {
765        // PPU A12-rise IRQ source: tick on the rising edge.
766        if self.irq_source == IrqSource::PpuA12Rise && level && (self.last_ppu_addr & 0x1000) == 0 {
767            self.tick_irq();
768        }
769        self.last_ppu_addr = if level { 0x1000 } else { 0x0000 };
770    }
771
772    fn notify_cpu_cycle(&mut self) {
773        if self.irq_source == IrqSource::CpuClock {
774            self.tick_irq();
775        }
776    }
777
778    fn irq_pending(&self) -> bool {
779        self.irq_pending
780    }
781
782    fn current_mirroring(&self) -> Mirroring {
783        // Mapper-controlled: the PPU defers to `nametable_address`, which uses
784        // the live `$D001` register. Report the closest enum for the UI.
785        match self.mirroring_reg & 0x03 {
786            0 => Mirroring::Vertical,
787            1 => Mirroring::Horizontal,
788            2 => Mirroring::SingleScreenA,
789            _ => Mirroring::SingleScreenB,
790        }
791    }
792
793    fn nametable_address(&self, addr: u16) -> u16 {
794        (self.nametable_offset(addr) & 0x07FF) as u16
795    }
796
797    fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
798        let mut info = crate::mapper::MapperDebugInfo {
799            mapper_id: self.board.mapper_id(),
800            name: match self.board {
801                JyBoard::M90 => "J.Y. Company ASIC (90)".into(),
802                JyBoard::M209 => "J.Y. Company ASIC (209)".into(),
803                JyBoard::M211 => "J.Y. Company ASIC (211)".into(),
804                JyBoard::M35 => "J.Y. Company ASIC (35)".into(),
805            },
806            mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
807            ..Default::default()
808        };
809        for (i, b) in self.prg_regs.iter().enumerate() {
810            info.prg_banks.push((format!("P{i}"), format!("{b:#04x}")));
811        }
812        info.prg_banks
813            .push(("mode".into(), format!("{:#04x}", self.prg_mode)));
814        for i in 0..8 {
815            info.chr_banks
816                .push((format!("C{i}"), format!("{:#06x}", self.chr_reg(i))));
817        }
818        info.chr_banks
819            .push(("mode".into(), format!("{}", self.chr_mode)));
820        info.irq_state
821            .push(("source".into(), format!("{:?}", self.irq_source)));
822        info.irq_state
823            .push(("prescaler".into(), format!("{:#04x}", self.irq_prescaler)));
824        info.irq_state
825            .push(("counter".into(), format!("{:#04x}", self.irq_counter)));
826        info.irq_state
827            .push(("enabled".into(), format!("{}", self.irq_enabled)));
828        info.irq_state
829            .push(("pending".into(), format!("{}", self.irq_pending)));
830        info.extra
831            .push(("ntROM".into(), format!("{}", self.nt_control_active())));
832        info
833    }
834
835    fn save_state(&self) -> Vec<u8> {
836        let mut out = Vec::with_capacity(64 + if self.chr_is_ram { self.chr.len() } else { 0 });
837        out.push(SAVE_STATE_VERSION);
838        out.extend_from_slice(&self.prg_regs);
839        out.extend_from_slice(&self.chr_low_regs);
840        out.extend_from_slice(&self.chr_high_regs);
841        out.extend_from_slice(&self.nt_low_regs);
842        out.extend_from_slice(&self.nt_high_regs);
843        out.extend_from_slice(&self.chr_latch);
844        out.push(self.prg_mode);
845        out.push(u8::from(self.enable_prg_at_6000));
846        out.push(self.chr_mode);
847        out.push(u8::from(self.advanced_nt_control));
848        out.push(u8::from(self.nt_global));
849        out.push(self.mirroring_reg);
850        out.push(u8::from(self.extended_mirroring));
851        out.push(self.nt_ram_select_bit);
852        out.push(u8::from(self.chr_block_mode));
853        out.push(self.chr_block);
854        out.push(u8::from(self.mirror_chr));
855        out.push(u8::from(self.irq_enabled));
856        out.push(self.irq_source.to_bits());
857        out.push(self.irq_count_direction);
858        out.push(u8::from(self.irq_small_prescaler));
859        out.push(self.irq_prescaler);
860        out.push(self.irq_counter);
861        out.push(self.irq_xor);
862        out.push(self.irq_funky_reg);
863        out.push(u8::from(self.irq_pending));
864        out.push(self.mul1);
865        out.push(self.mul2);
866        out.push(self.test_reg);
867        out.extend_from_slice(&self.last_ppu_addr.to_le_bytes());
868        if self.chr_is_ram {
869            out.extend_from_slice(&self.chr);
870        }
871        out
872    }
873
874    fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
875        let chr_part = if self.chr_is_ram { self.chr.len() } else { 0 };
876        // 1 (ver) + 4 + 8 + 8 + 4 + 4 + 2 (latch) + scalars(23) + 2 (last addr).
877        // The 23 scalars: 11 mode/mirroring flags + 9 IRQ fields + 3 misc regs.
878        let scalar_len = 1 + 4 + 8 + 8 + 4 + 4 + 2 + 23 + 2;
879        let expected = scalar_len + chr_part;
880        if data.len() != expected {
881            return Err(MapperError::WrongLength {
882                expected,
883                got: data.len(),
884            });
885        }
886        if data[0] != SAVE_STATE_VERSION {
887            return Err(MapperError::UnsupportedVersion(data[0]));
888        }
889        let mut c = 1usize;
890        let mut take = |n: usize| {
891            let s = &data[c..c + n];
892            c += n;
893            s
894        };
895        self.prg_regs.copy_from_slice(take(4));
896        self.chr_low_regs.copy_from_slice(take(8));
897        self.chr_high_regs.copy_from_slice(take(8));
898        self.nt_low_regs.copy_from_slice(take(4));
899        self.nt_high_regs.copy_from_slice(take(4));
900        self.chr_latch.copy_from_slice(take(2));
901        // The CHR latch indexes the 8-entry `chr_low_regs`/`chr_high_regs`
902        // groups (via `chr_reg`) in CHR mode 1, so a corrupted/hand-edited
903        // save-state must not be able to push it out of range. In normal
904        // operation the latch is only ever {0,2,4,6} (init {0,4}), all < 8.
905        self.chr_latch[0] &= 0x07;
906        self.chr_latch[1] &= 0x07;
907        self.prg_mode = take(1)[0];
908        self.enable_prg_at_6000 = take(1)[0] != 0;
909        self.chr_mode = take(1)[0];
910        self.advanced_nt_control = take(1)[0] != 0;
911        self.nt_global = take(1)[0] != 0;
912        self.mirroring_reg = take(1)[0];
913        self.extended_mirroring = take(1)[0] != 0;
914        self.nt_ram_select_bit = take(1)[0];
915        self.chr_block_mode = take(1)[0] != 0;
916        self.chr_block = take(1)[0];
917        self.mirror_chr = take(1)[0] != 0;
918        self.irq_enabled = take(1)[0] != 0;
919        self.irq_source = IrqSource::from_bits(take(1)[0]);
920        self.irq_count_direction = take(1)[0];
921        self.irq_small_prescaler = take(1)[0] != 0;
922        self.irq_prescaler = take(1)[0];
923        self.irq_counter = take(1)[0];
924        self.irq_xor = take(1)[0];
925        self.irq_funky_reg = take(1)[0];
926        self.irq_pending = take(1)[0] != 0;
927        self.mul1 = take(1)[0];
928        self.mul2 = take(1)[0];
929        self.test_reg = take(1)[0];
930        let la = take(2);
931        self.last_ppu_addr = u16::from_le_bytes([la[0], la[1]]);
932        if self.chr_is_ram {
933            let n = self.chr.len();
934            self.chr.copy_from_slice(take(n));
935        }
936        Ok(())
937    }
938}
939
940#[cfg(test)]
941#[allow(clippy::cast_possible_truncation)]
942mod tests {
943    use super::*;
944
945    fn synth_prg(banks_8k: usize) -> Box<[u8]> {
946        let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
947        for b in 0..banks_8k {
948            v[b * PRG_BANK_8K] = b as u8;
949        }
950        v.into_boxed_slice()
951    }
952
953    fn synth_chr(banks_1k: usize) -> Box<[u8]> {
954        let mut v = vec![0u8; banks_1k * CHR_BANK_1K];
955        for b in 0..banks_1k {
956            v[b * CHR_BANK_1K] = b as u8;
957        }
958        v.into_boxed_slice()
959    }
960
961    fn fresh(board: JyBoard) -> JyAsic {
962        // 64 * 8 KiB = 512 KiB PRG so $3F resolves to a distinct bank;
963        // 256 * 1 KiB = 256 KiB CHR.
964        JyAsic::new(synth_prg(64), synth_chr(256), Mirroring::Vertical, board).unwrap()
965    }
966
967    #[test]
968    fn prg_mode2_8k_banks_select_each_window() {
969        let mut m = fresh(JyBoard::M209);
970        m.cpu_write(0xD000, 0x02); // PRG mode 2 (8 KiB), last bank fixed.
971        m.cpu_write(0x8000, 5);
972        m.cpu_write(0x8001, 6);
973        m.cpu_write(0x8002, 7);
974        assert_eq!(m.cpu_read(0x8000), 5);
975        assert_eq!(m.cpu_read(0xA000), 6);
976        assert_eq!(m.cpu_read(0xC000), 7);
977        // Last window fixed to $3F when $D000 bit 2 clear.
978        assert_eq!(m.cpu_read(0xE000), 0x3F);
979    }
980
981    #[test]
982    fn prg_mode2_last_bank_switchable() {
983        let mut m = fresh(JyBoard::M209);
984        m.cpu_write(0xD000, 0x06); // mode 2 + switchable last bank.
985        m.cpu_write(0x8003, 9);
986        assert_eq!(m.cpu_read(0xE000), 9);
987    }
988
989    #[test]
990    fn prg_mode0_32k_uses_fixed_3c_window() {
991        let mut m = fresh(JyBoard::M209);
992        m.cpu_write(0xD000, 0x00); // 32 KiB, hard-wired to $3C..$3F.
993        assert_eq!(m.cpu_read(0x8000), 0x3C);
994        assert_eq!(m.cpu_read(0xA000), 0x3D);
995        assert_eq!(m.cpu_read(0xC000), 0x3E);
996        assert_eq!(m.cpu_read(0xE000), 0x3F);
997    }
998
999    #[test]
1000    fn prg_mode1_16k_low_and_high_windows() {
1001        let mut m = fresh(JyBoard::M209);
1002        m.cpu_write(0xD000, 0x01); // 16 KiB, high fixed to $3E/$3F.
1003        m.cpu_write(0x8001, 2); // low 16 KiB = banks (2<<1) = 4,5.
1004        assert_eq!(m.cpu_read(0x8000), 4);
1005        assert_eq!(m.cpu_read(0xA000), 5);
1006        assert_eq!(m.cpu_read(0xC000), 0x3E);
1007        assert_eq!(m.cpu_read(0xE000), 0x3F);
1008    }
1009
1010    #[test]
1011    fn prg_bits_reversed_in_mode3() {
1012        let mut m = fresh(JyBoard::M209);
1013        m.cpu_write(0xD000, 0x03); // mode 3 = mode 2 with reversed bank bits.
1014        // reg value 0x01 -> reversed -> 0x40.
1015        m.cpu_write(0x8000, 0x01);
1016        assert_eq!(m.cpu_read(0x8000), 0x40 % 64);
1017    }
1018
1019    #[test]
1020    fn prg_mode3_bit3_matches_mesen2() {
1021        // Verify the Mesen2-matching reversal (bits 0<->6, 1<->5, 2<->4) and
1022        // pin the documented bit-3 behaviour: Mesen2's `InvertPrgBits` drops
1023        // bit 3 (0x08) rather than carrying it through the centre. A bare
1024        // 0x08 input therefore reverses to 0x00.
1025        assert_eq!(JyAsic::invert_prg_bits(0x08, true), 0x00);
1026        // Bit pairs are swapped as documented.
1027        assert_eq!(JyAsic::invert_prg_bits(0x01, true), 0x40); // bit0 -> bit6
1028        assert_eq!(JyAsic::invert_prg_bits(0x40, true), 0x01); // bit6 -> bit0
1029        assert_eq!(JyAsic::invert_prg_bits(0x02, true), 0x20); // bit1 -> bit5
1030        assert_eq!(JyAsic::invert_prg_bits(0x04, true), 0x10); // bit2 -> bit4
1031        // A value with bit 3 set alongside others keeps the reversed pairs but
1032        // still discards bit 3 (0x09 = bit0|bit3 -> 0x40, the bit-3 part lost).
1033        assert_eq!(JyAsic::invert_prg_bits(0x09, true), 0x40);
1034        // No-invert is the identity.
1035        assert_eq!(JyAsic::invert_prg_bits(0x7F, false), 0x7F);
1036    }
1037
1038    #[test]
1039    fn prg_register_decode_ignores_a2_accepts_a11() {
1040        // Mesen2 decodes $8000-$FFFF with `addr & 0xF007`: A2 is ignored (so
1041        // $8004 aliases $8000) and A11 is NOT masked (so $8800 is still a
1042        // register write, unlike the wiki's stricter $F803).
1043        let mut m = fresh(JyBoard::M209);
1044        m.cpu_write(0xD000, 0x02); // PRG mode 2 (8 KiB windows).
1045        m.cpu_write(0x8004, 5); // A2 set -> aliases $8000.
1046        assert_eq!(m.cpu_read(0x8000), 5, "$8004 must alias the $8000 register");
1047        // A11 set ($8800) also decodes to the $8000 register under $F007.
1048        m.cpu_write(0x8800, 9);
1049        assert_eq!(
1050            m.cpu_read(0x8000),
1051            9,
1052            "$8800 must still write the $8000 reg"
1053        );
1054    }
1055
1056    #[test]
1057    fn prg_at_6000_when_enabled() {
1058        let mut m = fresh(JyBoard::M209);
1059        m.cpu_write(0xD000, 0x82); // mode 2 + enable PRG @ $6000.
1060        m.cpu_write(0x8003, 4);
1061        assert!(!m.cpu_read_unmapped(0x6000));
1062        assert_eq!(m.cpu_read(0x6000), 4);
1063    }
1064
1065    #[test]
1066    fn chr_8k_mode() {
1067        let mut m = fresh(JyBoard::M209);
1068        m.cpu_write(0xD000, 0x00); // CHR mode 0 (8 KiB).
1069        m.cpu_write(0x9000, 3); // reg0 low = 3 -> 8 KiB window from 1k bank 24.
1070        assert_eq!(m.ppu_read(0x0000), 24);
1071        assert_eq!(m.ppu_read(0x0400), 25);
1072    }
1073
1074    #[test]
1075    fn chr_1k_mode_each_slot() {
1076        let mut m = fresh(JyBoard::M209);
1077        m.cpu_write(0xD000, 0x18); // CHR mode 3 (1 KiB).
1078        m.cpu_write(0x9000, 10);
1079        m.cpu_write(0x9007, 20);
1080        assert_eq!(m.ppu_read(0x0000), 10);
1081        assert_eq!(m.ppu_read(0x1C00), 20);
1082    }
1083
1084    #[test]
1085    fn chr_high_byte_extends_bank() {
1086        let mut m = fresh(JyBoard::M209);
1087        m.cpu_write(0xD000, 0x18); // 1 KiB.
1088        m.cpu_write(0x9000, 0x05);
1089        m.cpu_write(0xA000, 0x01); // high byte -> bank 0x105 = 261, masked to 256.
1090        assert_eq!(m.ppu_read(0x0000), (0x105 % 256) as u8);
1091    }
1092
1093    #[test]
1094    fn chr_block_mode_outer_bank() {
1095        let mut m = fresh(JyBoard::M209);
1096        m.cpu_write(0xD000, 0x18); // 1 KiB.
1097        // $D003: chr_block_mode is ON when bit 5 == 0; set chr_block bits.
1098        m.cpu_write(0xD003, 0x01); // chr_block = ((0&0x18)>>2)|1 = 1.
1099        m.cpu_write(0x9000, 0x00);
1100        // mode 3 -> mask 0xFF, shift 8 -> bank = 0 | (1<<8) = 256, masked -> 0.
1101        assert_eq!(m.ppu_read(0x0000), 0);
1102    }
1103
1104    #[test]
1105    fn mirroring_register_select() {
1106        let mut m = fresh(JyBoard::M90);
1107        m.cpu_write(0xD001, 0x00);
1108        assert_eq!(m.current_mirroring(), Mirroring::Vertical);
1109        m.cpu_write(0xD001, 0x01);
1110        assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
1111        m.cpu_write(0xD001, 0x02);
1112        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
1113        m.cpu_write(0xD001, 0x03);
1114        assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
1115    }
1116
1117    #[test]
1118    fn mapper90_inhibits_rom_nametables() {
1119        let mut m = fresh(JyBoard::M90);
1120        // Enable ROM nametables globally via $D000; mapper 90 ignores it.
1121        m.cpu_write(0xD000, 0x60); // bit 5 (NT ROM) + bit 6 (global).
1122        assert!(!m.nt_control_active());
1123    }
1124
1125    #[test]
1126    fn mapper211_forces_rom_nametables() {
1127        let m = fresh(JyBoard::M211);
1128        // No register write needed; 211 always has the feature on.
1129        assert!(m.nt_control_active());
1130    }
1131
1132    #[test]
1133    fn mapper209_rom_nametable_global_reads_chr() {
1134        let mut m = fresh(JyBoard::M209);
1135        m.cpu_write(0xD000, 0x60); // NT ROM + global.
1136        m.cpu_write(0xB000, 7); // NT0 -> CHR 1k page 7.
1137        // ROM nametables are served via `nametable_fetch` (the PPU's
1138        // nametable path), NOT `ppu_read` (which only handles $0000-$1FFF).
1139        // Page 7 in synth CHR has its first byte == 7.
1140        assert_eq!(m.nametable_fetch(0x2000), Some(7));
1141    }
1142
1143    #[test]
1144    fn mapper209_ciram_nametable_returns_none() {
1145        // With ROM nametables off, `nametable_fetch` must decline so the PPU
1146        // reads its own CIRAM (banked via `nametable_address`).
1147        let mut m = fresh(JyBoard::M209);
1148        assert_eq!(m.nametable_fetch(0x2000), None);
1149    }
1150
1151    #[test]
1152    fn extended_mirroring_selects_per_nt_ciram_page() {
1153        // $D001 bit 3 enables Extended Mirroring: each nametable's CIRAM page
1154        // comes from $B00x bit 0. Verify `nametable_address` honours it (it
1155        // previously always used the MM register -> the field was inert).
1156        let mut m = fresh(JyBoard::M209);
1157        m.cpu_write(0xD001, 0x08); // Extended Mirroring on, MM bits = 0.
1158        assert!(m.nt_control_active());
1159        // All four B-regs bit 0 = 0 -> every table maps to CIRAM page 0.
1160        for table in 0..4u16 {
1161            let addr = 0x2000 + table * 0x400;
1162            assert_eq!(
1163                m.nametable_address(addr) & 0x0400,
1164                0,
1165                "table {table} page 0"
1166            );
1167        }
1168        // Set $B001 / $B003 bit 0 -> tables 1 and 3 move to CIRAM page 1.
1169        m.cpu_write(0xB001, 0x01);
1170        m.cpu_write(0xB003, 0x01);
1171        assert_eq!(m.nametable_address(0x2000) & 0x0400, 0x000); // NT0 -> page 0
1172        assert_eq!(m.nametable_address(0x2400) & 0x0400, 0x400); // NT1 -> page 1
1173        assert_eq!(m.nametable_address(0x2800) & 0x0400, 0x000); // NT2 -> page 0
1174        assert_eq!(m.nametable_address(0x2C00) & 0x0400, 0x400); // NT3 -> page 1
1175    }
1176
1177    #[test]
1178    fn extended_mirroring_off_uses_mm_register() {
1179        // With Extended Mirroring / advanced-NT control off, the MM register
1180        // drives CIRAM mapping (vertical here): NT0/NT2 -> page 0/0... actually
1181        // vertical maps tables 0,2 -> A and 1,3 -> B.
1182        let mut m = fresh(JyBoard::M209);
1183        m.cpu_write(0xD001, 0x00); // MM = 0 (vertical), Extended Mirroring off.
1184        assert!(!m.nt_control_active());
1185        assert_eq!(m.nametable_address(0x2000) & 0x0400, 0x000); // table 0 -> A
1186        assert_eq!(m.nametable_address(0x2400) & 0x0400, 0x400); // table 1 -> B
1187        assert_eq!(m.nametable_address(0x2800) & 0x0400, 0x000); // table 2 -> A
1188        assert_eq!(m.nametable_address(0x2C00) & 0x0400, 0x400); // table 3 -> B
1189    }
1190
1191    #[test]
1192    fn rom_nametable_write_is_absorbed() {
1193        // A write to a ROM nametable must be absorbed (drop), so the PPU does
1194        // not also touch CIRAM; CIRAM-backed tables decline the write.
1195        let mut m = fresh(JyBoard::M209);
1196        m.cpu_write(0xD000, 0x60); // ROM nametables, global.
1197        assert!(m.nametable_write(0x2000, 0x42)); // ROM NT -> absorbed.
1198        let mut m2 = fresh(JyBoard::M209); // no ROM nametables.
1199        assert!(!m2.nametable_write(0x2000, 0x42)); // CIRAM NT -> PPU handles.
1200    }
1201
1202    #[test]
1203    fn irq_cpu_clock_increment_wraps_to_assert() {
1204        let mut m = fresh(JyBoard::M209);
1205        m.cpu_write(0xC006, 0x00); // XOR = 0.
1206        m.cpu_write(0xC005, 0xFF); // counter = 0xFF.
1207        m.cpu_write(0xC004, 0xFF); // prescaler = 0xFF (small mask off -> $FF).
1208        // $C001: direction increment (bit 6 set), source CPU clock (bits 0-1=0),
1209        // small prescaler off.
1210        m.cpu_write(0xC001, 0x40);
1211        m.cpu_write(0xC000, 0x01); // enable.
1212        // First tick: prescaler 0xFF -> 0x00 wraps, counter 0xFF -> 0x00 wraps.
1213        m.notify_cpu_cycle();
1214        assert!(m.irq_pending());
1215    }
1216
1217    #[test]
1218    fn irq_decrement_underflow_asserts() {
1219        let mut m = fresh(JyBoard::M209);
1220        m.cpu_write(0xC006, 0x00);
1221        m.cpu_write(0xC005, 0x00); // counter = 0.
1222        m.cpu_write(0xC004, 0x00); // prescaler = 0.
1223        m.cpu_write(0xC001, 0x80); // direction decrement (bits 6-7 = 0b10).
1224        m.cpu_write(0xC000, 0x01); // enable.
1225        // prescaler 0 -> 0xFF (mask wrap) clocks counter 0 -> 0xFF -> assert.
1226        m.notify_cpu_cycle();
1227        assert!(m.irq_pending());
1228    }
1229
1230    #[test]
1231    fn irq_small_prescaler_mask() {
1232        let mut m = fresh(JyBoard::M209);
1233        m.cpu_write(0xC006, 0x00);
1234        m.cpu_write(0xC005, 0xFF); // counter at top.
1235        m.cpu_write(0xC004, 0x07); // prescaler low 3 bits = 7.
1236        // small prescaler (bit 2) + increment direction + CPU clock source.
1237        m.cpu_write(0xC001, 0x44);
1238        m.cpu_write(0xC000, 0x01);
1239        m.notify_cpu_cycle(); // prescaler 7 -> 0 wraps (mask 0x07), counter wraps.
1240        assert!(m.irq_pending());
1241    }
1242
1243    #[test]
1244    fn irq_xor_applied_to_counter_and_prescaler() {
1245        let mut m = fresh(JyBoard::M209);
1246        m.cpu_write(0xC006, 0x0F); // XOR.
1247        m.cpu_write(0xC005, 0xF0); // counter = 0xF0 ^ 0x0F = 0xFF.
1248        m.cpu_write(0xC001, 0x40); // increment, CPU clock.
1249        m.cpu_write(0xC004, 0xF0); // prescaler = 0xF0 ^ 0x0F = 0xFF.
1250        m.cpu_write(0xC000, 0x01);
1251        m.notify_cpu_cycle();
1252        assert!(m.irq_pending());
1253    }
1254
1255    #[test]
1256    fn irq_disable_acknowledges_and_resets_prescaler() {
1257        let mut m = fresh(JyBoard::M209);
1258        m.irq_pending = true;
1259        m.irq_prescaler = 0x42;
1260        m.cpu_write(0xC000, 0x00); // disable -> ack + reset prescaler.
1261        assert!(!m.irq_pending());
1262        assert_eq!(m.irq_prescaler, 0);
1263        m.irq_pending = true;
1264        m.cpu_write(0xC002, 0x00); // explicit disable.
1265        assert!(!m.irq_pending());
1266    }
1267
1268    #[test]
1269    fn irq_source_cpu_write_ticks_on_write() {
1270        let mut m = fresh(JyBoard::M209);
1271        m.cpu_write(0xC006, 0x00);
1272        // source = CPU writes (bits 0-1 = 3), increment direction. Every CPU
1273        // write (including register writes) clocks the prescaler while the
1274        // CPU-write source is active; capture the prescaler immediately before
1275        // a plain data write and confirm that write advances it by one.
1276        m.cpu_write(0xC001, 0x43);
1277        m.cpu_write(0xC000, 0x01); // enable.
1278        let before = m.irq_prescaler;
1279        m.cpu_write(0x5803, 0x00); // a data write -> one prescaler increment.
1280        assert_eq!(m.irq_prescaler, before.wrapping_add(1));
1281    }
1282
1283    #[test]
1284    fn irq_source_cpu_clock_does_not_tick_on_write() {
1285        let mut m = fresh(JyBoard::M209);
1286        m.cpu_write(0xC006, 0x00);
1287        m.cpu_write(0xC001, 0x40); // increment, source = CPU clock (not write).
1288        m.cpu_write(0xC000, 0x01);
1289        let before = m.irq_prescaler;
1290        m.cpu_write(0x5803, 0x00); // a data write must NOT tick (wrong source).
1291        assert_eq!(m.irq_prescaler, before);
1292    }
1293
1294    #[test]
1295    fn irq_a12_rise_source_ticks_on_rising_edge() {
1296        let mut m = fresh(JyBoard::M209);
1297        m.cpu_write(0xC006, 0x00);
1298        m.cpu_write(0xC005, 0xFF);
1299        m.cpu_write(0xC004, 0xFF);
1300        m.cpu_write(0xC001, 0x41); // increment, source A12 rise.
1301        m.cpu_write(0xC000, 0x01);
1302        m.notify_a12(false); // low first.
1303        assert!(!m.irq_pending());
1304        m.notify_a12(true); // rising edge -> tick -> assert.
1305        assert!(m.irq_pending());
1306    }
1307
1308    #[test]
1309    fn mapper209_mmc4_latch_switches_chr() {
1310        let mut m = fresh(JyBoard::M209);
1311        m.cpu_write(0xD000, 0x08); // CHR mode 1 (4 KiB).
1312        // A fetch at $0FE8 should set window-0 latch.
1313        m.ppu_read(0x0FE8);
1314        // chr_latch[0] = (0x0FE8>>4) & ((0x0FE8>>10 & 4)|2)
1315        //             = 0xFE & ((3 & 4)|2) = 0xFE & 2 = 2.
1316        assert_eq!(m.chr_latch[0], 2);
1317    }
1318
1319    #[test]
1320    fn mapper90_no_mmc4_latch() {
1321        let mut m = fresh(JyBoard::M90);
1322        let before = m.chr_latch;
1323        m.ppu_read(0x0FE8);
1324        assert_eq!(m.chr_latch, before); // mapper 90 doesn't auto-latch.
1325    }
1326
1327    #[test]
1328    fn multiplier_register() {
1329        let mut m = fresh(JyBoard::M209);
1330        m.cpu_write(0x5800, 6);
1331        m.cpu_write(0x5801, 7);
1332        assert_eq!(m.cpu_read(0x5800), 42); // 6 * 7 = 42, LSB.
1333        assert_eq!(m.cpu_read(0x5801), 0); // MSB.
1334        m.cpu_write(0x5800, 0xFF);
1335        m.cpu_write(0x5801, 0xFF);
1336        assert_eq!(m.cpu_read(0x5800), 0x01); // 0xFE01 LSB.
1337        assert_eq!(m.cpu_read(0x5801), 0xFE); // MSB.
1338    }
1339
1340    #[test]
1341    fn test_register_read_write() {
1342        let mut m = fresh(JyBoard::M209);
1343        m.cpu_write(0x5803, 0x5A);
1344        assert_eq!(m.cpu_read(0x5803), 0x5A);
1345    }
1346
1347    #[test]
1348    fn save_state_round_trip() {
1349        for board in [JyBoard::M90, JyBoard::M209, JyBoard::M211] {
1350            let mut m = fresh(board);
1351            m.cpu_write(0xD000, 0x9A); // mode bits + NT-ROM + PRG@6000.
1352            m.cpu_write(0x8000, 3);
1353            m.cpu_write(0x8003, 9);
1354            m.cpu_write(0x9000, 0x11);
1355            m.cpu_write(0xA000, 0x01);
1356            m.cpu_write(0xB000, 4);
1357            m.cpu_write(0xB004, 1);
1358            m.cpu_write(0xD001, 0x09); // extended mirroring + MM.
1359            m.cpu_write(0xD003, 0xA1);
1360            m.cpu_write(0xC006, 0x0F);
1361            m.cpu_write(0xC005, 0x12);
1362            m.cpu_write(0xC004, 0x34);
1363            m.cpu_write(0xC001, 0x41);
1364            m.cpu_write(0xC000, 0x01);
1365            m.ppu_read(0x0FE8); // move the latch.
1366
1367            let blob = m.save_state();
1368            let mut m2 = fresh(board);
1369            m2.load_state(&blob).unwrap();
1370
1371            assert_eq!(m.cpu_read(0x8000), m2.cpu_read(0x8000));
1372            assert_eq!(m.cpu_read(0xE000), m2.cpu_read(0xE000));
1373            assert_eq!(m.ppu_read(0x0000), m2.ppu_read(0x0000));
1374            assert_eq!(m.chr_latch, m2.chr_latch);
1375            assert_eq!(m.irq_counter, m2.irq_counter);
1376            assert_eq!(m.irq_prescaler, m2.irq_prescaler);
1377            assert_eq!(m.current_mirroring(), m2.current_mirroring());
1378            assert_eq!(m.test_reg, m2.test_reg);
1379        }
1380    }
1381
1382    #[test]
1383    fn save_state_rejects_bad_version() {
1384        let mut m = fresh(JyBoard::M209);
1385        let mut blob = m.save_state();
1386        blob[0] = 0xFF;
1387        assert!(matches!(
1388            m.load_state(&blob),
1389            Err(MapperError::UnsupportedVersion(0xFF))
1390        ));
1391    }
1392
1393    #[test]
1394    fn save_state_rejects_truncated() {
1395        let mut m = fresh(JyBoard::M209);
1396        let blob = m.save_state();
1397        assert!(matches!(
1398            m.load_state(&blob[..blob.len() - 1]),
1399            Err(MapperError::WrongLength { .. })
1400        ));
1401    }
1402
1403    #[test]
1404    fn load_state_clamps_chr_latch() {
1405        // A corrupted/hand-edited save-state must not be able to push the CHR
1406        // latch past 7 (it indexes the 8-entry CHR register groups in CHR mode
1407        // 1). Inject out-of-range latch bytes and confirm `load_state` masks
1408        // them to 0..=7 so a subsequent CHR fetch cannot panic.
1409        let mut m = fresh(JyBoard::M209);
1410        let mut blob = m.save_state();
1411        // Latch bytes sit right after prg(4)+chrLow(8)+chrHigh(8)+ntLow(4)
1412        // +ntHigh(4) = 28 scalars past the 1-byte version header.
1413        let latch_off = 1 + 4 + 8 + 8 + 4 + 4;
1414        blob[latch_off] = 0xFF;
1415        blob[latch_off + 1] = 0xFE;
1416        m.load_state(&blob).unwrap();
1417        assert!(m.chr_latch[0] < 8);
1418        assert!(m.chr_latch[1] < 8);
1419        assert_eq!(m.chr_latch[0], 0x07);
1420        assert_eq!(m.chr_latch[1], 0x06);
1421        // CHR mode 1 fetch through the (now-clamped) latch must not panic.
1422        m.cpu_write(0xD000, 0x08); // CHR mode 1 (4 KiB, MMC4 latch path).
1423        let _ = m.ppu_read(0x0000);
1424        let _ = m.ppu_read(0x1000);
1425    }
1426}