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


Я довольно много играю в Beat Saber с модами, и в какой-то момент появилась необходимость написать свои. Beat Saber сделан на Unity, так что я покопался в его коде, в коде существующих модов, разобрался, как там всё устроено, и написал об этом статью. Получилось довольно много материала, так что я разбил его на две части. В этой части мы рассмотрим, какие методы используется для того, чтобы игра запускала сторонние моды: модификации библиотек игры и самого движка, подмена адресов в таблице импорта dll и вмешательство в загрузку Mono Runtime.



Источники изображений: 1, 2


О Beat Saber


Beat Saber является одной из самых популярных игр для VR-шлемов. Если у вас есть такой шлем, то, скорее всего, вы уже знаете, что такое Beat Saber. Если нет, то, возможно, вы видели хотя бы одно видео из игры в рекомендациях Youtube:



Единственное, что Beat Saber поддерживает из модов — это пользовательские уровни. В игре есть официальный редактор, но нет никакого сервиса, чтобы как-либо распространять свои творения, и, по моему мнению, вряд ли появится. В основе практически любого уровня лежит какая-то песня. Разрешить выкладывать такие файлы в своем официальном сервисе — значит привлечь к себе внимание правообладателей с их постоянными запросами что-то удалить или вообще с требованиями закрыть весь сервис.


Поэтому не удивительно, что вокруг Beat Saber сформировалось огромное сообщество: Beat Saber Modding Group (BSMG). Именно сообщество ответственно за большую часть того, что есть в игре.


  • beatsaver.com — коллекция пользовательских уровней, созданных с помощью официального или неофициальных редакторов. Сюда добавляются десятки уровней каждый день.
  • bsaber.com — сайт, главная задача которого — помочь найти что-то хорошее среди того, что появляется на beatsaver.com. Здесь есть рекомендации кураторов, топы за неделю, поиск по музыкальным жанрам и многое другое.
  • scoresaber.com — таблицы лидеров для пользовательских уровней.
  • modelsaber.com — модели мечей, платформ и аватаров. Тут хранится визуальный 3D-контент, не содержащий дополнительного кода.
  • beatmods.com — коллекция модов, модифицирующих код игры.
  • github.com/Assistant/ModAssistant — ModAssistant, программа для установки модов. Игрок просто выбирает моды, которые ему нужны, и жмет Install. Не нужно даже вручную копировать файлы.

Все это поддерживается сообществом. Большинство модов и даже сами сайты — это проекты с открытым исходным кодом, их можно найти на GitHub, чем я и воспользовался при подготовке к этой статье. Примеры кода, которые я здесь привожу, сильно упрощены, чтобы показать только суть и не увеличивать объем текста. Для тех, кто хочет посмотреть полный код, я привожу ссылки на файлы с оригинальным кодом на GitHub.


Общая информация о Unity


Beat Saber сделан на игровом движке Unity. В качестве языка разработки логики для игр в Unity используется C# — в таком контексте его часто называют скриптовым языком. C# — это язык, программы на котором собираются в сборки с управляемым кодом .NET (managed .NET assemblies) — dll-библиотеки, содержащие команды промежуточного языка (Common Intermediate Language, CIL или иногда просто IL). При этом ядро движка написано на C++, а часть его библиотек — те, которые связаны с игровой логикой и пользовательским интерфейсом — на C#. Это значит, что Unity нужен дополнительный скриптовый движок, который должен выполнять CIL-код и иметь возможность обмениваться данными с ядром. Для этого в Unity используется Mono.


Mono — это одна из реализаций спецификации общеязыковой инфраструктуры (Common Language Infrastructure, CLI) с открытым исходным кодом. Mono работает на Windows, Linux, macOS, мобильных устройствах и игровых консолях. Разрабатывается с 2001 года компанией Ximian, которую в 2003 купила компания Novell, которую в 2011 купила Attachmate. Attachmate сократила почти всех, но разработчики Mono хотели и дальше разрабатывать Mono, поэтому организовали в том же году отдельную компанию Xamarin, которую в 2016 купили Microsoft.


