Это продолжение предыдущего поста, где я начал писать эмулятор CHIP-8 на Rust и вот, в неравной борьбе со своей невнимательностью и опечатками, я его доделал до рабочего состояния.

Никогда раньше (со времен универа) не занимался ничем низкоуровневым, где понадобились бы битовые сдвиги, маски и вот это всё, но по факту это просто.

В предыдущей заметке показал как разбирать код ROM-ов на опкоды и интерпретировать их. Далее просто необходимо внимательно реализовать все 35 команд CHIP-8.

Итоговый код получился такой:

use std::{path::PathBuf, usize};

pub struct Chip8 {
    pub memory: [u8; 4096],
    pub registers: [u8; 16],
    pub i_register: u16,
    pub pc: u16,
    pub delay_timer: u8,
    pub sound_timer: u8,
    pub stack: Vec<u16>,
    pub display: [[u8; 64]; 32],
    pub keys: [bool; 16],
    pub waiting_key_opcode: u16,
}

impl Default for Chip8 {
    fn default() -> Self {
        Self {
            memory: [0; 4096],
            registers: [0; 16],
            i_register: 0,
            pc: 0x200,
            delay_timer: 0,
            sound_timer: 0,
            stack: Vec::new(),
            display: [[0; 64]; 32],
            keys: [false; 16],
            waiting_key_opcode: 0,
        }
    }
}

impl Chip8 {
    // очищаем память и всё остальное
    pub fn reset(&mut self) {
        self.memory.fill(0);
        self.restart();
    }

    // очищаем всё кроме памяти
    pub fn restart(&mut self) {
        self.registers.fill(0);
        self.i_register = 0;
        self.pc = 0x200;
        self.stack.clear();
        self.display.fill([0; 64]);
        self.keys.fill(false);
        self.delay_timer = 0;
        self.sound_timer = 0;
        self.waiting_key_opcode = 0;
    }

    fn load_rom(&mut self, rom: &[u8], start_address: usize) {
        self.reset();
        let end_address = start_address + rom.len();
        if end_address > self.memory.len() {
            panic!("ROM is too large to fit in memory");
        }
        for (i, &byte) in rom.iter().enumerate() {
            self.memory[start_address + i] = byte;
        }
    }

    pub fn load_from_file(&mut self, filename: &PathBuf) {
        let buffer = std::fs::read(&filename).unwrap();
        self.load_rom(&buffer, 0x200);
    }

    pub fn get_current_opcode(&self) -> u16 {
        let byte1 = self.memory[self.pc as usize] as u16;
        let byte2 = self.memory[(self.pc + 1) as usize] as u16;
        (byte1 << 8) | byte2
    }

    // получаем текущий opcode и сдвигаем указатель pc на 2
    fn get_opcode(&mut self) -> u16 {
        if self.pc as usize + 1 >= self.memory.len() {
            panic!("Attempted to read opcode outside of memory bounds");
        }

        // ждем нажатия клавиши если нужно
        if self.waiting_key_opcode > 0 {
            return self.waiting_key_opcode;
        }

        let byte1 = self.memory[self.pc as usize] as u16;
        let byte2 = self.memory[(self.pc + 1) as usize] as u16;
        self.pc += 2;
        (byte1 << 8) | byte2
    }

    // уменьшаем таймеры
    pub fn update_timers(&mut self) {
        if self.delay_timer > 0 {
            self.delay_timer -= 1;
        }
        if self.sound_timer > 0 {
            self.sound_timer -= 1;
        }
    }

    pub fn execute_opcode(&mut self) {
        let opcode = self.get_opcode();
        match opcode & 0xF000 {
            0x0000 => self.handle_0xxx(opcode),
            0x1000 => self.handle_1xxx(opcode),
            0x2000 => self.handle_2xxx(opcode),
            0x3000 => self.handle_3xxx(opcode),
            0x4000 => self.handle_4xxx(opcode),
            0x5000 => self.handle_5xxx(opcode),
            0x6000 => self.handle_6xxx(opcode),
            0x7000 => self.handle_7xxx(opcode),
            0x8000 => self.handle_8xxx(opcode),
            0x9000 => self.handle_9xxx(opcode),
            0xA000 => self.handle_Axxx(opcode),
            0xB000 => self.handle_Bxxx(opcode),
            0xC000 => self.handle_Cxxx(opcode),
            0xD000 => self.handle_Dxxx(opcode),
            0xE000 => self.handle_Exxx(opcode),
            0xF000 => self.handle_Fxxx(opcode),
            _ => panic!("Unknown opcode: {:#X}", opcode),
        }
    }

