Раздел 23 · Rust

Картридж и мапперы

senior~120 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Картридж и мапперы

6502 умеет считать, но считать пока нечего: в адресном пространстве выше 0x4020 пусто. Это место картриджа. Игры 80-х не помещались в 16-битную шину, поэтому в картридж встроили маленькую схему, которая на лету переключает, какой кусок ROM виден процессору. Эта схема называется маппер, и сегодня мы прячем её за трейтом, ровно как прятали периферию за HAL.

Что внутри картриджа

Картридж это две микросхемы ROM и логика доступа.

  • PRG ROM: код игры, отображается в шину CPU выше 0x8000.
  • CHR ROM: графика (тайлы), отображается в шину PPU, об этом в уроке про фон.
  • Маппер: схема, которая решает, какой банк ROM виден по какому адресу прямо сейчас.

Маппер существует потому, что у 6502 всего 32 КБ под код ($8000..=$FFFF), а игры были крупнее. Решение: держать в шине только активный банк, а запись в картридж переключает банки. Это банковое переключение, и на нём держится почти вся библиотека NES.

Добавь модули

Сегодня ты соберёшь весь картриджный слой: загрузчик iNES и пять мапперов. Сначала заведём место под код. Открой src/lib.rs и добавь два модуля:

pub mod cartridge;
pub mod mapper;

Затем создай файл src/cartridge.rs и папку src/mapper/ с такими файлами внутри:

  • src/mapper/mod.rs (трейт Mapper, перечисление Mirroring, реэкспорты);
  • src/mapper/nrom.rs (маппер 0);
  • src/mapper/uxrom.rs (маппер 2);
  • src/mapper/cnrom.rs (маппер 3);
  • src/mapper/mmc1.rs (маппер 1);
  • src/mapper/mmc3.rs (маппер 4).

Дальше идём по файлам сверху вниз: сначала загрузчик, потом трейт, потом мапперы от простого к сложному.

iNES целиком

В уроке про архитектуру мы распарсили заголовок начерно. Теперь полный загрузчик: он разбирает заголовок, режет тело на PRG и CHR, и по номеру маппера собирает нужную реализацию. Создай файл src/cartridge.rs целиком:

//! Картридж и формат iNES.
//!
//! Урок 44. Файл `.nes` это образ картриджа: 16-байтный заголовок iNES, затем
//! банки PRG ROM (код и данные для процессора) и CHR ROM (тайлы для PPU). Заголовок
//! говорит, сколько тех и других, какой номер маппера и как разведено
//! зеркалирование. Мы разбираем заголовок и собираем подходящую реализацию
//! [`Mapper`].
//!
//! Поддержан и iNES, и его расширение NES 2.0 (отличается старшими битами в байтах
//! 7 и 8); для учебных мапперов хватает базовых полей.

use crate::mapper::{Cnrom, Mapper, Mirroring, Mmc1, Mmc3, Nrom, UxRom};

/// Размер одного банка PRG ROM по формату iNES.
pub const PRG_BANK: usize = 16 * 1024;
/// Размер одного банка CHR ROM по формату iNES.
pub const CHR_BANK: usize = 8 * 1024;

/// Ошибка разбора образа картриджа.
#[derive(Debug, PartialEq, Eq)]
pub enum RomError {
    /// Нет сигнатуры `NES\x1a` в начале файла.
    BadMagic,
    /// Файл короче, чем обещает заголовок.
    Truncated,
    /// Номер маппера, который мы пока не поддерживаем.
    UnsupportedMapper(u8),
}

impl std::fmt::Display for RomError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RomError::BadMagic => write!(f, "это не iNES: нет сигнатуры NES\\x1a"),
            RomError::Truncated => write!(f, "файл короче, чем обещает заголовок"),
            RomError::UnsupportedMapper(n) => write!(f, "маппер {n} пока не поддержан"),
        }
    }
}

impl std::error::Error for RomError {}

/// Разобранные данные образа: то, что нужно мапперу, чтобы начать работу.
pub struct RomData {
    pub prg_rom: Vec<u8>,
    /// CHR ROM. Если пусто, у картриджа CHR RAM (8 KiB), и PPU пишет в неё.
    pub chr_rom: Vec<u8>,
    pub mirroring: Mirroring,
    /// Размер PRG RAM в байтах (`$6000..=$7FFF`), минимум 8 KiB если есть батарейка.
    pub prg_ram_size: usize,
}

/// Разобрать байты `.nes` и собрать готовый маппер.
pub fn load(bytes: &[u8]) -> Result<Box<dyn Mapper>, RomError> {
    if bytes.len() < 16 || &bytes[0..4] != b"NES\x1a" {
        return Err(RomError::BadMagic);
    }

    let prg_banks = bytes[4] as usize;
    let chr_banks = bytes[5] as usize;
    let flags6 = bytes[6];
    let flags7 = bytes[7];

    let mapper_id = (flags7 & 0xF0) | (flags6 >> 4);
    let four_screen = flags6 & 0b0000_1000 != 0;
    let vertical = flags6 & 0b0000_0001 != 0;
    let has_trainer = flags6 & 0b0000_0100 != 0;

    let mirroring = if four_screen {
        Mirroring::FourScreen
    } else if vertical {
        Mirroring::Vertical
    } else {
        Mirroring::Horizontal
    };

    // Trainer это устаревший 512-байтный блок перед PRG, который мы пропускаем.
    let mut offset = 16 + if has_trainer { 512 } else { 0 };

    let prg_len = prg_banks * PRG_BANK;
    let chr_len = chr_banks * CHR_BANK;

    if bytes.len() < offset + prg_len + chr_len {
        return Err(RomError::Truncated);
    }

    let prg_rom = bytes[offset..offset + prg_len].to_vec();
    offset += prg_len;
    let chr_rom = bytes[offset..offset + chr_len].to_vec();

    let data = RomData {
        prg_rom,
        chr_rom,
        mirroring,
        prg_ram_size: 8 * 1024,
    };

    match mapper_id {
        0 => Ok(Box::new(Nrom::new(data))),
        1 => Ok(Box::new(Mmc1::new(data))),
        2 => Ok(Box::new(UxRom::new(data))),
        3 => Ok(Box::new(Cnrom::new(data))),
        4 => Ok(Box::new(Mmc3::new(data))),
        other => Err(RomError::UnsupportedMapper(other)),
    }
}

iNES отдаёт мапперу не сам файл, а уже нарезанные данные. Это RomData: владелец байтов PRG и CHR, плюс пара полей, которые маппер положит к себе.