Это стандартная практика в разработке игр, когда высокопроизводительная часть движка написана на C или C++, а игровая логика пишется на скриптовом языке, например Lua. Преимущество C# в качестве скриптового языка в том, что он достаточно “высокоуровневый”, чтобы упростить и ускорить разработку игры, но при этом позволяет использовать JIT-компиляцию в нативный код. Получается такая архитектура игрового движка:



Подробнее об этом можно прочитать в документации Mono.


Программные моды для игр и программ (также известные как плагины) — это dll-файлы (в нашем случае .NET-сборки), которые добавляются к игре и загружаются в память вместе с ней. Проблема в том, что Beat Saber не поддерживает сторонние плагины и сам по себе ничего загружать не будет. За внедрение поддержки плагинов в игру отвечает BSIPA.


BSIPA



BSIPA (Beat Saber Illusion Plugin Architecture) — это набор библиотек, которые модифицируют файлы Beat Saber так, чтобы игра могла загружать сторонние моды. Иногда такие библиотеки называют менеджерами плагинов.


BSIPA написана на C# (исходный код) и является форком IPA. Т.е. моддеры взяли уже существующий менеджер плагинов для Unity и улучшили его специально под нужды Beat Saber. В исходном коде BSIPA я выделяю три основных модуля: IPA, IPA.Loader и IPA.Injector. На самом деле, их больше, но остальные не так важны.


IPA.exe


> Исходный код


Обычно игроки используют ModAssistant и его аналоги, чтобы устанавливать моды, но мы рассмотрим, как это делается вручную. Для этого нужно скачать последний релиз BSIPA, распаковать все файлы в папку с игрой и запустить IPA.exe. Это исполняемый файл, который копирует файлы из только что распакованного архива в те папки, где они должны находиться. И это, в общем-то, все, что он делает: просто копирует файлы, и если какой-то из файлов уже существует в игре, то он делает его резервную копию. Вот список копируемых файлов:


Beat Saber_Data\Managed\I18N.dll
Beat Saber_Data\Managed\I18N.West.dll
Beat Saber_Data\Managed\IPA.Injector.dll
Beat Saber_Data\Managed\IPA.Injector.pdb
Beat Saber_Data\Managed\IPA.Loader.dll
Beat Saber_Data\Managed\IPA.Loader.pdb
Beat Saber_Data\Managed\IPA.Loader.xml
Beat Saber_Data\Managed\Microsoft.CSharp.dll
Beat Saber_Data\Managed\System.Runtime.Serialization.dll
Libs\0Harmony.1.2.0.1.dll
Libs\Ionic.Zip.1.9.1.8.dll
Libs\Mono.Cecil.0.10.4.0.dll
Libs\Mono.Cecil.Mdb.0.10.4.0.dll
Libs\Mono.Cecil.Pdb.0.10.4.0.dll
Libs\Mono.Cecil.Rocks.0.10.4.0.dll
Libs\Newtonsoft.Json.12.0.0.0.dll
Libs\SemVer.1.2.0.0.dll
winhttp.dll

Мы видим здесь еще два модуля BSIPA, которые я упомянул ранее: IPA.Loader.dll и IPA.Injector.dll. Остальные библиотеки нужны, чтобы работали эти две. Некоторые из них мы еще рассмотрим подробнее в этой статье и во второй части.


IPA.Loader.dll


Как и следует из названия этого модуля, он отвечает за загрузку и управление плагинами. Здесь определены классы PluginLoader, PluginManager и PluginComponent. PluginLoader — это класс, в котором определена логика загрузки плагинов из папки Plugins. В оригинальной IPA плагины загружаются в алфавитном порядке, что иногда приводит к проблемам. В BSIPA логика загрузки была переписана: Loader сначала загружает метаданные плагинов, анализирует зависимости, находит конфликты и строит порядок загрузки на основе этой информации.


Класс PluginManager отвечает за управление плагинами: поиск, активацию и деактивацию с учетом их зависимостей.


Класс PluginComponent — это компонент Unity, отвечающий за старт загрузки модов, их хранение и передачу им событий из игры. Например, если в основной игре сменилась активная сцена (мы запустили какой-то уровень или, наоборот, вернулись в главное меню), то PluginComponent итерирует по всем модам и сообщает им об этом. Подробнее об этом будет во второй части.