    fn handle_0xxx(&mut self, opcode: u16) {
        match opcode {
            0x00E0 => self.display.fill([0; 64]), // CLS
            0x00EE => self.pc = self.stack.pop().expect("Stack underflow"), // RET
            _ => panic!("Unknown opcode: {:#X}", opcode),
        }
    }

    fn handle_1xxx(&mut self, opcode: u16) {
        self.pc = opcode & 0x0FFF;  // JMP
    }

    fn handle_2xxx(&mut self, opcode: u16) {
        self.stack.push(self.pc);
        self.pc = opcode & 0x0FFF;
    }

    fn handle_3xxx(&mut self, opcode: u16) {
        let x = ((opcode & 0x0F00) >> 8) as usize;
        let nn = (opcode & 0x00FF) as u8;
        if self.registers[x] == nn {
            self.pc += 2;
        }
    }

    fn handle_4xxx(&mut self, opcode: u16) {
        let x = ((opcode & 0x0F00) >> 8) as usize;
        let nn = (opcode & 0x00FF) as u8;
        if self.registers[x] != nn {
            self.pc += 2;
        }
    }

    fn handle_5xxx(&mut self, opcode: u16) {
        let x = ((opcode & 0x0F00) >> 8) as usize;
        let y = ((opcode & 0x00F0) >> 4) as usize;
        if self.registers[x] == self.registers[y] {
            self.pc += 2;
        }
    }

    fn handle_6xxx(&mut self, opcode: u16) {
        let x = ((opcode & 0x0F00) >> 8) as usize;
        let nn = (opcode & 0x00FF) as u8;
        self.registers[x] = nn;
    }

    fn handle_7xxx(&mut self, opcode: u16) {
        let x = ((opcode & 0x0F00) >> 8) as usize;
        let nn = (opcode & 0x00FF) as u8;
        self.registers[x] = self.registers[x].wrapping_add(nn);
    }

    fn handle_8xxx(&mut self, opcode: u16) {
        let x = ((opcode & 0x0F00) >> 8) as usize;
        let y = ((opcode & 0x00F0) >> 4) as usize;
        let vx = self.registers[x];
        let vy = self.registers[y];

        match opcode & 0xF00F {
            0x8000 => { // LD
                self.registers[x] = vy;
            }
            0x8001 => { // OR
                self.registers[x] |= vy;
            }
            0x8002 => { // AND
                self.registers[x] &= vy;
            }
            0x8003 => { // XOR
                self.registers[x] ^= vy;
            }
            0x8004 => { // ADD
                let (res, carry) = vx.overflowing_add(vy);
                self.registers[x] = res;
                self.registers[0xF] = if carry { 1 } else { 0 };
            }
            0x8005 => {
                self.registers[0xF] = if vx > vy { 1 } else { 0 };
                self.registers[x] = vx.wrapping_sub(vy);
            }
            0x8006 => {
                self.registers[0xF] = vx & 0x01;
                self.registers[x] = vx >> 1;
            }
            0x8007 => {
                let (res, carry) = vy.overflowing_sub(vx);
                self.registers[0xF] = if carry { 0 } else { 1 };
                self.registers[x] = res;
            }
            0x800E => {
                self.registers[0xF] = (vx >> 7) & 1;
                self.registers[x] = vx << 1;
            }
            _ => panic!("Unknown opcode: {:#X}", opcode),
        }
    }

    fn handle_9xxx(&mut self, opcode: u16) {
        let x = ((opcode & 0xF00) >> 8) as usize;
        let y = ((opcode & 0x0F0) >> 4) as usize;
        if self.registers[x] != self.registers[y] {
            self.pc += 2;
        }
    }

    fn handle_Axxx(&mut self, opcode: u16) {
        self.i_register = opcode & 0xFFF;
    }

    fn handle_Bxxx(&mut self, opcode: u16) {
        self.pc = self.registers[0] as u16 + (opcode & 0xFFF);
    }

    fn handle_Cxxx(&mut self, opcode: u16) {
        let x = ((opcode & 0xF00) >> 8) as usize;
        let nn = (opcode & 0x0FF) as u8;
        self.registers[x] = rand::random::<u8>() & nn;
    }

    fn handle_Dxxx(&mut self, opcode: u16) {
        let vx = self.registers[((opcode & 0xF00) >> 8) as usize];
        let vy = self.registers[((opcode & 0x0F0) >> 4) as usize];
        let n = opcode & 0x000F;
        let x = (vx as usize) % 64;
        let y = (vy as usize) % 32;
        let sprite_data =
            &self.memory[self.i_register as usize..self.i_register as usize + n as usize];
        self.registers[0xF] = 0;
        for row in 0..n {
            for col in 0..8 {
                let pixel = (sprite_data[row as usize] >> (7 - col)) & 1;
                let disp_x = (x + col as usize) % 64;
                let disp_y = (y + row as usize) % 32;
                let cur_pixel = self.display[disp_y][disp_x];
                if cur_pixel > 0 && pixel > 0 {
                    self.registers[0xF] = 1;
                }
                self.display[disp_y][disp_x] = cur_pixel ^ pixel;
            }
        }
    }

