cge_nes 0.1.2

Cycle-accurate NES (Nintendo Entertainment System) emulator library: CPU, PPU, cartridge, input, and iNES ROM loading.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
//! Basic mapper implementations for NROM (0), UxROM (2), and CNROM (3).
//!
//! # Memory Map
//!
//! CPU Memory Map:
//! - `$6000-$7FFF`: PRG RAM bank (if available)
//! - `$8000-$BFFF`: First PRG ROM bank (switchable in mapper 2)
//! - `$C000-$FFFF`: Last PRG ROM bank (fixed)
//!
//! PPU Memory Map:
//! - `$0000-$1FFF`: Pattern Tables (CHR ROM/RAM)
//! - `$2000-$3EFF`: Name Tables (configurable mirroring)
//!
//! # Mapper Variants
//!
//! ## NROM (Mapper 0)
//! - No bank switching
//! - 16KB or 32KB fixed PRG ROM
//! - 8KB fixed CHR ROM or RAM
//!
//! ## UxROM (Mapper 2)
//! - 16KB fixed + 16KB switchable PRG ROM banks
//! - Bank selection by writing to $8000-$FFFF
//! - 8KB fixed CHR RAM
//!
//! ## CNROM (Mapper 3)
//! - 32KB fixed PRG ROM
//! - 8KB switchable CHR ROM banks
//! - Bank selection by writing to $8000-$FFFF

use crate::cartridge::{Cartridge, ChrRomContentStatus};
use crate::rom_loader::ines::mappers::{
    NameTableRam, CART_CPU_MAP_BEGIN_ADDR, LAST_UNREACHABLE_ADDRESS,
};
use crate::rom_loader::ines::{HeaderData, Mirroring};
use crate::{LoadRomResult, RomError};
use devices6502::size_const::*;
use devices6502::*;
use std::io;

/// Size of each PRG ROM bank in bytes
const PRG_ROM_PAGE_SIZE: usize = SIZE_16K;
/// Size of each CHR ROM/RAM bank in bytes
const CHR_MEM_PAGE_SIZE: usize = SIZE_8K;
/// Number of CHR RAM banks when no CHR ROM is present
const CHR_RAM_NUM_PAGES: usize = 2;
/// Maximum number of PRG ROM banks supported (256KB total)
const MAX_PRG_ROM_PAGES: usize = 16;
/// Maximum number of CHR ROM banks supported
const MAX_CHR_ROM_PAGES: usize = u8::MAX as usize;

/// Basic mapper implementation supporting NROM (0), UxROM (2), and CNROM (3) mappers.
///
/// This structure handles memory mapping for the three simplest NES mappers:
/// - NROM (0): No banking, fixed memory layout
/// - UxROM (2): PRG ROM banking only
/// - CNROM (3): CHR ROM banking only
#[derive(Default)]
struct Cart {
    /// Program ROM banks (16KB each)
    prg_rom: Vec<Rom<PRG_ROM_PAGE_SIZE>>,
    /// Character ROM/RAM banks (8KB each)
    chr_mem: Vec<Ram<CHR_MEM_PAGE_SIZE>>,
    /// Name table RAM with configurable mirroring
    name_tables_ram: NameTableRam,
    /// Whether CHR memory is RAM (true) or ROM (false)
    use_chr_ram: bool,
    /// Current bank register value for PRG ROM or CHR ROM switching
    bank_register: u8,
    /// Whether the bank register selects the CHR bank (true for CNROM with CHR
    /// ROM only). NROM, UxROM, and CNROM-with-CHR-RAM keep CHR fixed at bank 0
    /// because their bank register is consumed by PRG banking or is unused.
    chr_banked: bool,
    /// Whether the bank register selects the PRG bank (true for UxROM only).
    /// NROM (with 1 or 2 fixed banks) and CNROM (32KB fixed) do not bank PRG.
    prg_banked: bool,
}

