Пишем эмулятор Gameboy

в 8:00, , рубрики: c++, dmg, gameboy, Программирование, разработка, эмулятор, метки: , , ,

Здравствуйте!

Не так давно на Хабре появилась статья о создании эмулятора chip-8, благодаря которой удалось хотя бы поверхностно понять, как пишутся эмуляторы. После реализации своего эмулятора появилось желание пойти дальше. Выбор пал на оригинальный Gameboy. Как оказалось, выбор был идеальным для ситуации, когда хочется реализовать что-то более серьезное, а опыт разработки эмуляторов практически отсутствует.

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

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

В данной статье мы познакомимся с Gameboy и начнем с эмуляции его процессора и памяти.

Оглавление

Введение
Архитектура
Процессор
Прерывания
Память
Заключение

Введение

Пишем эмулятор Gameboy
Gameboy – портативная консоль Nintendo, выпуск которой начался в 1989 году. Речь пойдет именно об оригинальном черно-белом Gameboy. Стоит заметить, что в различных документах, которыми мы будем руководствоваться, используется кодовое название Gameboy – DMG(Dot Matrix Game). Далее я буду использовать именно его.

Перед тем как приступить, необходимо ознакомиться с техническими характеристиками DMG:

Процессор 8-битный Sharp LR35902 работающий на частоте 4.19 МГц
Оперативная память 8 Кбайт
Видеопамять 8 Кбайт
Разрешение экрана 160x144
Частота вертикальной развертки 59.73 Гц
Звук 4 канала, стерео звук

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

Для DMG существует замечательный документ под названием Gameboy CPU Manual. Он включает в себя несколько известных документов от именитых разработчиков и содержит практически всю необходимую нам информацию. Естественно это не все, но на данном этапе этого более чем достаточно.

Сразу предупреждаю, что в документах будут ошибки, даже в официальных. В течение данного цикла статей я постараюсь упомянуть все недочеты различных документов какие смог найти (вспомнить). Так же постараюсь восполнить многие пробелы. Суть в том, что для DMG не существует исчерпывающего описания. Доступные материалы дают лишь поверхностное представление о работе многих узлов консоли. Если программист не в курсе таких «подводных камней», то разработка эмулятора станет намного сложнее, чем она могла бы быть. DMG достаточно прост, когда на руках достоверная и подробная информация. И проблема в том, что многие важные детали можно почерпнуть только из исходного кода других эмуляторов, что, тем не менее, не делает нашу задачу проще. Код известных эмуляторов или излишне сложен (Gambatte), или представляет собой жуткое нагромождение, кхм, низкокачественного кода (Visual Boy Advance – смотреть без слез на его код невозможно).

Поскольку статьи написаны с оглядкой на мой эмулятор, то вот сразу ссылка на исходники и бинарник CookieBoy.

Архитектура

Начнем с архитектуры будущего эмулятора. Для эмуляции DMG нам придется реализовать множество модулей, которые практически независимы друг от друга. В подобных условиях было бы глупо идти напролом, складывая все в одну кучу (что нередко наблюдается в других эмуляторах. Привет VBA). Более элегантным решением является реализация отдельных частей DMG как отдельных классов, эмулирующих свои части железа.

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

И так, приступим.

Процессор

DMG содержит 8-битный процессор Sharp LR35902, работающий на частоте 4194304 Гц (не стоит удивляться такой точности – это число понадобиться нам в будущем). Можно считать его упрощенной версией процессора Zilog Z80, который, в свою очередь, основан на Intel 8080. По сравнению с Z80 отсутствуют некоторые регистры и наборы инструкций.

Процессор содержит восемь 8-битных регистров A, B, C, D, E, F, H, L и два 16-битных регистра специального назначения — PC и SP. Некоторые инструкции позволяют объединять 8-битные регистры и использовать их как 16-битные регистры, а именно AF, BC, DE, HL. Например, регистр BC представляет собой «склеенные» регистры B и C, где регистр C исполняет роль младшего байта, а B – старшего.
Регистры A, B, C, D, E, H, L являются регистрами общего назначения. Регистр A так же является и аккумулятором. Регистр F содержит флаги процессора и напрямую недоступен. Ниже приведена схема регистра. Биты от 0 до 3 не используются.

Бит 7 6 5 4 3 2 1 0
Флаг Z N H C 0 0 0 0

