В прошлой статье мы пробовали писать небольшие программы для CHIP8 на ассемблере, а в сегодняшней статье мы перейдем непосредственно к самой важной части - написанию ядра нашего эмулятора.
В общем виде эмуляция CHIP8 выглядит следующим образом:
-
Мы объявляем переменные, которые будут представлять регистры процессора, массивы которые представляют память, экран, состояние клавиатуры и т.п.
-
Инициализируем эти переменные начальными значениями, очищаем массивы памяти и экрана, загружаем в память стандартные шрифты CHIP8/SCHIP. После этого загружаем в память игру (копируем ее в массив памяти со смещением 0x200 от начала массива)
-
Считываем из памяти по адресу PC двухбайтовый опкод, конструкцией switch-case выбираем его и выполняем (здесь мы должны добиться полного соответствия с эмулируемой платформой: если опкод CHIP-8 присваивает регистру I какое-то значение, то и мы присваиваем его нашей переменной I; если опкод сдвигает изображение на экране на несколько пикселей вправо, то и мы сдвигаем данные в массиве представляющем экран, и т.п.). И не забываем увеличивать значение в регистре PC на 2 (необходимо для выполнения следующего опкода). Конструкция выглядит примерно так:
opcode = memory[PC];
PC += 2;
switch(opcode)
{
case opcode1: /** выполняем опкод **/; break;
case opcode2: /** выполняем опкод **/; break;
..
case opcodeN: /** выполняем опкод **/; break;
}
- Обновляем значения таймеров 60 раз в секунду, рисуем экран, считываем состояние клавиатуры и снова выполняем пункт 3.
Теперь приступим к написанию кода. Создаем файл emu_chip.h со следующим содержимым:
#ifndef CHIP_EMU_H
#define CHIP_EMU_H
#include <string>
class ChipEmu
{
private:
unsigned short opcode; // двухбайтовый опкод
unsigned char V[16]; // регистры общего назначения и флаг переноса VF
unsigned short I; // адрессный регистр
unsigned short PC; // указатель кода
unsigned char SP; // указаель стека
unsigned short stack[16]; // массив хранящий в себе стек
unsigned char memory[4096]; // память, в нее загружаем игру
unsigned char delay_timer; // таймеры задержки
unsigned char sound_timer; // и звука
// функция рисующая спрайт в массив screen
void drawSprite(unsigned char X, unsigned char Y, unsigned char N);
unsigned char hp48_flags[8]; // флаги, необходимы для опкодов Fx75, Fx85
public:
int mode; // переменная определяющая текущий режим:
// 0 - CHIP-8
// 1 - SCHIP
unsigned char screen[128][64]; // массив представляющий экран
unsigned char key[16]; // массив содержащий состояние клавиатуры:
// 0 - клавиша отпущена
// 1 - клавиша нажата
bool stop; // переменная для опкода 00FD
ChipEmu();
// инициализация переменных, загрузка шрифтов
void init();
// функция загрузки игры в память
bool loadGame(const char *filename);
// функция выполняющая опкод
void executeNextOpcode();
// функция уменьшающая значения таймеров
void decreaseTimers();
};
#endif
Файл emu_chip.cpp я выложил на http://pastebin.com/iiAz3Hfk Рассмотрим в нем ключевые моменты.
В функции init() мы обнуляем регистры, очищаем массивы памяти и экрана, а так же загружаем шрифты: маленький грузим в начало массива памяти, большой со смешением в 80 байт от начала. PC устанавливаем в 0x200, так как игра начинает выполняться с этого адреса.
Переменная mode нужна для того, что бы функция drawSprite() знала в каком режиме ей рисовать спрайты: CHIP-8 или SUPER CHIP. По-умолчанию это режим CHIP-8.
Функция loadGame() загружает игру в массив memory начиная со смещения 0x200, так как по спецификации CHIP-8 код игры должен начинаться с этого адреса.
Функция decreaseTimers() вызывается 60 раз в секунду для уменьшения таймеров. К ней мы еще вернемся в следующей статье, когда будем говорить о скорости эмуляции.
Теперь рассмотрим функцию executeNextOpcode(). Практически весь процесс эмуляции происходит именно в ней.
В строке 188 мы загружаем двухбайтовое значение из памяти с адресом PC в переменную opcode. Затем увеличиваем PC на 2, что бы при следующей итерации цикла загружались следующие два байта.
Я подразумеваю, что вы понимаете как работают операции сдвига и булева алгебра, иначе сложно будет разобраться как мы находим необходимый опкод и “извлекаем” из него определенные биты.
Попробую объяснить это на примере: Возмем опкод 8XY0. В нем X и Y - любая шестнадцатеричная цифра. Наша задача определить, что в переменной opcode содержится именно этот опкод, а затем “извлечь” из него значения X и Y.
Для начала нам нужно узнать старшие 4 бита опкода. Делаем это так: (opcode & 0xF000) >> 12
В результате выполнения opcode & 0xF000
мы получим значение 8000, которое сдвигаем на 12 бит вправо и получаем число 8.
Точно так же определяем остальные цифры:
Младший 0 получаем после операции opcode & 0x000F
, и теперь мы точно можем быть уверены, что это опкод 8XY0
Значение X получаем вот так (opcode & 0x0F00) >> 8
Значение Y получаем вот так (opcode & 0x00F0) >> 4
В функции executeNextOpcode() опкод выбирается через вложенные операторы switch-case. Внешний switch определяет старшие 4 бита опкода и, если их недостаточно для идентификации, вложенный switch определяет остальные необходимые биты.
Дальше смотрим список опкодов во второй части этой статьи и реализуем функционал который эти опкоды должны выполнять. К примеру, для опкода 00E0 (который очищает экран) в цикле забиваем нулями массив screen (строки 209-213). Вообще большая часть опкодов реализуется довольно просто, сложности могут возникнуть с опкодами DXYN, FX33, FX55 и FX65 (угадайте почему 2 последних входят в этот список).
Функция drawSprite() реализует поведение опкода DXYN. Она рисует в массив screen спрайт находящийся по смещению I. Координаты спрайта берутся из регистров VX и VY. Один байт по смещению I представляет одну строку спрайта для CHIP-8, и полстроки для SCHIP (при N=0). Каждый бит в этом байте представляет отдельный пиксель. Количество строк N извлекается из опкода. Если N=0, то в спрайте 16 строк. Также не стоит забывать о том, что если мы рисуем пиксель “поверх” уже существующего пикселя, то эта точка экрана очистится, а регистр VF установится в 1. То есть рисуем методом XOR.
В этой статье мы написали класс реализующий ядро эмулятора, а в следующей займемся интерфейсной частью - выводом графики и обработкой событий клавиатуры, а также рассмотрим как управлять скоростью эмуляции.