Вы наверняка слышали про эмуляторы игровых приставок и, возможно, даже играли в них не один час. Но задавались ли вы вопросом, как работает эмулятор игровой консоли? На примере игровой приставки 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 инструкций.
Начнем с кода для режима адресации нулевой страницы.
1 2 3 4 5 6 |
void addr_mode_zp() { cpu_addr = ram_getb(reg.PC); reg.PC++; } |
В данном случае инструкция состоит из двух байт: один — это сама инструкция, второй — адрес операнда инструкции. Функция ram_getb возвращает значения байта в RAM по адресу.
РЕКОМЕНДУЕМ:
Как самому создать игру
А вот пример кода для инструкции STX.
1 2 3 4 5 |
void op_stx() { ram_setb(cpu_addr, reg.X); } |
Функция 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
void uxrom_init() { prg_rom.low = 0; prg_rom.up = prg_rom.n - 1; chr_rom.cur = 0; } uint8_t uxrom_getb(uint16_t addr) { return ram_general_getb(addr); } void uxrom_setb(uint16_t addr, uint8_t b) { if (addr < 0x8000) ram_general_setb(addr, b); else if (ram_getb(addr) == b) prg_rom.low = b; } |
В данном случае маппер изначально выставляет верхний банк PRG ROM последним из возможных, и его адрес остается фиксированным все время. Нижняя же часть может меняться. Для этого пользователю нужно записать номер PRG ROM в память картриджа. Если номер картриджа совпадает со значением, которое лежит по адресу, куда пользователь записывает, то маппер меняет нижний банк.
Picture Processing Unit
Ключевую роль в отрисовке играет PPU — Picture Processing Unit. Именно благодаря ему у NES для своего времени была хорошая графика. 256 на 240 пикселей и палитра из 64 цветов прекрасно смотрелись на телевизорах того времени.
Если посчитать, то выйдет, что для хранения одного изображения на экране понадобится 45 Кбайт. Сейчас это значение выглядит смешно, но еще тридцать лет назад эта цифра была непосильна для консолей. Поэтому PPU достигает компромисса между эффективностью, памятью и качеством картинки.
Отрисовка фона
Весь экран можно разделить на 32 × 30 тайлов, каждый из которых — квадрат 8 × 8 пикселей. Четыре тайла составляют блок. Можно добавить разметку, чтобы лучше видеть структуру картинки.
Сетка темно-зеленая в случае с блоками и светло-зеленая для тайлов.
Все символы рисуются как пиксель-арт, в котором может быть только четыре цвета (2 бита на пиксель, 16 байт на тайл). Таким образом, все изображение занимает 15 Кбайт, тогда как PPU доступно только около 12 Кбайт.
Пример пиксель-арта |
Адрес | Назначение |
---|---|
$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 отвечает за перемещение камеры для заднего фона. Скроллинг может быть не только горизонтальным, но и вертикальным.
Пример скроллинга
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 отрисовывает линии по очереди. Так будет выглядеть код для отрисовки линии для фона.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
void ppu_draw_tile_line(int tile, int screen_x, int screen_y, int ny, int pal) { uint8_t low, high; int i, clr; low = ppu_getb(tile + ny % 8); high = ppu_getb(tile + ny % 8 + 8); for (i = 0; i < 8; i++) { int clr0, clr1; if (scr_x + i < 0 || scr_x + i > 255) continue; clr0 = (high >> (7 - i)) & 1; clr1 = (low >> (7 - i)) & 1; clr = (clr0 << 1) | clr1; set_pixel(screen_y, screen_x + i, ppu_getb(pal + clr)); } } void ppu_draw_bg_line() { int x, y, nx, ny; int screen_x, screen_y; uint16_t patt, name, name_right, attrib_left, attrib_right; screen_y = ppu.scanline; y = ppu.PPUSCROLL_Y + ppu.scanline; ny = y % 240; patt = ppu_bg_patt_tbl(); name_left = ppu_get_name_tbl_left(ppu.PPUSCROLL_X, y); name_right = ppu_get_name_tbl_right(ppu.PPUSCROLL_X, y); attrib_left = name_left + 0x3C0; attrib_right = name_right + 0x3C0; for (screen_x = -ppu.PPUSCROLL_X % 8; screen_x < 256; screen_x += 8) { uint16_t name, attrib, pal; int tile, tile_idx; x = screen_x + ppu.PPUSCROLL_X; nx = x % 256; if (x < 0) continue; if (x < 256) { name = name_left; attrib = attrib_left; } else { name = name_right; attrib = attrib_right; } tile_idx = ppu_getb(name + (ny >> 3) * 32 + (nx >> 3)); tile = patt + tile_idx * 16; pal = attrib + (ny >> 5) * 8 + (nx >> 5); pal = ppu_getb(pal); if (ny % 32 >= 16) pal >>= 4; if (nx % 32 >= 16) pal >>= 2; pal = 0x3f00 + (pal % 4) * 4; ppu_draw_tile_line(tile, screen_x, screen_y, ny, pal); } } |
На одном экране может присутствовать больше одной таблицы имен. Поэтому в коде нужно учитывать и это.
Две таблицы имен на экране
Функция ppu_draw_tile_line получает на вход адрес тайла, координаты, по которым он должен появиться, номер строки и палитру. На выходе она отрисовывает одну из линий этого тайла. Функция ppu_getb отвечает за получение байта памяти из PPU.
В функции ppu_draw_bg_line происходит непосредственная отрисовка одной линии заднего вида. Функция проходит по всем тайлам и отрисовывает их с учетом скроллинга. Для этого она в первую очередь определяет, какую таблицу имен использовать. Она также находит индекс тайла в таблице имен и его адрес в таблице шаблонов. После этого функция находит номер палитры в таблице атрибутов, а позже и адрес самой палитры.
РЕКОМЕНДУЕМ:
Лучшие эмуляторы SNES для Android
Выводы
На мой субъективный взгляд, этого материала достаточно, чтобы получить общее представление о работе NES и начать разрабатывать эмулятор. Всех желающих разобраться в этой теме подробнее приглашаю посетить репозиторий с уроками по созданию игр на NES и репозиторий с эмулятором NES.
Статья была опубликована 27.12.2018 и обновлена 31.08.2019.