Раздел 23 · Rust

Сборка и запуск игры

senior~150 мин

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

Сборка и запуск игры

Семь уроков мы строили детали: ядро 6502, картридж, PPU со спрайтами, APU. Каждая прошла свои тесты в одиночку. Финал про то, как связать их в одну машину так, чтобы такты сходились, кадр выдавался вовремя, кнопки нажимались, а горячий цикл не тормозил. На выходе настоящая игра на экране.

Это последний урок блока. После него у тебя на диске лежит полный our-nes: он открывает нативное окно со звуком и собирается в wasm для браузерного виджета. Все листинги ниже полные, копируй их в файлы один к одному.

Геймпад

Стандартный контроллер NES это сдвиговый регистр на восемь кнопок: A, B, Select, Start и крестовина. Процессор стробирует его записью в $4016, потом восемь раз читает по биту, начиная с кнопки A. Пока поднят strobe, регистр постоянно перезагружается текущим состоянием кнопок; как только strobe сняли, каждое чтение выдвигает следующий бит и сдвигает регистр вправо.

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

//! Геймпад NES: восемь кнопок через сдвиговый регистр.
//!
//! Урок 48. Контроллер это простой сдвиговый регистр. Процессор пишет в `$4016`
//! бит strobe: пока он поднят, регистр постоянно перезагружается текущим
//! состоянием кнопок. Когда strobe сбрасывают, каждое чтение `$4016` (геймпад 1)
//! отдаёт по одному биту, начиная с кнопки A. Это та же идея HAL из RU6: за
//! трейтом ввода может стоять клавиатура, тач или запись для теста.

/// Кнопки геймпада в порядке выдвигания из сдвигового регистра.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Button {
    A = 0,
    B = 1,
    Select = 2,
    Start = 3,
    Up = 4,
    Down = 5,
    Left = 6,
    Right = 7,
}

/// Состояние одного геймпада.
#[derive(Default)]
pub struct Controller {
    /// Бит на кнопку (см. [`Button`]).
    buttons: u8,
    /// Защёлкнутое на момент чтения состояние.
    shift: u8,
    /// Поднятый strobe постоянно перезагружает регистр.
    strobe: bool,
}

impl Controller {
    pub fn new() -> Self {
        Controller::default()
    }

    /// Выставить или снять кнопку (вызывает раннер по событиям клавиатуры).
    pub fn set_button(&mut self, button: Button, pressed: bool) {
        let mask = 1 << button as u8;
        if pressed {
            self.buttons |= mask;
        } else {
            self.buttons &= !mask;
        }
    }

    /// Запись в `$4016`: бит 0 это strobe.
    pub fn write(&mut self, value: u8) {
        self.strobe = value & 1 != 0;
        if self.strobe {
            self.shift = self.buttons;
        }
    }

    /// Чтение `$4016`/`$4017`: один бит за раз. При поднятом strobe всегда
    /// возвращается состояние кнопки A. Старшие биты на реальной шине это 0x40.
    pub fn read(&mut self) -> u8 {
        if self.strobe {
            self.shift = self.buttons;
        }
        let bit = self.shift & 1;
        self.shift >>= 1;
        self.shift |= 0x80; // после восьми чтений идут единицы (открытая шина)
        0x40 | bit
    }
}

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

    /// Прочитать восемь кнопок в порядке выдвигания: A, B, Select, Start, Up, Down,
    /// Left, Right (после стандартной последовательности strobe).
    fn read_eight(controller: &mut Controller) -> [bool; 8] {
        controller.write(1); // strobe high: защёлкнуть состояние
        controller.write(0); // strobe low: начать сдвиг
        let mut out = [false; 8];
        for slot in &mut out {
            *slot = controller.read() & 1 != 0;
        }
        out
    }

    #[test]
    fn reads_buttons_in_hardware_order() {
        let mut controller = Controller::new();
        controller.set_button(Button::A, true);
        controller.set_button(Button::Up, true);
        controller.set_button(Button::Right, true);
        // Индексы: A=0, B=1, Select=2, Start=3, Up=4, Down=5, Left=6, Right=7.
        assert_eq!(
            read_eight(&mut controller),
            [true, false, false, false, true, false, false, true]
        );
    }

    #[test]
    fn down_and_b_read_independently() {
        let mut controller = Controller::new();
        controller.set_button(Button::Down, true);
        controller.set_button(Button::B, true);
        assert_eq!(
            read_eight(&mut controller),
            [false, true, false, false, false, true, false, false]
        );
    }

    #[test]
    fn release_clears_button() {
        let mut controller = Controller::new();
        controller.set_button(Button::Up, true);
        assert!(read_eight(&mut controller)[4]);
        controller.set_button(Button::Up, false);
        assert!(!read_eight(&mut controller)[4]);
    }
}

Главное в read: каждое чтение возвращает младший бит регистра, потом сдвигает его вправо и подсовывает сверху единицу. Восемь чтений выдвигают восемь кнопок в порядке Button, а дальше пойдут единицы (это поведение открытой шины). Биты 0x40 сверху ничего не значат для игры, но реальное железо отдаёт именно их, и тесты на это полагаются. Раннер и виджет дёргают только set_button: им неважно, что внутри сдвиговый регистр.

Это та же идея HAL из RU6: за вводом может стоять клавиатура, тач или запись для теста, а контроллер видит только биты в поле buttons. Маршрутизация чтения $4016 и $4017 на геймпады это работа шины, к ней и переходим.

Шина и сборка

Шина NES это и есть карта памяти процессора: по диапазону адреса она решает, кому отдать байт. PPU, APU, картридж и геймпады, каждый отвечает за свой кусок. Здесь же четыре машины из предыдущих уроков соединяются в одну консоль Nes.

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

//! Сборка системы: процессор, PPU, картридж и геймпады на одной шине.
//!
//! Урок 48. Здесь четыре машины из предыдущих уроков соединяются в NES. Шина
//! [`SystemBus`] это карта памяти процессора: она решает, кому адресован каждый
//! байт. Сборка [`Nes`] держит процессор и шину и тактирует их вместе: на каждый
//! такт процессора PPU успевает сделать три точки. Это и есть синхронизация
//! CPU и PPU.
//!
//! Карта памяти процессора (NESdev):
//!
//! ```text
//! $0000-$1FFF  2 KiB ОЗУ, отражённые четыре раза
//! $2000-$3FFF  8 регистров PPU, отражённые каждые 8 байт
//! $4000-$4015  регистры APU
//! $4016-$4017  геймпады
//! $4014        запуск DMA спрайтов
//! $4020-$FFFF  картридж (через маппер)
//! ```

use crate::apu::Apu;
use crate::bus::Bus;
use crate::cartridge::{self, RomError};
use crate::controller::{Button, Controller};
use crate::cpu::Cpu;
use crate::mapper::Mapper;
use crate::ppu::Ppu;

/// Частота вывода звука по умолчанию (Гц). Под неё APU ресемплит свой поток.
pub const SAMPLE_RATE: f32 = 44_100.0;

/// Карта памяти процессора NES.
pub struct SystemBus {
    ram: [u8; 0x800],
    pub ppu: Ppu,
    pub apu: Apu,
    pub mapper: Box<dyn Mapper>,
    pub controllers: [Controller; 2],
    /// Сколько тактов процессор простоит из-за DMA спрайтов (опустошается за шаг).
    dma_stall: u32,
}

impl SystemBus {
    fn new(mapper: Box<dyn Mapper>) -> Self {
        SystemBus {
            ram: [0; 0x800],
            ppu: Ppu::new(),
            apu: Apu::new(SAMPLE_RATE),
            mapper,
            controllers: [Controller::new(), Controller::new()],
            dma_stall: 0,
        }
    }

