Как разрабатываются моды для Unity-игр: пишем свой мод

    В этой части на примере мода для Beat Saber мы рассмотрим общие принципы разработки модов для Unity-игр, узнаем, какие есть трудности, а также познакомимся с Harmony — библиотекой для модификации кода игр, которая используется в RimWorld, Battletech, Cities: Skylines и многих других играх.


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



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


    В предыдущей серии


    Прошлая часть


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


    Вот ее краткое (очень) содержание:


    Программные моды (также известные как плагины) — это dll-библиотеки, которые загружаются вместе с игрой и выполняют какой-то код, добавляя в игру новую функциональность или модифицируя существующую. Если у игры нет встроенной поддержки модов, то никакие dll-файлы она запускать не будет. Поэтому для внедрения сторонних модов используются специальные библиотеки, например BepInEx или IPA. В Beat Saber используется BSIPA — улучшенная версия IPA. Сначала ее просто адаптировали специально для Beat Saber, а сейчас она в техническом плане значительно превосходит оригинальную IPA и может использоваться для любых Unity-игр.


    Про Beat Saber и мод, который мы будем делать


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



    Давайте напишем мод, который показывает время в игре. Он будет показывать текущее время (обычные часы), количество минут, проведенных в игре с ее запуска, и количество минут, активно проведенных в игре, т.е. только время, проведенное в основном геймплее с размахиванием мечей и без учета времени в меню и на паузе.


    В этой статье будет описана полная разработка мода, начиная с создания пустого проекта. Я разбил все на 5 шагов, в конце каждого шага будет краткий вывод об особенностях разработки модов. Если не хотите углубляться в код и детали, то можно просто пробежаться по выводам. Для полного понимания желательно знать основы Unity: работа со сценами, иерархия объектов, компоненты и их жизненный цикл.


    Подготовка


    Для начала нам нужно сделать так, чтобы игра была пригодна для модов. Для этого в случае с Beat Saber нужно скачать ModAssistant, настроить его (ничего сложного), установить обязательные моды вроде BSIPA, SongCore и BS_Utils и установить другие моды по вкусу. Теперь игра поддерживает моды, а в папках с игрой есть все нужные для нас библиотеки, и можно приступать к разработке.


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


    Замечание про версии


    Все, что написано в данной статье, работает как минимум для Beat Saber версии 1.9.1 и BSIPA версии 4.0.5. Все развивается и меняется, поэтому если вы читаете этот текст спустя какое-то время после его публикации, то имейте в виду, что часть информации может устареть.


    Шаг 0: минимальный рабочий мод


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


    Начальные шаги неплохо написаны на сайте Beat Saber Modding Group (далее просто BSMG). К сожалению, только начальные шаги там и описаны. Там предлагается несколько шаблонов Visual Studio для создания проекта на выбор — просто берете, какой нравится и создаете проект из шаблона.


    В этой статье мы пойдем более трудным путем и создадим проект с нуля. Берем любимую среду разработки для C# (у меня Rider), создаем новый C#-проект, выбираем Class Library в качестве целевой сборки и выбираем версию .NET, совместимую с Unity (у меня 4.7.2). Получаем пустой проект. Теперь создаем файлы мода.


    manifest.json


    Json-файл, содержащий мета-данные для BSIPA. Помечаем его в проекте как EmbeddedResource, чтобы при сборке он добавлялся внутрь нашего dll-файла.


    {
      "$schema": "https://github.com/beat-saber-modding-group/BSIPA-MetadataFileSchema/blob/master/Schema.json",
      "author": "fck_r_sns",
      "description": "A mod to track active time spent in the game",
      "gameVersion": "1.8.0",
      "id": "BeatSaberTimeTracker",
      "name": "BeatSaberTimeTracker",
      "version": "0.0.1-alpha",
      "dependsOn": {}
    }

    $schema указывает на файл с описанием схемы для валидации формата. Файл лежит на GitHub в репозитории BSIPA. Нас это сильно волновать не должно, просто добавляем и забываем. В dependsOn указываем, какие сторонние моды мы используем в нашем собственном моде. BSIPA использует эту информацию, чтобы определить порядок загрузки dll-файлов. gameVersion и version используют семантическое версионирование.


    Plugin.cs


    Теперь создаем класс, который будет точкой входа для нашего плагина. В BSIPA 3 нужно было написать класс, реализующий интерфейс IBeatSaberPlugin. BSIPA 3 считывала все классы из dll-файла мода, находила там класс, реализующий интерфейс IBeatSaberPlugin, и создавала объект этого класса — так запускался мод. В BSIPA 4 убрали интерфейс IBeatSaberPlugin. Теперь BSIPA ищет класс, помеченный атрибутом [Plugin], и методы с атрибутами [Init], [OnStart] и [OnExit].


    using IPA;
    using Logger = IPA.Logging.Logger;
    
    namespace BeatSaberTimeTracker
    {
        [Plugin(RuntimeOptions.SingleStartInit)]
        internal class Plugin
        {
            public static Logger logger { get; private set; }
    
            [Init]
            public Plugin(Logger logger)
            {
                Plugin.logger = logger;
                logger.Debug("Init");
            }
    
            [OnStart]
            public void OnStart()
            {
                logger.Debug("OnStart");
            }
    
            [OnExit]
            public void OnExit()
            {
                logger.Debug("OnExit");
            }
        }
    }

    Название класса может быть любое, но обычно его просто называют Plugin. Главное, чтобы пространство имен (namespace) соответствовало названию, которое мы указали в манифесте — в данном случае это BeatSaberTimeTracker. На этом этапе мы просто будем писать в лог, если был вызван какой-то метод.


    Чтобы это собралось, нужно указать компилятору, где определены атрибуты [Plugin], [Init], [OnStart] и [OnExit]. Для этого в свойствах проекта добавляем в зависимости файл IPA.Loader.dll. Будем считать, что моды у нас уже внедрены в игру, а значит, все нужные библиотеки уже лежат в папке с Beat Saber где-то в папках Steam. Библиотеки игры, Unity, системные библиотеки и файлы IPA лежат в папке Beat Saber/Beat Saber_Data/Managed. Все просто добавляют файлы прямиком из папки Steam в проект и так и выкладывают на GitHub, тут нечего стесняться. BSMG сами советуют так делать.


    Собираем наш мод, копируем получившийся dll-файл в папку Beat Saber/Plugins и запускаем игру. Для простой отладки не обязательно подключать VR-шлем, можно запустить игру из терминала с флагом fpfc. Игра запустится в режиме отладки с управлением мышью. Этого достаточно, чтобы потыкать кнопки в главном меню. После этого выходим из игры, идем в папку Beat Saber/Logs и ищем там логи для нашего мода.


    [DEBUG @ 20:50:03 | BeatSaberTimeTracker] Init
    [DEBUG @ 20:50:03 | BeatSaberTimeTracker] OnStart
    [DEBUG @ 20:50:21 | BeatSaberTimeTracker] OnExit

    Поздравляю, наш мод работает.


    Вывод для шага 0


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


    Полный код текущего этапа


    Шаг 1: выводим время на экран


    На этом шаге сделаем так, чтобы мод делал что-то осмысленное, но еще не трогал код самой игры — добавим часы где-нибудь в углу и покажем время, проведенное в игре с ее запуска. Последуем принципу единственной ответственности и создадим новый класс TimeTracker. Класс Plugin нужен только для запуска и инициализации мода, никакой другой логики там быть не должно.


    На этом этапе класс TimeTracker будет создавать canvas в мировом пространстве, добавлять на него два текстовых поля и раз в секунду обновлять на них значения.


    Создаем объекты в Awake:


    private void Awake()
    {
        Plugin.logger.Debug("TimeTracker.Awake()");
    
        GameObject canvasGo = new GameObject("Canvas");
        canvasGo.transform.parent = transform;
        _canvas = canvasGo.AddComponent<Canvas>();
        _canvas.renderMode = RenderMode.WorldSpace;
    
        var canvasTransform = _canvas.transform;
        canvasTransform.position = new Vector3(-1f, 3.05f, 2.5f);
        canvasTransform.localScale = Vector3.one;
    
        _currentTimeText = CreateText(_canvas, new Vector2(0f, 0f), "");
        _totalTimeText = CreateText(_canvas, new Vector2(0f, -0.15f), "");
    }

    Создаем объект, добавляем на него Canvas, настраиваем его, создаем два текстовых поля. Текстовые поля создаются в CreateText:


    private static TextMeshProUGUI CreateText(Canvas canvas, Vector2 position, string text)
    {
        GameObject gameObject = new GameObject("CustomUIText");
        gameObject.SetActive(false);
        TextMeshProUGUI textMeshProUgui = gameObject.AddComponent<TextMeshProUGUI>();
    
        textMeshProUgui.rectTransform.SetParent(canvas.transform, false);
        textMeshProUgui.rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
        textMeshProUgui.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
        textMeshProUgui.rectTransform.sizeDelta = new Vector2(1f, 1f);
        textMeshProUgui.rectTransform.transform.localPosition = Vector3.zero;
        textMeshProUgui.rectTransform.anchoredPosition = position;
    
        textMeshProUgui.text = text;
        textMeshProUgui.fontSize = 0.15f;
        textMeshProUgui.color = Color.white;
        textMeshProUgui.alignment = TextAlignmentOptions.Left;
        gameObject.SetActive(true);
    
        return textMeshProUgui;
    }

    Этот метод выглядит громоздко, но, по сути, мы здесь просто создаем объект TextMeshProUGUI и выставляем параметры RectTransform, которые мы в обычном случае установили бы в редакторе Unity.


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


    Чтобы хотя бы примерно понимать, какие координаты должны быть у элементов интерфейса, я сначала вывел на экран 400 текстовых полей: сетку 20 на 20. В каждом поле я выводил его координаты. Это помогло мне начать хоть как-то ориентироваться в координатах и масштабе сцены.



    В Update обновляем значения на текстовых полях:


    private void Update()
    {
        if (Time.time >= _nextTextUpdate)
        {
            _currentTimeText.text = DateTime.Now.ToString("HH:mm");
            _totalTimeText.text = $"Total: {Mathf.FloorToInt(Time.time / 60f):00}:{Mathf.FloorToInt(Time.time % 60f):00}";
            _nextTextUpdate += TEXT_UPDATE_PERIOD;
        }
    }

    Теперь обновляем наш класс Plugin, чтобы он создавал объект TimeTracker:


    [OnStart]
    public void OnStart()
    {
        logger.Debug("OnStart");
    
        GameObject timeTrackerGo = new GameObject("TimeTracker");
        timeTrackerGo.AddComponent<TimeTracker>();
        Object.DontDestroyOnLoad(timeTrackerGo);
    }

    Чтобы наш объект жил долго и счастливо и не был убит сборщиком мусора, нужно либо прикрепить его к какой-нибудь существующей сцене в игре, либо вызвать DontDestroyOnLoad(…). Второй способ проще.


    Чтобы все это работало, нам нужно добавить библиотеки Unity в список зависимостей проекта: UnityEngine.CoreModule.dll для GameObject и MonoBehaviour, UnityEngine.UI.dll и Unity.TextMeshPro.dll для TextMeshPro и UnityEngine.UIModule.dll для Canvas. Взять их можно все там же, в папке с игрой.


    Собираем dll-файл, копируем его в папку с плагинами, запускаем игру и любуемся результатом.



    Смотрим логи:


    [DEBUG @ 21:37:18 | BeatSaberTimeTracker] Init
    [DEBUG @ 21:37:18 | BeatSaberTimeTracker] OnStart
    [DEBUG @ 21:37:18 | BeatSaberTimeTracker] TimeTracker.Awake()
    [DEBUG @ 21:37:24 | BeatSaberTimeTracker] OnExit
    [DEBUG @ 21:37:25 | BeatSaberTimeTracker] TimeTracker.OnDestroy()

    Все отлично, наш мод работает и уже даже приносит пользу. Пока что он живет своей жизнью — он не влияет на игру, а игра не влияет на него. Но из-за этого у нашего мода есть серьезная проблема: он показывает время всегда, даже если оно нам мешает. Например, в самом геймплее. С этим мы разберемся далее.



    Вывод из шага 1


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


    Полный код текущего этапа


    Дифф с прошлым этапом


    Шаг 2: взаимодействуем с логикой самой игры


    На этом шаге начинаем контактировать с игрой. Будем считать активное время, проведенное в геймплее, и прятать UI мода, когда он не нужен. Для этого нужно научиться определять переходы из меню в основной геймплей и определять, поставили ли игру на паузу.


    Обновляем метод Update. Теперь будем использовать логическую переменную _trackActiveTime, чтобы включать и выключать отслеживание активного времени. Ну и выводим его в новое текстовое поле _activeTimeText. Создаем его так же, как и остальные, просто сдвигаем координаты чуть пониже.


    private void Update()
    {
        if (_trackActiveTime)
        {
            _activeTime += Time.deltaTime;
        }
    
        if (Time.time >= _nextTextUpdate)
        {
            _currentTimeText.text = DateTime.Now.ToString("HH:mm");
            _totalTimeText.text = $"Total: {Mathf.FloorToInt(Time.time / 60f):00}:{Mathf.FloorToInt(Time.time % 60f):00}";
            _activeTimeText.text = $"Active: {Mathf.FloorToInt(_activeTime / 60f):00}:{Mathf.FloorToInt(_activeTime % 60f):00}";
            _nextTextUpdate += TEXT_UPDATE_PERIOD;
        }
    }

    Теперь добавляем метод для включения и выключения отслеживания активного времени:


    private void SetTrackingMode(bool isTracking)
    {
        _trackActiveTime = isTracking;
        _canvas.gameObject.SetActive(!isTracking);
    }

    Здесь мы устанавливаем _trackActiveTime и скрываем текстовые поля. Это заодно решает проблему из прошлого этапа, когда время показывалось в основном геймплее.


    Теперь нам нужно каким-то образом сделать так, чтобы основная игра вызывала SetTrackingMode(true), когда мы запускаем какой-то уровень, и SetTrackingMode(false), когда мы возвращаемся в меню или ставим игру на паузу. Проще всего это сделать через события. Для начала пойдем простым путем и добавим мод, который упрощает взаимодействие с игрой, а потом уже посмотрим, как это делается руками.


    Нам нужен мод BS_Utils. Добавляем в список зависимостей проекта библиотеку BS_Utils.dll из папки Beat Saber/Plugins (мы ее установили когда ставили моды через ModAssistant). Теперь добавляем BS_Utils в манифест. Это нужно для того, чтобы наш мод загружался после него.


    "dependsOn": {
        "BS Utils": "^1.4.0"
      },

    Находим в событиях BS_Utils те, которые нам нужны, подписываемся на них и переключаем отслеживание активного времени.


    BSEvents.gameSceneActive += EnableTrackingMode;
    BSEvents.menuSceneActive += DisableTrackingMode;
    BSEvents.songPaused += DisableTrackingMode;
    BSEvents.songUnpaused += EnableTrackingMode;

    Методы EnableTrackingMode и DisableTrackingMode я добавил просто для удобства, чтобы можно было их использовать как делегаты в событиях без аргументов.


    private void EnableTrackingMode()
    {
        SetTrackingMode(true);
    }
    
    private void DisableTrackingMode()
    {
        SetTrackingMode(false);
    }

    Собираем проект, копируем dll в Plugins, запускаем игру, проверяем.





    Если бы мы просто разрабатывали мод для Beat Saber, то на этом этапе можно было бы и остановиться. Мод готов, он делает то, что мы хотели, и так, как мы хотели. Он использует сторонний мод BS_Utils, но почти все моды используют его. BS_Utils поддерживается одним из главных разработчиков в сообществе BSMG, так что не нужно переживать, что в какой-то момент он перестанет работать. Но это познавательная статья, поэтому мы пойдем дальше. И мы еще не все разобрали, что нужно для разработки модов.


    Вывод из шага 2


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


    Полный код текущего этапа


    Дифф с прошлым этапом


    Шаг 3: удаляем BS_Utils, лезем в код игры


    Удаляем BS_Utils из зависимостей проекта и из манифеста. Компилятор сообщает нам, что BSEvents и его события теперь не определены. Их мы и будем заменять на этом шаге.



    Эти события срабатывают, когда активируется сцена с меню и сцена с основным геймплеем соответственно. Для работы со сценами у Unity есть статический класс SceneManager, у которого есть события sceneLoaded, sceneUnloaded и activeSceneChanged. Добавляем обработчики событий для них и просто выводим названия сцен в логи. Так как мы уже добавили библиотеку UnityEngine.CoreModule.dll в зависимости, проблем с определением SceneManager быть не должно.


    private void Awake()
    {
        ...
        SceneManager.sceneLoaded += OnSceneLoaded;
        SceneManager.sceneUnloaded += OnSceneUnloaded;
        SceneManager.activeSceneChanged += OnActiveSceneChanged;
        ...
    }
    
    private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        Plugin.logger.Debug("OnSceneLoaded: " + scene.name + " (" + mode + ")");
    }
    
    private void OnSceneUnloaded(Scene scene)
    {
        Plugin.logger.Debug("OnSceneUnloaded: " + scene.name);
    }
    
    private void OnActiveSceneChanged(Scene previous, Scene current)
    {
        Plugin.logger.Debug("OnActiveSceneChanged: " + previous.name + " -> " + current.name);
    }

    Собираем мод, запускаем игру, заходим в основной геймплей, выходим из него, выходим из игры, смотрим логи.


    [DEBUG @ 14:28:14 | BeatSaberTimeTracker] Plugin.Init
    [DEBUG @ 14:28:14 | BeatSaberTimeTracker] Plugin.OnStart
    [DEBUG @ 14:28:14 | BeatSaberTimeTracker] TimeTracker.Awake()
    [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: EmptyTransition (Additive)
    [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnActiveSceneChanged: PCInit -> EmptyTransition
    [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: MainMenu (Additive)
    [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: MenuCore (Additive)
    [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: MenuEnvironment (Additive)
    [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneLoaded: MenuViewControllers (Additive)
    [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnActiveSceneChanged: EmptyTransition -> MenuViewControllers
    [DEBUG @ 14:28:15 | BeatSaberTimeTracker] OnSceneUnloaded: EmptyTransition
    [DEBUG @ 14:28:22 | BeatSaberTimeTracker] OnSceneLoaded: BigMirrorEnvironment (Additive)
    [DEBUG @ 14:28:22 | BeatSaberTimeTracker] OnSceneLoaded: StandardGameplay (Additive)
    [DEBUG @ 14:28:23 | BeatSaberTimeTracker] OnSceneLoaded: GameplayCore (Additive)
    [DEBUG @ 14:28:23 | BeatSaberTimeTracker] OnSceneLoaded: GameCore (Additive)
    [DEBUG @ 14:28:23 | BeatSaberTimeTracker] OnActiveSceneChanged: MenuViewControllers -> GameCore
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: GameCore -> MenuViewControllers
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: MenuViewControllers -> MainMenu
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: MainMenu -> MenuCore
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: MenuCore -> MenuEnvironment
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnActiveSceneChanged: MenuEnvironment -> MenuViewControllers
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnSceneUnloaded: BigMirrorEnvironment
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnSceneUnloaded: StandardGameplay
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnSceneUnloaded: GameplayCore
    [DEBUG @ 14:28:29 | BeatSaberTimeTracker] OnSceneUnloaded: GameCore
    [DEBUG @ 14:28:34 | BeatSaberTimeTracker] Plugin.OnExit
    [DEBUG @ 14:28:34 | BeatSaberTimeTracker] TimeTracker.OnDestroy()

    Здесь так много разных сцен, потому что Beat Saber использует разные сцены для разных компонентов и загружает их в режиме Additive. Интерфейс на одной сцене, платформа с игроком — на другой. Анализируем логи и делаем вывод: отслеживать переход в основной геймплей можно, например, при активации сцены GameCore. По аналогии, переход в меню — по активации сцены MenuCore. Но с MenuCore есть проблема — судя по логам, она не активируется при запуске игры, когда мы только попадаем в меню. Поэтому для меню лучше использовать сцену MenuViewControllers. Еще одно полезное наблюдение: сцены для меню загружаются один раз при запуске игры и просто деактивируются при запуске геймплея, а вот сцены геймплея загружаются заново при запуске уровня. Это нам еще пригодится.


    Обновляем OnActiveSceneChanged: проверяем имя сцены и переключаем отслеживание активного времени:


    private void OnActiveSceneChanged(Scene previous, Scene current)
    {
        Plugin.logger.Debug("OnActiveSceneChanged: " + previous.name + " -> " + current.name);
        switch (current.name)
        {
            case "MenuViewControllers":
                DisableTrackingMode();
                break;
    
            case "GameCore":
                EnableTrackingMode();
                break;
        }
    }

    songPaused и songUnpaused


    Для следующих событий придется покопаться в коде игры, поэтому переходим к настоящему реверс-инжинирингу. Теперь нам нужна библиотека, в которой содержится код Beat Saber. В папке «Beat Saber/Beat Saber_Data/Managed» лежат 2 библиотеки: Main.dll и MainAssembly.dll. Я сначала копался в MainAssembly.dll, из-за чего потратил 2 дня на отладку одного очень странного поведения. Оказалось, что по какой-то причине и Main.dll, и MainAssembly.dll содержат определения одних и тех же классов. Я использовал MainAssembly.dll, а в игре использовались классы из Main.dll. Возможно, какая-то ошибка при сборке билда у разработчиков игры.


    Судя по тому, что я узнал и посмотрел в других модах, все, что нам нужно, лежит в библиотеке Main.dll. Нам нужно посмотреть ее содержимое, а для этого нужен декомпилятор. На сайте BSMG советуют использовать dnSpy. Я использую Rider в качестве среды разработки, и у него есть встроенный декомпилятор, поэтому про dnSpy ничего конкретного сказать не могу, не пользовался. Но, судя по описанию, вещь полезная — это не только декомпилятор, но еще и дебаггер, который может подключаться к Unity-процессам.


    Дальше идет рутина: берем содержимое Main.dll и ищем класс, который делает то, что нам нужно. Это сложно, но по-другому никак. Разве что можно пойти в Discord-канал BSMG и спросить. Вам, скорее всего, ответят, потому что там много людей, которые уже когда-то декомпилировали Main.dll и что-то там искали (и нашли).


    Рано или поздно мы найдем класс GamePause, который отвечает в игре за включение и выключение паузы. У него есть два метода: Pause и Resume. А еще у GamePause есть два события: didPauseEvent и didResumeEvent. Отлично, нам даже не пришлось делать что-то сложное, у GamePause уже есть события, на которые мы можем подписаться.


    Значит, нам каким-то образом нужно получить ссылку на компонент GamePause. В Unity это можно сделать так:


    Resources.FindObjectsOfTypeAll<GamePause>();

    Этому методу все равно, на какой сцене компонент, что за объект и активен ли он. Если компонент создан, он будет найден. Но нужно как-то найти момент времени, когда этот компонент создан. Можно предположить, что он висит на каком-то объекте на одной из сцен в геймплее. Мы уже выяснили, что геймплейные сцены каждый раз создаются заново. У нас есть обработчики событий OnSceneLoaded и OnActiveSceneChanged, поэтому мы можем отловить там сцену GameCore и в этот момент попробовать получить ссылку на GamePause. Проблема в том, что он может создаваться динамически чуть позже, чем загружаются сцены, поэтому тут есть два варианта: поискать в игре событие, которое срабатывает после того, как GamePause создан (вряд ли такое есть), либо вызывать Resources.FindObjectsOfTypeAll каждый кадр, пока не найдем компонент. Например, через корутину:


    IEnumerator InitGamePauseCallbacks()
    {
        while (true)
        {
            GamePause[] comps = Resources.FindObjectsOfTypeAll<GamePause>();
            if (comps.Length > 0)
            {
                Plugin.logger.Debug("GamePause has been found");
                GamePause gamePause = comps[0];
                gamePause.didPauseEvent += DisableTrackingMode;
                gamePause.didResumeEvent += EnableTrackingMode;
                break;
            }
    
            Plugin.logger.Debug("GamePause not found, skip a frame");
            yield return null;
        }
    }

    Запускаем ее в OnActiveSceneChanged для сцены GameCore:


    private void OnActiveSceneChanged(Scene previous, Scene current)
    {
        Plugin.logger.Debug("OnActiveSceneChanged: " + previous.name + " -> " + current.name);
        switch (current.name)
        {
            case "MenuViewControllers":
                DisableTrackingMode();
                break;
    
            case "GameCore":
                EnableTrackingMode();
                StartCoroutine(InitGamePauseCallbacks());
                break;
        }
    }

    Собираем мод, запускаем игру и убеждаемся, что все работает. Также можно заглянуть в логи. Там видно, что GamePause существует сразу же после активации GameCore, а значит, корутина не нужна и можно ее убрать. Я решил оставить для надежности.


    Вывод из шага 3


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


    Полный код текущего этапа


    Дифф с прошлым этапом


    Шаг 4: вмешиваемся в логику игры с помощью Harmony


    На этом этапе начинается магия, мы взглянем на Harmony — библиотеку для модификации C#-кода, которая используется моддерами во многих играх. Ее автор — Andreas Pardeike (сайт, GitHub), работает ведущим iOS-разработчиком / архитектором в шведской полиции (Swedish Police Authority). В отличие от библиотеки Mono.Cecil из прошлой статьи про моды, которая модифицирует и перезаписывает dll-файлы с .NET-сборками, Harmony модифицирует код во время исполнения программы (runtime). Модифицировать можно только методы, что обычно достаточно, так как нам нужно модифицировать именно поведение, а не состояние. Для модификации состояния есть много других способов, в том числе стандартных.


    Модификации Harmony в терминах самой библиотеки называются патчами (patches). Есть несколько видов патчей:


    • Prefix. Патч, который вызывается перед выполнением метода. С его помощью можно перехватить и изменить аргументы метода, либо решить, нужно ли вызывать сам метод или сразу выйти из него.
    • Postfix. Патч, который вызывается после выполнения метода. Можно перехватить и изменить возвращаемое значение.
    • Transpiler. Патч, который на ходу модифицирует скомпилированный IL-код. Можно использовать, если нужно изменить логику где-то в середине метода.
    • Finalizer. С этим патчем мы как бы оборачиваем оригинальный метод в конструкцию try/catch/finally, а сам патч является обработчиком одновременно и catch, и finally.

    Самые популярные патчи — это Prefix и Postfix. Transpiler слишком сложный, так как это уже не C#, а IL-код, да и зачастую проще скопировать исходный метод через декомпилятор, изменить там что-то и заменить весь метод через Prefix/Postfix. Finalizer звучит полезно, но он появился только недавно, в Harmony 2.0, поэтому примеров его использования я еще не видел.


    Когда я только придумывал идею для мода, я думал, что Harmony мне понадобится сразу же, как только я решу убрать BS_Utils. Оказалось, что GamePause сам по себе содержит все нужные события, и теперь придется искусственно усложнить задачу, чтобы показать, как работает Harmony. Давайте представим, что в GamePause нет событий didPauseEvent и didResumeEvent, и нам нужно что-то с этим сделать.


    Так как мы все еще придерживаемся принципа единственной ответственности, создаем класс HarmonyPatcher. У него будет всего один метод: public static void ApplyPatches() {}, в котором будет примерно такой код:


    Harmony harmony = new Harmony("com.fck_r_sns.BeatSaberTimeTracker");
    harmony.PatchAll(Assembly.GetExecutingAssembly());

    Этих двух строк достаточно, чтобы установить все патчи, который у нас есть (но их пока нет). «com.fck_r_sns.BeatSaberTimeTracker» — это имя пакета. Оно должно быть уникальным, чтобы не было коллизий с патчами из других модов. Теперь идем в класс Plugin, который у нас отвечает за старт и инициализацию мода, и добавляем туда вызов HarmonyPatcher.ApplyPatches() перед созданием TimeTracker.


    Переходим к написанию самих патчей. Для каждого метода, который мы хотим модифицировать, нужно написать отдельный класс. Каждый патч — это статический метод в этом классе. Чтобы указать, что это за патч, мы можем либо использовать соответствующее имя метода (например, метод с именем Prefix — это Prefix-патч), либо использовать любые имена и помечать методы атрибутами (например, [HarmonyPrefix]). Я всегда предпочитаю, чтобы код был явным и легко читаемым, поэтому я сторонник подхода с атрибутами. Начнем с патчей для метода GamePause.Pause(). Добавим в него Postfix-патч, который просто пишет в лог, что был вызван метод Pause() и сработал Postfix-патч.


    [HarmonyPatch(typeof(GamePause), nameof(GamePause.Pause), MethodType.Normal)]
    class GamePausePausePatch
    {
        [HarmonyPostfix]
        static void TestPostfixPatch()
        {
            Plugin.logger.Debug("GamePause.Pause.TestPostfixPatch");
        }
    }

    Атрибут [HarmonyPatch] указывает, какие класс и метод нам нужно модифицировать. Статический метод TestPostfixPatch помечен атрибутом [HarmonyPostfix], поэтому это Postfix-патч. Создаем аналогичный класс для GamePause.Resume() (можно в том же файле), собираем, запускаем игру, запускаем уровень, жмем паузу, снимаем паузу, выходим из игры, проверяем логи.


    Проверяем, что патчи применились:


    [DEBUG @ 16:21:55 | BeatSaberTimeTracker] Plugin.Init
    [DEBUG @ 16:21:55 | BeatSaberTimeTracker] Plugin.OnStart
    [DEBUG @ 16:21:55 | BeatSaberTimeTracker] HarmonyPatcher: Applied
    [DEBUG @ 16:21:55 | BeatSaberTimeTracker] TimeTracker.Awake()

    Проверяем, что Postfix-патчи сработали:


    [DEBUG @ 16:22:24 | BeatSaberTimeTracker] GamePause.Pause.TestPostfixPatch
    [DEBUG @ 16:22:31 | BeatSaberTimeTracker] GamePause.Resume.TestPostfixPatch

    Отлично, Harmony работает, можно переходить к логике. В нашем искусственном примере мы представили, что событий didPauseEvent и didResumeEvent не существует, а значит, нам нужно в Postfix-патчах что-то сделать, чтобы TimeTracker включал и выключал отслеживание активного времени. Тут мы натыкаемся на главную проблему Harmony — все патчи являются статическими методами. А TimeTracker — это компонент, который висит где-то в иерархии объектов и статическим явно не является. Тут я вижу два нормальных решения этой задачи.


    Первый — это сделать TimeTracker доступным из статического контекста. Например, сделать его синглтоном или каждый раз получать на него ссылку через Resources.FindObjectsOfTypeAll(). В BS_Utils, например, используется синглтон.


    Второй — это добавить класс со статическими событиями вроде BS_Utils.Utilities.BSEvents, который мы использовали на ранних этапах. Этот вариант мне нравится больше, давайте реализовывать его.


    Создаем класс EventsHelper:


    namespace BeatSaberTimeTracker
    {
        public static class EventsHelper
        {
            public static event Action onGamePaused;
            public static event Action onGameResumed;
        }
    }

    Теперь обновляем наши патчи, чтобы они вызывали эти события:


    [HarmonyPatch(typeof(GamePause), nameof(GamePause.Pause), MethodType.Normal)]
    class GamePausePatchPause
    {
        [HarmonyPostfix]
        static void FireOnGamePausedEvent()
        {
            EventsHelper.FireOnGamePausedEvent();
        }
    }

    GamePauseResumePatch делается аналогично. Пришлось добавить публичные методы FireOnGamePausedEvent и FireOnGameResumedEvent, так как нельзя вызывать события из-за пределов их класса. Теперь TimeTracker может в любой момент подписаться на события в EventsHelper. Получаем код со слабым зацеплением — именно из-за этого подход с событиями мне нравится больше, чем вариант с синглтоном или Resources.FindObjectsOfTypeAll().


    Если мы соберем мод и запустим игру, то все будет работать. Однако, мы пока не учли одну деталь. В оригинальном коде GamePause.Pause() есть проверка от многократного перехода в режим паузы.


    if (this._pause)
      return;
    this._pause = true;
    …

    Postfix-патч же будет вызван в любом случае: и если мы установили паузу, и если это было повторное нажатие. А значит, и событие EventsHelper будет срабатывать всегда, даже если фактического перехода в паузу уже не было. Давайте добавим Prefix-патч, в котором будем проверять текущее состояние паузы. Harmony позволяет читать и изменять приватные переменные класса, а также передавать состояние между патчами одного метода. В Harmony вообще много чего можно получить в патче:


    • Аргументы метода: собственно то, что было передано в метод при его вызове.
    • __instance: ссылка на текущий объект, для которого вызван метод. По сути это просто this.
    • __state: переменная любого типа для передачи состояния между патчами. Если нужно несколько переменных, то просто пишем структуру или класс.
    • __result: возвращаемый результат оригинального метода. Если нужно, можно его изменить.
    • Приватные переменные: добавляем три (3) знака подчеркивания (_) перед названием аргумента в патче, и Harmony подставит туда значение из приватной переменной.

    Начнем со структуры, которая будет хранить состояние:


    struct PauseState
    {
        public bool wasPaused;
    }

    Нам нужно всего одно значение, чтобы отслеживать состояние паузы, поэтому структура избыточна, но как я уже писал выше, я люблю ясный код. PauseState __state — это более ясный код, чем просто bool __state.


    Теперь добавляем Prefix-патч:


    [HarmonyPrefix]
    static void CheckIfAlreadyPaused(out PauseState __state, bool ____pause)
    {
        __state = new PauseState { wasPaused = ____pause };
    }

    Здесь мы добавляем состояние с модификатором out, чтобы его можно было изменять, и приватную переменную ____pause (_pause и еще три подчеркивания перед ней). Просто сохраняем ____pause в __state — тут ничего хитрого.


    Теперь обновляем Postfx-патч:


    [HarmonyPostfix]
    static void FireOnGamePausedEvent(PauseState __state, bool ____pause)
    {
        if (!__state.wasPaused && ____pause)
        {
            EventsHelper.FireOnGamePausedEvent();
        }
    }

    __state даст нам ту же структуру, которую мы записали в Prefix-патче. Сравниваем wasPaused с ____pause, чтобы проверить, что игра реально поставлена на паузу и вызываем событие.


    Полный код патчей


    Запускаем игру и проверяем, что все работает.


    Вывод из шага 4


    Harmony — это очень полезная и важная для сообщества моддеров библиотека, которая используется в RimWorld, Battletech, Cities: Skylines, Kerbal Space Program, Oxygen Not Included, Stardew Valley, Subnautica и многих других играх.


    Полный код текущего этапа


    Дифф с прошлым этапом


    Заключение


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


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

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 7

      +1
      Интересный, подробный пост, спасибо.
      Не могли бы рассказать как/чем модифицировать/смотреть код игр, защищенных с помощью il2cpp? Сейчас многие игры на Unity защищают им, не только на мобилках, но и на ПК.
        0
        К сожалению, про il2cpp ничего не знаю. И BepInEx и BSIPA работают с Mono и .NET сборками.
          0
          Для просмотра IL2CPP кода подойдет любой дизассемблер (IDA Pro?), в таком билде бинарник, собранный из C++ кода, который еще наверняка усложнен оптимизациями компилятора, удобного IL кода там уже не доступно.
          С модификацией тоже явно будет сложнее по тем же причинам (если вообще возможно адекватно это делать).
          Опыта в этом всем почти не было (кроме не очень успешных попыток изучать полученный билд своих тестовых проектов).
          Тоже было бы интересно узнать, как с этим обстоят дела. Жаль, если это в итоге убьет моддинг.
            0
            il2cpp это не совсем защита — это компиляция в нативный бинарник для соответствующей платформы. С учётом грядущего появления (пока что оно в альфе) нативной компиляции «из коробки» — скоро с модами будет совсем туго. С другой стороны — скорость исполнения кода вырастает в разы, так что грех жаловаться…
            0
            Вот такой мод было бы интересней
            Miniature Beat Saber by Daniel Beauchamp

            Miniature Beat Saber by Daniel Beauchamp

            Posted by TopVR on Teisipäev, 12. mai 2020
              0
              Тут мы подходим к одному серьезному ограничению при разработке модов для Unity-игр — у нас нет редактора Unity.

              В чем проблема сделать все что нужно в редакторе и сохранить в Asset Bundle? А затем из кода мода подгрузить ваш canvas через


              var bundle = AssetBundle.Load(...);
              var prefab = bundle.LoadAsset<GameObject>(...);
              var instance = Instantiate(prefab);
              var canvas = instance.GetComponent<Canvas>();
                0
                Можно и так, но координаты все равно придется выбирать наугад, так как не известно, как этот префаб ляжет в существующие сцены. Или известно? Я просто сам могу всего не знать и ошибаться.

              Only users with full accounts can post comments. Log in, please.