Skip to content

Side-car file persistence in save ZIPs

Feasibility study for mods to write auxiliary files into the Stationeers save ZIP to persist optional mod state without custom ThingSaveData subclasses.

Core finding: read-safe, write-unsafe

  • Load: Unknown ZIP entries are preserved silently (reader only looks up known filenames by name).
  • Save: Unknown ZIP entries are stripped (SaveHelper.Save rebuilds the ZIP from scratch, known entries only).
  • Implication: Side-car persistence requires mod to write the file on every save, not just once.

SaveHelper.Save overload disambiguation (Harmony pitfall)

SaveHelper declares two methods named Save:

// Assets.Scripts.Serialization.SaveHelper
// rocketstation_Data/Managed/Assembly-CSharp.dll

public static async UniTask<SaveResult> Save(string stationName, CancellationToken cancellationToken)
private static async UniTask<SaveResult> Save(DirectoryInfo saveDirectory, string saveFileName, bool newSave, CancellationToken cancellationToken)

The public overload is a thin wrapper that flows through SaveGame(SaveMethod.Save, ...) into DoSave, which calls the private worker. Every save path funnels through the private worker:

Entry point Routing
Save(string, CancellationToken) public -> SaveGame -> DoSave -> private Save
NewSave(string, CancellationToken) SaveGame -> DoNewSave -> private Save(..., newSave: true)
SaveAs(...) SaveGame -> DoSaveAs -> private Save -> CopyToHeadSave
QuickSave(string, CancellationToken) SaveGame -> DoQuickSave -> RollingSave -> private Save
AutoSave(string, CancellationToken) SaveGame -> DoAutoSave -> RollingSave -> private Save

The private worker is where the ZipOutputStream write happens, so that is the correct Harmony target.

Harmony pitfall: [HarmonyPatch(typeof(SaveHelper), nameof(SaveHelper.Save))] throws HarmonyException: Ambiguous match for HarmonyMethod[... methodname=Save, type=Normal, args=undefined] wrapping System.Reflection.AmbiguousMatchException at PatchAll time because HarmonyX's AccessTools.DeclaredMethod cannot choose between the two overloads by name alone. Entire PatchAll call throws: no patches in the assembly apply. (Why that happens, and the per-class loop that contains it: HarmonyPatchAllIsolation.) Mods targeting either overload must specify the argument types:

[HarmonyPatch(typeof(SaveHelper), "Save",
    new[] { typeof(DirectoryInfo), typeof(string), typeof(bool), typeof(CancellationToken) })]

Use the string literal "Save" because nameof(SaveHelper.Save) resolves via the publicly visible member (the (string, CancellationToken) overload); the private overload is what callers actually want to patch for ZIP-write interception.

ZIP write path (private SaveHelper.Save)

The write path changed between 0.2.6228.27061 and 0.2.6403.27689. Both forms are recorded below; the current one first. The behavior a mod depends on is different in each, so check which build you are targeting. At 0.2.6428.27798 the current form is unchanged, in the client and the dedicated-server build alike (the whole SaveHelper class is textually identical in the two).

Current: direct write on a dedicated save thread (0.2.6403.27689, unchanged at 0.2.6428.27798)

The private Save(DirectoryInfo, string, bool, CancellationToken) worker opens the destination path directly with FileMode.Create and streams the ZIP into it. There is no temp file, no CopyToAsync and no tempFile.Delete(). Unknown entries in any pre-existing save at the destination are still never read or copied, because FileMode.Create truncates.

world_meta.xml and world.xml now stream straight in through PutNextEntry plus Serializers.*.Serialize, rather than being built into a MemoryStream and passed to AddZipFile. Terrain serialization also moved inside the save delegate. AddZipFile survives for terrain, preview and screenshot.

Before the write, in the same call. The method starts by capturing the world synchronously and unpausing the game tick, all before its first await, so on the thread that called it, which is the main thread (see "State capture at save time"):

// Assets.Scripts.Serialization.SaveHelper :: private Save(DirectoryInfo, string, bool, CancellationToken), head
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:270614-270645 (server build :257027-257058, identical)

