Картридж и мапперы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Картридж и мапперы
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 и фон.