Самая хитрая строка загрузчика это сборка номера маппера: let mapper_id = (flags7 & 0xF0) | (flags6 >> 4). Младший ниббл номера лежит в старшем ниббле байта 6, старший ниббл в старшем ниббле байта 7. Их сшивают в один байт.

Заметь две детали в RomData, которые легко прозевать. Пустой chr_rom это не ошибка: у такого картриджа не ROM, а CHR RAM, и PPU будет в неё писать. А prg_ram_size это та самая память за $6000, в которой Zelda хранит сохранения: на плате к ней приделана батарейка.

Маппер как трейт

Вся разница между картриджами сводится к нескольким методам: как читать и писать PRG, как читать и писать CHR, как сейчас разведено зеркалирование. Прячем за трейтом, остальной эмулятор о конкретном маппере не знает. Создай файл src/mapper/mod.rs:

//! Мапперы: микросхемы на картридже, которые расширяют тесную карту памяти NES.
//!
//! Урок 44. У процессора всего 32 KiB окна под картридж (`$8000..=$FFFF`), а у PPU
//! 8 KiB под знакогенератор (`$0000..=$1FFF`). Игры больше этого, поэтому на плате
//! картриджа стоит логика переключения банков. Маппер это и есть та логика. Мы
//! прячем её за трейтом [`Mapper`]: ядро и шина не знают, NROM там, MMC1 или UxROM,
//! они просто читают и пишут байты, а маппер решает, какой банк сейчас виден.
//!
//! Это тот же приём разделения механизма и интерфейса, что и трейт `Bus` в эталоне
//! RU6: добавить новый маппер значит добавить реализацию трейта, не трогая шину.

mod cnrom;
mod mmc1;
mod mmc3;
mod nrom;
mod uxrom;

pub use cnrom::Cnrom;
pub use mmc1::Mmc1;
pub use mmc3::Mmc3;
pub use nrom::Nrom;
pub use uxrom::UxRom;

/// Зеркалирование таблиц имён (nametables). PPU физически хранит две таблицы по
/// 1 KiB, а адресует четыре. Как четыре логических отображаются на две физических,
/// задаёт картридж: разводкой на плате (NROM) или регистром маппера (MMC1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mirroring {
    /// Горизонтальное: верх и низ экрана это разные таблицы (скролл по вертикали).
    Horizontal,
    /// Вертикальное: лево и право это разные таблицы (скролл по горизонтали).
    Vertical,
    /// Один экран, нижняя физическая таблица.
    SingleScreenLower,
    /// Один экран, верхняя физическая таблица.
    SingleScreenUpper,
    /// Четыре экрана: на картридже своя видеопамять (мы её не эмулируем глубоко).
    FourScreen,
}

/// Контракт картриджной логики. Адреса приходят уже в пространстве CPU или PPU,
/// маппер переводит их в смещения внутри своих банков ROM или RAM.
pub trait Mapper {
    /// Чтение процессором из окна картриджа (`$4020..=$FFFF`; на практике интересен
    /// `$6000` и выше: PRG RAM и PRG ROM).
    fn cpu_read(&mut self, addr: u16) -> u8;

    /// Запись процессором. Для многих мапперов запись в ROM-окно это не запись
    /// данных, а команда переключения банка (MMC1, UxROM).
    fn cpu_write(&mut self, addr: u16, value: u8);

    /// Чтение PPU из знакогенератора (`$0000..=$1FFF`, паттерны тайлов).
    fn ppu_read(&mut self, addr: u16) -> u8;

    /// Запись PPU в знакогенератор (имеет смысл только для CHR RAM).
    fn ppu_write(&mut self, addr: u16, value: u8);

    /// Текущее зеркалирование таблиц имён. У MMC1 оно меняется в рантайме.
    fn mirroring(&self) -> Mirroring;

    /// Запрашивает ли маппер прерывание IRQ (нужно для MMC3 и подобных; у NROM,
    /// MMC1 и UxROM прерываний нет).
    fn irq_pending(&self) -> bool {
        false
    }

    /// Тик счётчика сканлайн-прерывания. PPU зовёт его раз на видимую строку, когда
    /// рендер включён (упрощённая модель фронта A12). У MMC3 на этом висит IRQ; у
    /// остальных мапперов это пустышка.
    fn scanline_tick(&mut self) {}
}

Это та же развилка «механизм против интерфейса», что вела весь прошлый блок: шина дёргает трейт, реализация решает, какой банк отдать. Заметь методы по умолчанию irq_pending и scanline_tick: простым мапперам они не нужны, поэтому берутся пустыми, ровно как tick в трейте шины. Добавить новый маппер значит написать реализацию трейта, не трогая ни шину, ни ядро.

NROM: банков нет

Начнём с самого простого. NROM (маппер 0) это картридж без переключения: PRG просто лежит в шине, 16 или 32 КБ. Если PRG 16 КБ, он отражается в оба банка $8000 и $C000. На NROM вышли Donkey Kong и первый Super Mario Bros. Создай файл src/mapper/nrom.rs:

//! NROM (маппер 0): самый простой картридж, без переключения банков.
//!
//! Урок 44. PRG ROM это 16 или 32 KiB, наглухо привязанные к `$8000..=$FFFF`
//! (16 KiB отражается в оба банка). CHR это 8 KiB ROM или, если в образе CHR нет,
//! 8 KiB RAM. Зеркалирование задано разводкой платы и не меняется. На NROM вышли
//! Donkey Kong, Super Mario Bros и десятки других ранних игр.

use crate::cartridge::RomData;
use crate::mapper::{Mapper, Mirroring};

pub struct Nrom {
    prg_rom: Vec<u8>,
    prg_ram: Vec<u8>,
    /// CHR: либо ROM из образа, либо RAM, если CHR в образе не было.
    chr: Vec<u8>,
    chr_is_ram: bool,
    mirroring: Mirroring,
}

impl Nrom {
    pub fn new(data: RomData) -> Self {
        let chr_is_ram = data.chr_rom.is_empty();
        let chr = if chr_is_ram {
            vec![0; 8 * 1024]
        } else {
            data.chr_rom
        };
        Nrom {
            prg_rom: data.prg_rom,
            prg_ram: vec![0; data.prg_ram_size],
            chr,
            chr_is_ram,
            mirroring: data.mirroring,
        }
    }
}

