Как написать эмулятор CHIP-8 на JS

Kate

Administrator
Команда форума

Как написать эмулятор CHIP-8 на JS​

Блог компании SkillFactory Разработка веб-сайтов *JavaScript *Программирование *Читальный зал
Перевод
Tutorial
Автор оригинала: Tania Rascia
Chip-8

Автор провёл детство за играми в эмуляторах 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? Начну с инструкции, аналогичной примеру выше:

ИнструкцияОписание
8xy4ADD 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 << 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 = 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.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: я никогда не создавала графических приложений в терминале, но это не слишком сложно.

qdek-w_vvorsivilu3-q-tzjaac.png


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


Веб-приложение — взаимодействие с браузером​

Следующий интерфейс, который я создала, предназначен для веба. Я сделала эту версию эмулятора немного более причудливой, поскольку браузер — привычная для меня среда, и я не могу устоять перед желанием сделать сайты в стиле ретро. Он также позволяет переключаться между играми.

ti29e3mwroydtijrndemrxjur2q.png


Экран​

Для экрана я использовала 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.

byvewcvapx4od6m1dcbbilfjhbi.png


Я считаю эту версию экспериментальной только потому, что она очень медленная по сравнению с другими, поэтому она менее удобна в использовании, но с клавишами и экраном всё работает правильно.

Точка входа​

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
// Если клавиша нажата, добавляем индекс в отображение нажатых клавиш
// Также отожмёт все клавиши, которые не были нажаты
if (r.IsKeyDown(currentKey)) {
keyDownIndices |= 1 << i
}
}

// Устанавливаем нажатые клавиши
cpu.interface.setKeys(keyDownIndices)

Вот и всё. Эта задача сложнее остальных, но я рада, что сделала это, чтобы завершить интерфейс и посмотреть, насколько хорошо этот интерфейс работает на разных платформах.

Заключение​

И вот мой проект «Chip-8». Вы можете посмотреть исходники на GitHub. Я узнала много нового о концепциях низкоуровневого программирования и о том, как работает процессор, а также о возможностях JavaScript за пределами браузерного приложения или сервера REST API. Мне ещё предстоит сделать несколько вещей, например попытаться написать простую игру, но эмулятор завершён, и я горжусь этим.

 
Сверху