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