Skip to content

Save file ZIP structure

Top-level file layout of a Stationeers save ZIP. The archive is a standard ZIP; each entry is plaintext XML, binary voxel data, or a PNG. Sizes in the example column are from an observed mid-sized save and are indicative, not exact.

File table

File Size (example) Content
world_meta.xml ~700 B Save metadata: world name, version, stats
world.xml ~2.6 MB ALL game data: things, atmospheres, networks, players
terrain.dat ~184 KB Terrain/voxel data (binary)
preview.png ~125 KB Save thumbnail
screenshot.png ~139 KB Screenshot

The five names are code constants (SaveLoadConstants, 0.2.6403.27689 decompile lines 265202-265210): TerrainFileName = "terrain.dat", MetaFileName = "world_meta.xml", WorldFileName = "world.xml", PreviewFileName = "preview.png", ScreenshotFileName = "screenshot.png". The writer (SaveHelper.Save, line 264972) emits them in the order world_meta.xml, world.xml, terrain.dat, preview.png, screenshot.png, and skips the two PNGs entirely in batch mode (if (!GameManager.IsBatchMode), line 265020), so a save written BY a dedicated server has only the first three members. A save that carries the PNGs was last written by a client.

How the archive is written: fresh every time, one at a time, off the main thread

The private worker SaveHelper.Save(DirectoryInfo, string, bool, CancellationToken) (270614) opens the destination with FileMode.Create:

using ZipOutputStream zipOutputStream = new ZipOutputStream(File.Open(savePath, FileMode.Create, FileAccess.Write));

FileMode.Create truncates an existing file, so a save is never merged into the previous archive; the head save is replaced wholesale by a freshly streamed ZIP containing only the entries this pass writes. That is the mechanism behind the sidecar rule above: there is no "existing entry" for the game to leave alone, so a mod that does not re-append on every save loses its data silently. It is also why a repack tool that drops unknown entries is indistinguishable, from the game's point of view, from a normal save.

Three more properties of the write path, each one a thing a tool or a patch can get wrong:

  • Saves are serialized by a re-entrancy guard, and the refusal is a returned failure, not an exception. SaveHelper.SaveGame (270477) is the single funnel behind NewSave, Save, SaveAs, QuickSave and AutoSave, and its second check is:
if (IsSaving)
{
    return SaveResult.Fail("Save Failed: Already saving.");
}

IsSaving is a private static bool set to true in PrepareToSave (270591) and cleared in SavingFinished (270611). A second save started while one is in flight does not queue, does not block and does not throw: it returns a failed SaveResult with that exact string. Anything driving saves programmatically has to read the result, because a dropped save looks identical to a successful one if the caller ignores the return value. The first check, ahead of it, rejects an empty station name ("Save Failed: Folder name is empty."), which is the autosave failure documented on ../GameSystems/StationAutoSave.md; PrepareToSave adds a third, "Save Failed: Game must be running to save", for any GameState other than Running or Paused. - There is a manual escape hatch for a stuck flag. public static void ForceIsSavingToFalse() (270472) exists and does exactly that. Its presence is the game's own admission that the flag can be left set, which happens if SavingFinished never runs. Treat "every save now fails with Already saving" as a stuck flag rather than a locked file. - The ZIP is streamed on a dedicated background thread, not a pool thread. RunOnSaveThread (270700) creates a new Thread per save, named "Stationeers Save Writer", IsBackground = true, Priority = BelowNormal, and completes a UniTaskCompletionSource when the delegate returns. The game tick is paused across the whole operation (PrepareToSave calls GameManager.PauseGameTick and waits for GameManager.GameTickPaused; the worker calls GameManager.UnpauseGameTick again once it has the world data). A Harmony patch on the private Save worker that appends a sidecar therefore runs off the main thread, inside that delegate, which rules out any Unity API call in the patch body. See ../Patterns/ThingEnumerationOffMainThread.md and ../GameSystems/SaveZipExtension.md.