impl Mapper for Nrom {
    fn cpu_read(&mut self, addr: u16) -> u8 {
        match addr {
            0x6000..=0x7FFF => {
                let index = (addr - 0x6000) as usize % self.prg_ram.len().max(1);
                self.prg_ram.get(index).copied().unwrap_or(0)
            }
            0x8000..=0xFFFF => {
                let index = (addr - 0x8000) as usize % self.prg_rom.len();
                self.prg_rom[index]
            }
            _ => 0,
        }
    }

    fn cpu_write(&mut self, addr: u16, value: u8) {
        if let 0x6000..=0x7FFF = addr {
            if !self.prg_ram.is_empty() {
                let index = (addr - 0x6000) as usize % self.prg_ram.len();
                self.prg_ram[index] = value;
            }
        }
        // Запись в PRG ROM на NROM игнорируется: банков переключать нечем.
    }

    fn ppu_read(&mut self, addr: u16) -> u8 {
        self.chr.get((addr & 0x1FFF) as usize).copied().unwrap_or(0)
    }

    fn ppu_write(&mut self, addr: u16, value: u8) {
        if self.chr_is_ram {
            let index = (addr & 0x1FFF) as usize;
            self.chr[index] = value;
        }
    }

    fn mirroring(&self) -> Mirroring {
        self.mirroring
    }
}

Главная строка тут % self.prg_rom.len(): отражение 16-килобайтного образа в оба окна выходит само, без отдельной ветки. А cpu_write в ROM молчит: переключать нечего. nestest это NROM-128, поэтому твой загрузчик из этого урока обязан запускать nestest без единого изменения в ядре.

UxROM: один регистр банка

Дальше первое настоящее переключение. UxROM (маппер 2) режет PRG на банки по 16 КБ. Нижнее окно $8000..=$BFFF показывает переключаемый банк, верхнее $C000..=$FFFF всегда показывает последний банк (там лежат векторы прерываний, их нельзя терять при переключении). Номер банка выбирается записью любого значения в любой адрес ROM-окна: значение и есть номер. Создай файл src/mapper/uxrom.rs:

//! UxROM (маппер 2): простое переключение банков PRG.
//!
//! Урок 44. PRG ROM нарезана на банки по 16 KiB. Окно `$8000..=$BFFF` показывает
//! переключаемый банк, окно `$C000..=$FFFF` всегда показывает последний банк (где
//! живут векторы сброса и прерываний). Номер банка выбирается записью любого
//! значения в `$8000..=$FFFF`: значение и есть номер. CHR это 8 KiB RAM. На UxROM
//! вышли Mega Man, Castlevania, Contra и другие игры посложнее NROM.

use crate::cartridge::{RomData, PRG_BANK};
use crate::mapper::{Mapper, Mirroring};

pub struct UxRom {
    prg_rom: Vec<u8>,
    chr_ram: Vec<u8>,
    mirroring: Mirroring,
    /// Сколько банков по 16 KiB в PRG ROM.
    bank_count: usize,
    /// Выбранный переключаемый банк для окна `$8000..=$BFFF`.
    bank_select: usize,
}

impl UxRom {
    pub fn new(data: RomData) -> Self {
        let bank_count = (data.prg_rom.len() / PRG_BANK).max(1);
        UxRom {
            prg_rom: data.prg_rom,
            // UxROM всегда с CHR RAM (в образе CHR обычно нет).
            chr_ram: if data.chr_rom.is_empty() {
                vec![0; 8 * 1024]
            } else {
                data.chr_rom
            },
            mirroring: data.mirroring,
            bank_count,
            bank_select: 0,
        }
    }
}

impl Mapper for UxRom {
    fn cpu_read(&mut self, addr: u16) -> u8 {
        match addr {
            0x8000..=0xBFFF => {
                let base = (self.bank_select % self.bank_count) * PRG_BANK;
                self.prg_rom[base + (addr - 0x8000) as usize]
            }
            0xC000..=0xFFFF => {
                // Последний банк прибит к верхнему окну.
                let base = (self.bank_count - 1) * PRG_BANK;
                self.prg_rom[base + (addr - 0xC000) as usize]
            }
            _ => 0,
        }
    }

    fn cpu_write(&mut self, addr: u16, value: u8) {
        if addr >= 0x8000 {
            // Любая запись в ROM-окно это команда выбора банка.
            self.bank_select = value as usize;
        }
    }

    fn ppu_read(&mut self, addr: u16) -> u8 {
        self.chr_ram[(addr & 0x1FFF) as usize]
    }

    fn ppu_write(&mut self, addr: u16, value: u8) {
        self.chr_ram[(addr & 0x1FFF) as usize] = value;
    }

    fn mirroring(&self) -> Mirroring {
        self.mirroring
    }
}

Вот теперь странность сразу видна: запись по адресу ROM не пишет в ROM (он только для чтения), а отдаёт команду микросхеме. На UxROM вышли Mega Man, Castlevania и Contra. Покрути переключение банков в виджете ниже: жми «записать банк N», смотри, как нижнее окно прыгает по банкам, а верхнее держит последний.

CNROM: переключается графика

UxROM переключал код. CNROM (маппер 3) делает зеркальное: PRG прибит наглухо как у NROM, а вот CHR ROM нарезана на банки по 8 КБ, и запись в ROM-окно выбирает банк CHR. Так игра меняет весь знакогенератор разом: например, чтобы показать другой набор тайлов. Создай файл src/mapper/cnrom.rs:

//! CNROM (маппер 3): фиксированный PRG, переключаемый банк CHR.
//!
//! Урок 44. PRG ROM как у NROM (16 или 32 KiB, наглухо в `$8000..=$FFFF`), а вот
//! CHR ROM нарезана на банки по 8 KiB. Запись любого значения в окно
//! `$8000..=$FFFF` выбирает банк CHR: так игра меняет весь знакогенератор разом.
//! На CNROM вышли Arkanoid, Bump'n'Jump, Cybernoid и другие игры с богатой
//! графикой, но простой логикой.

use crate::cartridge::{RomData, CHR_BANK};
use crate::mapper::{Mapper, Mirroring};

pub struct Cnrom {
    prg_rom: Vec<u8>,
    chr_rom: Vec<u8>,
    mirroring: Mirroring,
    chr_bank_count: usize,
    chr_bank: usize,
}

impl Cnrom {
    pub fn new(data: RomData) -> Self {
        // У CNROM всегда CHR ROM; на всякий случай подстрахуемся от пустого CHR.
        let chr_rom = if data.chr_rom.is_empty() {
            vec![0; CHR_BANK]
        } else {
            data.chr_rom
        };
        let chr_bank_count = (chr_rom.len() / CHR_BANK).max(1);
        Cnrom {
            prg_rom: data.prg_rom,
            chr_rom,
            mirroring: data.mirroring,
            chr_bank_count,
            chr_bank: 0,
        }
    }
}