IPA.Injector.dll


> Исходный код


Название Injector намекает, что этот модуль отвечает за внедрение в оригинальную игру. Начнем с забавного факта: BSIPA добавляет в игру антипиратскую защиту.


if (AntiPiracy.IsInvalid(Environment.CurrentDirectory))
{
    loader.Error("Invalid installation; please buy the game to run BSIPA.");
    return;
}

Защита примитивная, она просто проверяет, есть ли в папке с игрой файлы, которые явно указывают на взлом: SmartSteamEmu.ini, BSteam crack.dll, HUHUVR_steam_api64.dll и другие.


public static bool IsInvalid(string path)
{
    var dataPlugins = Path.Combine(GameVersionEarly.ResolveDataPath(path), "Plugins");
    return 
        File.Exists(Path.Combine(path, "IGG-GAMES.COM.url")) ||
        File.Exists(Path.Combine(path, "SmartSteamEmu.ini")) ||
        File.Exists(Path.Combine(path, "GAMESTORRENT.CO.url")) ||
        File.Exists(Path.Combine(dataPlugins, "BSteam crack.dll")) ||
        File.Exists(Path.Combine(dataPlugins, "HUHUVR_steam_api64.dll")) ||
        Directory.GetFiles(dataPlugins, "*.ini", SearchOption.TopDirectoryOnly).Length > 0;
}

Скорее всего, BSIPA это нужно из практических интересов. Если игра пиратская, то это значит, что она была взломана и часть ее файлов изменены. BSIPA тоже изменяет файлы игры, а значит, совместимость с пиратскими библиотеками не гарантируется.


IPA.Injector использует стороннюю библиотеку: Mono.Cecil (исходный код). Ее автор: Jb Evain, работал в Novell над разработкой Mono, сейчас работает в Microsoft, руководит разработкой Visual Studio Tools for Unity. Mono.Cecil существует с 2004 года, т.е. примерно со времен релиза первой версии Mono. Она позволяет читать и модернизировать .NET-сборки: с ее помощью можно менять различные параметры библиотек или даже редактировать IL-код, который в них записан.


IPA.Injector использует Mono.Cecil, чтобы модифицировать библиотеку UnityEngine.CoreModule.dll. Она написана на C# (иначе бы мы не смогли ее редактировать) и содержит базовые сущности Unity. Например, там определены классы GameObject (все объекты в игре, которые располагаются на игровых сценах, являются объектами этого класса) и класс MonoBehaviour (базовый класс для компонентов с игровой логикой, в том числе PluginComponent). IPA.Injector находит в библиотеке UnityEngine.CoreModule.dll класс UnityEngine.Application и модифицирует его статический конструктор (или создает, если его нет), добавляя туда создание бутстраппера:


var unityAsmDef = AssemblyDefinition.ReadAssembly(unityPath, new ReaderParameters { ... });
var unityModDef = unityAsmDef.MainModule;
var application = unityModDef.GetType("UnityEngine", "Application");

MethodDefinition cctor = null;
foreach (var m in application.Methods)
    if (m.IsRuntimeSpecialName && m.Name == ".cctor")
        cctor = m;

var createBootstrapper = unityModDef.ImportReference(((Action)CreateBootstrapper).Method);

if (cctor == null)
{
    cctor = new MethodDefinition(".cctor", ...);
    application.Methods.Add(cctor);

    var ilp = cctor.Body.GetILProcessor();
    ilp.Emit(OpCodes.Call, cbs);
    ilp.Emit(OpCodes.Ret);
}
else
{
    var ilp = cctor.Body.GetILProcessor();
    ilp.Replace(cctor.Body.Instructions[0], ilp.Create(OpCodes.Call, cbs));
    ilp.Replace(cctor.Body.Instructions[1], ilp.Create(OpCodes.Ret));
}

По крайней мере, в Unity 2019.3.0f3 у Application нет статического конструктора, а значит, эта модификация не удаляет существующий важный код и относительно безопасна. Если статический конструктор уже существует и Injector заменяет его, то это только в том случае, если мы повторно модифицируем библиотеку.