Назначение флагов:

  • Zero Flag (Z) – флаг установлен (бит равен 1), если результат последней математической операции равен нулю или два операнда оказались равными при сравнении.
  • Substract Flag (N) – флаг установлен, если последней операцией было вычитание.
  • Half Carry Flag (H) – флаг установлен, если в результате последней математической операции произошел перенос из младшего полу-байта.
  • Carry Flag (С) – флаг установлен, если в результате последней математической операции произошел перенос.

Регистр PC (program counter), как несложно догадаться, является счетчиком инструкций и содержит адрес следующей инструкции.

Регистр SP (stack pointer), соответственно, является указателем на вершину стека. Для тех, кто не в курсе, стек это область памяти, в которую записываются значения переменных, адреса возврата и прочее. SP содержит адрес вершины стека – стек растет вниз, от старших адресов к младшим. Для него всегда существует как минимум две операции. PUSH позволяет вставить некое значение – сначала регистр SP уменьшается, а затем происходит вставка нового значения. POP позволяет извлечь значение – сначала по адресу SP значение извлекается из памяти, а затем SP увеличивается.

Так же процессор содержит так называемый IME (interrupt master enable) – флаг, который разрешает обработку прерываний. Принимает, соответственно, два значения – запретить (0) и разрешить (1).

С теорией все, можно приступать к реализации. Поскольку нам придется работать как с 8-битными регистрами, так и с их 16-битными парами, то целесообразно реализовать механизм, который позволяет иметь одновременный доступ и к тем, и к тем без необходимости использовать битовые операции. Для этого объявим следующий тип:

union WordRegister
{
	struct
	{
		BYTE L;
		BYTE H;
	} bytes;
	WORD word;
}; 

Регистры процессора будем хранить как пары, а к отдельным частям будем иметь доступ благодаря объединению WordRegister. Поле «word» даст доступ ко всему 16-битному регистру. Поле «bytes» дает доступ к отдельным регистрам в паре. Единственное, регистры А и F стоит хранить отдельно. Регистр А является аккумулятором, а значит используется очень часто. Похожая ситуация с регистром F – флаги процессора приходится устанавливать довольно часто.

Теперь приступим к реализации собственно процессора – за это будет отвечать класс Cookieboy::CPU. Чтение и исполнение инструкций будет реализовано по обычной схеме – чтение опкода из памяти, а затем декодирование и исполнение посредством конструкции switch:

BYTE opcode = MMC.Read(PC);
PC++;

switch (opcode)
{
case 0x00:
    break;
}

Все опкоды имеют длину 1 байт, но некоторые инструкции используют так называемый префикс – первым байтом идет префикс набора инструкций (для нас единственный префикс это 0xCB), вторым байтом идет собственно опкод из этого набора. Реализация элементарная – как только мы наткнулись на 0xCB, то читаем еще один байт и декодируем его вложенным switch.

Данный код помещается в функцию void Step(), которая за один вызов исполняет одну инструкцию процессора и производит другие необходимые операции.

Естественно для чтения и записи в память нам понадобится другой класс – Cookieboy::Memory, объект которого можно видеть выше под именем «MMC». На данном этапе достаточно заглушки с основными методами:

class Memory
{
public:
	void Write(WORD addr, BYTE value);
	BYTE Read(WORD addr);
};

Процессор DMG имеет достаточно большое число инструкций, список которых можно найти в Gameboy CPU Manual. Там же указано, какие флаги процессора необходимо устанавливать и сколько тактов занимает исполнение каждой инструкции. ОЧЕНЬ внимательно читайте описание флагов – неправильно реализованная установка флагов нередко приводит к неработоспособности игр, а отладка превращается в пытку. Но спешу немного успокоить – существует тестовые ROM’ы для флагов процессора, но до исполнения ROM’ов нам еще далеко.

К слову о тактах. Если chip-8 был достаточно прост, и его эмуляция не требовала учета длительности исполнения инструкций, то c DMG дело обстоит иначе. Компоненты консоли работают не абы как, а синхронизированы с помощью генератора тактовой частоты. Для нас это означает то, что нам необходимо синхронизировать работу всех компонентов нашего эмулятора с процессором.

Решить эту проблему достаточно просто. Процессор является центральным звеном нашего эмулятора. Исполнив инструкцию, мы передаем другим компонентам затраченное процессором время в тактах для синхронизации всех компонентов между собой. Для этого я используют макрос SYNC_WITH_CPU(clockDelta), в который и передается затраченное процессором время на исполнение инструкции. Он уже вызывает функции синхронизации остальных компонентов эмулятора. Решение проблемы синхронизации можно было бы легко вынести за пределы класса процессора, если бы не одно но.

