Раздел 23 · Rust

PPU и фон

lead~130 мин

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

PPU и фон

Здесь начинается картинка. PPU это вторая полноценная машина блока, со своей памятью, своим таймингом и восемью регистрами, через которые с ним говорит CPU. Урок длинный и про детали: палитры, тайлы из двух битовых планов, нейметаблицы, конвейер фона и тот самый скролл, который отличает работающий эмулятор от слайд-шоу. Спрайты оставим на следующий урок, сегодня только фон.

Сегодня мы создаём весь файл PPU целиком, от начала до конца. Он длинный, потому что PPU потактовая и в ней много полей и методов. Часть из них (спрайты) мы впишем сейчас, а разберём в уроке 46: так файл будет компилироваться сразу, без заглушек. Сегодня же мы пройдём фоновую половину: палитру, тайлы, нейметаблицы, скролл Loopy и конвейер выборки.

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

PPU это модуль-папка src/ppu/ с двумя файлами: mod.rs (вся машина) и palette.rs (таблица цветов). Сначала подключи модуль в src/lib.rs:

pub mod ppu;

Затем создай папку src/ppu/ и в ней два файла, начнём с палитры.

Создай файл src/ppu/palette.rs:

//! Палитра 2C02: перевод 64 аппаратных цветов NES в RGB.
//!
//! Урок 45. PPU не хранит RGB. Он хранит индекс 0..63 в фиксированной аппаратной
//! палитре, а уже она светит конкретным цветом на телевизоре. Точного эталона нет
//! (цвет зависел от телевизора и ревизии PPU), поэтому берём общеупотребимый
//! набор. Каждый элемент это `(R, G, B)`.

/// 64 цвета аппаратной палитры NES.
pub const NES_PALETTE: [(u8, u8, u8); 64] = [
    (0x62, 0x62, 0x62),
    (0x00, 0x1F, 0xB2),
    (0x24, 0x04, 0xC8),
    (0x52, 0x00, 0xB2),
    (0x73, 0x00, 0x76),
    (0x80, 0x00, 0x24),
    (0x73, 0x0B, 0x00),
    (0x52, 0x28, 0x00),
    (0x24, 0x44, 0x00),
    (0x00, 0x57, 0x00),
    (0x00, 0x5C, 0x00),
    (0x00, 0x53, 0x24),
    (0x00, 0x3C, 0x76),
    (0x00, 0x00, 0x00),
    (0x00, 0x00, 0x00),
    (0x00, 0x00, 0x00),
    (0xAB, 0xAB, 0xAB),
    (0x0D, 0x57, 0xFF),
    (0x4B, 0x30, 0xFF),
    (0x8A, 0x13, 0xFF),
    (0xBC, 0x08, 0xD6),
    (0xD2, 0x12, 0x69),
    (0xC7, 0x2E, 0x00),
    (0x9D, 0x54, 0x00),
    (0x60, 0x7B, 0x00),
    (0x20, 0x98, 0x00),
    (0x00, 0xA3, 0x00),
    (0x00, 0x99, 0x42),
    (0x00, 0x7D, 0xB4),
    (0x00, 0x00, 0x00),
    (0x00, 0x00, 0x00),
    (0x00, 0x00, 0x00),
    (0xFF, 0xFF, 0xFF),
    (0x53, 0xAE, 0xFF),
    (0x90, 0x85, 0xFF),
    (0xD3, 0x65, 0xFF),
    (0xFF, 0x57, 0xFF),
    (0xFF, 0x5D, 0xCF),
    (0xFF, 0x77, 0x57),
    (0xFA, 0x9E, 0x00),
    (0xBD, 0xC7, 0x00),
    (0x7A, 0xE7, 0x00),
    (0x43, 0xF6, 0x11),
    (0x26, 0xEF, 0x7E),
    (0x2C, 0xD5, 0xF6),
    (0x4E, 0x4E, 0x4E),
    (0x00, 0x00, 0x00),
    (0x00, 0x00, 0x00),
    (0xFF, 0xFF, 0xFF),
    (0xB6, 0xE1, 0xFF),
    (0xCE, 0xD1, 0xFF),
    (0xE9, 0xC3, 0xFF),
    (0xFF, 0xBC, 0xFF),
    (0xFF, 0xBD, 0xF4),
    (0xFF, 0xC6, 0xC3),
    (0xFF, 0xD5, 0x9A),
    (0xE9, 0xE6, 0x81),
    (0xCE, 0xF4, 0x81),
    (0xB6, 0xFB, 0x9A),
    (0xA9, 0xFA, 0xC3),
    (0xA9, 0xF0, 0xF4),
    (0xB8, 0xB8, 0xB8),
    (0x00, 0x00, 0x00),
    (0x00, 0x00, 0x00),
];

/// Перевести индекс палитры NES (0..63) в упакованный `0xFFRRGGBB` (ARGB, alpha 255).
pub fn rgb(index: u8) -> u32 {
    let (r, g, b) = NES_PALETTE[(index & 0x3F) as usize];
    0xFF00_0000 | (r as u32) << 16 | (g as u32) << 8 | b as u32
}

PPU не хранит RGB. Он хранит индекс 0..63 в этой фиксированной аппаратной палитре, а rgb уже переводит индекс в один из 64 цветов. Шестьдесят четыре цвета это весь набор, на который способна машина: ничего сверх него нарисовать нельзя.

Тайл это два битовых плана

Паттерн-таблица хранит графику не пикселями, а двумя битовыми планами на тайл. Тайл это 16 байт: первые восемь это младший бит цвета каждого пикселя по строкам, вторые восемь это старший бит. Чтобы получить пиксель, берём по биту из каждого плана и склеиваем. Вот декодирование одной строки тайла из эталона (отладочный рендер паттерн-таблицы для виджета, мы впишем его метод debug_render_pattern чуть позже):

let lo = mapper.ppu_read(tile_addr + row);     // младший план строки
let hi = mapper.ppu_read(tile_addr + row + 8); // старший план той же строки
for col in 0..8u16 {
    let bit = 7 - col;
    // склеиваем два бита в двухбитный индекс цвета 0..3
    let pixel = (((hi >> bit) & 1) << 1) | ((lo >> bit) & 1);
    let pal_addr = 0x3F00 | ((palette_slot as u16) << 2) | pixel as u16;
    let color = self.palette_ram[Self::palette_index(pal_addr)];
    out[...] = rgb(color);
}

Это прямое продолжение битовой упаковки из RU3: два байта по восемь бит склеиваются в восемь двухбитных индексов цвета. Покрути тайл в виджете: меняй байты двух планов, выбирай палитру, и смотри, как из шестнадцати байт собирается картинка 8x8. Подсветка строки показывает ровно ту склейку битов, что в коде выше.

Сам индекс это ещё не цвет. Это номер 0..3 внутри палитры. А rgb переводит выбранный из палитры цвет в один из 64 аппаратных цветов NES. Индекс 0 в любой палитре это общий фоновый цвет: он один на весь экран. Поэтому из четырёх цветов палитры реально своих только три, а нулевой всегда показывает фон. Эта деталь вернётся в уроке про спрайты: нулевой индекс там значит «прозрачно».

Создай файл src/ppu/mod.rs:

Теперь сама машина. Это длинный файл, но впиши его целиком: половина методов (спрайты) понадобится в уроке 46, а сейчас нужна, чтобы файл компилировался без заглушек. После листинга мы пройдём фоновую часть по порядку.