После модификации в классе Application появляется новый статический конструктор (код получен с помощью декомпиляции модифицированной UnityEngine.CoreModule.dll):


static Application()
{
  IPA.Injector.Injector.CreateBootstrapper();
}

Метод CreateBootstrapper создает новый объект и добавляет в него компонент Bootstrapper:


private static void CreateBootstrapper()
{
    ...
    var bootstrapper = new GameObject("NonDestructiveBootstrapper")
            .AddComponent<Bootstrapper>();
    bootstrapper.Destroyed += Bootstrapper_Destroyed;
}

Бутстраппер представляет собой интересное техническое решение:


internal class Bootstrapper : MonoBehaviour
{
    public event Action Destroyed = delegate {};

    public void Start()
    {
        Destroy(gameObject);
    }

    public void OnDestroy()
    {
        Destroyed();
    }
}

Метод Start похож на метод Awake, но Awake вызывается в первый кадр, когда создается компонент, а Start вызывается в первый кадр после активации компонента (компоненты могут создаваться неактивными). Каждый из методов вызывается ровно один раз за жизненный цикл компонента и может использоваться для разных этапов инициализации компонента. Компонент Bootstrapper в своем методе Start вызывает уничтожение объекта, к которому он прикреплен. В методе OnDestroy вызывается колбэк, установленный в CreateBootstrapper:


private static void Bootstrapper_Destroyed()
{
    // wait for plugins to finish loading
    pluginAsyncLoadTask.Wait();
    permissionFixTask.Wait();

    PluginComponent.Create();
}

Колбэк дожидается асинхронной загрузки плагинов и создает компонент PluginComponent. Если честно, я не знаю, зачем нужен этот механизм с самоуничтожением бутстраппера и созданием PluginComponent в OnDestroy. Мое предположение: это нужно для того, чтобы сделать загрузку отложенной — метод Start вызывается, когда компонент становится активным, а он не может стать активным, пока не загрузится игровая сцена. Буду рад, если более опытные Unity-разработчики поправят меня в комментариях.


Теперь, когда загружается класс Application (т.е. на ранних этапах загрузки .NET-сборок Unity), у нас создается PluginComponent и загружаются плагины из папки Plugins. Получается, мы внедрились не в код Beat Saber, а в код самого Unity.


Помимо этого IPA.Injector редактирует еще один файл — MainAssembly.dll. Эта библиотека содержит код Beat Saber. С помощью все того же Mono.Cecil IPA.Injector убирает у всех классов sealed и делает все методы публичными и виртуальными. Теперь любой класс в любом плагине может наследоваться от любого класса в оригинальной игре и переопределять поведение его методов. Эта модификация уже не относится к загрузке плагинов, но она очень упрощает разработку модов.


Если бы у нас использовалась не BSIPA, а оригинальная IPA, то на этом можно было бы остановиться. В оригинальной версии IPA.exe запускает Injector, модифицирует UnityEngine.CoreModule.dll, и в игре появляются моды. У такого подхода есть один минус — каждый раз, когда оригинальная игра обновляется, нужно заново запускать IPA.exe и патчить игру. BSIPA решает эту проблему, но, как я уже писал выше, IPA.exe в BSIPA просто копирует файлы и не делает больше ничего. За запуск Injector в этом случае отвечает другая библиотека.


Unity Doorstop



За внедрение в код Unity отвечает библиотека UnityDoorstop-BSIPA. Она лежит среди файлов BSIPA и написана на чистом C. UnityDoorstop-BSIPA (исходный код) — это тоже форк, оригинальный проект можно найти здесь. Далее для простоты буду вместо UnityDoorstop-BSIPA писать Doorstop. Лозунгом Doorstop является фраза “Run managed code before Unity does!”, что в примерном переводе звучит как “Запускай управляемый код до того, как Unity сможет это сделать”. Напомню, что “управляемый код” — это в нашем случае код C#. Выше мы уже выяснили, что ядро движка Unity написано на C++, а пользовательские скрипты для игровой логики и некоторые части самого Unity — на C#. Значит, Doorstop каким-то образом позволяет нам вмешаться в логику, когда ядро Unity уже загрузилось, а C#-скрипты — еще нет.


