Harmony Patching
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
| Target | Purpose |
|---|---|
BlockPowered.OnBlockActivated | Intercept E press on powered blocks |
BlockPowerSource.updateState | Runs every tick for power sources (visual updates) |
BlockPowerSource.OnBlockPlaced | Block placement |
Power system
| Target | Purpose |
|---|---|
PowerGenerator.TickPowerGeneration | Fuel consumption / power generation tick |
PowerSource.RefreshPowerStats | Recalculate MaxOutput, MaxPower |
PowerSource.HandleSendPower | Power distribution to consumers |
PowerItem.HandlePowerReceived | Consumer receiving power |
UI
| Target | Purpose |
|---|---|
XUiC_PowerSourceWindowGroup.OnOpen | Redirect power source UI to custom window |
XUiC_WorkstationWindowGroup.OnOpen | Redirect workstation UI |
XUi.Init | Inject custom UI atlas entries after UI system initializes |
TileEntity persistence
| Target | Purpose |
|---|---|
TileEntityX.read(PooledBinaryReader) | Load custom data from save |
TileEntityX.write(PooledBinaryWriter) | Save custom data to save |
TileEntityPowerSource.ReplacedBy | Block 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 findMyNamespace.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/WasReleasedare not shadowed, so patching the base getter intercepts every read. The getters checkEnabledInHierarchyat 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(typePlayerActionsLocal). CompareReferenceEquals(__instance, thatSet.SomeAction)to scope the patch to one action — cheap enough for a hot getter. SelectionSet(defaultZ) has exactly one consumer:BlockToolSelection.CheckKeys, gated byIsEditMode() || DebugMenuEnabled. Vanilla already skips it when Ctrl is held (InputUtils.ControlKeyPressed) but NOT when Shift is held. It readsIsPressed(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 aDontDestroyOnLoadMonoBehaviour — Unity calls it before anyUpdatein 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 andInput.GetMouseButton(0/1)is physically held, start suppressing. Your component'sUpdatemay run afterPlayerMoveController.Updatein the refocus frame, so an Update-side latch can miss the first read. - Falsify
IsPressed,WasPressedandWasReleasedfor the local set'sPrimary/Secondary(compare viaReferenceEqualsagainstPlatformManager.NativePlatform.Input.PrimaryPlayer).PlayerMoveControllerfires attacks offPrimary.IsPressed(~line 1941) but bows/charged releases offPrimary.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
WasReleasedread 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 toIModApiand 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
_clrIdxand your patch says_cIdx, Harmony throwsParameter "_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, butBlock.OnBlockActivated(string, WorldBase, int, Vector3i, BlockValue, EntityPlayerLocal)uses_cIdxfor 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)orif (GameManager.IsDedicatedServer)guards. Power generation runs on server; visual effects run on client.
SendHasLocalChangesToRoot()— Call this after modifying anyPowerItemproperty 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 have0in your custom bit range. If0is also a real user-selectable value, encode stored levels aslevel + 1and treat encoded0as "use default." Always callSetModified()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 duringInit()and changing XML/properties later will not affect an already-spawned block. Example:SpotlightController,AutoTurretYawLerp, andAutoTurretPitchLerpeach keep a privatedegreesPerSecondfield. CacheFieldInfowithAccessTools.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 ids —
TileEntityPoweredRangedTrap.IsOwner(primaryId)checks the platform owner id, but player self-targeting can also depend onOwnerEntityID. When creating a custom ranged-trap-like block, set bothSetOwner(...)andOwnerEntityID = player.entityIdon placement, and backfillOwnerEntityIDwhen 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 -> targetcache, 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.updateStatewill 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")thenwm.Open("groupB")from within aPrefixonXUiC_*WindowGroup.OnOpencauses NullReferenceExceptions. Closing the group mid-initialization disrupts the tile entity handoff (e.g.,player.lootContainergets cleared). Instead, redirect at theGUIWindowManager.Openlevel by patchingGUIWindowManager.Openand 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"; } }