private static async UniTask<SaveResult> Save(DirectoryInfo saveDirectory, string saveFileName, bool newSave, CancellationToken cancellationToken)
{
    _stopwatch.Restart();
    XmlSaveLoad.WorldData worldData;
    try
    {
        worldData = XmlSaveLoad.GetWorldData();
    }
    catch (System.Exception ex)
    {
        return SaveResult.Fail(ex.Message);
    }
    ConsoleWindow.Print("Saving - got world data in " + StringManager.Get(_stopwatch.ElapsedMilliseconds) + "ms");
    if (newSave)
    {
        worldData.Id = World.GenerateWorldId();
    }
    XmlSaveLoad.WorldMetaData metaData = worldData.GetMetaData(saveDirectory.Name, 0uL);
    ConsoleWindow.Print("Saving - unpausing game tick");
    GameManager.UnpauseGameTick();
    MemoryStream terrainMs = new MemoryStream();
    MemoryStream previewMs = new MemoryStream();
    MemoryStream screenShotMs = new MemoryStream();
    if (!GameManager.IsBatchMode)
    {
        _stopwatch.Restart();
        byte[] array = GameManager.CreateScreenShot(400, 400, CameraController.CurrentCamera);
        previewMs.Write(array, 0, array.Length);
        byte[] array2 = GameManager.CreateScreenShot(613, 266, CameraController.CurrentCamera);
        screenShotMs.Write(array2, 0, array2.Length);
        ConsoleWindow.Print("Saving - created preview images in " + StringManager.Get(_stopwatch.ElapsedMilliseconds) + "ms");
    }

GetWorldData() has returned, with the tick still paused, before anything is written, and the tick runs again while the writer thread streams the file. A GetWorldData that throws ends the save right there, on the calling thread, with SaveResult.Fail(ex.Message). The excerpt below continues from the next line, _stopwatch.Restart(); (270646).

// Assets.Scripts.Serialization.SaveHelper :: private Save(DirectoryInfo, string, bool, CancellationToken)
// .work/decomp/0.2.6403.27689/Assembly-CSharp.decompiled.cs:265004-265032
// Unchanged at 0.2.6428.27798: .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:270646-270674 (server build :257059-257087),
// followed by a finally that closes terrainMs, previewMs and screenShotMs

_stopwatch.Restart();
string savePath = $"{saveDirectory}/{saveFileName}";
try
{
    await RunOnSaveThread(delegate
    {
        using ZipOutputStream zipOutputStream = new ZipOutputStream(File.Open(savePath, FileMode.Create, FileAccess.Write));
        zipOutputStream.SetLevel(SaveLoadConstants.ZipCompressionLevel);
        zipOutputStream.PutNextEntry(new ZipEntry(SaveLoadConstants.MetaFileName));
        Serializers.WorldMetaData.Serialize(zipOutputStream, metaData);
        zipOutputStream.CloseEntry();
        zipOutputStream.PutNextEntry(new ZipEntry(SaveLoadConstants.WorldFileName));
        Serializers.WorldData.Serialize(zipOutputStream, worldData);
        zipOutputStream.CloseEntry();
        VoxelTerrain.Serialize(terrainMs);
        AddZipFile(zipOutputStream, terrainMs, SaveLoadConstants.TerrainFileName);
        if (!GameManager.IsBatchMode)
        {
            AddZipFile(zipOutputStream, previewMs, SaveLoadConstants.PreviewFileName);
            AddZipFile(zipOutputStream, screenShotMs, SaveLoadConstants.ScreenshotFileName);
        }
        zipOutputStream.Finish();
    });
}
catch (System.Exception arg)
{
    _stopwatch.Stop();
    return SaveResult.Fail($"Failed to write save file at path {savePath} : {arg}");
}

The write runs on an explicitly constructed thread, not the thread pool and not UniTask.SwitchToThreadPool():

// .work/decomp/0.2.6403.27689/Assembly-CSharp.decompiled.cs:265060-265080
// Unchanged at 0.2.6428.27798: .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:270702-270722 (server build :257115-257135)

private static UniTask RunOnSaveThread(Action work)
{
    UniTaskCompletionSource completionSource = new UniTaskCompletionSource();
    Thread thread = new Thread((ThreadStart)delegate
    {
        try
        {
            work();
            completionSource.TrySetResult();
        }
        catch (System.Exception exception)
        {
            completionSource.TrySetException(exception);
        }
    });
    thread.IsBackground = true;
    thread.Priority = System.Threading.ThreadPriority.BelowNormal;
    thread.Name = "Stationeers Save Writer";
    thread.Start();
    return completionSource.Task;
}

Consequences for a mod, all established from the code above:

  • Single seal point is still zipOutputStream.Finish(), now inside the delegate. Any side-car entry must be added before it.
  • When the returned task completes successfully, the file is complete and closed. The ordering is strict: the using scope ends before work() returns, TrySetResult() runs only after work() returns, and the outer state machine then does await UniTask.SwitchToMainThread(); return SaveResult.Succeed;. Nothing is still streaming at completion.
  • The write is not cancellable. cancellationToken is never passed into RunOnSaveThread, and there is no await inside the delegate, so there is no state in which the task has completed while the thread is still writing.
  • Continuations resume on the main thread, because SwitchToMainThread() is the last step before the return. A continuation that re-opens the ZIP therefore does synchronous disk IO on the Unity main thread. That holds for a successful save. A write that throws is caught by the catch around RunOnSaveThread, which returns SaveResult.Fail on the writer thread, before the switch, so the continuation of a failed write runs on the "Stationeers Save Writer" thread; a GetWorldData that throws returns before any await, on the calling thread.
  • A failed write leaves a truncated file on disk. FileMode.Create truncates before writing, and the failure path returns SaveResult.Fail with the partial file present. Gate on result.Success.
  • File.Open(savePath, FileMode.Create, FileAccess.Write) uses FileShare.None, so a mod re-opening the file while the writer holds it fails loudly rather than corrupting anything. Read in the game's own mscorlib.dll (byte-identical in both installs) at 0.2.6428.27798: public static FileStream Open(string path, FileMode mode, FileAccess access) { return Open(path, mode, access, FileShare.None); }.
  • There is no Flush(true) or fsync anywhere in the path. Content is visible to any reader once disposal returns, but survival across a power loss at that instant is not guaranteed by this code.

ZipOutputStream.IsStreamOwner is not set here, so it takes the library default. The strong in-file evidence that the default is true (and therefore that the FileStream is released on disposal) is the Workshop-upload path at :268223-268226, which explicitly writes new ZipOutputStream(zipMs) { IsStreamOwner = false } to keep its MemoryStream alive. ICSharpCode.SharpZipLib is not in the decompile, so this is inference rather than a direct read.

At 0.2.6428.27798 it is a direct read. The game's ICSharpCode.SharpZipLib.dll (0.86.0.518), decompiled to .work/decomp/0.2.6428.27798/ICSharpCode.SharpZipLib.decompiled.cs, declares private bool isStreamOwner_ = true; on DeflaterOutputStream (4688), the base of ZipOutputStream, and its Close() (4893-4917) runs Finish() and then, in a finally, if (isStreamOwner_) { baseOutputStream_.Close(); }. Disposing the ZipOutputStream therefore closes the FileStream. The Workshop-upload path that sets IsStreamOwner = false is at :273869-273872 in this build.

Superseded: temp file plus copy (0.2.6228.27061)

Retained because it is correct for that build. In this form the worker created a temporary file, wrote all known entries to a ZipOutputStream, then copied it to the final location and deleted the temp file.

Uses ZipOutputStream from ICSharpCode.SharpZipLib (not System.IO.Compression.ZipArchive). Only writes five known entries: world_meta.xml, world.xml, terrain.dat, preview.png, screenshot.png. No archive open or re-open in ZipArchiveMode.Update, a completely fresh write to temp file.

Verbatim excerpt (relevant portion):

// Assets.Scripts.Serialization.SaveHelper :: Save method
// rocketstation_Data/Managed/Assembly-CSharp.dll

_stopwatch.Restart();
await UniTask.SwitchToThreadPool();
FileInfo tempFile = new FileInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
try
{
    await using ZipOutputStream zipStream = new ZipOutputStream(tempFile.OpenWrite());
    zipStream.SetLevel(SaveLoadConstants.ZipCompressionLevel);
    AddZipFile(zipStream, metaMs, SaveLoadConstants.MetaFileName);
    AddZipFile(zipStream, worldMs, SaveLoadConstants.WorldFileName);
    AddZipFile(zipStream, terrainMs, SaveLoadConstants.TerrainFileName);
    if (!GameManager.IsBatchMode)
    {
        AddZipFile(zipStream, previewMs, SaveLoadConstants.PreviewFileName);
        AddZipFile(zipStream, screenShotMs, SaveLoadConstants.ScreenshotFileName);
    }
    zipStream.Finish();
    await zipStream.FlushAsync(cancellationToken);
}
catch (Exception arg)
{
    _stopwatch.Stop();
    return SaveResult.Fail($"Failed to write to temp file at path {tempFile} : {arg}");
}
finally
{
    metaMs.Close();
    worldMs.Close();
    terrainMs.Close();
    previewMs.Close();
    screenShotMs.Close();
}
FileStream copyStream = tempFile.OpenRead();
try
{
    await using FileStream destinationStream = File.Open($"{saveDirectory}/{saveFileName}", FileMode.Create, FileAccess.Write);
    await copyStream.CopyToAsync(destinationStream, cancellationToken);
}
catch (Exception ex2)
{
    _stopwatch.Stop();
    return SaveResult.Fail(ex2.Message);
}
copyStream.Close();
tempFile.Delete();

Single seal point: zipStream.Finish() followed by zipStream.FlushAsync(). Any side-car entry must be added before zipStream.Finish() is called.

Load-time ZIP extraction (LoadHelper.ExtractToTemp)

By the time XmlSaveLoad.LoadWorld runs, the save ZIP is already fully extracted to a temp directory. The game does NOT open the save ZIP at LoadWorld time. LoadHelper.LoadGameTask calls ExtractToTemp(path) first, which uses ZipInputStream to enumerate every entry via GetNextEntry() and writes each to a temp directory as a loose file.

Every saved-world load runs through LoadGameTask. Its only caller is LoadHelper.LoadGame(path, stationName), whose seven callers are the save list (SaveItem.LoadSave, WorkshopSaveItem.LoadButtonClicked) and the console commands (LoadGameCommand.LoadGame, the path the dedicated server's -load flag drives, see "Save folder layout" below; LoadLatestCommand.FindAndLoadLatestGame; FileCommand.HandleStart, FileCommand.LoadAtFullPath and FileCommand.HandleLoadLatest). XmlSaveLoad.LoadWorld has exactly one caller, LoadWorldTask, and LoadWorldTask has exactly one, LoadGameTask. Starting a new world (World.StartNewWorld) and joining a server do not come through here.

The whole method set, including the catch block that the 0.2.6228.27061 excerpt on this page left out, is below. It is textually identical in the client and dedicated-server builds of 0.2.6428.27798.

// Assets.Scripts.Serialization.LoadHelper
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:270146-270177 (LoadGame, LoadGameTask), :270322-270344 (LoadWorldTask, ExtractToTemp)
// Identical in .work/decomp/0.2.6428.27798/Assembly-CSharp.DedicatedServer.decompiled.cs:256559-256590 and :256735-256757

public static void LoadGame(string path, string stationName)
{
    LoadGameTask(path, stationName).Forget();
}

private static async UniTaskVoid LoadGameTask(string path, string stationName)
{
    try
    {
        string text = ExtractToTemp(path);
        StationSaveContainer currentWorldSave = new StationSaveContainer(new DirectoryInfo(text));
        XmlSaveLoad.Instance.CurrentWorldSave = currentWorldSave;
        Assets.Scripts.UI.MainMenu.Instance.SetActive(active: false);
        ImGuiLoadingScreen.SetActive(active: true);
        HelperHintsTextController.RefreshDisplayState(isLoadingWorld: true);
        XmlSaveLoad.IsReadyToPlayWorldAudio = false;
        XmlSaveLoad.Instance.CurrentStationName = stationName;
        await LoadWorldTask(text);
    }
    catch (System.Exception exception)
    {
        ConsoleWindow.PrintError(exception).Forget();
        XmlSaveLoad.Instance.CurrentWorldSave = null;
        XmlSaveLoad.Instance.CurrentStationName = null;
        XmlSaveLoad.IsReadyToPlayWorldAudio = false;
        Singleton<ConfirmationPanel>.Instance.Show("FailedToStartGameMessageTitle", "FailedToStartGameMessageText", "ButtonExit", delegate
        {
            GameManager.LeaveGame();
            FadePanel.ToTransparentInstant();
        });
    }
}

private static async UniTask LoadWorldTask(string tempDir)
{
    await XmlSaveLoad.LoadWorld();
    Directory.Delete(tempDir, recursive: true);
}

private static string ExtractToTemp(string path)
{
    string text = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
    Directory.CreateDirectory(text);
    using ZipInputStream zipInputStream = new ZipInputStream(File.OpenRead(path));
    while (true)
    {
        ZipEntry nextEntry = zipInputStream.GetNextEntry();
        if (nextEntry == null)
        {
            break;
        }
        using FileStream destination = File.Create(Path.Combine(text, Path.GetFileName(nextEntry.Name)));
        zipInputStream.CopyTo(destination);
    }
    return text;
}

Critical implications for mod authors:

  • Every ZIP entry is extracted, known and unknown alike. Filenames not in the vanilla set (world.xml, world_meta.xml, terrain.dat, preview.png, screenshot.png) still land in the temp directory as loose files. A side-car entry named sprayplus-glow.xml is extracted to <tempDir>/sprayplus-glow.xml.
  • The save ZIP is closed before LoadWorld runs. Any Harmony patch that tries to re-open the save ZIP during LoadWorld or Thing.OnFinishedLoad will race the file lock and may hit "End of Central Directory record could not be found" if the path is stale.
  • Side-car reads at load time should read the loose temp file, not re-open the save ZIP. Compute the temp directory as Path.GetDirectoryName(XmlSaveLoad.Instance.CurrentWorldSave.World.FullName) and open <tempDir>/<entryName> directly.
  • Filename collision is possible. ExtractToTemp uses Path.GetFileName(nextEntry.Name) so nested directories in the ZIP are flattened. Keep side-car entry names distinct from the five vanilla filenames.
  • The extraction directory lives only as long as the load. LoadWorldTask deletes it with Directory.Delete(tempDir, recursive: true) as soon as await XmlSaveLoad.LoadWorld() returns, so a side-car must be read before LoadWorld completes. The Thing.OnFinishedLoad sweep still qualifies: it runs synchronously inside LoadWorld (see "Thing.OnFinishedLoad timing and caller"). A load that throws skips the delete: text is scoped to the try and there is no finally, so the directory is left behind under Path.GetTempPath().

When the load throws: the catch block

Any exception that propagates out of the try above ends the load. The catch does not rethrow. In order, it reports the exception on the console, clears XmlSaveLoad.Instance.CurrentWorldSave and XmlSaveLoad.Instance.CurrentStationName, sets XmlSaveLoad.IsReadyToPlayWorldAudio = false, and shows the failed-to-start dialog.

What it prints. ConsoleWindow.PrintError(System.Exception) and the string overload it calls:

// Assets.Scripts.ConsoleWindow
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:228575-228592
// Identical in .work/decomp/0.2.6428.27798/Assembly-CSharp.DedicatedServer.decompiled.cs:216057-216074

public static async UniTaskVoid PrintError(System.Exception exception)
{
    if (GameManager.IsThread)
    {
        await UniTask.SwitchToMainThread();
    }
    PrintError("Exception: " + exception.Message);
    Print(exception.StackTrace);
}

public static void PrintError(string output, bool suppressStacktrace = false)
{
    Print(output, ConsoleColor.Red, clearLine: false, aged: false);
    if (!suppressStacktrace)
    {
        Print(Environment.StackTrace, ConsoleColor.Gray);
    }
}

So a failed load makes three ConsoleWindow.Print calls, in this order (aged is inverted from its name; see ConsoleWindow):

Order Text Colour aged
1 "Exception: " + exception.Message Red false, so a client also shows it on the closed-console overlay
2 Environment.StackTrace, taken inside PrintError(string, bool): the stack of the reporting call in the catch, not the stack of the throw Gray true, open console only
3 exception.StackTrace: the stack the exception carries White true, open console only

Consequences read off that code:

  • Each Print is one entry. On a client a multi-line string still takes one ring-buffer slot, because ConsoleLine.Set (227172-227198) splits it into continuation lines of a single ConsoleLine.
  • Only Message and StackTrace are printed, never exception.ToString(), so this path shows no inner exception. For a TypeInitializationException the entry names the type whose initializer failed (The type initializer for '<type>' threw an exception.) but not the exception that made it fail.
  • On the main thread GameManager.IsThread (MainThreadId != Thread.CurrentThread.ManagedThreadId, 209425) is false, PrintError(System.Exception) never suspends, and all three entries are written before the catch reaches Show. Off the main thread the three prints wait for UniTask.SwitchToMainThread(), and the rest of the catch does not wait for them.

The dialog. The type is UI.ConfirmationPanel (public class ConfirmationPanel : Singleton<ConfirmationPanel>, IModal, client 104530, server 103876; the base is Assets.Scripts.Util.Singleton<T>), and the method is this overload:

// UI.ConfirmationPanel
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:104586-104589 (server build :103932, identical)

public void Show(string titleKey, string messageKey, string button1Key = null, UnityAction button1OnClick = null, string button2Key = null, UnityAction button2OnClick = null, string button3Key = null, UnityAction button3OnClick = null, bool closeOnEscape = true)
{
    ShowRaw(GetInterfaceOrNull(titleKey), GetInterfaceOrNull(messageKey), GetInterfaceOrNull(button1Key), button1OnClick, GetInterfaceOrNull(button2Key), button2OnClick, GetInterfaceOrNull(button3Key), button3OnClick, closeOnEscape);
}

The keys resolve through Localization.GetInterface. In rocketstation_Data/StreamingAssets/Language/english.xml (<Interface> section) they read:

Key English text
FailedToStartGameMessageTitle Failed to start game
FailedToStartGameMessageText There was an error while starting the game. Check the console (F3) for a full log.
ButtonExit Exit

Exit is the only button. SetUpPanel hands it new UnityAction[2] { CloseCurrentPanel, data.Button1OnClick }, which SetOnClickCallbacks adds as click listeners in that order, so a click closes the panel and then runs the catch's delegate: GameManager.LeaveGame(), then FadePanel.ToTransparentInstant().

Which failures reach the catch.

  • Anything thrown by ExtractToTemp (a missing or unreadable save file, a corrupt ZIP), by the setup lines, or anywhere in the awaited XmlSaveLoad.LoadWorld (client 274151-274450; textually identical in the server build at 260564-260863). LoadWorld has no try/catch of its own. Its three explicit throws are all NullReferenceException: "Failed to load the world.xml: " + fullName (274170), "error unable to load as world setting for '" + worldData.WorldSetting.Id + "' is not found" (274181) and "No terrainSettings data found" (274203). An exception out of a mod's patch on something LoadWorld calls or awaits lands here too, unless something on the way catches it (next two bullets).
  • Not an exception from a Thing.OnFinishedLoad override or a patch on it. GameManager.UpdateThingsOnGameStartAction catches it per Thing, prints OnFinishedLoad failed for <DisplayName> #<ReferenceId>: <exception> through PrintError(string), and the sweep moves on to the next Thing (see "Thing.OnFinishedLoad timing and caller").
  • Not anything inside a task the load starts and forgets. World.OnLoadingFinished (330903-330913), called near the end of LoadWorld (274445), runs GameManager.StartGame().Forget(), so an exception inside StartGame stays in that forgotten task.
  • Not the terrain-checksum abort, which is no exception at all. When VoxelTerrain.IsChecksumValid(worldData.TerrainChunkChecksums) is false, LoadWorld awaits PromptPanel.Instance.AwaitShowPrompt(GameStrings.StartGameFailurePromptTitle, GameStrings.SaveInvalidTerrainDataModified, GameStrings.ResetTerrainButton, FlagTerrainReset, GameStrings.CancelLoadButton, GameManager.LeaveGame, isEscapable: false) and, unless the player chose the reset, returns normally (274207-274215). That abort prints no Exception: entry, shows no failed-to-start dialog, and LoadWorldTask deletes the extraction directory as it does on success.

The catch can run after the world is already marked running. World.OnLoadingFinished calls GameManager.StartGame().Forget(), and the part of StartGame before its first await sets GameState = GameState.Running (210054). LoadWorld then still runs OrbitalSimulation.DeserializeSave(worldData) (the last statement of OnLoadingFinished, 330912), WorldLog.Load(worldData.WorldEventData) and SharedDLCManager.HostFinishedLoad() (274446-274447), and a final ConsoleWindow.Print. An exception from one of those reaches this catch with GameState already Running, so a failed load and a running world are not mutually exclusive.

Client versus dedicated server. Everything quoted above, plus ConsoleWindow.Print, the whole ConfirmationPanel class and Singleton<T>, is textually identical in Assembly-CSharp.decompiled.cs and Assembly-CSharp.DedicatedServer.decompiled.cs (diffed). What differs is where the entries land and what the dialog can do. Both follow from GameManager.IsBatchMode, which GameManager.SetMatchMode (client 209767-209780, server 197714-197727) sets when Application.isBatchMode is true or the platform is LinuxServer or WindowsServer:

  • Client (IsBatchMode false): the three entries go into the in-game console's ring buffer (ConsoleWindow.ConsoleBuffer) and nowhere else. The non-batch branch of the game's Print writes no Unity log line, so the player log does not carry them. The dialog is drawn and waits for Exit.
  • Dedicated server (IsBatchMode true; observed at 0.2.6428.27798, where a dedicated server's Unity log carries console lines in the HH:mm:ss: <text> form only the batch branch writes): Print never touches the ring buffer and prefixes each entry with HH:mm:ss:. With -logFile on the command line, entry 1 goes to UnityEngine.Debug.LogError and entries 2 and 3 to UnityEngine.Debug.Log. Without it, RocketSystemConsole.PrintToConsole (server 107430-107446) writes standard output with one System.Console.WriteLine per physical line, so only the first line of each entry carries the timestamp. Either way the report's first line reads HH:mm:ss: Exception: <message>. Routing details are on ConsoleWindow.
  • The dialog call is made on the server as well. AnnounceMessage.Display (client 284275-284282) checks !GameManager.IsBatchMode && (bool)Singleton<ConfirmationPanel>.Instance before its own Show; this catch has no such guard. What the call does on a headless process is not established; see Open questions.

Why this matters to a reader. A saved-world load that an exception ends gets exactly one entry from this catch of the form Exception: <message>, where <message> is the Message of the exception that ended it, followed by the two stack-trace entries and, on a client, the Failed to start game dialog. A tool watching the console can key on that entry to tell why a load failed. Four limits apply when keying on it:

  • The prefix is not unique to world loads. ConsoleWindow.PrintError(System.Exception) has 21 call sites in the client build and 17 in the server build at 0.2.6428.27798, LoadGameTask among them, so an Exception: entry alone does not say that a load failed.
  • Within the game's own code, a console entry that begins with Exception: does come from PrintError(System.Exception). Three other catch blocks build an "Exception: " + ex.Message string, LightManager.ThreadedWork (213894), ElectricityManager.ElectricityTick (277746) and LogicStack.LogicStackTick (411358), but the first two hand it to UnityEngine.Debug.LogError, which a client's log bridge re-prints lower-cased behind [ERROR], and the third prints it behind error in logic stack tick:. PrintError output never reaches a client's player log, so an Exception: line found there is not this entry (those two Debug.LogError strings are one source of such lines). On a dedicated server started with -logFile the two strings reach the Unity log as written, without the HH:mm:ss: prefix that PrintError output carries there.
  • A failure while creating a new world never produces the entry (below).
  • On a dedicated server the entry carries the timestamp prefix, so match by substring.

New worlds fail differently. Assets.Scripts.Objects.World.StartNewWorld (client 330834-330861, server 317218-317245, identical) has its own catch:

// Assets.Scripts.Objects.World :: StartNewWorld (try, catch and dialog)
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:330842-330860

try
{
    await NewAsync(worldName, _cancellation.Token);
    KeyManager.ResetKeyStateToDefault();
    ConsoleWindow.Print("World " + worldName + " created");
}
catch (System.Exception exception)
{
    UnityEngine.Debug.LogException(exception);
    isSuccessful = false;
}
if (!isSuccessful)
{
    Singleton<ConfirmationPanel>.Instance.Show("FailedToLoadGameMessageTitle", "FailedToLoadGameMessageText", "ButtonExit", delegate
    {
        GameManager.LeaveGame();
        FadePanel.ToTransparentInstant();
    });
}

It reports through UnityEngine.Debug.LogException, not ConsoleWindow.PrintError, so there is no Exception: entry; on a client the log bridge re-prints it lower-cased behind a bracketed level tag (see ConsoleWindow). Its dialog keys read Failed to load game and There was an error while loading the game. Check the console (F3) for a full log. The key names are the reverse of what the paths do: loading a save uses FailedToStartGame*, creating a world uses FailedToLoadGame*.

ZIP read path (LoadWorld)

LoadWorld reads world.xml as a plain file from the temp directory populated by ExtractToTemp. It never touches a ZipArchive or ZipInputStream.

// Assets.Scripts.Serialization.XmlSaveLoad :: LoadWorld (key excerpt)
// rocketstation_Data/Managed/Assembly-CSharp.dll

string fullName = Instance.CurrentWorldSave.World.FullName;
object obj = XmlSerialization.Deserialize(Serializers.WorldData, fullName);
if (!(obj is WorldData worldData))
{
    UpdateLoadingScreen(display: false);
    throw new NullReferenceException("Failed to load the world.xml: " + fullName);
}

fullName is <tempDir>/world.xml, a loose XML file on disk. XmlSerialization.Deserialize(XmlSerializer, string path) opens it with new StreamReader(path); no ZIP involvement. StationSaveContainer.World is a FileInfo populated from the temp directory's contents:

// Assets.Scripts.Serialization.StationSaveContainer (constructor body)
// rocketstation_Data/Managed/Assembly-CSharp.dll

World = files.FirstOrDefault((FileInfo x) => x.Name == "world.xml");

Thing.OnFinishedLoad timing and caller

The caller differs from the form recorded at 0.2.6228.27061. Both forms are recorded below, the current one first. Thing.OnFinishedLoad itself is unchanged: the body quoted below matches the 0.2.6428.27798 decompile (.work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:325477-325494) line for line, and so does GameManager.UpdateThingsOnGameStart() (:210106-210109).

Method signature:

// Assets.Scripts.Objects.Thing :: OnFinishedLoad
// rocketstation_Data/Managed/Assembly-CSharp.dll

public virtual void OnFinishedLoad()
{
    if (!IsCursor)
    {
        if (InternalAtmosphere == null && GameManager.RunSimulation)
        {
            InitInternalAtmosphere();
        }
        if (!GameManager.RunSimulation)
        {
            RefreshAnimState(skipAnimation: true);
        }
        if (this is IRotatable rotatable)
        {
            rotatable.RotatableBehaviour?.OnClientStart();
        }
        SuppressSound = false;
    }
}

Current: per-Thing try/catch, after OnReadyToPlay (0.2.6428.27798)

Caller: GameManager.UpdateThingsOnGameStartAction, invoked from GameManager.UpdateThingsOnGameStart(). Each Thing now runs behind its own try/catch:

// Assets.Scripts.GameManager
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:209323-209345 (UpdateThingsOnGameStartAction), :210106-210109 (UpdateThingsOnGameStart)
// Identical in .work/decomp/0.2.6428.27798/Assembly-CSharp.DedicatedServer.decompiled.cs:197270-197292 and :198063-198066

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}");
    }
};