    fn handle_Exxx(&mut self, opcode: u16) {
        let x = ((opcode & 0xF00) >> 8) as usize;
        let vx = self.registers[x] as usize; //key index
        match opcode & 0xF0FF {
            0xE09E => {
                if self.keys[vx] {
                    self.pc += 2;
                }
            }
            0xE0A1 => {
                if !self.keys[vx] {
                    self.pc += 2;
                }
            }
            _ => panic!("Unknown opcode: {:#X}", opcode),
        }
    }

    fn handle_Fxxx(&mut self, opcode: u16) {
        let x = ((opcode & 0xF00) >> 8) as usize;
        let vx = self.registers[x];
        match opcode & 0xF0FF {
            0xF007 => {
                self.registers[x] = self.delay_timer;
            }
            0xF00A => {
                self.waiting_key_opcode = opcode;
                for (i, &key) in self.keys.iter().enumerate() {
                    if key {
                        self.waiting_key_opcode = 0;
                        self.registers[x] = i as u8;
                        break;
                    }
                }
            }
            0xF015 => {
                self.delay_timer = self.registers[x];
            }
            0xF018 => {
                self.sound_timer = self.registers[x];
            }
            0xF01E => {
                self.i_register += self.registers[x] as u16;
            }
            0xF029 => {
                self.i_register = self.registers[x] as u16 * 0x05;
            }
            0xF033 => {
                self.memory[self.i_register as usize] = vx / 100;
                self.memory[self.i_register as usize + 1] = (vx / 10) % 10;
                self.memory[self.i_register as usize + 2] = vx % 10;
            }
            0xF055 => {
                for i in 0..=x {
                    self.memory[self.i_register as usize + i] = self.registers[i];
                }
            }
            0xF065 => {
                for i in 0..=x {
                    self.registers[i] = self.memory[self.i_register as usize + i];
                }
            }
            _ => panic!("Unknown opcode: {:#X}", opcode),
        }
    }
}

Как теперь это запустить?

После того как реализация эмулятора готова, можно визуализировать состояние дисплея и организовать обновление состояния эмулятора. Я выбрал Bevy для рендеринга и обработки нажатий кнопок. В итоге получилось что-то такое:

mod chip8;

use bevy::prelude::*;
use chip8::Chip8;
use clap::Parser;
use std::path::PathBuf;

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
    /// Path to the ROM file
    #[arg(short, long)]
    rom: PathBuf,
}

fn main() {
    let args = Args::parse();
    println!("Loading ROM: {:?}", args.rom);

    let mut chip8 = Chip8::default();

    chip8.load_from_file(&args.rom);

    App::new()
        .add_plugins(DefaultPlugins)
        .insert_resource(chip8)
        .add_systems(Startup, setup)
        .add_systems(Startup, setup_display)
        .add_systems(Update, (update_keys, draw_display))
        .add_systems(FixedUpdate, (run_chip8, update_chip8_timers))
        .add_systems(FixedUpdate, draw_registers)
        .run();
}

#[derive(Component)]
struct PcText;

fn setup(mut commands: Commands) {
    commands.spawn(Camera2d::default());
    commands.spawn((
        Text::new("PC: 0x0000\nOP: 0x0000"),
        TextFont {
            font_size: 16.0,
            ..default()
        },
        PcText,
    ));
}

const DISPLAY_WIDTH: usize = 64;
const DISPLAY_HEIGHT: usize = 32;

const PIXEL_SIZE: f32 = 10.0;

const COLOR_ON: Color = Color::srgb(0.0, 1.0, 0.0);
const COLOR_OFF: Color = Color::srgb(0.0, 0.0, 0.0);


#[derive(Component)]
struct Chip8Pixel {
    x: usize,
    y: usize,
}

// Создаем "пиксели" для отображения состояния дисплея CHIP-8
fn setup_display(mut commands: Commands) {
    for y in 0..DISPLAY_HEIGHT {
        for x in 0..DISPLAY_WIDTH {
            commands.spawn((
                Sprite::from_color(COLOR_OFF, Vec2::new(PIXEL_SIZE, PIXEL_SIZE)),
                Transform::from_xyz(
                    x as f32 * PIXEL_SIZE - DISPLAY_WIDTH as f32 * PIXEL_SIZE / 2.0,
                    DISPLAY_HEIGHT as f32 * PIXEL_SIZE / 2.0 - y as f32 * PIXEL_SIZE,
                    0.0,
                ),
                Chip8Pixel { x, y },
            ));
        }
    }
}

