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.

Это не особо относится к основной теме поста, поэтому и спрятано под спойлер. Я ненавижу читать посты, в которых пропускаются любопытные мне детали, как будто это некое общеизвестное знание, поэтому сам стараюсь этого избегать :)

Compressed read function high-level IL
Высокоуровневое промежуточное представление чтения сжатых данных

По сути, эта функция сравнивает запрошенный размер чтения с количеством данных, предварительно кэшированных в буфер распакованных данных. Затем она копирует максимально возможное количество данных из своего буфера предварительного кэширования в выходной буфер, а затем считывает следующий блок сжатых zlib данных в буфер предварительного кэширования, если предыдущий исчерпался. Этот процесс повторяется, пока не будет выполнен запрос.

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

Я пошагово выполнил этот код, установил контрольные точки чтения из памяти на данных, которые пока не понимал, и сразу заметил нечто любопытное!

Что за «адреса» из заголовка (0x139e585c)? Они передаются чему-то, что, по моему предположению, является подпрограммой Seek , обновляющей свойство position функции чтения файла, которая затем выполняет косвенный вызов другой функции, которая в буквальном смысле ничего не делает.

Вот всё содержимое функции:

retn    4

Вот и всё.

А затем чтение просто... продолжается с последней позиции? Так как функция вызывается косвенно, можно только предположить, что это некий объект-компонент C++, в котором объект внешнего класса обновляет свою position в Seek(), а затем вызывает внутреннюю Seek() функции чтения файла... которая оказывается no-op?

Установив контрольные точки чтения из памяти на поле объекта position, я заметил, что оно используется только в эквиваленте функции чтения памяти FTell(). Оно никак не влияет на то, откуда считываются данные.

Причина того, что Seek() оказалась no-op, вероятно, заключается в том, что внутренняя функция чтения файлов выполняет считывание непосредственно из буфера сжатых данных, который считывается блоками по 0x4000 байт. Так как нельзя сопоставить смещение несжатых данных со смещением сжатых, формат должен быть таким, чтобы игнорировал поиск (seek) и просто читал данные линейно.

...то есть расширение .lin становится гораздо более логичным.

? Чтобы считывать эти файлы, необходимо предположить, что выполнять поиск вперёд/назад невозможно. Довольно просто.

Порядок загрузки важен

Но у нас всё равно остаётся нерешённая проблема: почему в таблице файлов есть большое количество файлов с неправильными смещениями?

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

StaticLoadObject implementation
Реализация StaticLoadObject

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

ResolveName implementation
Реализация ResolveName

Имя 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 относительно начала пакета. Если вы думаете, что смещение неправильное, то вы угадали!

Данные экспорта

Что мы уже знаем:

  1. Выполнять поиск в функции чтения файлов нельзя.

  2. Смещения не соответствуют представлению на диске и не используются ни для чего, кроме отслеживания позиции.

  3. Размеры (как минимум для таблицы файлов, а позже я понял, и что в данных экспорта) некорректны.

  4. Мы знаем, что GameEngine — это первый объект, запрашиваемый на стороне C++ игры, и что он имеет индекс экспорта 0xEFB в пакете Engine. Может быть, это и не первый объект, который парсится, но первый запрашиваемый.

Чтобы выполнить свою задачу по дампингу этих файлов, я попробовал просто суммировать размер этих экспортов, чтобы понять последнее смещение файла... но попробовав сочетания этого вычисленного размера + любого из смещений {end_of_export_table, start_of_file}, я оказывался в странных местах, между которыми находились другие файлы компоновщика.

Чтобы заполнить пробелы, я сослался на Unreal-Library, и обнаружил в игровом движке следующую высокоуровневую логику парсинга:

  1. Экспортированный объект запрашивается игрой. Если он ещё не загружен, выполняется ленивая загрузка.

  2. Ленивая загрузка требует ресолвинга объекта типа super. В каких-то случаях это базовые типы Class или Struct, в других — иной родительский класс, в конечном итоге родительским типом которого оказывается Class.

  3. Экспорты обладают свойствами, которые могут иметь переменную длину. При чтении экспорта мы десериализируем его данные согласно его полям serial_size и serial_offset, однако типы, экспортированные со стороны C++, определяют подпрограмму десериализации.

При ресолвинге импортов/экспортов это приводит к следующей картине:

Export parsing flow
Поток парсинга экспорта

Чтобы показать конкретный пример, представим, что GameEngine имеет следующую иерархию классов:

GameEngine -> Engine -> Subsystem -> Class