/// Loads ROM data into an appropriate basic mapper implementation.
///
/// # Arguments
///
/// * `header_data` - Parsed iNES header information
/// * `rom_reader` - Reader containing ROM data positioned after the header
///
/// # Returns
///
/// Returns a `Result` containing either:
/// * `Ok(Box<dyn Cartridge>)` - A boxed cartridge implementation for the ROM
/// * `Err(RomError)` - An error if loading fails
///
/// # Errors
///
/// Returns `RomError` if:
/// * ROM data cannot be read
/// * ROM size doesn't match header
/// * Mapper type is invalid for basic implementation
pub fn load(header: &HeaderData, reader: &mut impl io::Read) -> LoadRomResult {
    // Validate CHR ROM size doesn't exceed maximum supported size (8KB * 255 = ~2MB)
    if header.chr_rom_size as usize > (MAX_CHR_ROM_PAGES * CHR_MEM_PAGE_SIZE) {
        return Err(RomError::RomFormat(
            "Wrong chr rom size using basic mapper (000, 002, 003). Up until ~2MB allowed".into(),
        ));
    }

    // Validate CHR ROM size is a multiple of 8KB pages
    if (header.chr_rom_size as usize % CHR_MEM_PAGE_SIZE) != 0 {
        return Err(RomError::RomFormat(
            "Wrong chr rom size using basic mapper (000, 002, 003). It must be a multiple of 8K"
                .into(),
        ));
    }

    // Validate PRG ROM size doesn't exceed maximum supported size (16KB * 16 = 256KB)
    if header.prg_rom_size as usize > (MAX_PRG_ROM_PAGES * PRG_ROM_PAGE_SIZE) {
        return Err(RomError::RomFormat(
            "Wrong prg rom size using basic mapper (000, 002, 003). Up until 256K allowed".into(),
        ));
    }

    // Validate PRG ROM size is a multiple of 16KB pages
    if (header.prg_rom_size as usize % PRG_ROM_PAGE_SIZE) != 0 {
        return Err(RomError::RomFormat(
            "Wrong prg rom size using basic mapper (000, 002, 003). It must be a multiple of 16K"
                .into(),
        ));
    }

    // NROM (mapper 0) is only defined for 16 KB or 32 KB of PRG ROM. Larger
    // headers are either invalid iNES or should be routed to a different
    // mapper (e.g. UxROM). Reject them explicitly so the bank register path
    // (intended for UxROM) isn't reached accidentally by an NROM header.
    if header.mapper == 0 && header.prg_rom_size > (2 * PRG_ROM_PAGE_SIZE) as u64 {
        return Err(RomError::RomFormat(
            "NROM (mapper 0) supports at most 32 KB of PRG ROM (2 x 16 KB banks)".into(),
        ));
    }

    // Allocate buffer for PRG ROM data
    let mut prg_rom_data = Vec::with_capacity(header.prg_rom_size as usize);
    prg_rom_data.resize(header.prg_rom_size as usize, 0u8);

    // Read PRG ROM data from file
    reader.read_exact(prg_rom_data.as_mut_slice())?;

    // Allocate and read CHR ROM data if present
    let mut chr_rom_data = Vec::new();
    chr_rom_data.resize(header.chr_rom_size as usize, 0u8);
    reader.read_exact(&mut chr_rom_data)?;

    // Convert CHR ROM data to Option based on size
    let chr_rom_data = if header.chr_rom_size == 0 {
        None // No CHR ROM, will use CHR RAM
    } else {
        Some(chr_rom_data.as_slice())
    };

    // Create cartridge with appropriate mirroring mode
    let mut cart = match header.mirroring {
        Mirroring::Horizontal => Box::new(Cart::new_horizontal(
            prg_rom_data.as_slice(),
            chr_rom_data,
            header.mapper,
        )),
        Mirroring::Vertical => Box::new(Cart::new_vertical(
            prg_rom_data.as_slice(),
            chr_rom_data,
            header.mapper,
        )),
        Mirroring::FourScreen => Box::new(Cart::new_four_screen(
            prg_rom_data.as_slice(),
            chr_rom_data,
            header.mapper,
        )),
    };

    // Set CHR memory type (RAM if no ROM present)
    cart.use_chr_ram = header.chr_rom_size == 0;

    Ok(cart)
}