//! PPU 2C02: графический сопроцессор NES.
//!
//! Уроки 45 и 46. PPU рисует картинку 256 на 240 пикселей, такт за тактом, пока
//! процессор занят своей логикой. Это отдельная машина со своей памятью (таблицы
//! имён и палитра), своим адресным пространством и своим точным таймингом по
//! сканлайнам и точкам. Мы реализуем её по-точечно: один вызов [`Ppu::tick`] это
//! одна точка (dot), а кадр это 262 сканлайна по 341 точке.
//!
//! Скроллинг держится на «петлевых» регистрах `v` и `t` (так их назвал loopy на
//! форуме NESdev): 15 бит, в которых упакованы грубые и тонкие координаты и выбор
//! таблицы имён. Конвейер фона работает сдвиговыми регистрами, как в железе:
//! пока на экран выходит текущий тайл, PPU уже подбирает следующий.
//!
//! PPU обращается к знакогенератору (CHR) через картридж, поэтому методам доступа
//! нужен `&mut dyn Mapper`: банки CHR может переключать маппер (урок 44).

mod palette;

pub use palette::{rgb, NES_PALETTE};

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

/// Ширина кадра в пикселях.
pub const WIDTH: usize = 256;
/// Высота кадра в пикселях.
pub const HEIGHT: usize = 240;

const CTRL_NMI: u8 = 0x80;
const CTRL_SPRITE_SIZE: u8 = 0x20;
const CTRL_BG_TABLE: u8 = 0x10;
const CTRL_SPRITE_TABLE: u8 = 0x08;
const CTRL_VRAM_INCREMENT: u8 = 0x04;

const MASK_SHOW_BG: u8 = 0x08;
const MASK_SHOW_SPRITES: u8 = 0x10;
const MASK_BG_LEFT: u8 = 0x02;
const MASK_SPRITES_LEFT: u8 = 0x04;

const STATUS_VBLANK: u8 = 0x80;
const STATUS_SPRITE0: u8 = 0x40;
const STATUS_OVERFLOW: u8 = 0x20;

/// Состояние графического сопроцессора.
pub struct Ppu {
    // --- регистры, видимые процессору ---
    ctrl: u8,
    mask: u8,
    status: u8,
    oam_addr: u8,

    // --- петлевые регистры скролла (loopy) ---
    /// Текущий адрес VRAM (15 бит): fine_y(3) NN(2) coarse_y(5) coarse_x(5).
    v: u16,
    /// Временный адрес VRAM, куда копятся записи скролла и адреса.
    t: u16,
    /// Тонкий горизонтальный скролл (3 бита).
    fine_x: u8,
    /// Защёлка первой/второй записи в `$2005`/`$2006`.
    write_latch: bool,
    /// Буфер отложенного чтения `$2007` (чтение VRAM запаздывает на один доступ).
    data_buffer: u8,

    // --- память PPU ---
    /// Таблицы имён, 4 KiB (двух физических хватает, но запас под four-screen).
    vram: [u8; 0x1000],
    /// Палитровая память, 32 байта.
    palette_ram: [u8; 32],
    /// Спрайтовая память (Object Attribute Memory), 64 спрайта по 4 байта.
    oam: [u8; 256],

    // --- конвейер фона ---
    bg_next_id: u8,
    bg_next_attrib: u8,
    bg_next_lsb: u8,
    bg_next_msb: u8,
    bg_shift_pattern_lo: u16,
    bg_shift_pattern_hi: u16,
    bg_shift_attrib_lo: u16,
    bg_shift_attrib_hi: u16,

    // --- спрайты текущей сканлайн ---
    /// Отобранные на следующую строку спрайты (до восьми), копия их 4 байт OAM.
    scanline_sprites: [SpriteEntry; 8],
    sprite_count: usize,
    sprite_shift_lo: [u8; 8],
    sprite_shift_hi: [u8; 8],
    sprite_zero_possible: bool,
    sprite_zero_rendered: bool,

    // --- тайминг ---
    /// Сканлайн: 0..239 видимые, 240 post, 241..260 vblank, 261 pre-render (-1).
    scanline: i16,
    /// Точка в строке, 0..340.
    dot: u16,
    /// Чётность кадра: на нечётных пропускается одна точка пре-рендера.
    frame_odd: bool,

    // --- линия A12 для сканлайн-IRQ маппера (MMC3) ---
    /// Текущий уровень бита 12 адреса PPU на шине.
    a12_high: bool,
    /// Сколько точек подряд A12 держится низким (фильтр ложных фронтов).
    a12_low_dots: u16,

    // --- выходы ---
    /// Готовый кадр в формате `0xFFRRGGBB`.
    pub frame: Box<[u32; WIDTH * HEIGHT]>,
    /// Поднимается на один кадр, когда он дорисован (сигнал «пора показать»).
    pub frame_complete: bool,
    /// Защёлка фронта NMI к процессору (снимается через [`Ppu::take_nmi`]).
    nmi_pending: bool,
    /// Прошлое состояние линии NMI (`vblank AND nmi_output`) для детекта фронта.
    prev_nmi_line: bool,
    /// Чтение `$2002` рядом с моментом установки vblank подавляет флаг и NMI на
    /// этот кадр (классическая гонка PPU).
    suppress_vblank: bool,
}

/// Одна запись OAM: четыре байта на спрайт.
#[derive(Clone, Copy, Default)]
struct SpriteEntry {
    y: u8,
    tile: u8,
    attr: u8,
    x: u8,
    /// Индекс спрайта в OAM (нужен, чтобы отследить спрайт 0).
    index: u8,
}

impl Default for Ppu {
    fn default() -> Self {
        Self::new()
    }
}

impl Ppu {
    pub fn new() -> Self {
        Ppu {
            ctrl: 0,
            mask: 0,
            status: 0,
            oam_addr: 0,
            v: 0,
            t: 0,
            fine_x: 0,
            write_latch: false,
            data_buffer: 0,
            vram: [0; 0x1000],
            palette_ram: [0; 32],
            oam: [0; 256],
            bg_next_id: 0,
            bg_next_attrib: 0,
            bg_next_lsb: 0,
            bg_next_msb: 0,
            bg_shift_pattern_lo: 0,
            bg_shift_pattern_hi: 0,
            bg_shift_attrib_lo: 0,
            bg_shift_attrib_hi: 0,
            scanline_sprites: [SpriteEntry::default(); 8],
            sprite_count: 0,
            sprite_shift_lo: [0; 8],
            sprite_shift_hi: [0; 8],
            sprite_zero_possible: false,
            sprite_zero_rendered: false,
            scanline: -1,
            dot: 0,
            frame_odd: false,
            a12_high: false,
            a12_low_dots: 0,
            frame: Box::new([0xFF00_0000; WIDTH * HEIGHT]),
            frame_complete: false,
            nmi_pending: false,
            prev_nmi_line: false,
            suppress_vblank: false,
        }
    }

    /// Пересчитать линию NMI (`vblank AND nmi_output`) и защёлкнуть её фронт. Зовётся
    /// каждый такт PPU, а также сразу после записи `$2000` и чтения `$2002`, чтобы
    /// поймать включение NMI прямо во время vblank.
    fn update_nmi_line(&mut self) {
        let line = (self.status & STATUS_VBLANK != 0) && (self.ctrl & CTRL_NMI != 0);
        if line && !self.prev_nmi_line {
            self.nmi_pending = true;
        }
        self.prev_nmi_line = line;
    }

