
Автор провёл детство за играми в эмуляторах NES и SNES на своём компьютере, но никогда не думал, что однажды сам напишет эмулятор. Иван Сергеев поставил перед автором задачу написать интерпретатор Chip-8, чтобы изучить основные понятия низкоуровневых языков программирования и то, как работает процессор.
Результат — эмулятор Chip-8 на JavaScript, который автор написал под его руководством. Подробности рассказываем, пока у нас начинается курс по Fullstack-разработке на Python.
Хотя есть множество реализаций интерпретатора Chip-8 на всевозможных языках программирования, мой Chip8.js работает с тремя средами: это веб-приложение, приложение CLI и нативное приложение. Исходный код и демо:
Есть и множество руководств о том, как сделать эмулятор Chip-8, таких как Mastering Chip8, How to Write an Emulator и, самое главное — Cowgod’s Chip-8 Technical Reference, основной ресурс для моего собственного эмулятора, а ещё веб-сайт, настолько старый, что его адрес заканчивается
.HTM. Таким образом, это не руководство, но обзор того, как я создала эмулятор, какие основные концепции я изучила, и о кое-какой специфике JavaScript в смысле создания браузерного, нативного и CLI-приложения.
Что такое Chip-8
Я не слышала о Chip-8 до начала этого проекта, поэтому предполагаю, что большинство людей тоже не слышали, если не ладят с эмуляторами. Chip-8 — это очень простой интерпретируемый язык программирования, разработанный в 1970-х годах для любителей компьютеров.
Люди писали простые программы Chip-8, которые имитировали популярные игры того времени: Pong, Tetris, Space Invaders и, вероятно, другие игры, потерянные в анналах истории.
Виртуальная машина, которая играет в них, на самом деле технически является интерпретатором Chip-8, а не эмулятором, поскольку эмулятор — это программное обеспечение, эмулирующее аппаратное обеспечение конкретной машины, а программы Chip-8 не привязаны к какому-либо конкретному оборудованию. Интерпретаторы Chip-8 часто использовались на графических калькуляторах.
Тем не менее этот интерпретатор достаточно близок к эмулятору, поэтому с него обычно начинают те, кто хочет научиться создавать эмуляторы; это значительно проще, чем создавать эмулятор NES или чего-либо ещё. А ещё это хорошая отправная точка для изучения многих концепций процессора в целом, таких как память, стеки и ввод-вывод, с которыми я ежедневно имею дело в бесконечно более сложном мире среды выполнения JavaScript.
Что входит в интерпретатор Chip-8?
Мне пришлось пройти много предварительной подготовки, чтобы хотя бы начать понимать, с чем я работаю; я никогда раньше не изучала основы информатики. Поэтому написала статью Понимание битов, байтов, оснований и запись шестнадцатеричного дампа в JavaScript, где подробно рассказала об этом.Из неё можно сделать два основных вывода:
                        Общие сведения о битах и байтах
                        
                    