    /// Скопировать 256 байт из страницы CPU в OAM (DMA по записи в `$4014`).
    fn oam_dma(&mut self, page: u8) {
        let base = (page as u16) << 8;
        for i in 0..256u16 {
            let byte = self.read(base + i);
            self.ppu.write_oam(byte);
        }
        // DMA крадёт 513 тактов (или 514, если стартовал на нечётном такте).
        self.dma_stall += 513;
    }
}

impl Bus for SystemBus {
    fn read(&mut self, addr: u16) -> u8 {
        match addr {
            0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize],
            0x2000..=0x3FFF => {
                let mapper = self.mapper.as_mut();
                self.ppu.read_register(addr & 0x7, mapper)
            }
            0x4015 => self.apu.read_status(),
            0x4016 => self.controllers[0].read(),
            0x4017 => self.controllers[1].read(),
            0x4000..=0x4014 => 0, // прочие регистры APU/I/O на чтение это open bus
            0x4018..=0x401F => 0, // тестовый режим, не используется
            _ => self.mapper.cpu_read(addr),
        }
    }

    fn write(&mut self, addr: u16, value: u8) {
        match addr {
            0x0000..=0x1FFF => self.ram[(addr & 0x07FF) as usize] = value,
            0x2000..=0x3FFF => {
                let mapper = self.mapper.as_mut();
                self.ppu.write_register(addr & 0x7, value, mapper);
            }
            0x4014 => self.oam_dma(value),
            0x4016 => {
                // Strobe идёт на оба геймпада сразу.
                self.controllers[0].write(value);
                self.controllers[1].write(value);
            }
            // APU: каналы ($4000..$4013), статус/включение ($4015), frame counter
            // ($4017). Заметь: $4017 на запись это APU, а на чтение это геймпад 2.
            0x4000..=0x4013 | 0x4015 | 0x4017 => self.apu.write_register(addr, value),
            0x4018..=0x401F => {}
            _ => self.mapper.cpu_write(addr, value),
        }
    }

    fn tick(&mut self) {
        // Один такт процессора это три точки PPU и один такт APU.
        let mapper = self.mapper.as_mut();
        self.ppu.tick(mapper);
        self.ppu.tick(mapper);
        self.ppu.tick(mapper);
        self.apu.tick();
    }

    fn dma_cycles_taken(&mut self) -> u32 {
        std::mem::take(&mut self.dma_stall)
    }

    fn poll_nmi(&mut self) -> bool {
        self.ppu.take_nmi()
    }

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

/// Собранная консоль: процессор плюс шина со всей периферией.
pub struct Nes {
    pub cpu: Cpu,
    pub bus: SystemBus,
}

impl Nes {
    /// Собрать консоль из готового маппера.
    pub fn new(mapper: Box<dyn Mapper>) -> Self {
        let mut bus = SystemBus::new(mapper);
        let mut cpu = Cpu::new();
        cpu.reset(&mut bus);
        Nes { cpu, bus }
    }

    /// Собрать консоль прямо из образа `.nes`.
    pub fn from_rom(bytes: &[u8]) -> Result<Self, RomError> {
        Ok(Self::new(cartridge::load(bytes)?))
    }

    /// Аппаратный сброс.
    pub fn reset(&mut self) {
        self.cpu.reset(&mut self.bus);
    }

    /// Один шаг процессора. PPU и APU крутятся синхронно внутри `cpu.step`: на
    /// каждый такт процессора шина тикает три точки PPU и один такт APU. Возвращает
    /// число прошедших тактов процессора (с учётом простоя DMA).
    pub fn step(&mut self) -> u32 {
        let before = self.cpu.cycles;
        self.cpu.step(&mut self.bus);
        (self.cpu.cycles - before) as u32
    }

    /// Прогнать ровно один кадр (до следующего начала vblank).
    pub fn run_frame(&mut self) {
        self.bus.ppu.frame_complete = false;
        while !self.bus.ppu.frame_complete {
            self.step();
        }
    }

    /// Готовый кадр в формате `0xFFRRGGBB`, 256 на 240.
    pub fn frame(&self) -> &[u32] {
        &self.bus.ppu.frame[..]
    }

    /// Забрать накопленные аудиосэмплы APU (моно, примерно [`SAMPLE_RATE`] в секунду).
    pub fn take_audio_samples(&mut self) -> Vec<f32> {
        self.bus.apu.take_samples()
    }

    /// Перенастроить частоту аудиовывода APU под устройство хоста.
    pub fn set_sample_rate(&mut self, rate: f32) {
        self.bus.apu.set_sample_rate(rate);
    }

    // --- отладочные представления PPU (для виджета) ---

    /// Отрисовать таблицу паттернов CHR (0 или 1) палитрой `palette_slot` (0..7) в
    /// буфер 128 на 128 пикселей.
    pub fn debug_render_pattern(&mut self, table: u8, palette_slot: u8, out: &mut [u32]) {
        let bus = &mut self.bus;
        bus.ppu
            .debug_render_pattern(table, palette_slot, out, bus.mapper.as_mut());
    }

    /// Отрисовать все таблицы имён (фон с атрибутами) в буфер 512 на 480 пикселей.
    pub fn debug_render_nametables(&mut self, out: &mut [u32]) {
        let bus = &mut self.bus;
        bus.ppu.debug_render_nametables(out, bus.mapper.as_mut());
    }

    /// Цвет ячейки палитры (0..31) как `0xFFRRGGBB`.
    pub fn debug_palette_color(&self, slot: u8) -> u32 {
        self.bus.ppu.debug_palette_color(slot)
    }

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

    /// Нажать или отпустить кнопку геймпада (`pad` это 0 или 1).
    pub fn set_button(&mut self, pad: usize, button: Button, pressed: bool) {
        self.bus.controllers[pad].set_button(button, pressed);
    }

    /// Прочитать байт по адресу процессора (для отладчика и тестов). Учти, что
    /// чтение регистров PPU имеет побочные эффекты; для ОЗУ их нет.
    pub fn read_byte(&mut self, addr: u16) -> u8 {
        self.bus.read(addr)
    }

    /// Заглянуть в память без побочных эффектов (для дизассемблера и дампа памяти).
    /// Регистры PPU и APU отдают 0, чтобы просмотр не сбрасывал флаги устройств.
    pub fn peek(&mut self, addr: u16) -> u8 {
        match addr {
            0x2000..=0x401F => 0,
            0x0000..=0x1FFF => self.read_byte(addr),
            _ => self.bus.mapper.cpu_read(addr),
        }
    }
}

Разберём шину по кускам.

Карта памяти в write. Развилка по диапазону адреса это и есть вся маршрутизация. $0000..$1FFF это 2 KiB ОЗУ, отражённые четырежды (отсюда addr & 0x07FF). $2000..$3FFF это восемь регистров PPU, отражённые каждые восемь байт (addr & 0x7). $4014 запускает DMA спрайтов, $4016 рассылает strobe на оба геймпада, а диапазон $4000..$4013 плюс $4015 и $4017 уходит в APU. Всё, что не разобрано ($4020..$FFFF), отдаём картриджу через маппер. Та же развилка механизм против интерфейса, что вела оба проекта.

Асимметрия $4017. Обрати внимание: на запись $4017 это frame counter APU (попадает в 0x4000..=0x4013 | 0x4015 | 0x4017), а на чтение это геймпад 2 (0x4016 => controllers[0], 0x4017 => controllers[1]). Один адрес, два разных устройства в зависимости от направления. Реальное железо такое сплошь и рядом, и карта памяти обязана это учитывать.

