MODFORGEWORLD
← Knowledge base

Harmony Patching

harmony-patchingv1updated 5h ago

Harmony Patching

Part of the 7DTD Modding Knowledgebase. Covers Harmony patch patterns for 7DTD mods — initialization, prefix/postfix, reflection, and common targets.


Overview

7DTD mods use HarmonyLib to patch game methods at runtime. Harmony is included with the game (via 0Harmony.dll). Patches let you intercept, modify, or replace game behavior without touching the game's source code.


Initialization

In your IModApi.InitMod:

[Preserve]
public class ModApi : IModApi
{
    public void InitMod(Mod _modInstance)
    {
        Harmony harmony = new Harmony("com.myname.mymod");
        harmony.PatchAll(); // patches all [HarmonyPatch] classes in this assembly
        Log.Out("[MyMod] Init complete");
    }
}

Use a unique Harmony ID (reverse domain notation) to avoid conflicts with other mods.

Wrap PatchAll in try/catch. If ANY patch target in the assembly fails to apply, PatchAll throws and the exception aborts InitMod — the game logs Failed initializing ModAPI instance and the ENTIRE mod is dead, not just the one patch. Catch, Log.Error, and continue; patches applied before the failure stay live.

Unpatchable methods — "IL Compile Error … Label #N is not marked"

Some vanilla methods cannot be Harmony-patched at all: MonoMod must re-JIT the original method's IL, and certain bodies (try/finally with early returns is the known trigger, e.g. PrefabLODManager.UpdateDisplay) fail with

HarmonyException: IL Compile Error (unknown location)
  → System.ArgumentException: Label #45 is not marked in method DMD<...>

This is a property of the TARGET method, not your patch — prefix/postfix alike fail identically. Workarounds: patch the method's caller instead (may have the same problem — callers in this engine also love try/finally), or skip Harmony and replicate/drive the logic from your own MonoBehaviour Update or LateUpdate, reading the manager's public state to stay in sync (e.g. re-run your pass when a lastUpdate-style timestamp field changes; LateUpdate runs after all Updates in the same frame, so your corrections land before the frame renders). Real example: FPV's RunPrefabLodDronePass + FPVPrefabLODHider (FPV/src/FPVPrefabLODPatch.cs), documented in [[Remote Camera World Loading (Drone FPV)]].


Patch Types

Prefix (runs before the original method)

[HarmonyPatch(typeof(TargetClass), "TargetMethod")]
public static class MyPatch
{
    // Return false to skip the original method
    static bool Prefix(TargetClass __instance)
    {
        if (ShouldSkip(__instance))
            return false; // skip original
        return true;      // run original
    }
}

Postfix (runs after the original method)

[HarmonyPatch(typeof(TargetClass), "TargetMethod")]
public static class MyPatch
{
    static void Postfix(TargetClass __instance, ref int __result)
    {
        // Modify the return value
        __result = 42;
    }
}

Finalizer (runs regardless of exceptions)

Finalizers run after the original method completes, whether it succeeded or threw an exception. Use them to suppress or transform exceptions. The __exception parameter receives the thrown exception (or null if none). Return null to suppress it, or return the exception to let it propagate.

[HarmonyPatch(typeof(TargetClass), "TargetMethod")]
public static class MyPatch
{
    static Exception Finalizer(Exception __exception)
    {
        if (__exception is NullReferenceException)
            return null; // suppress this specific exception type
        return __exception; // let all others propagate
    }
}

Key points:

  • Always filter by exception type — suppressing all exceptions blindly masks real bugs
  • Finalizers run even if a Prefix returned false (skipped the original)
  • You can inject __instance, __result, etc. just like Prefix/Postfix
  • Use sparingly — prefer fixing the root cause when possible. Finalizers are appropriate when the crash is in game code you can't modify (e.g., vanilla UI controllers that don't handle edge cases)

Prefix + Postfix with state

Pass state from prefix to postfix using __state:

static void Prefix(ref ushort power, out ushort __state)
{
    __state = power; // save original value
}

static void Postfix(ref ushort power, ushort __state)
{
    // __state has the value from Prefix
    if (power < __state) { /* power was reduced */ }
}

Dynamic Target Methods

When the target method name varies across game versions, use TargetMethod():

[HarmonyPatch]
public static class MyPatch
{
    static MethodBase TargetMethod()
    {
        // Try multiple method names
        string[] candidates = { "OnBlockPlaced", "OnBlockAdded", "PlaceBlock" };
        foreach (string name in candidates)
        {
            var mi = AccessTools.Method(typeof(BlockPowerSource), name);
            if (mi != null) return mi;
        }
        throw new MissingMethodException("Could not find target method");
    }