Компоненты консоли работают одновременно, никто не ждет, пока процессор закончит исполнение инструкции, как это делаем мы. Некоторые инструкции требует длительного времени на исполнение и в процессе происходит чтение и запись данных в память. Процессор, как можно догадаться, тратит определенное время на чтение/запись в память (4 такта). Это приводит к тому, что в процессе исполнения содержимое памяти может измениться, что, естественно, неплохо было бы тоже эмулировать.

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

Правильнее и красивее сделать все же по-другому. Мы точно знаем, что каждая операция записи или чтения из памяти одного байта занимает 4 такта. Достаточно добавить вспомогательные функции чтения и записи, которые сами вызывают функции синхронизации. Как только это будет сделано, большинство инструкций сразу обретет правильную длительность, ведь в действительности их время исполнения складывается как раз из операций чтения и записи. Получение опкода команды тоже сюда относится. Именно так я поступил в своем эмуляторе, что практически полностью освободило меня от ручной синхронизации и подсчета тактов. Лишь некоторые инструкции потребовали моего вмешательства.

А теперь немного отвлечемся, чтобы прояснить ситуацию с тактами. В различной документации наблюдается путаница. Некоторые документы пишут такие числа, что, например, NOP имеет длительность 4 такта, другие – 1 такт (так, например, написано в официальной документации Nintendo). Для понимания причины стоит немного отвлечься на теорию.

Любая инструкция процессора имеет определенную длительность, которую назовем машинный цикл. За один машинный цикл процессор может произвести одно действие от и до, как например чтение опкода, его декодирование и исполнение команды; чтение или запись значения в памяти. В свою очередь, машинный цикл состоит из машинных тактов, поскольку за один машинный цикл процессор может совершить несколько операций. И вот мы приходим к нашему процессору. Если мы говорим, что NOP длится 4 такта, то мы говорим о машинных тактах. Если мы говорим об 1 такте для NOP, то мы говорим о машинных циклах. Процессор DMG именно так и работает – его машинный цикл длится 4 машинных такта и многие инструкции имеют длительность ровно 4 такта или 1 машинный цикл – процессор DMG способен прочитать опкод из памяти, декодировать его и исполнить инструкцию всего за 4 машинных такта.

Здесь и далее я буду использовать более привычные машинные такты. Именно они соответствуют одному периоду тактового генератора, а значит, являются минимальной и неделимой единицей времени для нашего эмулятора. Таким образом, операция NOP будет длиться 4 такта.

На данном этапе уже можно полностью реализовать эмуляцию всех команд процессора. Отдельно стоит упомянуть некоторые из них:

  • HALT имеет достаточно интересное поведение, которое описано в CPU Manual (2.7.3. Low-Power Mode). Решение в лоб приведет к тому, что тест инструкции HALT не будет пройден. Здесь нужно быть внимательным как в реализации самой инструкции, так и в реализации обработчика прерываний (об этом далее). Реализация инструкции такова, что она не приостанавливает исполнение и приводит к упомянутому багу только в том случае, если IME равен нулю и в данный момент нет прерываний, которые необходимо обработать (об этом так же далее) – именно последний момент опущен в большинстве документов. В противном случае бага нет, и исполнение приостанавливается. Естественно, тактовый генератор и все остальные компоненты продолжают свою работу, а значит надо продолжать вызывать функции синхронизации, давая в качестве аргумента 4 такта (нет смысла считать в этом режиме по одному такту). Как будто процессор исполняет NOP.
  • В POP AF стоит учесть тот факт, что в регистре F есть неиспользуемые биты. Для этого необходимо обнулить младшие 4 бита регистра F после того, как его содержимое будет извлечено из стека.
  • Инструкции RLCA, RLA, RRCA, RRA всегда обнуляют флаг Z в регистре F.

Помимо этих недочетов есть и другие. CPU Manual содержит неполное описание длительности инструкций. Как можно догадаться, инструкции условных переходов должны иметь разную длительность в зависимости от того, произошел переход или нет. Можно было бы воспользоваться тестовыми ROM’ми, но они неправильно работают сами по себе из-за этих инструкций, поэтому выводят неизвестную ошибку даже не начав тестирование. Вот таблица этих инструкций с указанием их длительности:

Опкоды Переход не произошел Переход произошел
0xC2, 0xCA, 0xD2, 0xDA 12 16
0x20, 0x28, 0x30, 0x38 8 12
0xC4, 0xCC, 0xD4, 0xDC 12 24
0xC0, 0xC8, 0xD0, 0xD8 8 20

Так же для инструкций RST n (опкоды 0xC7, 0xCF, 0xD7, 0xDF, 0xE7, 0xEF, 0xF7, 0xFF) указана неправильная длительность. Правильное значение равно 16 тактам.

И так, на данный момент наш «процессор» способен читать инструкции из памяти, исполнять их и синхронизировать другие компоненты с собой (как бы синхронизирует, пока все это функции-пустышки). После этого нам необходимо проверить, не произошло ли прерывание после всей проделанной работы.

Прерывания

Прерывание – это событие, которое приостанавливает исполнение текущих инструкций процессора и передает управление обработчику прерывания. DMG работает именно по такому принципу.

В ходе синхронизации мы вызываем методы синхронизации других компонентов эмулятора, которые могут запросить прерывание. В DMG это осуществляется следующим образом. Существует два регистра (где они находятся будет рассмотрено далее) – IF (interrupt flags) и IE (interrupt enable). Их биты имеют определенное назначение, которое идентично в обоих регистрах:

Бит Прерывание
4 Joypad
3 Serial I/O transfer complete
2 Timer overflow
1 LCDC
0 V-Blank

Биты регистра IF показывают, какие прерывания были запрошены. Если бит установлен, то прерывание запрошено.

Биты регистра IE разрешают обработку прерываний. Если бит установлен в единицу и соответствующее прерывание было запрошено, то оно будет обработано. Если нет, то прерывание обработано не будет.

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

Одна важная деталь состоит в том, что прерывание выводит процессор из состояния останова, произошедшее в результате исполнения HALT или STOP. И здесь очень важен алгоритм, по которому проверяются регистры прерываний. Алгоритм таков:

  1. Проверяем, есть ли вообще прерывания, которые стоит обработать. Делается это с помощью операции логического И между регистрами IE и IF. Дополнительно стоит произвести операцию логического И с результатом и числом 0x1F для удаления возможного мусора, поскольку старшие три бита не используются в обоих регистрах.
  2. Если таких прерываний нет, то выходим из функции. Если же они есть, то именно сейчас мы должны вывести процессор из состояния останова.
  3. Теперь мы приступаем к обработке прерываний. Для этого мы проверяем, не запрещает ли флаг IME их обработку. Если нет, то:
    1. обнуляем IME;
    2. загружаем регистр PC в стек;
    3. вызываем обработчик прерывания путем установки регистра PC равному адресу обработчика в памяти;
    4. обнуляем бит регистра IF соответственно обработанному прерыванию.

Прерывания обрабатываются по одному за раз и в строго определенном порядке. Вся информация о приоритетах и адресах обработчиков указана в CPU Manual.

Важная деталь. Опять же, кому-то уже могла прийти в голову мысль – обработка прерывания очень похожа на вызов процедуры, а значит должна занимать какое-то время. Это действительно так и занимает она 20 тактов. По какой-то причине этот момент опущен в документах, описывающих DMG.

Теперь приступаем к реализации. Прерываниями у нас будет заниматься класс Cookieboy::Interrupts. В него мы помещаем регистры IE и IF и объявляем функции для доступа к этим регистрам (они понадобятся нам позже), а так же функцию, которая позволяет запросить определенное прерывание (нам же не хочется каждый раз манипулировать битами, чтобы запросить какое-то прерывание). Так же нам понадобится функция, которая будет проверять, какие прерывания стоит обработать. Помещаем вызов этой функции в конце функции Step процессора и дополнительно синхронизируем компоненты.

Немного про запрос прерываний. Он осуществляется установкой в регистре IF соответствующих битов. Перед установкой проверка регистра IE не требуется. Даже если биты в нем запрещают конкретное прерывание, мы все равно устанавливается биты в IF регистре для этого прерывания.

Если вы смотрели исходный код моей реализации Cookieboy::Interrupts, то могли заметить, что я возвращаю значение регистров IE и IF после того, как установлю в единицу все неиспользуемые в них биты (операция ИЛИ со значением 0xE0). Делаю я это не просто так. Многие регистры в I/O ports (об этом чуть ниже) используют не все биты, другие ограничивают доступ на чтение к некоторым битам или ко всему регистру сразу. Это так же необходимо учитывать – для этого неиспользуемые и запрещенные для чтения биты стоит установить в 1 перед возвращением.

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