Синхронизация в tick. CPU, PPU и APU тикают вместе, но в разном темпе: три такта PPU на один такт CPU. В нашем эмуляторе синхронизация не отдельный планировщик, а свойство самой шины. Ядро на каждый свой такт зовёт bus.tick(), а шина внутри прокручивает PPU трижды и APU один раз.

Синхронизация на tick даёт точность бесплатно: раз ядро тикает шину на каждый доступ к памяти и на каждый внутренний такт, PPU и APU автоматически оказываются там же, где железо. Поэтому Nes::step это просто один шаг процессора, вся периферия прокрутилась внутри него.

Покрути виджет: жми «шаг инструкции», смотри, как на каждый такт CPU счётчик точек PPU прыгает на три, как растёт сканлайн, и как на строке 241 загорается vblank и дёргается NMI.

OAM DMA ($4014). Не всё можно тикать лениво. DMA спрайтов это запись номера страницы в $4014, после которой oam_dma копирует 256 байт из RAM в OAM, и эта копия крадёт у процессора 513 тактов. Их нельзя потерять: пока идёт DMA, PPU продолжает рисовать, и если не прокрутить его на украденное время, картинка съедет. Поэтому шина копит долг в dma_stall, а ядро после записи спрашивает dma_cycles_taken() (который через std::mem::take забирает и обнуляет долг) и дотикивает периферию на это число. Вот связь sprite 0 hit, скролла и DMA в одну точку: машины видят друг друга в нужный момент, а не «потом досчитаем».

Кадровый цикл и NMI. PPU выставляет флаг frame_complete на старте vblank (сканлайн 241) и дёргает NMI, если он разрешён. Метод run_frame сбрасывает флаг и крутит step, пока флаг не поднят снова, то есть ровно до следующего vblank. Наружный код вызывает run_frame, забирает кадр методом frame(), отдаёт его на экран и засыпает до 1/60 секунды. Так держатся стабильные 60 кадров: эмулятор это жёсткий реальный таймбюджет.

Дизассемблер и отладчик

Отлаживать эмулятор по сырым байтам невозможно, поэтому нужен обратный перевод: взять опкод и операнды и показать LDA $0200,X. Это та же золотая трасса из урока 43, только теперь читаемая: дизассемблер питается той же таблицей опкодов, что и исполнитель, поэтому разойтись с реальным декодированием он не может. Поверх него отладчик даёт привычный набор: шаг на инструкцию, прогон до точки останова, снимок регистров.

Создай файлы src/disasm.rs и src/debug.rs.

src/disasm.rs:

//! Дизассемблер 6502: из байтов в читаемую строку.
//!
//! Урок 48. Отлаживать эмулятор по сырым байтам невозможно, поэтому нужен обратный
//! перевод: взять опкод и операнды и показать `LDA $0200,X`. Дизассемблер питается
//! той же таблицей [`crate::cpu::opcodes`], что и исполнитель, поэтому он не может
//! разойтись с реальным декодированием. Эта же строка потом светится в виджете
//! отладчика урока 48.

use crate::cpu::opcodes::{decode, AddrMode};

/// Результат разбора одной инструкции.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Disassembled {
    /// Готовая строка, например `LDA #$10` или `*SLO $0400,X` (звёздочка это
    /// неофициальный опкод).
    pub text: String,
    /// Длина инструкции в байтах (1, 2 или 3).
    pub length: u8,
}

/// Сколько байт занимает инструкция с данным режимом адресации (опкод плюс операнд).
pub fn instruction_length(mode: AddrMode) -> u8 {
    match mode {
        AddrMode::IMP | AddrMode::ACC => 1,
        AddrMode::IMM
        | AddrMode::ZP0
        | AddrMode::ZPX
        | AddrMode::ZPY
        | AddrMode::IZX
        | AddrMode::IZY
        | AddrMode::REL => 2,
        AddrMode::ABS | AddrMode::ABX | AddrMode::ABY | AddrMode::IND => 3,
    }
}

/// Дизассемблировать инструкцию по адресу `pc`. `bytes` это до трёх байт начиная с
/// `pc` (опкод и операнды); лишние байты игнорируются по длине инструкции.
pub fn disassemble(pc: u16, bytes: [u8; 3]) -> Disassembled {
    let info = decode(bytes[0]);
    let length = instruction_length(info.mode);
    let name = info.mnemonic.name();
    let mark = if info.official { "" } else { "*" };

    let lo = bytes[1] as u16;
    let hi = bytes[2] as u16;
    let word = lo | (hi << 8);

    let operand = match info.mode {
        AddrMode::IMP => String::new(),
        AddrMode::ACC => " A".to_string(),
        AddrMode::IMM => format!(" #${:02X}", bytes[1]),
        AddrMode::ZP0 => format!(" ${:02X}", bytes[1]),
        AddrMode::ZPX => format!(" ${:02X},X", bytes[1]),
        AddrMode::ZPY => format!(" ${:02X},Y", bytes[1]),
        AddrMode::IZX => format!(" (${:02X},X)", bytes[1]),
        AddrMode::IZY => format!(" (${:02X}),Y", bytes[1]),
        AddrMode::REL => {
            // Цель ветвления: адрес следующей инструкции плюс знаковое смещение.
            let target = pc.wrapping_add(2).wrapping_add(bytes[1] as i8 as u16);
            format!(" ${target:04X}")
        }
        AddrMode::ABS => format!(" ${word:04X}"),
        AddrMode::ABX => format!(" ${word:04X},X"),
        AddrMode::ABY => format!(" ${word:04X},Y"),
        AddrMode::IND => format!(" (${word:04X})"),
    };

    Disassembled {
        text: format!("{mark}{name}{operand}"),
        length,
    }
}

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

    #[test]
    fn formats_common_modes() {
        // LDA #$10 (непосредственное).
        assert_eq!(disassemble(0x8000, [0xA9, 0x10, 0x00]).text, "LDA #$10");
        // JMP $C5F5 (абсолютное).
        let d = disassemble(0xC000, [0x4C, 0xF5, 0xC5]);
        assert_eq!(d.text, "JMP $C5F5");
        assert_eq!(d.length, 3);
        // BNE с отрицательным смещением: цель это pc+2+offset.
        let d = disassemble(0x8014, [0xD0, 0xFA, 0x00]);
        assert_eq!(d.text, "BNE $8010");
    }

    #[test]
    fn marks_unofficial() {
        // $07 это неофициальный SLO $zp.
        let d = disassemble(0x8000, [0x07, 0x10, 0x00]);
        assert_eq!(d.text, "*SLO $10");
    }
}

src/debug.rs:

//! Отладчик: точки останова, пошаговый прогон, дизассемблирование вокруг PC.
//!
//! Урок 48. Чтобы эмулятор было приятно поддерживать, нужен тот же набор, что и у
//! настоящего отладчика: выполнить одну инструкцию, гнать до точки останова,
//! показать дизассемблер вокруг текущего PC и снимок регистров. Тот же отладчик
//! потом оживает в браузерном виджете урока 48: студент шагает по коду игры и
//! видит, как меняются регистры и флаги.

use std::collections::BTreeSet;

use crate::disasm::{disassemble, Disassembled};
use crate::nes::Nes;

/// Почему прогон остановился.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DebugStop {
    /// Уперлись в точку останова по этому адресу (PC уже стоит на ней).
    Breakpoint(u16),
    /// Исчерпан бюджет шагов (защита от вечного цикла).
    BudgetExhausted,
}

/// Состояние отладки: набор точек останова.
#[derive(Default)]
pub struct Debugger {
    breakpoints: BTreeSet<u16>,
}

impl Debugger {
    pub fn new() -> Self {
        Debugger::default()
    }

