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 behindNewSave,Save,SaveAs,QuickSaveandAutoSave, and its second check is:
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.pyrepack()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 (
SaveLoadConstantsnames,SaveHelper.Savewrite 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.mdrather than duplicated. - 2026-09-24 (game version 0.2.6428.27798): re-read
SaveLoadConstants(270844-270852) and the write order in the privateSaveHelper.Saveworker (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 openedFileMode.Createso 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 returningSaveResult.Fail("Save Failed: Already saving.")rather than throwing or queueing, guarded by a private staticIsSavingset inPrepareToSaveand cleared inSavingFinished;ForceIsSavingToFalse(270472) exists as an escape hatch for a stuck flag; and the ZIP is streamed byRunOnSaveThread(270700) on a per-saveThreadnamed "Stationeers Save Writer" atBelowNormalpriority, 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.