Skip to content

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's MoveToSlot fires OnChildEnterInventory, which for lenses unconditionally sets Sensor = child. The last chip to enter wins, overwriting any value set during deserialization. Active-slot restoration must wait for OnFinishedLoad, 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:

  1. Save-time capture. Postfix SerializeSave to write the reference-id (or index, or whatever identifies the active child) into the saved blob.
  2. Deserialize stash. Postfix DeserializeSave to read the saved value and store it in a dictionary keyed by the parent's ReferenceId. Do NOT attempt to apply it here; the children are not in their slots yet.
  3. OnFinishedLoad apply. Hook Thing.OnFinishedLoad (by whichever Harmony mechanism suits; inherited methods need TargetMethod() 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_Mode propagates via Interactable.set_State; we also call the private GetCartridge() 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 Mode is not persisted and also depends on rebuilt dynamic slots.
  • F0374: apply-side implementation including Thing.set_Mode via Interactable.set_State and the GetCartridge() 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:

  1. Thing.Create -> grid registration -> OnRegistered(cell) (prefab-default field values).
  2. DeserializeSave(thingData) (saved values applied).
  3. Child placement (OnChildEnterInventory per child).
  4. 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 LoadWorld directly. Hook something the tail of the load actually reaches (per-Thing OnFinishedLoad, or the GameState transition to Running), or take __result (the UniTask) 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) checks WorldManager.IsGamePaused only 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 before SetGamePause(true) landed runs to completion, including ElectricityManager.ElectricityTick() (204466), concurrently with the first stretch of LoadWorld and 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) sets GameState = GameState.Running; (204577) and calls UpdateThingsOnGameStart(); (204580).
  • Multiplayer client join: NetworkClient.ProcessJoinData (NetworkClient : NetworkBase at 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.

  1. 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]
  1. 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 Awake postfix 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 Awake postfix sets is already in place when the saved occupant arrives, and the occupant goes in regardless of Slot.Type. DynamicThing.MoveToSlot (306632-306697 [293025-293090]) refuses only a Thing being destroyed, a null slot, a failed CanEnter (if (!CanEnter(destinationSlot)), 306638 [293031]) or an occupied slot (306642 [293035]), and Thing.CanEnter (326562-326589 [312946-312973]) reads Slot.Type only for an entity. A slot turned into Slot.Class.Blocked in Awake therefore still receives its saved occupant, while a CanEnter postfix 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 ProcessThings on a join. Thing.OnFinishedLoad is such a point on both paths.
  • The arrival is the other decision point. DynamicThing.MoveToSlot calls the container's OnChildEnterInventory once the item is in the slot: after destinationSlot.Take(this); (306680 [293073]) and ParentSlot.RefreshSlotDisplay(); (306687 [293080]), at ParentSlot.Parent.OnChildEnterInventory(this); (306688 [293081]), and before the item's own OnEnterInventory and the slot's OnEnterEvent. Apart from the base calls 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 on Thing.OnChildEnterInventory therefore 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, "MoveToSlot and the container hooks". Equipment Plus's DynamicSlots decides at the arrival, from OnChildEnterInventory postfixes. Not in a release yet: Spray Paint Plus's SprayGunCanArrivalPatch decides there too; it is in commit fdcffdc5 on master, and ships in v1.13.0. The released v1.12.1 decides only in Awake and 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 sha256 56763957, 2026-09-27), a glow-on load left a saved can in its gun's Blocked, non-interactable slot, in single player, on a listen host and on a dedicated server's joiner that joined guessing glow on. On fdcffdc5'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.SetSlots builds a button only if (slot.IsInteractable) (247392 [234504]), IsInteractable is 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 by InventoryManager.Initialize(Entity) through InventoryWindowManager.AssignParent (292629 [279022]), and its only caller, Human.SetupReferencesWhenReady (368428-368442 [354796-354810]), started from Human.TakeControl (368950 [355318]), first waits while (GameManager.GameState != GameState.Running) (368430 [354798]). GameState becomes Running after every saved item is placed: on a load, LoadWorld waits out AwaitingMoveWhenReady (274423 [260836]) before World.OnLoadingFinished (274445 [260858]) starts GameManager.StartGame, which sets GameState = GameState.Running; (210054 [198001]); on a join, await ProcessThings(reader) (218862 [206766]) comes before GameManager.GameState = GameState.Running; (218882 [206786]) in NetworkClient.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 Awake runs inside UnityEngine.Object.Instantiate, before it returns (Unity's behaviour for an active prefab). Two things corroborate it. Thing.Create calls thing2.OnStartRender() (324932) straight after Instantiate, and that method walks Renderers, which only Thing.Awake fills (Renderers.Add(thingRenderer);, 326290). And it was measured on 2026-09-22 that a spray gun a join spawns has run its Awake postfix by the time the join payload is read, which on a joiner comes after ProcessThings (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 that Awake postfix 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 Instantiate does 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) and GridController.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 (Load plus a new generic Load<T> overload). Verified against the 0.2.6403.27689 decompile: zero grep hits for LoadThing, 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 = Loading lands only after three awaits); sole caller await XmlSaveLoad.LoadWorld(); at 264682; the GameTick top-of-loop pause gate (204394) and SwitchToThreadPool handoff (204418) read the same pass. Establishes that a Harmony postfix on LoadWorld fires 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, after GameState = Running at 204577) and the tail of NetworkClient.ProcessJoinData (213236, after GameState = Running at 213235 and followed by Device.InitAllDevices() at 213237); the action delegate (203847-203869) calls thing.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-Thing Create then DeserializeSave order, Instantiate before RegisterAs inside Thing.Create<T>, the child-side MoveToParent / MoveToParentWhenReady restore and the AwaitingMoveWhenReady wait before World.OnLoadingFinished, the join path (NetworkServer.Serialize parent first, ProcessThings create then apply, DynamicThing.DeserializeOnJoin with no wait) and Thing.DeserializeNew. Two points are marked inferred (Unity engine behaviour): Awake inside Instantiate, and a clone's slot occupant field not being copied. Raised by a Spray Paint Plus fixture assessment that found an Awake-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 in MoveToSlot, its only caller apart from 81 overrides' unconditional base calls, the load, join, DeserializeNew, client-update and insertion paths it covers, and DragInSlot as the exception), with Equipment Plus's DynamicSlots and Spray Paint Plus's SprayGunCanArrivalPatch deciding there, the latter under a not-in-a-release-yet marker (commit 72b2db56 on the local development branch phase3-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 (SetSlots builds buttons only for interactable slots; InventoryManager.Initialize builds the windows through AssignParent only from Human.SetupReferencesWhenReady, which waits for GameState.Running, and Running comes 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.md section 5 item 2); each point re-read here, with the override count and the DragInSlot exception 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 sha256 56763957, 2026-09-27) the saved can stayed in a Blocked, non-interactable slot after a glow-on load in single player and on a listen host, and after a join guessing glow on; on fdcffdc5'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 that Awake runs before the saved contents are placed on a load: in single player and on a listen host only the mod's Awake postfix decides that slot on that build, and it blocked a slot the can then occupied although its guard leaves an occupied slot alone. The Instantiate inference itself is unchanged. Additive. Section restamped; the marker stays, since the fix is not in a release.

Open questions

None at creation.