    pub fn add_breakpoint(&mut self, addr: u16) {
        self.breakpoints.insert(addr);
    }

    pub fn remove_breakpoint(&mut self, addr: u16) {
        self.breakpoints.remove(&addr);
    }

    pub fn has_breakpoint(&self, addr: u16) -> bool {
        self.breakpoints.contains(&addr)
    }

    pub fn breakpoints(&self) -> impl Iterator<Item = u16> + '_ {
        self.breakpoints.iter().copied()
    }

    /// Выполнить одну инструкцию. Возвращает, сколько тактов это стоило.
    pub fn step(&self, nes: &mut Nes) -> u32 {
        nes.step()
    }

    /// Гнать, пока PC не попадёт на точку останова или не кончится бюджет шагов.
    /// После остановки PC стоит на инструкции, которую ещё предстоит выполнить.
    pub fn run(&self, nes: &mut Nes, max_steps: u64) -> DebugStop {
        for _ in 0..max_steps {
            nes.step();
            if self.breakpoints.contains(&nes.cpu.pc) {
                return DebugStop::Breakpoint(nes.cpu.pc);
            }
        }
        DebugStop::BudgetExhausted
    }

    /// Дизассемблировать `count` инструкций подряд, начиная с адреса `start`.
    /// Возвращает пары (адрес, разбор). Память читается через `peek`, без
    /// побочных эффектов.
    pub fn disassemble_range(
        &self,
        nes: &mut Nes,
        start: u16,
        count: usize,
    ) -> Vec<(u16, Disassembled)> {
        let mut out = Vec::with_capacity(count);
        let mut pc = start;
        for _ in 0..count {
            let bytes = [
                nes.peek(pc),
                nes.peek(pc.wrapping_add(1)),
                nes.peek(pc.wrapping_add(2)),
            ];
            let d = disassemble(pc, bytes);
            let length = d.length as u16;
            out.push((pc, d));
            pc = pc.wrapping_add(length.max(1));
        }
        out
    }
}

/// Снимок регистров процессора для печати или показа в виджете.
#[derive(Clone, Copy, Debug)]
pub struct CpuSnapshot {
    pub pc: u16,
    pub a: u8,
    pub x: u8,
    pub y: u8,
    pub sp: u8,
    pub p: u8,
    pub cycles: u64,
}

impl CpuSnapshot {
    pub fn of(nes: &Nes) -> Self {
        CpuSnapshot {
            pc: nes.cpu.pc,
            a: nes.cpu.a,
            x: nes.cpu.x,
            y: nes.cpu.y,
            sp: nes.cpu.sp,
            p: nes.cpu.p.to_byte(false),
            cycles: nes.cpu.cycles,
        }
    }
}

impl std::fmt::Display for CpuSnapshot {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "PC:{:04X} A:{:02X} X:{:02X} Y:{:02X} P:{:02X} SP:{:02X} CYC:{}",
            self.pc, self.a, self.x, self.y, self.p, self.sp, self.cycles
        )
    }
}

Дизассемблер и отладчик ничего не эмулируют сами: они только смотрят на машину. disassemble_range и снимок регистров читают память через peek, который (в nes.rs) отдаёт ноль на регистрах PPU и APU, чтобы просмотр не сбрасывал их флаги. Это два инструмента, которые питают и текстовый дебаг нативного раннера, и визуальный отладчик в браузерном виджете.

Нативный раннер

Раннер это тонкая оболочка вокруг либы: открыть окно, читать клавиатуру, вывести звук, гонять кадры. Вся логика внутри our-nes, а в main.rs только мост к платформе через minifb (окно) и cpal (звук) плюс «играбельные» удобства: пауза, покадровый и поинструкционный шаг, сброс, mute, перемотка, регулировка скорости и живой дамп состояния процессора.

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

//! Нативный раннер: окно, клавиатура, звук, отладочные хоткеи.
//!
//! Урок 48. Тонкая оболочка вокруг эмулятора: открыть окно, читать геймпад, вывести
//! звук, гонять кадры. Вся логика внутри либы. Здесь же живут «играбельные» удобства
//! эмулятора: пауза, покадровый и поинструкционный шаг, сброс, mute, перемотка,
//! регулировка скорости и живой дамп состояния процессора в терминал. Глубокий
//! визуальный отладчик (регистры, дизассемблер, точки останова) это браузерный
//! виджет урока 48; здесь дебаг текстовый и не мешает игре.
//!
//! Запуск и настройки:
//!
//! ```text
//! cargo run --release -- путь/к/игре.nes [--scale N] [--mute]
//! ```
//!
//! Геймпад (две раскладки, чтобы обойти залипание клавиатуры): крестовина это
//! стрелки или WASD; A это Z или L; B это X или K; Select это правый Shift или
//! Space; Start это Enter. Раскладка WASD плюс K/L разносит руки и нажимается без
//! ghosting. Отладка и удобства:
//!
//! ```text
//! Esc  выход            P  пауза/продолжить     R  сброс
//! M    звук вкл/выкл     `  дамп состояния       Tab (зажать) перемотка
//! N    шаг на кадр       I  шаг на инструкцию    = / -  скорость быстрее/медленнее
//! ```
//!
//! Для `wasm32` раннер не нужен (там движок крутит виджет), поэтому под эту цель
//! бинарь пустой, а зависимости `minifb` и `cpal` исключены через target-cfg.

#[cfg(not(target_arch = "wasm32"))]
fn main() {
    runner::main();
}

#[cfg(target_arch = "wasm32")]
fn main() {}

#[cfg(not(target_arch = "wasm32"))]
mod runner {
    use std::collections::VecDeque;
    use std::io::Write;
    use std::sync::{Arc, Mutex};
    use std::time::{Duration, Instant};

    use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
    use minifb::{Key, KeyRepeat, Scale, Window, WindowOptions};
    use our_nes::ppu::{HEIGHT, WIDTH};
    use our_nes::{disassemble, Button, Nes};

    /// Общая очередь аудиосэмплов между эмулятором и аудиопотоком cpal.
    type AudioQueue = Arc<Mutex<VecDeque<f32>>>;

    /// Разобранные аргументы командной строки.
    struct Settings {
        rom: String,
        scale: Scale,
        muted: bool,
    }

