Skip to content

Save File Library

Valkerran edited this page Oct 2, 2026 · 3 revisions

Save File Library

PCEdit.SaveFileHandler is a dependency-free net10.0 library that parses, edits and re-serializes Planet Crafter saves. It has no knowledge of PCEdit's UI and can be used on its own.

The format it implements is described in Save File Format.

The three layers

Each has a small interface so it can be substituted in tests or in a host's DI container.

Interface Implementation Responsibility
IPlanetCrafterSaveFileStore PlanetCrafterSaveFileStore File I/O — Load(path) / Save(path, save) / SaveCopy(source, target, save), including byte-order-mark handling
IPlanetCrafterSaveFileSerializer PlanetCrafterSaveFileSerializer Splits / joins the ten sections and the framing characters
IJsonRecordSerializer JsonRecordSerializer One section or one list item ⇄ a model, via System.Text.Json
flowchart LR
    A["Store<br/>bytes ⇄ string"] --> B["Serializer<br/>string ⇄ 10 sections"] --> C["JsonRecordSerializer<br/>section ⇄ model"]
Loading

Store

Load reads with File.ReadAllText, which strips any BOM. Save re-checks the target file's first bytes and writes UTF-8 with a BOM only if the file already had one — or if the path is new ("Save As" semantics). That is what keeps Steam saves BOM'd and Game Pass saves BOM-less.

Save is atomic (v1.3.0). It writes to a sibling .pcedit-tmp file, flushes it to the physical disk, then swaps it in with File.Move(overwrite: true), so an interrupted write cannot leave a truncated save behind. The BOM probe therefore has to read the original path, before the swap — reading the temp file instead would silently regress the Game Pass path.

Serializer

Deserialize is lenient about whitespace and line endings; Serialize reproduces the game's framing exactly. The section order is hard-coded and must stay in step with PlanetCrafterSaveFile.

The section split matches an @ only where the framing line breaks bracket it — lookaround rather than a consuming match, so two separators sharing a line break around an empty section are both still found. Splitting on the bare character mis-framed any save with an @ in free text; see Save File Format. A file yielding more than ten sections is now rejected with a clear error instead of being read past and silently truncated on the next save.

Record serializer

Wraps System.Text.Json with camelCase naming and WhenWritingNull ignore semantics, wraps a JsonException in an InvalidDataException naming the offending section index, and hosts GameDecimalConverter (every decimal is written with a fractional part — 1.0, never 1).

Using it

var records    = new JsonRecordSerializer();
var serializer = new PlanetCrafterSaveFileSerializer(records);
var store      = new PlanetCrafterSaveFileStore(serializer);

var save = store.Load(@"C:\...\Standard-2.json");

// Every model is an immutable record — edit with `with`, never by hand-copying.
save = save with
{
    Unlocks = save.Unlocks with { TerraTokens = save.Unlocks.TerraTokens + 500 }
};

store.Save(@"C:\...\Standard-2.json", save);   // framing preserved byte for byte

Model conventions

Everything in PCEdit.SaveFileHandler/Models/ follows the same rules. Getting these wrong is how data goes missing.

1. Immutable records. Every model — including the root PlanetCrafterSaveFile — is a sealed record with { get; init; } properties. Change a field with a with expression. Never hand-copy every property into a new instance: that is exactly how demandGrps / supplyGrps were once dropped on every save.

2. Required vs nullable. A field the game always writes is a required property; a field the game may omit is nullable. Get this wrong and either Save throws or the field is silently lost.

3. Version-added fields are nullable. A key introduced by a newer game build must be nullable (bool?, not bool) so that WhenWritingNull omits it — an older save that never had the key does not gain it, and still round-trips byte for byte. SaveFileUnlocks.LogisticsPaused is the worked example.

4. Explicit [JsonPropertyName] only for irregular keys. camelCase is applied automatically. Name the property explicitly only where the game's key is abbreviated: pos, rot, liId, pnls, count, linkedWo on WorldObject; woIds, demandGrps, supplyGrps on Inventory. Verify a new key against the real fixtures — a wrong name no longer loses data (the catch-all preserves the bytes) but the property will never populate.

5. Every leaf model has a [JsonExtensionData] dictionary. Unknown keys are preserved, not dropped.

6. WorldObject is converter-driven, not attribute-driven. WorldObjectConverter records each record's key order on read and replays it on write. Adding a key there means a case in its Read switch and in WriteKey, plus entries in HasValue and DefaultOrder — or simply leave the key to ExtensionData, which the converter still positions correctly.

7. Do not compare models for equality. Record value-equality now includes the ExtensionData dictionary, which compares by reference, so two models parsed from identical bytes are not ==. Nothing in the codebase does this; don't start.

Editing the root

Unlocks, Metadata and Statistics are singular init-only root properties — changing one means rebuilding the root with save with { Unlocks = … }.

Terraformations, Players, WorldObjects, Inventories and the rest are List<T>. The list instance stays mutable even though the property is init-only, so editing one entry means replacing that element in the existing list — no root rebuild. In the app layer that is SaveFileWorkspace.ReplaceTerraformation / ReplacePlayer / ReplaceInventory, matched by PlanetId / Id / Id.

Helpers

Type Purpose
PlanetHash.Of(planetId) Unity's GetStableHashCode — bridges planetId ⇄ WorldObject.planet
GameDecimalConverter Forces the game's N.0 decimal formatting
WorldObjectConverter Key-order-preserving reader/writer for section 3

The invariant

An unedited load → save is byte-identical on disk. It is asserted two ways in the test suite — a whole-file string comparison and an on-disk byte comparison including the BOM. Any change to this library must keep both green. See Testing.

Clone this wiki locally