Как написать эмулятор игровой консоли NES

Как написать эмулятор игровой консоли NES

Вы наверняка слышали про эмуляторы игровых приставок и, возможно, даже играли в них не один час. Но задавались ли вы вопросом, как работает эмулятор игровой консоли? На примере игровой приставки NES, известной в наших краях как Dendy, я покажу, как написать собственный эмулятор. Мы разберемся с хитрой архитектурой данной консоли, выдававшей великолепную графику для своего времени и своей небольшой цены.

Все игровые приставки различными средствами адаптированы для запуска игр, что их и отличает от обычных ПК. Особенно это касается древних консолей вроде NES: в восьмидесятые годы с аппаратными ресурсами было дела плохи, особенно если нужно было сделать недорогой домашний агрегат. Экономить надо было буквально на всем, чем и объясняются некоторые инженерные решения.

Виды эмуляции

  • Интерпретация. Интерпретатор дает возможность программно эмулировать все части консоли. Данный способ самый простой и вместе с тем наименее производительный.
  • Динамическая рекомпиляция. Рекомпилятор довольно сложен в написании, но при этом значительно превосходит интерпретатор в производительности. Данный эмулятор может рекомпилировать инструкции эмулируемой машины в машинные инструкции вашего ПК. Проще говоря, рекомпилятор выступает в роли переводчика с одного машинного языка на другой.

Существуют эмуляторы для всех старых и даже некоторых новых приставок. Вот несколько примеров: Dolphin — эмулятор Wii и GameCube, ePSXe — PS1, PCSX2 — PS2, PPSSPP — PSP.

Среди пока что незаконченных, но быстро развивающихся эмуляторов: Cemu — эмулятор Wii U, RPCS3 — PS3, Yuzu — Switch, Xenia — эмулятор Xbox 360.

MOS 6502: регистры, режимы адресации и инструкции

NES неспроста снискала мировую популярность. Разработчики оборудования пытались создать наиболее производительные решения, применяя разного рода уловки. Разработчики игр досконально изучали особенности платформы и находили новые трюки, чтобы создать красивую картинку. Порой даже очень красивую картинку для своего времени.

Как и в случае с компьютером, основная логика программ выполняется на центральном процессоре приставки. Поэтому лучше всего начинать написание эмулятора именно с него. В NES установлен восьмибитный процессор MOS 6502 с комплексным набором инструкций (то есть как у Intel, а не как у ARM или PowerPC).

У процессора MOS 6502 шесть регистров, один из которых недоступен пользователю:

  • A — регистр, куда складываются результаты всех арифметических операций;
  • X, Y — индексные регистры;
  • SP — указатель на вершину стека;
  • P — регистр флагов, в x86 EFLAGS выполняет ту же функцию;
  • PC — счетчик команд, регистр, который указывает, какую команду выполнять следующей. Этот регистр недоступен напрямую.

Режимов адресации великое множество, и узнать о них будет полезно и за рамками этой статьи.

Название Определение Пример
Аккумуляторный Операндом инструкции является аккумулятор Арифметический сдвиг влево ASL
Предполагаемый Операнд явно указывается инструкцией Перенос значения A в X TAX
Немедленный Операнд дается в инструкции Загрузка значения в A LDA #$34
По абсолютному адресу Операндом является значение по абсолютному адресу Загрузка значения в X LDX $9010
По адресу в нулевой странице По абсолютному адресу первых 256 байт Загрузка значения в Y LDY $23
Относительный Адрес задается относительно PC Ветвление, если предыдущий операнд равен 0 BEQ $4A

Нулевая страница — первые 256 байт памяти, в которых размещаются наиболее часто используемые переменные ради более быстрого доступа к ним.

Всего у нашего процессора 256 инструкций, из которых 78 — это настоящие инструкции без учета разных режимов адресации. Сразу сократим работу: мы не будем писать обработчик для всех 256 инструкций. Напишем обработчики для всех режимов адресации и для 78 инструкций.

Начнем с кода для режима адресации нулевой страницы.

В данном случае инструкция состоит из двух байт: один — это сама инструкция, второй — адрес операнда инструкции. Функция ram_getb возвращает значения байта в RAM по адресу.

РЕКОМЕНДУЕМ:
Как самому создать игру

А вот пример кода для инструкции STX.

Функция ram_setb заменяет значение байта в RAM по адресу cpu_addr (операнд инструкции) на значение регистра X.

Таблица инструкций
Таблица инструкций

Также не стоит забывать, что MOS 6502 — мультицикличный процессор и разные инструкции могут выполняться разное время. Поэтому, чтобы выверить точное время выполнения, нужно знать, за сколько циклов выполняется инструкция.

