Раздел 23 · Rust

Ядро 6502

senior~160 мин

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

Ядро 6502

Самый длинный урок блока и самый важный. Учебный процессор из прошлого проекта мы рисовали под себя: фиксированная ширина инструкции, чистая таблица опкодов. 6502 другой: переменная длина, тринадцать режимов адресации, такты, которые зависят от пересечения страницы, фиктивные чтения. Зато на него есть эталонный тест nestest с побайтовым логом, и наша цель прямая: гонять его, пока трейс не совпадёт с золотым до последнего флага и такта.

Это первый урок, где ты пишешь код, а не читаешь. Дальше по блоку мы строим эмулятор NES по имени our-nes с нуля, файл за файлом, и каждый урок добавляет один кусок. Сегодня закладываем проект и собираем процессор целиком: к концу урока у тебя зелёный nestest, значит ядро считает правильно.

Заводим проект

Создай библиотечный крейт. Бинарь и crate-type мы пропишем руками в Cargo.toml, поэтому стартуем именно как либу.

cargo new --lib our-nes
cd our-nes

Теперь замени сгенерированный Cargo.toml на полный. Тут две поверхности у одного кода: rlib для уроков и тестов, cdylib для будущей сборки в wasm (движок виджета в уроке 48). Нативный раннер с окном и звуком объявлен бинарём, а его зависимости (minifb, cpal) висят под target-cfg, чтобы не лезть в wasm-сборку. На уроке 43 эти зависимости нам ещё не нужны, но Cargo стянет их при первой сборке нативного бинаря; если хочешь только гонять тесты, бинарь можно временно не создавать.

Создай файл Cargo.toml:

[package]
name = "our-nes"
version = "0.1.0"
edition = "2021"
description = "our-nes: учебный эмулятор NES (6502, PPU, APU, мапперы), эталон блока RU7"
license = "MIT"
publish = false

[lib]
# rlib для уроков и тестов, cdylib для сборки в wasm32 (движок виджета урока 48).
# Тот же код, две поверхности: feature "wasm" включает плоскую C-ABI обёртку,
# ровно как в эталоне RU6 (examples/our-cpu).
name = "our_nes"
crate-type = ["rlib", "cdylib"]

[features]
# Включает модуль `wasm` с экспортами extern "C" для браузера.
wasm = []

[[bin]]
# Нативный раннер: `cargo run -- путь/к/игре.nes`. Это тонкая оболочка вокруг либы.
name = "our-nes"
path = "src/main.rs"

[dependencies]

# minifb (окно и фреймбуфер раннера) нужен только для нативной сборки. Для wasm32 он
# не компилируется, поэтому зависимость объявлена через target-cfg: в сборке движка
# виджета (`--target wasm32-unknown-unknown`) его в графе нет, а bin там пустой.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
minifb = "0.27"
# cpal даёт кросс-платформенный звук (CoreAudio на macOS). Раннер скармливает ему
# аудиосэмплы APU. Как и minifb, только для нативной сборки, не для wasm.
cpal = "0.15"

[profile.release]
opt-level = 3
lto = true

Дальше точка входа библиотеки. src/lib.rs объявляет модули, которые существуют после текущего урока. На уроке 43 это всего два: шина и процессор. Каждый следующий урок дописывает сюда одну строку pub mod (картридж, PPU, APU, интеграция), так что финальный полный lib.rs соберётся к уроку 48. Пока он короткий:

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

//! our-nes: учебный эмулятор NES. Каждый урок блока добавляет один модуль.

pub mod bus;
pub mod cpu;

// Урок 44 добавит `pub mod cartridge;`, урок 45 `pub mod ppu;`, и так далее
// до полного `lib.rs` в уроке 48.

Процессор это модуль-папка: создай каталог src/cpu/ и три файла внутри, src/cpu/mod.rs, src/cpu/opcodes.rs, src/cpu/status.rs. Шина живёт одним файлом рядом, src/bus.rs. С неё и начнём: она самая маленькая и от неё зависит всё остальное.

Шина: единственная дверь процессора

6502 ничего не знает о том, что лежит по адресу. Он умеет читать и писать байт по 16-битному адресу, а маршрутизацию (ОЗУ, регистры PPU, регистры APU, картридж) берёт на себя тот, кто реализует трейт Bus. В nestest это плоская память, а в полной NES это системная шина из прошлого урока. Тот же приём, что в учебном процессоре RU6: ядро параметризовано шиной.

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

//! Шина процессора: единственная дверь 6502 в мир NES.
//!
//! Урок 42, 43. Как и в эталоне RU6 (`examples/our-cpu`), ядро ничего не знает о
//! том, что лежит по адресу. Оно умеет читать и писать байт по 16-битному адресу,
//! а маршрутизацию (ОЗУ, регистры PPU, регистры APU, картридж) берёт на себя тот,
//! кто реализует [`Bus`]. В NES это [`crate::nes::SystemBus`]; в тестах CPU это
//! плоская память.
//!
//! Линии прерываний (`NMI` от PPU, `IRQ` от APU и маппера) тоже идут через шину:
//! процессор только опрашивает их между инструкциями.

/// Контракт шины процессора 6502.
pub trait Bus {
    /// Прочитать байт. `&mut self`, потому что чтение регистра устройства часто
    /// имеет побочный эффект (например, чтение `$2002` сбрасывает vblank-флаг PPU).
    fn read(&mut self, addr: u16) -> u8;

    /// Записать байт.
    fn write(&mut self, addr: u16, value: u8);

    /// Продвинуть периферию ровно на один такт процессора. В NES это три точки PPU
    /// и один такт APU. Процессор зовёт `tick` на каждый свой такт (и на доступ к
    /// памяти, и на внутренний), поэтому PPU и APU идут синхронно с CPU точка в
    /// точку. Плоская память тестов это игнорирует.
    fn tick(&mut self) {}

    /// Сколько дополнительных тактов украл DMA спрайтов на последней записи (513
    /// при запуске через `$4014`). Процессор добавляет их к своему счётчику и
    /// прокручивает на них периферию. Обычная запись возвращает 0.
    fn dma_cycles_taken(&mut self) -> u32 {
        0
    }

    /// Снять фронт линии NMI. `true` ровно один раз на каждый поднятый фронт
    /// (PPU поднимает NMI в начале vblank). Возврат `true` обязывает процессор
    /// обработать прерывание перед следующей инструкцией.
    fn poll_nmi(&mut self) -> bool {
        false
    }

    /// Уровень линии IRQ прямо сейчас. В отличие от NMI это именно уровень, а не
    /// фронт: пока источник держит линию и флаг `I` снят, IRQ повторяется.
    fn irq_pending(&self) -> bool {
        false
    }
}

Методы с телом по умолчанию (tick, dma_cycles_taken, poll_nmi, irq_pending) шина nestest просто не переопределяет: плоской памяти периферия не нужна. Обязательны только read и write. Обрати внимание на &mut self в read: чтение регистра устройства это не чистая функция, оно может что-то поменять в железе, и тип это честно отражает.

Регистр флагов P

Теперь процессор. Начнём с самого мелкого кусочка: регистра флагов. Это восемь бит, но программно видно семь. Держим их отдельными булевыми полями: так код операций читается как проза (self.p.zero = result == 0), а в байт упаковываем только на границе (push в стек, трейс).

Создай файл src/cpu/status.rs:

//! Регистр состояния `P` процессора 6502.
//!
//! Урок 43. Восемь бит, но программно видно семь флагов. Держим их отдельными
//! полями: так код операций читается как проза (`cpu.p.zero = result == 0`), а в
//! байт упаковываем только на границе (push в стек, трейс, регистр для нестест).
//!
//! Раскладка байта (бит 7 слева):
//!
//! ```text
//! N V 1 B D I Z C
//! 7 6 5 4 3 2 1 0
//! ```
//!
//! Бит 5 в железе подтянут к единице и читается как 1 всегда. Бита `B` как
//! отдельного флага не существует: это значение бита 4 в *копии*, которую кладут
//! в стек, и оно различает источник (`PHP`/`BRK` кладут 1, аппаратные `IRQ`/`NMI`
//! кладут 0). Поэтому `B` тут не поле, а параметр упаковки.

/// Флаги состояния как набор булевых полей.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Status {
    /// `C`, перенос или заём.
    pub carry: bool,
    /// `Z`, результат равен нулю.
    pub zero: bool,
    /// `I`, запрет маскируемых прерываний (IRQ).
    pub interrupt_disable: bool,
    /// `D`, десятичный режим. На 2A03 он есть в байте, но игнорируется ALU.
    pub decimal: bool,
    /// `V`, знаковое переполнение.
    pub overflow: bool,
    /// `N`, старший бит результата (знак).
    pub negative: bool,
}

impl Status {
    /// Состояние после сброса: установлен только `I` (прерывания запрещены).
    /// В байте это `0x24` (с учётом всегда-единичного бита 5).
    pub fn at_reset() -> Self {
        Status {
            interrupt_disable: true,
            ..Status::default()
        }
    }