Память

Заранее определимся с одним термином – банк памяти. Под этим понимается область памяти строго определенного размера. Существует два вида банков – ROM банки, имеющие длину 0x4000 байт, и RAM банки, имеющие длину 0x2000 (стоит сразу привыкнуть к 16-ричной системе счисления, так будет проще и мне, и вам). Зачем это нужно? Процессор DMG способен работать с 16-битным адресами, а значит адресное пространство ограничено 0x10000 байтам. Из них только 0x8000 байт отведено на образ игры. В большинстве случаев этого недостаточно и в ход вступают банки памяти.

Обращаясь по адресам 0x4000-0x7FFF, без банков памяти мы бы попали именно по этому адресу в образе игры. С помощью банков памяти мы можем установить так, чтобы образ был поделен на банки, а по адресу 0x4000-0x7FFF был отображен выбранный банк. Таким образом, в один момент в этой области находится второй банк, в другой – десятый. Как мы захотим, в общем. Таким образом, мы приходим к виртуальным и физическим адресам. 0x4000-0x7FFF – это виртуальные адреса, которые не обязаны совпадать с физическими. Физический адрес – это настоящий адрес, по которому происходит доступ к ячейкам памяти.

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

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

Задача перевода виртуального адреса в физический лежит на MBC-контроллере, который находится внутри картриджа. Все операции чтения и записи в области ROM’а проходят через него. Так же сюда перенаправляются операции, связанные с внешней оперативной памятью.

Естественно мы не может изменить содержимое ROM. Операции записи используются как управляющие команды для MBC. В CPU Manual можно прочитать, какие адреса, за какие функции отвечают. Таким образом, записав по определенному адресу число 9, мы говорим, что хотим выбрать банк 9. После этого мы можем читать его содержимое, обращаясь по адресам 0x4000-0x7FFF.

На рисунке ниже представлена простейшая схема работы MBC. Здесь область 0x0000-0x3FFF всегда перенаправляется в банк 0, как в некоторых реальных контроллерах, а вот область 0x4000-0x7FFF перенаправляется в текущий банк.
Пишем эмулятор Gameboy
Рассмотрим схему адресного пространства DMG:

Секция памяти Начальный адрес Конечный адрес
ROM bank 0 0x0000 0x3FFF
Switchable ROM bank 0x4000 0x7FFF
Video RAM 0x8000 0x9FFF
Switchable RAM bank 0xA000 0xBFFF
Internal RAM 1 0xC000 0xDFFF
Echo of Internal RAM 1 0xE000 0xFDFF
OAM 0xFE00 0xFE9F
Не используется 0xFEA0 0xFEFF
I/O ports 0xFF00 0xFF4B
Не используется 0xFF4C 0xFF7F
Internal RAM 2 0xFF80 0xFFFE
Interrupt enable register 0xFFFF 0xFFFF

Подробнее о каждой секции:

  • ROM bank 0. Switchable ROM bank. Данные области мы уже рассмотрели.
  • Video RAM. Подробнее будет рассмотрена при реализации графики.
  • Internal RAM 1. Оперативная память внутри DMG.
  • Echo of Internal RAM 1. Здесь дублируется содержимое Internal RAM 1.
  • OAM. Здесь хранится описание спрайтов.
  • I/O ports. Здесь мы получаем доступ к регистрам других компонентов DMG.
  • Internal RAM 2. Оперативная память внутри DMG.
  • Interrupt enable register. В этом регистре хранятся флаги, которые разрешают обработку определенных прерываний. Это тот самый регистр IE, о котором мы уже говорили.

Поскольку архитектура эмулятора предполагает, что каждый компонент DMG будет иметь свой класс, то класс Cookieboy::Memory, эмулирующий память, будет содержать лишь следующие области памяти – ROM банки, internal RAM 1, Echo of internal RAM 1, Switchable RAM bank, internal RAM 2. При обращении ко всем другим областям будут вызываться методы доступа соответствующих классов.

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

switch (addr & 0xF000)
{
case 0x8000:
case 0x9000:
	//осуществляем операции с видеопамятью
	break;
}

И никаких громоздких условных конструкций. Пока можно оставить лишь заготовку, поскольку некоторые области памяти будут находиться в других классах (например, видеопамять), которые мы еще не реализовали. Можно лишь реализовать то, что действительно есть в Cookieboy::Memory. Здесь стоит обратить внимание на банки ROM’а и Switchable RAM bank.