    /// Текущий сканлайн (-1 это pre-render, 0..239 видимые, 241..260 vblank).
    pub fn scanline(&self) -> i16 {
        self.scanline
    }

    /// Текущая точка в строке (0..340).
    pub fn dot(&self) -> u16 {
        self.dot
    }

    // --- отладочные представления для виджета ---
    //
    // Эти методы читают CHR напрямую через `mapper.ppu_read` и таблицы имён из
    // `vram`, минуя `read_vram`: иначе отладочный рендер дёргал бы линию A12 и сбивал
    // сканлайн-IRQ маппера. Состояние эмуляции они не меняют.

    /// Цвет записи палитровой памяти (0..31) как `0xFFRRGGBB`. Для просмотра 32
    /// ячеек палитры в отладчике.
    pub fn debug_palette_color(&self, slot: u8) -> u32 {
        rgb(self.palette_ram[(slot & 0x1F) as usize])
    }

    /// Байт OAM по индексу (для просмотра спрайтов).
    pub fn debug_oam(&self, index: u8) -> u8 {
        self.oam[index as usize]
    }

    /// Текущий регистр PPUCTRL (виджет показывает выбранные таблицы и режимы).
    pub fn debug_ctrl(&self) -> u8 {
        self.ctrl
    }

    /// Отрисовать таблицу паттернов CHR (`table` это 0 или 1) выбранной палитрой
    /// (`palette_slot` 0..7) в буфер 128 на 128 пикселей формата `0xFFRRGGBB`.
    pub fn debug_render_pattern(
        &self,
        table: u8,
        palette_slot: u8,
        out: &mut [u32],
        mapper: &mut dyn Mapper,
    ) {
        let base = (table as u16 & 1) * 0x1000;
        for tile in 0..256u16 {
            let tile_x = (tile % 16) * 8;
            let tile_y = (tile / 16) * 8;
            let tile_addr = base + tile * 16;
            for row in 0..8u16 {
                let lo = mapper.ppu_read(tile_addr + row);
                let hi = mapper.ppu_read(tile_addr + row + 8);
                for col in 0..8u16 {
                    let bit = 7 - col;
                    let pixel = (((hi >> bit) & 1) << 1) | ((lo >> bit) & 1);
                    let pal_addr = 0x3F00 | ((palette_slot as u16) << 2) | pixel as u16;
                    let color = self.palette_ram[Self::palette_index(pal_addr)];
                    out[((tile_y + row) * 128 + tile_x + col) as usize] = rgb(color);
                }
            }
        }
    }

    /// Отрисовать все четыре таблицы имён (фон с атрибутами) в буфер 512 на 480
    /// пикселей. Видно полную карту фона и зеркалирование.
    pub fn debug_render_nametables(&self, out: &mut [u32], mapper: &mut dyn Mapper) {
        let bg_base = ((self.ctrl & CTRL_BG_TABLE) as u16 >> 4) << 12;
        let mirroring = mapper.mirroring();
        for quad in 0..4u16 {
            let origin_x = (quad % 2) * 256;
            let origin_y = (quad / 2) * 240;
            let nt_base = 0x2000 + quad * 0x400;
            for trow in 0..30u16 {
                for tcol in 0..32u16 {
                    let tile =
                        self.vram[self.nametable_index(nt_base + trow * 32 + tcol, mirroring)];
                    let at_addr = nt_base + 0x3C0 + (trow / 4) * 8 + (tcol / 4);
                    let attr = self.vram[self.nametable_index(at_addr, mirroring)];
                    let shift = ((trow & 0x02) << 1) | (tcol & 0x02);
                    let pal_slot = ((attr >> shift) & 0x03) as u16;
                    let tile_addr = bg_base + (tile as u16) * 16;
                    for row in 0..8u16 {
                        let lo = mapper.ppu_read(tile_addr + row);
                        let hi = mapper.ppu_read(tile_addr + row + 8);
                        for col in 0..8u16 {
                            let bit = 7 - col;
                            let pixel = (((hi >> bit) & 1) << 1) | ((lo >> bit) & 1);
                            let pal_addr = 0x3F00 | (pal_slot << 2) | pixel as u16;
                            let color = self.palette_ram[Self::palette_index(pal_addr)];
                            let x = origin_x + tcol * 8 + col;
                            let y = origin_y + trow * 8 + row;
                            out[(y * 512 + x) as usize] = rgb(color);
                        }
                    }
                }
            }
        }
    }

    /// Снять фронт линии NMI (вызывает шина перед каждой инструкцией).
    pub fn take_nmi(&mut self) -> bool {
        let edge = self.nmi_pending;
        self.nmi_pending = false;
        edge
    }

    fn rendering_enabled(&self) -> bool {
        self.mask & (MASK_SHOW_BG | MASK_SHOW_SPRITES) != 0
    }

    fn sprite_height(&self) -> u16 {
        if self.ctrl & CTRL_SPRITE_SIZE != 0 {
            16
        } else {
            8
        }
    }

    // --- доступ процессора к регистрам $2000..$2007 ---

    /// Чтение регистра PPU (`reg` это `addr & 7`).
    pub fn read_register(&mut self, reg: u16, mapper: &mut dyn Mapper) -> u8 {
        match reg {
            0x2 => {
                // Гонка: чтение в момент установки vblank (scanline 241, точка 1)
                // или на такт раньше читается как 0 и подавляет флаг и NMI на кадр.
                if self.scanline == 241 && (self.dot == 0 || self.dot == 1) {
                    self.suppress_vblank = true;
                    self.status &= !STATUS_VBLANK;
                }
                // PPUSTATUS: старшие 3 бита это флаги, младшие 5 это шум (open bus).
                let value = (self.status & 0xE0) | (self.data_buffer & 0x1F);
                self.status &= !STATUS_VBLANK; // чтение сбрасывает vblank
                self.write_latch = false;
                self.update_nmi_line();
                value
            }
            0x4 => self.oam[self.oam_addr as usize],
            0x7 => {
                // PPUDATA читается с задержкой на один доступ, кроме палитры.
                let addr = self.v & 0x3FFF;
                let mut value = self.read_vram(addr, mapper);
                if addr < 0x3F00 {
                    std::mem::swap(&mut value, &mut self.data_buffer);
                } else {
                    // Палитра возвращается сразу, но буфер всё равно заполняется
                    // подлежащей таблицей имён.
                    self.data_buffer = self.read_vram(addr - 0x1000, mapper);
                }
                self.increment_vram();
                value
            }
            _ => self.data_buffer, // остальные регистры на чтение дают open bus
        }
    }