    /// Собрать байт для трейса или для push в стек. `b` это значение бита 4:
    /// `true` для `PHP`/`BRK`, `false` для аппаратного входа в прерывание.
    pub fn to_byte(self, b: bool) -> u8 {
        let mut byte = 0b0010_0000; // бит 5 всегда 1
        if self.carry {
            byte |= 1 << 0;
        }
        if self.zero {
            byte |= 1 << 1;
        }
        if self.interrupt_disable {
            byte |= 1 << 2;
        }
        if self.decimal {
            byte |= 1 << 3;
        }
        if b {
            byte |= 1 << 4;
        }
        if self.overflow {
            byte |= 1 << 6;
        }
        if self.negative {
            byte |= 1 << 7;
        }
        byte
    }

    /// Разобрать байт обратно во флаги (например, `PLP`/`RTI`). Биты 4 и 5 при
    /// этом отбрасываются: в полях их нет.
    pub fn from_byte(byte: u8) -> Self {
        Status {
            carry: byte & (1 << 0) != 0,
            zero: byte & (1 << 1) != 0,
            interrupt_disable: byte & (1 << 2) != 0,
            decimal: byte & (1 << 3) != 0,
            overflow: byte & (1 << 6) != 0,
            negative: byte & (1 << 7) != 0,
        }
    }

    /// Выставить `Z` и `N` по результату операции. Это делает почти каждый опкод,
    /// поэтому вынесено в один помощник.
    pub fn set_zero_negative(&mut self, value: u8) {
        self.zero = value == 0;
        self.negative = value & 0x80 != 0;
    }
}

Раскладка байта (бит 7 слева): N V 1 B D I Z C. Тут две ловушки, на которых эмуляторы спотыкаются чаще всего.

Во-первых, бита 5 нет как флага: в железе он подтянут к единице и читается как 1 всегда. Видишь 0b0010_0000 в начале to_byte: это и есть тот единичный бит 5. Во-вторых, бита B тоже нет как флага. Это значение бита 4 в той копии регистра, которую кладут в стек, и оно различает источник: PHP и BRK кладут 1, а аппаратные IRQ и NMI кладут 0. Поэтому B это не поле структуры, а параметр упаковки b.

Флаги NV-BDIZC почти каждая инструкция трогает по одной схеме: выставляет Z и N по результату. Это вынесено в единственный помощник set_zero_negative, чтобы не повторять его в каждом опкоде. Обратный разбор байта во флаги (from_byte) нужен для PLP и RTI: они достают регистр со стека и при этом отбрасывают биты 4 и 5, потому что полей под них нет.

Декодирование: один байт это тройка

Учебный процессор из RU6 декодировал инструкцию вручную, разбирая поля опкода. У 6502 проще всего таблица на 256 строк: байт опкода это индекс, а в строке лежит тройка «мнемоника, режим адресации, базовые такты». Та же таблица питает и исполнитель, и дизассемблер из урока 48: декодирование живёт в одном месте.

Создай файл src/cpu/opcodes.rs. Тут полная таблица на 256 опкодов, не сокращай её: nestest гоняет почти весь набор, и любая пропущенная строка разъедет трейс.

//! Таблица опкодов 6502: декодирование без исполнения.
//!
//! Урок 43. Один байт опкода задаёт тройку: что делать (мнемоника), откуда брать
//! операнд (режим адресации) и сколько это стоит в тактах. Держим это таблицей на
//! 256 строк, а не россыпью `if`. Та же таблица питает и исполнитель, и
//! дизассемблер (урок 48): декодирование живёт в одном месте.
//!
//! В таблице есть и неофициальные опкоды. Реальные картриджи на них не
//! полагаются, но `nestest` гоняет стабильные illegal-опкоды (`SLO`, `LAX`, `DCP`,
//! `ISC` и компанию), и без них до зелёного не дойти. Нестабильные (`XAA`, `AHX`,
//! `TAS`, ...) реализованы по общепринятым формулам, но `nestest` их не трогает.

#![allow(clippy::upper_case_acronyms)]

use AddrMode::*;
use Mnemonic::*;

/// Режим адресации: как из байтов после опкода получить адрес операнда.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AddrMode {
    /// Без операнда (`INX`, `NOP`).
    IMP,
    /// Операнд это сам аккумулятор (`ASL A`).
    ACC,
    /// Непосредственное значение (`LDA #$10`).
    IMM,
    /// Нулевая страница: один байт адреса `$00xx`.
    ZP0,
    /// Нулевая страница со смещением по X.
    ZPX,
    /// Нулевая страница со смещением по Y.
    ZPY,
    /// Относительный переход (ветвления).
    REL,
    /// Абсолютный 16-битный адрес.
    ABS,
    /// Абсолютный со смещением по X.
    ABX,
    /// Абсолютный со смещением по Y.
    ABY,
    /// Косвенный (только `JMP ($addr)`), с багом переноса страницы.
    IND,
    /// Косвенный с предындексацией X: `($addr,X)`.
    IZX,
    /// Косвенный с постиндексацией Y: `($addr),Y`.
    IZY,
}

/// Мнемоника инструкции. Включает официальные и неофициальные опкоды.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[rustfmt::skip]
pub enum Mnemonic {
    // Загрузка и сохранение.
    LDA, LDX, LDY, STA, STX, STY,
    // Пересылки между регистрами.
    TAX, TAY, TXA, TYA, TSX, TXS,
    // Стек.
    PHA, PHP, PLA, PLP,
    // Арифметика и логика.
    ADC, SBC, AND, ORA, EOR, CMP, CPX, CPY, BIT,
    // Инкременты и декременты.
    INC, INX, INY, DEC, DEX, DEY,
    // Сдвиги.
    ASL, LSR, ROL, ROR,
    // Управление потоком.
    JMP, JSR, RTS, RTI, BRK,
    // Ветвления.
    BCC, BCS, BEQ, BNE, BMI, BPL, BVC, BVS,
    // Флаги.
    CLC, SEC, CLI, SEI, CLD, SED, CLV,
    // Официальный простой.
    NOP,
    // Неофициальные, стабильные (нужны для nestest).
    SLO, RLA, SRE, RRA, SAX, LAX, DCP, ISC, ANC, ALR, ARR, AXS,
    // Неофициальные, нестабильные (nestest их не трогает).
    XAA, AHX, TAS, SHY, SHX, LAS,
    /// Зависание процессора (illegal `KIL`/`JAM`).
    JAM,
}

/// Декодированный опкод: всё, что нужно и исполнителю, и дизассемблеру.
#[derive(Clone, Copy, Debug)]
pub struct OpInfo {
    pub mnemonic: Mnemonic,
    pub mode: AddrMode,
    /// Базовая стоимость в тактах.
    pub cycles: u8,
    /// Добавлять ли такт при переходе через границу страницы (только для
    /// читающих `ABX`/`ABY`/`IZY`; у записи и RMW штраф уже зашит в базу).
    pub page_penalty: bool,
    /// Официальный ли это опкод.
    pub official: bool,
}

const fn op(mnemonic: Mnemonic, mode: AddrMode, cycles: u8, page_penalty: bool) -> OpInfo {
    OpInfo {
        mnemonic,
        mode,
        cycles,
        page_penalty,
        official: true,
    }
}

const fn ill(mnemonic: Mnemonic, mode: AddrMode, cycles: u8, page_penalty: bool) -> OpInfo {
    OpInfo {
        mnemonic,
        mode,
        cycles,
        page_penalty,
        official: false,
    }
}

/// Декодировать байт опкода.
pub fn decode(opcode: u8) -> OpInfo {
    OPCODES[opcode as usize]
}