- Биты и байты. Бит — это двоичная цифра — 0 или 1, истина или ложь, включено или выключено. Восемь бит — это байт, основная единица информации, с которой работают компьютеры.
- 
Основания чисел. Десятичная система счисления является наиболее привычной для нас, но компьютеры обычно работают с двоичной (основание 2) или шестнадцатеричной (основание 16). 1111в двоичной системе,15в десятичной иfв шестнадцатеричной — это одно и то же число.
- Полубайты. Кроме того, 4 бита — это полубайт, что очень мило. Мне пришлось немного повозиться с ними.
- 
Префиксы. В JS 0x— это префикс шестнадцатеричных чисел,0b— это префикс двоичных чисел.
Я также написала Змейку в CLI, чтобы понять, как здесь работать с пикселями в терминале.
Процессор (CPU) — это основной процессор компьютера, который выполняет инструкции программы. Здесь он состоит из различных битов состояния, описанных ниже, а также цикла инструкций с шагами: извлечением, декодированием и выполнением.
Память
Chip-8 может получить доступ к 4 Кб памяти ОЗУ. Это
0,002% от объёма памяти на дискете. Большая часть данных процессора хранится в памяти.4 Кb — это 4096 байт, и JavaScript поддерживает полезные типизированные массивы, к примеру, Uint8Array с фиксированным размером элементов — здесь это 8 бит.
let memory = new Uint8Array(4096)
Вы можете получить доступ и использовать этот массив как обычный массив, от
memory[0] до memory[4095], устанавливая элементы массива в значения до 255. Значения выше 255 преобразуются в 255.Счётчик команд (PC)
Этот счётчик хранит адрес текущей инструкции в виде 16-битного целого числа. Каждая инструкция в Chip-8 обновляет PC, когда она завершена, чтобы перейти к следующей инструкции, обращаясь к инструкции по адресу, который записан в PC.
Что касается схемы размещения ячеек памяти Chip-8,
0x000 to 0x1FF зарезервировано, так что память начинается с адреса 0x200.let PC = 0x200 // memory[PC] будет обращаться к адресу текущей инструкции
Вы заметите, что массив памяти 8-битный, а PC — 16-битное целое число, поэтому, чтобы получился опкод big endian, объединяются два программных кода.
Регистры
Память обычно используется для долгосрочного хранения и программирования данных, поэтому регистры существуют как своего рода «кратковременная память» для немедленного получения данных и вычислений. Chip-8 имеет 16 8-битных регистров, от
V0 до VF.let registers = new Uint8Array(16)
Индексный регистр
Существует специальный 16-битный регистр, который обращается к определённой точке в памяти, так называемый
I. Регистр I существует в основном для чтения и записи в память, поскольку адресуемая память также 16-битная.let I = 0
Стек
Chip-8 имеет возможность переходить в подпрограммы, а также в стек для отслеживания того, куда возвращаться. Стек имеет размер 16 16-битных значений: до «переполнение стека» программа может перейти в 16 вложенных подпрограмм.
let stack = new Uint16Array(16)
Указатель стека
Указатель стека (SP) — это
8-битное целое число, которое указывает на место в стеке. Он должен быть только 8-битным, хотя стек 16-битный. Поскольку указатель ссылается только на индекс стека, он должен иметь значения только от 0 до 15.let SP = -1
// stack[SP] получит доступ к текущему адресу возврата в стеке.
Таймеры
Chip-8 способен издавать великолепный одиночный звуковой сигнал. Честно говоря, я не потрудилась реализовать реальный вывод «музыки», хотя сам процессор может с ней работать.
Есть два таймера, оба — 8-битные регистры: звуковой таймер (ST) для определения времени звукового сигнала и таймер задержки (DT) для определения времени некоторых событий в игре. Они отсчитывают время с частотой 60 Гц.
let DT = 0
let ST = 0Ввод с клавиатуры
Chip-8 поставлялся вот с такой удивительной шестнадцатеричной клавиатурой:
┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ C │
│ 4 │ 5 │ 6 │ D │
│ 7 │ 8 │ 9 │ E │
│ A │ 0 │ B │ F │
└───┴───┴───┴───┘На практике, похоже, используются лишь несколько клавиш, и вы можете сопоставить их с любой сеткой 4х4, но в разных играх они довольно непоследовательны.
Графический вывод
В Chip-8 используется монохромный дисплей с разрешением
64x32. Каждый пиксель либо включён, либо выключен.Спрайты, которые можно сохранить в памяти, имеют размер
8x15 — восемь пикселей в ширину и пятнадцать — в высоту. Chip-8 также поставляется с набором шрифтов, но он содержит только символы шестнадцатеричной клавиатуры.CPU
Сложите всё это вместе, и вы получите состояние процессора. Вот его класс:
class CPU {
  constructor() {
    this.memory = new Uint8Array(4096)
    this.registers = new Uint8Array(16)
    this.stack = new Uint16Array(16)
    this.ST = 0
    this.DT = 0
    this.I = 0
    this.SP = -1
    this.PC = 0x200
  }
}Декодирование инструкций Chip-8
Chip-8 имеет 36 инструкций. Все инструкции перечислены здесь. Все инструкции имеют длину 2 байта (16 бит). Каждая инструкция кодируется опкодом (кодом операции) и операндом — данными, над которыми производится операция. Примером инструкции может быть такая операция:
x = 1.
y = 2.
ADD x, y,где
ADD — опкод и x, y — операнды. Этот тип языка известен как язык ассемблера. Эта инструкция будет отображаться на:x = x + y
При таком наборе инструкций мне придётся хранить эти данные в 16 битах, так что каждая инструкция будет представлять собой число от
0x0000 до 0xffffff. Каждая позиция разряда в этих наборах состоит из 4 битов.Как же мне перейти от
nnnn к чему-то вроде ADD x, y? Начну с инструкции, аналогичной примеру выше:| Инструкция | Описание | 
|---|---|
| 8xy4 | ADD Vx, Vy | 
Есть одно ключевое слово,
ADD, а ещё два установленных в регистрах аргумента, Vx и Vy. Также есть несколько мнемоник опкодов, похожих на ключевые слова:- 
ADD(сложение).
- 
SUB(вычитание).
- 
JP(переход).
- 
SKP(пропуск).
- 
RET(возврат).
- 
LD(загрузка).
И несколько типов значений операндов, таких как:
- Адрес (I).
- Регистр (Vx,Vy).
- Константа(NилиNNдля полубайта или байта).
Теперь нужно найти способ интерпретации 16-битного опкода как более понятных инструкций.
Битовые маски
Каждая инструкция содержит шаблон, и он всегда будет одним и тем же, и переменные, которые могут меняться. Для
8xy4 паттерном является 8__4. Два полубайта в середине — это переменные. Создав битовую маску для этого шаблона, я могу определить инструкцию.Для маскирования используется побитовое AND (
&) с маской и сопоставляется с шаблоном. Таким образом, если появится команда 8124, захочется гарантировать, что полубайт в позиции 1 и 4 включён (пропущен), а полубайт в позиции 2 и 3 выключен (замаскирован). И вот маска: f00f.const opcode = 0x8124
const mask = 0xf00f
const pattern = 0x8004
const isMatch = (opcode & mask) === pattern // true
  8124
