Thing enumeration off the Unity main thread¶
A Harmony postfix on ElectricityManager.ElectricityTick runs on a UniTask ThreadPool worker, not the Unity main thread. UnityEngine.Object.FindObjectsOfType<T>() is documented as main-thread-only, so calling it from such a postfix crashes the native side of the engine intermittently. The game has its own non-Unity collections that are safe to iterate from any thread; use those instead.
The crash signature¶
Symptom: the dedicated server crashes shortly after world load with a native unity stack ending in:
0x... (UnityPlayer) (function-name not available)
0x... (Mono JIT Code) (wrapper managed-to-native) UnityEngine.Object:FindObjectsOfType (System.Type,bool)
0x... (Mono JIT Code) UnityEngine.Object:FindObjectsOfType<T_REF> ()
0x... (Mono JIT Code) <YourPlugin>.YourScenario:DoStuff ()
0x... (Mono JIT Code) Cysharp.Threading.Tasks.SwitchToThreadPoolAwaitable/Awaiter:Callback (object)
0x... (Mono JIT Code) System.Threading.QueueUserWorkItemCallback:System.Threading.IThreadPoolWorkItem.ExecuteWorkItem ()
0x... (Mono JIT Code) System.Threading.ThreadPoolWorkQueue:Dispatch ()
The SwitchToThreadPoolAwaitable frame is the diagnostic: the game's GameTick (via Cysharp.Threading.Tasks.AsyncUniTask) switches to the ThreadPool before calling ElectricityManager.ElectricityTick. Any code running in a postfix on that tick is on the worker, not the main thread. The crash is intermittent because Unity's internal scene-graph mutex sometimes happens to be free, and the call returns clean; other times it is held by main-thread work and the native side dereferences a stale pointer.
Object.FindObjectsOfType is the most common offender, but anything that calls into the Unity scene graph (most MonoBehaviour lookups, GameObject.Find, instantiation, gameObject.GetComponent) has the same constraint.
The non-Unity collections you can iterate safely¶
The game maintains its own ConcurrentDensePool<T> collections that are thread-safe to iterate from any thread (they manage their own locking). The relevant pools for power-system work:
// decompile line 199822, inside class OcclusionManager
public static readonly ConcurrentDensePool<Thing> AllThings = new ConcurrentDensePool<Thing>("AllThings", 65535);
public static readonly ConcurrentDensePool<DynamicThing> AllDynamicThings = new ConcurrentDensePool<DynamicThing>("AllDynamicThings", 65535);
// decompile line 253430, inside class CableNetwork
public static readonly ConcurrentDensePool<CableNetwork> AllCableNetworks = new ConcurrentDensePool<CableNetwork>("AllCableNetworks", 4096);
// decompile line 417824, inside class AtmosphericsManager (approximate; same pattern)
public static readonly ConcurrentDensePool<Atmosphere> AllAtmospheres = new ConcurrentDensePool<Atmosphere>("AllAtmospheres", 65535);
Idiomatic enumeration:
using Assets.Scripts; // OcclusionManager
using Assets.Scripts.Objects; // Thing
using Assets.Scripts.Objects.Electrical; // Battery, Transformer, AreaPowerControl, CableFuse
var batteries = new List<Battery>();
var transformers = new List<Transformer>();
var apcs = new List<AreaPowerControl>();
var fuses = new List<CableFuse>();
OcclusionManager.AllThings.ForEach(t =>
{
if (t == null) return;
if (t is Battery b) batteries.Add(b);
if (t is Transformer x) transformers.Add(x);
if (t is AreaPowerControl a) apcs.Add(a);
if (t is CableFuse f) fuses.Add(f);
});
int cableNetCount = CableNetwork.AllCableNetworks.ActiveCount;
AllThings holds every Thing in the scene (registered through Thing.OnRegistered / Thing.OnDeregistered); filtering via is against concrete types is cheap, and the per-iteration cost is comparable to a single FindObjectsOfType<Thing> call would be on the main thread.
The Unity-side accessors that ARE safe off-thread¶
Most reads on a Thing after it has been fully registered are managed-memory accesses with no engine round-trip, so they are safe from the worker:
thing.ReferenceId,thing.PrefabName,thing.OnOff,thing.Mode,thing.Poweredthing.IsBeingDestroyed(public bool IsBeingDestroyed => BeingDestroyed;, Thing line 318452, re-verified at 0.2.6403.27689): a plain managed bool read, and the liveness guard vanilla itself pairs with the null check inside pooledForEachcallbacks (see AtmosphericThingTickDispatch); filterAllThingsentries witht == null || t.IsBeingDestroyedbefore touching themBattery.PowerStored,Battery.PowerMaximum,Battery.PowerDeltaTransformer.Setting,Transformer.UsedPower,Transformer._powerProvided(private field, reflect or use___prefix in Harmony)Transformer.InputNetwork.CurrentLoad,OutputNetwork.CurrentLoad(CableNetwork properties)CableNetwork.RequiredLoad,CurrentLoad,PotentialLoad
What is NOT safe off-thread:
UnityEngine.Object.FindObjectsOfType<T>()(this page's headline crash).GameObject.Find,Object.Instantiate,Object.Destroy.thing.transform.positionwrites (reads ofpositionandrotationhappen to work but are not guaranteed by Unity docs; prefer the cachedWorldPosition/WorldRotationif available).thing.gameObject.GetComponent<T>()if T is not already cached on the Thing.
If a scenario needs a Unity-API write, marshal back to the main thread via a MainThreadDispatcher (see Mods/InspectorPlus/InspectorPlus/MainThreadDispatcher.cs for the working implementation).
Capturing a device name during off-thread snapshot build¶
The worker-thread constraint is not limited to a postfix. PowerGridPlus retired the vanilla per-network PowerTick trio and replaced ElectricityManager.ElectricityTick with a single Harmony prefix, AtomicElectricityTickPatch (Mods/PowerGridPlus/PowerGridPlus/Patches/AtomicElectricityTickPatch.cs). The whole prefix pipeline runs on the same UniTask worker: the patch header states "vanilla calls ElectricityTick on the UniTask ThreadPool worker; the prefix runs on the same worker", and step 1, the SNAPSHOT phase, is GridSnapshot.Build, which does "the single boundary read of every device's demand / output / control". Mods/PowerGridPlus/PowerGridPlus/DeliveryEffectClassifier.cs:32 restates it: "the classifier runs on the power worker (GridSnapshot.Build)". So every device field captured while building the snapshot is captured off the main thread, and the read rules above apply.
When the snapshot needs a device name, Thing.PrefabName is the correct source. It is a plain immutable managed string (see the safe-off-thread list above), so reading it from GridSnapshot.Build on the worker is fine. The two obvious alternatives are NOT confirmed worker-safe:
Thing.DisplayName(Assembly-CSharp decompile 0.2.6403.27689, line 317670) isvirtualand returns the player rename when one is set, otherwise falls through toLocalization.GetThingName(PrefabName):
public virtual string DisplayName
{
get
{
if (string.IsNullOrEmpty(CustomName))
{
return Localization.GetThingName(PrefabName);
}
return CustomName;
}
}
The fallback path (below) is not on the confirmed-safe list, and the virtual modifier means a subclass override could add further work. Thing.TrackableName => DisplayName (line 317682) carries the same caveat.
Localization.GetThingName(string thingPrefabName)(line 210256) callsAnimator.StringToHashon both the primary and the fallback lookup:
public static string GetThingName(string thingPrefabName)
{
ThingLocalized.TryGetValue(Animator.StringToHash(thingPrefabName), out var value);
if (value == null)
{
FallbackThingsLocalized.TryGetValue(Animator.StringToHash(thingPrefabName), out value);
if (value == null)
{
return string.Format(ErrorName, CurrentLanguage, thingPrefabName);
}
return value.PrefabName;
}
return value.PrefabName;
}
Animator.StringToHash is a Unity API whose off-thread safety is unconfirmed, so GetThingName (and therefore DisplayName's fallback) is not confirmed worker-safe.
Rule: during snapshot build, or any OcclusionManager.AllThings.ForEach on the power worker, capture Thing.PrefabName as the device identity. If a friendly display name is needed, resolve it later on the main thread; do not call Thing.DisplayName or Localization.GetThingName from the worker until their off-thread safety is confirmed.
Repro and the fix that landed¶
The pattern was discovered while implementing Plans/PgpVerifyHelper/, a scenario-driven verification plugin. The initial ScenarioRunner.LogInventory() called UnityEngine.Object.FindObjectsOfType<Battery>() from the ElectricityManager.ElectricityTick postfix. The first run on Luna succeeded (returned 23 Batteries cleanly). The second run, with the same code targeting Transformer, crashed inside the FindObjectsOfType native bridge. The fix was the OcclusionManager.AllThings pattern documented above; the second run with the fix completed without crashing and emitted 41 Transformers + 23 Batteries cleanly.
The reason the FIRST run happened to work but the second crashed is undetermined: most likely a race window where Unity's scene-graph mutex was free for the first call and held for the second. Either way, do not rely on FindObjectsOfType from any ElectricityTick-driven postfix; the OcclusionManager.AllThings path is correct.
Verification history¶
- 2026-07-15: added the "Capturing a device name during off-thread snapshot build" section (additive; no existing section changed, so no fresh-validator pass). PowerGridPlus's
AtomicElectricityTickPatchprefix (replacingElectricityManager.ElectricityTick) and itsGridSnapshot.BuildSNAPSHOT phase run on the UniTask ThreadPool worker (patch header comment;DeliveryEffectClassifier.cs:32), so device fields read during snapshot build are off-thread reads.Thing.PrefabName(plain immutable string, already on the safe list) is the correct off-thread name source;Thing.DisplayName(Assembly-CSharp 0.2.6403.27689 line 317670: player-renameCustomNamewith aLocalization.GetThingNamefallback, andvirtual) andLocalization.GetThingName(line 210256, callsAnimator.StringToHashon both the primary and the fallback lookup) are NOT confirmed worker-safe. Verified against the 0.2.6403.27689 decompile and the current PowerGridPlus source; top-level verified_in / verified_at advanced to 0.2.6403.27689 / 2026-07-15 (the pre-existing sections keep their earlier stamps). - 2026-07-07: added
thing.IsBeingDestroyedto the safe-off-thread reads list (game version 0.2.6403.27689:public bool IsBeingDestroyed => BeingDestroyed;on Thing, decompile line 318452). Occasion: PowerGridPlus partial-power forensics. Single-bullet addition with its own inline version note; the section stamp and the other entries keep their 0.2.6228.27061 basis. - 2026-05-25: page created. Source: live crash repro on the dedicated server during PgpVerifyHelper development (
TestRig/DedicatedServer/data/server.logafter the 2026-05-25 transformer-conservation run) plus thePlans/PgpVerifyHelper/PgpVerifyHelper/ScenarioRunner.csfix. Decompile reference for the static pools verified in place atOcclusionManager.AllThings(line 199822),CableNetwork.AllCableNetworks(line 253430),AtmosphericsManager.AllAtmospheres(line 417824).
Open questions¶
- Whether
Thing.OnRegistered/Thing.OnDeregisteredthemselves are safe to call off the main thread. The currentOcclusionManager.AllThings.Add/Removelook thread-safe (the pool is concurrent), but a worker-thread plugin that ALSO needs to callThing.OnRegisteredwould risk Unity-side state mutation; needs validation if a future scenario needs to spawn Things at runtime.