在这一部分中,我们将以Beat Saber的mod为例,研究为Unity游戏开发mod的一般原理,找出存在的困难,并熟悉Harmony,该Harmony是一个用于修改RimWorld,Battletech,Cities:Skylines和许多游戏代码的库其他游戏。
尽管本文看起来像是一个有关如何为Beat Saber编写自己的mod的教程,但其目的是展示用于创建任何自定义mod的原理以及在开发过程中必须解决的问题。保留所有权利,此处描述的所有内容至少在Windows上适用于所有Unity游戏。

图像源: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 , 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 .
创建mod是一个相当繁琐的过程。在开发mod时,您需要不断地研究反编译的代码,寻找可以满足您需求的类,对其进行修改,不断重建mod以检查游戏中的变化,并且缺乏常规的调试模式和功能强大的Unity编辑器。
然后,开发人员发布游戏的新版本,在其中他们更改了Mod中使用的逻辑,您需要重新做一遍。