impl Cartridge for Cart {
    fn read_cpu_mapped(&self, addr: u16) -> u8 {
        // Adjust address to include the cartridge memory offset
        let addr = addr + CART_CPU_MAP_BEGIN_ADDR;
        match addr {
            // Addresses before cartridge memory space are invalid
            0x0000..=LAST_UNREACHABLE_ADDRESS => unreachable!(),
            // PRG RAM space (not implemented in basic mappers)
            CART_CPU_MAP_BEGIN_ADDR..=0x7FFF => 0,
            // First PRG ROM bank ($8000-$BFFF)
            0x8000..=0xBFFF => match self.prg_rom.len() {
                0 => unreachable!(), // No PRG ROM present (invalid state)
                1 => unsafe { self.prg_rom.get_unchecked(0).read(addr - 0x8000) }, // NROM-128: mirror single bank
                2..=MAX_PRG_ROM_PAGES => {
                    // NROM-256 / CNROM: fixed bank 0.
                    // UxROM: switchable bank selected by `bank_register`.
                    let bank = if self.prg_banked {
                        self.bank_register as usize % self.prg_rom.len()
                    } else {
                        0
                    };
                    unsafe { self.prg_rom.get_unchecked(bank).read(addr - 0x8000) }
                }
                _ => unreachable!(),
            },
            // Second PRG ROM bank ($C000-$FFFF)
            0xC000..=0xFFFF => match self.prg_rom.len() {
                0 => unreachable!(), // No PRG ROM present (invalid state)
                // Always use last bank for upper 16KB
                _ => unsafe { self.prg_rom.last().unwrap_unchecked().read(addr - 0xC000) },
            },
        }
    }

    fn write_cpu_mapped(&mut self, data: u8, addr: u16) -> ChrRomContentStatus {
        // Adjust address to include the cartridge memory offset
        let addr = addr + CART_CPU_MAP_BEGIN_ADDR;
        match addr {
            // Addresses before cartridge memory space are invalid
            0x0000..=LAST_UNREACHABLE_ADDRESS => unreachable!(),
            // PRG RAM space (writes ignored in basic mappers)
            CART_CPU_MAP_BEGIN_ADDR..=0x7FFF => (),
            // Bank switching register (UxROM and CNROM)
            //
            // Bus conflicts (real UxROM / CNROM boards present the existing
            // PRG ROM byte on the data bus during writes, so the effective
            // written value is `(data & prg_rom_byte)`) are intentionally not
            // emulated: storing `data` directly produces the intended bank
            // for any cartridge image that was authored to work around the
            // conflict.
            0x8000..=0xFFFF => {
                self.bank_register = data;
            }
        };

        ChrRomContentStatus::Unchanged
    }

    fn read_ppu_mapped(&mut self, addr: u16) -> u8 {
        match addr {
            // Pattern Table access ($0000-$1FFF)
            0x0000..=0x1FFF => {
                // CHR bank is the bank register only for CNROM with CHR ROM.
                // NROM and UxROM use the bank register for PRG ROM banking, so
                // their CHR access must always hit the fixed (bank 0) region.
                let bank = if !self.use_chr_ram && self.chr_banked {
                    self.bank_register as usize
                } else {
                    0
                };
                let bank = bank % self.chr_mem.len();
                unsafe { self.chr_mem.get_unchecked(bank).read(addr) }
            }
            // Name Table access ($2000-$3EFF)
            0x2000..=0x3EFF => match &self.name_tables_ram {
                // Handle mirroring based on cartridge configuration
                NameTableRam::Horizontal(name_tables_ram) => name_tables_ram.read(addr - 0x2000),
                NameTableRam::Vertical(name_tables_ram) => name_tables_ram.read(addr - 0x2000),
                NameTableRam::FourScreens(name_tables_ram) => name_tables_ram.read(addr - 0x2000),
            },
            _ => unreachable!(),
        }
    }

    fn write_ppu_mapped(&mut self, data: u8, addr: u16) -> ChrRomContentStatus {
        match addr {
            // Pattern Table access ($0000-$1FFF)
            0x0000..=0x1FFF => {
                // Only allow writes if using CHR RAM
                if self.use_chr_ram == true {
                    // CHR RAM is not bank-switched: writes always go to bank 0,
                    // regardless of the bank register. Otherwise PRG-ROM-only
                    // mappers like UxROM would lose CHR data as soon as the
                    // game switches its PRG bank.
                    let bank = 0;
                    unsafe {
                        self.chr_mem.get_unchecked_mut(bank).write(data, addr);
                    }
                }
            }
            // Name Table access ($2000-$3EFF)
            0x2000..=0x3EFF => match &mut self.name_tables_ram {
                // Handle mirroring based on cartridge configuration
                NameTableRam::Horizontal(name_tables_ram) => {
                    name_tables_ram.write(data, addr - 0x2000)
                }
                NameTableRam::Vertical(name_tables_ram) => {
                    name_tables_ram.write(data, addr - 0x2000)
                }
                NameTableRam::FourScreens(name_tables_ram) => {
                    name_tables_ram.write(data, addr - 0x2000)
                }
            },
            _ => unreachable!(),
        }
        ChrRomContentStatus::Unchanged
    }
}