Также представим, что GameEngine — это первый объект, который парсится, ничего кроме него пока ещё не загружено. При запросе загрузки GameEngine из пакета Engine.u выполнится такая последовательность событий:

  1. Чтение/парсинг заголовка Engine.u (потому что пока не было создано ни одного пакета).

  2. Поиск экспорта GameEngine Engine. Его парсинг пока ещё не выполнен, поэтому нужно создать этот объект, сконструировав/десериализировав его.

  3. Родительский класс GameEngine — это Engine.Engine. Его парсинг пока ещё не выполнен, поэтому нужно десериализировать его до GameEngine.

  4. Core.Subsystem — это родительский класс Engine.Engine. Аналогично.

  5. Чтение/парсинг заголовка Core.u (потому что Core пока ещё не загружен)

  6. Core.Class — родительский класс Core.Subsystem (и базовый класс). Конструируем этот объект.

  7. Десериализация свойства Core.Class. Теперь можно продолжить создание Core.Subsystem.

  8. Десериализация свойства Core.Subsystem...

  9. Десериализация свойства Engine.Engine...

  10. Десериализация свойства Engine.GameEngine...

  11. Теперь можно вернуть полностью сконструированный Engine.GameEngine.

К сожалению, похоже, это может привести к чередованию данных экспорта. В случае описанного выше сценария данные могут находиться на диске согласно схеме ниже. Примечание: для упрощения я опустил Core.Class, а также потенциальный запуск десериализации других экспортов самими свойствами.

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│                                                             │
│                     Таблица файлов                          │
│                                                             │
│                                                             │
│                                                             │
├───────────────────────────┬─────────────────────────────────┤
│Заголовок Core.u           │ Заголовок Engine.u              │
│                           │                                 │
│                           │                                 │
├────┬────┬─────────────────┴──────────┬──────────────────────┤
│    │▰▰▰▰│▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰│                      │
│    │▰▰▰▰│▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰│          │    │      │
│    │▰▰▰▰│▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰│          │    │      │
│  ▲ │ ▲ ▰│▰▰ ▲ ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰│        ▲ │   ▲│   ▲  │
├──┼─┴─┼──┴───┼────────────────────────┴────────┼─┴───┼┴───┼──┤
│  │   │      │                              │     │       │  │
│  │   │    ┌─┴──────────────────────────┐   │     │       │  │
│  │   │    │ Данные экспорта Core.Subsystem │     │       │  │
│  │   │    └────────────────────────────┘   │     │       │  │
│  │ ┌─┴────────────────────────────────┐    │     │       │  │
│  │ │ Данные экспорта Engine (суперкласс)   │     │       │  │
│  │ └──────────────────────────────────┘    │     │       │  │
│  │                               ┌─────────┴─────┴───────┴──┤
│┌─┴────────────────────────┐      │     Свойства объекта     │
││ Начало объекта GameEngine│      │        GameEngine        │
│└──────────────────────────┘      └──────────────────────────┤
└─────────────────────────────────────────────────────────────┘

А теперь, если представить, что есть второй объект, тоже примыкающий к загруженному Engine после GameEngine, то их общий суперкласс Engine уже был распарсен, и его информация уже находится в памяти. То есть если сериализовать два объекта одного и того же типа, то у первого объекта все данные его родительского класса могут чередоваться с его собственными данными экспорта, а второй объект будет содержать собственные данные свойств.

К сожалению, это означает, что для статического чтения этих файлов (даже просто для статической рекомпиляции) необходимы полные знания о том, как парсится каждый реализованный C++ тип, чтобы иметь возможность парсинга всех экспортов и их свойств. Кроме того, чтение одного экспорта может вызвать ресолвинг импортов в вашем собственном объекте компоновщика, что, в свою очередь, может привести к десериализации экспортов в другом объекте компоновщика.

Это приводит к тому, что размер данных экспорта необязательно ошибочен сам по себе, однако он не будет особо полезен без выполнения полного парсинга. Если в процессе десериализации экспорта будут десериализоваться другие экспорты, они будут выполнять поиск и восстанавливать исходную позицию. После завершения десериализации экспорт вычитает position после десериализации функции чтения файлов из сохранённой position до десериализации, и выполняет assert того, что она равна ожидаемой длине экспорта. Однако это запутывает, потому что мы не можем просто считать SerialSize байт из этого смещения.

Примечание: я не полностью уверен, что данные чередуются, а не идут последовательно. При наблюдении за операциями поиска/чтения различных экспортов я видел, как поиск переходил в совершенно иное смещение посередине десериализации экспорта, затем происходила ещё одна десериализация экспорта, потом снова поиск приводил к исходному экспорту и снова продолжал его десериализацию. Однако всё это ужасно сложно отлаживать.

Почему??????

Я думаю, что для упаковки данных таким образом имелась очень веская причина. Чтобы понять её, нужно учесть ограничения того времени:

  1. Игра продавалась на физическом диске.

  2. У Xbox 64 МБ общей для CPU и GPU памяти, часть которой занимает операционная система.

  3. В то время CPU не были такими уж медленными, но пустая трата тактов всё равно была бы заметна.

