Skip to content

Stale mod reference JIT crash (caller-frame attribution)

A mod compiled against an old game version can carry a member reference (MemberRef) to a method whose signature no longer exists. Mono resolves member refs lazily, at JIT time of the method containing them, so the mod loads fine and the world loads fine; the crash comes later, when something first CALLS the stale method, and the resulting stack trace points at VANILLA code. This page is the diagnosis and mitigation recipe, with the ForceFieldDoorMod case as the worked example. A stale FIELD reference (a field that is gone, or that became a property) fails the same way with MissingFieldException, and the whole containing method fails, not only the stale instruction; see "Whole-method failure, measured" below. A try/catch in the calling method catches the failure, and one inside the failing method never fires; see "Catching the failure".

The deceptive failure shape

Observed on the dedicated server, repeating every simulation tick of the loaded save Luna_rearch (3,363+ occurrences in one server.log):

MissingMethodException: Method not found: bool Assets.Scripts.GridController.CanContainAtmos(Assets.Scripts.GridSystem.WorldGrid)
  at Assets.Scripts.Atmospherics.AtmosphericsManager+<>c.<.cctor>b__118_1 (Assets.Scripts.Objects.Thing thing) [0x0000c] in <34ec4ca08b584312830de78906df3608>:0 
  at Assets.Scripts.Util.DensePool`1[T].ForEach (System.Action`1[T] action) [0x00027] in <34ec4ca08b584312830de78906df3608>:0 
  at Assets.Scripts.GameManager.GameTick (System.Threading.CancellationToken cancellationToken) [0x0030c] in <34ec4ca08b584312830de78906df3608>:0 

Every frame is vanilla, every frame carries the game module's MVID (34ec4ca0... is the running Assembly-CSharp; the same MVID appears on unrelated healthy frames in the same log), and the named lambda's own IL is clean. The IL at the failing offset of b__118_1 is:

IL_000c: ldarg.1
IL_000d: callvirt instance void Assets.Scripts.Objects.Thing::OnAtmosphericTick()

The exception is thrown while JIT-compiling the virtual-dispatch TARGET of that callvirt: a mod subclass's OnAtmosphericTick() override whose body contains the stale ref. The callee never gets a stack frame because it never finished compiling; Mono attributes the throw to the caller's frame at the callvirt offset. Nothing in the trace names the mod.

Two corollaries:

  • A fresh world does not reproduce the crash even with the mod loaded: JIT is lazy, so the broken override only compiles when a Thing of that mod type is first dispatched. Only saves containing the mod's Things break (Luna_rearch has 15 StructureForceFieldDoor).
  • The repeat is per-call-attempt: Mono re-attempts the JIT on every dispatch, and GameTick's per-tick try/catch (see SimulationTickDriverHooks) logs the exception and moves on, so the whole simulation section (everything in the try, ElectricityManager.ElectricityTick included) is aborted EVERY tick while the tick loop itself keeps running. The world looks alive (tick count rises, autosaves fire) but no simulation phase completes.

Diagnosis recipe

  1. IL-dump the module of the innermost stack frame (ilspycmd -il <dll> -o <dir> on the on-disk assembly whose MVID matches) and read the caller's IL at the reported offset. If it is a callvirt to a virtual method, the broken code is an OVERRIDE of that method on the concrete type being visited, not the method in the trace.
  2. Find who defines such an override with the missing member: metadata strings are plain ASCII inside the DLL, so a byte-level grep for the missing member name across candidate DLLs works without decompiling anything. On 2026-07-02, scanning install/rocketstation_DedicatedServer_Data/Managed/*.dll, install/BepInEx/**/*.dll, and data/mods/**/*.dll for CanContainAtmos returned exactly two files: the game assembly (defines it) and data/mods/Workshop_3328065049/ForceFieldDoorMod.dll (references it).
  3. IL-dump the suspect mod and compare its exact ref signature against the current game IL. The worked example: ForceFieldDoorMod's forcefielddoormod.ForceFieldDoor : Airlock override OnAtmosphericTick() contains, twice, callvirt instance bool ['Assembly-CSharp']Assets.Scripts.GridController::CanContainAtmos(valuetype ['Assembly-CSharp']Assets.Scripts.GridSystem.WorldGrid) while the 0.2.6403.27689 game defines only CanContainAtmos(WorldGrid, bool) and CanContainAtmos(Grid3, bool) (both with allowCrewModules = true default; default arguments are compile-time, so old binaries keep the 1-arg call). Details of the API change: Api-removals-0.2.6403.
  4. Before fixing one member, sweep the mod's other externally-referenced members in code paths that will run (other overrides), or the fix just moves the crash. In the worked example every other ['Assembly-CSharp'] ref in OnAtmosphericTick, get_CanAirPass, PoweredChanged, and OnInteractableUpdated resolved against the current game IL (including the private AtmosphereHelper::_random field: Unity's Mono does not enforce member visibility at JIT, which is also why publicized-assembly mods run at all).