& f00f
  ====
  8004Аналогично
0f00 м 00f0 будет маскировать переменные, а сдвигом вправо (>>) они получат доступ к нужному полубайту.const x = (0x8124 & 0x0f00) >> 8 // 1
// (0x8124 & 0x0f00) is 100000000 in binary
// правый сдвиг на 8 (>> 8) удалит 8 нулей справа
// Останется 1
const y = (0x8124 & 0x00f0) >> 4 // 2
// (0x8124 & 0x00f0) — это 100000 в двоичном коде
// правый сдвиг на 4 (>> 4) удалит четыре нуля справа
// Останется 10, то есть двоичный эквивалент 2Поэтому для каждой из 36 инструкций я создала объект с уникальным идентификатором, маской, шаблоном и аргументами.
const instruction = {
  id: 'ADD_VX_VY',
  name: 'ADD',
  mask: 0xf00f,
  pattern: 0x8004,
  arguments: [
    { mask: 0x0f00, shift: 8, type: 'R' },
    { mask: 0x00f0, shift: 4, type: 'R' },
  ],
}Теперь, когда у меня есть эти объекты, каждый опкод может быть разобран на уникальный идентификатор, и значения аргументов могут быть определены.
Я создала массив
INSTRUCTION_SET, содержащий все эти инструкции, а также написала дизассемблер и тесты, чтобы гарантировать, что все они работают правильно. Вот дизассемблер:function disassemble(opcode) {
  // Ищем инструкцию исходя из байт-кода
  const instruction = INSTRUCTION_SET.find(
    (instruction) => (opcode & instruction.mask) === instruction.pattern
  )
  // Ищем аргументы
  const args = instruction.arguments.map(
    (arg) => (opcode & arg.mask) >> arg.shift
  )
  // Возвращает объект, содержащий инструкции и аргументы
  return { instruction, args }
}Чтение ПЗУ
Поскольку мы рассматриваем этот проект как эмулятор, каждый программный файл Chip-8 можно считать ПЗУ. ПЗУ — это просто двоичные данные, а мы пишем программу для их интерпретации. Мы можем представить процессор Chip8 как виртуальную консоль, а ПЗУ Chip-8 как виртуальный картридж.
Буфер ПЗУ примет необработанный двоичный файл и преобразует его в 16-битные слова big endian (слово — это единица данных, состоящая из определённого количества битов). Вот где пригодится статья о шестнадцатеричном дампе. Я собираю двоичные данные и преобразую их в блоки, которые я могу использовать, в нашем случае 16-битные опкоды. Big endian означает, что старший байт будет первым в буфере, поэтому, когда он встретит два байта
12 34, он создаст 1234 16-битный код. Код с little endian выглядел бы так: 3412.class RomBuffer {
  /**
   * @param {binary} fileContents ROM binary
   */
  constructor(fileContents) {
    this.data = []
    // Читаем сырые данные буфера из файла
    const buffer = fileContents
    // Создаём 16-битные опкоды big endian из буфера
    for (let i = 0; i < buffer.length; i += 2) {
      this.data.push((buffer[i] << 8) | (buffer[i + 1] << 0))
    }
  }
}Возвращаемые из этого буфера данные — это и есть «игра».
У процессора будет метод
load() — как при загрузке картриджа в консоль, — который будет брать данные из этого буфера и помещать их в память. И буфер, и память работают в JavaScript как массивы, поэтому загрузка памяти сводится к циклическому просмотру буфера и помещению байтов в массив памяти.Цикл выполнения инструкций — извлечение, декодирование, выполнение
Теперь у меня есть набор инструкций и игровые данные, готовые к интерпретации. Процессор просто должен что-то с ним сделать. Цикл инструкции состоит из трёх этапов — извлечения, декодирования и выполнения.
- Извлечение (fetch) — получение данных, хранящихся в памяти, при помощи счётчика программы.
- Декодирование — разбор 16-битного опкода для получения декодированной инструкции и значений аргументов.
- Выполнение — выполнение операции на основе декодированной инструкции и обновление счётчика программы.
Вот сжатая и упрощённая версия того, как работает цикл. Эти методы цикла ЦП являются частными и не раскрываются.
Первый шаг,
fetch, обращается к текущему опкоду из памяти.// Берём адрес из памяти
function fetch() {
  return memory[PC]
}decode разберёт опкод на понятный набор команд:
// Декодируем инструкцию
function decode(opcode) {
  return disassemble(opcode)
}Execute состоит из
switch со всеми 36 инструкциями в качестве case, и выполнит для найденной инструкции соответствующую операцию, обновив затем счётчик программы, чтобы следующий цикл извлечения нашёл следующий опкод. Любая обработка ошибок будет проходить здесь же, что приведёт к остановке процессора.// Выполняем инструкцию
function execute(instruction) {
  const { id, args } = instruction
  switch (id) {
    case 'ADD_VX_VY':
      // Выполняем операцию инструкции
      registers[args[0]] += registers[args[1]]
      // Обновляем счётчик
      PC = PC + 2
      break
    case 'SUB_VX_VY':
    // и т д.
  }
}В итоге я получаю процессор со всеми состояниями и циклом команд. Есть два метода, открытые на CPU, —
load — эквивалент загрузки картриджа в консоль с romBuffer в качестве игры, и step, который представляет собой три функции цикла инструкций (извлечение, декодирование, выполнение). step будет работать в бесконечном цикле.class CPU {
  constructor() {
    this.memory = new Uint8Array(4096)
    this.registers = new Uint8Array(16)
    this.stack = new Uint16Array(16)
    this.ST = 0
    this.DT = 0
    this.I = 0
    this.SP = -1
    this.PC = 0x200
  }
  // Загружаем буфер в память
  load(romBuffer) {
    this.reset()
    romBuffer.forEach((opcode, i) => {
      this.memory[i] = opcode
    })
  }
  // Шаг по инструкциям
  step() {
    const opcode = this._fetch()
    const instruction = this._decode(opcode)
    this._execute(instruction)
  }
  _fetch() {
    return this.memory[this.PC]
  }
  _decode(opcode) {
    return disassemble(opcode)
  }
  _execute(instruction) {
    const { id, args } = instruction
    switch (id) {
      case 'ADD_VX_VY':
        this.registers[args[0]] += this.registers[args[1]]
        this.PC = this.PC + 2
        break
    }
  }
}Сейчас не хватает только одного — возможности поиграть.
Создание интерфейса ЦП для ввода-вывода
Итак, у меня есть процессор, который интерпретирует и выполняет инструкции и обновляет все свои состояния, но я ещё ничего не могу с ним сделать.
Именно здесь в дело вступает ввод/вывод — связь между центральным процессором и внешним миром.
- Ввод — это данные, полученные центральным процессором.
- Вывод — это данные, отправленные центральным процессором.
Ввод будет с клавиатуры, вывод — в виде графики.
Я могла просто смешать код ввода-вывода с процессором напрямую, но тогда я была бы привязан к одной среде. Создав общий интерфейс CPU для соединения ввода/вывода и CPU, я могу взаимодействовать с любой системой.
Первое, что нужно было сделать, — это просмотреть инструкции и найти те, что имеют отношение к вводу/выводу. Несколько примеров таких инструкций:
- 
CLS— очистить экран.
- 
LD Vx, K— ожидание нажатия клавиши, сохранение значения клавиши в Vx.
- 
DRW Vx, Vy, nibble— отображение n-байтового спрайта, начинающегося в ячейке памяти I.
Исходя из этого, мы хотим, чтобы интерфейс имел такие методы:
- 
clearDisplay().
- 
waitKey().
- 
drawPixel()(drawSpriteбыло бы 1:1, но в итоге оказалось, что проще делать это попиксельно из интерфейса).
В JavaScript нет понятия абстрактного класса, но я создала класс, который не может быть инстанцирован, с методами, работающими только из классов, которые расширяют этот класс. Вот все методы интерфейса этого класса:
// Абстрактный класс интерфейса CPU
class CpuInterface {
  constructor() {
    if (new.target === CpuInterface) {
      throw new TypeError('Cannot instantiate abstract class')
    }
  }
  clearDisplay() {
    throw new TypeError('Must be implemented on the inherited class.')
  }
  waitKey() {
    throw new TypeError('Must be implemented on the inherited class.')
  }
  getKeys() {
    throw new TypeError('Must be implemented on the inherited class.')
  }
  drawPixel() {
    throw new TypeError('Must be implemented on the inherited class.')
  }
  enableSound() {
    throw new TypeError('Must be implemented on the inherited class.')
  }
  disableSound() {
    throw new TypeError('Must be implemented on the inherited class.')
  }
}Вот как это будет работать: интерфейс будет загружен в CPU при инициализации, и CPU сможет получить доступ к его методам.
class CPU {
  // Инстанцируем интерфейс
  constructor(cpuInterface) {
    this.interface = cpuInterface
  }
  _execute(instruction) {
    const { id, args } = instruction
    switch (id) {
      case 'CLS':
        // Используем интерфейс при выполнении инструкции
        this.interface.clearDisplay()
  }
}Перед установкой интерфейса в реальной среде (веб, терминал или нативная среда) я создала его макет для тестов. На самом деле он не подключён ни к какому вводу/выводу, но он помог мне настроить состояние интерфейса и подготовить его к работе с реальными данными. Я проигнорирую звуковые данные, потому что у них нет выхода на динамики. Остаются клавиатура и экран.
Экран
Экран имеет разрешение 64 пикселя в ширину на 32 пикселя в высоту. Итак, что касается процессора и интерфейса, то это 64x32 сетка битов, которые либо включены, либо выключены. Чтобы создать пустой экран, я могу просто создать 3D-массив нулей, представляя все пиксели выключенными. Буфер кадра — это часть памяти, содержащая растровое изображение, которое будет выведено на дисплей.
// Интерфейс для тестирования
class MockCpuInterface extends CpuInterface {
  constructor() {
    super()
    // Храним данные экрана в буфере кадров
    this.frameBuffer = this.createFrameBuffer()
  }
  // Создаём 3D массив нулей
  createFrameBuffer() {
    let frameBuffer = []
    for (let i = 0; i < 32; i++) {
      frameBuffer.push([])
      for (let j = 0; j < 64; j++) {
        frameBuffer[i].push(0)
      }
    }
    return frameBuffer
  }
  // Обновляем пиксель (0 или 1)
  drawPixel(x, y, value) {
    this.frameBuffer[y][x] ^= value
  }
}В итоге в смысле представления экрана я получаю что-то вроде этого:
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
и т.д.В функции
DRW процессор пройдётся по извлечённому из памяти спрайту и обновит каждый пиксель в нём. Детали опущены для краткости.case 'DRW_VX_VY_N':
  // Интерпретатор считывает n байт из памяти, начиная с адреса в I
  for (let i = 0; i < args[2]; i++) {
    let line = this.memory[this.I + i]
      // Каждый байт представляет собой строку из восьми пикселей
      for (let position = 0; position < 8; position++) {
        // ...Получаем значение, x, и y...
        this.interface.drawPixel(x, y, value)
      }
    }Функция
clearDisplay() — единственный метод, который будет использоваться для взаимодействия с экраном. Это всё, что нужно интерфейсу процессора для такого взаимодействия.Клавиши
Я сопоставила оригинальную клавиатуру со следующей сеткой клавиш:
┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │
│ Q │ W │ E │ R │
│ A │ S │ D │ F │
│ Z │ X │ C │ V │
└───┴───┴───┴───┘И поместила ключи в массив.
// лучше игнорировать
const keyMap = [
  '1', '2', '3', '4',
  'q', 'w', 'e', 'r', 
  'a', 's', 'd', 'f', 
  'z', 'x', 'c', 'v'
]Для хранения текущих нажатых клавиш опишите состояние:
this.keys = 0
В интерфейсе
keys — это двоичное число из 16 цифр, индекс представляет клавишу. Chip-8 просто хочет знать, какие клавиши нажаты, и на основе этого принимает решение:0b1000000000000000 // V нажата (keyMap[15], или индекс 15)
0b0000000000000011 // 1 и 2 нажаты (index 0, 1)
0b0000000000110000 // Q и W нажаты (index 4, 5)Теперь, если, например, нажата
V (keyMap[15]) и операнд — 0xf (десятичное 15), то клавиша нажата. Левый сдвиг (<<) и 1 создаст двоичное число с 1, за которым следует столько нулей, сколько находится в левом сдвиге.case 'SKP_VX':
  // Пропустить следующую инструкцию, если нажата клавиша со значением VX
  if (this.interface.getKeys() & (1 << this.registers[args[0]])) {
   // Пропускаем инструкцию
  } else {
    // Идём к следующей инструкции
  }Есть ещё один метод клавиш,
waitKey, где инструкция заключается в ожидании нажатия клавиши и возврате этой нажатой клавиши.Приложение CLI — взаимодействие с терминалом
Первый интерфейс, который я сделала, был для терминала. Это было мне не так знакомо, как работа с DOM: я никогда не создавала графических приложений в терминале, но это не слишком сложно.

Curses — это библиотека, используемая для создания текстовых пользовательских интерфейсов в терминале. Blessed — это библиотека, оборачивающая curses для Node.js.
Экран
Буфер кадра, содержащий битовую карту данных экрана, одинаков для всех реализаций, но способы взаимодействия экрана с каждой средой будут различаться.
С помощью
blessed я определила объект экрана:this.screen = blessed.screen({ smartCSR: true })
И использовала
fillRegion или clearRegion на пикселе с полным блоком юникода, чтобы заполнить его c frameBuffer в качестве источника данных.drawPixel(x, y, value) {
  this.frameBuffer[y][x] ^= value
  if (this.frameBuffer[y][x]) {
    this.screen.fillRegion(this.color, '█', x, x + 1, y, y + 1)
  } else {
    this.screen.clearRegion(x, x + 1, y, y + 1)
  }
  this.screen.render()
}Клавиши
Обработчик клавиш не слишком отличался от того, что в DOM. Если клавиша нажата, обработчик передаёт ключ, затем я могу использовать его для поиска индекса и обновления объекта keys с любыми дополнительными клавишами, которые были нажаты.
this.screen.on('keypress', (_, key) => {
  const keyIndex = keyMap.indexOf(key.full)
  if (keyIndex) {
    this._setKeys(keyIndex)
  }
})Особенно странной вещью было то, что у
blessed не было никакого keyup, которое я могла бы использовать, поэтому мне пришлось просто имитировать его, задав интервал периодической очистки клавиш.setInterval(() => {
  // Эмулируем keyup, чтобы очистить все нажатые клавиши
  this._resetKeys()
}, 100)Точка входа
Всё готово — буфер rom для преобразования двоичных данных в опкоды, интерфейс для подключения ввода/вывода, процессор, содержащий состояние, цикл команд и два открытых метода — один для загрузки игры, другой для выполнения цикла. Поэтому я создаю функцию
cycle, которая будет запускать инструкции процессора в бесконечном цикле.const fs = require('fs')
const { CPU } = require('../classes/CPU')
const { RomBuffer } = require('../classes/RomBuffer')
const {
  TerminalCpuInterface,
} = require('../classes/interfaces/TerminalCpuInterface')
// Извлекаем файл ПЗУ
const fileContents = fs.readFileSync(process.argv.slice(2)[0])
// Инициализируем интерфейс терминала
const cpuInterface = new TerminalCpuInterface()
// Инициализируем CPU с интерфейсом
const cpu = new CPU(cpuInterface)
// Преобразуем двоичные данные в опкоды
const romBuffer = new RomBuffer(fileContents)
// Загружаем игру
cpu.load(romBuffer)
function cycle() {
  cpu.step()
  setTimeout(cycle, 3)
}
cycle()В функции цикла также есть таймер задержки, но я удалила его из примера для наглядности.
Теперь, чтобы играть, я могу запустить файл точки входа терминала и передать ROM как аргумент.
npm run play:terminal roms/PONG
Веб-приложение — взаимодействие с браузером
Следующий интерфейс, который я создала, предназначен для веба. Я сделала эту версию эмулятора немного более причудливой, поскольку браузер — привычная для меня среда, и я не могу устоять перед желанием сделать сайты в стиле ретро. Он также позволяет переключаться между играми.

Экран
Для экрана я использовала Canvas API и его CanvasRenderingContext2D для поверхности рисования.
fillRect и canvas в основном то же, что fillRegion в blessed.this.screen = document.querySelector('canvas')
this.context = this.screen.getContext('2d')
this.context.fillStyle = 'black'
this.context.fillRect(0, 0, this.screen.width, this.screen.height)Одно небольшое отличие: я умножила все пиксели на 10, чтобы экран стал заметнее.
this.multiplier = 10
this.screen.width = DISPLAY_WIDTH * this.multiplier
this.screen.height = DISPLAY_HEIGHT * this.multiplierЭто сделало команду
drawPixel более многословной, но в остальном концепция осталась прежней.drawPixel(x, y, value) {
  this.frameBuffer[y][x] ^= value
  if (this.frameBuffer[y][x]) {
    this.context.fillStyle = COLOR
    this.context.fillRect(
      x * this.multiplier,
      y * this.multiplier,
      this.multiplier,
      this.multiplier
    )
  } else {
    this.context.fillStyle = 'black'
    this.context.fillRect(
      x * this.multiplier,
      y * this.multiplier,
      this.multiplier,
      this.multiplier
    )
  }
}Клавиши
У меня был доступ к гораздо большему количеству обработчиков событий клавиш в DOM, поэтому я смогла легко обрабатывать события
keyup и keydown.// Устанавливаем клавиши ненажатыми
document.addEventListener('keydown', event => {
  const keyIndex = keyMap.indexOf(event.key)
  if (keyIndex) {
    this._setKeys(keyIndex)
  }
})
// Сбрасываем клавиши по нажатию
document.addEventListener('keyup', event => {
  this._resetKeys()
})
}Точка входа
Для работы с модулями я импортировала все модули и установила их в глобальный объект, а затем использовала Browserify для работы в браузере. Установка их в глобальные делает их доступными в окне, чтобы я могла использовать вывод кода в сценарии браузера. Сегодня для этого можно использовать Webpack или что-то другое, но это было быстро и просто.
const { CPU } = require('../classes/CPU')
const { RomBuffer } = require('../classes/RomBuffer')
const { WebCpuInterface } = require('../classes/interfaces/WebCpuInterface')
const cpuInterface = new WebCpuInterface()
const cpu = new CPU(cpuInterface)
// Устанавливаем буфер CPU и ROM в глобальный объект, который станет окном в браузере.
global.cpu = cpu
global.RomBuffer = RomBufferТочка входа в веб использует ту же функцию
cycle, что и реализация терминала, но имеет функцию для получения каждого ПЗУ и сброса данных дисплея каждый раз, когда выбирается новое ПЗУ. Я привыкла работать с json данными и fetch, но в этом случае извлекла необработанный arrayBuffer из ответа.// Извлекаем ПЗУ и загружаем игру
async function loadRom() {
  const rom = event.target.value
  const response = await fetch(`./roms/${rom}`)
  const arrayBuffer = await response.arrayBuffer()
  const uint8View = new Uint8Array(arrayBuffer)
  const romBuffer = new RomBuffer(uint8View)
  cpu.interface.clearDisplay()
  cpu.load(romBuffer)
}
// Добавляем возможность выбирать игру
document.querySelector('select').addEventListener('change', loadRom)HTML содержит
canvas и select.<canvas></canvas>
<select>
  <option disabled selected>Load ROM...</option>
  <option value="CONNECT4">Connect4</option>
  <option value="PONG">Pong</option>