public static void UpdateThingsOnGameStart()
{
    OcclusionManager.AllThings.ForEach(UpdateThingsOnGameStartAction);
}

The ForEach around it has no catch of its own: OcclusionManager.AllThings is a ConcurrentDensePool<Thing> (220711) whose ForEach takes a lock and calls DensePool<T>.ForEach (234876-234882), a plain for loop over the active entries (234482-234495).

On the saved-world load path the sweep runs from the tail of XmlSaveLoad.LoadWorld, which calls GameManager.OnReadyToPlay() first and World.OnLoadingFinished(worldData) after it:

// Assets.Scripts.Serialization.XmlSaveLoad :: LoadWorld (tail)
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:274443-274449 (server build :260856-260862, same order)

GameManager.OnReadyToPlay();
WorldManager.Instance?.ResumePlay();
World.OnLoadingFinished(worldData);
WorldLog.Load(worldData.WorldEventData);
SharedDLCManager.HostFinishedLoad();
loadTimer.Stop();
ConsoleWindow.Print("loaded '" + worldData.WorldSetting.Id + "' " + StringManager.Get(worldData.GetThings().Count) + " things in " + StringManager.Get(overallLoadTime.ElapsedMilliseconds) + "ms");
// Assets.Scripts.Objects.World :: OnLoadingFinished
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:330903-330913 (server build :317287, identical)