    /// Запись регистра PPU (`reg` это `addr & 7`).
    pub fn write_register(&mut self, reg: u16, value: u8, mapper: &mut dyn Mapper) {
        match reg {
            0x0 => {
                self.ctrl = value;
                // t биты 10-11 это выбор базовой таблицы имён.
                self.t = (self.t & 0xF3FF) | ((value as u16 & 0x03) << 10);
                // Линия NMI могла подняться (включили NMI во время vblank) или упасть.
                self.update_nmi_line();
            }
            0x1 => self.mask = value,
            0x3 => self.oam_addr = value,
            0x4 => {
                self.oam[self.oam_addr as usize] = value;
                self.oam_addr = self.oam_addr.wrapping_add(1);
            }
            0x5 => {
                if !self.write_latch {
                    // Первая запись: грубый X и тонкий X.
                    self.t = (self.t & 0xFFE0) | (value as u16 >> 3);
                    self.fine_x = value & 0x07;
                    self.write_latch = true;
                } else {
                    // Вторая запись: грубый Y и тонкий Y.
                    self.t = (self.t & 0x8FFF) | ((value as u16 & 0x07) << 12);
                    self.t = (self.t & 0xFC1F) | ((value as u16 & 0xF8) << 2);
                    self.write_latch = false;
                }
            }
            0x6 => {
                if !self.write_latch {
                    // Старший байт адреса (6 бит).
                    self.t = (self.t & 0x00FF) | ((value as u16 & 0x3F) << 8);
                    self.write_latch = true;
                } else {
                    // Младший байт; t переносится в v. Новый адрес выставляет A12 на
                    // шину, что может тикнуть счётчик маппера (квирк, который ловит
                    // mmc3 1-clocking).
                    self.t = (self.t & 0xFF00) | value as u16;
                    self.v = self.t;
                    self.write_latch = false;
                    let v = self.v;
                    self.drive_a12(v, mapper);
                }
            }
            0x7 => {
                let addr = self.v & 0x3FFF;
                self.write_vram(addr, value, mapper);
                self.increment_vram();
            }
            _ => {}
        }
    }

    /// Прямая запись байта в OAM (используется DMA через `$4014`).
    pub fn write_oam(&mut self, value: u8) {
        self.oam[self.oam_addr as usize] = value;
        self.oam_addr = self.oam_addr.wrapping_add(1);
    }

    fn increment_vram(&mut self) {
        let step = if self.ctrl & CTRL_VRAM_INCREMENT != 0 {
            32
        } else {
            1
        };
        self.v = self.v.wrapping_add(step) & 0x7FFF;
    }

    // --- адресное пространство PPU ---

    /// Перевести адрес таблицы имён (`$2000..$2FFF`) в индекс внутри `vram` с
    /// учётом зеркалирования картриджа.
    fn nametable_index(&self, addr: u16, mirroring: Mirroring) -> usize {
        let offset = (addr - 0x2000) & 0x0FFF;
        let table = (offset / 0x400) as usize; // 0..3
        let within = (offset & 0x03FF) as usize;
        let bank = match mirroring {
            Mirroring::Horizontal => table >> 1, // 0,0,1,1
            Mirroring::Vertical => table & 1,    // 0,1,0,1
            Mirroring::SingleScreenLower => 0,
            Mirroring::SingleScreenUpper => 1,
            Mirroring::FourScreen => table,
        };
        bank * 0x400 + within
    }

    /// Индекс в палитровой RAM с учётом зеркал `$3F10/$14/$18/$1C → $3F00/...`.
    fn palette_index(addr: u16) -> usize {
        let mut index = (addr & 0x1F) as usize;
        if index >= 0x10 && index.is_multiple_of(4) {
            index -= 0x10;
        }
        index
    }

    /// Обновить линию A12 по адресу на шине PPU. На фронте 0 в 1 после достаточно
    /// долгого низкого уровня тикает сканлайн-счётчик маппера (так MMC3 считает
    /// строки). Фильтр по времени отбрасывает короткие дрожания A12 между выборками
    /// фона и атрибутов внутри одного тайла.
    fn drive_a12(&mut self, addr: u16, mapper: &mut dyn Mapper) {
        let high = addr & 0x1000 != 0;
        if high {
            if !self.a12_high && self.a12_low_dots >= 8 {
                mapper.scanline_tick();
            }
            self.a12_high = true;
        } else {
            if self.a12_high {
                self.a12_low_dots = 0;
            }
            self.a12_high = false;
        }
    }

    fn read_vram(&mut self, addr: u16, mapper: &mut dyn Mapper) -> u8 {
        self.drive_a12(addr, mapper);
        match addr & 0x3FFF {
            0x0000..=0x1FFF => mapper.ppu_read(addr),
            0x2000..=0x3EFF => {
                let index = self.nametable_index((addr & 0x2FFF).max(0x2000), mapper.mirroring());
                self.vram[index]
            }
            _ => self.palette_ram[Self::palette_index(addr)],
        }
    }

    fn write_vram(&mut self, addr: u16, value: u8, mapper: &mut dyn Mapper) {
        self.drive_a12(addr, mapper);
        match addr & 0x3FFF {
            0x0000..=0x1FFF => mapper.ppu_write(addr, value),
            0x2000..=0x3EFF => {
                let index = self.nametable_index((addr & 0x2FFF).max(0x2000), mapper.mirroring());
                self.vram[index] = value;
            }
            _ => self.palette_ram[Self::palette_index(addr)] = value,
        }
    }

    // --- петлевые операции скролла (loopy) ---

    fn increment_scroll_x(&mut self) {
        if !self.rendering_enabled() {
            return;
        }
        if (self.v & 0x001F) == 31 {
            self.v &= !0x001F; // грубый X = 0
            self.v ^= 0x0400; // переключить горизонтальную таблицу имён
        } else {
            self.v += 1;
        }
    }

    fn increment_scroll_y(&mut self) {
        if !self.rendering_enabled() {
            return;
        }
        if (self.v & 0x7000) != 0x7000 {
            self.v += 0x1000; // тонкий Y += 1
        } else {
            self.v &= !0x7000; // тонкий Y = 0
            let mut coarse_y = (self.v & 0x03E0) >> 5;
            if coarse_y == 29 {
                coarse_y = 0;
                self.v ^= 0x0800; // переключить вертикальную таблицу имён
            } else if coarse_y == 31 {
                coarse_y = 0; // в зоне атрибутов, без переключения таблицы
            } else {
                coarse_y += 1;
            }
            self.v = (self.v & !0x03E0) | (coarse_y << 5);
        }
    }

    fn transfer_x(&mut self) {
        if !self.rendering_enabled() {
            return;
        }
        // Скопировать из t грубый X и горизонтальный бит таблицы имён.
        self.v = (self.v & !0x041F) | (self.t & 0x041F);
    }

    fn transfer_y(&mut self) {
        if !self.rendering_enabled() {
            return;
        }
        // Скопировать из t тонкий Y, грубый Y и вертикальный бит таблицы имён.
        self.v = (self.v & !0x7BE0) | (self.t & 0x7BE0);
    }

    fn load_background_shifters(&mut self) {
        self.bg_shift_pattern_lo = (self.bg_shift_pattern_lo & 0xFF00) | self.bg_next_lsb as u16;
        self.bg_shift_pattern_hi = (self.bg_shift_pattern_hi & 0xFF00) | self.bg_next_msb as u16;
        self.bg_shift_attrib_lo = (self.bg_shift_attrib_lo & 0xFF00)
            | if self.bg_next_attrib & 1 != 0 {
                0xFF
            } else {
                0
            };
        self.bg_shift_attrib_hi = (self.bg_shift_attrib_hi & 0xFF00)
            | if self.bg_next_attrib & 2 != 0 {
                0xFF
            } else {
                0
            };
    }

