
Splinter Cell (2002) была одной из первых игр, купленных мной для Xbox, и она по-прежнему остаётся одной из самых любимых моих игр. Эта игра была разработана Ubisoft на движке Unreal Engine 2, лицензированном у небольшой инди-студии Epic Games, которая и сегодня продолжает использовать и лицензировать этот движок в современных малобюджетных инди-играх наподобие Fortnite и Halo: Campaign Evolved.
Я начал заниматься программированием/хакингом благодаря видеоиграм, и до сих пор получаю удовольствие от дата-майнинга/исследования контента, вырезанного из тех немногих игр, в которые играю сегодня. Недавно я решил поискать онлайн вырезанный контент Splinter Cell, и был удивлён отсутствием раскопанной информации. За исключением прототипа игры для Xbox, в котором содержались два уровня, вырезанные из розничной версии для Xbox и некоторые другие мелкие отличия, информации об игре практически нет.
Естественно, я решил законным образом создать резервную копию своего личного диска с игрой и приступил к ковырянию в файлах.
Изначально я планировал изучить формат игровых данных и разведать любые признаки вырезанного контента: текстуры, модели, любопытные строки... Интересными находками стали бы отладочные меню, голосовые файлы, концепции оружия или уровни, недоступные при обычном прохождении игры.
Дерево файлов игры (урезанное) выглядит так:
.
├── contentimage.xbx
├── dashupdate.xbe
├── default.xbe
├── downloader.xbe
├── dynamicxbox.umd
├── LMaps
│ ├── 000_menu
│ │ ├── common.lin
│ │ └── menu.lin
│ ├── 001_Training
│ │ ├── 0_0_2_Training.bik
│ │ ├── 0_0_2_Training.lin
│ │ ├── 0_0_2_Training_progress.tga
│ │ ├── 0_0_2_Training_start.tga
│ │ ├── 0_0_3_Training.lin
│ │ ├── 0_0_3_Training_complete.tga
│ │ ├── 0_0_3_Training_progress.tga
│ │ ├── common.lin
│ │ └── French
│ │ ├── 0_0_2_Training_progress.tga
│ │ ├── 0_0_2_Training_start.tga
│ │ ├── 0_0_3_Training_complete.tga
│ │ └── 0_0_3_Training_progress.tga
.xbe — это исполняемые файлы Xbox, .bik — файлы Bink Video, а .tga — изображения... Но .lin оказались для меня чем-то новым.
В Splinter Cell карты разделены на части. Например, в обучающей миссии 001_Training, вероятно, будет 0_0_2_Training.lin для первой части и 0_0_3_Training.lin для второй; они загружаются при переходе к определённой зоне карты.
Я сразу подумал, что common.lin может содержать данные, общие для обеих частей, что позволило бы снизить размер файла. В играх серии Halo, например, есть shared.map , содержащий общие для всех карт ресурсы; игра загружает данные по фиксированному адресу, чтобы файл можно было тривиальным образом преобразовать из двоичного блоба в структуры данных в памяти.
При изучении файла common.lin в шестнадцатеричном редакторе стали сразу же заметны следующие аспекты:
┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐
│00000000│ 04 00 00 00 0c 00 00 00 ┊ 78 9c 7b d7 97 c2 0000 │........┊x.{.....│
│00000010│ 06 2e 01 e1 04 00 00 00 ┊ 0c 00 00 00 78 9c 63 60 │........┊....x.c`│
│00000020│ 90 66 00 00 00 3a 00 1c ┊ 04 00 00 00 0c 00 00 00 │.f...:..┊........│
│00000030│ 78 9c 73 48 67 60 00 00 ┊ 02 39 00 a8 04 00 00 00 │x.sHg`..┊.9......│
│00000040│ 0c 00 00 00 78 9c b3 e0 ┊ 65 60 00 00 01 0b 00 46 │....x...┊e`.....F│
└────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘
Данные в интервалах
0x0..0x4и0x4..0x8— это 32-битные integer в little-endian:0x00000004и0x0000000C.По смещению
0x8, похоже, находится сжатый zlib блок данных, обозначенный в режиме ASCII, как «x», а в шестнадцатеричном режиме — как0x78 0x9c.По смещению
0x14, есть ещё одна такая последовательность:0xCбайт после смещения данных zlib (0x8), и ещё одна по смещению0x28.
Предположительно, здесь используется такой повторяющийся формат: {длина_распакованных_данных, длина_сжатых_данных, блок_zlib[длина_сжатых_данных]}.
Я написал небольшой инструмент для распаковки архива, и без проблем получил 64-килобайтный файл, с четырьмя u32 в начале. Так как эти четыре числа находятся в отдельных сжатых zlib блоках, я решил, что они не относятся к основным данным. Позже я выполнил их реверс-инжиниринг и разобрался, как они используются:
размер_распакованных_данных: 0x648EEE
размер_текстурного_кэша? - позже используется при вызове D3DDevice_CreateTexture2: 0x1B0000
размер_буфера_вершин? - используется при вызове D3DDevice_CreateVertexBuffer2: 0x6740
размер_буфера_индексов? - используется при вызове XGSetIndexBufferHeader: 0xD38
А вот, как выглядят первые 0x100 байт раздела основных данных:
┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐
│00000000│ 5c 58 9e 13 00 a3 c5 e3 ┊ 9f b4 92 9b 13 5c 58 9e │\X......┊.....\X.│
│00000010│ 13 01 00 00 00 04 2a d6 ┊ fe 7e 37 13 4d 61 70 73 │......*.┊.~7.Maps│
│00000020│ 5c 6d 65 6e 75 5c 6d 65 ┊ 6e 75 2e 75 6e 72 00 00 │\menu\me┊nu.unr..│
│00000030│ 00 00 00 ee de 00 00 00 ┊ 00 00 00 16 4d 61 70 73 │........┊....Maps│
│00000040│ 5c 31 5f 31 5f 30 54 62 ┊ 69 6c 69 73 69 2e 75 6e │\1_1_0Tb┊ilisi.un│
│00000050│ 72 00 f0 de 00 00 6d c9 ┊ 17 00 00 00 00 00 16 4d │r.....m.┊.......M│
│00000060│ 61 70 73 5c 31 5f 31 5f ┊ 31 54 62 69 6c 69 73 69 │aps\1_1_┊1Tbilisi│
│00000070│ 2e 75 6e 72 00 60 a8 18 ┊ 00 98 34 21 00 00 00 00 │.unr.`..┊..4!....│
│00000080│ 00 16 4d 61 70 73 5c 31 ┊ 5f 31 5f 32 54 62 69 6c │..Maps\1┊_1_2Tbil│
│00000090│ 69 73 69 2e 75 6e 72 00 ┊ 00 dd 39 00 89 63 19 00 │isi.unr.┊..9..c..│
│000000a0│ 00 00 00 00 18 4d 61 70 ┊ 73 5c 30 5f 30 5f 32 5f │.....Map┊s\0_0_2_│
│000000b0│ 54 72 61 69 6e 69 6e 67 ┊ 2e 75 6e 72 00 90 40 53 │Training┊.unr..@S│
│000000c0│ 00 0f 9f 0c 00 00 00 00 ┊ 00 18 4d 61 70 73 5c 30 │........┊..Maps\0│
│000000d0│ 5f 30 5f 33 5f 54 72 61 ┊ 69 6e 69 6e 67 2e 75 6e │_0_3_Tra┊ining.un│
│000000e0│ 72 00 a0 df 5f 00 48 86 ┊ 11 00 00 00 00 00 1e 4d │r..._.H.┊.......M│
│000000f0│ 61 70 73 5c 31 5f 32 5f ┊ 31 44 65 66 65 6e 73 65 │aps\1_2_┊1Defense│
└────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘
Вот, что находится в конце таблицы файлов:
┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐
│0002c580│ 79 6e 63 68 5c 69 6e 74 ┊ 5c 55 73 61 53 6f 6c 64 │ynch\int┊\UsaSold│
│0002c590│ 69 65 72 5c 55 53 4f 55 ┊ 4e 43 5f 33 2e 62 69 6e │ier\USOU┊NC_3.bin│
│0002c5a0│ 00 40 8d 9b 13 74 05 00 ┊ 00 00 00 00 00 c1 83 2a │.@...t..┊.......*│
│0002c5b0│ 9e 64 00 11 00 01 00 00 ┊ 00 10 0e 00 00 88 00 00 │.d......┊........│
│0002c5c0│ 00 fa 0f 00 00 f3 7a 11 ┊ 00 4e 00 00 00 3e 78 11 │......z.┊.N...>x.│
│0002c5d0│ 00 de ad f0 0f 42 01 9c ┊ 90 92 8f 96 93 9e 8b 96 │.....B..┊........│
│0002c5e0│ 90 91 9a 9c 97 9a 93 90 ┊ 91 df af bc ba bc b7 ba │........┊........│
│0002c5f0│ b3 b0 b1 df a6 c5 a3 ba ┊ bc b7 ba b3 b0 b1 a3 ac │........┊........│
│0002c600│ a6 ac ab ba b2 a3 df ce ┊ cf d0 cd c9 d0 cf cd df │........┊........│
│0002c610│ cd ce c5 cf cd c5 ce cb ┊ ff 00 00 00 00 00 00 00 │........┊........│
│0002c620│ 00 00 00 00 00 00 00 00 ┊ 00 01 00 00 00 fa 0f 00 │........┊........│
│0002c630│ 00 10 0e 00 00 05 4e 6f ┊ 6e 65 00 10 04 07 04 06 │......No┊ne......│
│0002c640│ 43 6f 6c 6f 72 00 10 04 ┊ 07 04 0d 49 6e 74 65 72 │Color...┊...Inter│
│0002c650│ 6e 61 6c 54 69 6d 65 00 ┊ 10 00 07 00 07 45 6e 67 │nalTime.┊.....Eng│
│0002c660│ 69 6e 65 00 10 00 07 04 ┊ 05 43 6f 72 65 00 10 00 │ine.....┊.Core...│
│0002c670│ 07 04 07 53 79 73 74 65 ┊ 6d 00 10 00 07 04 06 55 │...Syste┊m......U│
└────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘
Чтобы сэкономить время, я не буду говорить о своём процессе проб и ошибок, и просто перечислю ресурсы, на которых обсуждается этот формат:
В последних двух постах есть информация о структуре, которая помогла мне проанализировать формат упакованных int (это UTF-8 и кодирование переменной длины) и пару неизвестных переменных.
Из этих постов я понял, что за всё это время никто так и не разобрался в особенностях формата настолько глубоко, чтобы распаковать данные. Похоже, все думают, что создаётся некая виртуальная файловая система, а данные привязываются к конкретному адресу, и затем считываются. Возможно, так и происходит в каких-то играх/на каких-то консолях, но не в этом случае.
Теперь моя цель стала иной: я захотел выполнить реверс-инжиниринг этого формата файлов, чтобы можно было дампить из этой файловой системы отдельные файлы. После чего можно было бы вернуться к моей основной цели: поиску вырезанного контента. А потом, возможно, я даже сыграю в игру.
Вкратце про общую структуру .lin
Структура common.lin отличается от структуры других файлов .lin; выглядит она примерно так:
/* ==== Стандартные данные ==== */
// Эти три переменные, полученные при исследовании и реверс-инжиниринге, нельзя рассматривать
// как часть "целого" файла
u32 maybe_load_address; // 5C 58 9E 13 (0x139e585c) в common.lin
compressed_int name_length; // 0 в common.lin
char name[name_length];
/* ==== Заголовок файла common.lin ==== */
u32 magic; // 0x9fe3c5a3 в little endian, то есть A3 C5 E3 9F
u32 unk_address; // B4 92 9B 13, (0x139b92b4) - подозрительно похоже на maybe_load_address.
// unk_address - load_address даёт нам начало таблицы файлов,
// относительно magic?
u32 load_address2; // 5C 58 9E 13 - то же, что и maybe_load_address
u8 unknown[8]; // 01 00 00 00 04 2A D6 FE
compressed_int file_entry_count;
FileEntry file_entries[file_entry_count];
struct FileEntry {
compressed_int name_len;
char name[name_len];
u32 offset;
u32 len;
u32 unk;
}
Сразу за таблицей FileEntry последовательно идут 54 файла Unreal Engine Package (идентифицируемые по их магическому 0x9E2A83C1; также они называются файлами компоновщика), которые, предположительно, соответствуют файлам в таблице файлов.
У относящихся к карте файлов наподобие menu.lin и 0_0_2_Training.lin нет таблицы файлов, но есть первые три поля (ненулевая строка наподобие «menu\x0» в качестве поля имени), после чего идёт последовательность файлов компоновщика.
Однако с таблицы файлов начинаются сложности с парсингом этих данных.
Проблемы
Таблица файлов
Таблица файлов имеет очень простой формат, который я смог распарсить своей программой:
FileEntry {
name: Maps\\menu\\menu.unr,
offset: 0x0,
len: 0xDEEE,
unk: 0x0,
},
FileEntry {
name: Maps\\1_1_0Tbilisi.unr,
offset: 0xDEF0,
len: 0x17C96D,
unk: 0x0,
},
FileEntry {
name: Maps\\1_1_1Tbilisi.unr,
offset: 0x18A860,
len: 0x213498,
unk: 0x0,
},
FileEntry {
name: Maps\\1_1_2Tbilisi.unr,
offset: 0x39DD00,
len: 0x196389,
unk: 0x0,
},
FileEntry {
name: Maps\\0_0_2_Training.unr,
offset: 0x534090,
len: 0xC9F0F,
unk: 0x0,
},
FileEntry {
name: Maps\\0_0_3_Training.unr,
offset: 0x5FDFA0,
len: 0x118648,
unk: 0x0,
},
FileEntry {
name: Maps\\1_2_1DefenseMinistry.unr,
offset: 0x7165F0,
len: 0x249AF6,
unk: 0x0,
},
FileEntry {
name: Maps\\1_2_2DefenseMinistry.unr,
offset: 0x9600F0,
len: 0x20F662,
unk: 0x0,
},
<вырезано>
Поначалу кажется, что файлы выстроены последовательно и выровнены по границе ширины указателей. Однако обратите внимание, что смещение последнего файла равно... 0x9600F0. Это намного дальше, чем мой файл длиной 0x648EEE, а этот список файлов содержит 3582 файла, а не 54, как ожидалось из количества магических значений Unreal Package!
Несовпадение количества файлов может быть объяснено тем, что не каждый файл в этом контейнере имеет тип Unreal Package, но смещения всё равно выглядят крайне неправильно.
Чтение файлов
После отладки игры в эмуляторе первого Xbox xemu, я смог найти подпрограмму, которая открывает файл, а также функцию, считывающую и распаковывающую данные.
Методология идентификации файлов
На случай, если кому-то любопытна методология: я идентифицировал NtCreateFile, установил контрольную точку, записал HANDLE , возвращаемый для пути к файлу, который мне важен, а затем установил контрольную точку на NtReadFile и прервал выполнение, когда входной HANDLE совпал с ожидаемым значением. Стек вызовов/пошаговое выполнение позволили идентифицировать интересующие меня вызывающие функции. Или же можно использовать строку «unknown compression method» для поиска подпрограммы распаковки inflateInit2.
Это не особо относится к основной теме поста, поэтому и спрятано под спойлер. Я ненавижу читать посты, в которых пропускаются любопытные мне детали, как будто это некое общеизвестное знание, поэтому сам стараюсь этого избегать :)

