Skip to content

Save File Library

Valkerran edited this page Oct 10, 2026 · 6 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 strips a UTF-8 BOM and decodes strictly (v1.7.2): a file that is not valid UTF-8 is refused with an InvalidDataException, where it used to load with U+FFFD substituted and have that written back over the original on the next save. A UTF-16 or UTF-32 file is refused the same way. 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.

Records inside a list section split the same way, on a | followed by a line break (v1.7.2), so a | in player-typed text stays inside its JSON string.

Record serializer

Wraps System.Text.Json with camelCase naming and WhenWritingNull ignore semantics, wraps a JsonException in an InvalidDataException, and hosts the converter for GameNumber (see Number formatting).

Since v1.7.2 the error names the section, record and key at fault, for example Save-file section 4 (inventories), record 2 is not valid: … Path: $.id. The section number is the zero-based index from the format table; the record number counts from 1. The serializer also runs with RespectNullableAnnotations, so a JSON null in a non-nullable property is rejected, not dropped on the next save.

Since v1.7.3 three more malformed shapes are refused on read, each naming the key:

  • A key that appears twice (AllowDuplicateProperties = false). WorldObjectConverter parses the record itself, so the option does not reach it and it checks for itself.
  • A null in any key a model names, optional keys included. An unknown key holding null is still preserved as it came.
  • A missing key the game always writes ([JsonRequired], below).

These are read-side checks only: building or editing a model in code and serializing it behaves exactly as before.

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. Every record Id is required (v1.7.2): a record with no id is refused, where it used to load as id 0 and be written back that way.

Since v1.7.3 every property on an attribute-driven model is one or the other, never neither: a value key the game always writes carries [JsonRequired], so a record without it is refused instead of gaining an invented 0 on save. Use [JsonRequired], not C# required, for these, so initializers in tests and app code are not forced to set them. ModelRequiredKeyTests enforces the rule for every property, including ones added later. If a real save from some game version turns out to lack such a key, make that one key nullable, as logisticsPaused was.

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
GameNumber A float the game wrote, kept with its original text so an unedited value is written back exactly (v1.7.2; replaced GameDecimalConverter)
WorldObjectConverter Key-order-preserving reader/writer for section 3
MinimalJsonEncoder Escapes only what JSON requires (a quote, a backslash, control characters), so player-typed text such as Café & Bob's is written back as-is instead of as \uXXXX escapes (v1.7.1)

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