/// Полная таблица 256 опкодов, индекс это байт опкода.
#[rustfmt::skip]
static OPCODES: [OpInfo; 256] = [
    /* 00 */ op(BRK,IMP,7,false), op(ORA,IZX,6,false), ill(JAM,IMP,2,false), ill(SLO,IZX,8,false),
    /* 04 */ ill(NOP,ZP0,3,false), op(ORA,ZP0,3,false), op(ASL,ZP0,5,false), ill(SLO,ZP0,5,false),
    /* 08 */ op(PHP,IMP,3,false), op(ORA,IMM,2,false), op(ASL,ACC,2,false), ill(ANC,IMM,2,false),
    /* 0C */ ill(NOP,ABS,4,false), op(ORA,ABS,4,false), op(ASL,ABS,6,false), ill(SLO,ABS,6,false),
    /* 10 */ op(BPL,REL,2,true), op(ORA,IZY,5,true), ill(JAM,IMP,2,false), ill(SLO,IZY,8,false),
    /* 14 */ ill(NOP,ZPX,4,false), op(ORA,ZPX,4,false), op(ASL,ZPX,6,false), ill(SLO,ZPX,6,false),
    /* 18 */ op(CLC,IMP,2,false), op(ORA,ABY,4,true), ill(NOP,IMP,2,false), ill(SLO,ABY,7,false),
    /* 1C */ ill(NOP,ABX,4,true), op(ORA,ABX,4,true), op(ASL,ABX,7,false), ill(SLO,ABX,7,false),
    /* 20 */ op(JSR,ABS,6,false), op(AND,IZX,6,false), ill(JAM,IMP,2,false), ill(RLA,IZX,8,false),
    /* 24 */ op(BIT,ZP0,3,false), op(AND,ZP0,3,false), op(ROL,ZP0,5,false), ill(RLA,ZP0,5,false),
    /* 28 */ op(PLP,IMP,4,false), op(AND,IMM,2,false), op(ROL,ACC,2,false), ill(ANC,IMM,2,false),
    /* 2C */ op(BIT,ABS,4,false), op(AND,ABS,4,false), op(ROL,ABS,6,false), ill(RLA,ABS,6,false),
    /* 30 */ op(BMI,REL,2,true), op(AND,IZY,5,true), ill(JAM,IMP,2,false), ill(RLA,IZY,8,false),
    /* 34 */ ill(NOP,ZPX,4,false), op(AND,ZPX,4,false), op(ROL,ZPX,6,false), ill(RLA,ZPX,6,false),
    /* 38 */ op(SEC,IMP,2,false), op(AND,ABY,4,true), ill(NOP,IMP,2,false), ill(RLA,ABY,7,false),
    /* 3C */ ill(NOP,ABX,4,true), op(AND,ABX,4,true), op(ROL,ABX,7,false), ill(RLA,ABX,7,false),
    /* 40 */ op(RTI,IMP,6,false), op(EOR,IZX,6,false), ill(JAM,IMP,2,false), ill(SRE,IZX,8,false),
    /* 44 */ ill(NOP,ZP0,3,false), op(EOR,ZP0,3,false), op(LSR,ZP0,5,false), ill(SRE,ZP0,5,false),
    /* 48 */ op(PHA,IMP,3,false), op(EOR,IMM,2,false), op(LSR,ACC,2,false), ill(ALR,IMM,2,false),
    /* 4C */ op(JMP,ABS,3,false), op(EOR,ABS,4,false), op(LSR,ABS,6,false), ill(SRE,ABS,6,false),
    /* 50 */ op(BVC,REL,2,true), op(EOR,IZY,5,true), ill(JAM,IMP,2,false), ill(SRE,IZY,8,false),
    /* 54 */ ill(NOP,ZPX,4,false), op(EOR,ZPX,4,false), op(LSR,ZPX,6,false), ill(SRE,ZPX,6,false),
    /* 58 */ op(CLI,IMP,2,false), op(EOR,ABY,4,true), ill(NOP,IMP,2,false), ill(SRE,ABY,7,false),
    /* 5C */ ill(NOP,ABX,4,true), op(EOR,ABX,4,true), op(LSR,ABX,7,false), ill(SRE,ABX,7,false),
    /* 60 */ op(RTS,IMP,6,false), op(ADC,IZX,6,false), ill(JAM,IMP,2,false), ill(RRA,IZX,8,false),
    /* 64 */ ill(NOP,ZP0,3,false), op(ADC,ZP0,3,false), op(ROR,ZP0,5,false), ill(RRA,ZP0,5,false),
    /* 68 */ op(PLA,IMP,4,false), op(ADC,IMM,2,false), op(ROR,ACC,2,false), ill(ARR,IMM,2,false),
    /* 6C */ op(JMP,IND,5,false), op(ADC,ABS,4,false), op(ROR,ABS,6,false), ill(RRA,ABS,6,false),
    /* 70 */ op(BVS,REL,2,true), op(ADC,IZY,5,true), ill(JAM,IMP,2,false), ill(RRA,IZY,8,false),
    /* 74 */ ill(NOP,ZPX,4,false), op(ADC,ZPX,4,false), op(ROR,ZPX,6,false), ill(RRA,ZPX,6,false),
    /* 78 */ op(SEI,IMP,2,false), op(ADC,ABY,4,true), ill(NOP,IMP,2,false), ill(RRA,ABY,7,false),
    /* 7C */ ill(NOP,ABX,4,true), op(ADC,ABX,4,true), op(ROR,ABX,7,false), ill(RRA,ABX,7,false),
    /* 80 */ ill(NOP,IMM,2,false), op(STA,IZX,6,false), ill(NOP,IMM,2,false), ill(SAX,IZX,6,false),
    /* 84 */ op(STY,ZP0,3,false), op(STA,ZP0,3,false), op(STX,ZP0,3,false), ill(SAX,ZP0,3,false),
    /* 88 */ op(DEY,IMP,2,false), ill(NOP,IMM,2,false), op(TXA,IMP,2,false), ill(XAA,IMM,2,false),
    /* 8C */ op(STY,ABS,4,false), op(STA,ABS,4,false), op(STX,ABS,4,false), ill(SAX,ABS,4,false),
    /* 90 */ op(BCC,REL,2,true), op(STA,IZY,6,false), ill(JAM,IMP,2,false), ill(AHX,IZY,6,false),
    /* 94 */ op(STY,ZPX,4,false), op(STA,ZPX,4,false), op(STX,ZPY,4,false), ill(SAX,ZPY,4,false),
    /* 98 */ op(TYA,IMP,2,false), op(STA,ABY,5,false), op(TXS,IMP,2,false), ill(TAS,ABY,5,false),
    /* 9C */ ill(SHY,ABX,5,false), op(STA,ABX,5,false), ill(SHX,ABY,5,false), ill(AHX,ABY,5,false),
    /* A0 */ op(LDY,IMM,2,false), op(LDA,IZX,6,false), op(LDX,IMM,2,false), ill(LAX,IZX,6,false),
    /* A4 */ op(LDY,ZP0,3,false), op(LDA,ZP0,3,false), op(LDX,ZP0,3,false), ill(LAX,ZP0,3,false),
    /* A8 */ op(TAY,IMP,2,false), op(LDA,IMM,2,false), op(TAX,IMP,2,false), ill(LAX,IMM,2,false),
    /* AC */ op(LDY,ABS,4,false), op(LDA,ABS,4,false), op(LDX,ABS,4,false), ill(LAX,ABS,4,false),
    /* B0 */ op(BCS,REL,2,true), op(LDA,IZY,5,true), ill(JAM,IMP,2,false), ill(LAX,IZY,5,true),
    /* B4 */ op(LDY,ZPX,4,false), op(LDA,ZPX,4,false), op(LDX,ZPY,4,false), ill(LAX,ZPY,4,false),
    /* B8 */ op(CLV,IMP,2,false), op(LDA,ABY,4,true), op(TSX,IMP,2,false), ill(LAS,ABY,4,true),
    /* BC */ op(LDY,ABX,4,true), op(LDA,ABX,4,true), op(LDX,ABY,4,true), ill(LAX,ABY,4,true),
    /* C0 */ op(CPY,IMM,2,false), op(CMP,IZX,6,false), ill(NOP,IMM,2,false), ill(DCP,IZX,8,false),
    /* C4 */ op(CPY,ZP0,3,false), op(CMP,ZP0,3,false), op(DEC,ZP0,5,false), ill(DCP,ZP0,5,false),
    /* C8 */ op(INY,IMP,2,false), op(CMP,IMM,2,false), op(DEX,IMP,2,false), ill(AXS,IMM,2,false),
    /* CC */ op(CPY,ABS,4,false), op(CMP,ABS,4,false), op(DEC,ABS,6,false), ill(DCP,ABS,6,false),
    /* D0 */ op(BNE,REL,2,true), op(CMP,IZY,5,true), ill(JAM,IMP,2,false), ill(DCP,IZY,8,false),
    /* D4 */ ill(NOP,ZPX,4,false), op(CMP,ZPX,4,false), op(DEC,ZPX,6,false), ill(DCP,ZPX,6,false),
    /* D8 */ op(CLD,IMP,2,false), op(CMP,ABY,4,true), ill(NOP,IMP,2,false), ill(DCP,ABY,7,false),
    /* DC */ ill(NOP,ABX,4,true), op(CMP,ABX,4,true), op(DEC,ABX,7,false), ill(DCP,ABX,7,false),
    /* E0 */ op(CPX,IMM,2,false), op(SBC,IZX,6,false), ill(NOP,IMM,2,false), ill(ISC,IZX,8,false),
    /* E4 */ op(CPX,ZP0,3,false), op(SBC,ZP0,3,false), op(INC,ZP0,5,false), ill(ISC,ZP0,5,false),
    /* E8 */ op(INX,IMP,2,false), op(SBC,IMM,2,false), op(NOP,IMP,2,false), ill(SBC,IMM,2,false),
    /* EC */ op(CPX,ABS,4,false), op(SBC,ABS,4,false), op(INC,ABS,6,false), ill(ISC,ABS,6,false),
    /* F0 */ op(BEQ,REL,2,true), op(SBC,IZY,5,true), ill(JAM,IMP,2,false), ill(ISC,IZY,8,false),
    /* F4 */ ill(NOP,ZPX,4,false), op(SBC,ZPX,4,false), op(INC,ZPX,6,false), ill(ISC,ZPX,6,false),
    /* F8 */ op(SED,IMP,2,false), op(SBC,ABY,4,true), ill(NOP,IMP,2,false), ill(ISC,ABY,7,false),
    /* FC */ ill(NOP,ABX,4,true), op(SBC,ABX,4,true), op(INC,ABX,7,false), ill(ISC,ABX,7,false),
];