Полный список инструкций и режимов адресации можно найти в «Викиучебнике» и описании инструкций.

Мапперы

Так как в NES могут адресоваться только 64 Кбайт данных, чтобы переключаться между разными банками, в картридже существуют так называемые мапперы.

Адрес Назначение
$0000 — $07FF 2 Кбайт внутренней RAM
$0800 — $1FFF Ссылки на $0000 — $07FF
$2000 — $2007 Регистры PPU
$2008 — $3FFF Ссылки на $2000 — $2007
$4000 — $401F I/O и APU-регистры
$6000 — $7FFF Банк PRG RAM
$8000 — $BFFF Нижний банк PRG ROM
$C000 — $FFFF Верхний банк PRG ROM

Маппер отвечает за переключение PRG (Program) ROM и CHR (Character) ROM. В PRG ROM лежит основной код игр, он подключен напрямую к CPU. В CHR ROM лежат графические объекты, и он уже подключен к PPU.

Одновременно в процессор могут быть подключены только два банка PRG ROM по 16 Кбайт. Нижний банк расположен по адресам $8000 $BFFF, верхний находится по адресам $C000 $FFFF.

В разных картриджах могут быть разные мапперы. Всего их более двухсот, но не обязательно эмулировать их все. Достаточно сделать реализацию нескольких мапперов для запуска самых популярных игр.

Можно реализовать маппер как три функции:

  • функция инициализации;
  • врапперы над функциями получения и установки байтов RAM, такие как ram_getb и ram_setb.

Вот как выглядит маппер UxROM.

В данном случае маппер изначально выставляет верхний банк PRG ROM последним из возможных, и его адрес остается фиксированным все время. Нижняя же часть может меняться. Для этого пользователю нужно записать номер PRG ROM в память картриджа. Если номер картриджа совпадает со значением, которое лежит по адресу, куда пользователь записывает, то маппер меняет нижний банк.

Picture Processing Unit

Ключевую роль в отрисовке играет PPU — Picture Processing Unit. Именно благодаря ему у NES для своего времени была хорошая графика. 256 на 240 пикселей и палитра из 64 цветов прекрасно смотрелись на телевизорах того времени.

Если посчитать, то выйдет, что для хранения одного изображения на экране понадобится 45 Кбайт. Сейчас это значение выглядит смешно, но еще тридцать лет назад эта цифра была непосильна для консолей. Поэтому PPU достигает компромисса между эффективностью, памятью и качеством картинки.

Скриншот из Castlevania
Скриншот из Castlevania

Отрисовка фона

Весь экран можно разделить на 32 × 30 тайлов, каждый из которых — квадрат 8 × 8 пикселей. Четыре тайла составляют блок. Можно добавить разметку, чтобы лучше видеть структуру картинки.

Тайлы и блоки
Тайлы и блоки

Сетка темно-зеленая в случае с блоками и светло-зеленая для тайлов.

Все символы рисуются как пиксель-арт, в котором может быть только четыре цвета (2 бита на пиксель, 16 байт на тайл). Таким образом, все изображение занимает 15 Кбайт, тогда как PPU доступно только около 12 Кбайт.

написать эмулятор NES
написать эмулятор NES
Пример пиксель-арта
Адрес Назначение
$0000 — $0FFF Таблица символов 0(CHR ROM)
$1000 — $1FFF Таблица символов 1(CHR ROM)
$2000 — $23FF Таблица имен 0
$2400 — $27FF Таблица имен 1
$2800 — $2BFF Таблица имен 2
$2C00 — $2FFF Таблица имен 3
$3000 — $3EFF Ссылки на $2000 — $2EFF
$3F00 — $3F1F Палитры

Таблица имен

Для еще большей экономии места существуют таблицы имен и таблицы атрибутов. Каждый байт в таблице имен назначает, какой символ будет находиться в тайле, своеобразный индекс в таблице шаблонов. На одну таблицу имен уходит 960 байт в памяти PPU.

Скриншот с таблицей имен
Скриншот с таблицей имен

Палитры

В NES есть внутренняя палитра из 64 цветов. Также есть восемь палитр (четыре для фона, четыре для спрайтов), состоящие из четырех цветов, один из которых — цвет фона. Создатель игр может менять эти палитры, чтобы добиться наилучшей картинки. Таким образом, в них хранятся индексы внутренней палитры NES.

Пример игровых палитр
Пример игровых палитр

В таблице атрибутов хранятся как раз индексы палитр. Каждый из этих индексов соответствует одному блоку, поэтому внутри одного блока можно использовать только четыре цвета. Благодаря этим особенностям игры на NES выглядят так, будто состоят из отдельных блоков, хотя во многих случаях разработчики игр умело обходят ограничения консоли.