/// Обновляем состояние CHIP-8 с частотой 500Hz
fn run_chip8(mut chip8: ResMut<Chip8>, time: Res<Time>, mut accumulator: Local<f32>) {
    *accumulator += time.delta_secs();
    let cycle_time = 1.0 / 500.0;
    while *accumulator >= cycle_time {
        chip8.execute_opcode();
        *accumulator -= cycle_time;
    }
}

/// Обновляем таймеры CHIP-8 с частотой 60Hz
fn update_chip8_timers(mut chip8: ResMut<Chip8>, time: Res<Time>, mut accumulator: Local<f32>) {
    *accumulator += time.delta_secs();
    let timer_interval = 1.0 / 60.0;
    while *accumulator >= timer_interval {
        chip8.update_timers();
        *accumulator -= timer_interval;
    }
}

// Обрабатываем нажатия клавиш
fn update_keys(keyboard_input: Res<ButtonInput<KeyCode>>, mut chip8: ResMut<Chip8>) {
    // Перезапускаем эмулятор при нажатии Escape
    if keyboard_input.pressed(KeyCode::Escape) {
        chip8.restart();
    }

    let key_map = [
        KeyCode::KeyX,
        KeyCode::Digit1,
        KeyCode::Digit2,
        KeyCode::Digit3,
        KeyCode::KeyQ,
        KeyCode::KeyW,
        KeyCode::KeyE,
        KeyCode::KeyA,
        KeyCode::KeyS,
        KeyCode::KeyD,
        KeyCode::KeyZ,
        KeyCode::KeyC,
        KeyCode::Digit4,
        KeyCode::KeyR,
        KeyCode::KeyF,
        KeyCode::KeyV,
    ];

    for (i, &key_code) in key_map.iter().enumerate() {
        chip8.keys[i] = keyboard_input.pressed(key_code);
    }
}

// Обновляем состояние пикселей
fn draw_display(chip8: Res<Chip8>, mut query: Query<(&Chip8Pixel, &mut Sprite)>) {
    for (px, mut sprite) in query.ite   r_mut() {
        sprite.color = if chip8.display[px.y][px.x] > 0 { COLOR_ON } else { COLOR_OFF };
    }
}

// Обновляем регистр PC и текущий opcode
fn draw_registers(chip8: Res<Chip8>, mut text: Single<&mut Text, With<PcText>>) {
    let pc = chip8.pc;
    let op = chip8.get_current_opcode();
    text.0 = format!("PC: {:04X}\nOP: {:04X}", pc, op);
}

Компилируем, запускаем и видим такую красоту. Аж олдскулы свело!

Управлять играми можно через клавиши: 1 2 3 4 Q W E R A S D F Z X C V и Esc для сброса состояния эмулятора.

Единственное что я не делал - звук. В оригинале это просто бипер, который выдает одну ноту пока sound_timer > 0.

Если есть желание, можете доделать, патчи приветствуются. Код доступен на GitHub, там же лежат готовые сборки под Linux и Windows, ROMы можно скачать тут.

Как еще можно улучшить эмулятор:

  • Добавить меню с выбором ROMa
  • Оформить окно в ретро стиле
  • Сделать CRT шейдер
  • Сделать overlay с состоянием регистров эмулятора

UPD. Всё таки добавил звук. Сначала просто с помощью проигрывания готового OGG, потом переделал на генератор волны т.к. таскать с эмулятором единственный дополнительный ресурс - такое себе.

UPD2. Добавил паузу эмулятора на P

Что делать дальше?

После успешной разработки эмулятора CHIP-8 следующим шагом может быть создание эмулятора для более сложной системы:

  1. Super CHIP-8: Это расширение оригинального CHIP-8, добавляет новые инструкции и увеличивает разрешение экрана. Логичный и простой шаг.

  2. COSMAC VIP: Оригинальная платформа, для которой был разработан CHIP-8. Эмуляция COSMAC VIP предоставит опыт работы с реальной аппаратной архитектурой, включая эмуляцию процессора RCA 1802 и периферийных устройств.

  3. Game Boy: Портативка от Nintendo с 8-битной архитектурой. Потребуется понимание работы с более сложными процессорами, такими как Z80, а также работы с аудио- и видеоподсистемами.

  4. NES: Классическая 8-битная консоль, в СНГ продавались клоны под названием Dendy. Насколько знаю, может быть сложной т.к. имеет много недокументированных особенностей.