impl Mapper for Cnrom {
    fn cpu_read(&mut self, addr: u16) -> u8 {
        match addr {
            0x8000..=0xFFFF => {
                let index = (addr - 0x8000) as usize % self.prg_rom.len();
                self.prg_rom[index]
            }
            _ => 0,
        }
    }

    fn cpu_write(&mut self, addr: u16, value: u8) {
        if addr >= 0x8000 {
            // Значение это номер банка CHR (маскируем по числу банков).
            self.chr_bank = value as usize % self.chr_bank_count;
        }
    }

    fn ppu_read(&mut self, addr: u16) -> u8 {
        let base = self.chr_bank * CHR_BANK;
        self.chr_rom[base + (addr & 0x1FFF) as usize]
    }

    fn ppu_write(&mut self, _addr: u16, _value: u8) {
        // CHR это ROM, запись игнорируется.
    }

    fn mirroring(&self) -> Mirroring {
        self.mirroring
    }
}

Логика та же, что у UxROM, только переключается другая шина: не cpu_read лезет в новый банк PRG, а ppu_read в новый банк CHR. ppu_write молчит, потому что CHR здесь ROM, а не RAM.

MMC1: банк по биту

MMC1 (маппер 1) программируется непривычно. Процессор не пишет банк одним байтом, а вдвигает его по биту в пятибитный сдвиговый регистр пятью записями. На пятой записи собранное значение попадает в один из четырёх внутренних регистров, а какой именно, выбирают старшие биты адреса. Запись с установленным старшим битом сбрасывает сдвиг. Так Nintendo сэкономила выводы микросхемы ценой протокола. Создай файл src/mapper/mmc1.rs:

//! MMC1 (маппер 1): последовательный загрузчик банков Nintendo.
//!
//! Урок 44. MMC1 непривычно программируется: процессор не пишет банк одним байтом,
//! а вдвигает его по биту в 5-битный сдвиговый регистр пятью записями. На пятой
//! записи значение попадает в один из четырёх внутренних регистров, а какой именно
//! выбирают биты 14 и 13 адреса. Запись с установленным старшим битом сбрасывает
//! сдвиг. Так MMC1 экономит выводы микросхемы ценой протокола.
//!
//! Регистр Control задаёт зеркалирование, режим банков PRG и режим банков CHR.
//! На MMC1 вышли The Legend of Zelda, Metroid, Mega Man 2 и сотни других игр.

use crate::cartridge::{RomData, PRG_BANK};
use crate::mapper::{Mapper, Mirroring};

const PRG_WINDOW: usize = 16 * 1024;
const CHR_WINDOW: usize = 4 * 1024;

pub struct Mmc1 {
    prg_rom: Vec<u8>,
    prg_ram: Vec<u8>,
    chr: Vec<u8>,
    chr_is_ram: bool,

    /// Сдвиговый регистр и счётчик вдвинутых бит.
    shift: u8,
    shift_count: u8,

    /// Внутренние регистры MMC1.
    control: u8,
    chr_bank0: u8,
    chr_bank1: u8,
    prg_bank: u8,

    prg_bank_count: usize,
}

impl Mmc1 {
    pub fn new(data: RomData) -> Self {
        let chr_is_ram = data.chr_rom.is_empty();
        let chr = if chr_is_ram {
            vec![0; 8 * 1024]
        } else {
            data.chr_rom
        };
        let prg_bank_count = (data.prg_rom.len() / PRG_BANK).max(1);
        Mmc1 {
            prg_rom: data.prg_rom,
            prg_ram: vec![0; data.prg_ram_size],
            chr,
            chr_is_ram,
            // Сброс выставляет старшие биты control: режим PRG 3 (фикс последнего банка).
            shift: 0,
            shift_count: 0,
            control: 0x0C,
            chr_bank0: 0,
            chr_bank1: 0,
            prg_bank: 0,
            prg_bank_count,
        }
    }

    /// Записать 5-битное значение в выбранный адресом регистр.
    fn write_register(&mut self, addr: u16, value: u8) {
        match addr {
            0x8000..=0x9FFF => self.control = value & 0x1F,
            0xA000..=0xBFFF => self.chr_bank0 = value & 0x1F,
            0xC000..=0xDFFF => self.chr_bank1 = value & 0x1F,
            _ => self.prg_bank = value & 0x0F,
        }
    }

    /// Смещение в PRG ROM для адреса CPU с учётом режима банков.
    fn prg_offset(&self, addr: u16) -> usize {
        let prg_mode = (self.control >> 2) & 0x03;
        let last = self.prg_bank_count - 1;
        let bank = self.prg_bank as usize;
        let window = (addr as usize - 0x8000) & (PRG_WINDOW - 1);
        let selected = match prg_mode {
            // 32 KiB за раз: младший бит банка игнорируется.
            0 | 1 => {
                let big = (bank & !1) % self.prg_bank_count;
                return big * PRG_BANK + (addr as usize - 0x8000);
            }
            // Фиксируем первый банк в $8000, переключаем $C000.
            2 => {
                if addr < 0xC000 {
                    0
                } else {
                    bank % self.prg_bank_count
                }
            }
            // Фиксируем последний банк в $C000, переключаем $8000.
            _ => {
                if addr < 0xC000 {
                    bank % self.prg_bank_count
                } else {
                    last
                }
            }
        };
        selected * PRG_BANK + window
    }

    /// Смещение в CHR для адреса PPU с учётом режима банков.
    fn chr_offset(&self, addr: u16) -> usize {
        let addr = (addr & 0x1FFF) as usize;
        let chr_4kb = self.control & 0x10 != 0;
        let bank_total = (self.chr.len() / CHR_WINDOW).max(1);
        if chr_4kb {
            // Два независимых окна по 4 KiB.
            let (bank, window) = if addr < CHR_WINDOW {
                (self.chr_bank0 as usize, addr)
            } else {
                (self.chr_bank1 as usize, addr - CHR_WINDOW)
            };
            (bank % bank_total) * CHR_WINDOW + window
        } else {
            // Одно окно 8 KiB: номер банка кратен двум 4-килобайтным единицам.
            let base_4kb = self.chr_bank0 as usize & 0x1E;
            (base_4kb % bank_total) * CHR_WINDOW + addr
        }
    }
}