    pub fn main() {
        let settings = match parse_args() {
            Some(settings) => settings,
            None => std::process::exit(2),
        };

        let bytes = std::fs::read(&settings.rom).unwrap_or_else(|e| {
            eprintln!("не прочитать {}: {e}", settings.rom);
            std::process::exit(1);
        });
        let mut nes = Nes::from_rom(&bytes).unwrap_or_else(|e| {
            eprintln!("не загрузить картридж: {e}");
            std::process::exit(1);
        });

        // Звук. Если устройства нет (headless CI), просто играем без него.
        let queue: AudioQueue = Arc::new(Mutex::new(VecDeque::new()));
        let audio = start_audio(queue.clone());
        if let Some((rate, _)) = &audio {
            nes.set_sample_rate(*rate);
            eprintln!("звук: {rate:.0} Гц");
        } else {
            eprintln!("звук: устройство не найдено, играем без звука");
        }
        let max_queued = audio.as_ref().map_or(0, |(rate, _)| (rate * 0.12) as usize);

        let mut window = Window::new(
            "our-nes",
            WIDTH,
            HEIGHT,
            WindowOptions {
                scale: settings.scale,
                ..WindowOptions::default()
            },
        )
        .expect("окно открывается");
        window.set_target_fps(60);

        // На каждую кнопку несколько клавиш: стрелки и WASD для крестовины, Z/X и
        // L/K для A/B. Так есть раскладка без залипания клавиатуры (ghosting): на
        // дешёвых клавиатурах стрелки не нажимаются одновременно с Z, а WASD левой
        // рукой плюс K/L правой регистрируются вместе. Несколько клавиш на кнопку
        // объединяются по ИЛИ.
        let keymap: [(Button, &[Key]); 8] = [
            (Button::Up, &[Key::Up, Key::W]),
            (Button::Down, &[Key::Down, Key::S]),
            (Button::Left, &[Key::Left, Key::A]),
            (Button::Right, &[Key::Right, Key::D]),
            (Button::A, &[Key::Z, Key::L]),
            (Button::B, &[Key::X, Key::K]),
            (Button::Select, &[Key::RightShift, Key::Space]),
            (Button::Start, &[Key::Enter]),
        ];

        // Изменяемое состояние раннера.
        let mut paused = false;
        let mut muted = settings.muted;
        let mut debug = false;
        let mut speed = 1u32; // кадров эмуляции на один показанный кадр
        let mut dc = DcBlocker::default();
        let mut last_report = Instant::now();
        let mut frames = 0u32;
        let mut fps = 0u32;

        println!("{HELP}");

        while window.is_open() && !window.is_key_down(Key::Escape) {
            // --- переключатели по фронту нажатия ---
            if window.is_key_pressed(Key::P, KeyRepeat::No) {
                paused = !paused;
            }
            if window.is_key_pressed(Key::M, KeyRepeat::No) {
                muted = !muted;
            }
            if window.is_key_pressed(Key::R, KeyRepeat::No) {
                nes.reset();
            }
            if window.is_key_pressed(Key::Backquote, KeyRepeat::No) {
                debug = !debug;
                if !debug {
                    println!(); // закрыть живую строку
                }
            }
            if window.is_key_pressed(Key::Equal, KeyRepeat::No) {
                speed = (speed + 1).min(16);
            }
            if window.is_key_pressed(Key::Minus, KeyRepeat::No) {
                speed = speed.saturating_sub(1).max(1);
            }

            // --- геймпад (несколько клавиш на кнопку объединяются по ИЛИ) ---
            for (button, keys) in keymap {
                let pressed = keys.iter().any(|key| window.is_key_down(*key));
                nes.set_button(0, button, pressed);
            }

            // --- прогон эмуляции ---
            let fast_forward = window.is_key_down(Key::Tab);
            if paused {
                // На паузе доступен ручной шаг: кадр (N) или одна инструкция (S).
                if window.is_key_pressed(Key::N, KeyRepeat::Yes) {
                    nes.run_frame();
                }
                if window.is_key_pressed(Key::I, KeyRepeat::Yes) {
                    step_instruction(&mut nes);
                }
            } else {
                let runs = if fast_forward { 8 } else { speed };
                for _ in 0..runs {
                    nes.run_frame();
                    if audio.is_some() {
                        let samples = nes.take_audio_samples();
                        if !muted {
                            push_audio(&queue, &mut dc, samples, max_queued);
                        }
                    }
                }
            }

            window
                .update_with_buffer(nes.frame(), WIDTH, HEIGHT)
                .expect("кадр выводится");

            if debug {
                print_live_state(&mut nes, fps, paused);
            }

            // --- счётчик кадров и заголовок окна ---
            frames += 1;
            if last_report.elapsed() >= Duration::from_secs(1) {
                fps = frames;
                frames = 0;
                last_report = Instant::now();
                let mut flags = Vec::new();
                if paused {
                    flags.push("ПАУЗА".to_string());
                }
                if fast_forward {
                    flags.push("перемотка".to_string());
                } else if speed > 1 {
                    flags.push(format!("x{speed}"));
                }
                if muted {
                    flags.push("без звука".to_string());
                }
                let suffix = if flags.is_empty() {
                    String::new()
                } else {
                    format!("  [{}]", flags.join(" "))
                };
                window.set_title(&format!("our-nes  {fps} fps{suffix}"));
            }
        }
    }

    const HELP: &str = "our-nes: крестовина стрелки/WASD, A Z/L, B X/K, Select \
RShift/Space, Start Enter | P пауза | N шаг-кадр | I шаг-инструкция | R сброс | \
M звук | ` дамп | Tab перемотка | =/- скорость";

    fn parse_args() -> Option<Settings> {
        let mut rom = None;
        let mut scale = Scale::X2;
        let mut muted = false;
        let mut args = std::env::args().skip(1);
        while let Some(arg) = args.next() {
            match arg.as_str() {
                "--help" | "-h" => {
                    println!("использование: cargo run --release -- игра.nes [--scale N] [--mute]");
                    println!("{HELP}");
                    return None;
                }
                "--mute" => muted = true,
                "--scale" => {
                    scale = match args.next().as_deref() {
                        Some("1") => Scale::X1,
                        Some("2") => Scale::X2,
                        Some("4") => Scale::X4,
                        other => {
                            eprintln!("неизвестный масштаб {other:?} (поддержаны 1, 2, 4)");
                            return None;
                        }
                    };
                }
                _ if rom.is_none() => rom = Some(arg),
                _ => {}
            }
        }
        match rom {
            Some(rom) => Some(Settings { rom, scale, muted }),
            None => {
                eprintln!("использование: cargo run --release -- игра.nes [--scale N] [--mute]");
                eprintln!("своей игры нет? возьми тест-ROM из roms/ (например roms/nestest.nes)");
                None
            }
        }
    }

    /// Выполнить одну инструкцию и напечатать её дизассемблер с состоянием регистров.
    fn step_instruction(nes: &mut Nes) {
        let pc = nes.cpu.pc;
        let decoded = disassemble(
            pc,
            [
                nes.peek(pc),
                nes.peek(pc.wrapping_add(1)),
                nes.peek(pc.wrapping_add(2)),
            ],
        );
        let cpu = &nes.cpu;
        println!(
            "{pc:04X}  {:<12}  A:{:02X} X:{:02X} Y:{:02X} SP:{:02X} P:{} CYC:{}",
            decoded.text,
            cpu.a,
            cpu.x,
            cpu.y,
            cpu.sp,
            flags_str(cpu.p.to_byte(false)),
            cpu.cycles
        );
        nes.step();
    }

    /// Живая однострочная сводка состояния (перерисовывается на месте через `\r`).
    fn print_live_state(nes: &mut Nes, fps: u32, paused: bool) {
        let pc = nes.cpu.pc;
        let (a, x, y, sp, p, cyc) = {
            let cpu = &nes.cpu;
            (
                cpu.a,
                cpu.x,
                cpu.y,
                cpu.sp,
                cpu.p.to_byte(false),
                cpu.cycles,
            )
        };
        let (sl, dot) = {
            let ppu = &nes.bus.ppu;
            (ppu.scanline(), ppu.dot())
        };
        let mark = if paused { "⏸" } else { "▶" };
        print!(
            "\r{mark} PC:{pc:04X} A:{a:02X} X:{x:02X} Y:{y:02X} SP:{sp:02X} P:{} CYC:{cyc} SL:{sl:>3} DOT:{dot:>3} FPS:{fps:>3}    ",
            flags_str(p)
        );
        let _ = std::io::stdout().flush();
    }

    /// Флаги `P` буквами: установленный бит заглавной, снятый строчной.
    fn flags_str(p: u8) -> String {
        const NAMES: [(char, u8); 8] = [
            ('N', 7),
            ('V', 6),
            ('U', 5),
            ('B', 4),
            ('D', 3),
            ('I', 2),
            ('Z', 1),
            ('C', 0),
        ];
        NAMES
            .iter()
            .map(|(name, bit)| {
                if p >> bit & 1 == 1 {
                    *name
                } else {
                    name.to_ascii_lowercase()
                }
            })
            .collect()
    }