По сути, эта функция сравнивает запрошенный размер чтения с количеством данных, предварительно кэшированных в буфер распакованных данных. Затем она копирует максимально возможное количество данных из своего буфера предварительного кэширования в выходной буфер, а затем считывает следующий блок сжатых zlib данных в буфер предварительного кэширования, если предыдущий исчерпался. Этот процесс повторяется, пока не будет выполнен запрос.
Идентификация этой функции была довольно важной для моего процесса реверс-инжиниринга. Теперь я мог расставлять контрольные точки в коде, копирующем данные в выходной буфер, и проверять, кто вызывает эту функцию при считывании данных из интересующих меня смещений.
Я пошагово выполнил этот код, установил контрольные точки чтения из памяти на данных, которые пока не понимал, и сразу заметил нечто любопытное!
Что за «адреса» из заголовка (0x139e585c)? Они передаются чему-то, что, по моему предположению, является подпрограммой Seek , обновляющей свойство position функции чтения файла, которая затем выполняет косвенный вызов другой функции, которая в буквальном смысле ничего не делает.
Вот всё содержимое функции:
retn 4
Вот и всё.
А затем чтение просто... продолжается с последней позиции? Так как функция вызывается косвенно, можно только предположить, что это некий объект-компонент C++, в котором объект внешнего класса обновляет свою position в Seek(), а затем вызывает внутреннюю Seek() функции чтения файла... которая оказывается no-op?
Установив контрольные точки чтения из памяти на поле объекта position, я заметил, что оно используется только в эквиваленте функции чтения памяти FTell(). Оно никак не влияет на то, откуда считываются данные.
Причина того, что Seek() оказалась no-op, вероятно, заключается в том, что внутренняя функция чтения файлов выполняет считывание непосредственно из буфера сжатых данных, который считывается блоками по 0x4000 байт. Так как нельзя сопоставить смещение несжатых данных со смещением сжатых, формат должен быть таким, чтобы игнорировал поиск (seek) и просто читал данные линейно.
...то есть расширение .lin становится гораздо более логичным.
? Чтобы считывать эти файлы, необходимо предположить, что выполнять поиск вперёд/назад невозможно. Довольно просто.
Порядок загрузки важен
Но у нас всё равно остаётся нерешённая проблема: почему в таблице файлов есть большое количество файлов с неправильными смещениями?
Я продолжил расставлять контрольные точки внутри функции чтения файлов, чтобы оттрасировать места чтения интересных битов данных, и принудительно останавливал исполнение, когда считывались данные, идущие непосредственно после таблицы файлов. Наконец-то я оттрасировал операцию чтения файлов достаточно далеко, чтобы найти функцию StaticLoadObject:

