title: Save-load ordering: defer restore to OnFinishedLoad
type: Patterns
created_in: 0.2.6228.27061
verified_in: 0.2.6403.27689
verified_at: 2026-07-13
sources:
- Plans/EquipmentPlus/RESEARCH.md:250-251 (F0122, primary)
- Plans/EquipmentPlus/EquipmentPlus/ActiveSlotPersistence.cs:11-28 (F0333)
- Plans/EquipmentPlus/EquipmentPlus/AdvancedTabletPatches.cs:103-108 (F0373)
- Plans/EquipmentPlus/EquipmentPlus/ActiveSlotPersistence.cs:95-101 (F0374)
- .work/decomp/0.2.6403.27689/Assembly-CSharp.decompiled.cs :: XmlSaveLoad class (line 267663), Load
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.
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.
Open questions¶
None at creation.