За выбор палитры отвечает таблица атрибутов, последний компонент отрисовки.

Таблица атрибутов

Каждой таблице имен соответствует одна таблица атрибутов. Атрибуты занимают по два бита на блок и указывают на одну из четырех палитр, которую стоит использовать для отрисовки блока.

Скриншот с палитрами
Скриншот с палитрами

Как вы заметили, в разных блоках расположены разные палитры, всего их четыре. Но разработчики научились умело скрывать это, чередуя палитры и тайлы. Так как на один атрибут уходит два бита, то на всю таблицу уходит не более 64 байт. Всего с учетом того, что NES имеет две таблицы шаблонов (или 512 символов), четыре таблицы имен и четыре таблицы атрибутов, в итоге занято около 12 Кбайт. Существенно меньше и намного более гибко!

Отрисовка спрайтов

Для того чтобы рисовать динамические объекты, существуют спрайты. Их отрисовка несильно отличается от отрисовки фона, но тут есть ряд особенностей.

PPU имеет отдельную память — OAM (object attribute memory), в которой находятся параметры разных спрайтов. Всего спрайтов может быть до 64, каждый спрайт занимает четыре байта. Они отвечают за индекс символа для отрисовки, позицию на экране (x, y), флаги. Во флагах находится номер палитры, флаг отражения по вертикали и горизонтали, а также приоритет спрайта.

РЕКОМЕНДУЕМ:
Лучшие игры для программистов и технарей

Флаги отражения предназначены для простого отражения спрайта по вертикали и горизонтали. Это позволяет экономить на памяти для спрайтов и сохранить время процессора. Приоритет отвечает за то, какой спрайт должен быть отрисован, если спрайты перекрываются. Часто используются комбинации из нескольких спрайтов для отрисовки больших объектов — например, персонажей.

написать эмулятор
написать эмулятор
Простой спрайт и комбинация спрайтов

Синхронизация между CPU и PPU

Для общения между CPU и PPU в память CPU отображены некоторые регистры PPU. С их помощью CPU может управлять работой PPU.

  • Controller отвечает за выбор таблиц шаблонов и таблиц имен, размер спрайтов, а также за NMI.
  • С помощью регистра Mask можно включать и выключать отображение фона, спрайтов, менять цветовую гамму изображения.
  • Регистр Scroll отвечает за перемещение камеры для заднего фона. Скроллинг может быть не только горизонтальным, но и вертикальным.

Пример скроллинга

Пример скроллинга

Полный список регистров PPU

NMI

CPU и PPU работают на разных частотах, но, чтобы отрисовывать картинку, они должны работать синхронно. Весь цикл рендеринга PPU состоит из 262 тактов.

Такты Происходящее на экране
0–240 Отрисовывается картинка
241 Пропуск
242–261 Установка флага Vblank и срабатывание NMI

С 242-го по 261-й такт PPU устанавливает Vblank и вызывает NMI. Он уведомляет процессор, что отрисовка кадра закончена и PPU не будет обращаться к памяти, чтобы избежать конфликтов с CPU, а также сообщает, что ждет дальнейших команд от процессора.

NMI — non maskable interrupt. Прерывание, которое срабатывает после того, как PPU отрендерил кадр. Во время него у CPU есть около 2273 циклов, чтобы подготовить следующий кадр.

Пример кода PPU для отрисовки

PPU отрисовывает линии по очереди. Так будет выглядеть код для отрисовки линии для фона.

На одном экране может присутствовать больше одной таблицы имен. Поэтому в коде нужно учитывать и это.
Две таблицы имен на экране

Две таблицы имен на экране

Функция ppu_draw_tile_line получает на вход адрес тайла, координаты, по которым он должен появиться, номер строки и палитру. На выходе она отрисовывает одну из линий этого тайла. Функция ppu_getb отвечает за получение байта памяти из PPU.

В функции ppu_draw_bg_line происходит непосредственная отрисовка одной линии заднего вида. Функция проходит по всем тайлам и отрисовывает их с учетом скроллинга. Для этого она в первую очередь определяет, какую таблицу имен использовать. Она также находит индекс тайла в таблице имен и его адрес в таблице шаблонов. После этого функция находит номер палитры в таблице атрибутов, а позже и адрес самой палитры.

РЕКОМЕНДУЕМ:
Лучшие эмуляторы SNES для Android

Выводы

На мой субъективный взгляд, этого материала достаточно, чтобы получить общее представление о работе NES и начать разрабатывать эмулятор. Всех желающих разобраться в этой теме подробнее приглашаю посетить репозиторий с уроками по созданию игр на NES и репозиторий с эмулятором NES.

Статья была опубликована 27.12.2018 и обновлена 31.08.2019.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий