Как разрабатываются моды для игр, которые не поддерживают моды (на примере Beat Saber) — часть 2: пишем свой мод

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

Источники изображений: <a href="https://www.oculus.com/experiences/quest/2448060205267927/" rel="nofollow noreferrer noopener" target="_blank">1</a>, <a href="https://youtu.be/OH4txDD23_c" rel="nofollow noreferrer noopener" target="_blank">2</a>
Источники изображений: 1, 2

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

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

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

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

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

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

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

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

В этом лонге будет описана полная разработка мода, начиная с создания пустого проекта. Я разбил все на 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 используют семантическое версионирование (он же СемВер или SemVer). Подробнее о нем можно прочитать у меня в личном блоге на DTF.

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 сами советуют так делать.

Так выглядят зависимости у мода SongCore - одного из важнейших модов для Beat Saber. <a href="https://github.com/Kylemc1413/SongCore/blob/master/SongCore.csproj" rel="nofollow noreferrer noopener" target="_blank">GitHub</a>
Так выглядят зависимости у мода SongCore - одного из важнейших модов для Beat Saber. GitHub

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

"Beat Saber.exe" 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. В каждом поле я выводил его координаты. Это помогло мне начать хоть как-то ориентироваться в координатах и масштабе сцены.

Как разрабатываются моды для игр, которые не поддерживают моды (на примере Beat Saber) — часть 2: пишем свой мод

В 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-файл, копируем его в папку с плагинами, запускаем игру и любуемся результатом.

Как разрабатываются моды для игр, которые не поддерживают моды (на примере Beat Saber) — часть 2: пишем свой мод

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

[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()

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

Как разрабатываются моды для игр, которые не поддерживают моды (на примере Beat Saber) — часть 2: пишем свой мод

Вывод из шага 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 и его события теперь не определены. Их мы и будем заменять на этом шаге.

menuSceneActive и gameSceneActive

Эти события срабатывают, когда активируется сцена с меню и сцена с основным геймплеем соответственно. Для работы со сценами у 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. Нам нужно посмотреть ее содержимое, а для этого нужен декомпилятор. На сайте BSMG советуют использовать dnSpy. Я использую Rider в качестве среды разработки, и у него есть встроенный декомпилятор, поэтому про dnSpy ничего конкретного сказать не могу, не пользовался. Но, судя по описанию, вещь полезная — это не только декомпилятор, но еще и дебаггер, который может подключаться к Unity-процессам.

Дальше идет рутина: берем содержимое Main.dll и ищем класс, который делает то, что нам нужно. Это сложно, но по-другому никак. Разве что можно пойти в дискорд 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-редактора.

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

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

66 показов
4.2K4.2K открытий
22 репоста
6 комментариев

Статья отличная, жаль не оценят ее тут по достоинству.

Ответить

Это нормально. Она еще попадет на хабр, в мой англоязычный блог и я скину ее в дискорд BSMG. Она вполне тянет на туториал для новых моддеров Beat Saber.

Ответить

Вау, очень интересно, прочитал на одном дыхании

Ответить

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

Ответить

Комментарий недоступен

Ответить

Комментарий недоступен

Ответить