Формат .lin снижает серьёзность этих проблем следующим образом:

  1. Благодаря сжатию данных экономится место на диске... Если только забыть о том, что common.lin дублируется в папке каждой карты и одинаков для всех протестированных мной карт.

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

  3. Выстраивание файла в точном побайтовом порядке чтения повышает скорость ввода вывода благодаря тому, что не нужно выполнять поиск по физическому носителю, и гарантирует, что нам не понадобится магия для высокопроизводительного преобразования несжатого смещения в сжатое.

Логгинг порядка загрузки для статической рекомпиляции

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

..\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 (выделенные ниже) и обнаружил следующую функцию:

LinkerLoad HLIL

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

SerializeLinker HLIL

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

SaveObject HLIL

Патчинг двоичных файлов Xbox

Чтобы делать что-нибудь интересное, нам нужно запустить наш собственный код параллельно с игрой. Скрипты отладчика слишком медленные и ненадёжные, поэтому нам нужно что-то, работающее в эмуляторе или физическом устройстве. Было бы здорово, если бы мы написали и плагин QEMU для эмулятора... но это уже отдельная сложная задача.

В Windows или Unix инъецировать код в игру просто. В Windows можно создать CreateRemoteThread() или перехват DLL, а в Unix использовать LD_PRELOAD. На Xbox 360 можно «инъецировать» перманентные DLL. На первом Xbox есть только один процесс (насколько я знаю) и никаких DLL.

Вероятно, этому стоит посвятить отдельный пост, потому что сейчас информации довольно мало (покойся с миром, XboxHacker.org), но мне известны как минимум два инструмента, при помощи которых можно манипулировать исполняемыми файлами Xbox.

  1. Библиотека Python pyxbe

  2. Инструмент командной строки XboxImageExploder

Оба они позволяют добавлять в исполняемый файл новые разделы, по сути, создавая «нишу» кода, в которую можно помещать дополнительный код или данные. Когда система загружает образ, она согласует добавленный раздел с соответствующими разрешениями. Чтобы этот код выполнялся, нужно пропатчить одно место в исходном исполняемом файле.

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

Краткое описание патча:

  1. Определяем точку хука в конце функции LoadMap(). Это определение заставляет XePatcher записать эти команды, выполняющие переход исполнения на Hack_LoadMap в объявленном смещении файла.

  2. Hack_LoadMap вызывает Hack_DumpAllLinkers и выполняет стандартную подчистку эпилога для LoadMap(), которая не будет исполняться, потому что мы перехватили исполнение.

  3. Hack_DumpAllLinkers итеративно обходит глобальный список объектов Linker и вызывает Hack_DumpFile с этим компоновщиком в качестве аргумента.

  4. 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:

Engine.Engine package in UE Explorer

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

Замена текстуры приводит к проблемам:

Bugged Textures

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

Чтобы подтвердить свою гипотезу, я установил контрольную точку в функции, запускающей десериализацию объектов, чтобы она прерывалась непосредственно перед этим:

Код контрольной точки целевого экспорта

При помощи следующего скрипта я установил контрольную точку в подпрограмме 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, чтобы избежать удаления данных текстур.

Модифицировав файл и снова сдампив данные, я заметил, что размер файла текстур существенно изменился, и у меня получились вот такие красивые текстуры:

CIA Flag Texture
HUD Texture

Общие проблемы дампинга

Так как для экспортов выполняется ленивая загрузка, сдампить можно только то, что используется на уровне. Основное меню использует часть функциональности из Engine и Core. Поэтому если я загружал карту основного меню и дампил все linker после завершения загрузки, то получал только частичное представление Engine и Core.

Аналогично, всё, на что не было ссылок и что не использовалось из архива, нельзя было удобно восстановить без умного брутфорса, потому что мы не знаем, где начинаются данные. Например, в основном меню есть кисти, которые присутствуют в таблице экспортов, но, похоже, не используются, поэтому ничто не вызывает их загрузку.

Дальнейшие шаги

Хотя мы добились кое-каких успехов в дампинге данных, я не успокоюсь, пока не смогу дампить любую информацию, которую пожелаю. Важной вехой стала бы работа полной обучающей миссии с Xbox на PC. Я попробую двигаться в этом направлении, и если зайду в тупик, то, по крайней мере, попытаюсь сдампить данные из прототипа игры.

Этот формат определённо можно считывать статически, имея знание о порядке загрузки из игрового движка. Надеюсь, что кто-нибудь из сообщества сможет воспользоваться моей работой, чтобы всё это заработало в Unreal-Library.

Гораздо более обобщённым подходом стала бы статическая рекомпиляция; надеюсь, для неё понадобятся лишь два скрипта отладчика для дампинга имён файлов пакетов и загрузок экспортов, а не двоичный патч. Для SC1 они уже существуют. Сложность будет заключаться в добавлении этой системы в UELib (или какой-то другой проект) таким образом, чтобы это соответствовало точному поведению ввода-вывода в игре и десериализовало одновременно множество пакетов при помощи данных о порядке загрузки. Если вам любопытно, можете изучить issue, созданное мной в репозитории проекта.

Комментарии (0)