</select>Затем я просто развернула код на страницах GitHub, потому что он статический.
Нативное приложение — взаимодействие с нативной платформой
Я также сделала экспериментальную реализацию нативного UI. Я использовала Raylib для программирования простых игр, которая имела биндинг для Node.js.

Я считаю эту версию экспериментальной только потому, что она очень медленная по сравнению с другими, поэтому она менее удобна в использовании, но с клавишами и экраном всё работает правильно.
Точка входа
Raylib работает немного иначе, чем другие реализации, поскольку сама работает в цикле, а это значит, что я не буду использовать функцию
cycle.const r = require('raylib')
// Пока окно не закроется...
while (!r.WindowShouldClose()) {
  // Извлекаем, декодируем, выполняем
  cpu.step()
  r.BeginDrawing()
  // Отрисовываем экран с изменениями
  r.EndDrawing()
}
r.CloseWindow()Экран
В рамках методов
beginDrawing() и endDrawing() экран будет обновляться. Для реализации Raylib, вместо того чтобы держать всё в интерфейсе, я обращалась к интерфейсу прямо из скрипта. Это работает.r.BeginDrawing()
cpu.interface.frameBuffer.forEach((y, i) => {
  y.forEach((x, j) => {
    if (x) {
      r.DrawRectangleRec({ x, y, width, height }, r.GREEN)
    } else {
      r.DrawRectangleRec({ x, y, width, height }, r.BLACK)
    }
  })
})
r.EndDrawing()Клавиши
Заставить ключи работать на Raylib — это последнее, над чем я работала. Мне приходилось делать всё в методе
IsKeyDown — существовал метод GetKeyPressed, но он имел побочные эффекты и вызывал проблемы. Поэтому, вместо того чтобы просто ждать нажатия клавиши, как в других реализациях, я должна была перебирать все клавиши и проверять, нажаты ли они, и, если так, добавлять их в битовую маску клавиши.let keyDownIndices = 0
// Выполняем для всех клавиш
for (let i = 0; i < nativeKeyMap.length; i++) {
  const currentKey = nativeKeyMap[i]
  // Если клавиша нажата, добавляем индекс в отображение нажатых клавиш
  // Также отожмёт все клавиши, которые не были нажаты
  if (r.IsKeyDown(currentKey)) {
    keyDownIndices |= 1 << i
  }
}
// Устанавливаем нажатые клавиши
cpu.interface.setKeys(keyDownIndices)Вот и всё. Эта задача сложнее остальных, но я рада, что сделала это, чтобы завершить интерфейс и посмотреть, насколько хорошо этот интерфейс работает на разных платформах.
Заключение
И вот мой проект «Chip-8». Вы можете посмотреть исходники на GitHub. Я узнала много нового о концепциях низкоуровневого программирования и о том, как работает процессор, а также о возможностях JavaScript за пределами браузерного приложения или сервера REST API. Мне ещё предстоит сделать несколько вещей, например попытаться написать простую игру, но эмулятор завершён, и я горжусь этим.
Продолжить изучение JS вы сможете на наших курсах:

Узнайте подробности здесь.
                        Другие профессии и курсы
                        
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также
                    - Профессия Data Scientist
- Профессия Data Analyst
- Курс «Математика для Data Science»
- Курс «Математика и Machine Learning для Data Science»
- Курс по Data Engineering
- Курс «Machine Learning и Deep Learning»
- Курс по Machine Learning
Python, веб-разработка
- Профессия Fullstack-разработчик на Python
- Курс «Python для веб-разработки»
- Профессия Frontend-разработчик
- Профессия Веб-разработчик
Мобильная разработка
Java и C#
- Профессия Java-разработчик
- Профессия QA-инженер на JAVA
- Профессия C#-разработчик
- Профессия Разработчик игр на Unity
От основ — в глубину
А также
 
          