MainThreadDispatcher¶
ConcurrentQueue<Action> drained in Update() on a DontDestroyOnLoad MonoBehaviour. Required whenever a callback can fire off the Unity main thread and the callback needs to touch a Unity API. Appears verbatim as a helper class in multiple mods in this repo.
Problem¶
Unity's P/Invoke-backed APIs are not thread-safe and hard-crash the native player when called from any thread other than the Unity main thread. Stationeers drives several callback paths off the main thread:
PowerTick.ApplyStateruns on a UniTask ThreadPool worker. Any postfix onReceivePower/UsePower/GetGeneratedPower/GetUsedPower/VisualizerIntensitysetter inherits that thread. See../GameSystems/PowerTickThreading.md.FileSystemWatcherevents fire on a .NET thread-pool thread. See./FileSystemWatcherMainThread.md.IAsyncEnumerable/ background-thread inference (LLM) produces callbacks off the main thread.
F0308 (code comment, MainThreadDispatcher.cs:7-15):
// Stationeers drives power-tick code (PowerTick.ApplyState -> ReceivePower ->
// VisualizerIntensity setter) on a ThreadPool worker via UniTask's
// SwitchToThreadPoolAwaitable. Our Harmony postfixes inherit that thread,
// so any call to a Unity API (new GameObject, Shader.Find, Transform.position,
// LineRenderer.SetPosition) hard-crashes the native Unity player.
//
// This dispatcher parks a queue on a DontDestroyOnLoad GameObject, drained
// in Update() on the main thread. Patches enqueue closures from any thread,
// the closure body runs safely on the main thread one frame later.
Solution / recipe¶
A MonoBehaviour on a DontDestroyOnLoad GameObject owns a ConcurrentQueue<Action> and drains it from its own Update(). Other code enqueues Action closures from any thread; the closure body executes on the main thread approximately one frame later.
Minimal shape (from F0032):
MainThreadDispatcheris aMonoBehaviouron aDontDestroyOnLoadGameObject. It maintains aConcurrentQueue<Action>drained inUpdate(). Every Harmony postfix that touches Unity API enqueues onto this dispatcher. Closure runs on main thread one frame later. ~1 frame latency, fully safe.
Key invariants:
DontDestroyOnLoadkeeps the dispatcher alive across scene loads (the main menu unloads the world scene, but the dispatcher must still drain queued actions).- The queue MUST be
ConcurrentQueue<T>(or another lock-free structure). PlainQueue<T>races with enqueues from other threads. Update()can drain a fixed batch per frame or the entire queue; draining everything is safe if producers are bounded.- Field reads/writes (managed memory, no Unity P/Invoke) are safe from background threads. Only the Unity API calls need to be dispatched.
Capture config values on main thread before dispatch¶
F0350 (code comment, Plans/LLM/LLM/Plugin.cs:67-68):
// Capture config values now (main thread) so the background thread
// doesn't touch BepInEx ConfigEntry from a non-Unity thread.
When a background worker needs values from a Unity-main-thread-owned source (BepInEx ConfigEntry, Unity components, scene objects), capture them into plain locals on the main thread before starting the background work. Dispatching the capture back through MainThreadDispatcher defeats the purpose if the background worker is waiting on the value.
Cited verifications¶
- F0032 (Mods/PowerTransmitterPlus/RESEARCH.md:43-49): crash mechanism (UniTask ThreadPool worker, hard native crash on Unity API from non-main thread) and the field-reads-are-safe caveat. Full content on
../GameSystems/PowerTickThreading.md. - F0308 (MainThreadDispatcher.cs class header): implementation recipe (
ConcurrentQueue<Action>+DontDestroyOnLoad+ drain inUpdate). - F0350 (LLM Plugin.cs: main-thread capture): pair the dispatcher with up-front value capture for background workers that consume main-thread state.
The game's UnityMainThreadDispatcher, and why mods roll their own¶
Stationeers ships its own main-thread dispatcher, UnityMainThreadDispatcher : ManagerBase (decompile line 219184). It is usable in principle, but three properties make a self-owned mod-local dispatcher the safer default for a BepInEx mod.
Instance()throws when the dispatcher object is absent:
public static UnityMainThreadDispatcher Instance()
{
if (!Exists())
throw new System.Exception("UnityMainThreadDispatcher could not find the UnityMainThreadDispatcher object. Please ensure you have added the MainThreadExecutor Prefab to your scene.");
return _instance;
}
_instance is assigned in ManagerAwake (the ManagerBase lifecycle), so the dispatcher exists only once the MainThreadExecutor manager has spawned in the current scene. A plugin that enqueues from early load, the main menu, or any scene where the manager is not present gets an exception, not a no-op. A mod-local dispatcher created in Plugin.Awake is available the moment the plugin is alive.
Enqueue(Action)does not run the action directly. It wraps it in a coroutine started duringManagerUpdate:
public void Enqueue(Action action) => Enqueue(ActionWrapper(action));
private IEnumerator ActionWrapper(Action a) { a(); yield return null; }
// ManagerUpdate drains ExecutionQueue under: if (action.Target != null) action();
Two consequences: the action runs via StartCoroutine (extra indirection; runs only while the dispatcher MonoBehaviour is active and ManagerUpdate pumps), and the if (action.Target != null) guard silently DROPS any queued delegate whose Target is null. In practice the queued item is a closure (non-null Target) so it runs, but a target-less delegate vanishes without error.
- The execution queue is shared with the engine's own usage (it also carries
ChunkThreadtasks via a secondTaskQueue), and its pump is tied to the game's manager-update loop rather than the mod's control.
Trade-off summary:
Game UnityMainThreadDispatcher |
Mod-local dispatcher | |
|---|---|---|
| Availability | Throws if the MainThreadExecutor manager is not in the scene; must guard with Exists() and have a fallback |
Guaranteed from Plugin.Awake; defensive no-op before Init |
| Drain | StartCoroutine(ActionWrapper); Target != null guard drops target-less delegates |
Direct action() in Update(); no Target gotcha |
| Coupling | Game manager-update loop + scene/prefab presence | Mod-owned lifecycle, DontDestroyOnLoad |
| Queue | Shared with engine (also ChunkThread tasks) |
Isolated to the mod |
| Cost | No extra GameObject | One extra GameObject + Update tick (negligible); ~30 lines duplicated per mod |
Net: the game's dispatcher is usable if you guard UnityMainThreadDispatcher.Exists() and accept the coroutine / Target semantics, but the mod-local pattern above is what every dispatching mod in this repo uses, because it removes the throw-if-absent failure mode and the Target-drop gotcha for ~30 lines. When more than one mod needs it, consider promoting the helper to Patterns/ shared code rather than copying it per mod.
Headless dedicated server: Update does not pump after world load¶
A MonoBehaviour.Update-drained queue stalls server-side on a headless dedicated server once the world has loaded. The repo's dedicated-server doc captures this constraint (DedicatedServer/CLAUDE.md, "Path B: in-game scenario plugin" section):
on a headless dedicated server
MonoBehaviour.Updatedoes not fire after world load, and the top-levelGameManager.GameTickis an async UniTask state machine that switches to a ThreadPool worker
ScenarioRunner (DedicatedServer/dev-plugins/ScenarioRunner/) consequently pumps its scenario dispatcher from a Harmony postfix on ElectricityManager.ElectricityTick rather than MonoBehaviour.Update. That pump is gated on GameManager.RunSimulation, so it fires only while the simulation is unpaused: with a client connected, or with InspectorPlus's Force Unpause Without Client setting enabled in net.inspectorplus.cfg's [Server - Headless] section.
Implication for the mod-local MainThreadDispatcher pattern above: a self-owned Update() drain is sufficient for client-side and host-as-client usage (the rendering loop fires Update reliably), but server-side enqueues on a headless dedi accumulate without draining. Two recovery patterns:
- Skip headless dedi support if the dispatcher's only consumers run client-side or only matter when a UI is present. A motherboard-dropdown refresh cascade falls into this category: the dropdown lives on a rendering client, not on the server, so a stalled queue server-side is invisible. The host-as-client case still works because the host has a rendering loop.
- Add a second pump from a simulation-tick Harmony postfix that calls the dispatcher's drain method directly. The pattern is the one ScenarioRunner uses for its scenario dispatcher: a separate
[HarmonyPatch(typeof(ElectricityManager), nameof(ElectricityManager.ElectricityTick))]postfix that invokesDispatcher.Drain().ElectricityTickis on the Unity main thread; other ticks can be ThreadPool workers (see../GameSystems/PowerTickThreading.md). For the second pump to be wirable, the dispatcher's drain must be a public method, not just a privateUpdate().
When choosing between these, weigh whether ANY enqueue can originate server-side under headless conditions: if every enqueue site is on a code path that only runs with a UI present, pattern 1 is sufficient and adds no machinery; otherwise pattern 2 is required for headless soundness.
Verification history¶
- 2026-04-20: page created from the Research migration; implementation verbatim from F0308, with underlying-cause detail from F0032 and the main-thread-capture addendum from F0350.
- 2026-05-28: added "The game's UnityMainThreadDispatcher, and why mods roll their own". Read
UnityMainThreadDispatcher : ManagerBase(decompile line 219184):Instance()throws when the MainThreadExecutor manager is absent;Enqueue(Action)wraps in anActionWrappercoroutine and drains under anif (action.Target != null)guard that drops target-less delegates; the execution queue is shared with engineChunkThreadtasks. Documents the game-vs-mod-local trade-off. Additive (the page previously covered only the mod-local recipe); no existing claim contradicted, so no fresh validator. - 2026-05-28: added "Headless dedicated server: Update does not pump after world load". Documents the constraint already captured in
DedicatedServer/CLAUDE.md(the dedicated-server doc points atResearch/Patterns/ThingEnumerationOffMainThread.mdfor theGameTickworker-thread half but is the only place the no-Updatehalf lives). Cites ScenarioRunner'sElectricityManager.ElectricityTickpostfix as the in-repo precedent for the sim-tick pump workaround. Surfaced while running the Power Grid Plus passthrough-refresh dedi playtest on a copy ofAPC-Luna.save: the mod's cascade-refresh queue is enqueued fromPassthroughModeStore.RestoreFromSideCarand the cascade engine, but the dedi has no rendering motherboard consumer to notify, so the stalled-queue case is invisible from snapshots; the constraint is documented as repo lore, not independently re-verified this session. Additive; no existing claim contradicted; no fresh validator.
Open questions¶
None at creation.