How mods for Unity games are developed. Part 2: writing your mod

In this part, using an example of a mod for Beat Saber, we will look at the general principles of developing mods for Unity games, find out what difficulties are there, and also get acquainted with Harmony, a library for modifying game code used in RimWorld, Battletech, Cities: Skylines, and many other games.


Although this article looks like a tutorial on how to write your own mod for Beat Saber, its purpose is to show what principles are used to create any custom mods and what problems you have to solve during development. Everything described here, with some reservations, is applicable to all Unity games at least on Windows.



Image Sources: 1 , 2


In the previous series


Past part


Information from the first part is not needed to understand what will happen here, but still I advise you to read it.


Here is its brief (very) content:


( ) — 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 , 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 .





Creating mods is a rather tedious process. When developing mods, you need to constantly delve into the decompiled code, look for classes that do what you need, modify them, constantly rebuild the mods to check for changes in the game, suffer from the lack of a normal debugging mode and a full-fledged Unity editor.


And then the developers release a new version of the game, in which they changed the logic that was used in the mod, and you need to do it all over again.


All Articles