    fn update_shifters(&mut self) {
        if self.mask & MASK_SHOW_BG != 0 {
            self.bg_shift_pattern_lo <<= 1;
            self.bg_shift_pattern_hi <<= 1;
            self.bg_shift_attrib_lo <<= 1;
            self.bg_shift_attrib_hi <<= 1;
        }
        if self.mask & MASK_SHOW_SPRITES != 0 && (1..=257).contains(&self.dot) {
            for i in 0..self.sprite_count {
                if self.scanline_sprites[i].x > 0 {
                    self.scanline_sprites[i].x -= 1;
                } else {
                    self.sprite_shift_lo[i] <<= 1;
                    self.sprite_shift_hi[i] <<= 1;
                }
            }
        }
    }

    /// Один такт PPU (одна точка). Двигает тайминг, крутит конвейер фона, на
    /// видимых точках выводит пиксель. Возвращает управление шине, которая
    /// клокает PPU трижды на каждый такт процессора.
    pub fn tick(&mut self, mapper: &mut dyn Mapper) {
        let visible = self.scanline >= 0 && self.scanline < 240;
        let prerender = self.scanline == -1;

        if visible || prerender {
            self.run_fetch_pipeline(mapper);

            if prerender && (280..=304).contains(&self.dot) {
                self.transfer_y();
            }
            // Отбор спрайтов на следующую строку в конце видимой области.
            if self.dot == 257 && visible {
                self.evaluate_sprites();
                self.load_sprite_shifters(mapper);
            }
        }

        // Старт vblank: ставим флаг, если его не подавило чтение $2002 в гонке.
        if self.scanline == 241 && self.dot == 1 {
            if !self.suppress_vblank {
                self.status |= STATUS_VBLANK;
            }
            self.frame_complete = true;
        }
        // Конец vblank (pre-render, точка 1): сбрасываем флаги и подавление.
        if prerender && self.dot == 1 {
            self.status &= !(STATUS_VBLANK | STATUS_SPRITE0 | STATUS_OVERFLOW);
            self.suppress_vblank = false;
        }

        // Линия NMI пересчитывается каждый такт: фронт vblank поднимает NMI.
        self.update_nmi_line();

        if visible && (1..=256).contains(&self.dot) {
            self.render_pixel();
        }

        // Пока A12 низкий, копим длительность для фильтра фронта (см. drive_a12).
        if !self.a12_high {
            self.a12_low_dots = self.a12_low_dots.saturating_add(1);
        }

        self.advance_dot();
    }

    /// Конвейер выборки фона на видимых и пре-рендерной строках.
    fn run_fetch_pipeline(&mut self, mapper: &mut dyn Mapper) {
        let fetching = (2..=257).contains(&self.dot) || (321..=337).contains(&self.dot);
        if fetching {
            self.update_shifters();
            match (self.dot - 1) % 8 {
                0 => {
                    self.load_background_shifters();
                    let addr = 0x2000 | (self.v & 0x0FFF);
                    self.bg_next_id = self.read_vram(addr, mapper);
                }
                2 => {
                    let addr = 0x23C0
                        | (self.v & 0x0C00)
                        | ((self.v >> 4) & 0x38)
                        | ((self.v >> 2) & 0x07);
                    let mut attrib = self.read_vram(addr, mapper);
                    if (self.v >> 5) & 0x02 != 0 {
                        attrib >>= 4;
                    }
                    if self.v & 0x02 != 0 {
                        attrib >>= 2;
                    }
                    self.bg_next_attrib = attrib & 0x03;
                }
                4 => {
                    let fine_y = (self.v >> 12) & 0x07;
                    let base = (self.ctrl & CTRL_BG_TABLE) as u16 >> 4;
                    let addr = (base << 12) + ((self.bg_next_id as u16) << 4) + fine_y;
                    self.bg_next_lsb = self.read_vram(addr, mapper);
                }
                6 => {
                    let fine_y = (self.v >> 12) & 0x07;
                    let base = (self.ctrl & CTRL_BG_TABLE) as u16 >> 4;
                    let addr = (base << 12) + ((self.bg_next_id as u16) << 4) + fine_y + 8;
                    self.bg_next_msb = self.read_vram(addr, mapper);
                }
                7 => self.increment_scroll_x(),
                _ => {}
            }
        }

        if self.dot == 256 {
            self.increment_scroll_y();
        }
        if self.dot == 257 {
            self.load_background_shifters();
            self.transfer_x();
        }
        // Фиктивные чтения NT в конце строки (нужны мапперам, считающим обращения).
        if self.dot == 338 || self.dot == 340 {
            let addr = 0x2000 | (self.v & 0x0FFF);
            self.bg_next_id = self.read_vram(addr, mapper);
        }
    }

    /// Отобрать до восьми спрайтов, попадающих на следующую строку.
    fn evaluate_sprites(&mut self) {
        self.scanline_sprites = [SpriteEntry::default(); 8];
        self.sprite_count = 0;
        self.sprite_zero_possible = false;
        let height = self.sprite_height();

        for n in 0..64 {
            let base = n * 4;
            let y = self.oam[base] as i16;
            let row = self.scanline - y;
            if row < 0 || row >= height as i16 {
                continue;
            }
            if self.sprite_count == 8 {
                self.status |= STATUS_OVERFLOW;
                break;
            }
            if n == 0 {
                self.sprite_zero_possible = true;
            }
            self.scanline_sprites[self.sprite_count] = SpriteEntry {
                y: self.oam[base],
                tile: self.oam[base + 1],
                attr: self.oam[base + 2],
                x: self.oam[base + 3],
                index: n as u8,
            };
            self.sprite_count += 1;
        }
    }

    /// Подгрузить байты паттернов отобранных спрайтов в сдвиговые регистры.
    fn load_sprite_shifters(&mut self, mapper: &mut dyn Mapper) {
        let height = self.sprite_height();
        for i in 0..self.sprite_count {
            let sprite = self.scanline_sprites[i];
            let flip_v = sprite.attr & 0x80 != 0;
            let flip_h = sprite.attr & 0x40 != 0;
            let mut row = (self.scanline - sprite.y as i16) as u16;
            if flip_v {
                row = height - 1 - row;
            }

            let addr = if height == 16 {
                // 8x16: таблица из бита 0 номера тайла, верхняя и нижняя половины.
                let table = (sprite.tile as u16 & 0x01) << 12;
                let tile = (sprite.tile as u16 & 0xFE) + if row >= 8 { 1 } else { 0 };
                table | (tile << 4) | (row & 0x07)
            } else {
                let table = (self.ctrl & CTRL_SPRITE_TABLE) as u16; // 0x08
                (table << 9) | ((sprite.tile as u16) << 4) | row
            };

            let mut lo = self.read_vram(addr, mapper);
            let mut hi = self.read_vram(addr + 8, mapper);
            if flip_h {
                lo = lo.reverse_bits();
                hi = hi.reverse_bits();
            }
            self.sprite_shift_lo[i] = lo;
            self.sprite_shift_hi[i] = hi;
        }
    }