public static void OnLoadingFinished(XmlSaveLoad.WorldData worldData)
{
    HelperHintsManager.Initialize();
    WorldManager.StartWorld();
    GameManager.StartGame().Forget();
    if (!GameManager.IsBatchMode)
    {
        ImGuiLoadingScreen.SetActive(active: false);
    }
    OrbitalSimulation.DeserializeSave(worldData);
}
// Assets.Scripts.GameManager :: StartGame (head)
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:210052-210057 (server build :197999-198004, same head)

public static async UniTask StartGame()
{
    GameState = GameState.Running;
    GameTime = Time.time;
    HelperHintsTextController.InitializePanel();
    UpdateThingsOnGameStart();
    // ... first await: await NetworkServer.Host(); at 210083 (server 198030)

StartGame's first await comes after the sweep, and UniTask's AsyncUniTaskMethodBuilder.Start runs MoveNext synchronously (read from its IL in both installs' UniTask.dll by the 2026-09-25 validator), so the sweep completes inside the StartGame() call, inside World.OnLoadingFinished, inside LoadWorld. The dedicated-server build's StartGame differs only after that point: it ends with DelayedStartupPause().Forget() (198046), which pauses the world 5 s later if no client has connected.

Timing guarantee (current): OnFinishedLoad() is called AFTER all ThingSaveData deserialized, child-parent relationships established, atmospheres loaded, and devices initialized (Device.AllDevices.ForEach(Device.InitializeDeviceAction), 274373), AFTER GameManager.OnReadyToPlay(), with GameState already Running, and before LoadWorld completes, so before LoadWorldTask deletes the extraction directory. Consequences:

  • An exception from one Thing's OnFinishedLoad, or from the interactable sync after it, is printed as OnFinishedLoad failed for <DisplayName> #<ReferenceId>: <exception> and the sweep moves on to the next Thing. It does not end the load and never reaches LoadGameTask's catch (see "When the load throws: the catch block"). Because the same try holds the interactable sync, that Thing's JoinInProgressSync interactables then get no SetState or OnFinishedInteractionSync.
  • Only an exception thrown by the catch handler itself (for example from the thing.DisplayName getter) leaves the sweep. It would abort the rest of the sweep and the rest of StartGame's synchronous part, from StructureNetwork.StructureNetworksOnFinishedLoad() on, and it still would not reach LoadGameTask's catch, because StartGame is called with .Forget() and an async method stores its exception on its task.
  • OrbitalSimulation.DeserializeSave(worldData), WorldLog.Load and SharedDLCManager.HostFinishedLoad() run after the sweep, so an OnFinishedLoad override cannot rely on them.
  • The multiplayer-join caller, NetworkClient.ProcessJoinData, also calls GameManager.OnReadyToPlay() before the sweep (218880-218883); see SaveLoadOrdering.

Superseded: no try/catch (0.2.6228.27061)

Retained as recorded for that build. No decompile of 0.2.6228.27061 is on disk, so neither the missing try/catch nor the ordering against GameManager.OnReadyToPlay() below could be re-checked against that build; at 0.2.6428.27798 both are superseded by the section above.

Caller: GameManager.UpdateThingsOnGameStartAction, invoked from GameManager.UpdateThingsOnGameStart() at the end of load:

// Assets.Scripts.GameManager
// rocketstation_Data/Managed/Assembly-CSharp.dll

private static readonly Action<Thing> UpdateThingsOnGameStartAction = delegate(Thing thing)
{
    if ((object)thing == null)
    {
        return;
    }
    thing.OnFinishedLoad();
    foreach (Interactable interactable in thing.Interactables)
    {
        if (interactable.JoinInProgressSync && (bool)interactable.Animator)
        {
            interactable.SetState();
            thing.OnFinishedInteractionSync(interactable);
        }
    }
};

public static void UpdateThingsOnGameStart()
{
    OcclusionManager.AllThings.ForEach(UpdateThingsOnGameStartAction);
}

Timing guarantee: OnFinishedLoad() is called AFTER all ThingSaveData deserialized, child-parent relationships established, atmospheres loaded, and devices initialized. This occurs near the end of LoadWorld before GameManager.OnReadyToPlay().

Auto-save trigger and multiplayer guard

Auto-save is triggered by AutoSaveNow() with a strict multiplayer guard. The declaring type moved: at 0.2.6228.27061 it was GameManager.AutoSaveNow; at 0.2.6403.27689 it is StationAutoSave.AutoSaveNow (Assembly-CSharp.decompiled.cs:267612), which StationAutoSave.md documents. The body below is the 0.2.6228.27061 form and the guard logic is unchanged; re-verify against StationAutoSave before patching on the current build. Note also that there are two autosave drivers at the current version, the timer-driven one here and NetworkBase.AutoSaveOnLastClientLeave (:39256), and neither emits a save-confirmation console line (see SaveConsoleProtocol.md).

// Assets.Scripts.GameManager :: AutoSaveNow
// rocketstation_Data/Managed/Assembly-CSharp.dll

private static void AutoSaveNow()
{
    if (!GameManager.IsTutorial && !GameManager.IsNewTutorial && !WorldManager.IsGamePaused && GameManager.GameState == GameState.Running && !Assets.Scripts.Networking.NetworkManager.IsClient)
    {
        AutoSaveTask().Forget();
    }
}

private static async UniTaskVoid AutoSaveTask()
{
    SaveResult saveResult = await SaveHelper.AutoSave(XmlSaveLoad.Instance.CurrentStationName, default(CancellationToken));
    if (!saveResult.Success)
    {
        ConsoleWindow.PrintError(saveResult.Message);
    }
}

Critical guard: !Assets.Scripts.Networking.NetworkManager.IsClient

This ensures auto-save only runs on the host/server, never on clients. Auto-save uses the same SaveHelper.AutoSave() → SaveHelper.Save() code path as manual saves.

Save-copy behavior and unknown entry handling

When the player uses "Save As" in the in-game UI, the game calls SaveHelper.SaveAs(), which invokes SaveHelper.Save() (creating a fresh archive with no unknown entries) and then copies that file to the head location. Unknown entries in the original save are already gone after Save().

Rename behavior uses Directory.MoveTo() without touching the save file contents, so unknown entries in existing saves are preserved during station rename.

LaunchPadBooster extension hooks

Decompilation of LaunchPadBooster.dll reveals no save/load hooks or extension points for ZIP entry injection. The DLL provides world-search, mod management, and networking utilities, but does not expose OnSaveWorld, OnLoadWorld, ISaveExtension, or any Action<ZipArchive> event.

Mod.AddSaveDataType<T>() (from StationeersLaunchPad) registers only ThingSaveData subclasses in XmlSaveLoad.ExtraTypes. There is no public API for direct ZIP entry injection.

Required workaround: a Harmony patch on the private SaveHelper.Save; see "Where a side-car can be added (0.2.6428.27798)" below.

Where a side-car can be added (0.2.6428.27798)

LaunchPadBooster still offers no save hook. The installed LaunchPadBooster.dll (0.2.0, identical on both installs) has no reference to ZipArchive, ZipOutputStream or SaveHelper; its one save-related member is Mod.AddSaveDataType<T>() (.work/decomp/0.2.6428.27798/LaunchPadBooster.decompiled.cs:154-158), whose SaveDataPatch prefixes XmlSaveLoad.AddExtraTypes to append every mod's SaveDataTypes (418-449; see SaveDataRegistration).

So a side-car needs a Harmony patch on the private SaveHelper.Save (with the argument-typed attribute from "SaveHelper.Save overload disambiguation"), and there are two places it can put the entry:

  • Inside the write, before zipOutputStream.Finish(). The single seal point is inside the RunOnSaveThread delegate (see "ZIP write path"). No mod in this repository does this, and it has not been built or measured here.
  • After the returned task completes, by wrapping __result and reopening the finished file in ZipArchiveMode.Update (the recipe in "Harmony interception strategy"). There is no temp file to reopen: at 0.2.6403.27689 and 0.2.6428.27798 the writer writes the destination directly, and at 0.2.6228.27061 the temp file was deleted before the task completed. Every Update-mode open rewrites the whole save, so when there is nothing to store, probe the save read-only before opening it in Update mode (see "Reopening a save in ZipArchiveMode.Update rewrites every entry").

State capture at save time

The capture changed between 0.2.6228.27061 and 0.2.6428.27798, and one line recorded for the older build was wrong for it as well. Both forms are below, the current one first.

Current: captured on the main thread with the tick paused (0.2.6428.27798)

Every save entry point (SaveHelper.Save(string, CancellationToken), NewSave, SaveAs, QuickSave, AutoSave) runs SaveHelper.SaveGame (270477-270505), which awaits PrepareToSave before anything else:

// Assets.Scripts.Serialization.SaveHelper :: PrepareToSave
// .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs:270585-270604 (server build :256998-257017, identical)

private static async UniTask<SaveResult> PrepareToSave(CancellationToken cancellationToken)
{
    if (GameManager.GameState != GameState.Running && GameManager.GameState != GameState.Paused)
    {
        return SaveResult.Fail("Save Failed: Game must be running to save");
    }
    IsSaving = true;
    _stopwatch.Restart();
    GameManager.PauseGameTick();
    LiquidSolver.Instance.ReturnLiquidsAndReset();
    SpinnerPannel.Instance.ShowSpinner(SpinnerPannelStrings.Saving);
    await UniTask.WaitUntil(() => GameManager.GameTickPaused, PlayerLoopTiming.Update, cancellationToken);
    ConsoleWindow.Print("Saving - game tick paused in " + StringManager.Get(_stopwatch.ElapsedMilliseconds) + "ms");
    _stopwatch.Restart();
    await UniTask.SwitchToThreadPool();
    AtmosphericsController.PreSaveCleanup();
    await UniTask.SwitchToMainThread();
    ConsoleWindow.Print("Saving - atmospheres cleaned up in " + StringManager.Get(_stopwatch.ElapsedMilliseconds) + "ms");
    return SaveResult.Succeed;
}

PauseGameTick() only schedules the pause (_gameTickPauseScheduled = true, 209847-209853); the GameManager.GameTick loop (209864-209999) sets GameTickPaused when it next reaches its pause check, and PrepareToSave waits for that. The one thread-pool hop is for AtmosphericsController.PreSaveCleanup(), and PrepareToSave switches back to the main thread before it returns. SaveGame then calls DoSave, DoNewSave, DoSaveAs or, through DoQuickSave and DoAutoSave, RollingSave (270507-270583), and each calls the private Save(DirectoryInfo, string, bool, CancellationToken) directly. A UniTask builder's Start runs the state machine's MoveNext synchronously (see "Thing.OnFinishedLoad timing and caller"). So:

  • A Harmony prefix on the private Save, and the method's body up to its first await, run on the Unity main thread with the game tick paused.
  • XmlSaveLoad.GetWorldData() runs synchronously in that same call, right after the prefix (the head of the method is quoted under "Current: direct write on a dedicated save thread"). It builds a WorldData object in memory and walks the live Things with DensePool<Thing>.ActiveEnumerable.Enumerator enumerator2 = OcclusionManager.AllThings.Active().GetEnumerator(); (273965; GetWorldData is 273945-274026, server build from 260358), calling AddToSave for each.
  • The tick is unpaused before the write. GameManager.UnpauseGameTick() (270633) runs after GetWorldData and before the first await. The preview screenshots are then taken on the main thread, and the file is written on the "Stationeers Save Writer" thread while the game tick runs and the main thread carries on, unless the world itself is paused (the tick loop also waits while WorldManager.IsGamePaused, 209871). SavingFinished (270606-270612), which SaveGame starts with .Forget() once the Do* call returns, unpauses again and clears IsSaving.
  • Nothing is serialized before the write. world_meta.xml and world.xml are serialized from the in-memory WorldData straight into the ZipOutputStream on the writer thread, and terrain into a MemoryStream on the same thread (the write excerpt under "ZIP write path").
  • All mod state is accessible from the prefix, which runs on the main thread.

What follows for a mod: state captured in a prefix on the private Save matches world.xml, because both are read from the same paused world in the same main-thread call. State read any later, on the writer thread or in a continuation of the returned task, can already differ, since the tick and the main thread run again from UnpauseGameTick() on. That is why the recipe in "Harmony interception strategy" takes its snapshot in the prefix.

Superseded: serialization on the thread pool (0.2.6228.27061)

Retained as recorded for that build, with the third bullet corrected. The 2026-09-25 fresh validator re-read these points in a 0.2.6228.27061 decompile left in the workspace (.work/decompile/configcartridge/, outside the canonical decompile path): the first and fourth bullets held for that build and the third did not (its original wording is in Verification history); the second is a general statement that holds for both builds.

At SaveHelper.Save invocation: - XmlSaveLoad.GetWorldData() enumerates all Things via OcclusionManager.AllThings.AsPooledSpan() - All mod state (e.g., GlowPaintHelpers.GlowingThingIds) is fully populated and accessible - Main thread context, with the game tick paused: PrepareToSave paused it before Save was called, and Save unpaused it only after GetWorldData (corrected 2026-09-25) - Serialization to MemoryStream is synchronous on thread pool before ZipOutputStream finalization

Reopening a save in ZipArchiveMode.Update rewrites every entry

A side-car written after the save (the recipe in "Harmony interception strategy" below) reopens the finished .save with System.IO.Compression.ZipArchive in ZipArchiveMode.Update. In the game's own copy of that library, disposing an Update-mode archive truncates the file and writes every entry back, whether or not anything changed. There is no dirty check, so opening a save in Update mode only to find that there is nothing to do still rewrites all of it.

The library is rocketstation_Data/Managed/System.IO.Compression.dll, Mono's build (AssemblyInformationalVersion and AssemblyFileVersion 4.6.57.0, sha256 15e84f38fb5e6bb926fe92a79374a083b8dfbabfa11f8cd1d409f21b98447cfd). The dedicated server install ships a byte-identical copy (same sha256), so everything below holds on both halves. Line numbers are .work/decomp/0.2.6428.27798/System.IO.Compression.decompiled.cs.

Dispose writes for every mode except Read.

// System.IO.Compression.ZipArchive :: Dispose(bool)
// .work/decomp/0.2.6428.27798/System.IO.Compression.decompiled.cs:4032-4053

protected virtual void Dispose(bool disposing)
{
    if (!disposing || _isDisposed)
    {
        return;
    }
    try
    {
        ZipArchiveMode mode = _mode;
        if (mode != ZipArchiveMode.Read)
        {
            _ = mode - 1;
            _ = 1;
            WriteFile();
        }
    }
    finally
    {
        CloseStreams();
        _isDisposed = true;
    }
}

WriteFile in Update mode loads everything, truncates, and writes everything.

// System.IO.Compression.ZipArchive :: WriteFile
// .work/decomp/0.2.6428.27798/System.IO.Compression.decompiled.cs:4366-4396

private void WriteFile()
{
    if (_mode == ZipArchiveMode.Update)
    {
        List<ZipArchiveEntry> list = new List<ZipArchiveEntry>();
        foreach (ZipArchiveEntry entry in _entries)
        {
            if (!entry.LoadLocalHeaderExtraFieldAndCompressedBytesIfNeeded())
            {
                list.Add(entry);
            }
        }
        foreach (ZipArchiveEntry item in list)
        {
            item.Delete();
        }
        _archiveStream.Seek(0L, SeekOrigin.Begin);
        _archiveStream.SetLength(0L);
    }
    foreach (ZipArchiveEntry entry2 in _entries)
    {
        entry2.WriteAndFinishLocalEntry();
    }
    long position = _archiveStream.Position;
    foreach (ZipArchiveEntry entry3 in _entries)
    {
        entry3.WriteCentralDirectoryFileHeader();
    }
    long sizeOfCentralDirectory = _archiveStream.Position - position;
    WriteArchiveEpilogue(position, sizeOfCentralDirectory);
}

The per-entry load reads each original entry's compressed bytes into memory, unless the entry was opened for writing, and in this build always returns true, so the delete list above is always empty:

// System.IO.Compression.ZipArchiveEntry :: LoadLocalHeaderExtraFieldAndCompressedBytesIfNeeded
// .work/decomp/0.2.6428.27798/System.IO.Compression.decompiled.cs:5035-5058

internal bool LoadLocalHeaderExtraFieldAndCompressedBytesIfNeeded()
{
    if (_originallyInArchive)
    {
        _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin);
        _lhUnknownExtraFields = ZipLocalFileHeader.GetExtraFields(_archive.ArchiveReader);
    }
    if (!_everOpenedForWrite && _originallyInArchive)
    {
        _compressedBytes = new byte[_compressedSize / 2147483591 + 1][];
        for (int i = 0; i < _compressedBytes.Length - 1; i++)
        {
            _compressedBytes[i] = new byte[2147483591];
        }
        _compressedBytes[_compressedBytes.Length - 1] = new byte[_compressedSize % 2147483591];
        _archive.ArchiveStream.Seek(OffsetOfCompressedData, SeekOrigin.Begin);
        for (int j = 0; j < _compressedBytes.Length - 1; j++)
        {
            ZipHelper.ReadBytes(_archive.ArchiveStream, _compressedBytes[j], 2147483591);
        }
        ZipHelper.ReadBytes(_archive.ArchiveStream, _compressedBytes[_compressedBytes.Length - 1], (int)(_compressedSize % 2147483591));
    }
    return true;
}

Opening already reads more than a caller might expect. Init (4177-4266) in Update mode, on a non-empty stream, calls ReadEndOfCentralDirectory(), then EnsureCentralDirectoryRead(), then entry.ThrowIfNotOpenable(needToUncompress: false, needToLoadIntoMemory: true) for every entry, which seeks to and checks each local header (IsOpenable, 5136-5182). On a 0-byte stream it skips all of that (_readEntries = true), and disposing then writes an empty archive: a lone 22-byte end-of-central-directory record.

Read mode writes nothing. Init in Read mode calls only ReadEndOfCentralDirectory(); GetEntry (4072-4085) reads the central directory on first use through EnsureCentralDirectoryRead (4168-4175); Dispose skips WriteFile. On a 0-byte file Read mode throws InvalidDataException("Central Directory corrupt."), because ReadEndOfCentralDirectory (4292-4364) starts with _archiveStream.Seek(-18L, SeekOrigin.End) and turns the resulting IOException into that message.

What this means for a side-car mod:

  • Cost. Every open-and-dispose in Update mode holds the whole compressed archive in memory and writes the whole file again. A save is a few megabytes compressed (two rig saves read on 2026-09-25: 2,583,251 and 2,537,293 bytes, each with a world.xml of about 48 MB uncompressed).
  • Thread. The recipe below does this in a continuation of the save task, which after a successful save resumes on the Unity main thread (see "Current: direct write on a dedicated save thread"), so the whole rewrite is synchronous disk IO on the main thread.
  • Crash window. _archiveStream.SetLength(0L) runs before anything is written back. A crash, kill or power loss between that and the end of WriteArchiveEpilogue leaves a truncated save; nothing in this path writes to a temporary file first.
  • Nothing to do still costs a rewrite. archive.GetEntry(name)?.Delete() on an archive that has no such entry changes nothing, and the dispose rewrites the file anyway.

The pattern that follows: probe read-only before opening in Update mode. When the only purpose of the open is to remove an entry that may not be there, check with a read-only open first and return when it is absent:

bool present;
using (var fs = new FileStream(zipPath, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var archive = new ZipArchive(fs, ZipArchiveMode.Read))
{
    present = archive.GetEntry(entryName) != null;
}
if (!present)
    return;

using (var fs = new FileStream(zipPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
using (var archive = new ZipArchive(fs, ZipArchiveMode.Update))
{
    archive.GetEntry(entryName)?.Delete();
}

On the current game the entry is never there after a save, because the writer opens the destination with FileMode.Create and writes only its own entries, so the probe always returns early and the Update-mode branch is kept only as a guard. Adding an entry still needs the Update-mode open (or a write inside the game's own ZipOutputStream before zipOutputStream.Finish(), which no mod in this repository does), and that open pays the full rewrite each time.

Not in a release yet. Spray Paint Plus's GlowSideCar.RemoveSideCar does exactly this probe as of commit df406e15 (Mods/SprayPaintPlus/SprayPaintPlus/GlowSideCar.cs, 2026-09-27), targeted at v1.13.0. The published build, v1.12.1, opens the save in Update mode unconditionally, and so do the other six side-car classes in this repository (ActiveSlotSideCar and HelmetBeamSideCar in Equipment Plus, BurnReasonSideCar, PassthroughSideCar and PrioritySideCar in Power Grid Plus, AutoAimSideCar in Power Transmitter Plus), after each successful save for which they hold a snapshot: to write their entry, or, when the snapshot is empty, to remove it.

Measured, 2026-09-25, offline. A probe wrote a five-entry archive with the game's own ICSharpCode.SharpZipLib.dll in the game's call sequence (SetLevel, PutNextEntry(new ZipEntry(name)), write, CloseEntry, Finish, over File.Open(path, FileMode.Create, FileAccess.Write)), then ran both removal bodies on copies. The unconditional Update-mode body rewrote the file (998,219 to 998,019 bytes, new hash, modification time changed) although no side-car was present; the read-only probe left it byte-identical with its modification time unchanged; with a side-car added, the probe path removed it and the five game entries' contents were unchanged. The probe ran on .NET Framework's ZipArchive, not Mono's; the decompile reading above is what covers the game.

Telling a game-written save from a rewritten one

A save exactly as the game wrote it carries a structural fingerprint that any ZipArchive Update-mode rewrite removes, so a check can use it to tell whether something rewrote a save after the game wrote it.

The game's writer forces Zip64 on every entry. The game writes the archive with ICSharpCode.SharpZipLib 0.86.0.518 (rocketstation_Data/Managed/ICSharpCode.SharpZipLib.dll, AssemblyVersion("0.86.0.518"), sha256 40b3d590f95191f3e33e5d00e534fa40f823d9b1bb2a9afe05f139c4e0a3af8d, byte-identical in the dedicated server install; decompiled to .work/decomp/0.2.6428.27798/ICSharpCode.SharpZipLib.decompiled.cs, whose line numbers follow). Every entry goes in through new ZipEntry(name) with no size set (SaveHelper.Save and AddZipFile), so ZipEntry.Size reads -1 (12261-12276) when PutNextEntry runs, and the game never touches ZipOutputStream.UseZip64 (no occurrence in either build's Assembly-CSharp), which defaults to UseZip64.Dynamic (17491). PutNextEntry (17556-17754) then does:

// ICSharpCode.SharpZipLib.Zip.ZipOutputStream :: PutNextEntry
// .work/decomp/0.2.6428.27798/ICSharpCode.SharpZipLib.decompiled.cs:17629-17632

if (useZip64_ == UseZip64.On || (entry.Size < 0 && useZip64_ == UseZip64.Dynamic))
{
    entry.ForceZip64();
}

With Zip64 forced and the destination seekable, the local header gets -1 in both 32-bit size fields plus a Zip64 extra field whose two 64-bit values CloseEntry (17756-17857) later patches with the real sizes. Finish (17944-18062) writes each central directory record with WriteLeShort(51) as version made by, entry.Version as version needed (45 for a Zip64 entry, ZipEntry.Version 12121-12156), -1 in both sizes when IsZip64Forced(), and a Zip64 extra field (tag 1) holding the uncompressed and compressed sizes.

The result, per entry, in both the local header and the central directory:

Field Game-written (SharpZipLib) After a Mono ZipArchive Update-mode rewrite
32-bit compressed and uncompressed size 0xFFFFFFFF the real sizes
Extra field 16-byte Zip64 extended information, header ID 0x0001 none
Version needed 45 45 (carried from the central directory)
Version made by 51, host byte 0 51, host byte 0 (carried)
General-purpose flags 0x0000, no data descriptor 0x0000

Why a rewrite drops the field. Mono's ZipArchive discards Zip64 blocks when it reads (from the central directory in ZipCentralDirectoryFileHeader.TryReadBlock, 5948-5995, through Zip64ExtraField.GetAndRemoveZip64Block at 5983; from the local header extra fields it keeps through ZipLocalFileHeader.GetExtraFields, 5870-5883, which calls Zip64ExtraField.RemoveZip64Blocks) and writes one back only when SizesTooLarge() (5184-5191, a size above uint.MaxValue) or the local header offset is above uint.MaxValue (WriteLocalFileHeader 5193-5260, WriteCentralDirectoryFileHeader 4957-5033). A save is far below 4 GiB, so every rewritten entry loses the field: 20 bytes shorter in the local header and 20 in the central directory, 40 bytes per entry. Version made by and version needed are copied from the central directory record (ZipArchiveEntry constructor, 4824-4826) and written back, so they do not reveal a rewrite. An entry a mod adds with CreateEntry carries version made by 20 and version needed 20.

Measured, 2026-09-25. The raw records of the offline probe archive written with the game's SharpZipLib show the left-hand column on all five entries, with no Zip64 end-of-central-directory locator and no data descriptors. Two rig saves the game wrote and mods then rewrote in-game (listen host hostie, written 2026-09-24 and 2026-09-25) show the right-hand column on all five game entries (made by 51/0, version needed 45, plain sizes, no extra fields), plus side-car entries at 20/20. The .NET Framework rewrite in the offline probe also dropped the field (998,219 to 998,019 bytes, 200 bytes for five entries) but wrote version made by 45, a value Mono's rewrite does not produce.

Using it. To show that nothing rewrote a save, assert that every vanilla entry still carries the Zip64 field and the 0xFFFFFFFF sizes. The assertion means something only when no mod that reopens saves in Update mode is loaded: each such open erases the fingerprint on its own, and every side-car class in this repository reopens the save in Update mode after each successful save for which it holds a snapshot, to write its entry or to remove it (see "Not in a release yet" in the section above for the one exception, committed but not yet released).

Harmony interception strategy (verified in SprayPaintPlus v1.6.0)

Working pattern: Prefix + Postfix on the PRIVATE SaveHelper.Save worker, wrapping the returned UniTask.

The private Save(DirectoryInfo, string, bool, CancellationToken) worker is async (returns UniTask<SaveResult>). A Harmony Postfix on an async method fires after the kick-off stub returns the task, BEFORE any of the async body has executed (per Research/Patterns/AsyncHarmonyTrap.md). Direct Postfix access to the written file at the right moment therefore requires wrapping the returned task with a continuation.

[HarmonyPatch(typeof(SaveHelper), "Save",
    new[] { typeof(DirectoryInfo), typeof(string), typeof(bool), typeof(CancellationToken) })]
public class SaveHelperSaveSideCarPatch
{
    public static void Prefix()
    {
        // Snapshot mod state on the MAIN thread, in the same call in which
        // SaveHelper.Save captures the world with the game tick paused. Save
        // unpauses the tick before it writes the file on its own thread, so
        // state read any later can differ from what world.xml holds.
        MyMod.PendingSaveSnapshot = MyMod.SnapshotState();
    }

    public static void Postfix(
        DirectoryInfo saveDirectory,
        string saveFileName,
        ref UniTask<SaveResult> __result)
    {
        var originalTask = __result;
        var snapshot = MyMod.PendingSaveSnapshot;
        MyMod.PendingSaveSnapshot = null;
        var path = Path.Combine(saveDirectory.FullName, saveFileName);
        __result = WriteSideCarAfterSave(originalTask, path, snapshot);
    }

    private static async UniTask<SaveResult> WriteSideCarAfterSave(
        UniTask<SaveResult> saveTask, string path, StateType snapshot)
    {
        var result = await saveTask;
        if (!result.Success) return result;

        // The destination .save file is now sealed, and the async body's
        // using-blocks have closed the file handle. Safe to re-open in
        // ZipArchiveMode.Update and add the side-car entry.
        using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
        using (var archive = new ZipArchive(fs, ZipArchiveMode.Update))
        {
            archive.GetEntry("my-mod-sidecar.xml")?.Delete();
            var entry = archive.CreateEntry("my-mod-sidecar.xml", CompressionLevel.Optimal);
            using (var es = entry.Open())
            {
                // serialize snapshot into es
            }
        }
        return result;
    }
}

The prefix comment gives the 0.2.6428.27798 reason (see "State capture at save time"). At 0.2.6228.27061 it read "SaveHelper.Save's body switches to ThreadPool on its first await; reading the dictionary directly from the ThreadPool worker would race gameplay mutations", which was true for that build. The advice, snapshot in the prefix, is the same for both.

Wrap the task; never await it a second time. The postfix replaces __result with a task that awaits the original once. DoSave, DoNewSave, DoSaveAs and RollingSave already await whatever the private Save returns, and a UniTask takes one continuation: UniTaskCompletionSourceCore<T>.OnCompleted throws InvalidOperationException("Already continuation registered, can not await twice or get Status after await.") when a second one is registered (the throw read in the game's UniTask.dll on 2026-09-25; the 2026-09-25 fresh validator reported the same one-continuation limit for the task the private Save returns). Because the wrapper is what DoSaveAs awaits, the side-car is already in the manual save when CopyToHeadSave copies that file over the head save.

When the snapshot is empty, probe before opening in Update mode. The recipe's Update-mode open rewrites the whole save on dispose (see "Reopening a save in ZipArchiveMode.Update rewrites every entry"), which cannot be avoided when there is an entry to add. When there is nothing to store, do not open the save in Update mode just to delete an entry that, on the current game, cannot be there after a save: probe it read-only first and return when the entry is absent (the pattern is in that section).

Why the earlier "open the temp file" idea does not work, and note that the reason changed with the game. At 0.2.6403.27689 there is no temp file at all (nor at 0.2.6428.27798): the writer opens $"{saveDirectory}/{saveFileName}" directly (see "Current: direct write on a dedicated save thread"). At 0.2.6228.27061 a temp file existed but was tempFile.Delete()'d at the end of SaveHelper.Save's async body, before the returned task completed, so a continuation always found it gone. Either way the conclusion is the same and the recipe below is unaffected: only the final destination file is available post-completion.

What the current write path does improve is the guarantee at that moment. Because the using ZipOutputStream scope closes before work() returns, and TrySetResult() runs only after work() returns, a continuation on the returned task sees a complete, closed file rather than one that might still be streaming. The write is also not cancellable (cancellationToken never reaches RunOnSaveThread), so there is no completed-but-still-writing state to race. Two caveats: after a successful save the continuation resumes on the Unity main thread, so re-opening the ZIP there is synchronous main-thread disk IO; and a failed write leaves a truncated file behind because FileMode.Create truncates first, so gate on result.Success.

Why the public Save overload is not a valid target: it dispatches through SaveGame -> DoSave -> private Save. Patching the public entry misses autosave, quicksave, new-save, and save-as flows. The private worker is the single funnel (see "SaveHelper.Save overload disambiguation" section).

Load side: Postfix on XmlSaveLoad.LoadWorld. Read <tempDir>/my-mod-sidecar.xml directly as a loose file; do NOT attempt to reopen the save ZIP (see "Load-time ZIP extraction" section).

Design consideration: user warning on mod removal

If a mod writes side-car files (e.g., glow color state) and is later removed, those files remain in the save ZIP but are never read or applied. This is safe and desirable for cosmetic mods:

  • No save breakage (side-car file is simply ignored on load)
  • User can re-install the mod and state is preserved
  • If side-car file becomes stale or corrupted, absence of the mod means graceful degradation

Recommendation: Do NOT warn the user. Side-car data is intentionally optional. Document in mod README that removal is non-breaking.

For critical save state (e.g., equipment modifications, world changes), use ThingSaveData registration instead, which ensures the game will refuse to load if the mod is absent, forcing the user to restore the mod or acknowledge data loss.

Verdict

Side-car approach is viable for optional cosmetic state (glow colors) with these constraints: 1. Mod must inject side-car file on every save (Harmony Postfix on SaveHelper.Save) 2. Mod must read and re-apply state on every load (Thing.OnFinishedLoad Postfix) 3. Without mod, side-car file is harmlessly ignored; glow state is simply lost (desired behavior for optional features) 4. Enables removal without save breakage, unlike custom ThingSaveData which breaks loads when mod is absent

Not a replacement for critical save-breaking state (use ThingSaveData + registration for that). For glow-paint, this approach is superior to current GlowThingSaveData method because removal becomes non-fatal.

Save folder layout and the load command's resolution

A save on disk is a FOLDER under saves/ whose world data is a single *.save ZIP (the archive SaveHelper.Save writes above, holding world_meta.xml, world.xml, terrain.dat, and any side-cars). The loose world.xml / terrain.dat files only ever exist in the temp extraction directory at load time (see ExtractToTemp above), never in the saves folder itself.

The load <name> console command (the path the dedicated server's -load flag drives) resolves the name to that folder and requires exactly one *.save file in it (Assembly-CSharp.dll decompile lines 96534-96562):

// Util.Commands.LoadGameCommand.LoadGame
DirectoryInfo directoryInfo = new DirectoryInfo(filenameOrFullPath);
if (directoryInfo.Exists) { ConsoleWindow.Print(FileCommand.LoadAtFullPath(directoryInfo.FullName)); return; }
if (!directoryInfo.Exists) { directoryInfo = FileCommand.GetStationDirectory(filenameOrFullPath); }
if (directoryInfo.Exists)
{
    FileInfo[] files = directoryInfo.GetFiles(SaveLoadConstants.SaveFileSearchPattern, SearchOption.TopDirectoryOnly);
    if (files.Length != 1)
        ConsoleWindow.PrintError(".save file not found at " + directoryInfo.FullName);
    else
        LoadHelper.LoadGame(files[0].FullName, filenameOrFullPath);
    return;
}
// ... else falls through to starting a NEW world named <name> on the backupWorldName id
// Assets.Scripts.Serialization.SaveLoadConstants
public static readonly string SaveFileExtension = ".save";
public static readonly string SaveFileSearchPattern = "*.save";

Practical implications:

  • GetFiles("*.save", TopDirectoryOnly) must return EXACTLY ONE file. Zero (an empty or loose-file folder) and two-or-more both print .save file not found at <dir> and abort the load.
  • Seeding a save for the dedicated server means placing the world's single <name>.save ZIP inside data/saves/<name>/, NOT extracting it into loose world.xml / terrain.dat files. Extracting yields a folder with zero *.save files, so the load fails with .save file not found even though every world file is physically present. (Observed 2026-05-21: a hand-extracted Lunar save in data/saves/Luna/ produced exactly this error; replacing the loose files with the original Luna.save ZIP loaded cleanly.)
  • A bare name is mapped to <SavePath>/saves/<name> via GetStationDirectory; on the dedicated server <SavePath> is the -settings SavePath value. A full path bypasses this via LoadAtFullPath.
  • When no matching save folder exists at all, LoadGame does NOT error; it starts a NEW world under backupWorldName (the second load argument / map id). A typo'd save name therefore silently starts a fresh world.

Verification history

  • 2026-04-21: full verification pass against Assembly-CSharp.dll v0.2.6228.27061. Decompiled SaveHelper.Save (ZipOutputStream write path with 5 known entries), XmlSaveLoad.LoadWorld (no ZIP enumeration), Thing.OnFinishedLoad, GameManager.UpdateThingsOnGameStartAction (caller), GameManager.AutoSaveNow (multiplayer guard), and SaveHelper.CopyToHeadSave (file copy, not rebuild). Decompiled LaunchPadBooster.dll and confirmed no save/load extension hooks. Resolved: (A) confirmed ZipOutputStream-based complete rebuild with single seal point at Finish(), (B) confirmed save-copy via binary file copy (unknown entries already absent), (C) no user warning needed for optional side-car data. Recommended Postfix on SaveHelper.Save as concrete Harmony pattern.
  • 2026-04-21: added "SaveHelper.Save overload disambiguation" section after a live in-game test surfaced HarmonyException: Ambiguous match for ... methodname=Save at PatchAll time. Decompile verified SaveHelper declares two Save methods (public (string, CancellationToken) and private (DirectoryInfo, string, bool, CancellationToken)); every save path funnels through the private worker via SaveGame -> Do*Save dispatch. Documented the routing table and the required argument-type array in the [HarmonyPatch] attribute. Consequence of the bug: PatchAll throws on first ambiguous overload, so the ENTIRE mod's Harmony patches fail to apply, not just the ambiguous one.
  • 2026-04-21: conflict on "how the game reads save ZIPs at load time". Previous claim: "LoadWorld uses ZipArchive.GetEntry(name) for known filenames only; unknown entries remain in the archive untouched." New finding: the game does NOT open a ZipArchive at LoadWorld time. LoadHelper.LoadGameTask calls ExtractToTemp(path) first, which uses ZipInputStream.GetNextEntry() to extract every entry (known and unknown) to a temp directory as loose files. CurrentWorldSave.World.FullName is <tempDir>/world.xml, not a path into a ZIP. Fresh validator verdict: B is correct (the new finding). Result: added a "Load-time ZIP extraction (LoadHelper.ExtractToTemp)" section with the full decompile of LoadHelper.LoadGameTask and ExtractToTemp, rewrote the "ZIP read path (LoadWorld)" section to clarify that LoadWorld reads loose files not a ZIP, and updated the "Harmony interception strategy" section to replace the (wrong) "open the temp file in Update mode" recipe with the verified wrapped-UniTask pattern shipped in SprayPaintPlus v1.6.0. The side-car-on-load recipe was also corrected: consume <tempDir>/<entry-name> as a loose file in the XmlSaveLoad.LoadWorld postfix; attempting to reopen the save ZIP produces End of Central Directory record could not be found because the original ZIP was closed by ExtractToTemp.
  • 2026-08-09: conflict on "does the private SaveHelper.Save write to a temp file and copy, or write to the destination directly". Previous claim: the writer builds the ZIP into Path.GetTempPath() after await UniTask.SwitchToThreadPool(), copies it with CopyToAsync, then calls tempFile.Delete(); failure string Failed to write to temp file at path {tempFile} : {arg}. New finding: at 0.2.6403.27689 the writer opens $"{saveDirectory}/{saveFileName}" directly with FileMode.Create inside a RunOnSaveThread delegate (a dedicated background thread named "Stationeers Save Writer" at BelowNormal priority), with no temp file, no CopyToAsync and no delete; failure string Failed to write save file at path {savePath} : {arg}. Fresh validator verdict: the new finding is correct, quoting Assembly-CSharp.decompiled.cs:265004-265032 and :265060-265080. The validator also judged this a genuine game change between versions rather than an error in the original pass, since the older form is internally coherent and matches its own verification note. Result: the "ZIP write path" section now carries both forms, current first, with the superseded one retained and still stamped at 0.2.6228.27061 per the lossless principle. Added what the new path guarantees a Harmony continuation (complete and closed file at task completion, non-cancellable write, main-thread resumption, truncated file on failure). Corrected the obsolete "the temp file is deleted before the task completes" rationale in "Harmony interception strategy"; the recipe itself is unaffected. Also noted that AutoSaveNow moved from GameManager to StationAutoSave and that a second autosave driver exists on NetworkBase. Page restamped to 0.2.6403.27689; sections not re-read in this pass keep their older stamps.
  • 2026-05-21: added "Save folder layout and the load command's resolution" section. Sourced from Util.Commands.LoadGameCommand.LoadGame (decompile lines 96534-96562) and SaveLoadConstants.SaveFileExtension / SaveFileSearchPattern (lines 248112-248114). Additive (no existing claim contradicted): documents that the load command resolves a save folder to exactly one *.save file via GetFiles("*.save", TopDirectoryOnly), so a dedicated-server save must be seeded as the <name>.save ZIP inside data/saves/<name>/, not as extracted loose files. Found while seeding the Lunar save for a Power Grid Plus passthrough test.
  • 2026-09-25: extended "Load-time ZIP extraction (LoadHelper.ExtractToTemp)" against the 0.2.6428.27798 client and dedicated-server decompiles. The LoadGameTask excerpt, which had elided the UI setup and the catch block, is replaced by the full verbatim method set (LoadGame, LoadGameTask, LoadWorldTask, ExtractToTemp; client :270146-270177 and :270322-270344, server :256559-256590 and :256735-256757). Every line of the old excerpt is still present and ExtractToTemp is unchanged. Added the subsection "When the load throws: the catch block": the catch reports through ConsoleWindow.PrintError(System.Exception) (:228575-228592), which makes three Print calls ("Exception: " + exception.Message in Red with aged: false, then Environment.StackTrace in Gray, then exception.StackTrace in White), clears CurrentWorldSave, CurrentStationName and IsReadyToPlayWorldAudio, and calls UI.ConfirmationPanel.Show with FailedToStartGameMessageTitle, FailedToStartGameMessageText and ButtonExit (English strings read from english.xml). Also recorded: which failures reach the catch and which do not (the terrain-checksum cancel, the forgotten StartGame, the per-Thing OnFinishedLoad catch); that the catch can run with GameState already Running; that the extraction directory is deleted only when the load succeeds; the client versus dedicated-server difference (identical code; on the server the batch-mode Print branch sends the entries to standard output, or with -logFile to the Unity log, with an HH:mm:ss: prefix, and the dialog call is unguarded, its effect there left in Open questions); that PrintError(System.Exception) has 21 call sites in the client build and 17 in the server build; and the separate new-world path (World.StartNewWorld reports through Debug.LogException and shows the FailedToLoadGame* keys). Additive: nothing verified on the page was contradicted by this part. The section is restamped to 0.2.6428.27798; the "End of Central Directory" observation in its second implication bullet is carried from the 2026-04-21 pass and was not re-measured. Tag diagnostics added. Page frontmatter moved to 0.2.6428.27798; sections not re-read in this pass keep their older stamps.
  • 2026-09-25: conflict on "what calls Thing.OnFinishedLoad on a save load, and when". Previous claim (0.2.6228.27061): UpdateThingsOnGameStartAction calls thing.OnFinishedLoad() with no try/catch, and the sweep "occurs near the end of LoadWorld before GameManager.OnReadyToPlay()". New finding (0.2.6428.27798): the action wraps OnFinishedLoad and the interactable sync in a per-Thing try/catch that prints OnFinishedLoad failed for {thing.DisplayName} #{thing.ReferenceId}: {arg} and continues; on the saved-world path LoadWorld calls GameManager.OnReadyToPlay() (274443) and then World.OnLoadingFinished (274445), whose GameManager.StartGame().Forget() sets GameState = Running and runs the sweep before its first await, so OnFinishedLoad runs after OnReadyToPlay, still inside LoadWorld. Fresh validator verdict: B is correct (the new finding) on both parts, in the client and the dedicated-server build, quoting client :209323-209345, :274443-274449, :330903-330907 and :210052-210057 (server :197270-197292, :260856-260862, :317287-317291 and :197999-198004); it also read the IL of AsyncUniTaskMethodBuilder.Start in both installs' UniTask.dll to confirm that the part of StartGame before its first await runs synchronously. It could not say whether the old claim was right at 0.2.6228.27061, since no decompile of that build is on disk; SaveLoadOrdering already recorded the per-Thing try/catch at 0.2.6403.27689. Result: "Thing.OnFinishedLoad timing and caller" now carries both forms, current first, with the 0.2.6228.27061 caller excerpt and timing sentence retained verbatim under "Superseded" and still stamped 0.2.6228.27061 per the lossless principle. The unchanged Thing.OnFinishedLoad body was re-read at :325477-325494. The section is restamped to 0.2.6428.27798.
  • 2026-09-25: conflict on "what thread and tick state the private SaveHelper.Save captures the world in, and whether a temp file exists to reopen". Previous claims, in "State capture at save time" (0.2.6228.27061): "XmlSaveLoad.GetWorldData() enumerates all Things via OcclusionManager.AllThings.AsPooledSpan()", "Main thread context (game tick is unpaused before Save is called)" and "Serialization to MemoryStream is synchronous on thread pool before ZipOutputStream finalization"; in the "Harmony interception strategy" recipe, the prefix comment "Snapshot mod state on the MAIN thread. SaveHelper.Save's body switches to ThreadPool on its first await; reading the dictionary directly from the ThreadPool worker would race gameplay mutations."; and in "LaunchPadBooster extension hooks", "Required workaround: Harmony patch on SaveHelper.Save to intercept the ZipOutputStream or re-open the temp file in Update mode after Finish()." New finding (0.2.6428.27798): PrepareToSave pauses the tick, waits for GameTickPaused and returns on the main thread; a prefix on the private Save and GetWorldData() run synchronously on the main thread with the tick paused; GetWorldData walks OcclusionManager.AllThings.Active().GetEnumerator(); UnpauseGameTick() runs before the first await; world.xml is serialized straight into the ZipOutputStream on the "Stationeers Save Writer" thread; the body resumes on that thread after the await and switches to the main thread before returning; there is no temp file. Fresh validator verdict: B is correct on all six parts in both builds (A right only that the context is the main thread), quoting client :270593, :270596, :270601, :270620, :270632-270633, :270650, :270652, :270658, :270683-270684, :273965 and :270647 (the whole SaveHelper class, GetWorldData and the tick methods diffed identical in the server build). It also read a 0.2.6228.27061 decompile left in the workspace (.work/decompile/configcartridge/, an ilspycmd project whose Properties/AssemblyInfo.cs carries [assembly: AssemblyVersion("0.2.6228.27061")], outside the canonical decompile path) and reported that the AsPooledSpan() enumeration (XmlSaveLoad.cs:702, using PooledSpan<Thing> pooledSpan = OcclusionManager.AllThings.AsPooledSpan();), the thread-pool MemoryStream serialization and the "switches to ThreadPool" comment were right for that build (SaveHelper.cs:276-280: GameManager.UnpauseGameTick(); await UniTask.SwitchToThreadPool(); ... Serializers.WorldData.Serialize(worldMs, worldData);), that its temp file (SaveHelper.cs:304) was deleted with tempFile.Delete(); (:345) before the task completed, so a continuation could never have reopened it, and that the tick claim was wrong for that build too: PrepareToSave was the same (:236-239) and the unpause came after GetWorldData (:276). It added that a failed write returns SaveResult.Fail from the catch on the writer thread, before the switch to the main thread. Result: "State capture at save time" now carries both forms, current first; the superseded form keeps the first, second and fourth bullets verbatim and corrects the third, stamped 0.2.6228.27061 on the strength of the validator's reading; the recipe's prefix comment gives the current reason and the 0.2.6228.27061 wording is kept in the text below the recipe; the "Required workaround" line now points to a new subsection, "Where a side-car can be added (0.2.6428.27798)", which also re-confirms at LaunchPadBooster 0.2.0 that there is no save hook (the rest of "LaunchPadBooster extension hooks" was not re-read and keeps its stamp); and the "Continuations resume on the main thread" consequence now names the two failure paths.
  • 2026-09-25: re-read "ZIP write path" and "Current: direct write on a dedicated save thread" against the 0.2.6428.27798 client and dedicated-server decompiles and restamped both. The write excerpt and RunOnSaveThread are unchanged apart from their line numbers, now added to the excerpt headers (client :270646-270674 and :270702-270722, server :257059-257087 and :257115-257135). Added the head of the method (GetWorldData, UnpauseGameTick(), the screenshots, :270614-270645) to that subsection, and confirmed by direct read two things that were inference or unstated before: SharpZipLib's DeflaterOutputStream defaults isStreamOwner_ to true and closes the base stream in Close() (4688, 4893-4917 of the new .work/decomp/0.2.6428.27798/ICSharpCode.SharpZipLib.decompiled.cs), and the game's mscorlib File.Open(path, mode, access) passes FileShare.None. Added two sections. "Reopening a save in ZipArchiveMode.Update rewrites every entry": in the game's Mono System.IO.Compression.dll 4.6.57.0 (byte-identical on the server install; decompiled to .work/decomp/0.2.6428.27798/System.IO.Compression.decompiled.cs), Dispose calls WriteFile() for every mode except Read (4032-4053), and in Update mode WriteFile loads every entry's compressed bytes, truncates the stream and writes every entry back (4366-4396, 5035-5058), with no dirty check; Read mode writes nothing; plus the read-only probe pattern and the offline probe measurement. "Telling a game-written save from a rewritten one": SharpZipLib 0.86.0.518 forces Zip64 on every entry whose size is unknown (PutNextEntry 17629-17632), which every save entry is, so each carries 0xFFFFFFFF sizes and a 16-byte Zip64 extra field in both headers, while a Mono Update-mode rewrite drops the field (GetAndRemoveZip64Block, RemoveZip64Blocks, SizesTooLarge) and keeps made-by 51 and version needed 45; measured on the probe archive and on two rig saves on 2026-09-25. The Spray Paint Plus RemoveSideCar probe these sections mention exists only in the working tree and carries the "Not in a release yet" marker. Restamped "Harmony interception strategy" after re-reading it against 0.2.6428.27798 (the private-overload target, the routing through SaveGame, the wrapped task and the load-side read of <tempDir>/<entry> all hold) and adding the single-await note (UniTaskCompletionSourceCore<T>.OnCompleted throws on a second continuation, read in the game's UniTask.dll) and the empty-snapshot probe note. Additive apart from the conflict recorded above. Sections not named here keep their stamps.
  • 2026-09-27: Spray Paint Plus's read-only probe in GlowSideCar.RemoveSideCar was committed as df406e15 after a 76-of-76 playtest pass on that build; the markers now name the commit instead of the working tree. Still not in a release (targeted at v1.13.0). No content re-verified.

Open questions

None at creation. All three original open questions resolved in the 2026-04-21 verification pass.

  • 2026-09-25: what LoadHelper.LoadGameTask's dialog call does on a dedicated server. The server build calls Singleton<ConfirmationPanel>.Instance.Show("FailedToStartGameMessageTitle", ...) exactly as the client does, with no GameManager.IsBatchMode guard. Singleton<T>.Instance (Assets.Scripts.Util.Singleton<T>, server :227248-227306) returns the scene's ConfirmationPanel if there is one, inactive ones included, and otherwise adds a new one to a fresh GameObject; that one's serialized text and button fields would be unassigned, so SetUpPanel would throw a NullReferenceException at _localizedTitleText.text = data.TitleText. Whether the dedicated server's scenes carry a wired ConfirmationPanel is not visible in the decompile, so it is open whether the call is harmless there or throws out of the catch. Either way, when the catch runs on the main thread the three console entries are written before Show is called. Settling it takes one failed load on the dedicated server with its log read afterwards.