impl Mnemonic {
    /// Имя для дизассемблера. Неофициальные помечаются `*` в трейсе nestest.
    pub fn name(self) -> &'static str {
        match self {
            LDA => "LDA",
            LDX => "LDX",
            LDY => "LDY",
            STA => "STA",
            STX => "STX",
            STY => "STY",
            TAX => "TAX",
            TAY => "TAY",
            TXA => "TXA",
            TYA => "TYA",
            TSX => "TSX",
            TXS => "TXS",
            PHA => "PHA",
            PHP => "PHP",
            PLA => "PLA",
            PLP => "PLP",
            ADC => "ADC",
            SBC => "SBC",
            AND => "AND",
            ORA => "ORA",
            EOR => "EOR",
            CMP => "CMP",
            CPX => "CPX",
            CPY => "CPY",
            BIT => "BIT",
            INC => "INC",
            INX => "INX",
            INY => "INY",
            DEC => "DEC",
            DEX => "DEX",
            DEY => "DEY",
            ASL => "ASL",
            LSR => "LSR",
            ROL => "ROL",
            ROR => "ROR",
            JMP => "JMP",
            JSR => "JSR",
            RTS => "RTS",
            RTI => "RTI",
            BRK => "BRK",
            BCC => "BCC",
            BCS => "BCS",
            BEQ => "BEQ",
            BNE => "BNE",
            BMI => "BMI",
            BPL => "BPL",
            BVC => "BVC",
            BVS => "BVS",
            CLC => "CLC",
            SEC => "SEC",
            CLI => "CLI",
            SEI => "SEI",
            CLD => "CLD",
            SED => "SED",
            CLV => "CLV",
            NOP => "NOP",
            SLO => "SLO",
            RLA => "RLA",
            SRE => "SRE",
            RRA => "RRA",
            SAX => "SAX",
            LAX => "LAX",
            DCP => "DCP",
            ISC => "ISC",
            ANC => "ANC",
            ALR => "ALR",
            ARR => "ARR",
            AXS => "AXS",
            XAA => "XAA",
            AHX => "AHX",
            TAS => "TAS",
            SHY => "SHY",
            SHX => "SHX",
            LAS => "LAS",
            JAM => "JAM",
        }
    }
}

Сама таблица собрана двумя короткими конструкторами: op (официальный опкод) и ill (неофициальный). Один и тот же ORA живёт в восьми режимах, а между ними рассыпаны неофициальные опкоды. Колонка page_penalty это true только у читающих индексных режимов (ABX, ABY, IZY) и у ветвлений: у записи и RMW штраф за страницу уже зашит в базовое число тактов.

Неофициальные опкоды в таблице помечены конструктором ill. Реальные картриджи на них почти не полагаются, но nestest гоняет стабильные (SLO, LAX, DCP, ISC и компанию), и без них до зелёного не дойти. Нестабильные (XAA, AHX, TAS) реализованы по общепринятым формулам, но nestest их не трогает: их поведение зависит от температуры и экземпляра чипа.

Состояние процессора и доступ к памяти

Теперь главный файл, src/cpu/mod.rs. Он большой (около 800 строк), но почти весь это длинный match исполнения, который читается линейно. Создавай его целиком, ниже разберём ключевые куски по порядку: состояние, доступ к памяти, цикл шага, разрешение адреса, исполнение, ALU.

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

//! Ядро 6502 (точнее, его вариант 2A03 в NES: тот же набор команд, десятичный
//! режим ALU отключён).
//!
//! Урок 43. Процессор это маленькая машина состояний: регистры плюс цикл
//! fetch-decode-execute. Декодирование вынесено в [`opcodes`], флаги в [`status`].
//! Здесь живёт состояние, разрешение адреса операнда (с точными тактовыми
//! штрафами) и исполнение каждой мнемоники.
//!
//! Процессор параметризован трейтом [`Bus`], как и в эталоне RU6: одно ядро
//! работает и поверх плоской памяти (тест nestest), и поверх полной шины NES.

pub mod opcodes;
pub mod status;

use crate::bus::Bus;
use opcodes::{decode, AddrMode, Mnemonic, OpInfo};
use status::Status;

/// Вектор немаскируемого прерывания (PPU vblank).
pub const VECTOR_NMI: u16 = 0xFFFA;
/// Вектор сброса.
pub const VECTOR_RESET: u16 = 0xFFFC;
/// Вектор маскируемого прерывания и `BRK`.
pub const VECTOR_IRQ: u16 = 0xFFFE;

/// Как инструкция трогает операнд. От этого зависят фиктивные чтения (dummy
/// reads): процессор 6502 при индексной адресации сначала читает по
/// неисправленному адресу, и для пишущих инструкций это происходит всегда, а для
/// читающих только при переходе через страницу.
#[derive(Clone, Copy, PartialEq, Eq)]
enum AccessKind {
    Read,
    Write,
    ReadModifyWrite,
}

/// Куда указывает разрешённый операнд.
#[derive(Clone, Copy)]
enum Operand {
    /// Нет операнда (`INX`).
    Implied,
    /// Аккумулятор (`ASL A`).
    Accumulator,
    /// Непосредственное значение.
    Immediate(u8),
    /// Адрес в памяти.
    Memory(u16),
}

/// Состояние процессора 6502.
#[derive(Clone, Debug)]
pub struct Cpu {
    /// Аккумулятор.
    pub a: u8,
    /// Индексный регистр X.
    pub x: u8,
    /// Индексный регистр Y.
    pub y: u8,
    /// Указатель стека (стек живёт на странице `$0100`).
    pub sp: u8,
    /// Программный счётчик.
    pub pc: u16,
    /// Регистр состояния.
    pub p: Status,
    /// Накопленные такты с момента сброса (цикл-бюджет, нужен для синхронизации
    /// с PPU и для трейса nestest).
    pub cycles: u64,
    /// Процессор завис на illegal `JAM`.
    pub jammed: bool,
    /// Добавочные такты текущей инструкции, которые не пришлись на доступ к памяти
    /// (взятое ветвление, переход через страницу). Добиваются в конце шага.
    extra_cycles: u8,
    /// Прерывание опрошено в конце прошлой инструкции и обслуживается перед
    /// следующей. Так смена флага `I` через `CLI`/`SEI` видна с задержкой на одну
    /// инструкцию, как в железе.
    service_nmi: bool,
    service_irq: bool,
}

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

impl Cpu {
    /// Свежий процессор до сброса. Реальные значения регистров задаёт [`reset`].
    pub fn new() -> Self {
        Cpu {
            a: 0,
            x: 0,
            y: 0,
            sp: 0xFD,
            pc: 0,
            p: Status::at_reset(),
            cycles: 0,
            jammed: false,
            extra_cycles: 0,
            service_nmi: false,
            service_irq: false,
        }
    }

    /// Аппаратный сброс: читает вектор сброса и встаёт на него. Регистры данных
    /// аппаратно не обнуляются, а `sp` уезжает на `0xFD` (три фиктивных pop).
    pub fn reset(&mut self, bus: &mut impl Bus) {
        self.sp = 0xFD;
        self.p = Status::at_reset();
        self.pc = bus.read(VECTOR_RESET) as u16 | (bus.read(VECTOR_RESET + 1) as u16) << 8;
        self.cycles = 7;
        self.jammed = false;
    }

    /// Поставить процессор на конкретный адрес (используется тестом nestest,
    /// который стартует с `$C000` в автоматическом режиме).
    pub fn set_pc(&mut self, pc: u16) {
        self.pc = pc;
    }

    // --- доступ к памяти через шину ---
    //
    // Каждый доступ к памяти это один такт процессора. Перед самим доступом мы
    // тикаем шину (PPU успевает три точки, APU один такт), затем считываем или
    // пишем. Так PPU и CPU идут точка в точку: чтение `$2002` видит ровно то
    // состояние vblank, что и железо.

    /// Внутренний такт без обращения к памяти (индексация, фиктивные циклы, добивка
    /// до табличной длины инструкции).
    fn tick(&mut self, bus: &mut impl Bus) {
        bus.tick();
        self.cycles += 1;
    }

    fn read(&mut self, bus: &mut impl Bus, addr: u16) -> u8 {
        self.tick(bus);
        bus.read(addr)
    }

    fn write(&mut self, bus: &mut impl Bus, addr: u16, value: u8) {
        self.tick(bus);
        bus.write(addr, value);
        // DMA спрайтов крадёт сотни тактов: прокручиваем на них периферию.
        let stall = bus.dma_cycles_taken();
        for _ in 0..stall {
            self.tick(bus);
        }
    }