Когда мы запускаем игру на Unity (например, Beat Saber.exe), то в память одной из первых загружается библиотека UnityPlayer.dll. Она прилагается ко всем Unity-играм и отвечает за запуск и выполнение самой игры. У этой библиотеки есть таблица импорта, в которой говорится, что UnityPlayer использует функцию GetProcAddress из библиотеки kernel32.dll. GetProcAddress — это функция WinAPI, которая возвращает адрес функции из определенной библиотеки по ее названию. Я не видел исходного кода Unity, но судя по тому, что я видел в BSIPA и Doorstop, в UnityPlayer должно быть что-то вроде такого:


mono_dll = LoadLibrary(“Mono.dll”);
init_mono = GetProcAddress(mono_dll, ”mono_jit_init_version”);
mono = init_mono(...);
// дальше используем mono, чтобы загрузить и запустить код игры

mono_jit_init_version — это функция, которая отвечает за инициализацию и запуск Mono. Подробнее можно прочитать здесь. Doorstop вмешивается в этот процесс. Делается это в два шага.


Шаг 1. Проксируем GetProcAddress


Когда библиотека Doorstop.dll загружается в память компьютера, вызывается ее DllMain и, если reasonForDllLoad == DLL_PROCESS_ATTACH, то выполняется код, который в упрощенном виде выглядит так:


HMODULE targetModule = GetModuleHandleA("UnityPlayer");
iat_hook(targetModule, "kernel32.dll", &GetProcAddress, &hookGetProcAddress);

Полный код DllMain, полный код iat_hook


Этот код находит в памяти уже загруженную библиотеку UnityPlayer.dll, берет ее таблицу импорта (Import Address Table, IAT), находит в ней GetProcAddress из kernel32.dll и заменяет ее на нашу функцию hookGetProcAddress из Doorstop.dll. hookGetProcAddress в упрощенном виде выглядит так:


void * WINAPI hookGetProcAddress(HMODULE module, char const *name)
{
    if (lstrcmpA(name, "mono_jit_init_version") == 0)
    {
        init(module);
        return (void*)&ownMonoJitInitVersion;
    }
    return (void*)GetProcAddress(module, name);
}

Полный код hookGetProcAddress


Подробнее про IAT Hooking можно прочитать здесь. Получается, что hookGetProcAddress проксирует все вызовы GetProcAddress. Прокси-функция смотрит название функции, которую у нее запрашивают. Если это НЕ mono_jit_init_version, то hookGetProcAddress просто вызывает настоящую GetProcAddress и возвращает то, что у нее попросили, тем самым не мешая нормальной работе. Если у нее запрашивают mono_jit_init_version, то тогда она возвращает переопределенную функцию ownMonoJitInitVersion. Пользуясь случаем, прокси-функция получает указатель на библиотеку, в котором ищется mono_jit_init_version, и берет из нее настоящие функции Mono с помощью GetProcAddress (внутри init(module);):


/**
* \brief Loads Mono C API function pointers so that the above definitions can be called.
* \param monoLib Mono.dll module.
*/
inline void loadMonoFunctions(HMODULE monoLib)
{
    // Enjoy the fact that C allows such sloppy casting
    // In C++ you would have to cast to the precise function pointer type
#define GET_MONO_PROC(name) name = (void*)GetProcAddress(monoLib, #name)

    // Find and assign all our functions that we are going to use
    GET_MONO_PROC(mono_debug_domain_create);
    GET_MONO_PROC(mono_domain_assembly_open);
    GET_MONO_PROC(mono_assembly_get_image);
    GET_MONO_PROC(mono_runtime_invoke);
    GET_MONO_PROC(mono_debug_init);
    GET_MONO_PROC(mono_jit_init_version);
    GET_MONO_PROC(mono_jit_parse_options);
    GET_MONO_PROC(mono_method_desc_new);
    GET_MONO_PROC(mono_method_desc_search_in_image);
    GET_MONO_PROC(mono_method_signature);
    GET_MONO_PROC(mono_signature_get_param_count);
    GET_MONO_PROC(mono_array_new);
    GET_MONO_PROC(mono_get_string_class);
    GET_MONO_PROC(mono_string_new_utf16);
    GET_MONO_PROC(mono_gc_wbarrier_set_arrayref);
    GET_MONO_PROC(mono_array_addr_with_size);
    GET_MONO_PROC(mono_object_to_string);
    GET_MONO_PROC(mono_string_to_utf8);
    GET_MONO_PROC(mono_dllmap_insert);
    GET_MONO_PROC(mono_free);
}