Эта функция вызывает ResolveName , аргументы которой мне удалось записать в лог при помощи скрипта контрольных точек отладчика; это позволило мне определить, что InName — это ini:Engine.Engine.GameEngine:

Имя ini:Engine.Engine.GameEngine можно распарсить так:
ini:<- ресолвинг имени из файлов INI игрыEngine.Engine<- таблица INI, из которой нужно выполнять чтениеGameEngine<- ключ из таблицы, который нужно считать
В файле UW.ini из комплекта игры эта таблица определена так:
[Engine.Engine]
RenderDevice=D3DDrv.D3DRenderDevice
GameRenderDevice=D3DDrv.D3DRenderDevice
AudioDevice=XboxAudio.XboxAudioSubsystem
Console=Engine.Console
DefaultPlayerMenu=UPreview.UPreviewRootWindow
Language=int
GameEngine=Engine.GameEngine
EditorEngine=Editor.EditorEngine
WindowedRenderDevice=D3DDrv.D3DRenderDevice
DefaultGame=Echelon.EchelonGameInfo
DefaultServerGame=WarfareGame.WarfareTeamGame
ViewportManager=XboxDrv.XboxClient
Render=Render.Render
Input=Engine.Input
Canvas=Echelon.ECanvas
Editor3DRenderDevice=D3DDrv.D3DRenderDevice
То есть конечное значение, возвращаемое из этой функции — Engine.GameEngine, соответствующе тому, что ресолвит эта функция.
Затем оно используется для ресолвинга пакета Engine и его экспортируемого объекта GameEngine. Двоичный файл игры ищет файл Engine в своих имеющихся ресурсах (стратегия частичного сопоставления), в том числе и в таблице файлов LIN, а затем ресолвит это имя как System\Engine.u. Мой инструмент, считывающий таблицу файлов, подтверждает, что оно объявлено в файле LIN:
FileEntry {
name: System\\Engine.u,
offset: 0x13482120,
len: 0x127DA1,
unk: 0x0,
},
Только смещение начала файла + len совершенно нелогичны. Если предположить, что Engine.u — это первый файл, идущий непосредственно после таблицы файлов, то перейдя вперёд на его длину, мы, похоже, окажемся внутри какой-то строки?
┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐
│00154330│ 09 45 4d 65 73 68 53 46 ┊ 58 00 10 00 07 00 1b 43 │.EMeshSF┊X......C│
│00154340│ 68 61 6e 64 65 72 6c 65 ┊ 72 43 72 79 73 74 61 6c │handerle┊rCrystal│
│00154350│ 50 61 72 74 69 63 75 6c ┊ 65 00 10 00 07 00 12 46 │Particul┊e......F│
└────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘
Сэкономлю время и просто скажу, что я не идентифицировал ошибочный файл. Просто длины здесь неважны, и во всех отношениях неверны. Значит, функция чтения в игровом движке просто должна считывать данные по порядку, используя их собственное описание в заголовке?
Формат пакетов/файлов компоновщика Unreal Engine хорошо задокументирован; он действительно содержит в заголовке какие-то размеры. Пакеты хранят примерно то, что и можно ожидать от формата скриптов/данных какого-нибудь объектно-ориентированного программирования (ООП).
В нём содержатся экспортированные объекты, то есть именованные экземпляры некого типа ООП, имеющие свойства и данные. Или объект может быть определением класса/struct. Эти экспорты могут зависеть от типов, экспортированных из других пакетов, которые объявляются, как импорты. У обоих есть имена или строковые данные, определённые в таблице имён.
Я сопоставил данные из спецификации со следующей struct Rust:
pub struct PackageHeader<'i> {
pub version: u32,
pub flags: u32,
pub name_count: u32,
pub name_offset: u32,
pub export_count: u32,
pub export_offset: u32,
pub import_count: u32,
pub import_offset: u32,
// Примечание: этого нет в представленном выше задокументированном описании
pub unk: u32,
// То же самое.
// Не показано: в этой позиции находится сжатый int длины этих данных
pub unknown_data: &'i [u8],
pub guid_a: u32,
pub guid_b: u32,
pub guid_c: u32,
pub guid_d: u32,
// Не показано: в этой позиции находится сжатый int длины этих данных
pub generations: Vec<GenerationInfo>,
}
И, разумеется, смещения в этом формате тоже нельзя использовать (например, name_offset переносит нас в место после начала таблицы имён). Но количество выглядит нормально:
PackageHeader {
version: 0x110064,
flags: 0x1,
name_count: 0xE10,
name_offset: 0x88,
export_count: 0xFFA,
export_offset: 0x117AF3,
import_count: 0x4E,
import_offset: 0x11783E,
unk: 0xFF0ADDE,
unknown_data: [
...
]
guid_a: 0x0,
guid_b: 0x0,
guid_c: 0x0,
guid_d: 0x0,
generations: [
GenerationInfo {
export_count: 0xFFA,
name_count: 0xE10,
},
],
}
Дополнив мой инструмент так, чтобы он считывал эти таблицы, выполняя парсинг с предположением о том, что за ними сразу следует этот заголовок и следующая таблица, я получил импорты, которые выглядят так:
Package Core.Core
Import { class_package: 4, class_name: B64, package_index: 0, object_name: 4, object: None }
Class Core.Object
Import { class_package: 4, class_name: B62, package_index: FFFFFFFF, object_name: 13, object: None }
Class Core.Function
Import { class_package: 4, class_name: B62, package_index: FFFFFFFF, object_name: BBD, object: None }
Экспорты выглядят так:
Class Actor
(0x0) ObjectExport {
class_index: 0x0,
super_index: 0xFFFFFFFE,
package_index: 0x0,
object_name: 0x206,
object_flags: 0x40F0004,
serial_size: 0x3A8,
serial_offset: 0xF719,
}
Class Pawn
(0x1) ObjectExport {
class_index: 0x0,
super_index: 0x1,
package_index: 0x0,
object_name: 0x1A,
object_flags: 0x40F0004,
serial_size: 0x281,
serial_offset: 0xFAC1,
}
...
Class GameEngine
(0xEFB) ObjectExport {
class_index: 0x0,
super_index: 0x1C8,
package_index: 0x0,
object_name: 0x1D8,
object_flags: 0x40F0004,
serial_size: 0x5B,
serial_offset: 0xC50DB,
}
То есть у объекта GameEngine есть индекс экспорта 0xEFB , а его данные, предположительно, расположены по смещению 0xC50DB относительно начала пакета. Если вы думаете, что смещение неправильное, то вы угадали!
Данные экспорта
Что мы уже знаем:
Выполнять поиск в функции чтения файлов нельзя.
Смещения не соответствуют представлению на диске и не используются ни для чего, кроме отслеживания позиции.
Размеры (как минимум для таблицы файлов, а позже я понял, и что в данных экспорта) некорректны.
Мы знаем, что
GameEngine— это первый объект, запрашиваемый на стороне C++ игры, и что он имеет индекс экспорта0xEFBв пакетеEngine. Может быть, это и не первый объект, который парсится, но первый запрашиваемый.
Чтобы выполнить свою задачу по дампингу этих файлов, я попробовал просто суммировать размер этих экспортов, чтобы понять последнее смещение файла... но попробовав сочетания этого вычисленного размера + любого из смещений {end_of_export_table, start_of_file}, я оказывался в странных местах, между которыми находились другие файлы компоновщика.
Чтобы заполнить пробелы, я сослался на Unreal-Library, и обнаружил в игровом движке следующую высокоуровневую логику парсинга:
Экспортированный объект запрашивается игрой. Если он ещё не загружен, выполняется ленивая загрузка.
Ленивая загрузка требует ресолвинга объекта типа
super. В каких-то случаях это базовые типыClassилиStruct, в других — иной родительский класс, в конечном итоге родительским типом которого оказываетсяClass.Экспорты обладают свойствами, которые могут иметь переменную длину. При чтении экспорта мы десериализируем его данные согласно его полям
serial_sizeиserial_offset, однако типы, экспортированные со стороны C++, определяют подпрограмму десериализации.
При ресолвинге импортов/экспортов это приводит к следующей картине:

Чтобы показать конкретный пример, представим, что GameEngine имеет следующую иерархию классов:
GameEngine -> Engine -> Subsystem -> Class
Также представим, что GameEngine — это первый объект, который парсится, ничего кроме него пока ещё не загружено. При запросе загрузки GameEngine из пакета Engine.u выполнится такая последовательность событий:
Чтение/парсинг заголовка
Engine.u(потому что пока не было создано ни одного пакета).Поиск экспорта
GameEngineEngine. Его парсинг пока ещё не выполнен, поэтому нужно создать этот объект, сконструировав/десериализировав его.Родительский класс
GameEngine— этоEngine.Engine. Его парсинг пока ещё не выполнен, поэтому нужно десериализировать его доGameEngine.Core.Subsystem— это родительский классEngine.Engine. Аналогично.Чтение/парсинг заголовка
Core.u(потому чтоCoreпока ещё не загружен)Core.Class— родительский классCore.Subsystem(и базовый класс). Конструируем этот объект.Десериализация свойства
Core.Class. Теперь можно продолжить созданиеCore.Subsystem.Десериализация свойства
Core.Subsystem...Десериализация свойства
Engine.Engine...Десериализация свойства
Engine.GameEngine...Теперь можно вернуть полностью сконструированный
Engine.GameEngine.
К сожалению, похоже, это может привести к чередованию данных экспорта. В случае описанного выше сценария данные могут находиться на диске согласно схеме ниже. Примечание: для упрощения я опустил Core.Class, а также потенциальный запуск десериализации других экспортов самими свойствами.
┌─────────────────────────────────────────────────────────────┐
│ │
│ │
│ Таблица файлов │
│ │
│ │
│ │
├───────────────────────────┬─────────────────────────────────┤
│Заголовок Core.u │ Заголовок Engine.u │
│ │ │
│ │ │
├────┬────┬─────────────────┴──────────┬──────────────────────┤
│ │▰▰▰▰│▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰│ │
│ │▰▰▰▰│▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰│ │ │ │
│ │▰▰▰▰│▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰│ │ │ │
│ ▲ │ ▲ ▰│▰▰ ▲ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰│ ▲ │ ▲│ ▲ │
├──┼─┴─┼──┴───┼────────────────────────┴────────┼─┴───┼┴───┼──┤
│ │ │ │ │ │ │ │
│ │ │ ┌─┴──────────────────────────┐ │ │ │ │
│ │ │ │ Данные экспорта Core.Subsystem │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │ │
│ │ ┌─┴────────────────────────────────┐ │ │ │ │
│ │ │ Данные экспорта Engine (суперкласс) │ │ │ │
│ │ └──────────────────────────────────┘ │ │ │ │
│ │ ┌─────────┴─────┴───────┴──┤
│┌─┴────────────────────────┐ │ Свойства объекта │
││ Начало объекта GameEngine│ │ GameEngine │
│└──────────────────────────┘ └──────────────────────────┤
└─────────────────────────────────────────────────────────────┘
А теперь, если представить, что есть второй объект, тоже примыкающий к загруженному Engine после GameEngine, то их общий суперкласс Engine уже был распарсен, и его информация уже находится в памяти. То есть если сериализовать два объекта одного и того же типа, то у первого объекта все данные его родительского класса могут чередоваться с его собственными данными экспорта, а второй объект будет содержать собственные данные свойств.
К сожалению, это означает, что для статического чтения этих файлов (даже просто для статической рекомпиляции) необходимы полные знания о том, как парсится каждый реализованный C++ тип, чтобы иметь возможность парсинга всех экспортов и их свойств. Кроме того, чтение одного экспорта может вызвать ресолвинг импортов в вашем собственном объекте компоновщика, что, в свою очередь, может привести к десериализации экспортов в другом объекте компоновщика.
Это приводит к тому, что размер данных экспорта необязательно ошибочен сам по себе, однако он не будет особо полезен без выполнения полного парсинга. Если в процессе десериализации экспорта будут десериализоваться другие экспорты, они будут выполнять поиск и восстанавливать исходную позицию. После завершения десериализации экспорт вычитает position после десериализации функции чтения файлов из сохранённой position до десериализации, и выполняет assert того, что она равна ожидаемой длине экспорта. Однако это запутывает, потому что мы не можем просто считать SerialSize байт из этого смещения.
Примечание: я не полностью уверен, что данные чередуются, а не идут последовательно. При наблюдении за операциями поиска/чтения различных экспортов я видел, как поиск переходил в совершенно иное смещение посередине десериализации экспорта, затем происходила ещё одна десериализация экспорта, потом снова поиск приводил к исходному экспорту и снова продолжал его десериализацию. Однако всё это ужасно сложно отлаживать.
Почему??????
Я думаю, что для упаковки данных таким образом имелась очень веская причина. Чтобы понять её, нужно учесть ограничения того времени:
Игра продавалась на физическом диске.
У Xbox 64 МБ общей для CPU и GPU памяти, часть которой занимает операционная система.
В то время CPU не были такими уж медленными, но пустая трата тактов всё равно была бы заметна.
Формат .lin снижает серьёзность этих проблем следующим образом:
Благодаря сжатию данных экономится место на диске... Если только забыть о том, что
common.linдублируется в папке каждой карты и одинаков для всех протестированных мной карт.Потоковая передача данных из файла вместо распаковки его целиком снижает общую нагрузку на память на этапе загрузки данных.
Выстраивание файла в точном побайтовом порядке чтения повышает скорость ввода вывода благодаря тому, что не нужно выполнять поиск по физическому носителю, и гарантирует, что нам не понадобится магия для высокопроизводительного преобразования несжатого смещения в сжатое.
Логгинг порядка загрузки для статической рекомпиляции
Мне очень хотелось избежать выполнения дампинга в среде исполнения, для которого бы понадобилось играть в игру в эмуляторе или на физической консоли. Это решение плохо применимо к другим играм и в общем случае менее гибко. Но наблюдения в среде исполнения очень полезны для анализа формата, поэтому я добавил логгинг, чтобы получить представление о порядке чтения файла из сжатого архива при запуске игры:
..\System\Engine.u
..\System\Core.u
..\System\Echelon.u
..\Textures\HUD.utx
..\Sounds\FisherFoley.uax
..\Sounds\CommonMusic.uax
..\System\EchelonEffect.u
..\Textures\ETexSFX.utx
..\Textures\2-1_CIA_tex.utx
..\Textures\generic_shaders.utx
..\Textures\LightGenTex.utx
..\Textures\5_1_PresidentialPalace_tex.utx
..\Textures\1_2_Def_Ministry_tex.utx
..\Textures\EGO_Tex.utx
..\Textures\ETexIngredient.utx
..\Textures\1-1_TBilisi_tex.utx
..\Textures\1_3_CaspianOilRefinery_TEX.utx
..\StaticMeshes\EMeshSFX.usx
..\StaticMeshes\EGO_OBJ.usx
..\Textures\ETexCharacter.utx
..\Textures\4_3_Chinese_Embassy_tex.utx
..\Textures\4_3_0_Chinese_Embassy_tex.utx
..\Textures\4_3_2_Chinese_Embassy_tex.utx
..\Sounds\water.uax
..\Sounds\DestroyableObjet.uax
..\Sounds\FisherVoice.uax
..\Sounds\FisherEquipement.uax
..\Sounds\GunCommon.uax
..\Sounds\Interface.uax
..\Sounds\Electronic.uax
..\Sounds\Dog.uax
..\Sounds\Lambert.uax
..\StaticMeshes\EMeshIngredient.usx
..\StaticMeshes\EMeshCharacter.usx
..\Textures\2_2_1_Kalinatek_tex.utx
..\StaticMeshes\LightGenOBJ.usx
..\Textures\ETexRenderer.utx
..\Sounds\Door.uax
..\Sounds\GenericLife.uax
..\Sounds\Special.uax
..\Sounds\ThrowObject.uax
..\StaticMeshes\Generic_Mesh.usx
..\StaticMeshes\prog\generic_obj.usx
..\Textures\0_0_Training_tex.utx
..\Textures\3_4_Severo_tex.utx
..\System\EchelonIngredient.u
..\Sounds\Gun.uax
..\System\EchelonGameObject.u
..\Animations\ESkelIngredients.ukx
..\Sounds\Metal.uax
..\Animations\ETrk.ukx
..\StaticMeshes\2-1_cia_obj.usx
..\System\EchelonHUD.u
..\Animations\ESam.ukx
..\Maps\menu\menu.unr // <--- 55
..\Textures\2_2_Kalinatek_tex.utx
..\StaticMeshes\2_2_Kalinatek_OBJ.usx
..\System\EchelonPattern.u
..\Sounds\S3_4_2Voice.uax
..\Sounds\S3_4_3Voice.uax
..\Sounds\S2_2_2Voice.uax
..\Sounds\S2_1_2Voice.uax
..\Sounds\S5_1_2Voice.uax
..\Sounds\S3_2_2Voice.uax
..\Sounds\S4_2_2Voice.uax
..\Sounds\S4_1_1Voice.uax
..\Sounds\S1_2_1Voice.uax
..\Sounds\S1_1_2Voice.uax
..\Sounds\S0_0_3Voice.uax
..\Sounds\S3_2_1Voice.uax
..\Sounds\S4_2_1Voice.uax
..\Sounds\S1_3_3Voice.uax
..\Sounds\S0_0_2Voice.uax
..\Sounds\S4_3_2Voice.uax
..\Sounds\S1_1_1Voice.uax
..\Sounds\S2_2_1Voice.uax
..\Sounds\S4_3_1Voice.uax
..\Sounds\S5_1_1Voice.uax
..\Sounds\S4_1_2Voice.uax
..\Sounds\S2_1_1Voice.uax
..\Sounds\S1_1_0Voice.uax
..\Sounds\S2_2_3Voice.uax
..\Sounds\S2_1_0Voice.uax
..\Sounds\S1_2_2Voice.uax
..\Sounds\Vehicules.uax
..\Sounds\S1_1_Voice.uax
..\Sounds\S2_1_Voice.uax
..\Sounds\S4_3_0Voice.uax
..\Sounds\S1_3_2Voice.uax
..\Sounds\Machine.uax
..\Sounds\FireSound.uax
..\Sounds\SoundEvent.uax
..\Sounds\S0_0_Voice.uax
..\Sounds\S4_3_Voice.uax
..\Sounds\S4_2_Voice.uax
..\Sounds\S5_1_Voice.uax
..\Sounds\XboxLive.uax
..\System\EchelonCharacter.u
..\Sounds\GearCommon.uax
..\Animations\ENPC.ukx
..\Sounds\Exspetsnaz.uax
..\Sounds\GeorgianSoldier.uax
..\Sounds\RussianMafioso.uax
..\Sounds\GeorgianCop.uax
..\Sounds\EliteForce.uax
..\Sounds\CiaSecurity.uax
..\Sounds\CiaAgentMale.uax
..\Sounds\ChineseSoldier.uax
..\Animations\EFemale.ukx
..\Animations\EDog.ukx
..\Sounds\GeorgianPalaceGuard.uax
Скрипт дампинга файла
Я установил контрольную точку в прологе функции со строкой «LinkerExists»; позже выяснилось, что это конструктор объекта ULinkerLoad. Один из аргументов — имя файла для этого объекта.
При срабатывании контрольная точка выполняет в IDA следующий скрипт на Python, считывающий указатель имени файла, затем имя файла, выводит его в консоль IDA и продолжает исполнение:
import ida_idd, ida_kernwin, ctypes
p=ida_dbg.get_reg_val("ebx")
s=b""
while True:
c = ida_idd.dbg_read_memory(p,2)
if not c or c == b"\x00\x00": break
s += c; p+=2
ida_kernwin.msg("ULinkerLoad: " + s.decode('utf-16-le')+"\n")
В показанном выше списке я пометил файл 55 ..\Maps\menu\menu.unr. Файл common.lin содержит 54 файла компоновщика, а номер 55 в списке — это загружаемая карта, имеющая собственный файл .lin. Это указывает на то, что архив common.lin содержит всего 54 файла, а всё остальное считывается из архивов конкретных уровней.
Также я установил контрольную точку в функции, десериализующей экспорты (Preload) и выполнил логгинг считываемого экспорта и точки выполнения поиска по потоку:
ULinkerLoad: ..\System\Engine.u
ULinkerLoad: ..\System\Core.u
Export offset: 0x0,0x0,0x0,0x97,0x40f0004,0x4d,0x1b05
Seeking to/from: 0x1b05,0x10883
Export offset: 0xfffffffe,0x0,0x3,0x13d,0x70004,0x1c,0x6531
Seeking to/from: 0x6531,0x1b18
Read complete: 0xfffffffe,0x0,0x3,0x13d,0x70004,0x1c,0x6531
Seeking to/from: 0x1b18,0x654d
Export offset: 0xfffffffe,0x0,0x3,0x13c,0x70004,0x1c,0x6515
Seeking to/from: 0x6515,0x1b18
Read complete: 0xfffffffe,0x0,0x3,0x13c,0x70004,0x1c,0x6515
Seeking to/from: 0x1b18,0x6531
Export offset: 0xfffffffe,0x0,0x3,0x119d,0x70004,0x2c,0x6432
Seeking to/from: 0x6432,0x1b18
Seeking to/from: 0x6451,0x6452
Seeking to/from: 0x6453,0x6454
Seeking to/from: 0x6454,0x6455
Seeking to/from: 0x6455,0x6456
Export offset: 0xfffffffd,0x0,0x2d7,0x477,0x70004,0xb,0x1c35
Seeking to/from: 0x1c35,0x6457
Read complete: 0xfffffffd,0x0,0x2d7,0x477,0x70004,0xb,0x1c35
Seeking to/from: 0x6457,0x1c40
Export offset: 0xfffffffd,0x0,0x2d7,0x46d,0x70004,0xb,0x2736
Скрипт предварительной загрузки экспорта
Python-скрипт контрольной точки IDA вызывается на элементе Preload , идентифицируемом по строке «SerialSize» и после подпрограммы десериализации:
import ida_dbg, ida_idd, ida_kernwin, ctypes, time
export_addr=ida_dbg.get_reg_val("ebp")
class_index = int.from_bytes(ida_idd.dbg_read_memory(export_addr, 4), "little")
super_index = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 4, 4), "little")
package_index = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 8, 4), "little")
object_name = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 12, 4), "little")
object_flags = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 16, 4), "little")
serial_size = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 20, 4), "little")
serial_offset = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 24, 4), "little")
edx=ida_dbg.get_reg_val("edx")
properties = [class_index, super_index, package_index, object_name, object_flags, serial_size, serial_offset]
ida_kernwin.msg("Export data: " + ",".join(hex(n) for n in properties) +"\n")
В операциях загрузки нет никакого отчётливого паттерна. Похоже, порядок загрузки файлов/экспортов просто удовлетворяет графу зависимостей (экспорты, требуемые для родительских элементов/свойств типов, которые нужно распарсить) для запрошенных со стороны C++ объектов.
Думаю, приемлемым компромиссом при статическом выполнении этого процесса будет обязательный дампинг порядка загрузки файлов/объектов из игры... но чтобы доказать жизнеспособность такого подхода, нужны исследования.
Я дополнил свою программу так, чтобы она считывала строки моих логов в очередь экспортов для парсинга, используя завершённые операции чтения (строки, начинающиеся с Read complete, а не с Export offset). Затем она пытается найти соответствующий экспорт в таблице экспортов пакета и считать его размер. Процесс повторяется до появления следующего объекта компоновщика, затем он парсится, добавляется в список, и процесс начинается заново.
Быстро выяснилось, что такой подход не работает с моей простой программой. Наступал этап, на котором она не могла найти соответствующий строке лога экспорт; вероятно, это вызвано тем, что я считываю не тот объём данных, который требуется для достижения следующего Unreal Package, в котором объявляется экспорт.
Или это баг, или какие-то типы пытаются выполнять поиск и считывание без срабатывания Preload(). Как бы то ни было, я потратил больше недели на статическое решение и у меня не получилось успешно сдампить какие-либо данные.
Дампинг в среде исполнения
В процессе описанных выше исследований я обнаружил проект EnhancedSC — фанатский патч для Splinter Cell 1 на PC, который устраняет баги и добавляет улучшения геймплея. Его разработчики определённо знают движок игры лучше меня. Я зашёл на их Discord и спросил, знает ли кто-нибудь об этом формате. Мне сказали, что все, кто пытался в нём разобраться, заходили в тупик.
Впрочем, их заинтересовал мой прогресс, потому что они хотели портировать какой-нибудь контент из версий Xbox в игры на PC. Это сообщество помогло мне различными теориями и мыслями, а также познакомило с инструментами наподобие UE-Explorer.
Потратив неделю на статическую рекомпиляцию, я больше не хотел вкладываться в безуспешные попытки дампинга файлов. Например, могло обнаружиться, что файлы сильно отличаются от ожидаемых, не будут работать на PC или работать с UE Explorer. Мне нужно было что-нибудь сдампить.
Очевидно, что игра без проблем считывает данные. Я подумал, что, возможно, стоит просто сдампить их после считывания в какой-нибудь формат, что упростит их анализ.
В процессе статического анализа я наткнулся на функцию, показавшуюся мне крайне любопытной. Я идентифицировал описанную выше функцию ULinkerLoad , поискал магические значения файлов Unreal Package (выделенные ниже) и обнаружил следующую функцию:

Как и ожидалось, магические значения файлов сверялись с тем, что считывается с диска. Но для магических значений нашёлся ещё один результат в другой функции, присваивающей полю какой-то структуры магическое число:

В чём задача этого кода? Оказалось, что пользовательские сохранения игры — это просто объекты Unreal, сериализованные в том же формате, без сжатия и прочих идущих с ним в комплекте странностей!

Патчинг двоичных файлов Xbox
Чтобы делать что-нибудь интересное, нам нужно запустить наш собственный код параллельно с игрой. Скрипты отладчика слишком медленные и ненадёжные, поэтому нам нужно что-то, работающее в эмуляторе или физическом устройстве. Было бы здорово, если бы мы написали и плагин QEMU для эмулятора... но это уже отдельная сложная задача.
В Windows или Unix инъецировать код в игру просто. В Windows можно создать CreateRemoteThread() или перехват DLL, а в Unix использовать LD_PRELOAD. На Xbox 360 можно «инъецировать» перманентные DLL. На первом Xbox есть только один процесс (насколько я знаю) и никаких DLL.
Вероятно, этому стоит посвятить отдельный пост, потому что сейчас информации довольно мало (покойся с миром, XboxHacker.org), но мне известны как минимум два инструмента, при помощи которых можно манипулировать исполняемыми файлами Xbox.
Библиотека Python pyxbe
Инструмент командной строки XboxImageExploder
Оба они позволяют добавлять в исполняемый файл новые разделы, по сути, создавая «нишу» кода, в которую можно помещать дополнительный код или данные. Когда система загружает образ, она согласует добавленный раздел с соответствующими разрешениями. Чтобы этот код выполнялся, нужно пропатчить одно место в исходном исполняемом файле.
При помощи XboxImageExploder и XePatcher мне удалось написать патч, вызывающий подпрограмму сериализации для объекта после его загрузки в память.
Краткое описание патча:
Определяем точку хука в конце функции
LoadMap(). Это определение заставляет XePatcher записать эти команды, выполняющие переход исполнения наHack_LoadMapв объявленном смещении файла.Hack_LoadMapвызываетHack_DumpAllLinkersи выполняет стандартную подчистку эпилога дляLoadMap(), которая не будет исполняться, потому что мы перехватили исполнение.Hack_DumpAllLinkersитеративно обходит глобальный список объектовLinkerи вызываетHack_DumpFileс этим компоновщиком в качестве аргумента.Hack_DumpFileобеспечивает создание папки вывода для файлаLinker, а затем вызывает функцию игры, выполняющую сериализациюLinkerпо этому пути. Например, файл компоновщика..\System\Engine.uиз файлаcommon.linбудет записан вz:\System\Engine.u.
;---------------------------------------------------------
; В самом начале подпрограммы LoadMap()
;---------------------------------------------------------
; смещение файла, не виртуальный адрес
dd 73698h
dd (_load_map_return_end - _load_map_return_start)
_load_map_return_start:
; Переход к нашей обходной функции
push esi
mov eax, Hack_LoadMap
jmp eax
_load_map_return_end:
_Hack_LoadMapCalled:
dd 0
_Hack_LoadMap:
mov eax, Hack_DumpAllLinkers
call eax
mov eax, Hack_LoadMapCalled
mov dword [eax], 1
_load_map_restore_registers:
; возврат значения, которое мы перехватили
; в хуке
pop eax
; Так как мы выполнили патчинг в прологе, достаточно просто
; произвести восстановление регистров
pop edi
pop esi
pop ebx
mov esp, ebp
pop ebp
retn 8
_Hack_DumpAllLinkers:
push ebx
push esi
%define g_ObjectLinkers 0033c42ch
; Загружаем количество linker
mov ebx, [g_ObjectLinkers + 4]
test ebx, ebx
jz _dump_all_linkers_restore_registers
; esi будет нашим индексом
mov esi, 0
_dump_all_linkers_linker_loop_start:
cmp esi, ebx
jz _dump_all_linkers_linker_loop_finish
; Итеративно обходим объекты linker
mov eax, [g_ObjectLinkers]
mov ecx, esi
imul ecx, 4
add eax, ecx
mov eax, [eax]
push eax
mov ecx, Hack_DumpFile
call ecx
add esp, (4 * 1)
_dump_all_linkers_linker_loop_end:
inc esi
jmp _dump_all_linkers_linker_loop_start
_dump_all_linkers_linker_loop_finish:
_dump_all_linkers_restore_registers:
pop esi
pop ebx
ret
_Hack_DumpFile:
; Загружаем аргумент, обозначающий
; сохраняемый объект
mov eax, [esp + 4]
; Сохраняем регистры
push edi
push esi
push ebx
mov edi, eax
_dump_file_do_dump:
; Итеративно выполняем экспорты объектов и сохраняем их флаги
; ==== НЕ ИСПОЛЬЗУЕТСЯ
; Получаем указатель на данные экспорта
;mov ecx, [edi + 0x88]
; Получаем количество экспортов
;mov ebx, [edi + 0x8C]
; ==== НЕ ИСПОЛЬЗУЕТСЯ
; Распределяем место для пути к файлу
sub esp, 0x200
; Получаем имя файла linker
mov eax, [edi + 0x98]
; Помещаем имя входящего файла в esi
mov esi, eax
; Если имя входящего файла пустое, переходим к подпрограмме подчистки,
; потому что это не файл, который находится в упакованном .lin
cmp word [eax], 0
jz _Hack_DumpFile_Done
;===== СОЗДАНИЕ ПАПКИ
; Путь к файлу располагается в начале стека
mov ebx, esp
; Устанавливаем имя файла в стеке равным `z:`
; Это должен быть char*, а не wchar_t*
mov byte [esp], 'z'
mov byte [esp + 1], ':'
; Здесь будет храниться наша позиция в создаваемом пути
mov ebx, 0
_Hack_DumpFile_File_Directory:
; Мы ищем обратную косую черту,
; это wchar_t `\`
push 0x005c
; Получаем позицию последней обратной косой черты
; для входящего файла
push esi
mov eax, appStrchr
call eax
add esp, (4 * 2)
; Не найдено
test eax, eax
jz _Hack_DumpFile_Directory_Finish
; Мы нашли обратную косую черту -- проверяем,
; что отбросили часть данных до косой черты (ожидается, что она должна
; начинаться с "..\" )
test ebx, ebx
jnz _Hack_DumpFile_File_Directory_Create_Directory
; Изменяем ebx так, чтобы он указывал на первую косую черту и мы в дальнейшем
; могли использовать его для копирования.
mov ebx, eax
jmp _hack_dumpfile_directory_end
_Hack_DumpFile_File_Directory_Create_Directory:
; Пропускаем часть Z: в пути конечного файла
lea ecx, [esp + 2]
push edx
push esi
; Начало пути к файлу linker
mov esi, ebx
; Копируем из ebx в eax
_hack_dump_file_copy_directory_loop:
cmp esi, eax
je _hack_dump_file_copy_directory_loop_finish
mov dl, [esi]
mov [ecx], dl
inc ecx
; Выполняем трюки преобразования wchar_t в char
add esi, 2
jmp _hack_dump_file_copy_directory_loop
_hack_dump_file_copy_directory_loop_finish:
; Добавляем нулевой символ в конце
mov byte [ecx], 0
pop esi
pop edx
mov ecx, esp
; Делаем так, чтобы мы не повредили eax
push eax
; Атрибуты
push 0x0
; Создаём эту папку
push ecx
mov ecx, CreateDirectory
call ecx
; функция cdecl, выполняющая подчистку
pop eax
_hack_dumpfile_directory_end:
; Сохраняем позицию
lea esi, [eax + 2]
jmp _Hack_DumpFile_File_Directory
_Hack_DumpFile_Directory_Finish:
; Задаём путь к файлу, который мы хотим скопировать
mov esi, ebx
;===== СОЗДАНИЕ ФАЙЛА
; Путь к файлу расположен в начале стека
mov ebx, esp
; Задаём начало VeryLongString равным `Z:`
push ZDrive
push ebx
mov eax, wstrcpy
call eax
add esp, (4 * 2)
; Задаём цель копирования равной байтам, идущим непосредственно
; за `z:`, чтобы результат был таким:
; `z:\filename`
lea eax, [ebx + 4]
; Копируем имя файла в буфер пути
push esi
; Задаём ESI значение полного пути к файлу для дальнейшего использования
mov esi, ebx
push eax
mov eax, wstrcpy
call eax
add esp, (4 * 2)
; Ошибка
mov edx, dword [GlobalError]
; InOuter
mov eax, [edi + 2Ch]
; Размер заполнения?
push 0xFFFFFFFF
; Соответствие
push 0x0
; Ошибка
push edx
; Имя файла
push esi
; TopLeveLFlags
push -1
; Основание
push edi
; InOuter
push eax
; ( UObject* InOuter,
; UObject* Base,
; DWORD TopLevelFlags,
; const TCHAR* Filename,
; FOutputDevice* Error=GError,
; ULinkerLoad* Conform=NULL );
mov eax, UObject_SavePackage
call eax
add esp, (7 * 4)
_Hack_DumpFile_Done:
; Восстанавливаем стек, чтобы подчистить
; путь к файлу
add esp, 0x200
; Восстанавливаем флаги экспорта
_dump_file_restore_registers:
; Восстанавливаем сохранённые регистры
pop ebx
pop esi
pop edi
ret
Результаты
Теперь мы можем считывать файлы вывода в UE Explorer и даже запускать на PC основное меню Xbox и ролики на движке из первого уровня... но с забагованным освещением и текстурами. Всё остальное после ролика первого уровня, в том числе и интерактивная часть самого уровня, загружаться отказывается.
Приведённый выше патч, выполняющий дамп в конце LoadMap(), приводил к самому надёжному из всех моих экспериментов дампингу файлов. Похоже, в конце этой функции почти все данные считаны и готовы, но после этой точки определённо десериализуется ещё пара объектов. Однако дампинг после всех считываний объектов, похоже, только ухудшает ситуацию; возможно, потому что свойства некоторых объектов поменялись в памяти по сравнению с их значениями по умолчанию?
Загружаем файл Xbox в UE Explorer:

Ролик первого уровня, который должен выглядеть, как тускло освещённый коридор с тенями:
Замена текстуры приводит к проблемам:

На самом деле, текстуры — это просто неполные данные. Так как объект со всеми текстурами имеет небольшой размер, я решил, что между моментом десериализации объекта и моментом его дампинга исходные данные текстур преобразуются в целевой формат, а исходные удаляются из памяти.
Чтобы подтвердить свою гипотезу, я установил контрольную точку в функции, запускающей десериализацию объектов, чтобы она прерывалась непосредственно перед этим:
Код контрольной точки целевого экспорта
При помощи следующего скрипта я установил контрольную точку в подпрограмме Preload() непосредственно перед десериализацией объектов.
import ida_dbg, ida_idd, ida_kernwin, ctypes, time
export_addr=ida_dbg.get_reg_val("ebp")
serial_offset = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 24, 4), "little")
class_index = int.from_bytes(ida_idd.dbg_read_memory(export_addr, 4), "little")
super_index = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 4, 4), "little")
package_index = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 8, 4), "little")
object_name = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 12, 4), "little")
object_flags = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 16, 4), "little")
serial_size = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 20, 4), "little")
serial_offset = int.from_bytes(ida_idd.dbg_read_memory(export_addr + 24, 4), "little")
edx=ida_dbg.get_reg_val("edx")
properties = [class_index, super_index, package_index, object_name, object_flags, serial_size, serial_offset]
ida_kernwin.msg("Export offset: " + ",".join(hex(n) for n in properties) +"\n")
# Break only when the object offset matches my target
return serial_offset == 0x11f65f
Затем я установил контрольную точку в функции чтения данных и изучил, куда копируются с диска данные текстур. Мне пришлось пошагово выполнять код, чтобы разобраться, откуда брался указатель на точку назначения, а сразу после него находились длина и объём динамического массива. Я установил контрольную точку записи в память на поле длины и дожидался, пока оно станет равным нулю. Там, где это происходило, находился код и realloc , которые я просто заменял на nop, чтобы избежать удаления данных текстур.
Модифицировав файл и снова сдампив данные, я заметил, что размер файла текстур существенно изменился, и у меня получились вот такие красивые текстуры:


Общие проблемы дампинга
Так как для экспортов выполняется ленивая загрузка, сдампить можно только то, что используется на уровне. Основное меню использует часть функциональности из Engine и Core. Поэтому если я загружал карту основного меню и дампил все linker после завершения загрузки, то получал только частичное представление Engine и Core.
Аналогично, всё, на что не было ссылок и что не использовалось из архива, нельзя было удобно восстановить без умного брутфорса, потому что мы не знаем, где начинаются данные. Например, в основном меню есть кисти, которые присутствуют в таблице экспортов, но, похоже, не используются, поэтому ничто не вызывает их загрузку.
Дальнейшие шаги
Хотя мы добились кое-каких успехов в дампинге данных, я не успокоюсь, пока не смогу дампить любую информацию, которую пожелаю. Важной вехой стала бы работа полной обучающей миссии с Xbox на PC. Я попробую двигаться в этом направлении, и если зайду в тупик, то, по крайней мере, попытаюсь сдампить данные из прототипа игры.
Этот формат определённо можно считывать статически, имея знание о порядке загрузки из игрового движка. Надеюсь, что кто-нибудь из сообщества сможет воспользоваться моей работой, чтобы всё это заработало в Unreal-Library.
Гораздо более обобщённым подходом стала бы статическая рекомпиляция; надеюсь, для неё понадобятся лишь два скрипта отладчика для дампинга имён файлов пакетов и загрузок экспортов, а не двоичный патч. Для SC1 они уже существуют. Сложность будет заключаться в добавлении этой системы в UELib (или какой-то другой проект) таким образом, чтобы это соответствовало точному поведению ввода-вывода в игре и десериализовало одновременно множество пакетов при помощи данных о порядке загрузки. Если вам любопытно, можете изучить issue, созданное мной в репозитории проекта.