    fn fetch(&mut self, bus: &mut impl Bus) -> u8 {
        let byte = self.read(bus, self.pc);
        self.pc = self.pc.wrapping_add(1);
        byte
    }

    fn fetch16(&mut self, bus: &mut impl Bus) -> u16 {
        let lo = self.fetch(bus) as u16;
        let hi = self.fetch(bus) as u16;
        lo | (hi << 8)
    }

    /// Прочитать слово с багом переноса страницы 6502: если младший байт адреса
    /// `0xFF`, старший байт читается из начала той же страницы, а не следующей.
    /// Этим багом славится косвенный `JMP ($xxFF)`.
    fn read16_bug(&mut self, bus: &mut impl Bus, addr: u16) -> u16 {
        let lo = self.read(bus, addr) as u16;
        let hi_addr = (addr & 0xFF00) | ((addr + 1) & 0x00FF);
        let hi = self.read(bus, hi_addr) as u16;
        lo | (hi << 8)
    }

    /// Прочитать слово из нулевой страницы с заворотом внутри неё.
    fn read16_zp(&mut self, bus: &mut impl Bus, zp: u8) -> u16 {
        let lo = self.read(bus, zp as u16) as u16;
        let hi = self.read(bus, zp.wrapping_add(1) as u16) as u16;
        lo | (hi << 8)
    }

    // --- стек на странице $0100 ---

    fn push(&mut self, bus: &mut impl Bus, value: u8) {
        self.write(bus, 0x0100 | self.sp as u16, value);
        self.sp = self.sp.wrapping_sub(1);
    }

    fn pull(&mut self, bus: &mut impl Bus) -> u8 {
        self.sp = self.sp.wrapping_add(1);
        self.read(bus, 0x0100 | self.sp as u16)
    }

    fn push16(&mut self, bus: &mut impl Bus, value: u16) {
        self.push(bus, (value >> 8) as u8);
        self.push(bus, (value & 0xFF) as u8);
    }

    fn pull16(&mut self, bus: &mut impl Bus) -> u16 {
        let lo = self.pull(bus) as u16;
        let hi = self.pull(bus) as u16;
        lo | (hi << 8)
    }

    // --- прерывания ---

    /// Войти в обработчик немаскируемого прерывания (NMI). Дёргается шиной, когда
    /// PPU поднимает vblank-фронт.
    pub fn nmi(&mut self, bus: &mut impl Bus) {
        self.push16(bus, self.pc);
        self.push(bus, self.p.to_byte(false));
        self.p.interrupt_disable = true;
        self.pc = self.read16_bug(bus, VECTOR_NMI);
    }

    /// Войти в обработчик маскируемого прерывания (IRQ), если они не запрещены.
    fn irq(&mut self, bus: &mut impl Bus) {
        self.push16(bus, self.pc);
        self.push(bus, self.p.to_byte(false));
        self.p.interrupt_disable = true;
        self.pc = self.read16_bug(bus, VECTOR_IRQ);
    }

    /// Один полный шаг: проверка прерываний, затем fetch-decode-execute одной
    /// инструкции. Возвращает, сколько тактов это стоило.
    pub fn step(&mut self, bus: &mut impl Bus) -> u8 {
        if self.jammed {
            return 0;
        }

        // Прерывание, опрошенное в конце прошлой инструкции, обслуживается сейчас.
        // Вход стоит 7 тактов: часть набегает на доступы (push, чтение вектора),
        // остаток добиваем тиками до семи. NMI приоритетнее IRQ.
        if self.service_nmi {
            self.service_nmi = false;
            self.service_irq = false;
            let start = self.cycles;
            self.nmi(bus);
            self.pad_to(bus, start, 7);
            return (self.cycles - start) as u8;
        }
        if self.service_irq {
            self.service_irq = false;
            let start = self.cycles;
            self.irq(bus);
            self.pad_to(bus, start, 7);
            return (self.cycles - start) as u8;
        }

        let start = self.cycles;
        self.extra_cycles = 0;
        // Флаг I фиксируем до инструкции: смена через CLI/SEI/PLP повлияет на опрос
        // прерываний только со следующей инструкции (задержка как в железе).
        let interrupts_disabled_before = self.p.interrupt_disable;
        let opcode = self.fetch(bus);
        let info = decode(opcode);
        let kind = access_kind(info.mnemonic);
        let (operand, page_crossed) = self.resolve(bus, info.mode, kind);

        let mut target = info.cycles as u64;
        if info.page_penalty
            && matches!(info.mode, AddrMode::ABX | AddrMode::ABY | AddrMode::IZY)
            && page_crossed
        {
            target += 1;
        }

        self.execute(bus, info, operand, page_crossed);
        target += self.extra_cycles as u64;
        // Доступы уже натикали часть; добиваем внутренние такты до табличной длины.
        self.pad_to(bus, start, target);

        // Опрос прерываний в конце инструкции. Задержку флага I дают только
        // CLI/SEI/PLP: они меняют `I` на последнем такте, уже после опроса, поэтому
        // тут берётся значение до инструкции. RTI восстанавливает `I` раньше опроса,
        // поэтому учитывается сразу (для него и всех прочих берём текущее значение).
        let poll_disabled =
            if matches!(info.mnemonic, Mnemonic::CLI | Mnemonic::SEI | Mnemonic::PLP) {
                interrupts_disabled_before
            } else {
                self.p.interrupt_disable
            };
        if bus.poll_nmi() {
            self.service_nmi = true;
        }
        if bus.irq_pending() && !poll_disabled {
            self.service_irq = true;
        }

        (self.cycles - start) as u8
    }

    /// Добить шину тиками, пока с момента `start` не пройдёт ровно `target` тактов.
    fn pad_to(&mut self, bus: &mut impl Bus, start: u64, target: u64) {
        while self.cycles - start < target {
            self.tick(bus);
        }
    }

    /// Разрешить операнд по режиму адресации. Возвращает операнд и флаг перехода
    /// через границу страницы. `kind` управляет фиктивными чтениями: при индексной
    /// адресации процессор читает по неисправленному адресу, и для записи это всегда
    /// один такт, а для чтения только при переходе через страницу.
    fn resolve(&mut self, bus: &mut impl Bus, mode: AddrMode, kind: AccessKind) -> (Operand, bool) {
        use AddrMode::*;
        match mode {
            IMP => (Operand::Implied, false),
            ACC => (Operand::Accumulator, false),
            IMM => {
                let value = self.fetch(bus);
                (Operand::Immediate(value), false)
            }
            ZP0 => {
                let addr = self.fetch(bus) as u16;
                (Operand::Memory(addr), false)
            }
            ZPX => {
                let base = self.fetch(bus);
                // Фиктивное чтение по неиндексированному адресу (такт индексации).
                self.read(bus, base as u16);
                (Operand::Memory(base.wrapping_add(self.x) as u16), false)
            }
            ZPY => {
                let base = self.fetch(bus);
                self.read(bus, base as u16);
                (Operand::Memory(base.wrapping_add(self.y) as u16), false)
            }
            ABS => {
                let addr = self.fetch16(bus);
                (Operand::Memory(addr), false)
            }
            ABX => {
                let base = self.fetch16(bus);
                let addr = base.wrapping_add(self.x as u16);
                self.indexed_dummy_read(bus, base, addr, kind);
                (Operand::Memory(addr), page_crossed(base, addr))
            }
            ABY => {
                let base = self.fetch16(bus);
                let addr = base.wrapping_add(self.y as u16);
                self.indexed_dummy_read(bus, base, addr, kind);
                (Operand::Memory(addr), page_crossed(base, addr))
            }
            IND => {
                let ptr = self.fetch16(bus);
                let addr = self.read16_bug(bus, ptr);
                (Operand::Memory(addr), false)
            }
            IZX => {
                let pointer = self.fetch(bus);
                // Фиктивное чтение исходного указателя до прибавления X.
                self.read(bus, pointer as u16);
                let zp = pointer.wrapping_add(self.x);
                let addr = self.read16_zp(bus, zp);
                (Operand::Memory(addr), false)
            }
            IZY => {
                let zp = self.fetch(bus);
                let base = self.read16_zp(bus, zp);
                let addr = base.wrapping_add(self.y as u16);
                self.indexed_dummy_read(bus, base, addr, kind);
                (Operand::Memory(addr), page_crossed(base, addr))
            }
            REL => {
                let offset = self.fetch(bus) as i8;
                let addr = self.pc.wrapping_add(offset as u16);
                (Operand::Memory(addr), page_crossed(self.pc, addr))
            }
        }
    }

    /// Фиктивное чтение индексной адресации по неисправленному адресу (старший байт
    /// ещё не учёл перенос). Для записи и RMW происходит всегда, для чтения только
    /// при переходе через страницу.
    fn indexed_dummy_read(&mut self, bus: &mut impl Bus, base: u16, addr: u16, kind: AccessKind) {
        let crossed = page_crossed(base, addr);
        if kind != AccessKind::Read || crossed {
            let unfixed = (base & 0xFF00) | (addr & 0x00FF);
            self.read(bus, unfixed);
        }
    }