Шаг 2. Переопределеяем mono_jit_init_version


ownMonoJitInitVersion сначала вызывает настоящую mono_jit_init_version, чтобы создать Mono. Затем она с помощью Mono загружает сборку IPA.Injector.dll и запускает из нее статический метод Main. В (очень) упрощенном виде код ownMonoJitInitVersion выглядит так:


void *ownMonoJitInitVersion(const char *root_domain_name, const char *runtime_version)
{
    void *domain = mono_jit_init_version(root_domain_name, runtime_version);

    // Load our custom assembly into the domain
    void *assembly = mono_domain_assembly_open(domain, dll_path); // dll_path - это путь к IPA.Inhector.dll

    // Get assembly's image that contains CIL code
    void *image = mono_assembly_get_image(assembly);

    // Create a descriptor for a random Main method
    void *desc = mono_method_desc_new("*:Main", FALSE);

    // Find the first possible Main method in the assembly
    void *method = mono_method_desc_search_in_image(desc, image);

    mono_runtime_invoke(method, NULL, args, &exception);

    return domain;
}

Полный код ownMonoJitInitVersion


Мы уже рассмотрели выше, что IPA.Injector содержит код, который внедряет плагины в Beat Saber. После того, как IPA.Injector завершает свою работу, ownMonoJitInitVersion отдает Mono в Unity. Unity даже не в состоянии понять, что что-то было не так. Если б он вызвал настоящую mono_jit_init_version, то он бы получил Mono и начал бы дальше с ним работать. Если Unity запускает переопределенную ownMonoJitInitVersion, то он тоже получает Mono — он просто не в курсе, что этим Mono успели воспользоваться для чего-то еще.


winhttp.dll


Остался один нерешенный вопрос. Во-первых, в описании Doorstop я писал про библиотеку Doorstop.dll. Давайте опять взглянем на файлы, которые IPA.exe устанавливает в игру:


Beat Saber_Data\Managed\I18N.dll
Beat Saber_Data\Managed\I18N.West.dll
Beat Saber_Data\Managed\IPA.Injector.dll
Beat Saber_Data\Managed\IPA.Injector.pdb
Beat Saber_Data\Managed\IPA.Loader.dll
Beat Saber_Data\Managed\IPA.Loader.pdb
Beat Saber_Data\Managed\IPA.Loader.xml
Beat Saber_Data\Managed\Microsoft.CSharp.dll
Beat Saber_Data\Managed\System.Runtime.Serialization.dll
Libs\0Harmony.1.2.0.1.dll
Libs\Ionic.Zip.1.9.1.8.dll
Libs\Mono.Cecil.0.10.4.0.dll
Libs\Mono.Cecil.Mdb.0.10.4.0.dll
Libs\Mono.Cecil.Pdb.0.10.4.0.dll
Libs\Mono.Cecil.Rocks.0.10.4.0.dll
Libs\Newtonsoft.Json.12.0.0.0.dll
Libs\SemVer.1.2.0.0.dll
winhttp.dll

Как вы можете видеть, Doorstop.dll здесь нет. Во-вторых, даже если бы Doorstop.dll здесь бы, то почему Beat Saber или Unity должны его загружать в память, если его нет в таблице импорта? Решение: выдать Doorstop.dll за другую библиотеку, которую загружает Unity, и сделать так, чтобы она загрузилась раньше настоящей. Такой библиотекой в нашем случае является winhttp.dll — это системная библиотека Windows для http-запросов (хранится в C:/Windows/System32). У Unity в одной из библиотек в таблице импорта указано, что ей нужна библиотека winhttp.dll, поэтому во время загрузки Unity Windows сначала загружает winhttp.dll в память, и только после этого Unity начинает работать.