The two preview PNGs are rendered before the thread starts, in PrepareToSave's caller, via GameManager.CreateScreenShot(400, 400, ...) and (613, 266, ...) into MemoryStreams, and skipped entirely under GameManager.IsBatchMode, which is the batch-mode behaviour already described in the file table.

Mod sidecar entries

A save ZIP can carry additional entries beyond the five standard members: mods append their own files at save time. Observed entry listing of a real modded save (TestRig/DedicatedServer/data/saves/Luna/Luna.save, 2026-07-02, sizes in bytes):

       698  world_meta.xml
  61514025  world.xml
    456908  terrain.dat
    149788  preview.png
    177904  screenshot.png
       779  equipmentplus-active-slots.xml
       551  equipmentplus-beam.xml
      3667  pwrxmplus-autoaim.xml
      4421  pwrgridplus-passthrough.xml
       486  pwrgridplus-priority.xml

The last five are per-mod sidecars written by this repository's mods: equipmentplus-active-slots.xml and equipmentplus-beam.xml by EquipmentPlus (ActiveSlotSideCar.SideCarEntryName, HelmetBeamSideCar.SideCarEntryName), pwrxmplus-autoaim.xml by PowerTransmitterPlus (AutoAimSideCar.SideCarEntryName), and pwrgridplus-passthrough.xml / pwrgridplus-priority.xml by PowerGridPlus (PassthroughSideCar.SideCarEntryName, PrioritySideCar.SideCarEntryName). The mechanism, and why it works, is documented in ../GameSystems/SaveZipExtension.md: the game's LOAD path looks up known entry names and silently ignores everything else, while the game's SAVE path rebuilds the ZIP from scratch with only the known members, so a mod must re-append its sidecar on every save (Harmony patch on the private SaveHelper.Save worker).

Consequences for offline save tooling:

  • Any tool that repacks a save ZIP MUST copy through unknown entries verbatim (name, order, bytes), or it silently strips mod data. A repack that hardcodes the five standard members destroys the sidecars without any error; the mods then see missing state on next load. This bit in practice on 2026-07-02: tools/save-edit/stationeers_save.py repack() writes only the five standard members, so the save-surgery session used a one-off script that copies every entry through and edits only world.xml, precisely to keep the five sidecars alive.
  • The sidecar names are chosen by each mod; there is no registry and no namespace rule beyond collision avoidance. Treat "entries I do not recognize" as load-bearing mod data, never as junk.

Verification history

  • 2026-04-20: page created from the Research migration. Source: F0220 (save file ZIP structure).
  • 2026-07-02: re-confirmed the file table against the 0.2.6403.27689 decompile (SaveLoadConstants names, SaveHelper.Save write order, batch-mode PNG skip) and added the "Mod sidecar entries" section from the observed Luna.save entry listing during the same day's save-surgery session. Mechanism cross-referenced to ../GameSystems/SaveZipExtension.md rather than duplicated.
  • 2026-09-24 (game version 0.2.6428.27798): re-read SaveLoadConstants (270844-270852) and the write order in the private SaveHelper.Save worker (270652-270667) against the current decompile; all five names, the order and the batch-mode PNG skip are unchanged, so the file table is restamped to 0.2.6428.27798 and the frontmatter with it. Added "How the archive is written: fresh every time, one at a time, off the main thread", additive and contradicting nothing: the destination is opened FileMode.Create so the head archive is replaced rather than merged (which is the mechanism behind the existing sidecar rule); SaveGame (270477) is the single funnel behind all five entry points and refuses a concurrent save by returning SaveResult.Fail("Save Failed: Already saving.") rather than throwing or queueing, guarded by a private static IsSaving set in PrepareToSave and cleared in SavingFinished; ForceIsSavingToFalse (270472) exists as an escape hatch for a stuck flag; and the ZIP is streamed by RunOnSaveThread (270700) on a per-save Thread named "Stationeers Save Writer" at BelowNormal priority, which is why a Harmony patch on the private worker runs off the main thread. Occasion: an overnight curation pass; the re-entrancy behaviour was the part with no prior page.

Open questions

None at creation.