    /// Сосчитать и записать один пиксель экрана на текущей видимой точке.
    fn render_pixel(&mut self) {
        let x = self.dot - 1;
        let y = self.scanline as u16;

        // --- фон ---
        let (mut bg_pixel, mut bg_palette) = (0u8, 0u8);
        if self.mask & MASK_SHOW_BG != 0 && (self.mask & MASK_BG_LEFT != 0 || x >= 8) {
            let bit = 0x8000u16 >> self.fine_x;
            let p0 = ((self.bg_shift_pattern_lo & bit) != 0) as u8;
            let p1 = ((self.bg_shift_pattern_hi & bit) != 0) as u8;
            bg_pixel = (p1 << 1) | p0;
            let a0 = ((self.bg_shift_attrib_lo & bit) != 0) as u8;
            let a1 = ((self.bg_shift_attrib_hi & bit) != 0) as u8;
            bg_palette = (a1 << 1) | a0;
        }

        // --- спрайты ---
        let (mut fg_pixel, mut fg_palette, mut fg_priority) = (0u8, 0u8, false);
        self.sprite_zero_rendered = false;
        if self.mask & MASK_SHOW_SPRITES != 0 && (self.mask & MASK_SPRITES_LEFT != 0 || x >= 8) {
            for i in 0..self.sprite_count {
                // Спрайт активен, когда его счётчик X добежал до нуля.
                if self.scanline_sprites[i].x != 0 {
                    continue;
                }
                let p0 = ((self.sprite_shift_lo[i] & 0x80) != 0) as u8;
                let p1 = ((self.sprite_shift_hi[i] & 0x80) != 0) as u8;
                let pixel = (p1 << 1) | p0;
                if pixel == 0 {
                    continue; // прозрачный пиксель спрайта
                }
                fg_pixel = pixel;
                fg_palette = (self.scanline_sprites[i].attr & 0x03) + 4;
                fg_priority = self.scanline_sprites[i].attr & 0x20 == 0;
                if self.scanline_sprites[i].index == 0 {
                    self.sprite_zero_rendered = true;
                }
                break; // первый непрозрачный спрайт по приоритету OAM выигрывает
            }
        }

        // --- мультиплексор фона и спрайта ---
        let (pixel, palette) = match (bg_pixel, fg_pixel) {
            (0, 0) => (0, 0),
            (0, _) => (fg_pixel, fg_palette),
            (_, 0) => (bg_pixel, bg_palette),
            _ => {
                // Оба непрозрачны: решает приоритет спрайта.
                // И ровно здесь возможен sprite 0 hit.
                if self.sprite_zero_possible
                    && self.sprite_zero_rendered
                    && self.mask & MASK_SHOW_BG != 0
                    && self.mask & MASK_SHOW_SPRITES != 0
                    && x != 255
                {
                    self.status |= STATUS_SPRITE0;
                }
                if fg_priority {
                    (fg_pixel, fg_palette)
                } else {
                    (bg_pixel, bg_palette)
                }
            }
        };

        let color_index =
            self.palette_ram[Self::palette_index(0x3F00 | ((palette as u16) << 2) | pixel as u16)];
        self.frame[y as usize * WIDTH + x as usize] = rgb(color_index);
    }

    fn advance_dot(&mut self) {
        self.dot += 1;
        if self.dot > 340 {
            self.dot = 0;
            self.scanline += 1;
            if self.scanline > 260 {
                self.scanline = -1;
                self.frame_odd = !self.frame_odd;
                // На нечётных кадрах точка (0,0) пропускается при включённом рендере.
                if self.frame_odd && self.rendering_enabled() {
                    self.dot = 1;
                }
            }
        }
    }
}

Файл написан, разберём его фоновую часть по порядку.

Поля структуры

Ppu это всё состояние машины в одном месте. Сверху регистры, видимые процессору (ctrl, mask, status, oam_addr), затем петлевые регистры скролла (v, t, fine_x, write_latch, data_buffer), память (vram под таблицы имён, palette_ram под палитру, oam под спрайты), поля конвейера фона (bg_next_* и bg_shift_*), блок спрайтов (мы трогаем его в уроке 46), тайминг (scanline, dot, frame_odd) и выходы (frame, frame_complete, флаги NMI). frame лежит в Box, потому что 256 на 240 это 61440 значений по 4 байта: такой массив не хочется держать на стеке.

Память и адресное пространство PPU

У PPU своя 14-битная шина, отдельная от CPU. По ней разложены тайлы из картриджа, нейметаблицы и палитры.

Диапазон PPUЧто это
0x0000 до 0x1FFFпаттерн-таблицы (CHR картриджа), тайлы 8x8
0x2000 до 0x2FFFчетыре нейметаблицы (с зеркалированием от маппера)
0x3F00 до 0x3F1Fпалитры фона и спрайтов

Нейметаблица это карта экрана: 32 на 30 номеров тайлов. Рядом attribute table упаковывает выбор палитры по блокам 2x2 тайла, снова битами: два бита на блок. Адрес ячейки в vram зависит от зеркалирования картриджа, поэтому перевод вынесен в nametable_index:

fn nametable_index(&self, addr: u16, mirroring: Mirroring) -> usize {
    let offset = (addr - 0x2000) & 0x0FFF;
    let table = (offset / 0x400) as usize; // 0..3
    let within = (offset & 0x03FF) as usize;
    let bank = match mirroring {
        Mirroring::Horizontal => table >> 1, // 0,0,1,1
        Mirroring::Vertical => table & 1,    // 0,1,0,1
        Mirroring::SingleScreenLower => 0,
        Mirroring::SingleScreenUpper => 1,
        Mirroring::FourScreen => table,
    };
    bank * 0x400 + within
}

Вот и видно, зачем маппер из прошлого урока отдаёт mirroring(): ровно здесь четыре логических таблицы складываются в две физических. Горизонтальное даёт пары 0,0,1,1, вертикальное 0,1,0,1, и от этого зависит, куда уедет экран при скролле. Читает и пишет память пара read_vram и write_vram: они смотрят на старшие биты адреса и разводят обращение по картриджу (паттерны), vram (таблицы имён через nametable_index) или palette_ram (с зеркалами $3F10/$14/$18/$1C в palette_index).

Регистры $2000..$2007

CPU видит PPU через восемь регистров в $2000..$2007, и за всё чтение и запись отвечают read_register и write_register. На чтении интересны три: $2002 (PPUSTATUS, отдаёт флаги vblank и сбрасывает защёлку), $2004 (байт OAM), $2007 (PPUDATA с отложенным чтением через data_buffer). На записи $2000 это PPUCTRL (биты NMI, базовая таблица имён попадают в t), $2001 это PPUMASK (показывать фон и спрайты), $2003/$2004 это OAM, а $2005 (PPUSCROLL) и $2006 (PPUADDR) принимают по два байта подряд и делят общую защёлку write_latch.

Регистры скролла: модель Loopy

Весь скролл фона держится в четвёрке внутренних регистров v, t, fine_x, write_latch. Их назвал так пользователь loopy на форуме NESdev, и понять их это и есть понять фон NES.

Пятнадцать бит регистра v упакованы хитро: fine_y(3) NN(2) coarse_y(5) coarse_x(5). То есть в одном числе сидят и номер тайла по горизонтали, и по вертикали, и выбор нейметаблицы, и тонкое смещение внутри тайла. Записи в $2005 раскладываются по этим полям. Вот ветка $2005 из write_register:

0x5 => {
    if !self.write_latch {
        // Первая запись: грубый X и тонкий X.
        self.t = (self.t & 0xFFE0) | (value as u16 >> 3);
        self.fine_x = value & 0x07;
        self.write_latch = true;
    } else {
        // Вторая запись: грубый Y и тонкий Y.
        self.t = (self.t & 0x8FFF) | ((value as u16 & 0x07) << 12);
        self.t = (self.t & 0xFC1F) | ((value as u16 & 0xF8) << 2);
        self.write_latch = false;
    }
}