Если картридж, с которого был снят ROM, содержал MBC-контроллер, то в этих областях памяти нам надо реализовать логику этих контроллеров. Для этого можно поступить очень просто – доступ к этим областям перенаправляется в классы, которые реализуют соответствующие MBC-контроллеры, а они уже сами пусть решают, куда, как и что. Рассмотрим два примера – MBC 2 и MMM01. Первый – как пример, который позволит вам реализовать остальное. MMM01 – довольно странный MBC. По нему практически нет документации, а его реализация довольно сильно отличается от других MBC. Не повредит восполнить этот пробел в деле эмуляции DMG.

Для начала обзаведемся базовым классом MBC. Выглядеть он будет следующим образом:

const int ROMBankSize = 0x4000;
const int RAMBankSize = 0x2000;

class MBC
{
public:
	virtual void Write(WORD addr, BYTE value) = 0;
	virtual BYTE Read(WORD addr) = 0;
	
	virtual bool SaveRAM(const char *path, DWORD RAMSize);

	virtual bool LoadRAM(const char *path, DWORD RAMSize);

protected:
	MBC(BYTE *ROM, DWORD ROMSize, BYTE *RAMBanks, DWORD RAMSize) : ROM(ROM), ROMSize(ROMSize), RAMBanks(RAMBanks), RAMSize(RAMSize) {}

	BYTE *ROM;
	BYTE *RAMBanks;

	DWORD ROMOffset;
	DWORD RAMOffset;

	DWORD ROMSize;
	DWORD RAMSize;
};

Как видно, сначала идут функции записи и чтения – именно они будут вызываться из нашего Cookieboy::Memory. Далее идут функции сохранения и загрузки RAM. Тут мы сразу готовим почву для будущей эмуляции памяти в картридже, которая запитана от аккумулятора для сохранения ее содержимого после выключения консоли. Их реализацию я опущу – это всего лишь сохранение и чтение массива RAMBanks из файла, не более. Затем предельно очевидный конструктор и несколько полей:

  • ROM. Здесь у нас находится весь образ игры.
  • RAMBanks. Здесь находится оперативная память картриджа.
  • RAMOffset и ROMOffset. Это смещения, которые указывают на текущий банк памяти.
  • ROMSize и RAMSize, думаю, не требуют пояснений. Значения хранятся в банках памяти, а не в байтах.

С базовым классом покончено, теперь приступим к реализации класса, который эмулирует MBC2. Сразу посмотрим на код, а затем уже разберемся, как работает этот контроллер:

class MBC2 : public MBC
{
public:
	MBC2(BYTE *ROM, DWORD ROMSize, BYTE *RAMBanks, DWORD RAMSize) : MBC(ROM, ROMSize, RAMBanks, RAMSize)
	{
		ROMOffset = ROMBankSize;
		RAMOffset = 0;
	}

	virtual void Write(WORD addr, BYTE value)
	{
		switch (addr & 0xF000)
		{
		//ROM bank switching
		case 0x2000:
		case 0x3000:
			ROMOffset = value & 0xF;
			ROMOffset %= ROMSize;

			if (ROMOffset == 0)
			{
				ROMOffset = 1;
			}

			ROMOffset *= ROMBankSize;
			break;

		//RAM bank 0
		case 0xA000:
		case 0xB000:
			RAMBanks[addr - 0xA000] = value & 0xF;
			break;
		}
	}

	virtual BYTE Read(WORD addr)
	{
		switch (addr & 0xF000)
		{
		//ROM bank 0
		case 0x0000:
		case 0x1000:
		case 0x2000:
		case 0x3000:
			return ROM[addr];

		//ROM bank 1
		case 0x4000:
		case 0x5000:
		case 0x6000:
		case 0x7000:
			return ROM[ROMOffset + (addr - 0x4000)];

		//RAM bank 0
		case 0xA000:
		case 0xB000:
			return RAMBanks[addr - 0xA000] & 0xF;
		}

		return 0xFF;
	}
};

С функцией чтения все просто. ROMOffset используется как смещение для обращения к текущему банку ROM. С оперативной памятью есть одна деталь. MBC2 имеет 512 4-битовых блоков RAM. Мы, естественно, выделяем все 512 байт, просто операции записи и чтения обрезают значения до 4 младших бит.