impl Mapper for Mmc1 {
    fn cpu_read(&mut self, addr: u16) -> u8 {
        match addr {
            0x6000..=0x7FFF => {
                if self.prg_ram.is_empty() {
                    0
                } else {
                    let index = (addr - 0x6000) as usize % self.prg_ram.len();
                    self.prg_ram[index]
                }
            }
            0x8000..=0xFFFF => {
                let offset = self.prg_offset(addr);
                self.prg_rom[offset % self.prg_rom.len()]
            }
            _ => 0,
        }
    }

    fn cpu_write(&mut self, addr: u16, value: u8) {
        match addr {
            0x6000..=0x7FFF => {
                if !self.prg_ram.is_empty() {
                    let index = (addr - 0x6000) as usize % self.prg_ram.len();
                    self.prg_ram[index] = value;
                }
            }
            0x8000..=0xFFFF => {
                // Старший бит сбрасывает сдвиг и ставит режим PRG 3.
                if value & 0x80 != 0 {
                    self.shift = 0;
                    self.shift_count = 0;
                    self.control |= 0x0C;
                    return;
                }
                // Вдвигаем бит 0 значения в регистр (LSB первым).
                self.shift |= (value & 1) << self.shift_count;
                self.shift_count += 1;
                if self.shift_count == 5 {
                    let loaded = self.shift;
                    self.write_register(addr, loaded);
                    self.shift = 0;
                    self.shift_count = 0;
                }
            }
            _ => {}
        }
    }

    fn ppu_read(&mut self, addr: u16) -> u8 {
        let offset = self.chr_offset(addr);
        self.chr[offset % self.chr.len()]
    }

    fn ppu_write(&mut self, addr: u16, value: u8) {
        if self.chr_is_ram {
            let offset = self.chr_offset(addr);
            let len = self.chr.len();
            self.chr[offset % len] = value;
        }
    }

    fn mirroring(&self) -> Mirroring {
        match self.control & 0x03 {
            0 => Mirroring::SingleScreenLower,
            1 => Mirroring::SingleScreenUpper,
            2 => Mirroring::Vertical,
            _ => Mirroring::Horizontal,
        }
    }
}

Сердце MMC1 это cpu_write. Старший бит значения (value & 0x80) сбрасывает сдвиг и возвращает control в режим PRG 3. Иначе мы вдвигаем младший бит значения в shift, LSB первым, и считаем записи. На пятой собранное значение уходит в write_register, который по старшим битам адреса выбирает, какой из четырёх регистров обновить.

Регистр control задаёт сразу три вещи: зеркалирование (младшие два бита), режим банков PRG (биты 2 и 3) и режим банков CHR (бит 4). Поэтому prg_offset и chr_offset читают эти биты и решают, какой банк прибит, а какой переключается. Зеркалирование MMC1 умеет менять на лету, поэтому метод mirroring у него не возвращает поле, а вычисляет из control.

На MMC1 вышли The Legend of Zelda, Metroid и Mega Man 2. Это уже почти полноценный контроллер памяти: PRG, CHR и зеркалирование, всё переключаемо.

MMC3: банки плюс прерывание по строке

MMC3 (маппер 4) самый сложный из пятёрки, и на нём вышла половина поздней библиотеки NES: Super Mario Bros. 3, Mega Man с 3 по 6, Kirby’s Adventure. Он переключает PRG мелкими страницами по 8 КБ и CHR по 1 и 2 КБ. Но главное его отличие в другом: MMC3 умеет считать сканлайны экрана и дёргать IRQ на заданной строке. Так игры рисуют расщеплённый экран (неподвижный статусбар сверху, скролл снизу) без участия процессора в каждой строке.

Счётчик строк в железе тикает по фронтам линии A12 адреса PPU. Мы используем распространённое упрощение: PPU зовёт scanline_tick раз на видимую строку. Эта связь с PPU оживёт в уроке про рендеринг, а пока сама логика счётчика целиком наша. Создай файл src/mapper/mmc3.rs:

//! MMC3 (маппер 4, TxROM): банки плюс сканлайн-прерывание.
//!
//! Урок 44. Самый ходовой маппер второй половины жизни NES: на нём вышли Super
//! Mario Bros. 3, Mega Man 3 по 6, Kirby's Adventure, Crystalis и сотни других.
//! Он переключает PRG банками по 8 KiB и CHR банками по 1 и 2 KiB, а ещё умеет то,
//! чего нет у простых мапперов: считает сканлайны экрана и дёргает IRQ на заданной
//! строке. Так игры делают расщеплённый экран (статусбар сверху, скролл снизу) без
//! участия процессора в каждой строке.
//!
//! Счётчик строк в железе тикает по фронтам линии A12 адреса PPU (она прыгает,
//! когда PPU переключается между таблицей фона и таблицей спрайтов). Мы используем
//! распространённое упрощение: PPU зовёт [`Mapper::scanline_tick`] раз на видимую
//! строку. Этого хватает для подавляющего большинства игр.

use crate::cartridge::RomData;
use crate::mapper::{Mapper, Mirroring};

const PRG_PAGE: usize = 8 * 1024;
const CHR_PAGE: usize = 1024;

pub struct Mmc3 {
    prg_rom: Vec<u8>,
    prg_ram: Vec<u8>,
    chr: Vec<u8>,
    chr_is_ram: bool,

    /// Восемь банковых регистров: r0,r1 это 2 KiB CHR, r2..r5 это 1 KiB CHR,
    /// r6,r7 это 8 KiB PRG.
    bank_regs: [u8; 8],
    /// Что обновит следующая запись в `$8001` (младшие 3 бита `$8000`).
    bank_select: u8,
    /// Режим PRG (бит 6 `$8000`): какой банк PRG зафиксирован.
    prg_mode: bool,
    /// Режим CHR (бит 7 `$8000`): инверсия раскладки CHR (A12).
    chr_inversion: bool,

    mirroring: Mirroring,
    four_screen: bool,

    prg_page_count: usize,

    // --- сканлайн-IRQ ---
    irq_latch: u8,
    irq_counter: u8,
    irq_reload: bool,
    irq_enabled: bool,
    irq_pending: bool,
}

impl Mmc3 {
    pub fn new(data: RomData) -> Self {
        let chr_is_ram = data.chr_rom.is_empty();
        let chr = if chr_is_ram {
            vec![0; 8 * 1024]
        } else {
            data.chr_rom
        };
        let prg_page_count = (data.prg_rom.len() / PRG_PAGE).max(1);
        let four_screen = data.mirroring == Mirroring::FourScreen;
        Mmc3 {
            prg_rom: data.prg_rom,
            prg_ram: vec![0; data.prg_ram_size.max(8 * 1024)],
            chr,
            chr_is_ram,
            bank_regs: [0; 8],
            bank_select: 0,
            prg_mode: false,
            chr_inversion: false,
            mirroring: data.mirroring,
            four_screen,
            prg_page_count,
            irq_latch: 0,
            irq_counter: 0,
            irq_reload: false,
            irq_enabled: false,
            irq_pending: false,
        }
    }