Важно: записи копятся во временный t, а не в рабочий v. Перенос t в v происходит в строго определённые моменты кадра, и в этом весь фокус. Горизонтальные биты копируются в начале каждой видимой строки (transfer_x), вертикальные один раз в начале кадра (transfer_y):

fn transfer_x(&mut self) {
    if !self.rendering_enabled() { return; }
    // Скопировать из t грубый X и горизонтальный бит таблицы имён.
    self.v = (self.v & !0x041F) | (self.t & 0x041F);
}

fn transfer_y(&mut self) {
    if !self.rendering_enabled() { return; }
    // Скопировать из t тонкий Y, грубый Y и вертикальный бит таблицы имён.
    self.v = (self.v & !0x7BE0) | (self.t & 0x7BE0);
}

А по ходу строки v сам инкрементируется. Горизонтальный инкремент после каждого тайла умеет переключать нейметаблицу при выходе за край (increment_scroll_x), вертикальный в конце строки (increment_scroll_y). Вот горизонтальный, с переносом через границу экрана:

fn increment_scroll_x(&mut self) {
    if !self.rendering_enabled() { return; }
    if (self.v & 0x001F) == 31 {
        self.v &= !0x001F; // грубый X = 0
        self.v ^= 0x0400;  // переключить горизонтальную таблицу имён
    } else {
        self.v += 1;
    }
}

Эти методы (transfer_x, transfer_y, increment_scroll_x, increment_scroll_y и тонкий fine_x) и есть весь скролл NES. Скролл это не «сдвинуть картинку на N пикселей», а аккуратно гонять биты в v по расписанию кадра.

Конвейер фона

PPU не рисует тайл целиком, а работает как конвейер сдвиговых регистров, точь-в-точь как железо: пока на экран выходит текущий тайл, PPU уже подбирает следующий. Вся выборка собрана в run_fetch_pipeline, по строгому расписанию тактов, по восемь тактов на тайл: на такте 0 номер тайла, на 2 атрибут, на 4 и 6 два плана паттерна, на 7 сдвиг скролла.

match (self.dot - 1) % 8 {
    0 => {
        self.load_background_shifters();
        let addr = 0x2000 | (self.v & 0x0FFF);
        self.bg_next_id = self.read_vram(addr, mapper); // номер тайла
    }
    2 => {
        // адрес атрибута собран из полей v; два бита палитры на блок 2x2
        let addr = 0x23C0 | (self.v & 0x0C00)
            | ((self.v >> 4) & 0x38) | ((self.v >> 2) & 0x07);
        let mut attrib = self.read_vram(addr, mapper);
        if (self.v >> 5) & 0x02 != 0 { attrib >>= 4; }
        if self.v & 0x02 != 0 { attrib >>= 2; }
        self.bg_next_attrib = attrib & 0x03;
    }
    4 => {
        let fine_y = (self.v >> 12) & 0x07;
        let base = (self.ctrl & CTRL_BG_TABLE) as u16 >> 4;
        let addr = (base << 12) + ((self.bg_next_id as u16) << 4) + fine_y;
        self.bg_next_lsb = self.read_vram(addr, mapper); // младший план
    }
    6 => { /* старший план: тот же адрес + 8 */ }
    7 => self.increment_scroll_x(),
    _ => {}
}

Заметь: адрес тайла 0x2000 | (self.v & 0x0FFF) берёт нужную ячейку нейметаблицы прямо из v. То есть скролл и выборка это одна и та же машина: v одновременно и «где мы на экране», и «откуда читать». Подобранные байты load_background_shifters сдвигает в шифт-регистры, а пиксель собирает render_pixel с учётом тонкого fine_x.

// внутри render_pixel, фоновая часть:
let bit = 0x8000u16 >> self.fine_x;
let p0 = ((self.bg_shift_pattern_lo & bit) != 0) as u8;
let p1 = ((self.bg_shift_pattern_hi & bit) != 0) as u8;
bg_pixel = (p1 << 1) | p0;            // двухбитный индекс цвета
let a0 = ((self.bg_shift_attrib_lo & bit) != 0) as u8;
let a1 = ((self.bg_shift_attrib_hi & bit) != 0) as u8;
bg_palette = (a1 << 1) | a0;          // номер палитры из атрибута

Тайминг сканлайнов

PPU рисует кадр сканлайн за сканлайном, пиксель за пикселем: 341 точка на строку, 262 строки на кадр, включая невидимые. Вся машина это один метод tick, одна точка за вызов, который шина зовёт трижды на каждый такт процессора. Финальный урок свяжет это в кадр, а пока запомни: PPU потактовая, и именно поэтому скролл получается точным.

Спрайты впишем, а разберём дальше

В файле уже есть всё спрайтовое: поля scanline_sprites, sprite_*, структура SpriteEntry, методы evaluate_sprites, load_sprite_shifters и спрайтовая половина render_pixel (мультиплексор фона и спрайта, sprite 0 hit). Мы вписали их сейчас, чтобы файл компилировался целиком, но разбирать будем в уроке 46: OAM, отбор восьми спрайтов на строку, приоритет и тонкости тайминга. Пока просто оставь их на месте, на фон они не влияют.

Посмотреть живьём

Виджет ниже это наш эмулятор examples/our-nes/, собранный в WebAssembly: тот самый код, что мы пишем в блоке. В нём встроен визуальный отладчик PPU: паттерн-таблицы (тайлы из CHR картриджа), текущие палитры, нейметаблица. Открой панель PPU, погоняй кадры Шагом и сопоставь: тайлы в паттерн-таблице это ровно те шестнадцатибайтные блоки, что мы декодировали выше, а нейметаблица это сетка их номеров.

Проверка

Положи в tests/ppu.rs golden-тест всего конвейера. Он собирает крошечный NROM-образ прямо из машинного кода 6502, прошивает палитру, CHR RAM и таблицу имён, гоняет пару кадров и сверяет конкретные пиксели готового кадра.

//! Интеграционные тесты PPU на самодельных мини-картриджах (уроки 45, 46, 48).
//!
//! Готовых игр в репозитории нет, поэтому проверяем PPU собственными крошечными
//! программами 6502: собираем NROM-образ с CHR RAM, прошиваем код, который
//! настраивает палитру, знакогенератор и таблицу имён, гоняем кадр и сверяем
//! пиксели готового кадра. Это golden-тест всего конвейера: регистры PPU, скролл,
//! выборка фона, спрайты, NMI.

use our_nes::ppu::{rgb, HEIGHT, WIDTH};
use our_nes::Nes;

/// Собрать NROM-образ (16 KiB PRG, 8 KiB CHR RAM) из кода и вектора сброса.
fn build_rom(code: &[u8], reset: u16, nmi: u16) -> Vec<u8> {
    let mut rom = vec![0u8; 16];
    rom[0..4].copy_from_slice(b"NES\x1a");
    rom[4] = 1; // 1 банк PRG = 16 KiB
    rom[5] = 0; // 0 банков CHR = CHR RAM
    rom[6] = 0; // маппер 0, горизонтальное зеркалирование

    let mut prg = vec![0u8; 16 * 1024];
    prg[..code.len()].copy_from_slice(code);
    // Векторы в конце PRG: они отражаются на $FFFA..$FFFF.
    prg[0x3FFA] = (nmi & 0xFF) as u8;
    prg[0x3FFB] = (nmi >> 8) as u8;
    prg[0x3FFC] = (reset & 0xFF) as u8;
    prg[0x3FFD] = (reset >> 8) as u8;
    rom.extend_from_slice(&prg);
    rom
}