Mitigation calculus

  • Removing the mod also removes its Things from the loaded save (unregistered save data), changing world content; wrong when the save matters.
  • A Harmony patch ON the broken method was not attempted and is expected to fail: HarmonyX builds its replacement by copying the original body through MonoMod's DynamicMethodDefinition, which resolves every operand token; the stale MemberRef should throw the same MissingMethodException at patch time (see Open questions). The same reasoning applies to MonoMod detours and RuntimeHelpers.PrepareMethod, both of which force compilation of the broken body.
  • A caller-side Harmony filter (prefix on the clean vanilla dispatch site skipping the mod's Things) works but must cover every dispatch site of the virtual and silently disables the mod objects' behavior.
  • The chosen fix: rewrite the stale call sites in the mod DLL with Mono.Cecil (ships with BepInEx at BepInEx/core/Mono.Cecil.dll). For a parameter-added signature change this is two instructions per site: insert ldc.i4.1 (the new parameter's default value) before the call and retarget the operand to the current overload. Cecil branch targets are Instruction object references, so inserting before a non-branch-target instruction needs no fixups, and AssemblyDefinition.Write needs a DefaultAssemblyResolver with the game's Managed directory on its search path (without it, Write fails with Failed to resolve assembly: 'Assembly-CSharp, Version=<mod's compile-time version>'). Verified result IL:
IL_002c: ldc.i4.1
IL_002d: callvirt instance bool ['Assembly-CSharp']Assets.Scripts.GridController::CanContainAtmos(valuetype ['Assembly-CSharp']Assets.Scripts.GridSystem.WorldGrid, bool)

After swapping the rewritten DLL into data/mods/Workshop_3328065049/ (server stopped first; the running server holds the file), the same Luna_rearch boot produced zero MissingMethodException over sustained ticking and the full simulation section ran (ScenarioRunner scenario fired). The rewrite is to the dedicated server's LOCAL mod copy only; the client's Workshop copy stays broken, and a -SyncMods re-mirror clobbers the fix. Original DLL preserved next to the fix note in the session stash.

Whole-method failure, measured: a stale field and a direct call

A 2026-09-21 reproduction at game 0.2.6428.27798 extends the shape above in three ways. It holds for a stale FIELD reference (MissingFieldException), not only a method. It holds for a plain call, not only a callvirt. And it settles how much fails: the whole method. Mono refuses to compile the entire method that contains the stale reference, on its first call and on every call after, whether or not that call would ever execute the stale instruction.

The case. Spray Paint Plus v1.10.1 as published (SprayPaintPlus.dll sha256 87d49be86da90f04269898286d004921cea77b02a38e094fd4967978f5c24ec3) was compiled against a game in which Networks.StructureNetwork.StructureList was a field. In 0.2.6428.27798 it is a get-only property (decompile line 194248) over the base class's member list (193250-193252); when that changed, and what it broke, is on RoboticArmRail and HarmonyFieldOrProperty:

public List<INetworkedStructure> StructureList => Members;
public abstract class ReferencableNetwork<TMember>(long referenceId = 0L) : ReferencableNetwork(referenceId) where TMember : class, INetworkMember
{
    public readonly List<TMember> Members = new List<TMember>();

The mod's Harmony prefix on OnServer.SetCustomColor, SprayPaintPlus.NetworkPainterPatch.Prefix (103 bytes of IL), calls a private helper, NetworkPainterPatch.PaintNetwork (1,044 bytes of IL). In ilspycmd -il of the published DLL, the statement in Prefix that makes the call (source line 107, PaintNetwork(thing, colorIndex, ctrlHeld);) is:

IL_0055: ldarg.0
IL_0056: ldarg.1
IL_0057: ldloc.2
IL_0058: call void SprayPaintPlus.NetworkPainterPatch::PaintNetwork(class ['Assembly-CSharp']Assets.Scripts.Objects.Thing, int32, bool)

All eight references to StructureList in the DLL are inside PaintNetwork, two on each of its HydroponicTray, PassiveVent, Pipe and Chute branches (IL_0029, IL_0037, IL_00af, IL_00bd, IL_0138, IL_0146, IL_0257, IL_0265), each of them:

ldfld class [mscorlib]System.Collections.Generic.List`1<class ['Assembly-CSharp']Networks.INetworkedStructure> ['Assembly-CSharp']Networks.StructureNetwork::StructureList

Prefix contains none. The Cable branch of PaintNetwork reads CableNetwork::CableList (IL_01d3, IL_01e1), which is still a field in 0.2.6428.27798 (public readonly List<Cable> CableList, 276242), and the other game fields it reads also still resolve (RoboticArmNetwork.RailList, 176722; ElevatorShaft.ShaftNetwork, 402083).

The discriminating observation. A spray-can stroke on a cable threw exactly what a stroke on a pipe threw, although for a Cable every type test ahead of the cable branch fails and no stale ldfld would execute. Cable, then pipe (Unity log, verbatim apart from the developer path):

MissingFieldException: Field not found: System.Collections.Generic.List`1<Networks.INetworkedStructure> Networks.StructureNetwork.StructureList Due to: Could not find field in class
  at SprayPaintPlus.NetworkPainterPatch.Prefix (Assets.Scripts.Objects.Thing thing, System.Int32 colorIndex) [0x00055] in <REPO_CLONE_PATH>\Mods\SprayPaintPlus\SprayPaintPlus\NetworkPainterPatch.cs:107
  at (wrapper dynamic-method) OnServer.DMD<OnServer::SetCustomColor>(Assets.Scripts.Objects.Thing,int)
  at Assets.Scripts.Objects.Items.ISprayer.DoSpray (Assets.Scripts.Objects.Thing thing, Assets.Scripts.Objects.Items.ISprayer sprayer, System.Boolean doAction) [0x001c9] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at (wrapper dynamic-method) Assets.Scripts.Objects.Thing.DMD<Assets.Scripts.Objects.Thing::AttackWith>(Assets.Scripts.Objects.Thing,Assets.Scripts.Objects.Attack,bool)
  at Assets.Scripts.Objects.Structure.AttackWith (Assets.Scripts.Objects.Attack attack, System.Boolean doAction) [0x00a10] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at Assets.Scripts.Objects.Electrical.Cable.AttackWith (Assets.Scripts.Objects.Attack attack, System.Boolean doAction) [0x0009c] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at (wrapper dynamic-method) OnServer.DMD<OnServer::AttackWith>(Assets.Scripts.Objects.Thing,byte,byte,long,UnityEngine.Vector3,single,bool,bool)
  at (wrapper dynamic-method) Assets.Scripts.Inventory.InventoryManager.DMD<Assets.Scripts.Inventory.InventoryManager::UseItemComplete>(Assets.Scripts.Inventory.InventoryManager)
  at Assets.Scripts.Inventory.InventoryManager+<WaitUntilDone>d__236.MoveNext () [0x002ab] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at UnityEngine.SetupCoroutine.InvokeMoveNext (System.Collections.IEnumerator enumerator, System.IntPtr returnValueAddress) [0x00026] in <c39a522eee05469b8171a6cfeb646c59>:0
MissingFieldException: Field not found: System.Collections.Generic.List`1<Networks.INetworkedStructure> Networks.StructureNetwork.StructureList Due to: Could not find field in class
  at SprayPaintPlus.NetworkPainterPatch.Prefix (Assets.Scripts.Objects.Thing thing, System.Int32 colorIndex) [0x00055] in <REPO_CLONE_PATH>\Mods\SprayPaintPlus\SprayPaintPlus\NetworkPainterPatch.cs:107
  at (wrapper dynamic-method) OnServer.DMD<OnServer::SetCustomColor>(Assets.Scripts.Objects.Thing,int)
  at Assets.Scripts.Objects.Items.ISprayer.DoSpray (Assets.Scripts.Objects.Thing thing, Assets.Scripts.Objects.Items.ISprayer sprayer, System.Boolean doAction) [0x001c9] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at (wrapper dynamic-method) Assets.Scripts.Objects.Thing.DMD<Assets.Scripts.Objects.Thing::AttackWith>(Assets.Scripts.Objects.Thing,Assets.Scripts.Objects.Attack,bool)
  at Assets.Scripts.Objects.Structure.AttackWith (Assets.Scripts.Objects.Attack attack, System.Boolean doAction) [0x00a10] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at (wrapper dynamic-method) OnServer.DMD<OnServer::AttackWith>(Assets.Scripts.Objects.Thing,byte,byte,long,UnityEngine.Vector3,single,bool,bool)
  at (wrapper dynamic-method) Assets.Scripts.Inventory.InventoryManager.DMD<Assets.Scripts.Inventory.InventoryManager::UseItemComplete>(Assets.Scripts.Inventory.InventoryManager)
  at Assets.Scripts.Inventory.InventoryManager+<WaitUntilDone>d__236.MoveNext () [0x002ab] in <a5819ca3c3f4425ba9eae35fe9c21567>:0
  at UnityEngine.SetupCoroutine.InvokeMoveNext (System.Collections.IEnumerator enumerator, System.IntPtr returnValueAddress) [0x00026] in <c39a522eee05469b8171a6cfeb646c59>:0

Had Mono failed only the instruction, the cable stroke would have run the cable branch and returned normally. It threw, so the failure is the whole method.

Caller-frame attribution through a direct call. PaintNetwork never appears in the trace. The top frame is Prefix at [0x00055], the first instruction of the statement that calls PaintNetwork (IL_0055 up to the call at IL_0058), on source line 107, the call's line. That matches the ForceFieldDoorMod case above, where the reported offset (IL_000c) is also the first instruction of the call statement rather than the callvirt itself (IL_000d). At 1,044 bytes of IL, PaintNetwork is far larger than anything Mono inlines, so the attribution is not an inlining artefact: the callee never finished compiling.

Every call fails. Seven of seven calls threw, across two processes: five through the input path (a spray-can hold on a cable and one on a pipe, both through InventoryManager.UseItemComplete; three portable-kit builds through InventoryManager.UseSecondaryComplete, see DynamicThingConstructor), and two direct OnServer.AttackWith calls from the test rig (POST /player/use on a pipe and on a cable), which both returned:

AttackWith failed: Field not found: System.Collections.Generic.List`1<Networks.INetworkedStructure> Networks.StructureNetwork.StructureList Due to: Could not find field in class

A second mod in the same process shows the same attribution. DataNetworkConnector (Workshop item 3547208483, DataNetworkConnector.dll sha256 a098d980951334c94e4cd6ecf19f1eceefd62fc7ec2048ddd05de632ba0d1bc2) fails at prefab setup on every boot of this game version. Its semoro.DataNetworkConnector.PrefabBlueprintUtil.GenerateWireframe (171 bytes of IL) contains

IL_008b: stfld bool ['Assembly-CSharp']Assets.Scripts.UI.Wireframe::ShowTransformArrow

and Assets.Scripts.UI.Wireframe (decompile 261202) has no member of that name in 0.2.6428.27798. The log names the caller, Blueprintify, at [0x0008c] (verbatim apart from the author's build path):

MissingFieldException: Field not found: bool Assets.Scripts.UI.Wireframe.ShowTransformArrow Due to: Could not find field in class
  at semoro.DataNetworkConnector.PrefabBlueprintUtil.Blueprintify (Assets.Scripts.Objects.Thing thing) [0x0008c] in <author's build path>\PrefabBlueprintUtil.cs:39
  at LaunchPadBooster.PrefabSetup`1[T].LaunchPadBooster.IPrefabSetup.Run (System.Collections.Generic.IEnumerable`1[T] things) [0x00061] in <89848b719d434a6594124b960546583f>:0

Blueprintify has no reference to ShowTransformArrow. IL_008c is the first instruction of its statement that calls GenerateWireframe:

IL_008c: ldloc.0
IL_008d: ldarg.0
IL_008e: callvirt instance class [UnityEngine.CoreModule]UnityEngine.Transform [UnityEngine.CoreModule]UnityEngine.Component::get_transform()
IL_0093: call void semoro.DataNetworkConnector.PrefabBlueprintUtil::GenerateWireframe(class [UnityEngine.CoreModule]UnityEngine.GameObject, class [UnityEngine.CoreModule]UnityEngine.Transform)

This case does not separate whole-method from instruction-level failure (on that path the Wireframe was just added and has no edges, so GenerateWireframe would have reached the stale store anyway), but it confirms caller-frame attribution in a second, unrelated mod.

Reading such a trace. The method in the top frame is not the broken one. The exception message names the missing member, and the frame's offset (with the source line, when the module ships symbols) names the statement that made the failing call. Among the methods that statement calls, the broken one is the one whose body references the named member, possibly on a branch the failing call would never have taken. For a callvirt, check the overrides on the runtime type as well (step 1 of the recipe above).

Which try/catch catches such a failure is measured in the next section.

Catching the failure: which try/catch fires

A controlled probe on 2026-09-21 measured what code can catch when a call fails this way. It ran on the TestRig dedicated server at game 0.2.6428.27798: Unity 2022.3.62f3 (96770f904ca7), Mono 6.13.0 (MonoBleedingEdge/EmbedRuntime/mono-2.0-bdwgc.dll), BepInEx 5.4.23.5, HarmonyX 2.9.0.0, with MONO_INLINELIMIT, MONO_ENV_OPTIONS and MONO_DEBUG unset. A throwaway plugin (ProbeMod, net472) was compiled against a library ProbeLib v1 and run against v2. Both versions carry the identity ProbeLib, Version=1.0.0.0, and the plugin confirmed at startup that v2 was the one loaded. v1:

public class Holder
{
    public static int StaticField = 11;

    public static int StaticMethod() { return 22; }

    public int InstanceField = 33;
}

v2:

public class Holder
{
    // v1: public static field. v2: get-only static property (no backing initializer, so
    // Holder has no static constructor in v2).
    public static int StaticField { get { return 11; } }

    // v1: public static int StaticMethod(). v2: removed.

    // v1: public instance field. v2: get-only instance property, the same shape change
    // the game made to Networks.StructureNetwork.StructureList.
    public int InstanceField { get { return 33; } }
}

Every case gave the same result with all cases in reverse order in one process, all in forward order in another, and each case alone in its own process: 19 processes, and 16 of 16 comparable cases identical (the seventeenth case is a timing control).

Case Shape Result
1 caller try/catch around a NoInlining callee that reads the stale static field the caller's catch fired (MissingFieldException); the top frame is the caller, the callee never appears
2 the same, the callee calls the removed method the caller's catch fired (MissingMethodException)
3 the callee wraps the stale read in its own try/catch, and the caller has one too only the caller's catch fired; the catch inside the failing method never did
8 caller catch around a NoInlining callee that reads the stale instance field the caller's catch fired (MissingFieldException)
5, 5b a 6-byte inlining candidate without NoInlining (ldsfld + ret; call + ret) the immediate caller's catch fired; the failure did not move up to the caller's caller
5g a 6-byte candidate marked AggressiveInlining the immediate caller's catch fired
6a a Harmony prefix whose body is try { Core(); } catch, with the stale read in a NoInlining Core the prefix's own catch fired, and the patched method's original body still ran and returned its unpatched value
6b a Harmony prefix with the stale read in its own body Harmony.Patch did not throw; every later call to the patched method threw out through the DMD<...> wrapper, and the original body never ran
7a a class with a static field initialiser and no static constructor (beforefieldinit), touched directly inside a method's try that method's catch did NOT fire: TypeInitializationException (inner MissingFieldException) escaped to the next frame up
7b the same kind of class, reached through a NoInlining accessor the caller's catch fired
7c a class with an explicit static constructor, touched directly inside a method's try that method's own catch fired; top frame System.Object.__icall_wrapper_mono_generic_class_init

The central pair, case 3 verbatim (R.Caught and R.Event are the probe's logging helpers):

internal static class Case3
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static int InnerWithOwnCatch()
    {
        try
        {
            return ProbeLib.Holder.StaticField;
        }
        catch (Exception e)
        {
            R.Caught("INNER catch in Case3.InnerWithOwnCatch, the same method as the stale read", e);
            return -1;
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    internal static void Run()
    {
        try
        {
            int v = InnerWithOwnCatch();
            R.Event("outer try completed normally, InnerWithOwnCatch returned " + v);
        }
        catch (Exception e)
        {
            R.Caught("OUTER catch in Case3.Run, the caller", e);
        }
    }
}

The static-initialiser classes of cases 7a, 7b and 7c:

internal static class Init7a
{
    internal static readonly int V = ProbeLib.Holder.StaticField;
}

// 7b: the same shape, reached only through a NoInlining accessor.
internal static class Init7b
{
    internal static readonly int V = ProbeLib.Holder.StaticField;
}

// 7c: an explicit static constructor, so the class is NOT beforefieldinit.
internal static class Init7c
{
    internal static readonly int V;

    static Init7c()
    {
        V = ProbeLib.Holder.StaticField;
    }
}

Other measured details:

  • Each call fails anew and is caught anew: 6 of 6 single calls, each with a new exception object, then 1000 of 1000 in each of three timed passes. At steady state a failing call cost 5.0 to 5.9 microseconds, against 1.9 to 2.9 microseconds for an ordinary throw and catch of a new MissingFieldException and 1.1 to 1.3 nanoseconds for a valid NoInlining call. The first failing call in a process cost 54 to 164 microseconds.
  • In all three static-initialiser variants, a second touch rethrew the same TypeInitializationException object, and its inner MissingFieldException had no stack trace (StackTrace null).
  • In case 7a the top frame was the escaping method's call site in its caller (Case7.DriveA () [0x00005]), the same attribution a method that fails to compile gets.
  • Inlining controls: a valid method of the exact ldsfld + ret shape is inlined on this runtime (a loop over it ran at 0.23 to 0.49 ns per iteration, against 1.09 to 1.54 ns for its NoInlining twin), and AggressiveInlining is honoured (a caller-name probe saw the inlined method's caller). A valid call + ret method calling a NoInlining helper was not inlined. So the stale candidates in cases 5 and 5g were caught in their immediate caller although a valid method of the same shape would have been inlined into it.

The same shape in the shipped game, measured 2026-09-22. The probe's cases are synthetic; vanilla supplies one too. Structure.StructureDestroyed wraps its kit refund in a try/catch (see Structure, "Refund failures in 0.2.6428.27798"), and that refund reaches OnServer.SetCustomColor through ToolUse.SpawnItem, so a wrench deconstruct of a painted structure under the same v1.10.1 build drives the broken prefix from inside a vanilla try. The catch fired. Two things follow, both useful when reading a report:

  • The same broken build looks like two different faults on two paths. A spray stroke has nothing between the throw and the caller, so the rig's POST /player/use came back AttackWith failed: Field not found: ... (above). The deconstruct call returned normally and the only trace was a red console line, because vanilla caught it several frames up, across the Harmony DMD<OnServer::SetCustomColor> wrapper. Once the failure has become an ordinary thrown exception at the attributed frame, it propagates like any other and the nearest enclosing try takes it, wherever that is.
  • The console trace beside such a line names the catch site, not the mod. ConsoleWindow.PrintError prints its own Environment.StackTrace unless the caller passes suppressStacktrace: true (see ConsoleWindow and InGameConsoleOutput), so the grey line under Pipe (Corner) #66825 failed to return its parts on deconstruct: ... began at System.Environment.get_StackTrace, then ConsoleWindow.PrintError, then Structure.StructureDestroyed, then Structure.AttackWith. Nothing named the mod, SpawnItem, the DMD wrapper or the prefix. Only ex.Message carried the missing member, Networks.StructureNetwork.StructureList, which is the one part of such a line worth acting on.

What this means for a mod's own code:

  • To survive a member going stale, put the try/catch in the method that CALLS the code holding the reference, and keep that code in a separate method (NoInlining makes the boundary explicit). A catch in the same method as the stale reference can never fire, because that method never runs.
  • A Harmony prefix guarded that way fails harmlessly: its own catch fires and the patched method's original body still runs (case 6a). A patch method with the stale reference in its own body fails every call of the patched method and suppresses the original (case 6b). Spray Paint Plus v1.10.1 above had the case 6a shape without the catch: its Prefix calls PaintNetwork inside a try/finally, so the exception left the prefix and the patched method, and the original OnServer.SetCustomColor never ran, the same outcome as case 6b.
  • A beforefieldinit class whose static field initialisers read a possibly stale member escapes the catch of the method that touches it (case 7a). Reach such a class through a separate accessor (case 7b), or give it an explicit static constructor (case 7c).

The probe ran on the dedicated server only. The client install's mono-2.0-bdwgc.dll is byte-identical to the server's outside the Authenticode signature, its data-directory entry and the PE checksum (compared 2026-09-21), so both run the same runtime code; the catch behaviour itself was measured on the server.

Detecting it before players do

A stale reference is visible without launching the game: every type and member reference in a mod DLL either resolves against the installed game's assemblies or it does not. testrig check-bindings <Mod>, or testrig check-bindings --dll <path> for any built copy (such as the one Steam keeps in its Workshop content folder), resolves them all against rocketstation_Data/Managed, BepInEx core and BepInEx plugins, and names each one that does not resolve together with the method whose body uses it (TestRig/MANUAL.md, "Static checks"). Run it first after every game update, and before every release.

Measured on 0.2.6428.27798:

  • The published Spray Paint Plus v1.10.1 (sha256 87d49be8...) reports exactly one unresolved reference: Networks.StructureNetwork::StructureList, "field not found; a get-only property of that name exists", used at the eight ldfld sites of NetworkPainterPatch::PaintNetwork. That is the outage in "Whole-method failure, measured", found statically. The v1.12.0 build (sha256 15148db8...) reports none.
  • Published Workshop mods with references that no longer resolve: KeypadMod (3478434324) and FilterCleanerMod (3452651592) call UniTask's Delay(int, bool, PlayerLoopTiming, CancellationToken), which the bundled UniTask no longer has (every remaining Delay overload takes a fifth bool); FilterCleanerMod reports eight unresolved references in all. DataNetworkConnector (3547208483) writes Assets.Scripts.UI.Wireframe::ShowTransformArrow, which neither Wireframe nor its base types has any more: the stale stfld described above.

What it cannot see, because nothing in a DLL's reference tables records it: Harmony targets named by string, reflection by member name, a game interface or abstract class that gained a member a mod type must implement, an override whose base method's signature changed, a field that switched between static and instance, and the dedicated server's separately compiled Assembly-CSharp.

Verification history

  • 2026-07-02: page created during the Luna_rearch dead-sim investigation at game 0.2.6403.27689. Exception block quoted verbatim from TestRig/DedicatedServer/data/server.log; caller IL and mod IL quoted from same-day ilspycmd -il dumps; the two-file binary-grep result, the 15-door save census (world.xml PrefabName scan), and the post-fix clean boot are from the same session.
  • 2026-09-21: added "Whole-method failure, measured: a stale field and a direct call" at game 0.2.6428.27798, from a TestRig reproduction on a listen host with Spray Paint Plus v1.10.1 as published: ilspycmd -il of that DLL and of DataNetworkConnector.dll (Workshop 3547208483), the Unity logs of two sessions (seven of seven calls threw), the rig's two POST /player/use responses, and the decompile lines in the frontmatter. The cable-stroke observation answers the granularity question that was open on OnServer: the whole method fails. Added one sentence to the intro pointing at the new section. Additive: the 2026-07-02 sections are unchanged and keep their 0.2.6403.27689 stamps; the frontmatter moved to 0.2.6428.27798 for the new section.
  • 2026-09-21: added "Catching the failure: which try/catch fires" from a throwaway probe plugin on the TestRig dedicated server at game 0.2.6428.27798 (Unity 2022.3.62f3, Mono 6.13.0, BepInEx 5.4.23.5, HarmonyX 2.9.0.0): seventeen cases, identical across a reverse-order run, a forward-order run and seventeen single-case processes (16 of 16 comparable cases; one timing control). Measured: a caller's catch catches a NoInlining callee's stale field read, removed-method call and instance field read, while a catch inside the failing method never fires; every call fails anew (steady-state cost 5.0 to 5.9 microseconds per failing call); stale 6-byte inlining candidates, AggressiveInlining included, are caught in the immediate caller; a Harmony prefix guarding a NoInlining core catches it and lets the original run, while a prefix holding the stale read itself does not fail Patch() but fails every call and suppresses the original; a beforefieldinit initialiser touched directly escapes the touching method's catch as TypeInitializationException, while an accessor or an explicit static constructor keeps it catchable. The client and server mono-2.0-bdwgc.dll were compared and match outside the signature. Mechanisms read from Mono's source rather than measured went to Open questions. Additive; the Open question on patching a method whose own body holds the stale reference stays open, as the probe did not cover it.
  • 2026-09-23: added "The same shape in the shipped game, measured 2026-09-22" to "Catching the failure: which try/catch fires", from a listen-host run at game 0.2.6428.27798 with the same published v1.10.1 DLL. A wrench deconstruct of a painted structure reaches the broken prefix from inside Structure.StructureDestroyed's own try, and that catch fired, so the same build fails loudly through the caller on a spray stroke and quietly as a red console line on a deconstruct. Also recorded the reading trap: ConsoleWindow.PrintError prints its own Environment.StackTrace, so the grey line beside such an error names the catch site (StructureDestroyed, AttackWith) and never the mod, the DMD wrapper or the prefix, leaving ex.Message as the only part that identifies the missing member. Additive; the probe results are unchanged and the section was restamped to 2026-09-23 for the new content. Bumped frontmatter verified_at; added a sources line and three related links. Full measurement record: Structure, "Measured: what the caught refund failure costs, and what it does not".
  • 2026-09-21: added "Detecting it before players do" at game 0.2.6428.27798. Each result was re-run by hand with testrig check-bindings --dll against the published v1.10.1 DLL and the Workshop copies of KeypadMod, FilterCleanerMod and DataNetworkConnector; the v1.12.0 result is from testrig check-bindings SprayPaintPlus on the release build.

Open questions

  • Whether HarmonyX/MonoMod actually throws when patching a method whose body contains an unresolvable MemberRef (asserted here from MonoMod token-resolution behavior, not tested; testing it costs a boot with a throwaway patch). If someone verifies it either way, restamp the Mitigation calculus section. The 2026-09-21 probe measured stale references in patch methods, not in the patched original, so it does not settle this.
  • Why a stale 6-byte inlining candidate (probe cases 5, 5b, 5g) is caught in its immediate caller although a valid method of that shape is inlined: attributed by the probe's author to Mono's inliner falling back to an ordinary call when the candidate cannot be compiled. Read from Mono's source, not measured; the outcome itself is measured.
  • Why a beforefieldinit initialiser touched directly (probe case 7a) escapes the touching method's own catch: attributed by the probe's author to Mono running such a type initialiser while it compiles the method that reads the field, which turns the initialiser's failure into a compile failure of the reading method. That fits the observed top frame (the call site in the caller), but the mechanism was read from Mono's source, not measured.