[Перевод] Эмуляция компьютера: интерпретатор CHIP-8 и формирование изображений

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

Let’s block ads! (Why?)

Read More

Recent Posts

Apple возобновила переговоры с OpenAI и Google для интеграции ИИ в iPhone

Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…

2 дня ago

Российская «дочка» Google подготовила 23 иска к крупнейшим игрокам рекламного рынка

Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…

3 дня ago

Google завершил обновление основного алгоритма March 2024 Core Update

Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…

3 дня ago

Нейросети будут писать тексты объявления за продавцов на Авито

У частных продавцов на Авито появилась возможность составлять текст объявлений с помощью нейросети. Новый функционал доступен в категории «Обувь, одежда,…

3 дня ago

Объявлены победители международной премии Workspace Digital Awards-2024

24 апреля 2024 года в Москве состоялась церемония вручения наград международного конкурса Workspace Digital Awards. В этом году участниками стали…

3 дня ago

Яндекс проведет гик-фестиваль Young Con

27 июня Яндекс проведет гик-фестиваль Young Con для студентов и молодых специалистов, которые интересуются технологиями и хотят работать в IT.…

4 дня ago