    /// Прочитать значение операнда (для читающих и RMW инструкций).
    fn load(&mut self, bus: &mut impl Bus, operand: Operand) -> u8 {
        match operand {
            Operand::Immediate(value) => value,
            Operand::Accumulator => self.a,
            Operand::Memory(addr) => self.read(bus, addr),
            Operand::Implied => 0,
        }
    }

    /// Записать значение обратно (для store и RMW).
    fn store(&mut self, bus: &mut impl Bus, operand: Operand, value: u8) {
        match operand {
            Operand::Accumulator => self.a = value,
            Operand::Memory(addr) => self.write(bus, addr, value),
            _ => {}
        }
    }

    fn addr_of(operand: Operand) -> u16 {
        match operand {
            Operand::Memory(addr) => addr,
            _ => 0,
        }
    }

    /// Запись нестабильного store-опкода. При переходе через страницу старший байт
    /// целевого адреса подменяется записываемым значением (квирк железа).
    fn store_unstable(&mut self, bus: &mut impl Bus, base: u16, addr: u16, value: u8) {
        let target = if page_crossed(base, addr) {
            (addr & 0x00FF) | ((value as u16) << 8)
        } else {
            addr
        };
        self.write(bus, target, value);
    }

    /// Исполнить декодированную инструкцию.
    fn execute(&mut self, bus: &mut impl Bus, info: OpInfo, operand: Operand, page_crossed: bool) {
        use Mnemonic::*;
        match info.mnemonic {
            // --- загрузка и сохранение ---
            LDA => {
                self.a = self.load(bus, operand);
                self.p.set_zero_negative(self.a);
            }
            LDX => {
                self.x = self.load(bus, operand);
                self.p.set_zero_negative(self.x);
            }
            LDY => {
                self.y = self.load(bus, operand);
                self.p.set_zero_negative(self.y);
            }
            STA => self.store(bus, operand, self.a),
            STX => self.store(bus, operand, self.x),
            STY => self.store(bus, operand, self.y),

            // --- пересылки ---
            TAX => {
                self.x = self.a;
                self.p.set_zero_negative(self.x);
            }
            TAY => {
                self.y = self.a;
                self.p.set_zero_negative(self.y);
            }
            TXA => {
                self.a = self.x;
                self.p.set_zero_negative(self.a);
            }
            TYA => {
                self.a = self.y;
                self.p.set_zero_negative(self.a);
            }
            TSX => {
                self.x = self.sp;
                self.p.set_zero_negative(self.x);
            }
            TXS => self.sp = self.x,

            // --- стек ---
            PHA => self.push(bus, self.a),
            PHP => self.push(bus, self.p.to_byte(true)),
            PLA => {
                self.a = self.pull(bus);
                self.p.set_zero_negative(self.a);
            }
            PLP => {
                let byte = self.pull(bus);
                self.p = Status::from_byte(byte);
            }

            // --- арифметика и логика ---
            ADC => {
                let value = self.load(bus, operand);
                self.adc(value);
            }
            SBC => {
                let value = self.load(bus, operand);
                self.sbc(value);
            }
            AND => {
                self.a &= self.load(bus, operand);
                self.p.set_zero_negative(self.a);
            }
            ORA => {
                self.a |= self.load(bus, operand);
                self.p.set_zero_negative(self.a);
            }
            EOR => {
                self.a ^= self.load(bus, operand);
                self.p.set_zero_negative(self.a);
            }
            CMP => {
                let value = self.load(bus, operand);
                self.compare(self.a, value);
            }
            CPX => {
                let value = self.load(bus, operand);
                self.compare(self.x, value);
            }
            CPY => {
                let value = self.load(bus, operand);
                self.compare(self.y, value);
            }
            BIT => {
                let value = self.load(bus, operand);
                self.p.zero = (self.a & value) == 0;
                self.p.overflow = value & 0x40 != 0;
                self.p.negative = value & 0x80 != 0;
            }

            // --- инкременты и декременты ---
            INC => {
                let value = self.load(bus, operand).wrapping_add(1);
                self.p.set_zero_negative(value);
                self.store(bus, operand, value);
            }
            DEC => {
                let value = self.load(bus, operand).wrapping_sub(1);
                self.p.set_zero_negative(value);
                self.store(bus, operand, value);
            }
            INX => {
                self.x = self.x.wrapping_add(1);
                self.p.set_zero_negative(self.x);
            }
            INY => {
                self.y = self.y.wrapping_add(1);
                self.p.set_zero_negative(self.y);
            }
            DEX => {
                self.x = self.x.wrapping_sub(1);
                self.p.set_zero_negative(self.x);
            }
            DEY => {
                self.y = self.y.wrapping_sub(1);
                self.p.set_zero_negative(self.y);
            }

            // --- сдвиги ---
            ASL => {
                let value = self.load(bus, operand);
                let result = self.asl(value);
                self.store(bus, operand, result);
            }
            LSR => {
                let value = self.load(bus, operand);
                let result = self.lsr(value);
                self.store(bus, operand, result);
            }
            ROL => {
                let value = self.load(bus, operand);
                let result = self.rol(value);
                self.store(bus, operand, result);
            }
            ROR => {
                let value = self.load(bus, operand);
                let result = self.ror(value);
                self.store(bus, operand, result);
            }

            // --- управление потоком ---
            JMP => self.pc = Self::addr_of(operand),
            JSR => {
                // На стек кладётся адрес последнего байта инструкции (pc-1).
                self.push16(bus, self.pc.wrapping_sub(1));
                self.pc = Self::addr_of(operand);
            }
            RTS => self.pc = self.pull16(bus).wrapping_add(1),
            RTI => {
                let byte = self.pull(bus);
                self.p = Status::from_byte(byte);
                self.pc = self.pull16(bus);
            }
            BRK => {
                // BRK двухбайтный: пропускаем байт-заполнитель.
                self.pc = self.pc.wrapping_add(1);
                self.push16(bus, self.pc);
                self.push(bus, self.p.to_byte(true));
                self.p.interrupt_disable = true;
                self.pc = self.read16_bug(bus, VECTOR_IRQ);
            }

            // --- ветвления ---
            BCC => self.branch(operand, page_crossed, !self.p.carry),
            BCS => self.branch(operand, page_crossed, self.p.carry),
            BEQ => self.branch(operand, page_crossed, self.p.zero),
            BNE => self.branch(operand, page_crossed, !self.p.zero),
            BMI => self.branch(operand, page_crossed, self.p.negative),
            BPL => self.branch(operand, page_crossed, !self.p.negative),
            BVC => self.branch(operand, page_crossed, !self.p.overflow),
            BVS => self.branch(operand, page_crossed, self.p.overflow),

            // --- флаги ---
            CLC => self.p.carry = false,
            SEC => self.p.carry = true,
            CLI => self.p.interrupt_disable = false,
            SEI => self.p.interrupt_disable = true,
            CLD => self.p.decimal = false,
            SED => self.p.decimal = true,
            CLV => self.p.overflow = false,

            NOP => {}

            // --- неофициальные стабильные ---
            LAX => {
                let value = self.load(bus, operand);
                self.a = value;
                self.x = value;
                self.p.set_zero_negative(value);
            }
            SAX => self.store(bus, operand, self.a & self.x),
            DCP => {
                let value = self.load(bus, operand).wrapping_sub(1);
                self.store(bus, operand, value);
                self.compare(self.a, value);
            }
            ISC => {
                let value = self.load(bus, operand).wrapping_add(1);
                self.store(bus, operand, value);
                self.sbc(value);
            }
            SLO => {
                let value = self.load(bus, operand);
                let result = self.asl(value);
                self.store(bus, operand, result);
                self.a |= result;
                self.p.set_zero_negative(self.a);
            }
            RLA => {
                let value = self.load(bus, operand);
                let result = self.rol(value);
                self.store(bus, operand, result);
                self.a &= result;
                self.p.set_zero_negative(self.a);
            }
            SRE => {
                let value = self.load(bus, operand);
                let result = self.lsr(value);
                self.store(bus, operand, result);
                self.a ^= result;
                self.p.set_zero_negative(self.a);
            }
            RRA => {
                let value = self.load(bus, operand);
                let result = self.ror(value);
                self.store(bus, operand, result);
                self.adc(result);
            }
            ANC => {
                self.a &= self.load(bus, operand);
                self.p.set_zero_negative(self.a);
                self.p.carry = self.p.negative;
            }
            ALR => {
                self.a &= self.load(bus, operand);
                self.a = self.lsr(self.a);
            }
            ARR => {
                self.a &= self.load(bus, operand);
                self.a = (self.a >> 1) | ((self.p.carry as u8) << 7);
                self.p.set_zero_negative(self.a);
                self.p.carry = self.a & 0x40 != 0;
                self.p.overflow = ((self.a >> 6) ^ (self.a >> 5)) & 1 != 0;
            }
            AXS => {
                let value = self.load(bus, operand);
                let result = (self.a & self.x) as u16;
                let diff = result.wrapping_sub(value as u16);
                self.p.carry = result >= value as u16;
                self.x = diff as u8;
                self.p.set_zero_negative(self.x);
            }

            // --- неофициальные нестабильные (nestest их не запускает) ---
            XAA => {
                self.a = self.x & self.load(bus, operand);
                self.p.set_zero_negative(self.a);
            }
            LAS => {
                let value = self.load(bus, operand) & self.sp;
                self.a = value;
                self.x = value;
                self.sp = value;
                self.p.set_zero_negative(value);
            }
            // Нестабильные store-опкоды: пишут `reg & (старший_байт_базы + 1)`, а
            // при переходе через страницу подменяют старший байт целевого адреса
            // этим же значением. `base` это абсолютный операнд до индексации.
            AHX => {
                let addr = Self::addr_of(operand);
                let base = addr.wrapping_sub(self.y as u16);
                let value = self.a & self.x & ((base >> 8) as u8).wrapping_add(1);
                self.store_unstable(bus, base, addr, value);
            }
            TAS => {
                self.sp = self.a & self.x;
                let addr = Self::addr_of(operand);
                let base = addr.wrapping_sub(self.y as u16);
                let value = self.sp & ((base >> 8) as u8).wrapping_add(1);
                self.store_unstable(bus, base, addr, value);
            }
            SHY => {
                let addr = Self::addr_of(operand);
                let base = addr.wrapping_sub(self.x as u16);
                let value = self.y & ((base >> 8) as u8).wrapping_add(1);
                self.store_unstable(bus, base, addr, value);
            }
            SHX => {
                let addr = Self::addr_of(operand);
                let base = addr.wrapping_sub(self.y as u16);
                let value = self.x & ((base >> 8) as u8).wrapping_add(1);
                self.store_unstable(bus, base, addr, value);
            }

            JAM => self.jammed = true,
        }
    }