impl Default for NameTableRam {
    fn default() -> Self {
        Self::Horizontal(Default::default())
    }
}

impl Cart {
    /// Creates a new Cart instance with the specified PRG ROM and optional CHR ROM data.
    ///
    /// # Arguments
    ///
    /// * `prg_rom` - Program ROM data
    /// * `chr_rom` - Optional character ROM data (None means use CHR RAM)
    /// * `mapper` - The mapper number (0, 2, or 3) to determine which mappers
    ///   the bank register controls. CNROM (3) uses the bank register for CHR
    ///   bank switching; NROM (0) and UxROM (2) use it for PRG ROM switching.
    ///
    /// # Implementation Details
    ///
    /// 1. Calculates number of 16KB PRG ROM and 8KB CHR ROM/RAM pages
    /// 2. Initializes cart with default settings
    /// 3. Loads PRG ROM data into 16KB banks
    /// 4. If CHR ROM provided:
    ///    - Loads CHR ROM data into 8KB banks
    ///    - Sets use_chr_ram to false
    /// 5. If no CHR ROM:
    ///    - Creates 2 empty 8KB CHR RAM banks
    ///    - Sets use_chr_ram to true
    fn new(prg_rom: &[u8], chr_rom: Option<&[u8]>, mapper: u8) -> Self {
        // Calculate number of 16KB PRG ROM pages
        let prg_pages_count = prg_rom.len() / PRG_ROM_PAGE_SIZE;
        // Calculate number of 8KB CHR pages (2 for CHR RAM, or based on ROM size)
        let chr_pages_count = chr_rom.map_or(CHR_RAM_NUM_PAGES, |chr_rom| {
            chr_rom.len() / CHR_MEM_PAGE_SIZE
        });

        let mut cart = Self::default();
        cart.prg_rom.reserve(prg_pages_count);
        cart.chr_mem.reserve(chr_pages_count);
        // Only UxROM (mapper 2) uses the bank register to select a PRG ROM
        // bank at $8000-$BFFF. NROM (mapper 0) has fixed PRG and CNROM
        // (mapper 3) has a fixed 32KB PRG window.
        cart.prg_banked = mapper == 2;
        // Only CNROM (mapper 3) with CHR ROM uses the bank register to
        // select CHR banks. NROM and UxROM keep CHR fixed at bank 0 because
        // their bank register is consumed by PRG banking, and CNROM with
        // CHR RAM (an invalid board) also keeps CHR fixed.
        cart.chr_banked = mapper == 3 && chr_rom.is_some();

        // Load PRG ROM data into 16KB banks
        for page_index in 0..prg_pages_count {
            let start = page_index * PRG_ROM_PAGE_SIZE;
            let end = start + PRG_ROM_PAGE_SIZE;

            cart.prg_rom.push(Device::with_data(&prg_rom[start..end]));
        }

        // Handle CHR ROM/RAM setup
        if let Some(chr_rom) = chr_rom {
            // Load CHR ROM data into 8KB banks
            for page_index in 0..chr_pages_count {
                let start = page_index * CHR_MEM_PAGE_SIZE;
                let end = start + CHR_MEM_PAGE_SIZE;

                cart.chr_mem.push(Device::with_data(&chr_rom[start..end]));
                cart.use_chr_ram = false;
            }
        } else {
            // Initialize CHR RAM banks
            cart.use_chr_ram = true;
            cart.chr_mem.resize(CHR_RAM_NUM_PAGES, Default::default());
        }

        cart
    }

    /// Creates a new Cart with horizontal name table mirroring.
    ///
    /// # Arguments
    ///
    /// * `prg_rom` - Program ROM data
    /// * `chr_rom` - Optional character ROM data
    /// * `mapper` - The mapper number (0, 2, or 3)
    pub fn new_horizontal(prg_rom: &[u8], chr_rom: Option<&[u8]>, mapper: u8) -> Self {
        Self::new(prg_rom, chr_rom, mapper)
    }

