[Перевод] Эмуляция компьютера: интерпретатор CHIP-8 и формирование изображений
Подготовка к выводу изображений
В прошлый раз мы написали интерпретатор CHIP-8, который способен выполнять все операции за исключением одной — Dxyn
(DRW Vx, Vy, nibble
). Ради упрощения реализации этой инструкции мы инкапсулируем графическую память и код в классе Image
. Кадр размером 64×32 пикселя будет представлен в виде единого фрагмента данных в памяти. Каждому пикселю будет соответствовать один байт:
0x000:|--------------------------------------------------------------|
0x040:| |
0x080:| |
0x0C0:| |
...
0x7C0:|--------------------------------------------------------------|
Для описания этой памяти нам понадобится три значения: количество строк, количество столбцов и начальный адрес (нам его даёт malloc). Если у нас есть этот адрес, указывающий на элемент графической памяти, находящийся в верхнем левом углу вышеприведённой схемы, обращение к отдельным пикселям будет выполняться очень просто. Вот несколько примеров:
img[col=0, row=0] = img[0]
img[col=0, row=1] = img[width]
img[col=1, row=3] = img[3*width+1]
Теперь, когда у нас есть эти сведения, мы готовы к тому, чтобы создать соответствующий заголовочный файл:
// image.h
class Image {
public:
// Выделение и освобождение памяти в ctor и dtor.
Image(int cols, int rows);
~Image();
uint8_t* Row(int r);
// Возвращает пиксель, который может быть изменён.
uint8_t& At(int c, int r);
void SetAll(uint8_t value);
private:
int cols_;
int rows_;
uint8_t* data_;
};
Тут надо обратить внимание на то, что мы динамически выделяем память, владельцем которой будет этот класс. В более крупной системе мы могли бы решить воспользоваться std::unique_ptr
вместе с особой функцией для выделения памяти. Но тут мы просто используем malloc
в конструкторе и free
в деструкторе класса.
// image.cpp
Image::Image(int cols, int rows) {
data_ = static_cast<uint8_t*>(malloc(cols * rows * sizeof(uint8_t)));
cols_ = cols;
rows_ = rows;
}
Image::~Image() {
free(data_);
}
uint8_t* Image::Row(int r) {
return &data_[r * cols_];
}
uint8_t& Image::At(int c, int r) {
return Row(r)[c];
}
void Image::SetAll(uint8_t value) {
std::memset(data_, value, rows_ * cols_);
}
void Image::DrawToStdout() {
for (int r = 0; r < rows_; r++) {
for (int c = 0; c < cols_; c++) {
if (At(c,r) > 0) {
std::cout << "X";
} else {
std::cout << " ";
}
}
std::cout << std::endl;
}
std::cout << std::endl;
}
Здесь мне удобнее пользоваться такими именами переменных, как rows_
(строки) и cols_
(столбцы), а не x
и y
. Дело в том, что имя переменной «rows» чётко ассоциируется у меня со «строками», а вот о том, что такое «x», я вполне могу забыть. Функция At
возвращает uint8_t&
, что даёт нам возможность и получать значения отдельных пикселей, и устанавливать эти значения. Это плохо с точки зрения инкапсуляции, но такой приём часто используется в графических API. Мы, кроме того, предусмотрели тут удобную функцию DrawToStdout
, которая позволяет выводить в консоль то, что должно быть отображено на экране, делая это даже тогда, когда подсистема графического вывода эмулятора ещё не реализована. Сейчас мы можем добавить в класс CpuChip8
поле frame_
типа Image
и поработать над реализацией соответствующих механизмов.
// cpu_chip8.h
class CpuChip8 {
public:
constexpr innt kFrameWidth = 64;
constexpr innt kFrameHeight = 32;
CpuChip8() : frame_(kFrameWidth, kFrameHeight) {}
...
private:
...
Image frame_;
};
Теперь давайте поговорим о том, как CHIP-8 выполняет вывод графических данных. А именно, «рисование» спрайта в текущем (и единственном) кадровом буфере выполняется по принципам, используемым в операции XOR
. Все спрайты описываются в виде изображений с глубиной цвета в 1 бит (каждый пиксель может быть либо «включен», либо «выключен»). Ширина спрайта равняется 8 битам, высота может меняться. Ограничение на ширину спрайта применяется из-за того, что каждый пиксель спрайта представлен единственным битом. Посмотрим на описание набора шрифтов, присутствующее в предыдущем материале, и попробуем «расшифровать» одну из цифр.
0xF0, 0x90, 0x90, 0x90, 0xF0, // 0
0xF0 это 1111 0000 -> XXXX
0x90 это 1001 0000 -> X X
0x90 это 1001 0000 -> X X
0x90 это 1001 0000 -> X X
0xF0 это 1111 0000 -> XXXX
Замечательно! Помните, я говорил о том, что вывод графики основан на операции XOR? Так вот, это значит, что единственный способ убрать спрайт с экрана заключается в том, чтобы вывести ещё один спрайт поверх него (фактически — тот же самый спрайт), так как 1 ⊕ 1 даёт 0. Именно поэтому при работе с CHIP-8-программами часто заметно мерцание, так как спрайты постоянно выводятся на экран и стираются с него для вывода движущихся объектов.
Итак, мы готовы к тому, чтобы создать функцию для вывода спрайтов. Нам понадобится начальная точка и сам спрайт (область памяти). Так как спрайты могут иметь переменную высоту, мы получаем и соответствующий параметр, описывающий её. Отмечу, что одна особенность интерпретатора CHIP-8 потребовала некоторого времени на её отладку. Она заключается в том, что интерпретатор поддерживает вывод графики за пределами экрана. Когда спрайт выходит за границы экрана, его рисование продолжается на другой стороне экрана. Это поведение проявляется и при указании стартовых координат спрайта (то есть — вывод 15 строк в координате 255,255 — это совершенно нормально). Кроме того, интерпретатору нужно сообщать о том, был ли при выводе спрайта стёрт какой-нибудь пиксель (это часто используется для обнаружения столкновений объектов, выводимых на экран).
// image.cpp
// Возвращает true в том случае, если новое значение стирает пиксель.
bool Image::XOR(int c, int r, uint8_t val) {
uint8_t& current_val = At(c, r);
uint8_t prev_val = current_val;
current_val ^= val;
return current_val == 0 && prev_val > 0;
}
bool Image::XORSprite(int c, int r, int height, uint8_t* sprite) {
// Переход на другую сторону экрана при выводе спрайта.
bool pixel_was_disabled = false;
for (int y = 0; y < height; y++) {
int current_r = r + y;
while (current_r >= rows_) { current_r -= rows_; }
uint8_t sprite_byte = sprite[y];
for (int x = 0; x < 8; x++) {
int current_c = c + x;
while (current_c >= cols_) { current_c -= cols_; }
// Обратите внимание: Сканирование выполняется от MSbit до LSbit
uint8_t sprite_val = (sprite_byte & (0x80 >> x)) >> (7-x);
pixel_was_disabled |= XOR(current_c, current_r, sprite_val);
}
}
return pixel_was_disabled;
}
Нам нужно позаботиться о том, чтобы извлекать биты, представляя их значениями 1
или 0
. Так как класс Image
поддерживает [0..255]
, операции XOR, без этого ограничения, могут наделать много беспорядка. Когда же применяется это ограничение, соответствующая инструкция нашего CPU получается очень простой — нужно всего лишь извлечь параметры, необходимые для вызова XORSprite
.
CpuChip8::Instruction CpuChip8::GenDRAW(uint8_t reg_x, uint8_t reg_y, uint8_t n_rows) {
return [this, reg_x, reg_y, n_rows]() {
uint8_t x_coord = v_registers_[reg_x];
uint8_t y_coord = v_registers_[reg_y];
bool pixels_unset = frame_.XORSprite(x_coord, y_coord, n_rows,
memory_ + index_register_);
v_registers_[0xF] = pixels_unset;
NEXT;
};
}
Итоги
Если вы дошли до этого момента — у вас уже должна появиться возможность запускать некоторые ROMы! Поставьте вызов DrawToStdout
после цикла выполнения кода и понаблюдайте за тем, что попадает в консоль. Правда, пока на нашем интерпретаторе можно запускать только программы, не ожидающие пользовательского ввода.
В следующем материале из этой серии мы подключим к проекту библиотеку SDL, что позволит выводить графику на экран.
Если бы вы писали собственный интерпретатор CHIP-8 — каким языком программирования вы бы пользовались?