Недавно мы опубликовали перевод первого материала из серии статей, посвящённой эмуляции компьютера. Автор этих статей подробно рассказывает о написании интерпретатора CHIP-8 на C++. В той публикации мы устроили опрос о целесообразности перевода продолжения цикла. Почти 94% тех, кто принял участие в опросе, продолжение перевода поддержали. Поэтому сегодня мы представляем вашему вниманию второй материал о CHIP-8.



Подготовка к выводу изображений


В прошлый раз мы написали интерпретатор CHIP-8, который способен выполнять все операции за исключением одной — Dxyn (DRW Vx, Vy, nibble). Ради упрощения реализации этой инструкции мы инкапсулируем графическую память и код в классе Image. Кадр размером 64x32 пикселя будет представлен в виде единого фрагмента данных в памяти. Каждому пикселю будет соответствовать один байт:

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 — каким языком программирования вы бы пользовались?