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

от автора

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

Инструкция Описание
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 пикселя в высоту. Итак, что касается процессора и интерфейса, то это 64×32 сетка битов, которые либо включены, либо выключены. Чтобы создать пустой экран, я могу просто создать 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 вы сможете на наших курсах:

Узнайте подробности здесь.


ссылка на оригинал статьи https://habr.com/ru/articles/593525/