    /// DC-блокер: убирает постоянную составляющую микса APU (иначе на старте и паузах
    /// слышен щелчок).
    #[derive(Default)]
    struct DcBlocker {
        prev_in: f32,
        prev_out: f32,
    }

    impl DcBlocker {
        fn process(&mut self, sample: f32) -> f32 {
            let out = sample - self.prev_in + 0.995 * self.prev_out;
            self.prev_in = sample;
            self.prev_out = out;
            out
        }
    }

    fn push_audio(queue: &AudioQueue, dc: &mut DcBlocker, samples: Vec<f32>, max_queued: usize) {
        let mut queued = queue.lock().unwrap();
        // Если буфер переполнен (картинка отстаёт), не копим задержку.
        if queued.len() >= max_queued {
            return;
        }
        for raw in samples {
            queued.push_back((dc.process(raw) * 1.2).clamp(-1.0, 1.0));
        }
    }

    /// Поднять аудиопоток cpal. Возвращает частоту устройства и сам поток (его надо
    /// держать живым). `None`, если устройства нет или формат не f32.
    fn start_audio(queue: AudioQueue) -> Option<(f32, cpal::Stream)> {
        let device = cpal::default_host().default_output_device()?;
        let config = device.default_output_config().ok()?;
        let rate = config.sample_rate().0 as f32;
        let channels = config.channels() as usize;

        if config.sample_format() != cpal::SampleFormat::F32 {
            eprintln!("звук: формат устройства не f32, играем без звука");
            return None;
        }

        let stream = device
            .build_output_stream(
                &config.into(),
                move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
                    let mut queued = queue.lock().unwrap();
                    for frame in data.chunks_mut(channels) {
                        let sample = queued.pop_front().unwrap_or(0.0);
                        for slot in frame.iter_mut() {
                            *slot = sample;
                        }
                    }
                },
                |err| eprintln!("ошибка аудиопотока: {err}"),
                None,
            )
            .ok()?;
        stream.play().ok()?;
        Some((rate, stream))
    }
}

Запуск простой: cargo run -- путь/к/игре.nes (или cargo run --release -- игра.nes для полной скорости). Своей игры нет? Возьми любой свободный тест-ROM из roms/. Главный цикл повторяет ту же тройку: прочитать клавиатуру в геймпад через set_button, прокрутить run_frame, отдать frame() в окно minifb. Звук уезжает в общую очередь, из которой колбэк cpal забирает сэмплы в своём потоке (поэтому очередь под Arc<Mutex<..>>), а DcBlocker срезает постоянную составляющую, чтобы на старте и паузах не было щелчка.

Карта клавиш даёт две раскладки на каждую кнопку: стрелки и WASD для крестовины, Z/X и L/K для A/B. Это нужно, чтобы обойти ghosting (на дешёвых клавиатурах часть клавиш не регистрируется одновременно); несколько клавиш на одну кнопку объединяются по ИЛИ. Заметь: minifb и cpal подключаются только под нативную цель. Как мы видели в Cargo.toml урока 43, они объявлены через [target.'cfg(not(target_arch = "wasm32"))'.dependencies], а сам main под wasm32 пустой. Один и тот же crate собирается и в нативный бинарь, и в wasm без лишних зависимостей.

Один код, две поверхности

У движка две двери наружу. Одну мы только что сделали (нативный раннер). Вторая это WebAssembly: тот же Nes собирается в wasm32 и становится движком браузерного виджета. Браузер не умеет передавать в wasm структуры, поэтому ABI плоский: числа ходят целыми, а большие буферы (кадр, аудио, образ ROM, текст дизассемблера) живут в линейной памяти, а JS читает их по указателю. wasm-bindgen тут не нужен: импортов нет, наружу экспортируются memory и функции nes_*.

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

//! Тонкая обёртка эмулятора для WebAssembly.
//!
//! Урок 48 (и движок виджетов RU11). Это та же консоль, что и в уроках: процессор,
//! PPU, картридж, отладчик. Меняется только дверь наружу. Браузер не умеет
//! передавать в wasm структуры, поэтому ABI плоский: числа ходят целыми, а большие
//! буферы (кадр, аудио, образ ROM, текст дизассемблера) живут в линейной памяти, а
//! JS читает их по указателю. wasm-bindgen не нужен: импортов нет, экспортируются
//! `memory` и функции `nes_*`, ровно как в эталоне RU6 (`examples/our-cpu`).
//!
//! Машина одна на модуль и живёт в `thread_local` (wasm32 однопоточный).

use std::cell::RefCell;

use crate::controller::Button;
use crate::debug::Debugger;
use crate::disasm::disassemble;
use crate::nes::Nes;
use crate::ppu::{HEIGHT, WIDTH};

/// Состояние модуля: консоль (если ROM загружен), отладчик и буферы обмена.
#[derive(Default)]
struct Machine {
    nes: Option<Nes>,
    debugger: Debugger,
    /// Сюда JS кладёт байты образа `.nes` перед `nes_boot`.
    rom: Vec<u8>,
    /// Кадр в формате RGBA (4 байта на пиксель) для `canvas`.
    framebuffer: Vec<u8>,
    /// Аудиосэмплы, снятые с APU за последний прогон.
    audio: Vec<f32>,
    /// Текстовый буфер дизассемблера.
    text: Vec<u8>,
    /// Буферы отладочных представлений PPU: сырой кадр `0xFFRRGGBB` и его RGBA для
    /// canvas. Паттерн-таблица 128 на 128, таблицы имён 512 на 480.
    pattern_argb: Vec<u32>,
    pattern_rgba: Vec<u8>,
    nametable_argb: Vec<u32>,
    nametable_rgba: Vec<u8>,
}

/// Ширина и высота отладочных представлений.
const PATTERN_DIM: usize = 128;
const NAMETABLE_W: usize = 512;
const NAMETABLE_H: usize = 480;

thread_local! {
    static MACHINE: RefCell<Machine> = RefCell::new(Machine {
        framebuffer: vec![0; WIDTH * HEIGHT * 4],
        pattern_argb: vec![0; PATTERN_DIM * PATTERN_DIM],
        pattern_rgba: vec![0; PATTERN_DIM * PATTERN_DIM * 4],
        nametable_argb: vec![0; NAMETABLE_W * NAMETABLE_H],
        nametable_rgba: vec![0; NAMETABLE_W * NAMETABLE_H * 4],
        ..Machine::default()
    });
}

/// Перевести буфер `0xFFRRGGBB` в RGBA-байты для `canvas`.
fn argb_to_rgba(src: &[u32], dst: &mut [u8]) {
    for (pixel, argb) in src.iter().enumerate() {
        let base = pixel * 4;
        dst[base] = (argb >> 16) as u8;
        dst[base + 1] = (argb >> 8) as u8;
        dst[base + 2] = *argb as u8;
        dst[base + 3] = 0xFF;
    }
}

fn with<R>(f: impl FnOnce(&mut Machine) -> R) -> R {
    MACHINE.with(|m| f(&mut m.borrow_mut()))
}

// --- загрузка ROM ---

/// Подготовить буфер под образ ROM длины `len` и вернуть указатель на него.
/// JS пишет туда байты `.nes`, затем зовёт [`nes_boot`].
#[no_mangle]
pub extern "C" fn nes_rom_ptr(len: u32) -> *mut u8 {
    with(|m| {
        m.rom.resize(len as usize, 0);
        m.rom.as_mut_ptr()
    })
}

/// Собрать консоль из загруженного образа. Возврат: 1 успех, 0 ошибка разбора.
#[no_mangle]
pub extern "C" fn nes_boot() -> i32 {
    with(|m| match Nes::from_rom(&m.rom) {
        Ok(nes) => {
            m.nes = Some(nes);
            1
        }
        Err(_) => 0,
    })
}