    /// Смещение в PRG ROM для адреса CPU с учётом режима банков. Два окна
    /// переключаемые, два прибиты к предпоследнему и последнему банкам.
    fn prg_offset(&self, addr: u16) -> usize {
        let last = self.prg_page_count - 1;
        let second_last = last.saturating_sub(1);
        let r6 = self.bank_regs[6] as usize % self.prg_page_count;
        let r7 = self.bank_regs[7] as usize % self.prg_page_count;
        let page = match (addr >> 13) & 0x03 {
            // $8000-$9FFF
            0 => {
                if self.prg_mode {
                    second_last
                } else {
                    r6
                }
            }
            // $A000-$BFFF
            1 => r7,
            // $C000-$DFFF
            2 => {
                if self.prg_mode {
                    r6
                } else {
                    second_last
                }
            }
            // $E000-$FFFF
            _ => last,
        };
        page * PRG_PAGE + (addr as usize & (PRG_PAGE - 1))
    }

    /// Смещение в CHR для адреса PPU. Раскладка зависит от бита инверсии: либо два
    /// 2-килобайтных окна снизу и четыре 1-килобайтных сверху, либо наоборот.
    fn chr_offset(&self, addr: u16) -> usize {
        let addr = (addr & 0x1FFF) as usize;
        // Слот по 1 KiB (0..7) внутри 8 KiB пространства CHR.
        let slot = addr / CHR_PAGE;
        // Регистр и смещение внутри 2-килобайтных банков (младший бит игнорируется).
        let bank = if !self.chr_inversion {
            match slot {
                0 => self.bank_regs[0] as usize & 0xFE,
                1 => (self.bank_regs[0] as usize & 0xFE) + 1,
                2 => self.bank_regs[1] as usize & 0xFE,
                3 => (self.bank_regs[1] as usize & 0xFE) + 1,
                4 => self.bank_regs[2] as usize,
                5 => self.bank_regs[3] as usize,
                6 => self.bank_regs[4] as usize,
                _ => self.bank_regs[5] as usize,
            }
        } else {
            match slot {
                0 => self.bank_regs[2] as usize,
                1 => self.bank_regs[3] as usize,
                2 => self.bank_regs[4] as usize,
                3 => self.bank_regs[5] as usize,
                4 => self.bank_regs[0] as usize & 0xFE,
                5 => (self.bank_regs[0] as usize & 0xFE) + 1,
                6 => self.bank_regs[1] as usize & 0xFE,
                _ => (self.bank_regs[1] as usize & 0xFE) + 1,
            }
        };
        let page_count = (self.chr.len() / CHR_PAGE).max(1);
        (bank % page_count) * CHR_PAGE + (addr & (CHR_PAGE - 1))
    }
}

impl Mapper for Mmc3 {
    fn cpu_read(&mut self, addr: u16) -> u8 {
        match addr {
            0x6000..=0x7FFF => {
                let index = (addr - 0x6000) as usize % self.prg_ram.len();
                self.prg_ram[index]
            }
            0x8000..=0xFFFF => {
                let offset = self.prg_offset(addr);
                self.prg_rom[offset % self.prg_rom.len()]
            }
            _ => 0,
        }
    }

    fn cpu_write(&mut self, addr: u16, value: u8) {
        match addr {
            0x6000..=0x7FFF => {
                let index = (addr - 0x6000) as usize % self.prg_ram.len();
                self.prg_ram[index] = value;
            }
            // Регистры выбираются чётностью адреса в каждом диапазоне.
            0x8000..=0x9FFF => {
                if addr & 1 == 0 {
                    self.bank_select = value & 0x07;
                    self.prg_mode = value & 0x40 != 0;
                    self.chr_inversion = value & 0x80 != 0;
                } else {
                    self.bank_regs[self.bank_select as usize] = value;
                }
            }
            0xA000..=0xBFFF => {
                if addr & 1 == 0 && !self.four_screen {
                    self.mirroring = if value & 1 == 0 {
                        Mirroring::Vertical
                    } else {
                        Mirroring::Horizontal
                    };
                }
                // Нечётный адрес это защита PRG RAM, нам она не важна.
            }
            0xC000..=0xDFFF => {
                if addr & 1 == 0 {
                    self.irq_latch = value;
                } else {
                    // Запрос перезагрузки счётчика на следующем тике.
                    self.irq_counter = 0;
                    self.irq_reload = true;
                }
            }
            0xE000..=0xFFFF => {
                if addr & 1 == 0 {
                    // Запретить и снять текущий запрос IRQ.
                    self.irq_enabled = false;
                    self.irq_pending = false;
                } else {
                    self.irq_enabled = true;
                }
            }
            _ => {}
        }
    }

    fn ppu_read(&mut self, addr: u16) -> u8 {
        let offset = self.chr_offset(addr);
        self.chr[offset % self.chr.len()]
    }

    fn ppu_write(&mut self, addr: u16, value: u8) {
        if self.chr_is_ram {
            let offset = self.chr_offset(addr);
            let len = self.chr.len();
            self.chr[offset % len] = value;
        }
    }

    fn mirroring(&self) -> Mirroring {
        self.mirroring
    }

    fn irq_pending(&self) -> bool {
        self.irq_pending
    }

    fn scanline_tick(&mut self) {
        // Классическая логика MMC3: при нуле (или запросе) перезагрузить счётчик из
        // latch, иначе уменьшить. Если после этого счётчик стал нулём и IRQ
        // разрешён, поднять запрос.
        if self.irq_counter == 0 || self.irq_reload {
            self.irq_counter = self.irq_latch;
            self.irq_reload = false;
        } else {
            self.irq_counter -= 1;
        }
        if self.irq_counter == 0 && self.irq_enabled {
            self.irq_pending = true;
        }
    }
}

Тут многое держится на чётности адреса. В диапазоне $8000..=$9FFF чётный адрес выбирает, какой из восьми регистров обновится следующей записью, а нечётный записывает в него. Та же чётность в $C000 (latch против запроса перезагрузки) и $E000 (запретить против разрешить IRQ).