    // --- помощники ALU ---

    fn adc(&mut self, value: u8) {
        let sum = self.a as u16 + value as u16 + self.p.carry as u16;
        let result = sum as u8;
        self.p.carry = sum > 0xFF;
        // Переполнение знака: одинаковый знак входов и другой знак результата.
        self.p.overflow = (self.a ^ result) & (value ^ result) & 0x80 != 0;
        self.a = result;
        self.p.set_zero_negative(self.a);
    }

    fn sbc(&mut self, value: u8) {
        // Вычитание это сложение с дополнительным кодом: a + !value + carry.
        self.adc(!value);
    }

    fn compare(&mut self, register: u8, value: u8) {
        let diff = register.wrapping_sub(value);
        self.p.carry = register >= value;
        self.p.set_zero_negative(diff);
    }

    fn asl(&mut self, value: u8) -> u8 {
        self.p.carry = value & 0x80 != 0;
        let result = value << 1;
        self.p.set_zero_negative(result);
        result
    }

    fn lsr(&mut self, value: u8) -> u8 {
        self.p.carry = value & 0x01 != 0;
        let result = value >> 1;
        self.p.set_zero_negative(result);
        result
    }

    fn rol(&mut self, value: u8) -> u8 {
        let carry_in = self.p.carry as u8;
        self.p.carry = value & 0x80 != 0;
        let result = (value << 1) | carry_in;
        self.p.set_zero_negative(result);
        result
    }

    fn ror(&mut self, value: u8) -> u8 {
        let carry_in = self.p.carry as u8;
        self.p.carry = value & 0x01 != 0;
        let result = (value >> 1) | (carry_in << 7);
        self.p.set_zero_negative(result);
        result
    }

    fn branch(&mut self, operand: Operand, page_crossed: bool, take: bool) {
        if !take {
            return;
        }
        // Взятое ветвление стоит +1 такт, переход через страницу ещё +1.
        // Это внутренние такты (без доступа к памяти), добьются в конце шага.
        self.extra_cycles += 1;
        if page_crossed {
            self.extra_cycles += 1;
        }
        self.pc = Self::addr_of(operand);
    }
}

/// Перешёл ли адрес через границу страницы относительно базы (старший байт изменился).
fn page_crossed(base: u16, addr: u16) -> bool {
    (base & 0xFF00) != (addr & 0xFF00)
}

/// Как инструкция трогает память: читает, пишет или читает-меняет-пишет. Определяет
/// фиктивные чтения при индексной адресации.
fn access_kind(mnemonic: Mnemonic) -> AccessKind {
    use Mnemonic::*;
    match mnemonic {
        STA | STX | STY | SAX | SHX | SHY | AHX | TAS => AccessKind::Write,
        ASL | LSR | ROL | ROR | INC | DEC | SLO | RLA | SRE | RRA | DCP | ISC => {
            AccessKind::ReadModifyWrite
        }
        _ => AccessKind::Read,
    }
}

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

Состояние и сброс

Три регистра данных (a, x, y) это та же тройка, что у любого учебного процессора, а ниже начинаются особенности железа. cycles это бюджет тактов: на нём держится синхронизация с PPU из прошлого урока. extra_cycles копит такты, которых не было в таблице (взятое ветвление, переход через страницу). А service_nmi и service_irq это отложенные прерывания: 6502 опрашивает линии прерываний не сразу, а в конце инструкции, и обслуживает на следующем шаге. Из-за этого смена флага запрета прерываний видна с задержкой ровно в одну инструкцию, как в кремнии.

Сброс не обнуляет регистры данных. Он только читает вектор сброса и встаёт на него, а указатель стека уезжает на 0xFD. Векторы лежат в самом верху адресного пространства, и картридж заполняет их в уроке про мапперы. Адрес собирается в порядке младший байт, потом старший: это little-endian из RU3, 6502 хранит слова именно так.

Каждый доступ это такт

Главный приём всего ядра: каждый доступ к памяти это один такт процессора. Метод read сначала зовёт tick (который тикает шину и наращивает cycles), и только потом читает байт. Так PPU и CPU идут точка в точку: чтение $2002 видит ровно то состояние vblank, что и железо. На этом построена вся точность тактов: мы не считаем «сколько раз дёрнули шину», мы тикаем шину на каждый доступ и в конце добиваем остаток до табличной длины.

Декодирование и режимы адресации

Шаг читает опкод, декодирует его в OpInfo через таблицу из opcodes.rs, потом зовёт resolve: тот по режиму адресации вычисляет, где лежит операнд, и попутно решает, был ли переход через страницу. У 6502 тринадцать режимов, и resolve это самая хитрая часть ядра, потому что здесь живут точные такты.

Обрати внимание на self.read(bus, base as u16) внутри ZPX: это чтение, результат которого нам не нужен. Зачем оно? Затем, что в железе оно происходит. 6502 сначала читает по неисправленному адресу, и только потом по правильному.

Фиктивное чтение важно не из-за тактов (хотя и из-за них тоже), а из-за побочных эффектов. Если фиктивный адрес попал на регистр PPU, лишнее чтение что-то меняет в железе, и эмулятор обязан его воспроизвести. Логика, когда оно случается, вынесена в метод indexed_dummy_read: для записи и RMW читаем по неисправленному адресу всегда, для чтения только при переходе через страницу.

Страничный переход определяется одной строкой page_crossed в конце файла: совпадает ли старший байт базы и итогового адреса.

Цикл шага

Теперь видно, как всё собирается в один шаг. step проверяет отложенные прерывания, читает опкод, декодирует, разрешает операнд, исполняет, добивает такты до табличной длины и в конце опрашивает линии прерываний на будущее. Базовые такты берутся из таблицы, плюс единица штрафа за страницу, но только для читающих индексных режимов (ABX, ABY, IZY), как и обещала колонка page_penalty.

Ключевой приём: такты считаются не «сколько раз дёрнули шину», а «до табличной длины». Каждый доступ к памяти тикает шину сам, а в конце pad_to добивает оставшиеся внутренние такты. Так одна инструкция отдаёт ровно столько тактов, сколько обещала таблица, плюс честные штрафы за страницу и взятое ветвление. Именно на это число PPU прокрутится в финальном уроке.

Само исполнение это большой match по мнемонике. Логические опкоды выглядят так:

AND => {
    self.a &= self.load(bus, operand);
    self.p.set_zero_negative(self.a);
}
ORA => {
    self.a |= self.load(bus, operand);
    self.p.set_zero_negative(self.a);
}

Чистая проза: загрузили операнд, применили операцию, выставили Z и N. Большинство опкодов такие же короткие. Сложность не в них, а в арифметике и в тактах.

ADC и флаг переполнения

Сложение с переносом, ADC, это место, где новички теряют флаг V чаще всего. Вот тот же помощник adc из файла, отдельно.

