Кое-кто из вас может помнить серию примечательных игр для Game Boy Advance, вышедших в течение 2004 года. Светло-серые картриджи с простыми этикетками сильно отличались от обычных, тёмно-серых, с разноцветными этикетками. На них продавались игры, портированные с оригинальной Nintendo Entertainment System. Эти игры, известные в США как Classic NES Series, интересны по нескольким причинам.
Особенно интересны они с точки зрения эмуляции GBA. Обычно игры для Game Boy Advance невероятно проблемны, а сама платформа содержит множество средств для защиты от сбоев. Поэтому для запуска игр эмуляторы должны быть совместимы с ошибками оригинального оборудования. Однако в серии Classic NES Series разработчики пошли дальше и попытались защитить игру от запуска в эмуляторах.
Если вы пробовали играть в одном из старых эмуляторов, то, наверно, видели экран Game Pak Error. Как выяснилось, в этих играх используются хитрости и неопределённое поведение, усложняющие эмуляцию. Похоже, это была намеренная попытка защиты от копирования таких игр. В интересах точности эмуляции я кропотливо исследовал, реализовал и записал все необычные действия, выполняемые в этих играх.
Хитрость 1: зеркалирование памяти
Первая хитрость, используемая в играх, задействует структуру памяти Game Boy Advance. GBA имеет «плоское» (несегментированное) адресное пространство памяти. Однако верхние восемь бит адреса сигнализируют шине, какое устройство должно иметь доступ к памяти в данный момент. 00 — это BIOS, 02 — основное ОЗУ, 03 — ОЗУ на чипе, и т.д. Однако, поскольку только 8 верхних битов сигнализируют об устройстве, а большинство устройств имеет очень ограниченное (меньше 16 мебибайт) адресное пространство, биты между верхними 8 битами и нижними битами, сигнализирующими об адресе в устройстве, не имеют определённой цели.
Например, основное ОЗУ занимает 256 кибибайт. Это равняется 18 битам адресного пространства. Значит, адреса в этой области памяти находятся в диапазоне от 02000000 до 0203FFFF, а всё от 02040000 до 02FFFFFF остаётся неадресованным. В обычном ARM-устройстве доступ к недопустимым адресам приводит к ошибке data abort (сброс данных). Однако GBA не поддерживает сбросы данных, поэтому происходящее в этот момент особенно интересно. Поскольку верхние 8 бит используются для выбора устройства, а нижние 18 — для адресации в устройстве, посередине остаётся 6 неиспользуемых бит. Эти неиспользуемые биты на самом деле просто игнорируются. Это значит, что при попытке доступа к данным выше действительных областей памяти в основном ОЗУ верхние биты фактически маскируются и остаются снова только области с действительными адресами. В некоторых эмуляторах такое свойство называется «зеркальной» («mirrored») памятью.
Classic NES Series не делает с зеркальной памятью ничего особенного: она копирует код в основное ОЗУ, а затем переходит к одному из этих зеркальных адресов. Такое действие запутывает некоторые эмуляторы, но благодаря особенностям реализации областей памяти оно никогда не вызывало проблем в mGBA. Однако это ещё самый простой трюк, использованный в Classic NES.
Хитрость 2: код во VRAM
Гораздо интереснее дальнейшая работа игр: они начинают копировать данные в видеопамять (VRAM), что само по себе совершенно нормально, но потом выполнение инструкций переносится в эти копированные в видеопамять данные: после копирования кода в область ОЗУ, обычно зарезервированную под графику, он исполняется там. Когда я впервые увидел такое поведение игр, то подумал, что сделал какую-то большую ошибку. Переход к недействительному адресу — это типичный симптом сбоя в работе эмулятора. Обычно это происходит при копировании в исполняемые адреса или память. Но после глубокого исследования я понял, что если позволить игре выполнять код в видеопамяти, сбоя не произойдёт, и работа будет относительно стабильной. Оставались ещё и другие проблемы, но было очевидно, что это тактика для защиты от эмуляции. Использование видеопамяти в операциях, для которых она совершенно точно не предназначена, сначала сбило меня с толку, но когда я позволил играм такую работу, то встретился ещё и с другими проблемами.
Хитрость 3: STM в регистрах DMA
Следующая хитрость — очень нестандартное использование инструкций STM. STM (расшифровывается как «store multiple», множественное хранение) — это класс инструкций, предназначенный для упаковки нескольких регистров центрального процессора в последовательную память. Существует четыре разновидности инструкций STM: последующий декремент, предварительный декремент, последующий инкремент и предварительный инкремент. Описание «последующий декремент» относится к структуре упаковываемой памяти: значения сохраняются поочерёдно, адрес уменьшается на размер слова после сохранения каждого слова. Такая операция напоминает процесс записи в память в порядке декремента. При сохранении значений A, B и C они запишутся в память как CBA, поэтому можно начинать с A, поскольку это исходный адрес, и обрабатывать значения в обратном порядке.
Однако, как писал Мартин Корт (Martin Korth), процессор на самом деле заранее вычисляет, где будет находиться адрес последнего регистра, и записывает значения в том же порядке, как в случае инкремента вместо декремента. Поэтому, несмотря на то, что в результате в памяти будет записано CBA, он сначала записывает C. Эмулятору нужно будет постоянно заранее рассчитывать, сколько регистров будет сохраняться, что может замедлять работу. В целом, порядок записи в память может казаться неважным, особенно для одноядерных процессоров, у которых запись в память можно считать атомарной. Для основного ОЗУ это может быть верным. (Поскольку запись выполняется одной инструкцией, DMA (direct memory access) не может высвободить ЦП посередине процесса записей.) Однако в играх Classic NES Series здесь проделывается хитрый трюк: передача данных DMA за одну инструкцию.
Передачи данных DMA используются для эффективного копирования памяти из одной области в другую, часто или из Game Pak в основное ОЗУ, или из основного ОЗУ в звуковые FIFO-буферы. В области регистров ввода-вывода памяти есть три последовательных регистра на канал DMA (а всего каналов DMA четыре), в которые возможна запись для настройки передачи данных DMA. Обычно игра указывает исходный и конечный адреса двумя отдельными записями 32 бит, затем начинает передачу данных, записывая количество и контрольные биты DMA или одной окончательной записью 32 бит, или двумя записями по 16 бит в каждую половину контрольного регистра.
Но игры Classic NES Series поступают намного умнее: поскольку эти три регистра расположены в памяти последовательно, они используют инструкции STMIA и STMDA для одновременной записи всех трёх значений. STMIA — это самый простой случай: запись в один регистр, инкремент, запись в следующий регистр, инкремент, запись в контрольный регистр, инкремент. STMDA немного отличается: она выполняет декремент, поэтому неосведомлённый об этом эмулятор может записать контрольные биты до адреса, что приводит к неправильной передаче данных DMA. Несмотря на то, что A, B и C записываются как CBA, и начальным адресом является адрес A, A необходимо записывать последней. Мне пришлось использовать вес Хемминга для записываемых регистров и подобрать начальное смещение записи, чтобы порядок работал правильно. После изменения этих операций согласно ожиданиям оборудования передача данных стала выполняться должным образом.
Хитрость 4: сокрытие типов сохранения
Но хитрости на этом не закончились. Следующий трюк не такой сложный, и в некоторых других играх он тоже используется. В картридже Game Boy Advance может быть один или несколько механизмов сохранения. Некоторые игры используют сохранения в NVRAM, которая имеет байтовую адресацию. Они существуют в блоке памяти 0E и могут нормально сохраняться. В других картриджах используется флэш-память в той же области и применяется стандартный протокол для записи байтов во флэш-память или стирания областей для перепрограммирования. Третий тип — это EEPROM, он расположен в верхней части области памяти Game Pak (область 0D). В нём используется протокол битового уровня, применяющий передачу данных DMA для отправки серий битов в EEPROM для программирования. Однако каждая игра может иметь только один тип сохранения, а в заголовке картриджа не указывается, какой тип в нём использован. Некоторые эмуляторы, в том числе и mGBA, пытаются определить тип сохранения, ожидая, пока игра не попробует взаимодействовать с одним из них. Но часть игр, включая и Classic NES Series, обманывает эмуляторы, пытаясь сначала получить доступ к неверному типу. Например, все эти игры используют EEPROM, но притворяются, что в них применяется SRAM. Если они определяют, что выполнена запись в SRAM, то демонстрируют экран Game Pak Error, показанный выше. Эту хитрость довольно просто обойти, и эмулятор заранее проверяет код игры. Если он обнаруживает код, связанный с игрой Classic NES Series, то принудительно меняет тип сохранения на EEPROM.
Хитрость 5: нарушение предварительной выборки
Следующую хитрость этих игр мне оказалось найти сложнее всего. Для полного исследования потребовалось несколько дней, после чего пришлось внести довольно низкоуровневые изменения в базовый цикл эмуляции. Процессоры устройств имеют многоэтапный процесс выполнения инструкций, называемый конвейером. На каждом этапе выполняется отдельная задача, чтобы одна часть ЦП была занята делом, пока другая выполняет свой собственный этап. Конвейер спроектирован таким образом, что после выполнения инструкции на одном этапе и передачи на следующий последующая инструкция может незамедлительно занять уже освободившийся этап. Процессор Game Boy Advance ARM7TMDI имеет три этапа, которые необходимы для точной эмуляции: выборка (инструкций), декодирование и выполнении. На этапе выборки в шину памяти подаётся запрос на память, связанную с инструкцией. Затем она передаётся на этап декодирования, на котором процессор выясняет, что это за инструкция. И, наконец, процессор выполняет инструкцию. Наивный интерпретатор объединит все три этапа, или чтобы ускорить работу, или просто не зная принципов работы процессоров. До недавнего времени в mGBA этапы декодирования и выполнения были объединены. Однако при изучении кода игр Classic NES Series было сделано важное открытие: игра изменяла инструкцию в непосредственной близости от этапа выполнения. Вот ассемблерный код, полученный из видеопамяти Classic NES Metroid.
06000260: E3A01000 mov r1, #0
06000264: E28FE008 add lr, pc, #8
06000268: E51F0010 ldr r0, [$06000260]
0600026C: E58E0000 str r0, [lr, #0]
06000270: E3A010FF mov r1, #255
06000274: E3A010FF mov r1, #255
Работа кода довольно проста. Он сохраняет 0 в регистр r1, затем загружает слово по адресу 06000260 в регистр r0, сохраняет его по адресу 06000274. Потом сохраняет 255 в регистр r1, и, наконец… ну, на самом деле, я немного соврал. Заметьте, что последняя инструкция в этом ассемблерном блоке — это тот же адрес, который сохранён двумя инструкциями ранее. Значение, сохраняемое по этому адресу — это инструкция, сохраняющая в r1 значение 0 вместо 255. Так что же делает этот код? Ответ зависит от длины конвейера.
Самое важное в понимании этого блока кода — осознать, что после передачи инструкции в конвейер изменение памяти, возвращающей этот адрес, неприменимо. Это похоже на принцип целостности кэша, но даже более строгий случай. Это значит, что если конвейер достаточно длинный, то инструкцией, поступающей на конвейер в процессе записи, будет так, которая сохраняет 255. Если конвейер слишком короткий, она будет сохранять 0. Как оказалось, игры не загружаются, если находят значение 0 в регистре r1, и нормально запускаются при значении 255. Осознав это, я должен был расширить эмулируемый в mGBA конвейер и вставить муляж этапа между выполнением и выборкой. В конвейере реального ARM7TDMI между этими двумя этапами есть этап декодирования. Однако я невнимательно прочитал спецификацию и не понял, что этот этап реализован отдельно. После добавления ещё одного этапа конвейера в интерпретатор игры Classic NES Series неожиданно начали работать!
Хитрость 6: неоднородности в звуковом FIFO-буфере
Однако существует одна сложность: хотя в игры можно играть, звук был безнадёжно испорчен. Решение потребовало небольшой отладки, и этот трюк тоже оказался уникальным для Classic NES Series и поэтому из-за неполного совпадений спецификаций реализован немного неправильно. Game Boy Advance имеет шесть аудиоканалов: четыре канала процедурно генерируемого звука (функционально расширенные каналы оригинального Game Boy) и два аудиоканала PCM. Насколько я понял, игры Classic NES Series используют только один канал, один из каналов PCM. Аудиоканалы PCM управляются небольшим внутренним FIFO-буфером, начинающим передачу данных DMA при достижении определённой точки. Игры конфигурируют их для записи 32 бит за раз в регистры ввода-вывода, связанные с каждым каналом. Поскольку каналы PCM шириной всего 8 бит, запись 32 бит на самом деле является четырьмя сэмплами. Но у игр Classic NES Series всё немного по-другому: они записывают всего 16 бит за раз, в половину, а не в целый регистр. Поскольку я предполагал, что игры за раз могут записывать только 32 бита, это приводило к тому, что эмулятор за раз записывал два сэмпла, необходимые игре, и два пустых сэмпла. Этот банальный недосмотр полностью искажал звук в играх. После внесения простого исправления игры стали работать нормально.
Мы достигли успеха, но зачем такие сложности?
Не знаю, зачем Nintendo пошла на всё это ради обычных портов игр для NES. Полнофункциональные эмуляторы NES существовали уже давно, хорошие примеры появились ещё в 1997 году. Хотя в игры Classic NES Series впервые можно было поиграть на портативной консоли, эмуляция Game Boy Advance на других портативных устройствах существовала уже несколько лет. Кроме того, хотя перечисленные мной проблемы помешали эмуляции в некоторых проектах, такая защита применялась только в Classic NES Series. Не понимаю, зачем они приложили столько усилий, чтобы помешать разработчикам эмуляторов в этом конкретном случае, но для меня это вылилось в несколько долгих вечеров последовательного анализа функций кода игры, пока дела не стали совсем плохи.
Однако после устранения всех этих проблем игры запускаются и работают на 100%. Самые актуальные исправления внесены в версию 0.1.0, но некоторые крупные правки отложены на потом. Игры будут полностью поддерживаться в версии 0.2.0 после её выпуска, и в них можно поиграть уже сейчас в nightly-релизах.
Комментарии (0)