В этой статье я хочу разобрать внутреннее устройство легендарной игры Sonic the Hedgehog для приставки Sega Mega Drive, а также способы ее модификации или, как еще говорят, хакинга. Эта игра насчитывает порядка сотни хаков, включающих как действительно достойные работы (такие как Pana Der Hejhog или Sonic Remastered), так и странные и даже жутковатые (вроде An Ordinary Sonic ROM Hack). Чтобы понять, как их создавать, нужно разобраться, как писать на языке ассемеблера Motorola 68K (обычно игры для приставок тех времен писались именно на ассемблере), откуда взять дизассемблированный вариант игры и какую архитектуру имеет ее движок.


Sonic hacks


Дизассемблирование ROM-файлов для Sega осуществляется при помощи коммерческого дизассемблера и дебаггера IDA Pro. Затем происходит кропотливый процесс разметки, структурирования и причесывания сырого ассемблерного кода с использованием дебаггера (и смекалки). Этот процесс требует хорошего понимания технических особенностей платформы Sega Mega Drive и игр для нее.


К счастью, на GitHub уже есть дизассемблированные и размеченные версии всех игр серии Sonic the Hedgehog, созданные энтузиастами при поддержке сайта Sonic Retro. Лучше всего размечен и структурирован исходный код именно первой игры серии. Эта версия кода находится в репозитории sonicretro / s1disasm и именно она будет разобрана в статье.


Погружение в внутреннее устройство игрушки начнем с теории.


Технический обзор приставки



Sega Mega Drive (известная в США как Sega Genesis) оснащена 32-битным центральным процессором Motorola MC68000 (сокращенно Motorola 68K) и дополнительным звуковым сопроцессором Zilog Z80 (взамодействие с Z80 происходит через общую память). Объем оперативной памяти (RAM) – 64K. Разрешение экрана в основном режиме (в американской версии) – 320x224 пикселей.


Использованный процессор Motorola 68K в свое время был достаточно распространен. Этот чип применялся в самых разных системах, от популярных домашних компьютеров и игровых приставок до космических шаттлов. Одна из модификаций Motorola 68K даже была установлена в легендарном Apple Macintosh.


Графическая подсистема Mega Drive основана на видеоконтроллере Yamaha YM7101 и поддерживает аппаратную работу с двумя слоями фона и отрисовку до 80 спрайтов поверх них. Подробнее графика в игре будет разобрана далее; прочитать о графике в Sega Mega Drive отдельно можно в статье "Как работала графическая система Sega Mega Drive: Video Display Processor".


Для сборки игры используется макроассемблер AS. Набор инструкций этой платформы совершенно не сложный и содержит всего 82 инструкции. Для сравнения: по подсчетам пользователя ResearchGate современный Intel Core i7 имеет 338 инструкций.


Процессор имеет восемь 32-битных регистров общего назначения: D0D7. Все эти регистры активно используются в играх для хранения промежуточных данных и в качестве операндов арифметических операций. Также существует восемь специальных адресных регистров A0A7. Адресные регистры оптимизированы для хранения указателей на какие-либо объекты в памяти и их использование в некоторых операциях невозможно. Последний адресный регистр A7 по совместительству работает указателем стека и имеет алиас SP.


Большинство инструкций могут работать в одном из трех режимов разрядности. Суффикс .l (long) обозначает работу в 32-битном режиме. При использовании суффиксов .w (word) и .b (byte) процессор будет работать только с младшими 16-ю или 8-ю битами каждого из операндов соответственно.


Разберем основные инструкции Motorola 68K.


Операция копирования


move – скопировать данные из источника в приемник.


Примеры:


  • move.l #48, d4 – поместить десятичное число 48 в регистр d4.
  • move.w d5, d6 – скопировать младшие 16 бит регистра d5 в регистр d6.
  • move.w #$12FF, obStatus(a0) – поместить шестнадцатеричное число 12FF по адресу в памяти, заданному константой obStatus со смещением, заданным в регистре a0.