Логика прерывания живёт в scanline_tick. На каждой видимой строке счётчик либо перезагружается из latch (если он на нуле или был запрос), либо уменьшается на единицу. Как только он достигнет нуля при разрешённом IRQ, маппер поднимает irq_pending, и ядро 6502 увидит запрос на следующем шаге. Запись в $E000 снимает запрос. Сейчас scanline_tick некому звать, это сделает PPU, но логика уже готова и тестируется отдельно.

Зеркалирование нейметаблиц

Картридж задаёт ещё одну вещь, которая нужна не процессору, а PPU: как зеркалится видеопамять.

Зеркалирование определяет, как скроллится экран, и PPU спросит его в следующем уроке. Пока достаточно, что mirroring() это часть контракта маппера, потому что MMC1 и MMC3 умеют менять его прямо во время игры.

Проверка

Картриджный слой собран. Проверим его настоящим тестом: он грузит реальный nestest.nes, гоняет зеркалирование PRG у NROM, переключение банков у UxROM и CNROM, последовательную загрузку регистров MMC1 и сканлайн-IRQ у MMC3. Создай файл tests/mappers.rs:

//! Тесты картриджа и мапперов (урок 44).
//!
//! Проверяем разбор iNES на настоящем `nestest.nes` и поведение трёх мапперов:
//! зеркалирование PRG у NROM, переключение банков у UxROM, последовательную
//! загрузку регистров и фикс последнего банка у MMC1.

use our_nes::cartridge::{self, RomData, CHR_BANK, PRG_BANK};
use our_nes::mapper::{Cnrom, Mmc1, Mmc3, Nrom, UxRom};
use our_nes::{Mapper, Mirroring};

/// Собрать минимальный валидный iNES-образ для теста разбора.
fn ines(prg_banks: u8, chr_banks: u8, flags6: u8, mapper_hi: u8) -> Vec<u8> {
    let mut rom = vec![0u8; 16];
    rom[0..4].copy_from_slice(b"NES\x1a");
    rom[4] = prg_banks;
    rom[5] = chr_banks;
    rom[6] = flags6;
    rom[7] = mapper_hi;
    rom.resize(
        16 + prg_banks as usize * PRG_BANK + chr_banks as usize * 8 * 1024,
        0,
    );
    rom
}

#[test]
fn parses_real_nestest_header() {
    let rom = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/roms/nestest.nes")).unwrap();
    let mut mapper = cartridge::load(&rom).expect("nestest разбирается");
    // nestest это NROM-128 (16 KiB PRG), горизонтальное зеркалирование.
    assert_eq!(mapper.mirroring(), Mirroring::Horizontal);
    // Вектор сброса nestest указывает в PRG (читаем $FFFC/$FFFD).
    let reset = mapper.cpu_read(0xFFFC) as u16 | (mapper.cpu_read(0xFFFD) as u16) << 8;
    assert_eq!(reset, 0xC004, "вектор сброса nestest");
}

#[test]
fn rejects_non_ines() {
    assert!(cartridge::load(b"not a rom").is_err());
}

#[test]
fn mapper_id_combines_both_nibbles() {
    // Младший ниббл маппера в flags6 (биты 7-4), старший в flags7 (биты 7-4).
    let rom = ines(1, 1, 0x20, 0x10); // маппер = 0x10 | 0x02 = 0x12 = 18, не поддержан
    assert!(matches!(
        cartridge::load(&rom),
        Err(cartridge::RomError::UnsupportedMapper(18))
    ));
}

#[test]
fn nrom_mirrors_16kb_prg() {
    let mut prg = vec![0u8; PRG_BANK];
    prg[0] = 0xAA; // первый байт банка
    prg[PRG_BANK - 1] = 0xBB; // последний байт банка
    let data = RomData {
        prg_rom: prg,
        chr_rom: vec![],
        mirroring: Mirroring::Horizontal,
        prg_ram_size: 8 * 1024,
    };
    let mut nrom = Nrom::new(data);
    // 16 KiB отражается: $8000 и $C000 это один и тот же байт.
    assert_eq!(nrom.cpu_read(0x8000), 0xAA);
    assert_eq!(nrom.cpu_read(0xC000), 0xAA);
    assert_eq!(nrom.cpu_read(0xBFFF), 0xBB);
    assert_eq!(nrom.cpu_read(0xFFFF), 0xBB);
}

#[test]
fn nrom_chr_ram_is_writable() {
    let data = RomData {
        prg_rom: vec![0; PRG_BANK],
        chr_rom: vec![], // нет CHR ROM, значит CHR RAM
        mirroring: Mirroring::Horizontal,
        prg_ram_size: 0,
    };
    let mut nrom = Nrom::new(data);
    nrom.ppu_write(0x0123, 0x7E);
    assert_eq!(nrom.ppu_read(0x0123), 0x7E);
}

#[test]
fn uxrom_switches_low_bank_keeps_last_fixed() {
    // Четыре банка по 16 KiB, помечаем каждый его номером в первом байте.
    let mut prg = vec![0u8; 4 * PRG_BANK];
    for bank in 0..4 {
        prg[bank * PRG_BANK] = bank as u8;
    }
    let data = RomData {
        prg_rom: prg,
        chr_rom: vec![],
        mirroring: Mirroring::Vertical,
        prg_ram_size: 0,
    };
    let mut uxrom = UxRom::new(data);
    // По умолчанию нижнее окно это банк 0, верхнее всегда последний (банк 3).
    assert_eq!(uxrom.cpu_read(0x8000), 0);
    assert_eq!(uxrom.cpu_read(0xC000), 3);
    // Переключаем нижнее окно на банк 2.
    uxrom.cpu_write(0x8000, 2);
    assert_eq!(uxrom.cpu_read(0x8000), 2);
    assert_eq!(uxrom.cpu_read(0xC000), 3); // верхнее не изменилось
}

#[test]
fn cnrom_switches_chr_bank() {
    // Четыре банка CHR по 8 KiB, помечены номером в первом байте.
    let mut chr = vec![0u8; 4 * CHR_BANK];
    for bank in 0..4 {
        chr[bank * CHR_BANK] = bank as u8;
    }
    let data = RomData {
        prg_rom: vec![0; PRG_BANK],
        chr_rom: chr,
        mirroring: Mirroring::Horizontal,
        prg_ram_size: 0,
    };
    let mut cnrom = Cnrom::new(data);
    // По умолчанию виден банк 0.
    assert_eq!(cnrom.ppu_read(0x0000), 0);
    // Запись в ROM-окно выбирает банк CHR.
    cnrom.cpu_write(0x8000, 2);
    assert_eq!(cnrom.ppu_read(0x0000), 2);
    cnrom.cpu_write(0xFFFF, 3);
    assert_eq!(cnrom.ppu_read(0x0000), 3);
}