/// Адрес начала PRG в пространстве процессора.
const PRG_BASE: u16 = 0x8000;

#[test]
fn renders_backdrop_color() {
    // Программа: записать цвет фона в $3F00 и включить рендер фона.
    #[rustfmt::skip]
    let code = [
        0xA9, 0x3F,       // LDA #$3F
        0x8D, 0x06, 0x20, // STA $2006  (старший байт адреса палитры)
        0xA9, 0x00,       // LDA #$00
        0x8D, 0x06, 0x20, // STA $2006  (младший: адрес $3F00)
        0xA9, 0x21,       // LDA #$21   (синий цвет палитры NES)
        0x8D, 0x07, 0x20, // STA $2007  (записать в $3F00)
        0xA9, 0x08,       // LDA #$08
        0x8D, 0x01, 0x20, // STA $2001  (PPUMASK: показывать фон)
        0x4C, 0x14, 0x80, // JMP $8014  (вечный цикл)
    ];
    let rom = build_rom(&code, PRG_BASE, PRG_BASE);
    let mut nes = Nes::from_rom(&rom).unwrap();

    // Пара кадров на прогрев и применение настроек.
    nes.run_frame();
    nes.run_frame();

    let expected = rgb(0x21);
    let frame = nes.frame();
    // Без тайлов и паттернов весь фон это нулевой пиксель = цвет фона $3F00.
    assert_eq!(frame[0], expected, "верхний левый пиксель это цвет фона");
    assert_eq!(frame[WIDTH * HEIGHT / 2], expected, "центр кадра тоже фон");
    assert_eq!(
        frame[WIDTH * HEIGHT - 1],
        expected,
        "нижний правый угол тоже фон"
    );
}

#[test]
fn renders_a_tile_from_chr_ram() {
    // Прошиваем в CHR RAM тайл 1 (плоскость 0 вся в единицах = пиксель 1),
    // ставим палитру (фон чёрный, цвет 1 белый), заполняем угол таблицы имён
    // тайлом 1 и включаем фон с показом левого столбца.
    #[rustfmt::skip]
    let code = [
        // --- CHR RAM: тайл 1 по адресу $0010, 8 байт плоскости 0 = $FF ---
        0xA9, 0x00, 0x8D, 0x06, 0x20, // LDA #$00; STA $2006
        0xA9, 0x10, 0x8D, 0x06, 0x20, // LDA #$10; STA $2006  (адрес $0010)
        0xA2, 0x08,                   // LDX #$08            (8 строк)
        0xA9, 0xFF,                   // LDA #$FF
        // loop_lo ($8010):
        0x8D, 0x07, 0x20,             // STA $2007
        0xCA,                         // DEX
        0xD0, 0xFA,                   // BNE loop_lo (-6)
        // 8 байт плоскости 1 = $00
        0xA2, 0x08,                   // LDX #$08
        0xA9, 0x00,                   // LDA #$00
        // loop_hi ($801B):
        0x8D, 0x07, 0x20,             // STA $2007
        0xCA,                         // DEX
        0xD0, 0xFA,                   // BNE loop_hi (-6)
        // --- палитра: $3F00 = $0F (чёрный), $3F01 = $30 (белый) ---
        0xA9, 0x3F, 0x8D, 0x06, 0x20, // LDA #$3F; STA $2006
        0xA9, 0x00, 0x8D, 0x06, 0x20, // LDA #$00; STA $2006  (адрес $3F00)
        0xA9, 0x0F, 0x8D, 0x07, 0x20, // LDA #$0F; STA $2007  ($3F00 = чёрный)
        0xA9, 0x30, 0x8D, 0x07, 0x20, // LDA #$30; STA $2007  ($3F01 = белый)
        // --- таблица имён: $2000 = тайл 1 (верхний левый угол экрана) ---
        0xA9, 0x20, 0x8D, 0x06, 0x20, // LDA #$20; STA $2006
        0xA9, 0x00, 0x8D, 0x06, 0x20, // LDA #$00; STA $2006  (адрес $2000)
        0xA9, 0x01, 0x8D, 0x07, 0x20, // LDA #$01; STA $2007  (тайл 1)
        // --- сброс адреса скролла в 0 ---
        0xA9, 0x00, 0x8D, 0x06, 0x20, // LDA #$00; STA $2006
        0x8D, 0x06, 0x20,             // STA $2006  (v = $0000)
        // --- PPUMASK: фон + левый столбец ($08 | $02) ---
        0xA9, 0x0A, 0x8D, 0x01, 0x20, // LDA #$0A; STA $2001
        // вечный цикл
        0x4C, 0x4E, 0x80,             // JMP $804E
    ];
    let rom = build_rom(&code, PRG_BASE, PRG_BASE);
    let mut nes = Nes::from_rom(&rom).unwrap();

    nes.run_frame();
    nes.run_frame();

    let frame = nes.frame();
    let white = rgb(0x30);
    let black = rgb(0x0F);
    // Верхний левый тайл (8x8) должен быть белым (пиксель 1, палитра 0 цвет 1).
    assert_eq!(frame[0], white, "пиксель (0,0) тайла белый");
    assert_eq!(frame[7], white, "пиксель (7,0) тайла белый");
    assert_eq!(frame[7 * WIDTH + 7], white, "пиксель (7,7) тайла белый");
    // Сразу за тайлом снова фон (тайл 0, пиксель 0 = чёрный backdrop).
    assert_eq!(frame[8], black, "пиксель (8,0) это фон");
    assert_eq!(frame[10 * WIDTH], black, "ниже тайла это фон");
}

#[test]
fn vblank_triggers_nmi() {
    // NMI-обработчик инкрементит счётчик в $00. Главный код включает NMI и крутит
    // цикл. За несколько кадров счётчик должен заметно вырасти.
    #[rustfmt::skip]
    let main = [
        0xA9, 0x80,       // LDA #$80
        0x8D, 0x00, 0x20, // STA $2000  (PPUCTRL: включить NMI на vblank)
        0x4C, 0x05, 0x80, // JMP $8005  (вечный цикл)
    ];
    // Обработчик NMI кладём по фиксированному адресу $8040.
    #[rustfmt::skip]
    let handler = [
        0xE6, 0x00,       // INC $00
        0x40,             // RTI
    ];
    let mut code = vec![0u8; 0x43];
    code[..main.len()].copy_from_slice(&main);
    code[0x40..0x40 + handler.len()].copy_from_slice(&handler);

    let rom = build_rom(&code, PRG_BASE, 0x8040);
    let mut nes = Nes::from_rom(&rom).unwrap();

    for _ in 0..5 {
        nes.run_frame();
    }
    let count = nes.read_byte(0x0000);
    assert!(
        count >= 4,
        "NMI должен срабатывать раз в кадр, насчитали {count}"
    );
}

Запусти:

cargo test --test ppu

Три теста должны пройти: однотонный фон по цвету палитры, один тайл из CHR RAM в углу экрана и срабатывание NMI на vblank.

Дальше

Фон есть, но мир NES состоит ещё и из подвижных объектов поверх него. Дальше спрайты: OAM, sprite 0 hit и тонкости тайминга, на которых держится половина игровых трюков. Код спрайтов уже в нашем файле, осталось его понять.

Домашка