Save-load ordering: defer restore to OnFinishedLoad¶
When a parent Thing's saved state references child Things that have not yet been placed into its slots, DeserializeSave cannot restore that state immediately. The child-placement pass fires OnChildEnterInventory per child, which can clobber values set earlier. The pattern: stash the saved value in a dictionary during DeserializeSave, then apply it from OnFinishedLoad, which runs after every child has been placed.
Problem¶
F0122 (Plans/EquipmentPlus/RESEARCH.md:250-251, primary):
Vanilla restores children (chips/cartridges) one at a time AFTER the parent's
DeserializeSave. Each child'sMoveToSlotfiresOnChildEnterInventory, which for lenses unconditionally setsSensor = child. The last chip to enter wins, overwriting any value set during deserialization. Active-slot restoration must wait forOnFinishedLoad, which fires after all children are placed.
F0333 (code comment, ActiveSlotPersistence.cs:11-28) extends to AdvancedTablet:
/// <summary>
/// Vanilla's load sequence places every child of a Thing (sensor chips
/// inside lenses, cartridges inside a tablet) one at a time AFTER the
/// parent's DeserializeSave returns. Each MoveToSlot fires the parent's
/// OnChildEnterInventory, which for SensorLenses unconditionally sets
/// <c>Sensor = child</c>. As a result, the last chip to re-enter wins
/// and any value we set from saved data is immediately overwritten.
///
/// AdvancedTablet has an analogous problem: <c>Mode</c> isn't persisted
/// at all, so it resets to 0 (first cartridge) on every load.
///
/// This class runs the saved active-slot restore from <c>OnFinishedLoad</c>
/// -- a Thing-lifecycle hook that fires after child slots are populated.
/// Save-time capture is done by <c>SensorLensesSerializeSavePatch</c> and
/// <c>AdvancedTabletSerializeSavePatch</c>; at deserialize time the
/// saved value is stashed in a dictionary here; <c>OnFinishedLoad</c>
/// reads the dictionary and applies.
/// </summary>
Solution / recipe¶
Three-step pattern:
- Save-time capture.
Postfix SerializeSaveto write the reference-id (or index, or whatever identifies the active child) into the saved blob. - Deserialize stash.
Postfix DeserializeSaveto read the saved value and store it in a dictionary keyed by the parent'sReferenceId. Do NOT attempt to apply it here; the children are not in their slots yet. - OnFinishedLoad apply. Hook
Thing.OnFinishedLoad(by whichever Harmony mechanism suits; inherited methods needTargetMethod()per./HarmonyInheritedMethods.md). Look up the dictionary entry, find the matching child by reference-id, and set the active-slot field.
F0373 (Plans/EquipmentPlus/EquipmentPlus/AdvancedTabletPatches.cs:103-108) is the specific AdvancedTablet case:
Mode cannot be restored yet -- CartridgeSlots is rebuilt by our RefreshSlots later, and vanilla hasn't placed cartridges into the slots yet either. Stash the saved reference id and apply it in OnFinishedLoad.
F0374 (Plans/EquipmentPlus/EquipmentPlus/ActiveSlotPersistence.cs:95-101) documents the apply step:
Find the CartridgeSlots index whose occupant matches the saved cartridge reference id, and set Mode to that index.
Thing.set_Modepropagates viaInteractable.set_State; we also call the privateGetCartridge()to force the display to reflect the new active cartridge immediately.
Skeleton:
// Stash during deserialize:
[HarmonyPostfix]
static void DeserializePostfix(Thing __instance, /* saved data */ data)
{
_savedActiveIds[__instance.ReferenceId] = data.ActiveChildRefId;
}
// Apply during OnFinishedLoad:
[HarmonyPostfix]
static void OnFinishedLoadPostfix(Thing __instance)
{
if (!_savedActiveIds.TryGetValue(__instance.ReferenceId, out var savedId)) return;
_savedActiveIds.Remove(__instance.ReferenceId);
// Find child by reference id among populated slots, then set active-slot field.
}
Cited verifications¶
- F0122: primary rule (OnChildEnterInventory clobbers; wait for OnFinishedLoad).
- F0333: generalized to both SensorLenses and AdvancedTablet.
- F0373: AdvancedTablet-specific case where
Modeis not persisted and also depends on rebuilt dynamic slots. - F0374: apply-side implementation including
Thing.set_ModeviaInteractable.set_Stateand theGetCartridge()refresh.
OnRegistered fires before DeserializeSave¶
The per-Thing load order starts earlier than the DeserializeSave/OnFinishedLoad pair above. XmlSaveLoad.LoadThing (decompile line 251312) is:
Thing thing2 = Thing.Create<Thing>(thing, worldPosition, worldRotation, thingData.ReferenceId);
...
thing2.DeserializeSave(thingData);
thing2.ValidateOnLoad(CurrentSaveRevision);
Thing.Create registers the thing into the world synchronously; for structures the registration path is GridController.AddGridStructure (line 191515), whose last act is structure.OnRegistered(cell) (line 191563). So the complete per-Thing order on world load is:
Thing.Create-> grid registration ->OnRegistered(cell)(prefab-default field values).DeserializeSave(thingData)(saved values applied).- Child placement (
OnChildEnterInventoryper child). OnFinishedLoad(everything in place).
Implication: a Harmony postfix on OnRegistered that initialises a field to a non-vanilla default (e.g. PowerGridPlus initialising Transformer.Setting = OutputMaximum) is automatically overwritten by the saved value for loaded things, while sticking for fresh constructions (which never run DeserializeSave). This gives "default for new builds, preserve saved state" semantics with no load-vs-build discrimination logic.
XmlSaveLoad.LoadThing renamed to Load at 0.2.6403¶
At game version 0.2.6403.27689, XmlSaveLoad.LoadThing(ThingSaveData, bool) no longer exists: a whole-decompile grep for LoadThing returns zero hits. The per-Thing loader is now a pair of overloads on the same class (XmlSaveLoad : ManagerBase, decompile line 267663):
public static T Load<T>(ThingSaveData thingData, bool generatesTerrain = true) where T : Thing // line 268424, uses Prefab.Find<T> + Thing.Create<T>
public static Thing Load(ThingSaveData thingData, bool generatesTerrain = true) // line 268463
The non-generic body (lines 268463-268500) keeps the semantics the previous section relies on, in the same order:
public static Thing Load(ThingSaveData thingData, bool generatesTerrain = true)
{
Thing thing = Prefab.Find(thingData.PrefabName);
if (thing == null)
{
UnityEngine.Debug.LogWarning("Can't spawn " + thingData.PrefabName);
return null;
}
if (!thingData.IsValidData())
{
return null;
}
Vector3 worldPosition = thingData.WorldPosition;
Quaternion worldRotation = thingData.WorldRotation;
if (thingData is StructureSaveData structureSaveData)
{
worldPosition = structureSaveData.RegisteredWorldPosition;
worldRotation = structureSaveData.RegisteredWorldRotation;
RocketRecordData rocketRecord = structureSaveData.RocketRecord;
if (rocketRecord != null && rocketRecord.RocketNetworkId != 0L)
{
EngineFuselage engineFuselage = Referencable.Find<RocketNetwork>(rocketRecord.RocketNetworkId)?.Anchor;
if ((object)engineFuselage != null)
{
worldPosition = engineFuselage.ThingTransformPosition + rocketRecord.Offset;
}
}
}
Thing thing2 = Thing.Create<Thing>(thing, worldPosition, worldRotation, thingData.ReferenceId);
if (!thing2)
{
return null;
}
thing2.generateTerrain = generatesTerrain;
thing2.DeserializeSave(thingData);
thing2.ValidateOnLoad(CurrentSaveRevision);
return thing2;
}
Thing.Create -> DeserializeSave -> ValidateOnLoad is unchanged, so the ordering conclusion in "OnRegistered fires before DeserializeSave" above (verified at 0.2.6228.27061 through the old LoadThing name) carries over; only the method name and the added generic overload differ. The GridController.AddGridStructure registration leg of that section was not re-read at 0.2.6403.
Mod impact: any mod that calls or reflection-resolves XmlSaveLoad.LoadThing by name breaks at 0.2.6403 (MissingMethodException on a compiled call, null from AccessTools.Method). The 2026-07-02 dedicated-server boot investigation records ModularConsoleMod as a casualty of exactly this removal. Retarget to Load(ThingSaveData, bool); when resolving by reflection, pass explicit parameter types since the name now also has a generic overload. See ../Unsorted/Api-removals-0.2.6403.md for the sibling API removals in the same game update.
XmlSaveLoad.LoadWorld is async: a Harmony postfix fires at load START, not load completion¶
XmlSaveLoad.LoadWorld (same class, decompile line 268507 at 0.2.6403.27689) is an async method. Verbatim head:
public static async UniTask LoadWorld(bool loadWithoutChars = false) // 268507
{
WorldManager.SetGamePause(pauseGame: true); // 268509: first synchronous statement
await ImGuiLoadingScreen.SetState(GameStrings.LoadingScreenInitializing.DisplayString); // 268510: first await
FadePanel.ToBlack(0.5f);
await UniTask.Delay(500, DelayType.UnscaledDeltaTime);
Stopwatch overallLoadTime = new Stopwatch();
overallLoadTime.Start();
await UniTask.DelayFrame(2);
GameManager.GameState = GameState.Loading; // 268516: only after three awaits
...
}
The C# compiler rewrites an async method into a stub that constructs the state machine, runs it to the first suspending await, and returns the UniTask. Harmony patches that stub. A [HarmonyPostfix] on LoadWorld therefore runs when the stub returns, i.e. as soon as the method suspends at its first await (268510): at postfix time the pause request from 268509 has been issued, GameState is not yet Loading (268516), nothing has been deserialized, and the actual load continues asynchronously long after the postfix returned. The postfix observes load START, never load completion. The single vanilla caller awaits the task (await XmlSaveLoad.LoadWorld();, 264682).
Two consequences for mods:
- To run code at load completion, do not postfix
LoadWorlddirectly. Hook something the tail of the load actually reaches (per-ThingOnFinishedLoad, or theGameStatetransition toRunning), or take__result(theUniTask) in the postfix and continue after awaiting it. - A postfix that assumes "no simulation tick is running because the game just paused" is also wrong on arrival:
GameManager.GameTick(204387) checksWorldManager.IsGamePausedonly at the TOP of each loop iteration (204394) and then moves the tick body onto a ThreadPool worker (await UniTask.SwitchToThreadPool(), 204418). A tick that passed the pause check beforeSetGamePause(true)landed runs to completion, includingElectricityManager.ElectricityTick()(204466), concurrently with the first stretch ofLoadWorldand with anything a load-start postfix does. See PowerTickThreading.
OnFinishedLoad also fires on multiplayer client join, not only on save load¶
The OnFinishedLoad hook this page's pattern relies on is NOT exclusive to the save-load path. Both game-entry paths funnel into the same per-Thing sweep, GameManager.UpdateThingsOnGameStart (204629-204632):
public static void UpdateThingsOnGameStart()
{
OcclusionManager.AllThings.ForEach(UpdateThingsOnGameStartAction);
}
whose action (203847-203869) invokes thing.OnFinishedLoad() per thing behind a per-thing try / catch, verbatim:
private static readonly Action<Thing> UpdateThingsOnGameStartAction = delegate(Thing thing)
{
if ((object)thing == null)
{
return;
}
try
{
thing.OnFinishedLoad();
foreach (Interactable interactable in thing.Interactables)
{
if (interactable.JoinInProgressSync && (bool)interactable.Animator)
{
interactable.SetState();
thing.OnFinishedInteractionSync(interactable);
}
}
}
catch (System.Exception arg)
{
ConsoleWindow.PrintError($"OnFinishedLoad failed for {thing.DisplayName} #{thing.ReferenceId}: {arg}");
}
};
Call sites (whole-decompile census at 0.2.6403.27689: exactly two):
- Host / single-player start after load:
GameManager.StartGame(204575) setsGameState = GameState.Running;(204577) and callsUpdateThingsOnGameStart();(204580). - Multiplayer client join:
NetworkClient.ProcessJoinData(NetworkClient : NetworkBaseat 212856; the method at 213188, awaited from the join flow at 213084) ends the join with (213235-213239), verbatim:
GameManager.GameState = GameState.Running;
GameManager.UpdateThingsOnGameStart();
Device.InitAllDevices();
Singleton<DiscordClient>.Instance.UpdateActivityInGame();
Rocket.OnFinishedLoad();
(Device.InitAllDevices at 371889-371892 is AllDevices.ForEach(InitializeDeviceAction);, the same device network-registration sweep the load path runs at 268725; the joining client re-registers device network membership right after the OnFinishedLoad sweep.) On both paths the sweep runs after GameState was set to Running, so a GameManager.GameState check inside an OnFinishedLoad hook sees Running, never Loading or Joining.
Consequence for this page's pattern (stale-cache hazard): a mod restore keyed on Thing.OnFinishedLoad ALSO fires on every multiplayer join, for every replicated thing, with whatever state the mod's process-level caches hold at that moment. A stash dictionary populated by an earlier world in the same game process (a single-player session before joining a server, or a previous join) is still there when the join-time sweep runs, and colliding ReferenceIds apply stale entries to the new world's things. A load-keyed restore must clear its stashes on world teardown (or key them per world session) and must tolerate things for which no stash entry exists.
Awake runs before a Thing's saved contents are placed, on load and on join¶
A container's Awake always runs while its slots are still empty. Every path that restores slot contents creates the container with Thing.Create first, and the contents are moved in afterwards from the CHILD's side, by a child that looks the container up by reference id. So whatever an Awake postfix decides about a slot, it decides before that slot's saved occupant is in it: on a save load, on a multiplayer join, and for a Thing a server sends to clients mid-session.
Read in the 0.2.6428.27798 decompiles. Every body cited in this section is textually identical in the client and the dedicated server Assembly-CSharp (each member was extracted from both files and diffed), so client line numbers come first and the dedicated server's follow in brackets where they were pinned.
Save load.
- The writer puts a container before its contents.
XmlSaveLoad.AddToSave(274028-274042 [260441-260455]) adds a Thing, then recurses into its occupied slots:
worldData.OrderedThings.Add(thing.SerializeSave()); // 274034 [260447]
foreach (Slot slot in thing.Slots)
{
if (!slot.IsEmpty())
{
AddToSave(slot.Get(), worldData, thing); // 274039 [260452]
}
}
OrderedThings is the <AllThings> array ([XmlArray("AllThings")], 273404 [259817]), and WorldData.GetThings() (273453-273460) returns it unless the legacy <Things> array (OldThings, 273410 [259823]) is present and non-empty.
2. XmlSaveLoad.LoadWorld (274151-274450 [260564-260863]) calls Load once per saved Thing, in that order (_ = Load(thingSaveData) == null;, 274313 [260726]), and yields a frame only between whole Load calls (if (j % 500 == 0 && timer.ElapsedMilliseconds > 250), 274281 [260694]).
3. XmlSaveLoad.Load (274107-274144 [260520-260557]) creates, then deserializes:
Thing thing2 = Thing.Create<Thing>(thing, worldPosition, worldRotation, thingData.ReferenceId); // 274135 [260548]
...
thing2.DeserializeSave(thingData); // 274141 [260554]
Thing.Create<T>(Thing prefab, Vector3 worldPosition, Quaternion worldRotation, long referenceId = 0L)(324917-324972 [311301-311356]) instantiates first and registers the reference id last:
Thing thing2 = UnityEngine.Object.Instantiate(thing, worldPosition, worldRotation); // 324928 [311312]
...
thing2.OnStartRender(); // 324932 [311316]
...
flag = Referencable.RegisterAs(thing2, referenceId); // 324960 [311344]
Awake runs inside that Instantiate call (inferred, see "Read and inferred" below).
5. The occupant puts itself back when IT deserializes. DynamicThing.DeserializeSave (305502-305523 [291895-291916]) calls MoveToParent (305525-305558 [291918-291951]), which looks the container up and moves in at once when it is found:
Thing thing = Referencable.Find<Thing>(parentReferenceId); // 305531 [291924]
...
MoveToSlot(thing.Slots[parentSlotId], thing); // 305538 [291931]
Otherwise it starts MoveToParentWhenReady (305560-305586 [291953-291979]), which repeats the same Find once per frame for up to 10 s (float timeToWait = 10f;, 305564 [291957]) and then calls MoveToSlot(parent.Slots[parentSlotId], parent); (305583 [291976]).
6. LoadWorld does not finish until every such wait has ended (while (DynamicThing.AwaitingMoveWhenReady.Count > 0), 274423 [260836]). Only after that does it reach World.OnLoadingFinished(worldData); (274445 [260858]), whose GameManager.StartGame().Forget(); (330907 [317291]) runs the OnFinishedLoad sweep (UpdateThingsOnGameStart(); in GameManager.StartGame, 210057 [198004]).
In a game-written save the container's Load, its Awake and its own DeserializeSave included, has therefore finished before the occupant's Load starts, and step 5 finds the container at once. In a file whose order was changed by hand, the occupant waits instead, and Find can only succeed after the container's Thing.Create has run RegisterAs, which is after Instantiate. The retry resumes on a later frame and the loop yields only between whole Load calls, so the container's own DeserializeSave has run by then too. Either way the container awoke first.
Multiplayer join. The server writes a container's record before its contents: NetworkServer.Serialize (219360-219371 [207264-207275]) calls thing.SerializeOnJoin(writer) and then Serialize(writer, slot.Occupant, ref count) for each occupied slot, and Thing.SerializeAllOnJoin (328450-328480 [314834-314864]) starts it only from root Things. The joining client's NetworkClient.ProcessThings (219024-219041 [206928-206945]) takes one record at a time and creates before it applies:
Thing thing = Thing.Create<Thing>(prefabHash, position, rotation, referenceId); // 219036 [206940]
thing.DeserializeOnJoin(reader); // 219037 [206941]
DynamicThing.DeserializeOnJoin (307189-307226 [293582-293619]) looks the container up with Referencable.Find<Thing>(referenceId) and calls MoveToSlot(thing.Slots[index], thing); (307211 [293604]). It does not wait: a container that is not there yet only logs error {DisplayName} with #{base.ReferenceId} cant find its parent #{referenceId} (307203 [293596]). The joiner's OnFinishedLoad sweep comes after all of this (ProcessThings is awaited at 218862 and GameManager.UpdateThingsOnGameStart(); runs at 218883; see "OnFinishedLoad also fires on multiplayer client join" above). A Thing the server creates mid-session reaches clients through Thing.DeserializeNew (328208-328216), which is the same Create<Thing> followed by DeserializeOnJoin.
What the slots hold at Awake: nothing. Thing.Awake (326221-326372 [312605-312756]) only prepares its slots: ConfigureSlots(); (326292), which sets each slot's Parent and Action (327802-327809), then slot.Initialize(); (326302). DynamicThing.Awake (306415-306445) and Item.Awake (311386-311394 [297770-297778]) put nothing in a slot. The occupant is held in private DynamicThing _dynamicThing; (316832 [303216]), which has no [SerializeField], so a clone starts with every slot empty.
Consequences for a mod.
- An
Awakepostfix that treats an occupied slot differently never sees an occupied slot on a save load or a join. Its "occupied" branch is reachable only from a call made later. - Slot state an
Awakepostfix sets is already in place when the saved occupant arrives, and the occupant goes in regardless ofSlot.Type.DynamicThing.MoveToSlot(306632-306697 [293025-293090]) refuses only a Thing being destroyed, a null slot, a failedCanEnter(if (!CanEnter(destinationSlot)), 306638 [293031]) or an occupied slot (306642 [293035]), andThing.CanEnter(326562-326589 [312946-312973]) readsSlot.Typeonly for an entity. A slot turned intoSlot.Class.BlockedinAwaketherefore still receives its saved occupant, while aCanEnterpostfix does run for the load-time move and can refuse it. See SlotInsertionBlock, "Legacy-state handling". - A decision that has to see the restored contents must run after step 6 on a load and after
ProcessThingson a join.Thing.OnFinishedLoadis such a point on both paths. - The arrival is the other decision point.
DynamicThing.MoveToSlotcalls the container'sOnChildEnterInventoryonce the item is in the slot: afterdestinationSlot.Take(this);(306680 [293073]) andParentSlot.RefreshSlotDisplay();(306687 [293080]), atParentSlot.Parent.OnChildEnterInventory(this);(306688 [293081]), and before the item's ownOnEnterInventoryand the slot'sOnEnterEvent. Apart from thebasecalls of its 81 overrides, each of which makes that call once and unconditionally, this is the only call of it in either build. A postfix onThing.OnChildEnterInventorytherefore sees each saved item already in its slot, on a load (step 5 above), on a join (DeserializeOnJoin) and for a Thing sent mid-session (Thing.DeserializeNew), and it sees the same for a client applying the server's move (DynamicThing.ProcessUpdateTransform,MoveToSlot(slot, thing);at 307271 [293664]) and for a normal insertion. The exception is a dragged item:DynamicThing.DragInSlot(305907-305933 [292300-292326]) takes the slot without calling it. See DynamicThing, "MoveToSlotand the container hooks". Equipment Plus'sDynamicSlotsdecides at the arrival, fromOnChildEnterInventorypostfixes. Not in a release yet: Spray Paint Plus'sSprayGunCanArrivalPatchdecides there too; it is in commitfdcffdc5on master, and ships in v1.13.0. The released v1.12.1 decides only inAwakeand at the join fix-up, so after a load with glow paint on, its can stays in a hidden, blocked slot (see SlotInsertionBlock, "Legacy-state handling"). Both were seen on the test rig. On a build without the arrival hook (Release DLL sha25656763957, 2026-09-27), a glow-on load left a saved can in its gun'sBlocked, non-interactable slot, in single player, on a listen host and on a dedicated server's joiner that joined guessing glow on. Onfdcffdc5's build (c0c00c18, 2026-09-28) the same load and joins gave the slot back with the can in it. - A decision made at the arrival that changes whether a slot is interactable does not reach an inventory window that is already drawn:
InventoryWindow.SetSlotsbuilds a button onlyif (slot.IsInteractable)(247392 [234504]),IsInteractableis a plain field, and nothing rebuilds a window when it changes. On a load or a join no window exists yet. The local player's windows are built byInventoryManager.Initialize(Entity)throughInventoryWindowManager.AssignParent(292629 [279022]), and its only caller,Human.SetupReferencesWhenReady(368428-368442 [354796-354810]), started fromHuman.TakeControl(368950 [355318]), first waitswhile (GameManager.GameState != GameState.Running)(368430 [354798]).GameStatebecomesRunningafter every saved item is placed: on a load,LoadWorldwaits outAwaitingMoveWhenReady(274423 [260836]) beforeWorld.OnLoadingFinished(274445 [260858]) startsGameManager.StartGame, which setsGameState = GameState.Running;(210054 [198001]); on a join,await ProcessThings(reader)(218862 [206766]) comes beforeGameManager.GameState = GameState.Running;(218882 [206786]) inNetworkClient.ProcessJoinData. Only a change later in the session can meet a window drawn before it. See InventoryWindow, "Window lifecycle".
Read and inferred. Everything above is read in the two decompiles except two points, both Unity engine behaviour that a decompile cannot show:
- That
Awakeruns insideUnityEngine.Object.Instantiate, before it returns (Unity's behaviour for an active prefab). Two things corroborate it.Thing.Createcallsthing2.OnStartRender()(324932) straight afterInstantiate, and that method walksRenderers, which onlyThing.Awakefills (Renderers.Add(thingRenderer);, 326290). And it was measured on 2026-09-22 that a spray gun a join spawns has run itsAwakepostfix by the time the join payload is read, which on a joiner comes afterProcessThings(see LaunchPadBoosterNetworking, "A Thing the join spawns has already run Awake"). The 2026-09-27 run in the arrival bullet above agrees on the load path too: in single player and on a listen host, where nothing but thatAwakepostfix decides the slot, it blocked a slot that the saved can then occupied, and its guard leaves an occupied slot alone, so it ran before the can was placed. - That
Instantiatedoes not copy a private field without[SerializeField](Unity's serialization rule), which is why a clone's slots start empty.
Verification history¶
- 2026-04-20: page created from the Research migration; F0122 primary, three additional sources corroborating across two device classes.
- 2026-06-10: added the "OnRegistered fires before DeserializeSave" section from a direct read of
XmlSaveLoad.LoadThing(line 251312) andGridController.AddGridStructure(line 191515-191563) in the 0.2.6228.27061 decompile, during the PowerGridPlus Setting-init design. - 2026-07-02: added the "XmlSaveLoad.LoadThing renamed to Load at 0.2.6403" section (
Loadplus a new genericLoad<T>overload). Verified against the 0.2.6403.27689 decompile: zero grep hits forLoadThing, replacement overloads quoted verbatim from lines 268424 and 268463. The 0.2.6228-stamped ordering sections above were not restamped; the new section records which part of their evidence (the Create -> DeserializeSave -> ValidateOnLoad order) is re-confirmed by the new body and which part (GridController.AddGridStructure) was not re-read. - 2026-07-13: added the "XmlSaveLoad.LoadWorld is async" section (game version 0.2.6403.27689) from a fresh-validation pass on a decompile-claim audit. Verbatim method head read at 268507-268516 (
public static async UniTask LoadWorld(bool loadWithoutChars = false);SetGamePause(true)is the first synchronous statement,GameState = Loadinglands only after three awaits); sole callerawait XmlSaveLoad.LoadWorld();at 264682; theGameTicktop-of-loop pause gate (204394) andSwitchToThreadPoolhandoff (204418) read the same pass. Establishes that a Harmony postfix onLoadWorldfires at load start (the async stub returns at the first suspending await) with a simulation tick possibly still in flight. Additive; no existing content contradicted. - 2026-07-18: added the "OnFinishedLoad also fires on multiplayer client join" section (game version 0.2.6403.27689); the finding was produced and independently adversarially verified against the decompile this session. Whole-decompile census:
UpdateThingsOnGameStart(204629-204632,OcclusionManager.AllThings.ForEach(UpdateThingsOnGameStartAction)) has exactly two callers,GameManager.StartGame(204580, afterGameState = Runningat 204577) and the tail ofNetworkClient.ProcessJoinData(213236, afterGameState = Runningat 213235 and followed byDevice.InitAllDevices()at 213237); the action delegate (203847-203869) callsthing.OnFinishedLoad()(203855) behind a per-thing try / catch. Records the stale-cache hazard for OnFinishedLoad-keyed restores on join. Additive; no existing content contradicted. - 2026-09-27: added "Awake runs before a Thing's saved contents are placed, on load and on join" (game version 0.2.6428.27798), read in the client and dedicated server decompiles with every cited member extracted from both and diffed (identical). Covers the writer order (
XmlSaveLoad.AddToSave, container before contents), the per-ThingCreatethenDeserializeSaveorder,InstantiatebeforeRegisterAsinsideThing.Create<T>, the child-sideMoveToParent/MoveToParentWhenReadyrestore and theAwaitingMoveWhenReadywait beforeWorld.OnLoadingFinished, the join path (NetworkServer.Serializeparent first,ProcessThingscreate then apply,DynamicThing.DeserializeOnJoinwith no wait) andThing.DeserializeNew. Two points are marked inferred (Unity engine behaviour):AwakeinsideInstantiate, and a clone's slot occupant field not being copied. Raised by a Spray Paint Plus fixture assessment that found anAwake-time slot block never sees an occupied slot; the same reading corrected./SlotInsertionBlock.md"Legacy-state handling" through the fresh-validator protocol the same day. Additive here: it refines step 1 of the per-Thing order in "OnRegistered fires before DeserializeSave" (verified at 0.2.6228.27061, not re-read in full this pass) without contradicting it. - 2026-09-27: added two bullets to "Consequences for a mod" in "Awake runs before a Thing's saved contents are placed" (game version 0.2.6428.27798): the arrival,
OnChildEnterInventory, as the other decision point (its place inMoveToSlot, its only caller apart from 81 overrides' unconditionalbasecalls, the load, join,DeserializeNew, client-update and insertion paths it covers, andDragInSlotas the exception), with Equipment Plus'sDynamicSlotsand Spray Paint Plus'sSprayGunCanArrivalPatchdeciding there, the latter under a not-in-a-release-yet marker (commit72b2db56on the local development branchphase3-b72, not yet on master; v1.12.1 leaves the can in a hidden, blocked slot); and why a decision there meets no stale inventory window on a load or a join (SetSlotsbuilds buttons only for interactable slots;InventoryManager.Initializebuilds the windows throughAssignParentonly fromHuman.SetupReferencesWhenReady, which waits forGameState.Running, andRunningcomes after every saved item is placed on both paths). Every member cited was read in the client and dedicated server decompiles and compared (textually identical). Raised by the Spray Paint Plus can-slot fix's decompile read (.work/2026-09-27-phase3-rig/M4-RESULT.mdsection 5 item 2); each point re-read here, with the override count and theDragInSlotexception added. Additive; the existing bullets are unchanged. The Spray Paint Plus behaviour is read from source, not measured in game. - 2026-09-28: the Spray Paint Plus fix this page marks landed on master as
fdcffdc5; the cited files are unchanged from the branch commits named in the entries above, so only the markers and sources were updated to cite it. Still not in a release (v1.12.1 is the published build). - 2026-09-28 (game version 0.2.6428.27798): the entry of 2026-09-27 above recorded the Spray Paint Plus behaviour in the arrival bullet as read from source and not measured. The bullet now carries the rig readings of the check
a spray gun saved with a can in it keeps the can visible and removable: on a build without the arrival hook (Release DLL sha25656763957, 2026-09-27) the saved can stayed in aBlocked, non-interactable slot after a glow-on load in single player and on a listen host, and after a join guessing glow on; onfdcffdc5's build (c0c00c18, 2026-09-28) the slot came back with the can in it in all three sessions. Added the same run to "Read and inferred" as a third corroboration thatAwakeruns before the saved contents are placed on a load: in single player and on a listen host only the mod'sAwakepostfix decides that slot on that build, and it blocked a slot the can then occupied although its guard leaves an occupied slot alone. TheInstantiateinference itself is unchanged. Additive. Section restamped; the marker stays, since the fix is not in a release.
Open questions¶
None at creation.