Как видно из примеров выше, для записи десятичных чисел в этом ассемблере используется префикс #. Для записи шестандатиричных числе используется префикс #$.


При записи операнда в скобках вычисление происходит со значением по такому адресу в памяти:


  • move.l #5, (a0) – поместить десятичное число 5 по адресу в памяти, хранящемуся в регистре a0.
  • move.l (a1), d2 – поместить значение по адресу, хранящемуся в регистре a1, в регистр d2.

Такой режим адресации возможен только для адресных регистров (a*).


Арифметические операции


  • add – прибавить значения источника к значению приемника.
  • sub – отнять значение источника от значения приемника.
  • mulu – беззнаковое умножение; muls – знаковое умножение.
  • divu – беззнаковое деление; divs – знаковое деление.

Примеры:


  • add.b #$08, d0 – прибавить шестнадцатеричное число 08 к значению регистра d0.
  • sub.w (v_screenposx).w, d1 – разделить значение в регистре d1 на значение в памяти по адресу v_screenposx (в 16-разрядном режиме).
  • mulu.w #10, d0 – умножить значение в регистре d0 на 10 (число в регистре d0 считать беззнаковым).
  • divs.w #$68, d2 – целочисленно разделить значение в регистре d2 на шестнадцатеричное число 68 (число в регистре d2 считать знаковым).

Операции управления потоком выполнения


  • jmp, bra – безусловный переход.
  • jsr, bsr – вызов подпрограммы, rts – возврат из подпрограммы (аналоги call и ret в x86).

Примеры:


jmp .foo  ; безусловный переход на метку .foo
nop       ; данная пустая операция (nop) не будет выполнена
.foo:     ; метка .foo

SubRoutine:  ; объявление подпрограммы SubRoutine
nop
rts          ; возврат из подпрограммы

bsr SubRoutine  ; вызов подпрограммы SubRoutine

Операции ветвления


Для выполнения условных переходов в процессоре 68K используется регистр CCR (Condition Code Register). Инструкции cmp, tst и btst позволяют выставить биты (флаги) этого регистра, которые затем используются в операциях условного перехода beq, bne, bge, ble и других.


  • cmp – сравнить значения.
  • tst – сравнить значение с нулем.
  • btst – сравнить заданный бит с нулем.
  • beq/bne – перейти, если сравниваемые значения были равны/не равны.
  • bge/ble – перейти, если второе сравниваемое значение было больше/меньше первого.

Примеры:


cmp.w   #32,d0  ; сравнить d0 и 32
bge.s   .foo    ; если d0 > 32, перейти на метку .foo

btst    #0,d0  ; сравнить нулевой (младший) бит d0 с нулем
bne.s   .foo   ; если бит не равен нулю, перейти на метку .foo

Более подробно изучить команды Motorola 68K вам поможет отличный мануал (с ужасным фоном и шрифтами) автора Марки Джестера, где каждая из команд разобрана максимально подробно.


Сборка игры


Репозиторий s1disasm содержит Python-скрипт, автоматически запускающий нужную версию ассемблера для текущей операционной системы со всеми необходимыми флагами. Также этот скрипт выполняет специфическую для игры Sonic the Hedgehog операцию "Kosinski compression", которая сжимает карты уровней и другие бинарные данные (чтобы они поместились в память картриджа).


Все, что нужно сделать пользователю, это перейти в git-ветку AS (git checkout AS) и выполнить команду:


./build.py

Результатом выполнения скрипта должен стать готовый ROM-файл игры с названием s1built.bin. Этот файл можно запустить в вашем любимом эмуляторе Sega Mega Drive. Для macOS, например, рекомендуется использовать замечательный OpenEmu.


Архитектура движка игры