/// Аппаратный сброс.
#[no_mangle]
pub extern "C" fn nes_reset() {
    with(|m| {
        if let Some(nes) = m.nes.as_mut() {
            nes.reset();
        }
    });
}

// --- прогон ---

/// Прогнать один кадр. После вызова кадр готов в RGBA-буфере, а аудио в аудиобуфере.
#[no_mangle]
pub extern "C" fn nes_run_frame() {
    with(|m| {
        let Some(nes) = m.nes.as_mut() else { return };
        nes.run_frame();
        render_into(&mut m.framebuffer, nes);
        m.audio = nes.take_audio_samples();
    });
}

/// Выполнить одну инструкцию (для пошагового режима отладчика).
#[no_mangle]
pub extern "C" fn nes_step() {
    with(|m| {
        if let Some(nes) = m.nes.as_mut() {
            nes.step();
            render_into(&mut m.framebuffer, nes);
        }
    });
}

/// Гнать до точки останова или бюджета шагов. Возврат: 1 точка останова, 0 бюджет.
#[no_mangle]
pub extern "C" fn nes_run_until_breakpoint(max_steps: u32) -> i32 {
    with(|m| {
        let Some(nes) = m.nes.as_mut() else { return 0 };
        let hit = matches!(
            m.debugger.run(nes, max_steps as u64),
            crate::debug::DebugStop::Breakpoint(_)
        );
        render_into(&mut m.framebuffer, nes);
        hit as i32
    })
}

fn render_into(buffer: &mut [u8], nes: &Nes) {
    for (pixel, argb) in nes.frame().iter().enumerate() {
        let base = pixel * 4;
        buffer[base] = (argb >> 16) as u8; // R
        buffer[base + 1] = (argb >> 8) as u8; // G
        buffer[base + 2] = *argb as u8; // B
        buffer[base + 3] = 0xFF; // A
    }
}

// --- кадр ---

/// Указатель на RGBA-буфер кадра (256*240*4 байта).
#[no_mangle]
pub extern "C" fn nes_framebuffer_ptr() -> *const u8 {
    with(|m| m.framebuffer.as_ptr())
}

/// Ширина и высота кадра (фиксированы, но удобно дёргать из JS).
#[no_mangle]
pub extern "C" fn nes_frame_width() -> u32 {
    WIDTH as u32
}

#[no_mangle]
pub extern "C" fn nes_frame_height() -> u32 {
    HEIGHT as u32
}

// --- аудио ---

/// Указатель на буфер аудиосэмплов (моно f32) за последний кадр.
#[no_mangle]
pub extern "C" fn nes_audio_ptr() -> *const f32 {
    with(|m| m.audio.as_ptr())
}

/// Сколько сэмплов лежит в аудиобуфере.
#[no_mangle]
pub extern "C" fn nes_audio_len() -> u32 {
    with(|m| m.audio.len() as u32)
}

// --- ввод ---

/// Нажать или отпустить кнопку. `button` это индекс 0..7
/// (A, B, Select, Start, Up, Down, Left, Right).
#[no_mangle]
pub extern "C" fn nes_set_button(pad: u32, button: u32, pressed: u32) {
    let Some(button) = button_from_index(button) else {
        return;
    };
    with(|m| {
        if let Some(nes) = m.nes.as_mut() {
            nes.set_button(pad as usize, button, pressed != 0);
        }
    });
}

fn button_from_index(index: u32) -> Option<Button> {
    Some(match index {
        0 => Button::A,
        1 => Button::B,
        2 => Button::Select,
        3 => Button::Start,
        4 => Button::Up,
        5 => Button::Down,
        6 => Button::Left,
        7 => Button::Right,
        _ => return None,
    })
}

// --- отладчик: регистры ---

#[no_mangle]
pub extern "C" fn nes_pc() -> u32 {
    with(|m| m.nes.as_ref().map_or(0, |n| n.cpu.pc as u32))
}

#[no_mangle]
pub extern "C" fn nes_reg_a() -> u32 {
    with(|m| m.nes.as_ref().map_or(0, |n| n.cpu.a as u32))
}

#[no_mangle]
pub extern "C" fn nes_reg_x() -> u32 {
    with(|m| m.nes.as_ref().map_or(0, |n| n.cpu.x as u32))
}

#[no_mangle]
pub extern "C" fn nes_reg_y() -> u32 {
    with(|m| m.nes.as_ref().map_or(0, |n| n.cpu.y as u32))
}

#[no_mangle]
pub extern "C" fn nes_reg_sp() -> u32 {
    with(|m| m.nes.as_ref().map_or(0, |n| n.cpu.sp as u32))
}

/// Регистр состояния `P` одним байтом (бит B = 0, бит 5 = 1).
#[no_mangle]
pub extern "C" fn nes_reg_p() -> u32 {
    with(|m| m.nes.as_ref().map_or(0, |n| n.cpu.p.to_byte(false) as u32))
}

#[no_mangle]
pub extern "C" fn nes_cycles() -> u32 {
    with(|m| m.nes.as_ref().map_or(0, |n| n.cpu.cycles as u32))
}

/// Заглянуть в память без побочных эффектов.
#[no_mangle]
pub extern "C" fn nes_peek(addr: u32) -> u32 {
    with(|m| m.nes.as_mut().map_or(0, |n| n.peek(addr as u16) as u32))
}

// --- отладчик: точки останова ---

#[no_mangle]
pub extern "C" fn nes_bp_add(addr: u32) {
    with(|m| m.debugger.add_breakpoint(addr as u16));
}

#[no_mangle]
pub extern "C" fn nes_bp_remove(addr: u32) {
    with(|m| m.debugger.remove_breakpoint(addr as u16));
}

// --- отладчик: дизассемблер ---

/// Дизассемблировать инструкцию по адресу `addr` в текстовый буфер. Возвращает
/// длину инструкции в байтах (1..3); сам текст JS читает из [`nes_text_ptr`]
/// длиной [`nes_text_len`].
#[no_mangle]
pub extern "C" fn nes_disasm(addr: u32) -> u32 {
    with(|m| {
        let Some(nes) = m.nes.as_mut() else { return 0 };
        let pc = addr as u16;
        let bytes = [
            nes.peek(pc),
            nes.peek(pc.wrapping_add(1)),
            nes.peek(pc.wrapping_add(2)),
        ];
        let d = disassemble(pc, bytes);
        m.text.clear();
        m.text.extend_from_slice(d.text.as_bytes());
        d.length as u32
    })
}

#[no_mangle]
pub extern "C" fn nes_text_ptr() -> *const u8 {
    with(|m| m.text.as_ptr())
}

#[no_mangle]
pub extern "C" fn nes_text_len() -> u32 {
    with(|m| m.text.len() as u32)
}

// --- отладочные представления PPU (виджет урока 48) ---

/// Отрисовать таблицу паттернов CHR (`table` 0 или 1) палитрой `palette_slot`
/// (0..7) в RGBA-буфер 128 на 128. JS читает его по [`nes_pattern_ptr`].
#[no_mangle]
pub extern "C" fn nes_render_pattern(table: u32, palette_slot: u32) {
    with(|m| {
        if let Some(nes) = m.nes.as_mut() {
            nes.debug_render_pattern(table as u8, palette_slot as u8, &mut m.pattern_argb);
            argb_to_rgba(&m.pattern_argb, &mut m.pattern_rgba);
        }
    });
}

/// Указатель на RGBA-буфер таблицы паттернов (128 * 128 * 4 байта).
#[no_mangle]
pub extern "C" fn nes_pattern_ptr() -> *const u8 {
    with(|m| m.pattern_rgba.as_ptr())
}

