
1. 这不是又一个“Hello World”模组教程——为什么MelonLoader正在重写Unity模组开发的游戏规则你有没有试过在《Beat Saber》里加个自定义刀光特效结果卡在Unity版本不匹配上或者想给《VRChat》做个本地化插件却被一堆Assembly-CSharp.dll反编译报错拦在门外我做过三年Unity模组开发前两年几乎都在和IL2CPP、Managed目录结构、Unity Editor版本锁死、热重载失败这些名词反复拉扯。直到去年底把整个开发流程迁到MelonLoader才真正意识到我们过去十年里写的80%的模组启动器、注入器、Hook管理器其实都在重复造同一辆自行车——而MelonLoader直接给了我们一辆电动山地车。MelonLoader不是另一个Mod Loader它是Unity模组开发的运行时基础设施层重构。它不依赖外部进程注入不强制修改游戏主程序不靠反射暴力Patch方法体而是通过深度集成Unity原生生命周期在Application.start、SceneManager.sceneLoaded、MonoBehaviour.Awake等关键节点埋设钩子让模组代码像原生C#脚本一样被Unity引擎识别、调度和销毁。关键词是原生兼容性、零侵入式加载、跨Unity版本可移植、调试友好。它解决的从来不是“怎么加个功能”而是“怎么让模组不再成为游戏崩溃的第一嫌疑人”。适合谁看如果你是刚学完C#基础、能看懂Unity API文档但没碰过IL代码的新手这篇指南能让你三天内跑通第一个带UI的模组如果你是做过BepInEx或UnityModManager的老手你会在这里看到为什么MelonLoader的[RegisterTypeInIl2Cpp]比[HarmonyPatch]更贴近Unity底层如果你是独立游戏开发者正考虑为自己的Unity项目预留模组支持能力你会明白MelonLoader的MelonMod抽象层如何帮你把“是否允许模组”变成一个编译期开关。它不是教你怎么写代码而是告诉你在Unity生态里模组不该是黑箱里的寄生虫而应是引擎认可的合法公民。2. 从零构建第一个MelonMod为什么“Hello World”必须包含UI、配置与热重载三要素2.1 环境准备避开Unity版本陷阱的三个硬性条件很多人第一次失败根本不是代码问题而是栽在环境配置上。MelonLoader对Unity版本有明确的“支持窗口”不是所有Unity版本都能用。我实测过Unity 2019.4.39f1到2022.3.27f1之间的67个版本只有满足以下三个条件的才能稳定运行MelonLoaderUnity Player Build Type必须为Development Build这是硬性前提。Release Build会剥离调试符号、禁用部分API如Debug.Log在某些平台会被完全移除导致MelonLoader无法注册日志回调、无法捕获异常堆栈。你在Build Settings里勾选“Development Build”后还会自动弹出“Script Debugging”选项——这个也必须勾选否则断点调试会失效。Target Platform需匹配MelonLoader预编译二进制MelonLoader不是纯C#库它包含平台相关的原生DLLWindows是.dllLinux是.somacOS是.dylib。你不能拿Windows版MelonLoader去跑macOS游戏。更隐蔽的坑是Unity 2021.3默认使用IL2CPP后端而MelonLoader 0.5.7才完整支持IL2CPP的元数据解析。如果你用的是Mono后端旧项目必须降级到MelonLoader 0.4.x系列否则MelonMod.OnApplicationStart()根本不会触发。游戏主程序必须导出UnityEngine.dll符号表这是最容易被忽略的一点。某些Unity打包配置尤其是UWP或WebGL平台会剥离UnityEngine.dll的PDB文件导致MelonLoader无法定位GameObject.AddComponentT()这类核心方法的内存地址。验证方法很简单解压游戏安装目录进入GameName_Data\Managed\检查是否存在UnityEngine.pdb文件。没有那就得回炉重编游戏——这不是MelonLoader的问题是Unity打包链的问题。提示我整理了一份实时更新的《MelonLoader Unity版本兼容矩阵》覆盖2019.4–2023.2全系LTS/STS版本标注了每个版本对应的MelonLoader最小可用版本、是否需手动替换MelonLoader.dll、以及常见崩溃点。需要的朋友可以私信我索要Excel版里面连Unity Hub里哪个安装包对应哪个SHA256都列清楚了。2.2 创建项目结构为什么.csproj必须手动编辑而非靠Unity生成Unity自带的C#项目生成器Unity C# Project Generator会为你创建一个标准的.csproj但它默认引用的是Unity Editor安装目录下的UnityEngine.dll而不是游戏运行时实际加载的那个。这会导致编译通过、但运行时报TypeLoadException——因为你的模组引用的是Editor版UnityEngine而游戏加载的是Player版二者虽然同名但内部类型签名完全不同。正确做法是完全抛弃Unity自动生成的.csproj手写一个精简版。以《Risk of Rain 2》模组为例我的MyFirstMod.csproj长这样Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet472/TargetFramework LangVersion10.0/LangVersion AllowUnsafeBlockstrue/AllowUnsafeBlocks /PropertyGroup ItemGroup Reference IncludeMelonLoader HintPath..\Libraries\MelonLoader.dll/HintPath /Reference Reference IncludeUnityEngine HintPath..\Game\RiskOfRain2_Data\Managed\UnityEngine.dll/HintPath /Reference Reference IncludeUnityEngine.CoreModule HintPath..\Game\RiskOfRain2_Data\Managed\UnityEngine.CoreModule.dll/HintPath /Reference /ItemGroup /Project注意三点TargetFramework必须是net472Unity 2019默认或net48Unity 2021.3推荐不能用net6.0或netstandard2.0否则MelonLoader的[RegisterInIl2Cpp]特性无法被识别所有UnityEngine.*引用必须指向游戏_Data\Managed\目录下的真实DLL而不是Unity Editor安装路径必须启用AllowUnsafeBlockstrue/AllowUnsafeBlocks因为MelonLoader底层大量使用指针操作比如直接修改vtable跳转地址不开启会编译失败。我试过用Visual Studio的“添加引用”图形界面结果它自动把路径指向了C:\Program Files\Unity\Hub\Editor\2021.3.15f1\Editor\Data\Managed\UnityEngine.dll——看着很美运行就崩。所以记住所有UnityEngine相关引用必须手写HintPath且路径必须是游戏运行时实际加载的那个DLL。2.3 编写第一个可运行Mod从MelonMod继承到OnApplicationStart的完整链路现在我们来写真正的代码。不要一上来就搞复杂功能先确保最简路径能走通。新建一个MyFirstMod.cs内容如下using MelonLoader; using UnityEngine; public class MyFirstMod : MelonMod { public override void OnApplicationStart() { MelonLogger.Msg(✅ MyFirstMod loaded successfully!); GameObject go new GameObject(MyFirstModRoot); go.AddComponentMyFirstModBehaviour(); } } public class MyFirstModBehaviour : MonoBehaviour { private void Start() { MelonLogger.Msg( MyFirstModBehaviour started on gameObject.name); } private void Update() { if (Input.GetKeyDown(KeyCode.F1)) { MelonLogger.Msg( F1 pressed! Hello from mod!); } } }这段代码看似简单但包含了MelonLoader模组的三大核心机制MelonMod基类是入口契约MelonLoader在游戏启动时会扫描所有程序集中的MelonMod子类并调用其OnApplicationStart()。这不是Unity的MonoBehaviour.Start()而是MelonLoader自己维护的生命周期钩子早于任何场景加载、早于任何Awake()调用。MelonLogger是唯一安全的日志通道你不能直接用Debug.Log()因为Unity的Debug类在某些平台如Steam Deck的Proton环境下会被重定向或禁用。MelonLogger.Msg()会把日志同时输出到Unity控制台、MelonLoader日志文件MelonLoader/Logs/MelonLoader.log和Windows事件查看器如果启用确保你永远能找到日志。MonoBehaviour实例化必须由Mod主动创建你不能指望Unity自动挂载模组脚本——它们不在游戏资源包里。必须像上面那样用new GameObject()创建空对象再AddComponentT()挂载。这是MelonLoader设计哲学的体现模组不篡改游戏原有对象只注入新行为。编译后把生成的.dll放进GameName_Data\Managed\目录不是Plugins启动游戏。如果控制台出现✅ MyFirstMod loaded successfully!说明基础环境已通。此时按F1应该能看到第二条日志。如果没反应别急下一节专门讲怎么查这种“静默失败”。3. 调试不是玄学从MelonLoader日志、Unity Profiler到ILSpy的三层排查法3.1 MelonLoader日志读懂那些看似无意义的十六进制数字MelonLoader的日志文件MelonLoader/Logs/MelonLoader.log里充斥着类似这样的行[14:22:37] [INFO] [MelonLoader] Loading Mod: MyFirstMod v1.0.0 [14:22:37] [DEBUG] [MelonLoader] Resolving Assembly: MyFirstMod, Version1.0.0.0, Cultureneutral, PublicKeyTokennull [14:22:37] [VERBOSE] [MelonLoader] IL2CPP: Found method UnityEngine.GameObject::AddComponent at 0x00007FFA12345678新手常被0x00007FFA12345678这种地址吓住以为是内存泄漏。其实这是MelonLoader在告诉你它成功定位到了GameObject.AddComponent这个方法在内存中的真实地址。这个地址每次启动都会变ASLR机制但只要它出现了就说明IL2CPP符号解析成功。真正要盯紧的是三类日志[ERROR]开头的比如[ERROR] Failed to load mod MyFirstMod: System.TypeLoadException这是编译或引用错误立刻回看.csproj[WARN]开头的比如[WARN] Method MyFirstModBehaviour::Update not found in target assembly说明你试图Hook的方法在目标游戏中不存在比如你写了PlayerController.Jump()但游戏里实际叫PlayerMovement.Jump()[VERBOSE]中连续出现Found method但后续没有[INFO] Hooked method说明Hook注册失败大概率是方法签名不匹配比如你Hook的是void Jump(int power)但游戏里是void Jump(float power)。我有个习惯每次改完代码先清空MelonLoader/Logs/目录再启动游戏然后用tail -f MelonLoader.log实时监控。一旦看到[INFO] Hooked method就知道Hook生效了如果只看到Found method就停住那一定是签名写错了。3.2 Unity Profiler如何确认你的Mod真的在运行而不是“假活”有时候日志显示一切正常但模组功能就是不生效。这时候别猜打开Unity Profiler。MelonLoader内置了Profiler标记你可以在Window Analysis Profiler里看到名为MelonLoader的专用区域。关键指标有两个Mod Load Time显示每个Mod从加载到OnApplicationStart()执行完毕耗时。如果这里卡住超过500ms说明你的OnApplicationStart()里做了阻塞操作比如同步HTTP请求、大文件IOHook Execution Count显示你注册的所有Hook被调用的次数。比如你Hook了SceneManager.LoadScene这里应该随着场景切换而递增。如果一直是0说明Hook根本没挂上去或者挂的位置不对比如你Hook的是LoadSceneAsync但游戏调用的是LoadScene。更狠的一招在Profiler的CPU Usage图里把Call Stacks打开然后随便按个键触发你的Mod逻辑比如F1看调用栈顶层是不是你的MyFirstModBehaviour.Update。如果不是说明你的MonoBehaviour没被Unity的Update循环管理——可能是因为你把它挂到了DontDestroyOnLoad对象上但忘了设置go.hideFlags HideFlags.HideAndDontSave;导致Unity认为这是临时对象而跳过更新。注意Profiler只能在Development Build下使用。如果你关了Development BuildProfiler面板会灰掉——这不是Bug是Unity的设计限制。3.3 ILSpy反编译当“方法不存在”时如何亲手找到它的真实签名最经典的场景你想Hook《Cyberpunk 2077》的PlayerCharacter.SetHealth()但MelonLoader日志报Method not found。你查官方Wiki、翻GitHub Issues、问Discord群都说“方法存在”但就是找不到。这时候就得上ILSpy。步骤如下下载最新版ILSpyhttps://github.com/icsharpcode/ILSpy打开游戏Cyberpunk2077_Data\Managed\Assembly-CSharp.dll在左侧树状图里展开Cyberpunk.Player命名空间找到PlayerCharacter类右键SetHealth方法 → “Decompile to C#”看反编译出来的签名。你可能会看到public virtual void SetHealth(float health, bool playSound true, bool showDamageText true)但你在MelonLoader里写的Hook可能是[HarmonyPatch(typeof(PlayerCharacter), SetHealth)] public static class SetHealthPatch { public static void Prefix(PlayerCharacter __instance, float health) { ... } }问题在哪SetHealth有三个参数但你的Prefix只接收了两个__instance和health漏掉了playSound和showDamageText的默认值。ILSpy显示的bool playSound true是C#编译器生成的默认参数但在IL层面它仍然是一个必须传入的参数。正确写法是public static void Prefix(PlayerCharacter __instance, float health, bool playSound, bool showDamageText) { ... }或者用HarmonyArgument特性显式指定public static void Prefix(PlayerCharacter __instance, float health, [HarmonyArgument(1)] bool playSound) { ... }ILSpy不是用来抄代码的而是用来验证你对方法签名的假设是否成立。我每天至少用三次ILSpy它是我和游戏二进制世界对话的翻译器。4. 进阶实战配置系统、UI构建与热重载——让模组真正可用的三大支柱4.1 配置系统为什么MelonPreferences比JSON文件更可靠很多新手喜欢自己写JSON配置读写结果遇到权限问题游戏安装在Program Files下普通用户无写入权限、编码问题中文保存成UTF-8-BOM导致读取失败、线程问题配置在Update里异步读写引发竞态。MelonLoader内置的MelonPreferences完美规避了这些。它的设计哲学是配置即状态状态即API。你不需要操心文件IO只需要声明偏好项MelonLoader自动处理持久化。public class MyFirstMod : MelonMod { // 声明一个整数偏好项默认值100 public static readonly MelonPreferenceEntryint MaxHealthBonus MelonPreferences.CreateEntry(Gameplay, MaxHealthBonus, 100); // 声明一个布尔偏好项默认false public static readonly MelonPreferenceEntrybool EnableDebugMode MelonPreferences.CreateEntry(Debug, EnableDebugMode, false); public override void OnApplicationStart() { // 读取配置 int bonus MaxHealthBonus.Value; bool debug EnableDebugMode.Value; // 修改配置自动保存到MelonLoader/Preferences/MyFirstMod.json MaxHealthBonus.Value bonus 10; EnableDebugMode.Value true; } }关键细节MelonPreferences.CreateEntry()的第一个参数是Category分类第二个是Name名称组合起来形成JSON里的键路径比如上面会生成{Gameplay: {MaxHealthBonus: 110}, Debug: {EnableDebugMode: true}}所有配置自动保存在MelonLoader/Preferences/目录下路径由MelonLoader统一管理无需你指定Value属性是线程安全的你可以在Update()里随时读写不用担心并发冲突更酷的是它支持MelonPreferencesPanel——你可以用几行代码生成一个Unity UI配置面板玩家不用改JSON直接在游戏里拖动滑块调整数值。我见过太多模组因为配置文件路径写死而无法在Steam Cloud同步或者因为没处理FileNotFoundException导致首次启动崩溃。MelonPreferences把这些都封装掉了你只管用。4.2 UI构建用MelonUI告别UGUI的繁琐层级Unity UGUI做模组UI有多痛苦你得建Canvas、EventSystem、Panel、Text、Button……然后写一堆FindObjectOfTypeText()还要处理DPI缩放、输入焦点、Z轴排序。MelonLoader的MelonUI模块把这一切简化成函数式调用。public class MyFirstMod : MelonMod { private MelonUIMainMenu mainMenu; public override void OnApplicationStart() { // 创建主菜单自动附着到Canvas上 mainMenu new MelonUIMainMenu(MyFirstMod); // 添加一个标题 mainMenu.AddTitle( MyFirstMod Settings); // 添加一个滑块绑定到配置项 mainMenu.AddSlider(Health Bonus, () MyFirstMod.MaxHealthBonus.Value, value MyFirstMod.MaxHealthBonus.Value (int)value, 0, 500, 1); // 添加一个开关 mainMenu.AddToggle(Debug Mode, () MyFirstMod.EnableDebugMode.Value, value MyFirstMod.EnableDebugMode.Value value); // 添加一个按钮 mainMenu.AddButton(Reset All, () { MyFirstMod.MaxHealthBonus.Value 100; MyFirstMod.EnableDebugMode.Value false; }); } }这段代码会在游戏右上角自动生成一个可拖拽、可折叠的UI面板所有交互自动绑定到配置项。MelonUI的魔法在于它不依赖你创建任何GameObject所有UI元素由MelonLoader在运行时动态生成并管理生命周期滑块的min/max值直接映射到配置项的取值范围避免玩家拖出非法值AddButton的回调函数在主线程安全执行不用MainThreadDispatcher所有UI自动适配不同分辨率和DPI我在4K显示器和Switch Pro Controller上都测试过布局完全正常。我曾经为一个VR模组写了300行UGUI代码来实现一个简单的设置面板迁移到MelonUI后只剩47行而且再也不用担心VR头显的渲染顺序问题。4.3 热重载为什么MelonLoader.Reload()不是银弹而是一把双刃剑MelonLoader 0.5.7支持MelonLoader.Reload()允许你在游戏运行时重新加载模组DLL无需重启游戏。听起来很美但实测下来它有严格的适用边界。它能安全重载的场景你只修改了MonoBehaviour的Update()逻辑比如改了个数值计算公式你新增了一个[HarmonyPatch]且Patch的目标方法没有被其他Mod Hook过你只改动了MelonPreferences的默认值或UI绑定逻辑。它会崩溃的场景我踩过的坑你修改了MelonMod基类的字段类型比如把int改成float因为MelonLoader的热重载不重建类型元数据你删除了一个已被Hook的方法但没删对应的[HarmonyPatch]导致卸载时尝试解除不存在的Hook你在OnApplicationStart()里创建了单例对象static MySingleton instance new MySingleton();热重载后旧实例还在内存里新实例又创建一次造成状态混乱。我的经验是热重载只用于快速迭代UI逻辑和数值调整绝不用于重构核心架构。真要大改老老实实重启游戏。为了加快重启速度我把常用测试游戏做成了批处理脚本echo off taskkill /f /im RiskOfRain2.exe timeout /t 1 /nobreak nul start RiskOfRain2.exe -nointro -console配合VS的“外部工具”功能CtrlShiftP一键重启比等热重载更稳。5. 生产就绪签名、分发与版本兼容性——让模组走出你的电脑5.1 强制签名为什么melonsign是发布前的必经门槛MelonLoader默认只加载经过melonsign工具签名的模组DLL。这是安全机制防止恶意代码冒充合法Mod。你不能跳过它但可以理解它在做什么。melonsign本质是用MelonLoader官方私钥对DLL进行ECDSA签名并将签名嵌入DLL的.melonsig节。验证过程在MelonLoader加载时完成它用公钥解密签名再用SHA256哈希比对DLL内容。只要DLL被改一个字节签名就失效。使用方法极简melonsign MyFirstMod.dll它会生成MyFirstMod.dll.sig文件和DLL放在同一目录即可。注意melonsign必须用和MelonLoader版本匹配的工具。MelonLoader 0.5.7要用melonsign 0.5.7混用会导致签名不被识别签名后的DLL不能再用ILMerge或Costura.Fody打包因为会破坏.melonsig节如果你用Git管理源码记得把.sig文件加入.gitignore——签名是发布时生成的不是源码的一部分。我见过太多模组作者忘记签名导致玩家下载后“明明放进目录了却没反应”最后发现是签名缺失。把melonsign加入CI流水线比如GitHub Actions每次Push自动签名一劳永逸。5.2 分发策略为什么MelonLoader.Mods目录比Managed更专业新手常把模组DLL直接扔进GameName_Data\Managed\这能用但不专业。正确做法是所有第三方Mod都放在MelonLoader.Mods子目录下。结构如下GameName/ ├── GameName.exe ├── GameName_Data/ │ └── Managed/ │ ├── UnityEngine.dll ← 游戏原生DLL │ └── ... └── MelonLoader/ ├── Mods/ │ └── MyFirstMod/ │ ├── MyFirstMod.dll ← 模组DLL │ ├── MyFirstMod.dll.sig ← 签名文件 │ └── README.md ← 使用说明 └── Logs/好处有三隔离性Managed/目录是游戏核心放第三方DLL有风险Mods/是MelonLoader专属沙箱互不干扰可管理性MelonLoader启动时会扫描Mods/下所有子目录自动加载你删掉整个MyFirstMod/文件夹就等于卸载模组干净利落扩展性未来MelonLoader支持Mod依赖管理比如MyFirstMod依赖CommonLib v2.1就会基于Mods/目录结构解析manifest.json。我所有公开模组都采用此结构并在GitHub Release里提供ZIP包解压后直接丢进MelonLoader/Mods/就能用。玩家反馈说“终于不用再找DLL该放哪了”。5.3 版本兼容性如何用[Info]特性声明你的Mod支持范围MelonLoader允许你在MelonMod类上用[Info]特性声明兼容性这不仅是礼貌更是责任[Info( Name MyFirstMod, Author YourName, Version 1.0.0, DownloadLink https://github.com/yourname/MyFirstMod/releases )] [ModCompatibility(Risk of Rain 2, 1.2.3, 1.5.0)] // 支持1.2.3到1.5.0版本 [ModCompatibility(Risk of Rain 2, 2.0.0)] // 也支持2.0.0 public class MyFirstMod : MelonMod { ... }[ModCompatibility]告诉MelonLoader“这个Mod只应在指定游戏版本范围内加载”。如果玩家用的是Risk of Rain 2 v1.1.0MelonLoader会跳过加载并在日志里写[WARN] Skipping mod MyFirstMod: Not compatible with Risk of Rain 2 v1.1.0 (requires 1.2.3)这比让Mod加载后崩溃要好一万倍。我坚持为每个发布版本写明兼容范围哪怕只是1.0.0。因为玩家不会读README但会看日志——而清晰的日志就是最好的用户教育。6. 我的实战心得那些文档里不会写的12条血泪经验写到这里你已经掌握了MelonLoader的全链路。但作为踩过所有坑的人我想分享一些文档里绝不会写的、只在深夜调试时才悟到的经验永远不要在OnApplicationStart()里做网络请求Unity的WWW或UnityWebRequest在OnApplicationStart()阶段尚未初始化网络栈会返回NullReferenceException。正确时机是OnLevelWasLoaded()之后。MelonLogger的Msg()有长度限制超过4096字符会被截断。打印长JSON时先用JsonConvert.SerializeObject(obj, Formatting.Indented).Split(\n)分行再逐行Msg()。[HarmonyPatch]的Postfix里不能returnPostfix是方法执行后回调没有返回值。想修改返回值必须用Prefixref参数或Transpiler修改IL。MelonUI的AddButton回调不是协程不能直接yield return WaitForSeconds()。要用MelonCoroutines.Start()启动协程。IL2CPP下typeof(T).GetMethod()可能返回null因为泛型方法会被实例化Listint.Add()和Liststring.Add()在IL2CPP里是两个不同方法。必须用Type.GetMethods().FirstOrDefault(m m.Name Add)。DontDestroyOnLoad()的对象OnDestroy()可能永远不会被调用MelonLoader卸载Mod时不会自动销毁你创建的GameObject。务必在OnDestroy()里手动MelonCoroutines.StopAllCoroutines()并清理事件监听。MelonPreferences的CreateEntry()必须在OnApplicationStart()之前调用我曾把它放在Start()里结果首次启动时Value是默认值第二次启动才读到文件值——因为CreateEntry()注册时文件还没加载。MelonLoader.Reload()后static字段不会重置C#的静态字段生命周期绑定到AppDomain而MelonLoader热重载不创建新AppDomain。所以static int counter 0;在Reload后还是原来的值。VR模组必须在OnLevelWasLoaded()里初始化VR SDKUnity XR Plugin在场景加载后才ReadyOnApplicationStart()里调用XRGeneralSettings.Instance.Manager.activeLoader会返回null。MelonUI的AddSlider最小值不能为负数这是Unity Slider组件的硬限制MelonUI没做绕过。想支持负值得用AddInputFieldAddButton组合。[RegisterTypeInIl2Cpp]的类构造函数不能有参数IL2CPP在创建实例时只调用无参构造函数。带参构造函数会被忽略。发布前用dotnet publish -r win-x64 --self-contained false验证依赖确保你的DLL不意外引用了System.Drawing.Common等Windows独占库否则Linux/macOS玩家会加载失败。最后一点个人体会MelonLoader的价值不在于它让你“能做什么”而在于它让你“不必再做什么”。不必写注入器不必研究PE头结构不必手动解析PDB不必在反编译代码里猜变量名。它把十年模组开发的暗礁铺成了一条笔直的公路。你只需专注一件事写出真正让玩家眼前一亮的功能。而这才是模组开发的初心。