Начнем обзор с основной точки входа для сборки игры – файла sonic.asm. В нем находятся процедуры инициализации: ожидание готовности сопроцессора Zilog Z80 (WaitForZ80), установки параметров видеопроцессора (VDPSetupGame) и проверка контрольной суммы. После инициализации игра выполняет подпрограмму GameInit и переходит в главный цикл MainGameLoop, задачей которого является считывание переменной глобального игрового режима и запуск соответствующему ему кода.


Полный список глобальных переменных, используемых игрой вынесен в файл Variables.asm. Каждая переменная представляет собой константу со ссылкой на адрес в RAM, где должно храниться значение переменной.


Глобальный игровой режим хранится в переменной v_gamemode. Список глобальных игровых режимов включает в себя:


  • 00 – экран "Sega",
  • 01 – титульный экран с Соником,
  • 08 – демо,
  • 0C – уровень,
  • 10 – special stage,
  • 14 – экран "Continue",
  • 18 – финальная заставка,
  • 1C – финальные титры,
  • 8C – титр уровня.

В строке move.b #id_Sega,(v_gamemode).w подпрограммы GameInit в переменную v_gamemode заносится режим, в котором игра должна стартовать. Если заменить id_Sega на id_Title и собрать игру командой ./build.py, то мы получим первый рабочий хак, в котором вместо отображения экрана "Sega" игра сразу приветствует нас титульным экраном, что может сократить время дебага.


Переменная v_gamemode определяет подпрограмму для главного цикла, которая должна исполняться в данный момент. Например, в режиме id_Title приставка будет исполнять подпрограмму GM_Title, а в режиме id_Level – GM_Level.


Игровые параметры


Многие переменные, объявленные в файле Variables.asm представляют интерес для хакинга. Рассмотрим для примера v_sonspeedmax, v_sonspeedacc и v_sonspeeddec.


Изменяя значения, помещаемые в эти переменные в подпрограмме Sonic_Main, можно изменять динамические характеристики перемещения Соника: максимальная скорость, значение ускорения и торможения соответственно, получая интересные результаты:


move.w  #$600,(v_sonspeedmax).w  ; Sonic's top speed
move.w  #$C,(v_sonspeedacc).w    ; Sonic's acceleration
move.w  #$80,(v_sonspeeddec).w   ; Sonic's deceleration

Дробные переменные хранятся в формате с фиксированной точкой, поэтому для получения реальных значений их необходимо разделить на 256. Так, ускорение Соника составит 0xC / 256 = 0.046875, а торможение – 0x80 / 256 = 0.5 (пикселей на игровой цикл в квадрате).


Графика


Графикой в Mega Drive занимается тайловый графический процессор Sega 315?5313 (Video Display Processor, VDP). Конфигурирование VDP производится с помощью регистров, запись в которые производится через специальные адеса в памяти vdp_data_port и vdp_control_port. Изначальная конфигурация процессора устанавливается в подпрограмме VDPSetupGame, которая берет параметры по адресу VDPSetupArray. Однако, в каждом из глобальных игровых режимов, некоторые регистры выставляются повторно. Например, на игровых уровнях это делает подпрограмма GM_Level. Подробное описание функций всех регистров VDP приведено в вики Sega Retro.


Для примера приведем скриншот игры с включенным режимом Low Color (нулевой бит регистра Mode Register 1 выставлен в ноль):



Video Display Processor позволяет аппаратно работать с двумя фоновыми слоями – background (слой B) и foreground (слой A), а также со слоем спрайтов, которые отображаются поверх фона. Фоновые слои собираются из тайлов 8x8 пикселей с помощью карт тайлов. Спрайты также собираются из тайлов; максимальный размер спрайта – 4x4 тайла. Таким образом, максимальный размер аппаратного спрайта составляет 32x32 пикселя.


Удаление кода из подпрограмм работы с фоновыми слоями (LoadTilesAsYouMove, DrawChunks) и спрайтами (BuildSprites) позволяет понять, какие из игровых объектов к какому слою относятся:



Как видно из названия, подпрограмма LoadTilesAsYouMove занимается подгрузкой тайлов на фоновые слои по мере продвижения игрока по уровню. В игре Sonic the Hedgehog размер обоих тайловых плоскостей составляет 64x32 тайла или 512x256 пикселей.


Так выглядит изменяющийся foreground-слой на уровне Green Hill Zone, GIF-анимация, 5 Мб:

Графический процессор также позволяет устанавливать не только общее смещение фонового слоя, но и смещение его отдельных горизонтальных рядов тайлов, используя так называемую таблицу скроллинга. Эта возможность позволяет перемещать удаленные элементы фона медленнее, чем близкие. Таким образом создается эффект параллакса, который имитирует 3D-графику и придает сцене объем. Этот эффект используется почти во всех уровнях Sonic the Hedgehog.


Эффект параллакса в фоновом слое уровня Marble Zone, GIF-анимация, 1 Мб:

Игровые объекты


Движок игры выделяет 8192 байта в RAM на хранение состояния динамических объектов сцены. Вся эта информация хранится по смещению v_objspace. К динамическим объектам относится все объекты на уровне, кроме стен и пола. Примеры: Соник, враги Соника, мониторы с бонусами, кольца, пружины, Босс и так далее. При необходимости объекты отрисовывают себя в слое спрайтов при помощи подпрограммы DisplaySprite.


Sonic game objects


Размер данных состояния игрового объекта статичен и составляет 64 байта. Эта информация формирует структуру, поля которой могут быть получены с использованием макросов, объявленных в файле Constants.asm. Адрес структуры данных текущего обрабатываемого игрового объекта обычно заносится в регистр a0. Таким образом, данные игрового объекта могут быть считаны так:


  • ObX(a0), ObY(a0) – текущие координаты объекта в пикселях.
  • ObVelX(a0), ObVelX(a1) – текущая скорость объекта в 1/256 пикселя за шаг.
  • obHeight(a0), obWidth(a0) – высота и ширина объекта.
  • obSubtype(a0) – подтип объекта (пример: тип бонусного монитора).
  • obStatus(a0) – байт с флагами состояния объекта.
  • obRoutine(a0) – номер текущей подпрограммы объекта.

Отметим, что система координат в игре типична для экранной графики и имеет ось X направленную вправо, и ось Y, направленную вниз.


Подпрограмма ExecuteObjects вызывается на каждом шаге главного цикла уровня. Она последовательно вызывает программный код каждого из игровых объектов, присутствующих на сцене. Список указателей на программный код каждого из объектов задан в таблице в файле Object Pointers.asm. Программный код большей части объектов вынесен в отдельный файл в каталоге _incObj.


Многие из объектов имеют по несколько подпрограмм, которые соответствуют разным этапам жизни объекта (например, только что созданный объект, объект в рабочем состоянии, "предсмертное" состояние). В этом случае номер подпрограммы, которую необходимо выполнять сейчас, хранится в поле obRoutine.


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


Хак: no-ring challenge


В качестве примера реализуем довольно простой хак, который может понравиться самым ярым фанатам игры: no-ring challenge. Этот хак предлагает пройти игру без золотых колец. В результате, каждая атака Соника будет сразу убивать его и заставлять игрока проходить уровень заново (либо с точки сохранения).


Перейдем в главную подпрограмму объекта "Кольцо" – Ring_Main и добавим в самое ее начало простейшую инструкцию прыжка (bra) на процедуру удаления кольца:


Ring_Main:  ; Routine 0
    bra.w   Ring_Delete ; удалить все кольца сразу по созданию

    lea (v_objstate).w,a2
    moveq   #0,d0
    move.b  obRespawnNo(a0),d0
    ; ...