Теперь функция записи. Именно здесь эмулируется логика MBC. MBC2 поддерживает только смену банков ROM. Меняются они с помощью записи номера банка длиной 4 бита в область адресов 0x2000-0x3FFF. Нулевой банк выбрать нельзя, т.к. он и так находится в 0x0000-0x3FFF. Здесь же стоит проверять выход за границы ROM. Некоторые игры по неопределенной причине пытаются выбрать банк, которого не существует. Это, естественно, приводит к ошибке. С проверкой игра работает, как ни в чем не бывало. Одной из таких игр является WordZap. Может это последствия неточной эмуляции (на идеальную эмуляцию DMG я, естественно, не претендую), но в любом случае проверка не повредит.

Да, 0xFF возвращается не случайно – на DMG данное значение возвращается в случае, когда содержимое не определено.

Наконец, рассмотрим MMM01. Я не уверен в правильности своего кода, поскольку описание этого контроллера было найдено на форуме, а само оно написано неизвестно кем. Код:

class MBC_MMM01 : public MBC
{
public:
	enum MMM01ModesEnum
	{
		MMM01MODE_ROMONLY = 0,
		MMM01MODE_BANKING = 1
	};

	MBC_MMM01(BYTE *ROM, DWORD ROMSize, BYTE *RAMBanks, DWORD RAMSize) : MBC(ROM, ROMSize, RAMBanks, RAMSize)
	{
		ROMOffset = ROMBankSize;
		RAMOffset = 0;
		RAMEnabled = false;
		Mode = MMM01MODE_ROMONLY;
		ROMBase = 0x0;
	}

	virtual void Write(WORD addr, BYTE value)
	{
		switch (addr & 0xF000)
		{
		//Modes switching
		case 0x0000:
		case 0x1000:
			if (Mode == MMM01MODE_ROMONLY)
			{
				Mode = MMM01MODE_BANKING;
			}
			else
			{
				RAMEnabled = (value & 0x0F) == 0x0A;
			}
			break;

		//ROM bank switching
		case 0x2000:
		case 0x3000:
			if (Mode == MMM01MODE_ROMONLY)
			{
				ROMBase = value & 0x3F;
				ROMBase %= ROMSize - 2;
				ROMBase *= ROMBankSize;
			}
			else
			{
				if (value + ROMBase / ROMBankSize > ROMSize - 3)
				{
					value = (ROMSize - 3 - ROMBase / ROMBankSize) & 0xFF;
				}

				ROMOffset = value * ROMBankSize;
			}
			break;

		//RAM bank switching in banking mode
		case 0x4000:
		case 0x5000:
			if (Mode == MMM01MODE_BANKING)
			{
				value %= RAMSize;
				RAMOffset = value * RAMBankSize;
			}
			break;
		//Switchable RAM bank
		case 0xA000:
		case 0xB000:
			if (RAMEnabled)
			{
				RAMBanks[RAMOffset + (addr - 0xA000)] = value;
			}
			break;
		}
	}

	virtual BYTE Read(WORD addr)
	{
		if (Mode == MMM01MODE_ROMONLY)
		{
			switch (addr & 0xF000)
			{
			//ROM bank 0
			case 0x0000:
			case 0x1000:
			case 0x2000:
			case 0x3000:
			//ROM bank 1
			case 0x4000:
			case 0x5000:
			case 0x6000:
			case 0x7000:
				return ROM[addr];

			//Switchable RAM bank
			case 0xA000:
			case 0xB000:
				if (RAMEnabled)
				{
					return RAMBanks[RAMOffset + (addr - 0xA000)];
				}
			}
		}
		else
		{
			switch (addr & 0xF000)
			{
			//ROM bank 0
			case 0x0000:
			case 0x1000:
			case 0x2000:
			case 0x3000:
				return ROM[ROMBankSize * 2 + ROMBase + addr];

			//ROM bank 1
			case 0x4000:
			case 0x5000:
			case 0x6000:
			case 0x7000:
				return ROM[ROMBankSize * 2 + ROMBase + ROMOffset + (addr - 0x4000)];

			//Switchable RAM bank
			case 0xA000:
			case 0xB000:
				if (RAMEnabled)
				{
					return RAMBanks[RAMOffset + (addr - 0xA000)];
				}
			}
		}

		return 0xFF;
	}

private:
	bool RAMEnabled;
	MMM01ModesEnum Mode;	
	DWORD ROMBase;
};

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

