Используем Python для извлечения фона из Super Mario Bros
В этой статье мы собираемся зареверсить Super Mario Bros 1985 года, чтобы извлечь изображение фона. Всё будет сделано с помощью одного лишь Python!
8К открытий8К показов
В этой статье мы собираемся зареверсить Super Mario Bros 1985 года, чтобы извлечь изображение фона. Конечно, это всё можно попробовать сделать с помощью компьютерного зрения, однако представленный здесь способ несколько интереснее. Весь исходный код доступен на GitHub.
Анализ исходного кода
Зареверсить любую программу гораздо проще, если у вас есть исходники. В нашем случае они представлены в виде 17000 строк кода 6502 ассемблера (NES CPU). Поскольку Nintendo никогда официально не открывала доступ к исходному коду, пришлось раздобыть его самостоятельно через дизассемблирование машинного кода SMB, кропотливо расшифровывая деталь за деталью и вставляя комментарии и осмысленные имена символов.
Просматриваем файл и находим что-то приблизительно похожее на данные уровня, которые мы ищем:
Если вы не знакомы с ассемблером, то тут говорится вставить эти байты в скомпилированную программу, а затем разрешить другим частям программы ссылаться на них через символ L_GroundArea6
. Можете считать это массивом, в котором каждый элемент является одним байтом.
Первое, что стоит отметить, — здесь представлен очень небольшой объём данных — около 100 байт. Это исключает любую кодировку, позволяющую произвольно размещать блоки на уровне. Просмотрев файл, можно обнаружить, что эти данные на самом деле читаются (после нескольких переходов по ссылкам) в AreaParserCore. Эта подпрограмма вызывает множество других подпрограмм, в итоге вызывая отдельную подпрограмму для каждого типа объектов (из возможных 40), размещённого в сцене (например, StaircaseObject
, VerticalPipe
, RowOfBricks
):
Подпрограмма пишет в MetatileBuffer
— раздел памяти длиной в 13 байт, отображающий колонку блоков, где каждый байт представляет один блок. Метаблок — это блок размером 16×16, который составляет фон в SMB:
Он называется так, потому что состоит из четырёх пиксельных блоков 8×8, но об этом позже.
Тот факт, что декодер работает с предопределёнными объектами, объясняет небольшой размер уровней: данные уровня ссылаются только на типы объектов и их местоположение, например, «разместить трубу на (20, 16), ряд блоков на (10, 5), …». Тем не менее это значит, что для преобразования данных об уровне в метаблоки требуется много кода.
Портировать такие объёмы кода для извлечения уровней слишком долго, поэтому попробуем другой подход.
py65emu
Если бы у нас был интерфейс между Python и ассемблером 6502, мы могли бы вызвать подпрограмму AreaParserCore
для каждой колонки на уровне, а затем использовать более лаконичный Python для конвертации информации о блоке в нужное изображение.
Здесь нам пригодится эмулятор 6502 py65emu. Вот как можно настроить py65emu для такой же конфигурации карты памяти, как на NES:
После этого мы можем выполнять одиночные инструкции с помощью метода cpu.step()
. А ещё мы можем исследовать память с помощью mmu.read()
и проверять регистры машины с помощью cpu.r.a
, cpu.r.pc
и т.д. Кроме того, мы можем записывать в память с помощью mmu.write()
.
Стоит отметить, что это только эмулятор процессора NES: он не эмулирует другие составляющие вроде PPU, поэтому его нельзя использовать для эмуляции целой игры. Тем не менее, этого должно быть достаточно для вызова нужных подпрограмм, так как они не требуют ничего, кроме процессора и памяти.
План состоит в том, чтобы настроить процессор так, как мы это сделали выше, а затем для каждой колонки уровня инициализировать разделы памяти с входными данными, нужными AreaParserCore
. После этого мы вызываем AreaParserCore
и считываем данные колонки. Полученный результат можно будет сконвертировать в изображение с помощью Python.
Но прежде чем мы это сделаем, нам нужно скомпилировать ассемблерный код в машинный.
x816
Как указано в исходниках, код компилируется с помощью x816 — ассемблера 6502, базирующийся на MS-DOS, который используется поклонниками NES и ромхакерами и прекрасно работает в DOSBox.
Наряду с ROM-памятью, требуемой py65emu, x816 создаёт файл символов, сопоставляющий символы с их местоположением в памяти внутри адресного пространства процессора. Вот небольшой отрывок:
Здесь мы видим функцию AreaParserCore
, доступ к которой можно будет получить по адресу 0x93fc
.
Для удобства был создан парсер для символьного файла, который соотносит символьные имена и адреса:
Подпрограммы
Напоминаем, сейчас нашей целью является возможность вызывать подпрограмму AreaParserCore
из Python.
Чтобы понять механику работы подпрограмм, давайте взглянем на небольшую подпрограмму и её вызов:
Инструкция jsr
(jump to subroutine, войти в подпрограмму) помещает регистр ПК в стек и присваивает ему адрес, на который ссылается WritePPUReg1
. Регистр говорит процессору адрес следующей инструкции для загрузки, поэтому после jsr
будет выполнена первая строка WritePPUReg1
.
В конце подпрограммы выполняется инструкция rts
(return from subroutine, вернуться из подпрограммы). Она удаляет из стека сохранённое значение и сохраняет его в регистре, что заставляет процессор выполнять инструкцию, следующую за вызовом jsr
.
Что здорово в подпрограммах, так это возможность использовать вложенные вызовы, то есть подпрограммы, вызывающие подпрограммы. Адреса возврата будут сначала помещены в стек, а затем удалены в правильном порядке — точно так же, как при вызове функций в высокоуровневом языке.
Код для выполнения подпрограммы из Python:
Здесь происходит следующее: сохраняется текущее значение регистра указателя стека (s
), эмулируется вызов jsr
, а затем выполняются инструкции до тех пор, пока стек не вернётся к начальной высоте, что случится только после возвращения первой подпрограммы. Это полезно, так как теперь у нас есть способ вызывать подпрограммы 6502 напрямую из Python.
Однако мы забываем об одной вещи: как передать данные на вход этой подпрограммы? Нам нужно как-то сообщить программе, какой уровень мы пытаемся отобразить или какую конкретно колонку мы хотим пропарсить.
В отличие от высокоуровневых языков вроде C или Python, подпрограммы ассемблера 6502 не принимают входные данные явно. Вместо этого входные данные передаются путём установки значений ячеек памяти в какой-то момент до вызова, которые затем считываются внутри подпрограммы. Учитывая размер AreaParserCore
, зареверсить нужные входные данные таким образом будет сложно и риск ошибки будет велик.
Valgrind для NES?
Чтобы выяснить, где находятся входные данные для AreaParserCore
, почерпнём вдохновение из инструмента memcheck для Valgrind. Memcheck отслеживает обращения к неинициализированному участку памяти путем хранения «теневой» памяти для каждого действительного участка. Теневая память запоминает, было ли что-либо записано на соответствующий участок. Если программа читает из адреса, в который никогда ничего не записывалось, выводится ошибка. Если бы мы могли запустить AreaParserCore
с таким инструментом, это могло бы решить нашу проблему.
На самом деле, написать простой вариант memcheck для py56emu не составляет труда:
Здесь мы оборачиваем блок управления памятью (MMU) py65emu. Этот класс содержит массив _unitialized
, элементы которого говорят нам, записывалось ли что-нибудь в соответствующий байт эмулированной RAM. Когда происходит чтение из неинициализированного участка памяти, его адрес и соответствующее имя символа выводятся на экран.
Вот что выдаёт обёрнутый MMU при вызове execute_subroutine(sym_file['AREAPARSERCORE'])
:
Посмотрев на код, можно заметить, что многие из этих значений устанавливаются подпрограммой InitializeArea
, поэтому давайте перезапустим скрипт сначала с вызовом этой функции. Повторяя процесс, мы в итоге получаем такую последовательность вызовов, где требуется установить только номер мира и номер участка:
Здесь мы записываем первые 48 колонок World 1-1 в metatile_data
, используя подпрограмму IncrementColumnPos
для увеличения внутренних переменных, которые нужны для отслеживания текущей колонки.
Здесь можно увидеть содержимое metatile_data
, наложенное на один из скриншотов из игры (байты с нулевым значением не показаны):
metatile_data
полностью соответствует фону.
Изображения метаблоков
(Вы можете пропустить этот шаг и перейти к конечному результату)
Теперь давайте узнаем, как превратить полученные номера метаблоков в изображения. В получении этой информации помог анализ исходного кода и изучение документации Nesdev Wiki.
Чтобы понять, как отобразить каждый метаблок, сначала нужно узнать о цветовых палитрах на NES. NES PPU может отображать до 64 разных цветов, хотя чёрный повторяется несколько раз (подробности можно почитать здесь):
На каждом уровне Mario для создания фона используется только 10 из 64 цветов , разделённых на четыре цветовые палитры; первый цвет всегда один и тот же. Здесь мы видим палитры для World 1-1:
Посмотрим на номер метаблока, выраженный в двоичной системе. Это номер метаблока треснувшей плитки, по которой бегает герой:
Индекс палитры говорит нам, какую палитру использовать при отображении метаблока. В данном случае это Палитра 1. Индекс палитры также является индексом этих двух массивов:
Вместе эти массивы дают 16-битный адрес, который в нашем случае указывает на Palette1_Mtiles
:
Умноженный на 4 индекс метаблока является индексом этого массива. На каждую строку приходится по 4 записи, наш пример относится к 20 строке, что подтверждает комментарий cracked rock terrain
.
Четыре значения на этой строке по сути являются ID блока: каждый метаблок состоит из четырёх блоков 8х8 в порядке верхний левый, нижний левый, верхний правый, нижний правый. Эти ID отправляются напрямую в PPU NES. ID ссылается на 16 байт данных в CHR-ROM NES, где каждая запись начинается с адреса 0x1000 + 16 * <id блока>
:
CHR-ROM — часть read-only памяти, доступ к которой имеет только PPU. Эта часть отделена от PRG-ROM, где хранится программный код. Таким образом, к указанным выше данным нет доступа из исходного кода и их необходимо извлекать из дампа ROM SMB.
16 байт на каждый блок дают 2-битный блок 8х8: первый бит — первые 8 байт, второй бит — вторые 8 байт:
Соотносим с первой палитрой:
И соединяем:
Вот мы и получили изображение блока.
Складываем всё воедино
Повторяем это действие для каждого метаблока и получаем изображение всего уровня:
Вот мы и извлекли изображение уровня SMB на чистом Python!
8К открытий8К показов