С бонусными мониторами поступим так же. Главная подпрограмма объекта "Монитор" находится в файле 26 Monitor.asm. Тип монитора хранится в поле obSubtype размером 1 байт. Экспериментальным методом выяснено, что значение для монитора с кольцами равно 6. Добавим простую проверку типа монитора с помощью инструкции cmp и условный прыжок beq на процедуру его удаления в случае, если его тип равен шести:


Mon_Main:   ; Routine 0
    cmp.b   #6,obSubtype(a0)  ; монитор с кольцами?
    beq     DeleteObject      ; если да, удаляем объект

    addq.b  #2,obRoutine(a0)
    move.b  #$E,obHeight(a0)
    ; ...

Можно убедиться, что в получившемся ROM'е кольца и бонусные мониторы с кольцами на уровнях будут отсутствовать, что усложнит прохождение. Патч целиком можно посмотреть на GitHub. Туда же выложен готовый бинарник на случай, если кто-то захочет поиграть в такую версию игры, не разбираясь со сборкой.


Хак: притягивание колец


В первой и второй играх серии бонусный щит только защищает игрока, но при этом не имеет никаких дополнительных функций. Попробуем его улучшить и добавить к нему возможность электрического щита из Sonic the Hedgehog 3 – притягивание колец.


GIF-анимация, иллюстрирующая действие электрического щита, 1 Мб:

Алгоритм Sonic the Hedgehog 3


Алгоритм, использующийся для этого эффекта, подробно описан в вики Sonic Retro. Если персонаж, обладающий электрическим щитом, находится ближе, чем в 64 пикселях от кольца по каждой из осей, кольцо переходит в режим намагниченности и начинает движение. В дальнейшем этот флаг больше не снимается с кольца. В режиме намагниченности кольцо ускоряется на 0.1875 за каждый шаг в сторону игрока, если оно уже движется в нужную сторону по данной оси. Если же кольцо движется от игрока, ускорение составляет 0.75.


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


Реализация алгоритма


Изучим подробнее исполняемый код объекта "Кольцо", который находится в файле 25 & 37 Rings.asm. Список подпрограмм объекта:


Ring_Index:
ptr_Ring_Main:      dc.w Ring_Main-Ring_Index
ptr_Ring_Animate:   dc.w Ring_Animate-Ring_Index
ptr_Ring_Collect:   dc.w Ring_Collect-Ring_Index
ptr_Ring_Sparkle:   dc.w Ring_Sparkle-Ring_Index
ptr_Ring_Delete:    dc.w Ring_Delete-Ring_Index

Кольцо имеет несколько жизненных этапов: Main – инициализация; Animate – обычное висение с анимацией вращения; Collect – момент сбора Соником; Sparkle – анимация искр при сборе Соником; Delete – удаление кольца. Для того, чтобы изменить поведение кольца в обычном его состоянии, смотрим в сторону подпрограммы ptr_Ring_Animate.


Для хранения состояния намагниченности кольца отлично подойдет однобайтное поле obStatus. Анализ кода кольца показывает, что в этой версии игры флаги состояния для этого объекта не используются. Назначим нулевой бит obStatus (наиболее младший) ответственным за хранение флага намагниченности. Установка отдельного бита ячейки памяти будет возможна с помощью инструкции bset, а проверка, установлен ли он – с помощью инструкции btst.


Добавим в начало подпрограммы Ring_Animate код, устанавливающий флаг намагниченности в случае, если игрок ближе к кольцу, чем на 64 пикселя по каждой оси:


Ring_Animate: ; Routine 2
    tst.b   (v_shield).w  ; у Соника есть щит?
    beq.s   .animate      ; если нет, пропускаем

.dist_from_sonic:
    ; определяем расстояние до Соника
    move.w  (v_player+obX).w,d0  ; считываем позицию игрока по X в d0
    sub.w   obX(a0),d0           ; d0 = расстояние по X
    move.w  (v_player+obY).w,d1  ; считываем позицию игрока по Y в d1
    sub.w   obY(a0),d1           ; d1 = расстояние по Y