Doorstop собирается в библиотеку под тем же названием: winhttp.dll. Там содержится код Doorstop, отвечающий за все манипуляции с GetProcAddress и mono_jit_init_version, а также туда добавляется таблица экспорта (Export Address Table) со всеми теми же функциями, что в оригинальной winhttp.dll. Загрузка библиотек в Windows устроена так, что Windows сначала проверяет, есть ли нужные библиотеки в папке с программой, и только потом, если ничего не нашел, идет в System32. Поэтому при запуске игры Windows в первую очередь находит наш файл. Поддельная библиотека загружает настоящую winhttp динамически (с помощью LoadLibrary) и просто перенаправляет все вызовы из своей таблицы экспорта на адреса настоящих функций (с помощью GetProcAddress). Можно сравнить размеры: поддельная библиотека весит 16кб, а настоящая — 960кб.


В репозиториях IPA и BSIPA есть скрипты для генерации файла proxy.c, который содержит таблицу экспорта, совпадающую с оригинальной библиотекой, и загружает все методы через GetProcAddress. В IPA такой скрипт написан на PowerShell, а в BSIPA — на Python. Сгенерированный файл выглядит так: proxy.c.


Подробнее про DLL Search Order Hijacking можно прочитать тут.


Перечисление действий в хронологическом порядке


Повторяем все шаги в хронологическом порядке.


  • Запускаем IPA.exe. Все библиотеки копируются в нужные папки игры. Никаких изменений в коде игры пока нет.
  • Запускаем игру (Beat Saber.exe).
  • Windows ищет системные библиотеки, нужные для Unity. Одна из таких библиотек — это winhttp.dll. Windows находит в папке с игрой нашу поддельную библиотеку winhttp.dll и загружает ее.
  • В поддельной winhttp.dll вызывается код Doorstop. Он заменяет функцию GetProcAddress из системной библиотеки kernel32.dll на прокси hookGetProcAddress.
  • Загружается библиотека UnityPlayer.dll, написанная на C++.
  • Unity вызывает GetProcAddress, чтобы найти функцию mono_jit_init_version. Так как мы заменили GetProcAddress, она находит переопределенную ownMonoJitInitVersion.
  • ownMonoJitInitVersion загружает Mono.
  • ownMonoJitInitVersion использует Mono, чтобы загрузить IPA.Injector.dll и вызвать метод Main из нее.
  • IPA.Injector с помощью библиотеки Mono.Cecil модифицирует класс Application из библиотеки UnityEngine.CoreModule.dll и добавляет туда статический конструктор, в котором загружаются моды для Beat Saber.
  • ownMonoJitInitVersion возвращает Mono в Unity.
  • Unity использует Mono, чтобы запустить части движка, написанные на C#.
  • Загружается модифицированный класс Application и вызывается его статический конструктор. Он загружает моды для Beat Saber.
  • Unity использует Mono, чтобы запустить код Beat Saber.
  • Оригинальный код игры и код из модов теперь существуют вместе.

Про часть 2


На этом первая часть завершена. Мы разобрались, как с помощью манипуляций с библиотеками Windows и Unity можно добавить моды в Beat Saber. Несмотря на то, что BSIPA — это форк, сделанный специально для этой игры, мы в этой статье нигде не делали предположений о внутренней структуре игры и не использовали ее код, а значит можно сказать, что эти методы применимы ко всем Unity-играм.


В следующей части мы напишем свой собственный мод для Beat Saber: посмотрим, как моды обмениваются информацией с игрой, как модифицировать поведение оригинальной игры, а также воспользуемся Harmony — библиотекой для модификации C#-кода, которая используется моддерами в RimWorld, BATTLETECH, Cities: Skylines, Kerbal Space Program, Oxygen Not Included, Stardew Valley, Subnautica и многих других.


Благодарности


Хочется поблагодарить сообщество BSMG и в особенности DaNike за то, что ответили на мои вопросы в Discord и помогли разобраться с тем, как работает Doorstop и зачем ему нужна winhttp.dll. Вряд ли они это прочитают, но без них у меня бы не сложилось полное понимание, как все работает, а значит и не было бы этой статьи.