stihl не предоставил(а) никакой дополнительной информации.
Много лет назад я пытался узнать, как работает игра «Мор. Утопия» на самом деле. Но тогда формат ее скриптов оказался мне не по зубам. Сегодня мы вскроем движок игры, чтобы узнать, как работают скрипты. И даже напишем собственный декомпилятор для неизвестного языка программирования!
или Зарегистрируйся, где я показывал, как вскрыть и изменить ресурсы «Ядерного титбита». Я решил не тратить на это время и распаковал архив готовым софтом Для просмотра ссылки Войди или Зарегистрируйся.
Внутри лежит куча бинарных файлов с расширением .bin. Первые четыре байта не содержат никакой заметной сигнатуры.
Обычно разработчики встраивают проверенные скриптовые языки вроде Lua или Python. Раз файл бинарный, значит, скрипт был скомпилирован. Однако по разреженному (полному нулей) байт‑коду стало ясно, что формат скриптов проприетарный. Только самодельные языки так расточительно используют пространство файла — сравни с оптимизированными процессорными опкодами. Нули в байт‑коде могут означать, что команда занимает фиксированное количество байтов, а аргументов к ней не завезли.
Первым делом я пошел гуглить моды и наткнулся на частичный Для просмотра ссылки Войдиили Зарегистрируйся формата файла. Он помог на первоначальном этапе, но ближе к середине списка заголовков документ обрывается, не оставляя никаких ссылок на продолжение. Так что пришлось взять в руки старый добрый отладчик и выяснить все самостоятельно.
Этого исследования не было бы, если бы недавно не была опубликована альфа‑версия игры. Примечательна она тем, что исполняемые файлы поставляются вместе с файлами .pdb, которые содержат прототипы функций, а также все используемые структуры. Именно отладочные символы позволили сократить время исследования с нескольких месяцев до пары недель.
Но сначала отключим полноэкранный режим, для этого изменим config.ini:
Теперь с игрой можно работать в отладчике, не опасаясь зависаний. Посмотрим, какие файлы вообще открываются игрой. Для этого используем Для просмотра ссылки Войдиили Зарегистрируйся точки останова. Переходим на WinAPI-функцию CreateFileA и создаем такой бряк:
Текст журнала: OPEN: {ansi@[ESP+4]}
Условие остановки = 0
Условие добавления в журнал = 1
В текст журнала можно вставлять Для просмотра ссылки Войдиили Зарегистрируйся вместе с обрабатывающими Для просмотра ссылки Войди или Зарегистрируйся. В данном случае мы просим отладчик добавлять в журнал запись о каждом открытом файле, получая путь к файлу через стек, то есть, по сути, просто берем строку из первого аргумента функции CreateFileA.
Запускаем игру и видим в журнале полотно из игровых ассетов:
Вот это уже интересно. Скрипты запакованы в архиве, но в целях отладки или патчинга игра сначала пытается прочитать их с диска. И только если там их нет, читает содержимое файлов из игрового архива. Это значительно облегчает создание модов.
Допустим, я хочу остановить отладчик на конкретном файле. Для этого слегка изменим точку останова:
Условие остановки = strstr(ansi([ESP+4]), "fire.bin")
Функция strstr возвращает единицу, если в строке, которую вернет ansi, будет подстрока fire.bin. Перезапустив отладчик, ловим остановку на нужном файле. Смотрим в стеке адрес, откуда пришел вызов:
возврат к engine.CScriptManager::RunScript+88 из ???
Похоже на точку запуска скрипта! Самое время открыть Engine.dll в IDA Pro.
Тело скрипта передается в конструктор объекта CScript. IDA весьма ограниченно умеет работать с программами, написанными на C++, поэтому CScript::CScript — это просто имя функции, никак не связанное с классом в целом. Многие классы имеют таблицу виртуальных функций, по которой можно восстановить их принадлежность, даже если у нас под рукой нет символов.
Согласно прототипу функции, первый аргумент — это файл скрипта и он же является телом класса. Такое решение часто встречается в старых играх. Они экономили на сериализации и просто дампили тела классов на диск, например для создания сохранений. Посмотрим на определение класса CScript:
Структура прочитана из PDB-файла. IDA не умеет нормально работать с шаблонами, поэтому эти страшные длинные строчки с кучей запятых и угловых скобок — не более чем названия сишных структур. В глаза бросается схожесть тела класса со структурой скрипта, Для просмотра ссылки Войдиили Зарегистрируйся на фанатском сайте. С ней можно работать.
Первые четыре байта — это поле m_ulGlobalVarCount, дальше массив байтов m_pGlobalVarTypes. Далее идет массив типа boost::scoped_array. Следом за ним — словарь std::map. Из определений не очевидно, какими байтами они представлены в теле класса. Проще всего срисовать в отладчике значения каждого поля для любого известного файла. Так или иначе, в дампе класса все эти поля должны сводиться к простым сишным типам.
Для просмотра ссылки Войдиили Зарегистрируйся
Для этого включаем в IDA адресацию относительно начала функции, а затем синхронизацию между окнами с ассемблером и псевдокодом. Теперь мы можем найти указанный адрес в любом запущенном отладчике, вбив строку CScript::CScript + E3. И подсмотреть реальные значения полей в динамике.
Хорошо помогают вызовы функций типа qmemcpy, исходные данные лежат в регистре ESI, а количество копируемых байтов — в ECX. По ним можно понять, где в файле предполагается длинная строка, а не поле типа int.
Некоторые поля (например, m_ulGlobalVarCount) бывают нулевыми, а значит, следом за ними не идет интересующая нас структура. Отлавливаю работу с файлами, у которых есть эта структура, посредством условных точек останова вроде EAX != 0.
Для просмотра ссылки Войдиили Зарегистрируйся
Таким нехитрым образом я восстановил заголовки файла в 010 Editor. Процесс несложный: делаем предположение, отражаем его в шаблоне, проверяем значения в отладчике. Повторяем, пока картинка не сложится. Готовый шаблон слишком велик для цитирования, ищи его в Для просмотра ссылки Войдиили Зарегистрируйся на GitHub. Вот его упрощенная версия:
Файл начинается с определения глобальных переменных. Дальше идет «бассейн», состоящий из строчек в формате Unicode. За ним следуют определения используемых функций, фактически это секция импорта. Поле m_ulRunOp говорит, с какой команды запускать скрипт. Задания и события служат той же цели. Заголовки заканчиваются массивом команд — ради них мы и разбирали формат файла!
Поставив на нее брейк‑пойнт, я выяснил, что аргумент ulType принимает номер опкода, а объект reader ссылается на текущее положение в прочитанном файле fire.bin. Чтобы выяснить, где номера, а где аргументы, я решил собрать номера при помощи условной точки останова:
Текст журнала: ulType: {EDI}
Условие добавления в журнал: 1
Получаем в журнале список:
Внутри функции CreateInstruction огромный switch с обработчиками для каждого номера. Первоначальная задача — написать дизассемблер длин. Нам не так важно, как работает опкод, но важно выяснить, сколько он занимает места в файле. Тогда мы сможем правильно прочесть все остальные.
Итак, сколько он занимает места? В качестве подсказки можно использовать тело класса:
Наследование интерфейса IInstruction гарантирует, что первые четыре байта — ссылка на vftable. Дальше два поля: m_ulVarIn и m_ulVarOut. Посмотрев на код, убеждаемся, что курсор объекта reader два раза смещают на четыре байта, ровно под два поля из тела класса. Значит, читается восемь байт. Создаем такую же структуру в нашем шаблоне:
В CreateInstruction обрабатывается 87 опкодов, и несколько литров кофе спустя я разобрал их все. Оставалось только сравнить номера опкодов в шаблоне и список из перехваченных номеров в отладчике. Через какое‑то время я отловил ошибки, и шаблон стал работать со всеми скриптами.
Разобрать код без ошибок — полдела. Следующий шаг — понять, что он делает.
Видим, что код увеличивает глобальный счетчик команд m_ulCurOp — местный аналог регистра EIP.
По логике должен вызываться метод Set, но в реальности «вызываются» байты по адресу, который я назвал vSet. Дело в том, что IVariableEx — интерфейс, конкретная реализация которого зависит от типа переменной. 24 байта оставлено под шесть ссылок для шести разных типов переменных. Общий интерфейс оставляет место под конкретную ссылку, но не знает ее точного расположения. Так что можно считать, что это вызов метода Set. Через него код передает значение переменной m_ulVarIn в переменную m_ulVarOut. Это можно записать примерно такой строчкой псевдокода:
Объект m_Stack представляет собой std::vector — безразмерный массив из стандартной библиотеки C++. На нем реализован стек виртуальной машины самодельного скриптового языка. Стек работает по принципу LIFO (Last In, First Out), то есть добавляет последний элемент в конец списка и достает следующий элемент из конца. Поэтому код использует номера со знаком минус, это отрицательный индекс от конца списка.
Я решил написать декомпилятор на Python, он отлично подходит для написания бинарных парсеров. Для начала мне понадобился свой класс reader, чтобы удобно читать поток байтов:
С его помощью добавление нового опкода становится максимально простым, а код — легко читаемым:
Из созданного «Идой» кода не всегда понятно, что делает сложная команда. Проще всего посмотреть на состояние стека (в котором лежат все переменные) до и после выполнения команды. Для этого нужно написать очередной скрипт для отладчика, но в этот раз для IDA Pro.
Когда мы проваливаемся внутрь любой инструкции вроде CInstructionMov, вторым аргументом передается CScriptRun: ata *data — ссылка на структуру с глобальными переменными. Ее мы и будем считывать:
IDAPython позволяет читать значения регистров и памяти прямо во время отладки:
Я беру ссылку из стека и раскручиваю ее до нужных структур по заранее известным смещениям:
Здесь используется интересный трюк. Стек содержит ссылки на объекты — производные классы от CVariableBase. Первые четыре байта тела любого объекта — ссылка на vftable. По ее адресу можно получить имя класса, предоставляемое отладочными символами. А зная класс переменной, нетрудно прочесть ее значения:
Дампинг значений до и после исполнения интересующей меня инструкции помог разобрать опкоды, не копаясь в тонне декомпилированного с ошибками кода C++. Только если команда вела себя неожиданным образом, приходилось заглядывать в декомпилятор, но большинство инструкций разобралось и так.
Дело за малым — разобрать аналогичным образом код остальных 86 опкодов. Примерно неделю спустя я получил рабочий декомпилятор. Статья превратится в книгу, если я буду описывать каждый опкод отдельно. Поэтому читай полный код декомпилятора Для просмотра ссылки Войдиили Зарегистрируйся.
Меня интересуют скрипты полной версии, но при попытке открыть их шаблоном 010 Editor от альфа‑версии вылезает ошибка шаблонизатора. Заголовки разбираются нормально, значит, их формат не изменился, а вот список опкодов ломает исполнение шаблона. Выясним, в чем дело, сравнив оба движка.
Для этого понадобится плагин к IDA Pro — Для просмотра ссылки Войдиили Зарегистрируйся. Он создан для сравнения двух версий одного и того же файла. Так называемый патч‑диффинг помогает найти место, исправленное патчем безопасности, и написать эксплоит для старой версии. Тот же метод поможет определить расположение известных функций, чтобы понять, что изменилось в новой версии движка.
IDA славится обратной совместимостью со своими же старыми версиями, поэтому в конце работы плагин падает с ошибкой.
Замечательно, лезу в исходники файла C:\Program Files\IDA Professional 9.0 SP1\python\idc.py, так как помню, что функция должна быть там, и закономерно там ее не нахожу. Смотрю код старой версии:
Разработчики «Иды» любят менять имена функций, оставляя к ним старые комментарии. Поискав по тексту копию комментария, находим новое имя:
Меняю код Diaphora и перезапускаю плагин. В этот раз все прошло успешно. Плагин создает базу SQLite для обоих файлов и затем сравнивает ее в поисках одинаковых функций. На выходе получаем несколько новых окон со списком похожих функций. Делаем снимок базы и жмем Import all functions.
Diaphora закончила сравнение
Теперь у меня есть практически полностью размеченная база для новой версии движка. Остается найти изменения в коде функции CreateInstruction. Поочередно сравниваю все инструкции, пока не натыкаюсь на первые различия:
Ближе к концу списка вставлено два новых опкода! Первый занимает девять байт и не делает ничего полезного. А вот второй передает управление и фактически повторяет код CInstructionCall, поэтому в декомпиляторе я назвал его Call2. Остальные опкоды работают так же, как раньше.
При создании объекта, то есть появлении игрока в непосредственной близости, запускается код по адресу RunOp. Адреса представляют собой порядковый номер опкода, примерно как номер строки в бейсике. Первый, то есть нулевой, опкод заталкивает в стек значение 1. Второй вызывает функцию SetVisibility с единственным аргументом, который берется из вершины стека. Дальше следует бесконечный цикл из вызова функции Hold.
Заголовок GlobalTasks содержит адреса событий — что‑то вроде колбэков, вызов которых происходит в ответ на какие‑то события в игре. Насколько я понимаю, единственное «событие» в жизни объекта «кровать» — это ситуация, когда игрок жмет кнопку действия. Код начинается со смещения 0x7 и запускает функцию ActivateSleepMode, то есть открывает меню для выбора нужного времени сна.
В начале скрипта через функцию PlaySound проигрывается звук drink. Дальше проверяется значение атрибута hunger, и если голод существует, то из него вычитается 0,07. Теперь ты знаешь, как работает бутылка молока. А еще мы убедились, что декомпилятор работает без ошибок.
или Зарегистрируйся. Какой‑то безумец методично правил все байты скрипта weather.bin, пока не вывел закономерность в реакциях игры на изменения.
Действительно, скрипт содержит множество значений типа float, которые отвечают за освещение, туман и цветокоррекцию.
Результат работы модификации
Код остальных декомпилированных скриптов ищи в моем Для просмотра ссылки Войдиили Зарегистрируйся, я собрал его от четырех различных версий игры.
Скрипт считает вероятность появления предмета в урне, и если игроку везет, то через функцию AddItem в урну добавляется объект bottle_empty, то есть пустая бутылка. Подсмотрев в другом скрипте имя powder — название объекта «порошочек», заменим в скрипте одну строку другой.
Интерфейс альфа‑версии немного отличается от привычного
Так выглядели оригинальные скрипты до компиляции.
До создания полноценного SDK еще много работы, но начало положено. Декомпилятор далек от идеала, не все команды отображаются корректно. Следующим шагом будет декомпиляция в С‑подобный код вроде Hex-Rays. Сегодня мы обрели бесценный опыт реверса софта на C++, а комьюнити получило инструмент для создания новых модов, пусть пока еще и сырой.
info
Если тебе тоже интересно создание модификаций для «Мора» — предлагаю объединить наши усилия. Добавляйся в чат по реверсу игры!Сбор информации
Скрипты хранятся в файле Scripts.vfs. Формат самодельный и простейший, можешь почитать, как их вскрывают, в одной из моих Для просмотра ссылки ВойдиВнутри лежит куча бинарных файлов с расширением .bin. Первые четыре байта не содержат никакой заметной сигнатуры.
Обычно разработчики встраивают проверенные скриптовые языки вроде Lua или Python. Раз файл бинарный, значит, скрипт был скомпилирован. Однако по разреженному (полному нулей) байт‑коду стало ясно, что формат скриптов проприетарный. Только самодельные языки так расточительно используют пространство файла — сравни с оптимизированными процессорными опкодами. Нули в байт‑коде могут означать, что команда занимает фиксированное количество байтов, а аргументов к ней не завезли.
Первым делом я пошел гуглить моды и наткнулся на частичный Для просмотра ссылки Войди
Этого исследования не было бы, если бы недавно не была опубликована альфа‑версия игры. Примечательна она тем, что исполняемые файлы поставляются вместе с файлами .pdb, которые содержат прототипы функций, а также все используемые структуры. Именно отладочные символы позволили сократить время исследования с нескольких месяцев до пары недель.
Ищем точку входа
Чтобы узнать, какие поля есть в закрытом формате на самом деле, поищем место в коде игры, где читаются скрипты. Для этого запустим игру в x64dbg, современной альтернативе OllyDbg. Отладчик автоматически подгружает с диска символы, расставляя адресам в памяти оригинальные названия.Но сначала отключим полноэкранный режим, для этого изменим config.ini:
Код:
[Video]
XRes = 1024
YRes = 768
Fullscreen = 0
Теперь с игрой можно работать в отладчике, не опасаясь зависаний. Посмотрим, какие файлы вообще открываются игрой. Для этого используем Для просмотра ссылки Войди
Текст журнала: OPEN: {ansi@[ESP+4]}
Условие остановки = 0
Условие добавления в журнал = 1
В текст журнала можно вставлять Для просмотра ссылки Войди
Запускаем игру и видим в журнале полотно из игровых ассетов:
OPEN: C:\games\pathologic\alpha\data\scripts\fire.bin
Вот это уже интересно. Скрипты запакованы в архиве, но в целях отладки или патчинга игра сначала пытается прочитать их с диска. И только если там их нет, читает содержимое файлов из игрового архива. Это значительно облегчает создание модов.
Допустим, я хочу остановить отладчик на конкретном файле. Для этого слегка изменим точку останова:
Условие остановки = strstr(ansi([ESP+4]), "fire.bin")
Функция strstr возвращает единицу, если в строке, которую вернет ansi, будет подстрока fire.bin. Перезапустив отладчик, ловим остановку на нужном файле. Смотрим в стеке адрес, откуда пришел вызов:
возврат к engine.CScriptManager::RunScript+88 из ???
Похоже на точку запуска скрипта! Самое время открыть Engine.dll в IDA Pro.
Исследуем заголовки
IDA показывает не так много входящих ссылок на CreateFileA, быстро находим фрагмент кода внутри RunScript, читающий файл скрипта через виртуальную файловую систему (VFS).
Код:
v8 = __v.second.m_pManager->m_pFS->CreateMappedLoadObject(__v.second.m_pManager->m_pFS, pszScriptName);
// (...)
ScriptDataPtr = Script_2->GetMemoryPointer(Script_2);
CScript::CScript(ScriptDataPtr, __v.second.__vftable, v43)
Тело скрипта передается в конструктор объекта CScript. IDA весьма ограниченно умеет работать с программами, написанными на C++, поэтому CScript::CScript — это просто имя функции, никак не связанное с классом в целом. Многие классы имеют таблицу виртуальных функций, по которой можно восстановить их принадлежность, даже если у нас под рукой нет символов.
void __userpurge CScript::CScript(CScript *this@<ecx>, CScript *pScript, unsigned int ulSize);
Согласно прототипу функции, первый аргумент — это файл скрипта и он же является телом класса. Такое решение часто встречается в старых играх. Они экономили на сериализации и просто дампили тела классов на диск, например для создания сохранений. Посмотрим на определение класса CScript:
Код:
struct __cppobj __unaligned __declspec(align(4)) CScript
{
unsigned int m_ulGlobalVarCount;
boost::scoped_array<unsigned char> m_pGlobalVarTypes;
_STL::map<CEString,unsigned long,_STL::less<CEString>,_STL::allocator<_STL:air<CEString const ,unsigned long> > > m_Properties;
unsigned int m_ulDataPoolSize;
boost::scoped_array<char> m_pDataPool;
unsigned int m_ulGlobalCount;
boost::scoped_array<CScript::GLOBAL_FUNCTION> m_pGlobals;
unsigned int m_ulTaskCount;
boost::scoped_array<CScript::TASK> m_pTasks;
_STL::map<unsigned long,CScript::EVENT,_STL::less<unsigned long>,_STL::allocator<_STL:air<unsigned long const ,CScript::EVENT> > > m_GlobalEvents;
unsigned int m_ulCodeSize;
boost::scoped_array<boost::scoped_ptr<IInstruction> > m_pCode;
unsigned int m_ulRunTask;
unsigned int m_ulRunOp;
_STL::set<IScriptNotify *,_STL::less<IScriptNotify *>,_STL::allocator<IScriptNotify *> > m_Notify[3];
};
Структура прочитана из PDB-файла. IDA не умеет нормально работать с шаблонами, поэтому эти страшные длинные строчки с кучей запятых и угловых скобок — не более чем названия сишных структур. В глаза бросается схожесть тела класса со структурой скрипта, Для просмотра ссылки Войди
Первые четыре байта — это поле m_ulGlobalVarCount, дальше массив байтов m_pGlobalVarTypes. Далее идет массив типа boost::scoped_array. Следом за ним — словарь std::map. Из определений не очевидно, какими байтами они представлены в теле класса. Проще всего срисовать в отладчике значения каждого поля для любого известного файла. Так или иначе, в дампе класса все эти поля должны сводиться к простым сишным типам.
Для просмотра ссылки Войди
Для этого включаем в IDA адресацию относительно начала функции, а затем синхронизацию между окнами с ассемблером и псевдокодом. Теперь мы можем найти указанный адрес в любом запущенном отладчике, вбив строку CScript::CScript + E3. И подсмотреть реальные значения полей в динамике.
qmemcpy(task->m_pVarTypes.ptr, var_count_ptr + 1, task->m_ulVarCount);
Хорошо помогают вызовы функций типа qmemcpy, исходные данные лежат в регистре ESI, а количество копируемых байтов — в ECX. По ним можно понять, где в файле предполагается длинная строка, а не поле типа int.
Некоторые поля (например, m_ulGlobalVarCount) бывают нулевыми, а значит, следом за ними не идет интересующая нас структура. Отлавливаю работу с файлами, у которых есть эта структура, посредством условных точек останова вроде EAX != 0.
Для просмотра ссылки Войди
Таким нехитрым образом я восстановил заголовки файла в 010 Editor. Процесс несложный: делаем предположение, отражаем его в шаблоне, проверяем значения в отладчике. Повторяем, пока картинка не сложится. Готовый шаблон слишком велик для цитирования, ищи его в Для просмотра ссылки Войди
Код:
struct VarTypes
{
VAR_TYPE type;
BYTE flag;
BYTE len;
CHAR str[len];
};
struct GLOBAL_FUNCTION
{
BYTE Len;
CHAR Name[Len];
DWORD ArgCount;
};
struct EVENT
{
DWORD ulEventID;
DWORD m_ulOp;
DWORD m_ulVarCount;
CHAR m_pVarTypes[m_ulVarCount];
};
struct TASK
{
DWORD m_ulVarCount;
BYTE m_pVarTypes[m_ulVarCount];
DWORD m_ulParmCount;
DWORD event_count;
EVENT m_Events[event_count];
};
struct EventId
{
DWORD event_id;
DWORD m_ulOp;
DWORD m_ulVarCount;
BYTE m_pVarTypes[m_ulVarCount];
};
struct Headers
{
DWORD m_ulGlobalVarCount;
VarTypes gvar_types[m_ulGlobalVarCount];
DWORD m_ulDataPoolSize;
BYTE m_pDataPool[m_ulDataPoolSize];
DWORD m_ulGlobalCount;
GLOBAL_FUNCTION m_pGlobals[m_ulGlobalCount];
DWORD m_ulRunTask;
DWORD m_ulRunOp;
DWORD m_ulTaskCount;
TASK m_pTasks[m_ulTaskCount];
DWORD event_count;
EventId Events[event_count];
DWORD m_ulCodeSize;
BYTE code[m_ulCodeSize];
};
Файл начинается с определения глобальных переменных. Дальше идет «бассейн», состоящий из строчек в формате Unicode. За ним следуют определения используемых функций, фактически это секция импорта. Поле m_ulRunOp говорит, с какой команды запускать скрипт. Задания и события служат той же цели. Заголовки заканчиваются массивом команд — ради них мы и разбирали формат файла!
Исследуем байт-код
Я решил рассмотреть и другие методы класса CScript и наткнулся на такую функцию:void __usercall CScript::CreateInstruction(unsigned int ulType@<edi>, CStringReader *reader@<ecx>, CScript *pScript);
Поставив на нее брейк‑пойнт, я выяснил, что аргумент ulType принимает номер опкода, а объект reader ссылается на текущее положение в прочитанном файле fire.bin. Чтобы выяснить, где номера, а где аргументы, я решил собрать номера при помощи условной точки останова:
Текст журнала: ulType: {EDI}
Условие добавления в журнал: 1
Получаем в журнале список:
Код:
ulType: 1A
ulType: 4E
ulType: 51
ulType: 1A
ulType: 4E
ulType: 17
ulType: 17
ulType: 4D
Находим номера в теле файла.
00F0 00 51 00 00 00 00 00 1A 00 00 00 00 00 4E 00 00 .Q...........N..
0100 00 00 00 51 00 01 00 00 00 1A 00 00 00 00 00 4E ...Q...........N
0110 00 00 00 00 00 17 00 02 00 00 00 05 05 17 00 01 ................
0120 00 00 00 01 4D 00 2C 00 00 00 1A 00 00 00 00 00 ....M.,.........
Сначала идет номер опкода, затем аргументы переменной длины. Значит, придется составить карту, исследовав каждый номер по отдельности.
void __usercall CScript::CreateInstruction(unsigned int ulType@<edi>, CStringReader *reader@<ecx>, CScript *pScript)
{
// (...)
switch ( ulType )
{
case 0u:
v4 = operator new(0xCu);
if ( !v4 )
goto LABEL_4;
CInstructionMov::CInstructionMov(v4, reader);
return result;
case 1u:
v6 = operator new(0xCu);
if ( !v6 )
goto LABEL_4;
CInstructionMovB::CInstructionMovB(v6, reader);
return result;
case 2u:
v7 = operator new(0xCu);
if ( !v7 )
goto LABEL_4;
CInstructionMovI::CInstructionMovI(v7, reader);
return result;
// (...)
}
}
Внутри функции CreateInstruction огромный switch с обработчиками для каждого номера. Первоначальная задача — написать дизассемблер длин. Нам не так важно, как работает опкод, но важно выяснить, сколько он занимает места в файле. Тогда мы сможем правильно прочесть все остальные.
Код:
void __usercall CInstructionMov::CInstructionMov(CInstructionMov *this@<eax>, CStringReader *reader@<edx>)
{
const char *m_pszDataCur; // ecx
this->__vftable = &CInstructionMov::vftable;
m_pszDataCur = reader->m_pszDataCur;
this->m_ulVarIn = *m_pszDataCur;
m_pszDataCur += 4;
reader->m_pszDataCur = m_pszDataCur;
this->m_ulVarOut = *m_pszDataCur;
reader->m_pszDataCur = m_pszDataCur + 4;
}
Итак, сколько он занимает места? В качестве подсказки можно использовать тело класса:
Код:
struct __cppobj CInstructionMov : IInstruction
{
unsigned int m_ulVarIn;
unsigned int m_ulVarOut;
};
Наследование интерфейса IInstruction гарантирует, что первые четыре байта — ссылка на vftable. Дальше два поля: m_ulVarIn и m_ulVarOut. Посмотрев на код, убеждаемся, что курсор объекта reader два раза смещают на четыре байта, ровно под два поля из тела класса. Значит, читается восемь байт. Создаем такую же структуру в нашем шаблоне:
Код:
struct CInstructionMov
{
DWORD VarIn;
DWORD ulVarOut;
};
В CreateInstruction обрабатывается 87 опкодов, и несколько литров кофе спустя я разобрал их все. Оставалось только сравнить номера опкодов в шаблоне и список из перехваченных номеров в отладчике. Через какое‑то время я отловил ошибки, и шаблон стал работать со всеми скриптами.
Разобрать код без ошибок — полдела. Следующий шаг — понять, что он делает.
Пишем декомпилятор
У меня уже есть список опкодов, из названий которых примерно понятно, что они делают. Но в идеале нужно получить читаемый псевдокод, из которого будет ясно, что делает весь скрипт. По логике объектно ориентированного программирования объект опкода должен содержать код исполнения, который вызывается при запуске скрипта. И действительно, поискав по имени, находим метод Execute у класса с тем же именем:
Код:
bool __thiscall CInstructionMov::Execute(CInstructionMov *this, CScriptRun:ata *data, float fDeltaTime)
{
_BYTE pExceptionObject[12]; // [esp+8h] [ebp-Ch] BYREF
if ( !(*data->m_Stack._M_finish[-this->m_ulVarOut].m_Object->vSet)(
data->m_Stack._M_finish[-this->m_ulVarOut].m_Object,
data->m_Stack._M_finish[-this->m_ulVarIn].m_Object) )
{
CScriptRun::Error::Error("Can't convert variable");
CxxThrowException(pExceptionObject, &TI1_AVError_CScriptRun);
}
++data->m_ulCurOp;
return 0;
}
Видим, что код увеличивает глобальный счетчик команд m_ulCurOp — местный аналог регистра EIP.
Код:
struct IVariableEx_vtbl
{
void (__thiscall *~IBase)(IBase *this);
bool (__thiscall *Release)(IGeneric *this);
IGeneric *(__thiscall *QueryInterface)(IGeneric *this, unsigned int);
VAR_TYPE (__thiscall *GetVariableType)(IVariable *this);
_BYTE vSet[24];
bool (__thiscall *Set)(IVariable *this, bool);
_BYTE vGet[24];
bool (__thiscall *Get)(IVariable *this, bool *);
// (...)
};
По логике должен вызываться метод Set, но в реальности «вызываются» байты по адресу, который я назвал vSet. Дело в том, что IVariableEx — интерфейс, конкретная реализация которого зависит от типа переменной. 24 байта оставлено под шесть ссылок для шести разных типов переменных. Общий интерфейс оставляет место под конкретную ссылку, но не знает ее точного расположения. Так что можно считать, что это вызов метода Set. Через него код передает значение переменной m_ulVarIn в переменную m_ulVarOut. Это можно записать примерно такой строчкой псевдокода:
Stack[-VarOut] = Stack[-VarIn]
Объект m_Stack представляет собой std::vector — безразмерный массив из стандартной библиотеки C++. На нем реализован стек виртуальной машины самодельного скриптового языка. Стек работает по принципу LIFO (Last In, First Out), то есть добавляет последний элемент в конец списка и достает следующий элемент из конца. Поэтому код использует номера со знаком минус, это отрицательный индекс от конца списка.
Я решил написать декомпилятор на Python, он отлично подходит для написания бинарных парсеров. Для начала мне понадобился свой класс reader, чтобы удобно читать поток байтов:
Код:
class Reader:
def init(self, path):
self.f = open(path, "rb")
def del(self):
self.f.close()
def read(self, size, format):
return struct.unpack(format, self.f.read(size))[0]
def uint32(self):
return self.read(4, 'I')
def uint16(self):
return self.read(2, 'H')
def uint8(self):
return self.read(1, 'B')
def bytes(self, size):
return self.f.read(size)
def str_a(self, size):
return self.f.read(size).decode('utf-8')
def float(self):
return round(struct.unpack('f', self.f.read(4))[0], 5)
С его помощью добавление нового опкода становится максимально простым, а код — легко читаемым:
Код:
class CInstructionMov:
def init(self, r):
self.OpCode = 'Mov'
self.VarIn = r.uint32()
self.VarOut = r.uint32()
def repr(self):
return f'Stack[-{self.VarOut}] = Stack[-{self.VarIn}]'
Из созданного «Идой» кода не всегда понятно, что делает сложная команда. Проще всего посмотреть на состояние стека (в котором лежат все переменные) до и после выполнения команды. Для этого нужно написать очередной скрипт для отладчика, но в этот раз для IDA Pro.
Когда мы проваливаемся внутрь любой инструкции вроде CInstructionMov, вторым аргументом передается CScriptRun:
Код:
struct __cppobj CScriptRun:ata
{
CScript *m_pScript;
CScriptRun *m_pScriptRun;
IScriptContext *m_pContext;
CScriptRun::GF_RESOLVED *m_pResolved;
boost::scoped_array m_pGlobalVars;
_STL::vector m_Tasks;
_STL::vector m_Stack;
unsigned int m_ulCurOp;
CSerPtr m_pOpData;
};
IDAPython позволяет читать значения регистров и памяти прямо во время отладки:
Код:
def dump_vm_state():
data_ptr = get_reg_value('esp') + 4
data_adr = read_dbg_dword(data_ptr)
stack_ptr = data_adr + 0x20
cur_op = read_dbg_dword(data_adr + 0x2c)
print(f'CurOP: {hex(cur_op)}')
print(f'Stack: ')
dump_vector(stack_ptr)
Я беру ссылку из стека и раскручиваю ее до нужных структур по заранее известным смещениям:
Код:
def dump_vector(addr):
m_start = idc.read_dbg_dword(addr)
m_finish = idc.read_dbg_dword(addr+4)
while m_start < m_finish:
m_obj = idc.read_dbg_dword(m_start)
vftable = idc.read_dbg_dword(m_obj)
class_name = idc.get_name(vftable)
demangle_type = idc.get_inf_attr(INF_SHORT_DN)
class_name = idc.demangle_name(class_name, demangle_type)
class_name = RE_VAR_TYPE.findall(class_name)[0]
value = ''
if class_name == 'CVariableInt':
value = idc.read_dbg_dword(m_obj+8)
if class_name == 'CVariableFloat':
value = idc.read_dbg_dword(m_obj+8)
if class_name == 'CVariableString':
vector = idc.read_dbg_dword(m_obj+8)
str_data = read_dbg_memory(vector, 100)
value = '"' + get_str_from_addr(str_data) + '"'
if class_name == 'CVariableBool':
value = idc.read_dbg_byte(m_obj+8)
if class_name == 'CVariableVector':
x = idc.read_dbg_dword(m_obj+8)
y = idc.read_dbg_dword(m_obj+8+4)
z = idc.read_dbg_dword(m_obj+8+4+4)
value = f'({x},{y},{z})'
print(hex(m_start), hex(m_obj), class_name, value)
m_start +=4
Здесь используется интересный трюк. Стек содержит ссылки на объекты — производные классы от CVariableBase. Первые четыре байта тела любого объекта — ссылка на vftable. По ее адресу можно получить имя класса, предоставляемое отладочными символами. А зная класс переменной, нетрудно прочесть ее значения:
Код:
Python>dump_vm_state()
CurOP: 0x1015
Stack:
0x3dcd1f8 0x3dcda38 CVariableFloat 0
0x3dcd1fc 0x3dcda60 CVariableFloat 1086324736
0x3dcd200 0x3dcda88 CVariableString "dawn_bk.tex"
0x3dcd204 0x3dcdab8 CVariableString "dawn_ft.tex"
0x3dcd208 0x3dcdae8 CVariableString "dawn_lt.tex"
0x3dcd20c 0x3dcdb18 CVariableString "dawn_rt.tex"
0x3dcd210 0x3dcdb48 CVariableString "dawn_up.tex"
0x3dcd214 0x3dcdb78 CVariableString "dawn_rain_bk.tex"
0x3dcd218 0x3dcdba8 CVariableString "dawn_rain_ft.tex"
0x3dcd21c 0x3dcdd50 CVariableString "dawn_rain_lt.tex"
0x3dcd220 0x3dcdd80 CVariableString "dawn_rain_rt.tex"
0x3dcd224 0x3dcddb0 CVariableString "dawn_rain_up.tex"
0x3dcd228 0x3dcdde0 CVariableBool 3131961344
0x3dcd22c 0x3dcde08 CVariableVector (1053345994,1048872069,1048872069)
0x3dcd230 0x3dcde38 CVariableVector (1053345994,1048872069,1048872069)
0x3dcd234 0x3dcde68 CVariableFloat 1161527296
0x3dcd238 0x3dcde90 CVariableFloat 1167867904
0x3dcd23c 0x3dcdeb8 CVariableVector (1061734602,1057326470,1055056611)
0x3dcd240 0x3dcdee8 CVariableVector (1061734602,1057326470,1055056611)
0x3dcd244 0x3dcdf18 CVariableFloat 0
0x3dcd248 0x3dcdf40 CVariableFloat 1090519040
0x3dcd24c 0x3dcdf68 CVariableFloat 1086324736
Дампинг значений до и после исполнения интересующей меня инструкции помог разобрать опкоды, не копаясь в тонне декомпилированного с ошибками кода C++. Только если команда вела себя неожиданным образом, приходилось заглядывать в декомпилятор, но большинство инструкций разобралось и так.
Дело за малым — разобрать аналогичным образом код остальных 86 опкодов. Примерно неделю спустя я получил рабочий декомпилятор. Статья превратится в книгу, если я буду описывать каждый опкод отдельно. Поэтому читай полный код декомпилятора Для просмотра ссылки Войди
Миграция с альфа-версии
Альфа‑версия хранит в архиве 343 скрипта, датированных июлем 2004 года. Официальное издание «Мора» содержит уже 978 штук, с разными датами вплоть до октября 2005-го.Меня интересуют скрипты полной версии, но при попытке открыть их шаблоном 010 Editor от альфа‑версии вылезает ошибка шаблонизатора. Заголовки разбираются нормально, значит, их формат не изменился, а вот список опкодов ломает исполнение шаблона. Выясним, в чем дело, сравнив оба движка.
Для этого понадобится плагин к IDA Pro — Для просмотра ссылки Войди
IDA славится обратной совместимостью со своими же старыми версиями, поэтому в конце работы плагин падает с ошибкой.
Код:
[Diaphora: Wed Apr 2 19:57:08 2025] Error: module 'idc' has no attribute 'get_ordinal_qty'
Traceback (most recent call last):
File "C:\Program Files\IDA Professional 9.0\diaphora\3.2.1\diaphora_ida.py", line 1281, in export
self.do_export(crashed_before)
File "C:\Program Files\IDA Professional 9.0\diaphora\3.2.1\diaphora_ida.py", line 1248, in do_export
self.export_structures()
File "C:\Program Files\IDA Professional 9.0\diaphora\3.2.1\diaphora_ida.py", line 3329, in export_structures
local_types = idc.get_ordinal_qty()
File "C:\Program Files\IDA Professional 9.0 SP1\python\ida_ida.py", line 4612, in getattribute
return getattr(self.orig, name)
AttributeError: module 'idc' has no attribute
Замечательно, лезу в исходники файла C:\Program Files\IDA Professional 9.0 SP1\python\idc.py, так как помню, что функция должна быть там, и закономерно там ее не нахожу. Смотрю код старой версии:
Код:
def get_ordinal_qty():
"""
Get number of local types + 1
@return: value >= 1. 1 means that there are no local types.
"""
return ida_typeinf.get_ordinal_qty(None)
Разработчики «Иды» любят менять имена функций, оставляя к ним старые комментарии. Поискав по тексту копию комментария, находим новое имя:
Код:
def get_ordinal_limit():
"""
Get number of local types + 1
@return: value >= 1. 1 means that there are no local types.
"""
return ida_typeinf.get_ordinal_limit(None)
Меняю код Diaphora и перезапускаю плагин. В этот раз все прошло успешно. Плагин создает базу SQLite для обоих файлов и затем сравнивает ее в поисках одинаковых функций. На выходе получаем несколько новых окон со списком похожих функций. Делаем снимок базы и жмем Import all functions.
Теперь у меня есть практически полностью размеченная база для новой версии движка. Остается найти изменения в коде функции CreateInstruction. Поочередно сравниваю все инструкции, пока не натыкаюсь на первые различия:
Код:
case 0x4C:
v73 = operator new(0x14u);
if ( v73 )
CInstructionPow2::CInstructionPow2(v73, a1);
return;
case 0x4D:
v76 = operator new(0xCu);
if ( v76 )
CInstructionCall::vftable(v76, a1);
return;
case 0x4E:
if ( operator new(0x10u) )
NEW_OCPODE_1(a1);
return;
case 0x4F:
v19 = operator new(8u);
if ( !v19 )
return;
*v19 = &NEW_COMMAND_2_vtable;
break;
Ближе к концу списка вставлено два новых опкода! Первый занимает девять байт и не делает ничего полезного. А вот второй передает управление и фактически повторяет код CInstructionCall, поэтому в декомпиляторе я назвал его Call2. Остальные опкоды работают так же, как раньше.
Исследуем существующие скрипты
Декомпилятор написан и работает. Местами декомпиляция не очень точная, но мы уже можем понять, что делает тот или иной скрипт. Чтобы опкоды не сливались в единую стену текста, я решил разделять их на типичные базовые блоки — ставить разрыв строки после команд передачи управления.bed.bin
Скрипт отвечает за поведение кроватей в игре.
Код:
Import:
SetVisibility (1 args)
Hold (0 args)
IsOverrideActive (1 args)
ActivateSleepMode (1 args)
RunOp = 0x0
RunTask = 0
GlobalTasks:
GTASK_0 Params = 0
EVENT_0 Op = 0x7 Vars = (object)
0x0: Push((bool) 1)
0x1: @ SetVisibility(Stack[-1])
0x2: Pop(1)
0x3: @ Hold()
0x4: Pop(0)
0x5: GOTO 0x3
0x6: Return(0)
0x7: PushEmpty(bool, bool)
0x8: @ IsOverrideActive(Stack[-1])
0x9: Pop(0)
0xa: Stack[-1] = !Stack[-1]
0xb: IF (Stack[-1] == 0) GOTO 0xe; Pop(1)
0xc: @ ActivateSleepMode(Stack[-3])
0xd: Pop(0)
0xe: Return(2)
При создании объекта, то есть появлении игрока в непосредственной близости, запускается код по адресу RunOp. Адреса представляют собой порядковый номер опкода, примерно как номер строки в бейсике. Первый, то есть нулевой, опкод заталкивает в стек значение 1. Второй вызывает функцию SetVisibility с единственным аргументом, который берется из вершины стека. Дальше следует бесконечный цикл из вызова функции Hold.
Заголовок GlobalTasks содержит адреса событий — что‑то вроде колбэков, вызов которых происходит в ответ на какие‑то события в игре. Насколько я понимаю, единственное «событие» в жизни объекта «кровать» — это ситуация, когда игрок жмет кнопку действия. Код начинается со смещения 0x7 и запускает функцию ActivateSleepMode, то есть открывает меню для выбора нужного времени сна.
item_milk.bin
Посмотрим на код объекта «молоко»:
Код:
Strings:
drink
hunger
add
Import:
PlaySound (1 args)
HasProperty (2 args)
GetProperty (2 args)
SetProperty (2 args)
CreateFloatVector (1 args)
SendWorldWndMessage (2 args)
RunOp = 0x0
RunTask = 0
Список команд слишком длинный для цитат, я приведу возможную трассу исполнения:
0x0: Push("drink")
0x1: @ PlaySound(Stack[-1])
0x2: Pop(1)
0x3: PushEmpty(bool, string, float, float, float)
0x4: Stack[-4] = "hunger"
0x5: Stack[-3] = (float) -0.07
0x6: Stack[-2] = (int) 0
0x7: Stack[-1] = (int) 1
0x8: Call2 0xf
0xf: PushEmpty(bool, float, bool, float)
0x10: @ HasProperty(Stack[-8], Stack[-2])
0x11: Pop(0)
0x12: Stack[-2] = !Stack[-2]
0x13: IF (Stack[-1] == 0) GOTO 0x16; Pop(1)
0x16: @ GetProperty(Stack[-8], Stack[-1])
0x17: Pop(0)
0x18: PushEmpty(float, float, float, float)
0x19: Stack[-3] = Stack[-5] + Stack[-11]
0x1a: Stack[-2] = Stack[-10]
0x1b: Stack[-1] = Stack[-9]
0x1c: Call2 0x22
0x22: PushEmpty()
0x23: Pop(0); Push((bool) Stack[-3] < Stack[-2])
0x24: IF (Stack[-1] == 0) GOTO 0x27; Pop(1)
0x25: Stack[-4] = Stack[-2]
0x26: Return(0)
0x1d: Pop(3)
0x1e: @ SetProperty(Stack[-9], Stack[-1])
0x1f: Pop(1)
0x20: Stack[-9] = (bool) 1
0x21: Return(4)
В начале скрипта через функцию PlaySound проигрывается звук drink. Дальше проверяется значение атрибута hunger, и если голод существует, то из него вычитается 0,07. Теперь ты знаешь, как работает бутылка молока. А еще мы убедились, что декомпилятор работает без ошибок.
Известные модификации
Для начала я решил посмотреть, какие вообще существуют модификации для «Мора». Оказалось, что большинство серьезных модов базируется на Для просмотра ссылки Войди
Код:
0x3: Pop(0)
0x4: Push("night_bk.tex")
0x5: Push("night_ft.tex")
0x6: Push("night_lt.tex")
0x7: Push("night_rt.tex")
0x8: Push("night_up.tex")
0x9: Push("night_rain_bk.tex")
0xa: Push("night_rain_ft.tex")
0xb: Push("night_rain_lt.tex")
0xc: Push("night_rain_rt.tex")
0xd: Push("night_rain_up.tex")
0xe: Push((bool) 0)
0xf: Push(CVector(0.0, 0.0, 0.0))
0x10: Push(CVector(0.27451, 0.27451, 0.27451))
0x11: Push((float)3000.0)
0x12: Push((float)5000.0)
0x13: Push(CVector(0.19608, 0.19608, 0.19608))
0x14: Push(CVector(0.19608, 0.19608, 0.19608))
0x15: @ ForceWeather(Stack[-17], Stack[-16], Stack[-15], Stack[-14], Stack[-13], Stack[-12], Stack[-11], Stack[-10], Stack[-9], Stack[-8], Stack[-7], Stack[-6], Stack[-5], Stack[-4], Stack[-3], Stack[-2], Stack[-1])
0x16: Pop(17)
Действительно, скрипт содержит множество значений типа float, которые отвечают за освещение, туман и цветокоррекцию.
Код остальных декомпилированных скриптов ищи в моем Для просмотра ссылки Войди
Создание модификации
Первое, что пришло мне в голову, — изменить содержимое городских урн. Согласно игре, основная функция лечащего врача — копаться в помойках. Облегчим ношу Бакалавра Данковского, разместив в каждой урне по целебному порошочку. Для этого откроем скрипт urna.bin.
Код:
0x0: PushEmpty(float, float)
0x1: Push((bool) 1)
0x2: @ SetVisibility(Stack[-1])
0x3: Pop(1)
0x4: PushEmpty()
0x5: Call2 0x1a
0x1a: @ RemoveAllItems()
0x1b: Pop(0)
0x1c: PushEmpty(string, int, int, int)
0x1d: Stack[-4] = "bottle_empty"
0x1e: Stack[-3] = (int) 1
0x1f: Stack[-2] = (int) 2
0x20: Stack[-1] = (int) 2
0x21: Call2 0x38
0x38: PushEmpty(int, bool, int, bool)
0x39: PushEmpty(bool, int, int)
0x3a: Stack[-2] = Stack[-10]
0x3b: Stack[-1] = Stack[-9]
0x3c: Call2 0x47
0x47: PushEmpty(int, int)
0x48: @ irand(Stack[-1], Stack[-3])
0x49: Pop(0)
0x4a: Stack[-5] = (bool) Stack[-1] < Stack[-4]); Pop(1)
0x4b: Return(2)
0x3d: Pop(2)
0x3e: IF (Stack[-1] == 0) GOTO 0x46; Pop(1)
0x3f: @ irand(Stack[-2], Stack[-5])
0x40: Pop(0)
0x41: Push((int) 0)
0x42: Push((int) 1)
0x43: Stack[-4] += Stack[-1]
0x44: @ AddItem(Stack[-3], Stack[-10], Stack[-2], Stack[-1])
0x45: Pop(2)
0x46: Return(4)
Скрипт считает вероятность появления предмета в урне, и если игроку везет, то через функцию AddItem в урну добавляется объект bottle_empty, то есть пустая бутылка. Подсмотрев в другом скрипте имя powder — название объекта «порошочек», заменим в скрипте одну строку другой.
Выводы
Публикация отладочной информации вместе с игрой открыла дорогу к восстановлению кода и созданию модификаций для множества игр.До создания полноценного SDK еще много работы, но начало положено. Декомпилятор далек от идеала, не все команды отображаются корректно. Следующим шагом будет декомпиляция в С‑подобный код вроде Hex-Rays. Сегодня мы обрели бесценный опыт реверса софта на C++, а комьюнити получило инструмент для создания новых модов, пусть пока еще и сырой.