/// Отрисовать все таблицы имён (фон с атрибутами) в RGBA-буфер 512 на 480.
#[no_mangle]
pub extern "C" fn nes_render_nametables() {
    with(|m| {
        if let Some(nes) = m.nes.as_mut() {
            nes.debug_render_nametables(&mut m.nametable_argb);
            argb_to_rgba(&m.nametable_argb, &mut m.nametable_rgba);
        }
    });
}

/// Указатель на RGBA-буфер таблиц имён (512 * 480 * 4 байта).
#[no_mangle]
pub extern "C" fn nes_nametable_ptr() -> *const u8 {
    with(|m| m.nametable_rgba.as_ptr())
}

/// Цвет ячейки палитры (0..31) как `0xFFRRGGBB`.
#[no_mangle]
pub extern "C" fn nes_palette_color(slot: u32) -> u32 {
    with(|m| {
        m.nes
            .as_ref()
            .map_or(0, |n| n.debug_palette_color(slot as u8))
    })
}

/// Байт OAM по индексу (0..255) для просмотра спрайтов.
#[no_mangle]
pub extern "C" fn nes_oam(index: u32) -> u32 {
    with(|m| {
        m.nes
            .as_ref()
            .map_or(0, |n| n.debug_oam(index as u8) as u32)
    })
}

Собирается это так: cargo build --features wasm --target wasm32-unknown-unknown. Под фичей wasm lib.rs подключает модуль wasm, все функции помечены #[no_mangle] extern "C", и наружу торчат только числа и указатели. JS кладёт байты .nes в буфер по nes_rom_ptr, зовёт nes_boot, потом в цикле дёргает nes_run_frame и читает кадр по nes_framebuffer_ptr, аудио по nes_audio_ptr. Машина одна на модуль и живёт в thread_local, потому что wasm32 однопоточный. Это ровно тот же движок, что крутит виджеты этого блока (и виджет тайминга выше, и эмулятор ниже), просто завёрнутый в плоский ABI.

Загрузи свой свободный .nes в виджет ниже и поиграй прямо здесь: это весь блок целиком, от карты памяти до микшера, собранный в одну машину. Тот же приём «один Rust, две поверхности» мы доведём до конца в блоке про WASM.

Финальный src/lib.rs

Осталось собрать дерево модулей. Этот lib.rs рос по одному pub mod на урок: ядро и шину завели в уроках 42, 43, картридж в 44, PPU в 45, 46, APU в 47, а сегодня добавились controller, nes, disasm, debug и wasm под фичей. Вот финальный полный вид.

//! `our-nes`: эталонный эмулятор NES, блок RU7.
//!
//! Полноценная NES это четыре сцепленных машины: процессор 2A03 (ядро 6502 плюс
//! APU), графический сопроцессор PPU 2C02, картридж с мапперами и шина, которая их
//! связывает. Мы собираем их снизу вверх, по одному уроку на слой, и тот же код в
//! уроке 48 компилируется в `wasm32` и становится движком виджета. Архитектура и
//! HAL переиспользуют идеи эталона RU6 (`examples/our-cpu`): ядро параметризовано
//! трейтом шины, периферия прячется за трейтами, отладчик и трейс общие.
//!
//! Слои по урокам:
//!
//! - [`cpu`] и его [`cpu::opcodes`]: ядро 6502, набор команд, такты (урок 43).
//! - [`bus`]: трейт шины, через который процессор видит мир (урок 42, 43).
//! - [`cartridge`] и [`mapper`]: формат iNES, переключение банков (урок 44).
//! - [`ppu`]: графический сопроцессор, фон и спрайты (уроки 45, 46).
//! - [`apu`]: звук, каналы pulse, triangle, noise, DMC (урок 47).
//! - [`controller`], [`nes`], [`disasm`], [`debug`]: геймпады, сборка системы,
//!   дизассемблер и отладчик (урок 48).
//! - [`wasm`] (под feature `wasm`): плоский C-ABI для виджета в браузере.

pub mod apu;
pub mod bus;
pub mod cartridge;
pub mod controller;
pub mod cpu;
pub mod debug;
pub mod disasm;
pub mod mapper;
pub mod nes;
pub mod ppu;

/// Обёртка консоли для WebAssembly: плоский C-ABI, тот же движок (урок 48, RU11).
#[cfg(feature = "wasm")]
pub mod wasm;

pub use apu::Apu;
pub use bus::Bus;
pub use cartridge::{load, RomError};
pub use controller::{Button, Controller};
pub use cpu::{status::Status, Cpu};
pub use debug::{DebugStop, Debugger};
pub use disasm::{disassemble, Disassembled};
pub use mapper::{Mapper, Mirroring};
pub use nes::Nes;
pub use ppu::Ppu;

Re-экспорты наверху это публичный фасад crate: раннер пишет use our_nes::{disassemble, Button, Nes}, тест пишет use our_nes::Nes, и никому не нужно знать, в каком модуле что лежит.

Проверка

Финишная черта это реальный тест-ROM от blargg, прогнанный через всю систему. Он гоняет все официальные опкоды на картридже MMC1 и пишет статус в $6000. Это проверяет связку процессора, шины и маппера на настоящем образе, а не на синтетике.

Создай файл tests/blargg.rs:

//! Прогон реального тест-ROM blargg через всю систему (уроки 43, 44, 48).
//!
//! `instr_test-v5/official_only.nes` это тест от blargg на картридже MMC1 с PRG
//! RAM и CHR RAM. Он гоняет все официальные опкоды и пишет статус в `$6000`:
//! сначала `0x80` (идёт тест), потом код результата (`0x00` это успех), а человеко
//! читаемый текст в `$6004`. Магия `$DE $B0 $61` в `$6001..$6003` помечает, что
//! протокол активен. Это проверяет связку процессора, шины и маппера на настоящем
//! образе, а не на синтетике.

use our_nes::Nes;

#[test]
fn blargg_instr_test_official_passes() {
    let rom = std::fs::read(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/roms/instr_test_official_only.nes"
    ))
    .expect("instr_test лежит в roms/");
    let mut nes = Nes::from_rom(&rom).expect("MMC1 разбирается");

    // Гоняем кадрами, пока тест не объявит результат (или не выйдем за лимит).
    let mut result = 0xFFu8;
    for _ in 0..2000 {
        nes.run_frame();
        let magic = [
            nes.read_byte(0x6001),
            nes.read_byte(0x6002),
            nes.read_byte(0x6003),
        ];
        if magic == [0xDE, 0xB0, 0x61] {
            let status = nes.read_byte(0x6000);
            if status != 0x80 {
                result = status;
                break;
            }
        }
    }

    assert_eq!(
        result, 0x00,
        "blargg instr_test официальные опкоды: 0x00 это успех"
    );
}

Тест устроен ровно как настоящая консоль: грузит образ, крутит run_frame и читает память через read_byte, пока тест не выставит код результата. Прогони всё:

cargo test

Зелёный blargg_instr_test_official_passes (плюс юнит-тесты контроллера, дизассемблера и остальных слоёв) значит, что машина собрана верно: процессор, шина и маппер сходятся на реальном железном тесте. А теперь запусти игру и поиграй:

cargo run -- путь/к/игре.nes

Окно открылось, звук пошёл, кнопки нажимаются. Это финиш блока: у тебя на руках полный эмулятор NES.

Дальше

Блок закрыт: у тебя полноценный эмулятор NES, который запускает реальную игру в нативном окне и в браузере. Дальше раздел уходит в async вглубь, а собранный здесь эмулятор ещё вернётся в WASM-блоке как готовый движок виджета.

Домашка