Привет всем,
Не смотря на мой большой опыт в реверсе игр под Sega Mega Drive
, крякмисов под неё я никогда не решал, да и не попадались они мне на просторах интернета. Но, на днях появился забавный крэкми, который захотелось решить. Делюсь с вами решением...
Описание
Описание задания и сам ром можно скачать здесь: https://github.com/zznop/jump
Не смотря на то, что в списке ресурсов там говорится про Гидру, стандартом де-факто среди инструментов для отладки и реверса игр на Сегу является Smd Ida Tools. В нём есть всё необходимое для решения данного крекми:
- Загрузчик ромов для Иды
- Отладчик
- Просмотр и изменение памяти RAM/VDP
- Отображение практически полной информации по VDP
Закидываем в плагины к Иде последний релиз, и начинаем смотреть, что у нас имеется.
Решение
Запуск любой игры на Сегу начинается с выполнения вектора Reset
. Указатель на него можно найти во втором DWORD-е от начала рома.
Видим парочку неопознанных функций начиная с адреса 0x27A
. Давайте взглянем что там.
sub_2EA()
По своему опыту скажу, что так обычно выглядит функция ожидания завершения VBLANK
-прерывания. Посмотрим, где ещё есть обращения к переменной byte_FF0026
:
Видим, что нулевой бит как раз устанавливается в прерывании VBLANK
. Значит переменную назовём vblank_ready
, а функцию, где она проверяется — wait_for_vblank
.
sub_60E()
Далее по коду вызывается функция sub_60E
. Посмотрим, что там:
То, что записывается первой командой в адрес VDP_CTRL
— это команда управления VDP
. Чтобы узнать, что же она делает, становимся на эту команду, и нажимаем клавишу J
:
Видим, что инициализируется запись в CRAM
(место, где хранятся палитры). Значит, весь последующий код функции просто задаёт какую-то начальную палитру. Соответственно, функцию можно назвать init_cram
.
sub_71A()
Видим, что снова передаётся какая-то команда в VDP_CTRL
, значит снова жмём J
и узнаём, что это команда инициализирует запись в видеопамять:
Далее разбираться, что же там передаётся в видео-память, смысла нет. Поэтому просто обзываем функцию load_vdp_data
.
sub_C60()
Здесь происходит практически то же самое, что и в предыдущей функции, поэтому, не вдаваясь в подробности, просто назовём функцию load_vdp_data2
.
sub_8DA()
Тут уже кода побольше. И, к тому же, в этой функции вызывается ещё одна. Заглянем сразу туда — в sub_D08
.
sub_D08()
Видим, что в регистре D0
приходит команда для VDP_CTRL
, в D1
— значение, которым будет заполняться VRAM
, а в D2
и D3
— ширина и высота заполнения (т.к. получается два цикла: внутренний и внешний). Обзываем функцию fill_vram_by_addr
.
sub_8DA()
Возвращаемся в предыдущую функцию. Раз значение в регистре D0
передаётся как команда для VDP_CTRL
, нажмём на значении клавишу J
. Получим:
Опять же, из опыта реверса игр на Сегу могу сказать, что эта команда инициализирует запись маппинга тайлов. Адреса, которые начинаются на $Fxxx
, $Exxx
, $Dxxx
, $Cxxx
в 90% случаев будут адресами регионов с этими самыми маппингами. Что такое маппинги:
это такие значения, которыми можно указывать, куда выводить тот или иной тайл на экране (тайл — это квадрат из пикселей размером 8x8
).
Значит функцию можно назвать как init_tile_mappings
.
sub_CDC()
Первая же команда инициализирует запись по адресу $F000
. Одно замечание: среди адресов "маппингов", есть ещё регион, где хранится таблица спрайтов (это их позиции, тайлы, на которые они указывают и т.д.) Узнать, какой регион за что отвечает можно будет под отладкой. Но пока нам это не нужно, поэтому назовём функцию просто init_other_mappings
.
Также, видим, что в этой функции инициализируются две переменные: word_FF000A
и word_FF000C
. Из своего опыта (да, он решает) скажу, что если какие-то две переменные находятся рядом в адресном пространстве и связаны с маппингами, то это в большинстве случаев будут координаты какого-то объекта (например, спрайта). Поэтому, предлагаю обозвать их как sprite_pos_x
и sprite_pos_y
. Ошибка в x
и y
допустима, т.к. дальше под отладкой это легко будет исправить.
VBLANK
Так как дальше по коду идёт цикл, то можно предположить, что основную инициализацию мы закончили. Теперь можно посмотреть на VBLANK
-прерывание.
Видим, что инкрементируются две переменные (что странно, в списке ссылок на каждую из них абсолютно пусто). Но, раз они обновляются раз в кадр, можно назвать их timer1
и timer2
.
Далее вызывается функция sub_2FE
. Посмотрим, что там:
sub_2FE()
А там — работа с IO_CT1_DATA
портом (отвечает за первый джойстик). В регистр A0
грузится адрес порта, и передаётся в функцию sub_310
. Переходим туда:
sub_310()
Мой опыт снова мне помогает. Если вы видите код, который работает с джойстиком, и две переменные в памяти, значит одна хранит pressed keys
, а вторая — held keys
, т.е. нажатые только что и удерживаемые клавиши. Так и обзовём эти переменные: pressed_keys
и held_keys
. А функцию тогда можно назвать как update_joypad_state
.
sub_2FE()
Обзываем функцию как read_joypad
.
Цикл обработчика
Теперь всё выглядит куда понятнее:
Значит этот цикл реагирует на нажатые клавиши, и выполняет соответствующие им действия. Пройдёмся по каждой из вызываемых в цикле функций.
sub_4D4()
Кода здесь много. Начнём с первой вызываемой функции: sub_60C
.
sub_60C()
Она ничего не делает — так может показаться сначала. Просто возврат из текущей функции — rts
. Но, т.к. на неё происходят только прыжки (bsr
), значит rts
вернёт нас обратно в цикл обработчика. Я бы назвал эту функцию как retn_to_loop
.
sub_4D4()
Далее видим обращение к переменной word_FF000E
. Она нигде, кроме текущей функции не используется и, поначалу, назначение мне её было не понятно. Но, если присмотреться, можно предположить, что эта переменная нужна лишь для небольшой задержки между обработкой нажатых клавиш. (Она и так плохо реализована в этом роме, но, думаю, без этой переменной было бы куда хуже).
Далее у нас идёт большое количество кода, который как-то обрабатывает переменные sprite_pos_x
и sprite_pos_y
, что может говорить только об одном — это нужно для отображения спрайта выделения вокруг выделенного в алфавите символа.
Значит теперь можно смело назвать функцию как update_selection
. Идём дальше.
Код проверяет, установлены ли биты каких-то нажатых клавиш, и вызывает определённые функции. Посмотрим на них.
sub_D28()
Какая-то шаманская магия. Сначала из переменной word_FF0018
берётся WORD
, затем происходит выполнение одной интересной инструкции:
bsr.w *+4
Эта команда просто прыгает на следующую за ней инструкцию.
Далее — ещё одна магия:
move.l d0,(sp)
rts
Значение в регистре D0
кладётся на вершину стека. Тут стоит отметить, что у Сеги, как и у какого-нибудь x86
, адрес возврата из функции при её вызове кладётся на вершину стека. Соответственно, первая инструкция кладёт на вершину какой-то адрес, а вторая — поднимает его со стека и совершает по нему переход. Хороший трюк.
Теперь нужно понять, что это за значение в переменной, по которому потом происходит переход. Но, для начала назовём эту переменную как jmp_addr
.
А функции назовём функции так:
sub_D38
:goto_to_d0
sub_D28
:jump_to_var_addr
jmp_addr
Выясним, где эта переменная заполняется. Смотрим список референсов:
Существует лишь одно место записи в эту переменную. Посмотрим на него.
sub_3A4()
Здесь, в зависимости от координаты спрайта (помним, что это скорее всего адрес выделенного символа, заносится то или иное значение. Видим следующий участок кода:
Имеющееся значение сдвигается вправо на 4 бита, в младший байт помещается новое значение, и результат заносится в переменную снова. В теории, наша переменная jmp_addr
хранит те символы, которые мы можем вводить на экране ввода ключа. Заметим также, что размер переменной — WORD
.
По сути, функцию sub_3A4
можно назвать как update_jmp_addr
.
sub_414()
Теперь у нас осталась всего одна функция в цикле, которая не распознана. И называется она sub_414
.
Код её напоминает код функции update_jmp_addr
, только в конце у нас происходит вызов функции sub_45E
. Заглянем туда.
sub_45E()
Видим, что в регистр D0
заносится число #$4B1E2003
, которое затем отправляется в VDP_CTRL
, а это значит, что мы имеем дело с ещё одной командой управления VDP
. Жмём J
, получаем команду записи в регион с маппингами $Cxxx
.
Далее по коду происходит работа с переменной byte_FF0014
, которая нигде, кроме текущей функции не используется. Если присмотреться, как она используется, можно заметить, что максимальное число, которое в ней может установиться, это 4
. У меня такое предположение, что эта текущая длина введённого ключа. Давайте это проверим.
Запускаем отладчик
Я воспользуюсь отладчиком из Smd Ida Tools
, но, по сути, достаточно будет и какого-нибудь Gens KMod, или Gens ReRecording. Главное, чтобы была фича с отображением адресов в памяти.
Моя теория подтвердилась. Значит переменную byte_FF0014
теперь можно обозвать key_length
.
Есть ещё одна переменная: dword_FF0010
, которая так же используется только в текущей функции, и её содержимое, после сложения с начальной командой в D0
(напоминаю, это было число #$4B1E2003
) отправляется в VDP_CTRL
. Долго не думая, я назвал переменную add_to_vdp_cmd
.
Так что же делает эта функция? У меня есть предположение, что она отрисовывает введённый символ. Проверить это просто — запустив отладчик, и сравнив состояние до вызова функции sub_45E
и после:
До:
После:
Я был прав — эта функция отрисовывает введённый символ. Назовём её do_draw_input_char
, а функцию, которая её вызывает (sub_414
) — draw_input_char
.
Что теперь?
Давайте пока проверим, что переменная, которую мы назвали jmp_addr
действительно хранит введённый ключ. Воспользуемся теми же Memory Watch
:
Как видим, догадка была верна. Что нам это даёт? Мы можем прыгать на любой адрес. Только на какой? В списке функций все разобраны ведь:
Тогда я начал просто прокручивать код, пока не обнаружил вот такое:
Намётанный глаз увидел в конце неразмеченных байт последовательность $4E, $75
. Это опкод инструкции rts
, т.е. возврата из функции. Значит эти неразмеченные байтики могут быть кодом какой-то функции. Попробуем их обозначить как код, жмём C
:
Очевидно, это код функции. Можно также нажать на нём P
, чтобы код стал функцией. Запомним это имя: sub_D3C
.
Тут возникает мысль: а что если прыгнуть на sub_D3C
? Звучит неплохо, правда одного прыжка сюда явно будет недостаточно, т.к. на переменную word_FF0020
ссылок больше не нашлось.
Тогда меня посетила ещё одна мысль: а что если поискать другой такой неразмеченный код? Открываем диалог Binary search
(Alt+B), вводим в нём последовательность 4E 75
, ставим галку Find all accurrences
:
Жмём ОК
, чтобы начать поиск, получаем следующие результаты.
Как минимум ещё два места в роме могут содержать код функции, нужно их проверить. Кликаем по первому из вариантов, прокручиваем чуть вверх, и снова видим последовательность неопределённых байт. Обозначим их как функция? Да! Жмём P
там, где начинаются байты:
Круто! Теперь у нас есть функция sub_34C
. Пробуем повторить то же самое ещё и с последним из найденных вариантов, и… получаем облом. Там такое большое количество байт перед 4E 75
, что не понятно, где начинается функция. И, явно, не всех из этих байт выше являются кодом, т.к. очень много повторяющихся байт.
Определяем начало функции
Нам будет проще всего найти начало функции, если мы найдём, где заканчиваются именно данные. Как это сделать? На самом деле совершенно не сложно:
- Крутим до начала данных (там будет ссылка на них из кода)
- Переходим по ссылке и ищем цикл, в котором должен будет фигурировать размер этих самых данных
- Размечаем массив
Итак, выполняем первый пункт...:
… и сразу видим, что в цикле из нашего массива копируется по 4 байта данных за раз (потому что move.l
) в VDP_DATA
. Рядом видим число 2047
. Может сначала показаться, что итоговый размер массива 2047 * 4
, но цикл на основе dbf
выполняется на +1
итерацию больше, т.к. последнее сравниваемое значение не 0
, а -1
.
Итого: размер массива равен 2048 * 4 = 8192
. Обозначим байты как массив. Для этого жмём *
и указываем размер:
Крутим в конец массива, и видим там байты, которые будут именно байтами кода:
Теперь у нас появилась функция sub_2D86
, и у нас есть всё, чтобы решить этот крекми! Посмотрим, что делает новоиспечённая функция.
sub_2D86()
А она всего лишь заносит в регистр D1
значение #$4147
и вызывает функцию sub_34C
. Взглянем на неё.
sub_34C()
Видим, что здесь вычитывается значение переменной word_FF0020
. Если посмотреть ссылки на неё, то увидим ещё одно место, где как раз происходит запись в эту переменную, и это будет как раз то место, куда я хотел прыгать через переменную jmp_addr
. Это подтверждает догадку, что прыгать на sub_D3C
точно нужно.
А вот происходящее далее мне стало лень понимать, поэтому я закинул ром в GHIDRA, нашёл эту функцию, и посмотрел декомпилированный код:
void FUN_0000034c(void)
{
ushort in_D1w;
short sVar1;
ushort *puVar2;
if (((ushort)(in_D1w ^ DAT_00ff0020 ^ 0x5e4e) == 0x5a5a) &&
((ushort)(in_D1w ^ DAT_00ff0020 ^ 0x4a44) == 0x4e50)) {
write_volatile_4(0xc00004,0x4c060003);
sVar1 = 0x22;
puVar2 = &DAT_00002d94;
do {
write_volatile_2(VDP_DATA,in_D1w ^ DAT_00ff0020 ^ *puVar2);
sVar1 = sVar1 + -1;
puVar2 = puVar2 + 1;
} while (sVar1 != -1);
}
return;
}
Видим, что используется переменная со странным именем in_D1w
, а ещё переменная DAT_00ff0020
, которая адресом своим напоминает упомянутую ранее word_FF0020
.
in_D1w
говорит нам, что это значение берётся из регистр D1
, а точнее из его младшей WORD-половины, а устанавливает регистр D1
функция, которая его вызывает. Помните #$4147
? Значит нужно обозначить данный регистр, как входной аргумент функции.
Для этого в окне с декомпилированным кодом жмём правой кнопкой мыши на имени функции, и выбираем пункт меню Edit Function Signature
:
Для того, чтобы указать, что функция принимает аргумент через конкретный регистр, а именно не стандартным для текущей конвенции вызовов способом, нужно поставить галку Use Custom Storage
и нажать на иконку с зелёным плюсом:
Появится позиция для нового входного аргумента. Кликаем по ней два раза, и получаем диалог указания типа и носителя аргумента:
В декомпилированном коде видим, что in_D1w
имеет тип ushort
, значит его и укажем в поле с типом. Затем нажмём кнопку Add
:
Появится позиция для указания носителя аргумента, нам нужно указать в Location
регистр D1w
, и нажать OK
:
Декомпилированный код примет вид:
void FUN_0000034c(ushort param_1)
{
short sVar1;
ushort *puVar2;
if (((ushort)(param_1 ^ DAT_00ff0020 ^ 0x5e4e) == 0x5a5a) &&
((ushort)(param_1 ^ DAT_00ff0020 ^ 0x4a44) == 0x4e50)) {
write_volatile_4(0xc00004,0x4c060003);
sVar1 = 0x22;
puVar2 = &DAT_00002d94;
do {
write_volatile_2(VDP_DATA,param_1 ^ DAT_00ff0020 ^ *puVar2);
sVar1 = sVar1 + -1;
puVar2 = puVar2 + 1;
} while (sVar1 != -1);
}
return;
}
Знаем, что значение param_1
у нас константное, передаётся вызывающей функцией и равно #$4147
. Тогда каким должно быть значение DAT_00ff0020
? Считаем:
0x4147 ^ DAT_00ff0020 ^ 0x5e4e = 0x5a5a
0x4147 ^ DAT_00ff0020 ^ 0x4a44 = 0x4e50
Т.к. xor
— операция обратимая, можно все константные числа поксорить между собой и получить искомое значение переменной DAT_00ff0020
.
DAT_00ff0020 = 0x4147 ^ 0x5e4e ^ 0x5a5a = 0x4553
DAT_00ff0020 = 0x4147 ^ 0x4a44 ^ 0x4e50 = 0x4553
Выходит, что значение переменной должно быть равно 0x4553
. Кажется, я уже видел место, где такое значение устанавливается...
Выводы и решение
Приходим к следующим результатам:
- Сначала нужно прыгнуть на адрес
0x0D3C
, для этого нужно ввести код0D3C
- Прыгнуть на функцию по адресу
0x2D86
, которая устанавливает в регистрD1
значение#$4147
, для этого нужно ввести код2D86
Экспериментальным путём выясняем клавишу, которую нужно нажать, чтобы проверить введённый ключ: B
. Пробуем:
Спасибо!
Автор: DrMefistO