    static void Postfix(object[] __args) { /* ... */ }
}

Overloads: patch the workhorse, not a forwarder

When a method has several overloads, vanilla usually implements one "workhorse" overload and the rest are one-line forwarders into it. Patch the workhorse:

  • Every call path funnels through it, so one patch covers all overloads.
  • Tiny forwarders can be inlined by Mono's JIT into callers, which silently bypasses a detour placed on the forwarder. Big methods are never inlined.
  • A TargetMethod() that returns "first overload whose first param matches" is a trap — reflection order tends to hand back the forwarder.

Concrete case (3.0.x): EntityBuffs.AddBuff(string, int, bool, bool, float) is a one-liner forwarding to EntityBuffs.AddBuff(string, Vector3i, int, bool, bool, float) where all the logic lives; MinEventActionAddBuff (the effect-group action behind bleeding/infection/stun) calls the forwarder. zPhone's "No Status Change" patch on the forwarder never fired in gameplay. Fix: select the largest string-first overload in TargetMethod() (survives TFP adding parameters, always lands on the workhorse). Same caution appears in zPhone's GodMasterKey patches: one-line getters like TEFeatureLockable.IsLocked() may be inlined into callers, so back them up by also patching a bigger method on the same deny path (IsUserAllowed).


Accessing Private Fields

Via __instance + reflection

static bool Prefix(XUiC_PowerSourceWindowGroup __instance)
{
    var field = __instance.GetType().GetField("tileEntity",
        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
    var te = field?.GetValue(__instance) as TileEntityPowerSource;
}

Via Harmony's AccessTools

var field = AccessTools.Field(typeof(PowerItem), "Parent");
var parent = field?.GetValue(item) as PowerItem;

var prop = AccessTools.Property(typeof(TileEntity), "PersistentData");
var data = prop?.GetValue(te, null);

Common Patch Targets

Block interaction

TargetPurpose
BlockPowered.OnBlockActivatedIntercept E press on powered blocks
BlockPowerSource.updateStateRuns every tick for power sources (visual updates)
BlockPowerSource.OnBlockPlacedBlock placement

Power system

TargetPurpose
PowerGenerator.TickPowerGenerationFuel consumption / power generation tick
PowerSource.RefreshPowerStatsRecalculate MaxOutput, MaxPower
PowerSource.HandleSendPowerPower distribution to consumers
PowerItem.HandlePowerReceivedConsumer receiving power

UI

TargetPurpose
XUiC_PowerSourceWindowGroup.OnOpenRedirect power source UI to custom window
XUiC_WorkstationWindowGroup.OnOpenRedirect workstation UI
XUi.InitInject custom UI atlas entries after UI system initializes

TileEntity persistence

TargetPurpose
TileEntityX.read(PooledBinaryReader)Load custom data from save
TileEntityX.write(PooledBinaryWriter)Save custom data to save
TileEntityPowerSource.ReplacedByBlock destroyed — drop items

Intercepting UI Window Open

To redirect a vanilla UI to your custom window:

[HarmonyPatch(typeof(XUiC_PowerSourceWindowGroup), "OnOpen")]
public class Patch_RedirectUI
{
    static bool Prefix(XUiC_PowerSourceWindowGroup __instance)
    {
        // Check if this is your custom block
        var te = GetTileEntity(__instance);
        if (te == null || !(te is TileEntityMyBlock))
            return true; // let vanilla handle

        // Close vanilla, open custom
        var wm = __instance.xui.playerUI.windowManager;

        // Close vanilla window (use reflection for version safety)
        TryClose(wm, "powersource");

        // Open custom window group
        TryOpen(wm, "MyWindowGroup", modal: true);

        // Assign tile entity to custom controller
        var ctrl = FindController(__instance.xui, "windowMyBlock");
        if (ctrl != null)
            ctrl.TileEntity = te;

        return false; // skip vanilla OnOpen
    }
}

GUIWindowManager Reflection

GUIWindowManager.Open and Close signatures vary by game version. Use reflection:

static bool TryOpen(object wm, string key, bool modal)
{
    var method = wm.GetType().GetMethod("Open",
        BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
        null,
        new[] { typeof(string), typeof(int), typeof(int), typeof(bool), typeof(bool) },
        null);
    if (method != null)
    {
        method.Invoke(wm, new object[] { key, -1, -1, modal, false });
        return true;
    }
    return false;
}

Patching Type.GetType for Mod Controllers

The game's XUi system uses Type.GetType(name) to resolve controller classes, which only searches Assembly-CSharp. Mod assemblies aren't found. Fix with Harmony patches on Type.GetType:

[HarmonyPatch(typeof(Type), "GetType", new[] { typeof(string) })]
public static class Patch_TypeGetType
{
    static void Postfix(string __0, ref Type __result)
    {
        if (__result != null) return;

        // Strip assembly qualifier if present (e.g. "MyBlock, MyMod" -> "MyBlock")
        string plainName = __0;
        int commaIdx = __0.IndexOf(',');
        if (commaIdx >= 0)
            plainName = __0.Substring(0, commaIdx).Trim();

        foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
        {
            // Try full name first (handles namespace-qualified names)
            __result = asm.GetType(plainName);
            if (__result != null) return;

            // Fall back to short name search (handles namespaced classes)
            try
            {
                foreach (var t in asm.GetTypes())
                {
                    if (t.Name == plainName)
                    {
                        __result = t;
                        return;
                    }
                }
            }
            catch { }
        }
    }
}

Warning: Assembly.GetType() does NOT accept assembly-qualified names like "MyBlock, MyMod" — you must strip the assembly part first. Also, if your classes are in a namespace, asm.GetType("MyBlock") won't find MyNamespace.MyBlock — you need to search by short name as a fallback.

Patch all three overloads: GetType(string), GetType(string, bool), GetType(string, bool, bool).


Suppressing a Vanilla Keybind (InControl read-side patch)

When a mod hotkey collides with a vanilla binding (e.g. zPhone's Shift+Z vs SelectionSet, whose default binding is plain Z), don't try to "eat" the key in a MonoBehaviour Update — the vanilla consumer may read the key earlier in the same frame (script execution order is undefined). Instead suppress on the read side with a postfix on the InControl getter (verified working, zPhone 2026-07):

using InControl;   // csproj: reference <ManagedDir>\InControl.dll

[HarmonyPatch(typeof(OneAxisInputControl), nameof(OneAxisInputControl.IsPressed), MethodType.Getter)]
public class SelectionSetSuppressPatch
{
    static void Postfix(OneAxisInputControl __instance, ref bool __result)
    {
        if (__result && MyMod.ShouldSuppress(__instance)) __result = false;
    }
}

Facts that make this work (verified against 3.0.x Assembly-CSharp + InControl.dll, 2026-07):

  • All game actions are InControl.PlayerAction : OneAxisInputControl; IsPressed / WasPressed / WasReleased are not shadowed, so patching the base getter intercepts every read. The getters check EnabledInHierarchy at read time, so read-side falsification is consistent with InControl's own disable semantics.
  • The local player's action set lives at Platform.PlatformManager.NativePlatform.Input.PrimaryPlayer (type PlayerActionsLocal). Compare ReferenceEquals(__instance, thatSet.SomeAction) to scope the patch to one action — cheap enough for a hot getter.
  • SelectionSet (default Z) has exactly one consumer: BlockToolSelection.CheckKeys, gated by IsEditMode() || DebugMenuEnabled. Vanilla already skips it when Ctrl is held (InputUtils.ControlKeyPressed) but NOT when Shift is held. It reads IsPressed (held state, starts/updates the selection every frame), so the suppression condition must hold for the whole physical key-hold, not just the first frame.
  • Default editor-group bindings for reference: Z=SelectionSet, X=SelectionRotate, L=SelectionFill, J=SelectionClear, Backspace=SelectionDelete, Insert=SelectionMoveMode, K=Prefab, P=DetachCamera (see PlayerActionsLocal.CreateActions/SetDefaultBindings).
  • Scope the suppression to the exact physical chord (Input.GetKey(KeyCode.Z)
    • shift held + mod feature armed) so a user who rebinds the vanilla action to another key is unaffected.

Variant: swallowing the window-refocus click (zPhone FocusClickGuard, 2026-07)

Clicking an unfocused 7DTD window delivers that click to gameplay — the held item swings/fires. Same read-side patch fixes it; the differences are the trigger and the release semantics:

  • Arm in OnApplicationFocus(true) on a DontDestroyOnLoad MonoBehaviour — Unity calls it before any Update in that frame, so arming is script-order-safe. Open a short grace window (~0.3 s unscaled).
  • Latch inside the getter postfix, not in your Update: if armed and Input.GetMouseButton(0/1) is physically held, start suppressing. Your component's Update may run after PlayerMoveController.Update in the refocus frame, so an Update-side latch can miss the first read.
  • Falsify IsPressed, WasPressed and WasReleased for the local set's Primary/Secondary (compare via ReferenceEquals against PlatformManager.NativePlatform.Input.PrimaryPlayer). PlayerMoveController fires attacks off Primary.IsPressed (~line 1941) but bows/charged releases off Primary.WasReleased (~line 2011) — swallowing only the press turns the refocus click into a release-fire.
  • Keep suppressing until both mouse buttons are up plus one extra frame, so the trailing WasReleased read is falsified regardless of script order.
  • Alt-tab refocus via keyboard opens the grace window but no button is held, so it expires harmlessly; deliberate clicks after the grace window pass through.

Gotchas

[Preserve] attribute — Add to IModApi and block classes. Unity's IL stripping can remove classes only referenced via reflection/XML.

Parameter names must match exactly — Harmony matches patch method parameters to the original method by name. If the original uses _clrIdx and your patch says _cIdx, Harmony throws Parameter "_cIdx" not found. Worse: different overloads of the same method can use different names for the same parameter. For example, Block.OnBlockActivated(WorldBase, int, Vector3i, BlockValue, EntityPlayerLocal) uses _clrIdx, but Block.OnBlockActivated(string, WorldBase, int, Vector3i, BlockValue, EntityPlayerLocal) uses _cIdx for the same int. Best practice: always use positional injection (__0, __1, __2, etc.) which matches by position and is immune to naming inconsistencies. Only use named parameters when you've verified the exact name via decompilation (e.g. dnSpy).

Server vs client — Many patches need if (ConnectionManager.Instance.IsServer) or if (GameManager.IsDedicatedServer) guards. Power generation runs on server; visual effects run on client.

SendHasLocalChangesToRoot() — Call this after modifying any PowerItem property to propagate changes through the power network.

SetModified() — Call on TileEntity after any state change to ensure persistence and client sync.

Packed settings in existing TileEntity fields need a zero/default plan — If you reuse spare bits in a vanilla persisted field such as a trap's TargetType, remember that freshly placed and existing blocks will have 0 in your custom bit range. If 0 is also a real user-selectable value, encode stored levels as level + 1 and treat encoded 0 as "use default." Always call SetModified() after changing the packed value.

Private runtime tuning fields can be patched per instance with AccessTools.Field — Some XML properties are copied into private controller fields during Init() and changing XML/properties later will not affect an already-spawned block. Example: SpotlightController, AutoTurretYawLerp, and AutoTurretPitchLerp each keep a private degreesPerSecond field. Cache FieldInfo with AccessTools.Field(...) and set it during the update patch when a per-TileEntity UI setting must affect live behavior.

Trap self-targeting may require both owner idsTileEntityPoweredRangedTrap.IsOwner(primaryId) checks the platform owner id, but player self-targeting can also depend on OwnerEntityID. When creating a custom ranged-trap-like block, set both SetOwner(...) and OwnerEntityID = player.entityId on placement, and backfill OwnerEntityID when an existing block is opened if needed.

Multi-instance target reservations need stale-pruning and periodic re-evaluation — If several patched traps coordinate targets through a static trap -> target cache, prune reservations for null/dead targets and unpowered traps before treating a target as claimed. If a trap's existing reservation becomes invalid or is claimed by another trap, bypass any search throttle and find a replacement immediately; otherwise the trap may briefly act like no targets exist even when plenty are in range. For "closest target unless already claimed" behavior, keep the current target only between search ticks; when the search timer expires, rebuild valid candidates sorted by distance, choose the closest unclaimed candidate, and only share a claimed candidate if every valid candidate is already claimed.

Multiple postfixes on the same method — Harmony patches from different mods stack. Your postfix on BlockPowerSource.updateState will run alongside other mods' patches. Don't assume you're the only one patching.

Never close/reopen window groups from inside OnOpen — Calling wm.Close("groupA") then wm.Open("groupB") from within a Prefix on XUiC_*WindowGroup.OnOpen causes NullReferenceExceptions. Closing the group mid-initialization disrupts the tile entity handoff (e.g., player.lootContainer gets cleared). Instead, redirect at the GUIWindowManager.Open level by patching GUIWindowManager.Open and changing the window group name string before the window system starts initializing. Use a static flag set in a block activation patch to know when to redirect:

// 1. Track activation in BlockSecureLoot.OnBlockActivated Prefix
static void Prefix(BlockValue _blockValue) {
    RedirectPatch.IsMyBlockOpening = _blockValue.Block?.GetBlockName() == "myBlock";
}

// 2. Redirect in GUIWindowManager.Open Prefix
static void Prefix(ref string __0) {
    if (__0 == "looting" && RedirectPatch.IsMyBlockOpening) {
        RedirectPatch.IsMyBlockOpening = false;
        __0 = "myCustomGroup";
    }
}