Возвращаясь к эмуляции памяти, стоит немного прояснить область памяти под названием I/O ports. Т.к. DMG состоит из различных компонентов, то неплохо бы иметь возможность как-то влиять на их работу и даже контролировать. Для этого в области памяти I/O ports мы имеем доступ к регистрам всех остальных компонентов DMG: контроллер экрана, звук, таймеры, управление и т.д. Естественно, все эти регистры в нашем эмуляторе будут находиться в соответствующих классах, а значит Cookieboy::Memory будет лишь перенаправлять все операции в них. Список и назначение всех регистров можно найти в CPU Manual. Так же я буду рассматривать их при необходимости. Кстати, одни их них мы уже рассмотрели – IF. Этот регистр доступен именно в этой области памяти, поэтому необходимо перенаправить операции чтения и записи в класс Cookieboy::Interrupts. Мы уже можем это сделать, т.к. обеспокоились об этом заранее при рассмотрении прерываний.

Настало время еще одной важной функции – загрузки ROM из файла. Перед реализацией загрузки образа в память самое время упомянуть, что же происходит при включении DMG.

Сначала идет исполнение Bootstrap ROM’а, который хранится внутри DMG. Его содержимое можно найти в исходном коде класса Cookieboy::Memory. Он не делает ничего особенного, кроме проверки содержимого картриджа и отображения лого Nintendo. Он имеет длину 256 байт, исполнение начинается с 0 – т.е. после включения регистр PC процессора равен нулю. Его исполнение заканчивается командой, которая осуществляется запись по адресу 0xFF50. По этому адресу находится скрытый регистр, который указывает, откуда в данный момент поступают команды для процессора – из Bootstrap ROM’а или картриджа. Как ни странно, описания этого регистра нет практически нигде. Более того, нет даже упоминаний о нем.

Интересный факт. Bootstrap ROM был получен не так давно, а извлекли его посредством фотографии кристалла процессора. Автор сфотографировал часть процессора, в которой находился этот ROM, и на глаз по одному биту считал все содержимое.

Замечу, что при включении оперативная память DMG и картриджа содержит случайные числа. Это незначительная деталь, поэтому обычно эмуляторы заполняют эти области нулями. Как поступать – решать вам. Скорее всего, лучше или хуже от этого игры работать не станут. Учтите, речь только об оперативной памяти. Заполнение случайными значениями других областей приведет к некорректной работе эмулятора.

Конечно, не хотелось бы каждый раз запускать этот образ. Для этого можно сделать следующее. Регистр PC должен быть равен 0x100 – именно по этому адресу находится первая команда в образах игр. Далее, все регистры процессора и область памяти I/O ports необходимо проинициализировать значениями, которые оставляет после себя Bootstrap ROM – эти значения можно найти в CPU Manual. Не все игры настолько хорошо написаны, чтобы устанавливать все необходимые значения самостоятельно, некоторые могут полагаться на значения, которые установлены после выполнения Bootstrap ROM. Для этого все компоненты содержат функцию EmulateBIOS, посредством которой устанавливаются все необходимые значения.

И так, приступим к загрузке обараза. Весь файл образа читается в массив, а из заголовочной части образа читаются метаданные образа. Самое важное это узнать тип картриджа (тип MBC-контроллера) и размер внешней оперативной памяти внутри картриджа. Адреса указаны в CPU Manual. Так же стоит реализовать те проверки, которые делает Bootstrap ROM. С помощью них можно легко узнать, действительно ли файл является образом для DMG. Первая проверка – лого Nintendo. Каждый ROM содержит логотип Nintendo, который и отображается при выполнении Bootstrap ROM. Он должен иметь строго определенное значение. Какое – указано в CPU Manual. Так же можно проверить контрольную сумму заголовка образа. Для этого можно воспользоваться следующим кодом:

BYTE Complement = 0;
for (int i = 0x134; i <= 0x14C; i++)
{
	Complement = Complement - ROM[i] - 1; 
}
if (Complement != ROM[0x14D])
{
	//проверка не пройдена 
}

Если проверки прошли, то мы выделяем место под оперативную память картриджа и создаем объект соответствующего MBC чипа.

Касательно оперативной памяти картриджа, то неплохо всегда иметь под рукой как минимум один банк памяти, даже если образ «говорит», что она не используется. Некоторые игры выдают себя за картриджи без MBС, но, тем не менее, могут иметь простенький чип лишь для оперативной памяти.

Все, с памятью покончено.

Заключение

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

Автор: creker

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js