Skip to content

验证环境

本页内容已按 Build 740622 对应的本机 Managed DLL 和项目配置检查;尚未在游戏内完成运行验证。

⬅ 返回

Harmony 补丁整理

本篇是 Harmony 在《缺氧(ONI)》Mod 开发中的实战速查表 > 👉 目标:快速了解以及什么时候该用哪一种 Patch ---

阅读指引

  • 基础操作:了解 Harmony 初始化及 Prefix / Postfix 的基本用法。
  • 常用参数:了解 __instance / __state / AccessTools 读写游戏数据。
  • 特殊场景:了解 Transpiler / Finalizer 处理底层逻辑与异常的方式。

一、Harmony 是什么?

Harmony 是一个 运行时方法注入库,用于在不修改源代码的情况下:

  • 插入逻辑
  • 拦截原方法
  • 修改返回值
  • 重写部分执行流程

二、Patch 类型总览

Patch 类型执行时机常见用途
Prefix原方法执行前拦截 / 改参数 / 阻止执行
Postfix原方法执行后补充逻辑 / 改返回值
TranspilerIL 层改硬编码 / if / 常量
Finalizer原方法及其他补丁的异常处理阶段观察、转换或抑制异常

三、Harmony 初始化

在 ONI 中,继承 UserMod2 后,游戏会自动帮你执行 harmony.PatchAll()。你不需要再手动调用它,除非你有特殊的动态补丁需求。

csharp
using HarmonyLib;
using KMod;

namespace MyMod
{
    public class MyFirstMod : UserMod2
    {
        public override void OnLoad(Harmony harmony)
        {
            // 1. 必须保留基类调用,它会处理自动 PatchAll
            base.OnLoad(harmony);

            // 2. 这里通常用来放“全局初始化”逻辑
            // 比如:打印日志确认 Mod 已加载
            Debug.Log("MyFirstMod 已成功加载!");
        }
    }
}

四、Prefix(执行前拦截)

Prefix 用于在原代码跑起来之前先一步执行。

4.1 基础用法:单纯插入逻辑

如果你只想在某个动作发生时顺便做点事(比如记录日志),补丁返回 void 即可。

csharp
[HarmonyPatch(typeof(Operational), nameof(Operational.SetActive))]
public static class Log_Patch
{
    public static void Prefix(bool value)
    {
        // 只是看一眼参数,不影响原逻辑执行
        Debug.Log($"建筑状态尝试切换为: {value}");
    }
}

4.2 进阶用法:干预原逻辑

如果你想改变游戏的结果,需要注意 bool 返回值和 ref 关键字的作用。

方式代码关键点实际效果
修改参数ref bool value原方法仍会运行,但收到的是补丁修改后的参数
跳过原方法return false跳过原方法及后续会影响原方法的 Prefix

案例对比: 我们依然是在[HarmonyPatch(typeof(Operational), nameof(Operational.SetActive))]上打补丁。

csharp

// 情况 A:修改参数(原逻辑继续运行)
public static void Prefix(ref bool value)
{
    value = true; // 哪怕玩家点了关闭,建筑也会因为参数被改而保持开启
}

// 情况 B:跳过原方法
public static bool Prefix(bool value, bool force_ignore)
{
    return false;  // 原方法体内的代码不会执行
}

五、Postfix(执行后补充)

Postfix 在原方法执行完毕后运行,通常用于读取或调整返回值、补充后置逻辑。它不会自动阻止原方法执行,但仍应根据目标方法的生命周期和其他 Mod 的补丁方式选择。

5.1 实战代码:追加额外逻辑

  • 作用: 在原逻辑跑完后,顺便执行一些你自己的操作。
csharp
[HarmonyPatch(typeof(ElectrolyzerConfig), "CreateBuildingDef")]
public class ElectrolyzerCreateBuildingDef_Patch
{
    public static void Postfix(ref BuildingDef __result)
    {
        // 将电解器的功耗改为 1 瓦
        __result.EnergyConsumptionWhenActive = 1f;
        Debug.Log("电解器的功耗已被修改为 1 瓦");
    }
}
  • 场景: 初始化后的属性修正、添加自定义的组件、触发额外的通知。
  • 优势: 无论你的代码写得好不好,原版代码都已经安全跑完了,不会导致核心功能丢失。

5.2 修改返回值:修改计算结果

  • 作用: 劫持原方法的返回值,让游戏拿到你“调包”后的结果。
csharp
[HarmonyPatch(typeof(Overheatable), nameof(Overheatable.OverheatTemperature), MethodType.Getter)]
public static class Overheatable_OverheatTemperature_Patch
{
    // ref __result 指向原方法算出来的那个返回值
    public static void Postfix(ref float __result)
    {
        __result = 9999f; // 让建筑永远不会因为温度过高而损坏(强行改掉判定数值)
    }
}
枚举项怎么认出它(看 ILSpy/dnSpy)干嘛的
Normal方法名后面带个括号,比如 OnSpawn()在某个动作执行前后“插一脚”
Getter代码里长这样:float Speed用于修改游戏“读取”到的数据
Setter代码里长这样:set用于在游戏尝试修改某个数值时进行拦截
Constructor跟类名长得一模一样的方法在对象刚创建、还没放进世界时,提前修改它的默认字段或初始化逻辑
StaticConstructor前面带 static 的构造函数专门用来修改游戏加载时就定死的全局静态常量
Enumerator里面有一堆 yield return 的方法专门对付 ONI 里的异步动作、动画序列或那些不是瞬间完成的任务

5.3 处理重载

在 ONI 源码中,经常会出现多个方法名相同但参数不同的方法。如果不指定参数类型,Harmony 会报错。

案例:拦截 ModUtil.AddBuildingToPlanScreen 这个方法在游戏源码中有多个重载版本,我们对比来看:

csharp
public static class ModUtil
{
    // 重载 A:只有 2 个参数
    public static void AddBuildingToPlanScreen(HashedString category, string building_id) { ... }

    // 重载 B:有 3 个参数
    public static void AddBuildingToPlanScreen(HashedString category, string building_id, string subcategoryID) { ... }

    // 重载 c:有 5 个参数
    public static void AddBuildingToPlanScreen(HashedString category, string building_id, string subcategoryID, string relativeBuildingId, ModUtil.BuildingOrdering ordering = ModUtil.BuildingOrdering.After) { ... }
}

// 【对应的 HarmonyPatch 写法】
// 拦截“重载 B”,必须明确写出那 3 个参数的类型:
[HarmonyPatch(typeof(ModUtil), "AddBuildingToPlanScreen", new Type[] { typeof(HashedString), typeof(string), typeof(string) })]
// 试试举一反三 重载a会是怎么样的?

// ❌ 错误写法
[HarmonyPatch(typeof(ModUtil), "AddBuildingToPlanScreen")]

总结:Prefix 与 Postfix 怎么选?

  • 需要在原方法执行前修改参数、决定是否跳过原方法,或初始化 __state 时,考虑 Prefix。
  • 只需要读取或调整原方法结果、在原逻辑之后补充行为时,通常优先考虑 Postfix。
  • 如果需要修改方法内部的控制流或常量,再考虑 Transpiler;如果必须处理异常或保证异常路径上的清理逻辑,再考虑 Finalizer。

六、状态桥接(__state 传值)

有时候你需要在执行之后知道执行之前发生了什么(比如:存货前有多少,存货后剩多少)。__state 用于在 PrefixPostfix 之间传递本次调用的临时状态。

6.1 实战用法:前后状态对比

  • 作用: 记录一个初始值,等原逻辑跑完后再拿出来算差值。
csharp
[HarmonyPatch(typeof(Storage), "Store")]
public static class Storage_Monitor_Patch
{
    // 1. 在执行前,把当前帧数塞进 __state 储物柜
    public static void Prefix(out int __state)
    {
        __state = Time.frameCount;
    }

    // 2. 在执行后,从 __state 储物柜拿出刚才存的帧数
    public static void Postfix(int __state)
    {
        int cost = Time.frameCount - __state;
        if (cost > 0)
        {
            Debug.Log($"这次存货居然花了 {cost} 帧的时间!");
        }
    }
}

为什么用它?

  • 性能追踪: 记录方法运行前后的时间差。
  • 逻辑判定: 记录执行前的数值(比如生命值),执行后发现数值没变,就触发补救逻辑。
  • 简洁性: 它只在这一次补丁调用中有效,跑完就销毁,不会像全局变量那样污染你的代码。

参数细节说明

  • Prefix 里: 必须用 out 或者 ref(比如 out int __state),否则存不进去。
  • Postfix 里: 直接用(比如 int __state)即可拿到之前存的值。

七、访问 private 字段(AccessTools 与 ___

在 ONI 源码中,很多变量被声明为 private 你直接用 __instance 变量名 是访问不到的。这时有两种常用的“破门”方法。

7.1 方法一:三下划线字段注入

如果只是想简单读写一个实例变量,Harmony 提供了一个极简写法:在参数名前加 三个下划线。

csharp
[HarmonyPatch(typeof(Storage), "OnSpawn")]
public static class Storage_Easy_Patch
{
    // ___capacityKg 对应 Storage 类里的私有变量 capacityKg
    public static void Postfix(Storage __instance, ref float ___capacityKg)
    {
        ___capacityKg = 9999f; // 直接像改普通变量一样改掉它
    }
}

7.2 方法二:使用 AccessTools

AccessTools 是 Harmony 提供的反射辅助工具。它适合需要在补丁方法之外保存 FieldInfo、访问静态私有字段,或需要更明确控制反射查找的场景。

csharp
using System.Reflection;

public static class Storage_Hard_Patch
{
    // 缓存 FieldInfo,避免每次都重复查找字段
    private static readonly FieldInfo CapacityField =
        AccessTools.Field(typeof(Storage), "capacityKg");

    [HarmonyPatch(typeof(Storage), "OnSpawn")]
    public static class Patch
    {
        public static void Postfix(Storage __instance)
        {
            CapacityField.SetValue(__instance, 9999f);
        }
    }
}

怎么选?

  • 三下划线 (___):适合直接在补丁参数中读写实例字段,代码较短。
  • AccessTools:适合静态字段、补丁方法之外的字段引用,以及需要显式缓存 FieldInfo 的场景。
  • 缓存 FieldInfo 可以避免重复查找字段,但 FieldInfo.SetValue() 仍然是反射调用,不应笼统宣称比 ___field 注入更快。

Transpiler(修改 IL)

Transpiler 在补丁阶段接收并修改原方法的 IL 指令,适合 Prefix 和 Postfix 无法表达的内部逻辑修改。

8.1 它的作用

如果你发现 PrefixPostfix 都没法改掉某个逻辑,通常是因为那个逻辑是硬编码在方法中间的。

  • 改常量: 比如代码里写死了一个 if (speed > 10f),你想把 10f 改成 100f
  • 替换指令: 比如替换原版的一条检测指令。
  • 插入指令: 在目标指令附近加入你自己的逻辑。

8.2 实战代码:修改硬编码数值

  • 场景: 游戏原本规定挖矿只掉一半掉落物0.5f,我们通过补丁把它改成全掉 1.0f
csharp
[HarmonyPatch(typeof(WorldDamage), "OnDigComplete")]
public static class WorldDamage_OnDigComplete_Patch
{
    // Transpiler 接收的是一组 IL 指令集
    private static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions)
    {
        foreach (var ins in instructions)
        {
            // 目标:找到指令是“加载浮点数 0.5”的那一行
            if (ins.opcode == OpCodes.Ldc_R4 && (float)ins.operand == 0.5f)
            {
                // 改成 1.0f
                ins.operand = 1.0f;
            }
            yield return ins;
        }
    }
}

为什么说要慎用?

  • 报错难找: Transpiler 写错可能导致方法无效或游戏启动时报错。
  • 容易冲突: 多个 Mod 修改同一个方法的相同指令区域时,需要考虑补丁顺序和匹配方式。
  • 门槛高: 你需要看懂 IL(中间语言)指令,例如 ldarg.0callstfld

九、Finalizer(异常处理)

csharp
[HarmonyPatch(typeof(SomeClass), "SomeMethod")]
public static class SomeMethod_Finalizer
{
    public static void Finalizer(Exception __exception)
    {
        if (__exception != null)
        {
            Debug.LogError(__exception);
        }
    }
}

这个 void Finalizer 只观察并记录异常;原异常仍会继续传递给调用方。若确实要抑制异常,必须让 Finalizer 返回 Exception 并在需要抑制时返回 null

csharp
public static Exception Finalizer(Exception __exception)
{
    if (__exception != null)
        Debug.LogError(__exception);

    return null;
}

只有在确认可以安全忽略异常时才这样做。需要保留原异常时返回 __exception


十、ONI Mod 常见问题

  • Patch 方法忘记 static
  • 参数签名与原方法不一致
  • Prefix return false 但没处理返回值
  • Transpiler 修改逻辑过多