fn adc(&mut self, value: u8) {
    let sum = self.a as u16 + value as u16 + self.p.carry as u16;
    let result = sum as u8;
    self.p.carry = sum > 0xFF;
    // Переполнение знака: одинаковый знак входов и другой знак результата.
    self.p.overflow = (self.a ^ result) & (value ^ result) & 0x80 != 0;
    self.a = result;
    self.p.set_zero_negative(self.a);
}

Флаг C простой: сумма не влезла в 8 бит. А V это про знаковую арифметику. Он взводится, когда оба слагаемых одного знака, а результат вышел другого: значит, знаковое сложение переполнилось. Маска (self.a ^ result) & (value ^ result) & 0x80 ловит ровно это. Вычитание SBC не пишут отдельно: это сложение с дополнительным кодом, self.adc(!value).

Покрути сложение в виджете ниже: меняй A, операнд и входной перенос, смотри, как загораются C, Z, N, V и почему. Особенно поучительны пресеты, где сумма влезает в байт, но знаковое переполнение всё равно случилось.

Два самых коварных бага

Когда трейс начинает расходиться с эталоном, в девяти случаях из десяти виноват один из двух квирков железа.

Первый это баг косвенного JMP. Инструкция JMP ($xxFF) должна прочитать адрес перехода из $xxFF и $xx00 + 1, но 6502 не переносит единицу в старший байт указателя: старший байт берётся из начала той же страницы. Это не наша оплошность, это поведение чипа, и эмулятор обязан его повторить. Этот квирк живёт в методе read16_bug из файла выше:

fn read16_bug(&mut self, bus: &mut impl Bus, addr: u16) -> u16 {
    let lo = self.read(bus, addr) as u16;
    let hi_addr = (addr & 0xFF00) | ((addr + 1) & 0x00FF);
    let hi = self.read(bus, hi_addr) as u16;
    lo | (hi << 8)
}

Строка (addr & 0xFF00) | ((addr + 1) & 0x00FF) это и есть баг: к младшему байту прибавили единицу с заворотом внутри страницы, а старший байт оставили тем же. Тот же метод читает векторы NMI, IRQ и косвенный JMP.

Второй это флаг V в ADC и SBC, который мы только что разобрали: легко посчитать перенос и забыть про знаковое переполнение, и тогда тест разойдётся ровно на первой арифметике с граничными числами.

Зелёный nestest

nestest это ROM, который прогоняет почти все опкоды (официальные и стабильные неофициальные) и пишет результат в память. Рядом лежит эталонный построчный лог известного эмулятора Nintendulator. Запускаем nestest с pc = 0xC000 в автоматическом режиме, на каждом шаге собираем строку состояния и сверяем с золотой. Первая разошедшаяся строка показывает точный адрес бага.

Создай файл tests/nestest.rs. Шина тут плоская: PPU и APU не нужны, автоматический режим nestest их не трогает.

//! Golden-тест ядра 6502 против `nestest.nes` и эталонного трейса `nestest.log`.
//!
//! Урок 43. `nestest` это канонический тест процессора NES: он прогоняет все
//! опкоды (официальные и стабильные неофициальные) и пишет результат в RAM, а
//! референсный эмулятор Nintendulator оставил построчный лог состояния. Мы стартуем
//! с `$C000` в автоматическом режиме и на каждой инструкции сверяем PC, регистры,
//! флаги и счётчик тактов. Расхождение это точный адрес первого бага.

use our_nes::bus::Bus;
use our_nes::cpu::Cpu;

/// Плоская шина под nestest: 2 KiB ОЗУ с зеркалами и 16 KiB PRG ROM (NROM-128),
/// отражённые в `$8000` и `$C000`. PPU и APU не нужны: автоматический режим
/// nestest их не трогает.
struct NromBus {
    ram: [u8; 0x800],
    prg: Vec<u8>,
}

impl NromBus {
    fn new(prg: Vec<u8>) -> Self {
        NromBus {
            ram: [0; 0x800],
            prg,
        }
    }
}

impl Bus for NromBus {
    fn read(&mut self, addr: u16) -> u8 {
        match addr {
            0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
            0x8000..=0xFFFF => {
                // 16 KiB PRG зеркалируется на оба банка $8000 и $C000.
                let index = (addr - 0x8000) as usize % self.prg.len();
                self.prg[index]
            }
            _ => 0xFF, // открытая шина для незанятых адресов
        }
    }

    fn write(&mut self, addr: u16, value: u8) {
        if let 0x0000..=0x1FFF = addr {
            self.ram[(addr & 0x07FF) as usize] = value;
        }
    }
}

/// Одна разобранная строка эталонного лога: то, что мы умеем сверить.
#[derive(Debug, PartialEq, Eq)]
struct LogLine {
    pc: u16,
    a: u8,
    x: u8,
    y: u8,
    p: u8,
    sp: u8,
    cyc: u64,
}

/// Достать значение из строки по префиксу вроде `A:`, `SP:`, `CYC:`.
fn field<'a>(line: &'a str, key: &str) -> &'a str {
    let start = line.find(key).expect("ключ есть в строке") + key.len();
    let rest = &line[start..];
    let end = rest.find([' ', ',']).unwrap_or(rest.len());
    &rest[..end]
}

fn parse_log_line(line: &str) -> LogLine {
    LogLine {
        pc: u16::from_str_radix(&line[0..4], 16).unwrap(),
        a: u8::from_str_radix(field(line, "A:"), 16).unwrap(),
        x: u8::from_str_radix(field(line, "X:"), 16).unwrap(),
        y: u8::from_str_radix(field(line, "Y:"), 16).unwrap(),
        p: u8::from_str_radix(field(line, "P:"), 16).unwrap(),
        sp: u8::from_str_radix(field(line, "SP:"), 16).unwrap(),
        cyc: field(line, "CYC:").parse().unwrap(),
    }
}

fn cpu_line(cpu: &Cpu) -> LogLine {
    LogLine {
        pc: cpu.pc,
        a: cpu.a,
        x: cpu.x,
        y: cpu.y,
        p: cpu.p.to_byte(false), // в логе бит B всегда 0, бит5 всегда 1
        sp: cpu.sp,
        cyc: cpu.cycles,
    }
}

#[test]
fn nestest_matches_golden_log() {
    let rom = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/roms/nestest.nes"))
        .expect("nestest.nes лежит в roms/");
    let log = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/roms/nestest.log"))
        .expect("nestest.log лежит в roms/");

    // iNES: 16 байт заголовка, затем PRG банками по 16 KiB. nestest это NROM-128.
    assert_eq!(&rom[0..4], b"NES\x1a", "это iNES");
    let prg_banks = rom[4] as usize;
    let prg = rom[16..16 + prg_banks * 16384].to_vec();

    let mut cpu = Cpu::new();
    let mut bus = NromBus::new(prg);
    // Автоматический режим nestest: старт с $C000, флаги $24, sp $FD, cyc 7.
    cpu.set_pc(0xC000);
    cpu.cycles = 7;

    for (index, raw) in log.lines().enumerate() {
        let expected = parse_log_line(raw);
        let actual = cpu_line(&cpu);
        assert_eq!(
            actual,
            expected,
            "\nрасхождение на строке {} лога:\n  ждали:   {:04X?}\n  получили: {:04X?}\n  лог: {}",
            index + 1,
            expected,
            actual,
            raw
        );
        cpu.step(&mut bus);
    }

    // nestest пишет код результата в $0002 (0x00 значит все тесты прошли).
    assert_eq!(
        bus.read(0x0002),
        0x00,
        "$02 должен быть 0x00 после прохождения"
    );
    assert_eq!(
        bus.read(0x0003),
        0x00,
        "$03 должен быть 0x00 после прохождения"
    );
}

Тесту нужны два файла рядом: roms/nestest.nes (сам ROM) и roms/nestest.log (эталонный трейс Nintendulator). Создай в корне крейта папку roms/ и положи туда оба. Взять их можно со страницы Emulator tests на NESdev, она же есть в ресурсах урока. Путь в тесте собирается через CARGO_MANIFEST_DIR, так что папка должна лежать рядом с Cargo.toml, а не где-то ещё.

Сравниваемая строка собирается функцией cpu_line из регистров в формате лога: PC, A, X, Y, упакованный байт флагов, SP, CYC. В байте флагов бит B всегда 0, поэтому to_byte(false). Шина NromBus зеркалит 16 KiB PRG на оба банка $8000 и $C000, потому что nestest это NROM-128. Если файлов нет, тест честно упадёт на expect с понятным сообщением.

Запускай так:

cargo test --test nestest

Это контрольная точка урока. Зелёный nestest значит, что ядро считает правильно: все опкоды, все флаги, все такты. Это тот же golden-трейс, что закрывал прошлый блок, только эталон не наш, а общий для всех эмуляторов NES. Падение на конкретной строке указывает на точный адрес и инструкцию, где трейс разошёлся: с этого адреса и начинай искать баг.

Дальше

Зелёный nestest значит, что ядро считает правильно: все опкоды, все флаги, все такты. Дальше подключаем к нему реальную игру через картридж и мапперы: научим шину отдавать PRG ROM и переключать банки.

Домашка