/// Вдвинуть 5-битное значение в регистр MMC1 пятью записями (LSB первым).
fn mmc1_load(mmc1: &mut Mmc1, addr: u16, value: u8) {
    for bit in 0..5 {
        mmc1.cpu_write(addr, (value >> bit) & 1);
    }
}

#[test]
fn mmc1_serial_load_switches_prg_bank() {
    // Восемь банков по 16 KiB, помечены номером.
    let mut prg = vec![0u8; 8 * PRG_BANK];
    for bank in 0..8 {
        prg[bank * PRG_BANK] = bank as u8;
    }
    let data = RomData {
        prg_rom: prg,
        chr_rom: vec![0; 8 * 1024],
        mirroring: Mirroring::Horizontal,
        prg_ram_size: 8 * 1024,
    };
    let mut mmc1 = Mmc1::new(data);
    // После сброса режим PRG 3: последний банк (7) прибит к $C000.
    assert_eq!(mmc1.cpu_read(0xC000), 7);
    // Переключаемое окно $8000 ставим на банк 5.
    mmc1_load(&mut mmc1, 0xE000, 5);
    assert_eq!(mmc1.cpu_read(0x8000), 5);
    assert_eq!(mmc1.cpu_read(0xC000), 7); // фикс не сдвинулся
}

#[test]
fn mmc1_reset_bit_clears_shift() {
    let data = RomData {
        prg_rom: vec![0; 2 * PRG_BANK],
        chr_rom: vec![0; 8 * 1024],
        mirroring: Mirroring::Horizontal,
        prg_ram_size: 0,
    };
    let mut mmc1 = Mmc1::new(data);
    // Частично вдвинули, затем сброс старшим битом, затем мусор не должен сложиться.
    mmc1.cpu_write(0x8000, 1);
    mmc1.cpu_write(0x8000, 1);
    mmc1.cpu_write(0x8000, 0x80); // сброс
                                  // Сброс ставит control в 0x0C: режим PRG 3, биты зеркалирования 0 (один экран).
    assert_eq!(mmc1.mirroring(), Mirroring::SingleScreenLower);
}

#[test]
fn mmc3_fixes_last_bank_and_switches_r6() {
    // Восемь страниц PRG по 8 KiB, помечены номером.
    let mut prg = vec![0u8; 8 * PRG_BANK]; // 16 страниц по 8 KiB
    let page = 8 * 1024;
    let pages = prg.len() / page;
    for p in 0..pages {
        prg[p * page] = p as u8;
    }
    let data = RomData {
        prg_rom: prg,
        chr_rom: vec![0; 8 * 1024],
        mirroring: Mirroring::Vertical,
        prg_ram_size: 8 * 1024,
    };
    let mut mmc3 = Mmc3::new(data);
    // Режим PRG 0 по умолчанию: $E000 это последняя страница, $C000 предпоследняя.
    assert_eq!(mmc3.cpu_read(0xE000), (pages - 1) as u8);
    assert_eq!(mmc3.cpu_read(0xC000), (pages - 2) as u8);
    // Выбрать регистр r6 ($8000 = 6) и положить туда страницу 3: окно $8000.
    mmc3.cpu_write(0x8000, 6);
    mmc3.cpu_write(0x8001, 3);
    assert_eq!(mmc3.cpu_read(0x8000), 3);
    assert_eq!(mmc3.cpu_read(0xE000), (pages - 1) as u8); // фикс не сдвинулся
}

#[test]
fn mmc3_scanline_irq_fires_after_latch() {
    let data = RomData {
        prg_rom: vec![0; 2 * PRG_BANK],
        chr_rom: vec![0; 8 * 1024],
        mirroring: Mirroring::Vertical,
        prg_ram_size: 0,
    };
    let mut mmc3 = Mmc3::new(data);
    // latch = 3, reload, enable.
    mmc3.cpu_write(0xC000, 3); // latch
    mmc3.cpu_write(0xC001, 0); // reload на следующем тике
    mmc3.cpu_write(0xE001, 0); // enable
    assert!(!mmc3.irq_pending());
    mmc3.scanline_tick(); // перезагрузка в 3
    mmc3.scanline_tick(); // 2
    mmc3.scanline_tick(); // 1
    assert!(!mmc3.irq_pending());
    mmc3.scanline_tick(); // 0 -> IRQ
    assert!(mmc3.irq_pending());
    // Запись в $E000 снимает запрос.
    mmc3.cpu_write(0xE000, 0);
    assert!(!mmc3.irq_pending());
}

#[test]
fn mmc1_control_sets_mirroring() {
    let data = RomData {
        prg_rom: vec![0; 2 * PRG_BANK],
        chr_rom: vec![0; 8 * 1024],
        mirroring: Mirroring::Horizontal,
        prg_ram_size: 0,
    };
    let mut mmc1 = Mmc1::new(data);
    // Записать в Control ($8000) значение с битами зеркалирования = 2 (вертикаль).
    mmc1_load(&mut mmc1, 0x8000, 0b0_1110); // mirroring=2, prg mode=3
    assert_eq!(mmc1.mirroring(), Mirroring::Vertical);
}

Запусти:

cargo test --test mappers

Что проверяет каждая группа:

  • parses_real_nestest_header грузит настоящий nestest, убеждается, что это NROM-128 с горизонтальным зеркалированием и что вектор сброса читается из $FFFC/$FFFD.
  • rejects_non_ines и mapper_id_combines_both_nibbles бьют по загрузчику: чужие байты отбиваются, а номер маппера правильно сшивается из двух нибблов.
  • NROM-тесты проверяют отражение 16 КБ в оба окна и запись в CHR RAM.
  • UxROM и CNROM проверяют само переключение: у первого прыгает нижнее окно PRG, верхнее держит последний банк; у второго переключается банк CHR.
  • MMC1-тесты гоняют сериализацию: пять записей собирают банк, фикс последнего банка не сдвигается, старший бит сбрасывает сдвиг, control меняет зеркалирование.
  • MMC3-тесты проверяют фикс последней страницы при переключении r6 и что сканлайн-IRQ срабатывает ровно через latch тиков, а запись в $E000 его снимает.

Все тесты зелёные значит картриджный слой готов целиком.

Дальше

Картридж отдаёт код и графику. Код уже исполняется на ядре 6502, а графика пока никому не нужна, потому что некому рисовать. Заводим художника: PPU и фон.

Домашка