.check_magnetised:
    ; проверяем, не установлен ли уже флаг намагниченности для кольца
    btst    #0,obStatus(a0)  ; нулевой бит obStatus равен 0?
    bne.s   .attract         ; не равен => флаг установлен, идем на .attract

.check_near_x:
    ; флаг намагниченности не установлен, проверяем расстояния
    cmp.w   #64,d0    ; дистанция по X > 64?
    bge.s   .animate  ; если да, пропускаем
    cmp.w   #-64,d0   ; дистанция по X < -64?
    ble.s   .animate  ; если да, пропускаем

.check_near_y:
    cmp.w   #64,d1    ; дистанция по Y > 64?
    bge.s   .animate  ; если да, пропускаем
    cmp.w   #-64,d1   ; дистанция по Y < -64?
    ble.s   .animate  ; если да, пропускаем

    ; кольцо внутри квадрата 64x64, устанавливаем флаг намагниченности
    bset    #0,obStatus(a0)

.attract:
    ; здесь должен быть алгоритм притягивания кольца

.animate:
    ; ...

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


if sign(obVelX) == sign(distX):

Для простой реализации такого сравнения вспомним, что способ хранения знаковых целых чисел в большинстве процессоров (включая Motorola 68K) подразумевает возможность определить знак числа простым считыванием самого старшего бита, причем 0 будет обозначать положительное число, а 1 – отрицательное. Проверить равенство битов в байте можно с помощью операции исключающего "или" – XOR (eor на Motorola 68K).


Как уже было отмечено выше, дробные значения (включая скорость игровых объектов) хранятся в формате с фиксированной точкой, поэтому необходимые значения ускорения (0.1875 и 0.75) необходимо домножить на 256 (получив 48 и 192 соответственно).


Реализуем процедуру притягивания колец:


.attract:
    ; рассчитываем ускорение кольца по X
    move.w #48,d4          ; записываем в d4 ускорение 48
    move.w  obVelX(a0),d3  ; считываем скорость кольца по X в d3
    eor.w   d0, d3         ; сравниваем знаки расстояния и скорости кольца
    btst    #$F,d3         ; если 15-й бит равен 1 (знаки равны)...
    beq.s   .x_towards     ; идем на x_towards
    move.w #192,d4         ; знаки не равны, записываем в d4 ускорение 192

.x_towards:
    ; если необходимо лететь влево, применяем унарный минус (neg) к ускорению (d4)
    cmp.w   #0,d0
    bge.s   .attract_x
    neg d4

.attract_x:
    ; прибавляем значение ускорения по X к скорости по X
    add.w   d4,obVelX(a0)

    ; повторяем аналогичную операцию для оси Y:
    move.w #48,d4
    move.w  obVelY(a0),d3
    eor.w   d1,d3
    btst    #$F,d3
    beq.s   .y_towards
    move.w #192,d4

.y_towards:
    cmp.w   #0,d1
    bge.s   .attract_y
    neg d4

.attract_y:
    add.w   d4,obVelY(a0)

.animate:
    ; ...

Для того, чтобы кольцо действительно осуществляло движение согласно установленным скоростям, необходимо добавить вызов упомянутой выше подпрограммы SpeedToPos:


.animate:
    jsr (SpeedToPos).l

Собираем итоговый ROM игры, запускаем и — вуаля — фича из Sonic 3 доступна в Sonic 1!


GIF-анимация результата, 3.5 Мб:

На GitHub можно просмотреть патч целиком, а также скачать получившийся бинарник.


Заключение


Эта статья является лишь базовым разбором методов модификации игры. Статья не рассматривает инструменты для редактирования уровней (SonED2, Chaos), создание новых персонажей, игровых объектов и механик. Также не рассмотрен симулятор/отладчик Motorola 68K EASy68K, который может помочь более пристально разобраться, как работает процессор Sega.


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


Ссылки