    /// Creates a new Cart with vertical name table mirroring.
    ///
    /// # Arguments
    ///
    /// * `prg_rom` - Program ROM data
    /// * `chr_rom` - Optional character ROM data
    /// * `mapper` - The mapper number (0, 2, or 3)
    pub fn new_vertical(prg_rom: &[u8], chr_rom: Option<&[u8]>, mapper: u8) -> Self {
        let mut new_cart = Self::new(prg_rom, chr_rom, mapper);
        new_cart.name_tables_ram = NameTableRam::Vertical(Default::default());
        new_cart
    }

    /// Creates a new Cart with four-screen name table setup.
    ///
    /// # Arguments
    ///
    /// * `prg_rom` - Program ROM data
    /// * `chr_rom` - Optional character ROM data
    /// * `mapper` - The mapper number (0, 2, or 3)
    pub fn new_four_screen(prg_rom: &[u8], chr_rom: Option<&[u8]>, mapper: u8) -> Self {
        let mut new_cart = Self::new(prg_rom, chr_rom, mapper);
        new_cart.name_tables_ram = NameTableRam::FourScreens(Default::default());
        new_cart
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn uxrom_with_chr_ram() -> Cart {
        // 128KB PRG, no CHR ROM (i.e. CHR RAM)
        let prg_rom = vec![0u8; 8 * PRG_ROM_PAGE_SIZE];
        Cart::new(prg_rom.as_slice(), None, 2)
    }

    fn uxrom_with_2_prg_banks() -> Cart {
        // 32KB PRG, no CHR ROM (i.e. CHR RAM). UxROM with only 2 PRG banks
        // is rare in practice but valid per the iNES spec; the bank register
        // must still select between the two banks at $8000-$BFFF.
        let prg_rom = vec![0u8; 2 * PRG_ROM_PAGE_SIZE];
        Cart::new(prg_rom.as_slice(), None, 2)
    }

    fn cnrom_with_chr_rom(num_chr_banks: usize) -> Cart {
        // 32KB PRG, N x 8KB CHR ROM
        let prg_rom = vec![0u8; 2 * PRG_ROM_PAGE_SIZE];
        let chr_rom = vec![0u8; num_chr_banks * CHR_MEM_PAGE_SIZE];
        Cart::new(prg_rom.as_slice(), Some(chr_rom.as_slice()), 3)
    }

    fn uxrom_with_chr_rom(num_chr_banks: usize) -> Cart {
        // 128KB PRG, N x 8KB CHR ROM (rare combination, but the implementation
        // needs to keep CHR fixed at bank 0 for it just like for CHR RAM).
        let prg_rom = vec![0u8; 8 * PRG_ROM_PAGE_SIZE];
        let chr_rom = vec![0u8; num_chr_banks * CHR_MEM_PAGE_SIZE];
        Cart::new(prg_rom.as_slice(), Some(chr_rom.as_slice()), 2)
    }

    /// Regression test: UxROM (mapper 2) uses the bank register for PRG ROM
    /// switching, so CHR RAM writes must remain at bank 0 even after the bank
    /// register has been changed. Without the fix, writes/reads would be
    /// redirected to a different (empty) bank and the game would lose all its
    /// tile data.
    #[test]
    fn uxrom_chr_ram_survives_bank_register_changes() {
        let mut cart = uxrom_with_chr_ram();

        // Write a unique byte to CHR RAM at $0100.
        cart.write_ppu_mapped(0xAB, 0x0100);
        assert_eq!(cart.read_ppu_mapped(0x0100), 0xAB);

        // Simulate the game switching its PRG bank. The bank register now
        // points at PRG bank 5, which is odd, and (with the bug) would have
        // selected the second 8KB CHR RAM bank instead of the first.
        cart.write_cpu_mapped(5, 0x8000 - CART_CPU_MAP_BEGIN_ADDR);
        assert_eq!(cart.read_ppu_mapped(0x0100), 0xAB);

        // Try a few more PRG bank values to be sure the data sticks regardless
        // of whether the bank index is even or odd.
        for prg_bank in [0u8, 1, 3, 7, 15] {
            cart.write_cpu_mapped(prg_bank, 0xC000 - CART_CPU_MAP_BEGIN_ADDR);
            assert_eq!(
                cart.read_ppu_mapped(0x0100),
                0xAB,
                "CHR RAM data lost when bank register was {prg_bank}"
            );
        }
    }

    /// Regression test: UxROM with 2 PRG banks (32 KB total) must honour the
    /// bank register for the switchable $8000-$BFFF window. The previous
    /// implementation always returned bank 0 for the 2-bank case, silently
    /// breaking any cart that actually used the switchable window.
    #[test]
    fn uxrom_2_bank_prg_switches() {
        let mut cart = uxrom_with_2_prg_banks();

        // Initial read at $8000 must come from PRG bank 0 (default state).
        assert_eq!(cart.read_cpu_mapped(0x8000 - CART_CPU_MAP_BEGIN_ADDR), 0);

        // Write a distinct byte to every byte of PRG bank 1.
        cart.prg_rom[1].init_data(&vec![0x5A; PRG_ROM_PAGE_SIZE]);

        // The $C000-$FFFF window is fixed to the last bank (bank 1 here).
        assert_eq!(cart.read_cpu_mapped(0xC000 - CART_CPU_MAP_BEGIN_ADDR), 0x5A);

        // Select bank 1 via the bank register and confirm $8000 follows.
        cart.write_cpu_mapped(1, 0x8000 - CART_CPU_MAP_BEGIN_ADDR);
        assert_eq!(
            cart.read_cpu_mapped(0x8000 - CART_CPU_MAP_BEGIN_ADDR),
            0x5A,
            "$8000-$BFFF must reflect the switchable bank when prg_banked is true"
        );
        // $C000 is still the last bank.
        assert_eq!(cart.read_cpu_mapped(0xC000 - CART_CPU_MAP_BEGIN_ADDR), 0x5A);

        // Select bank 0 again and confirm $8000 returns to bank 0.
        cart.write_cpu_mapped(0, 0x8000 - CART_CPU_MAP_BEGIN_ADDR);
        assert_eq!(cart.read_cpu_mapped(0x8000 - CART_CPU_MAP_BEGIN_ADDR), 0);
        assert_eq!(cart.read_cpu_mapped(0xC000 - CART_CPU_MAP_BEGIN_ADDR), 0x5A);
    }

    /// Regression test: UxROM with CHR RAM should still see data across the
    /// whole 8KB CHR window, not just the first entry of the internal
    /// `chr_mem` array.
    #[test]
    fn uxrom_chr_ram_addressable_across_full_window() {
        let mut cart = uxrom_with_chr_ram();

        cart.write_ppu_mapped(0x11, 0x0000);
        cart.write_ppu_mapped(0x22, 0x0FFF);
        cart.write_ppu_mapped(0x33, 0x1000);
        cart.write_ppu_mapped(0x44, 0x1FFF);

        // Touch the bank register so the buggy code path would misroute the
        // read.
        cart.write_cpu_mapped(1, 0x8000 - CART_CPU_MAP_BEGIN_ADDR);

        assert_eq!(cart.read_ppu_mapped(0x0000), 0x11);
        assert_eq!(cart.read_ppu_mapped(0x0FFF), 0x22);
        assert_eq!(cart.read_ppu_mapped(0x1000), 0x33);
        assert_eq!(cart.read_ppu_mapped(0x1FFF), 0x44);
    }

    /// Sanity check that CNROM (mapper 3) still uses the bank register for
    /// CHR ROM bank switching — i.e. the fix didn't accidentally break the
    /// mapper that genuinely does CHR banking.
    #[test]
    fn cnrom_chr_rom_still_banked_by_register() {
        let mut cart = cnrom_with_chr_rom(4);

        // Fill each 8KB CHR bank with a distinct byte.
        for bank in 0..4 {
            cart.chr_mem[bank].init_data(&vec![0x10 + bank as u8; CHR_MEM_PAGE_SIZE]);
        }

        // Bank register selects CHR bank 0 by default.
        assert_eq!(cart.read_ppu_mapped(0x0000), 0x10);

        // Switch to CHR bank 1.
        cart.write_cpu_mapped(1, 0x8000 - CART_CPU_MAP_BEGIN_ADDR);
        assert_eq!(cart.read_ppu_mapped(0x0000), 0x11);

        // Switch to CHR bank 3.
        cart.write_cpu_mapped(3, 0x8000 - CART_CPU_MAP_BEGIN_ADDR);
        assert_eq!(cart.read_ppu_mapped(0x0000), 0x13);
    }

    /// UxROM with CHR ROM (the rare case) must also keep its CHR ROM fixed at
    /// bank 0 — the bank register is for PRG ROM banking only, even though
    /// there is a real CHR ROM that could theoretically be banked.
    #[test]
    fn uxrom_chr_rom_fixed_at_bank_zero() {
        let mut cart = uxrom_with_chr_rom(4);

        for bank in 0..4 {
            cart.chr_mem[bank].init_data(&vec![0x20 + bank as u8; CHR_MEM_PAGE_SIZE]);
        }

        // Even after writing a non-zero bank register, the CHR read must
        // still come from bank 0.
        cart.write_cpu_mapped(2, 0x8000 - CART_CPU_MAP_BEGIN_ADDR);
        assert_eq!(cart.read_ppu_mapped(0x0000), 0x20);
    }

    /// Builds a Cart with the requested mirroring mode and an empty
    /// name-table backing store. Used by the mirroring tests below.
    fn cart_for_mirroring(mirroring: crate::rom_loader::ines::Mirroring) -> Cart {
        let prg = vec![0u8; 2 * PRG_ROM_PAGE_SIZE];
        let chr = vec![0u8; 2 * CHR_MEM_PAGE_SIZE];

        let mut cart = Cart::new(prg.as_slice(), Some(chr.as_slice()), 0);
        cart.name_tables_ram = match mirroring {
            crate::rom_loader::ines::Mirroring::Horizontal => NameTableRam::Horizontal(
                crate::rom_loader::ines::mappers::HorizontalNameTablesRam::default(),
            ),
            crate::rom_loader::ines::Mirroring::Vertical => NameTableRam::Vertical(
                crate::rom_loader::ines::mappers::VerticalNameTablesRam::default(),
            ),
            crate::rom_loader::ines::Mirroring::FourScreen => NameTableRam::FourScreens(
                crate::rom_loader::ines::mappers::FourScreensNameTablesRam::default(),
            ),
        };
        cart
    }

    /// Vertical mirroring: $2000 ↔ $2800, $2400 ↔ $2C00. 2KB backing store.
    #[test]
    fn vertical_mirroring_two_name_tables() {
        let mut cart = cart_for_mirroring(crate::rom_loader::ines::Mirroring::Vertical);

        cart.write_ppu_mapped(1, 0x2000);
        cart.write_ppu_mapped(2, 0x2401);
        cart.write_ppu_mapped(3, 0x2802);
        cart.write_ppu_mapped(4, 0x2C03);

        assert_eq!(cart.read_ppu_mapped(0x2000), 1, "screen-0 byte 0");
        assert_eq!(cart.read_ppu_mapped(0x2401), 2, "screen-1 byte 1");
        assert_eq!(cart.read_ppu_mapped(0x2802), 3, "screen-0 mirror at $2802");
        assert_eq!(cart.read_ppu_mapped(0x2C03), 4, "screen-1 mirror at $2C03");

        // $3000-$3FFF aliases $2000-$2FFF.
        assert_eq!(cart.read_ppu_mapped(0x3000), 1);
        assert_eq!(cart.read_ppu_mapped(0x3401), 2);
        assert_eq!(cart.read_ppu_mapped(0x3802), 3);
        assert_eq!(cart.read_ppu_mapped(0x3C03), 4);
    }

    /// Horizontal mirroring: $2000 ↔ $2400, $2800 ↔ $2C00.
    ///
    /// The `Adjacent<Mirror<Ram<SIZE_1K>, 2>, Mirror<Ram<SIZE_1K>, 2>>` storage
    /// holds **two independent 1KB tiles** (NT0 at bytes $000-$3FF and NT1 at
    /// bytes $000-$3FF of the second Mirror instance). Each tile internally
    /// mirrors its lower 1KB across its own upper 1KB PPU range — that's how
    /// `Mirror<..., 2>` works.
    ///
    /// Concretely:
    /// - $2000 and $2400 alias to NT0's `addr & 0x3FF` (first Mirror, first half)
    /// - $2800 and $2C00 alias to NT1's `addr & 0x3FF` (second Mirror, first half)
    ///
    /// We test that the **two tiles are independent**: a write to the
    /// lower NT's byte 0 must not affect the upper NT's byte 0 (and vice
    /// versa). Within each NT, $2000 ↔ $2400 mirror exactly.
    #[test]
    fn horizontal_mirroring_two_distinct_tiles() {
        let mut cart = cart_for_mirroring(crate::rom_loader::ines::Mirroring::Horizontal);

        // Write to the lower NT only.
        cart.write_ppu_mapped(0x11, 0x2000);
        assert_eq!(cart.read_ppu_mapped(0x2000), 0x11, "lower tile byte 0");
        assert_eq!(
            cart.read_ppu_mapped(0x2400),
            0x11,
            "$2400 mirrors $2000 inside the same 1KB tile"
        );

        // Write to the upper NT only.
        cart.write_ppu_mapped(0x22, 0x2800);
        assert_eq!(cart.read_ppu_mapped(0x2800), 0x22, "upper tile byte 0");
        assert_eq!(
            cart.read_ppu_mapped(0x2C00),
            0x22,
            "$2C00 mirrors $2800 inside the same 1KB tile"
        );

        // Independence: the two NTs must NOT alias. A write to $2000
        // must not change $2800, and vice versa.
        assert_ne!(
            cart.read_ppu_mapped(0x2000),
            cart.read_ppu_mapped(0x2800),
            "the two name tables must hold distinct data"
        );

        // $3000-$3FFF aliases $2000-$2FFF.
        assert_eq!(cart.read_ppu_mapped(0x3000), 0x11);
        assert_eq!(cart.read_ppu_mapped(0x3400), 0x11);
        assert_eq!(cart.read_ppu_mapped(0x3800), 0x22);
        assert_eq!(cart.read_ppu_mapped(0x3C00), 0x22);
    }

    /// Four-screen mirroring: 4KB unique storage, no mirrors. Each of
    /// the four $400 windows should be independent.
    #[test]
    fn four_screen_mirroring_all_four_independent() {
        let mut cart = cart_for_mirroring(crate::rom_loader::ines::Mirroring::FourScreen);

        cart.write_ppu_mapped(1, 0x2000);
        cart.write_ppu_mapped(2, 0x2400);
        cart.write_ppu_mapped(3, 0x2800);
        cart.write_ppu_mapped(4, 0x2C00);

        assert_eq!(cart.read_ppu_mapped(0x2000), 1, "screen-0 byte 0");
        assert_eq!(cart.read_ppu_mapped(0x2400), 2, "screen-1 byte 0");
        assert_eq!(cart.read_ppu_mapped(0x2800), 3, "screen-2 byte 0");
        assert_eq!(cart.read_ppu_mapped(0x2C00), 4, "screen-3 byte 0");

        // $3000-$3FFF aliases $2000-$2FFF.
        assert_eq!(cart.read_ppu_mapped(0x3000), 1);
        assert_eq!(cart.read_ppu_mapped(0x3400), 2);
        assert_eq!(cart.read_ppu_mapped(0x3800), 3);
        assert_eq!(cart.read_ppu_mapped(0x3C00), 4);
    }

    /// Regression test for horizontal mirroring collapse: with the
    /// two-tile storage (`Adjacent<Mirror<Ram<SIZE_1K>, 2>, Mirror<Ram<SIZE_1K>, 2>>`)
    /// in place, $2000 and $2800 must each use their own 1KB tile. If a
    /// future refactor collapses the two tiles into a single 1KB Ram, the
    /// second write would overwrite the first and the two reads would
    /// collide.
    #[test]
    fn horizontal_mirroring_does_not_collapse_into_single_tile() {
        let mut cart = cart_for_mirroring(crate::rom_loader::ines::Mirroring::Horizontal);

        // Write to the lower NT then read from both halves of that tile.
        cart.write_ppu_mapped(0xAA, 0x2000);
        let r0 = cart.read_ppu_mapped(0x2000);
        let r1 = cart.read_ppu_mapped(0x2400);
        assert_eq!(r0, 0xAA, "$2000 must read what we just wrote there");
        assert_eq!(r1, 0xAA, "$2400 mirrors $2000 within the same tile");

        // Write to the upper NT then read from both halves of that tile.
        cart.write_ppu_mapped(0xBB, 0x2800);
        let r2 = cart.read_ppu_mapped(0x2800);
        let r3 = cart.read_ppu_mapped(0x2C00);
        assert_eq!(r2, 0xBB, "$2800 must read what we just wrote there");
        assert_eq!(r3, 0xBB, "$2C00 mirrors $2800 within the same tile");

        // The two tiles must hold DISTINCT data: writing NT0 must not
        // touch NT1 (and vice versa).
        assert_ne!(
            r0, r2,
            "lower NT and upper NT must not alias — a single tile storage would fail here"
        );
        assert_ne!(r1, r3, "lower NT mirror and upper NT mirror must not alias");
    }
}