Skip to content

Serialization API

GodotECS.Serialize (plus GodotECS.Core.EntityScene) — deterministic little-endian binary snapshots, prefabs, and save slots. Source: addons/godotecs/src/serialize/.

Format v3 (all little-endian): header magic u32 ('GECS'), formatVersion u32 (=3), tick u32, archetypeCount u32, then per archetype (id, typeCount, per non-shared type: typeIndex/size/compVersion, sharedCount, per shared: typeIndex/size/compVersion/payload); body: chunkCount u32, then per chunk (archetypeId, count, keyLen + per-row entity [index, version], maskLen + enableable mask bytes, blobLen + concatenated non-shared components in canonical column order). Canonical order: archetypes by id, chunks in creation order, rows 0..Count-1. The FNV-1a hash covers canonical bytes including tick but excluding entity index/version, so Save → Load → Hash round-trips stably. Load/Append reject v1/v2 with an error. TypeIndex values follow runtime registration order — stable for identical registration sequences, not across builds (stable IDs are a Source Generator task).

BinaryReader and BinaryWriter

csharp
public struct BinaryWriter
{
    public BinaryWriter(int capacity = 256);
    public int Length { get; }
    public void WriteInt(int v);
    public void WriteUInt(uint v);
    public void WriteFloat(float v);
    public void WriteBytes(System.ReadOnlySpan<byte> b);  // empty span: no-op
    public byte[] ToArray();
}

public struct BinaryReader
{
    public BinaryReader(byte[] data);   // throws ArgumentNullException on null
    public bool End { get; }
    public int Position { get; }
    public int Remaining { get; }
    public int ReadInt();               // throws InvalidDataException when truncated
    public uint ReadUInt();
    public float ReadFloat();
    public byte[] ReadBytes(int count);
    public void Skip(int count);
}

Growable little-endian buffer (Write*) and bounds-checked cursor (Read*/Skip), both throwing InvalidDataException ("Snapshot troncato…") on overread.

Snapshot

csharp
public static class Snapshot
{
    public const uint Magic = 0x53434547;   // 'G','E','C','S' in LE
    public const uint FormatVersion = 3;
    public static byte[] Save(World world);                                    // canonical bytes, tick included
    public static void Load(World world, byte[] data);                         // replaces contents, sets Tick
    public static ulong Hash(World world);                                     // FNV-1a 64 over Save bytes
    public static ulong HashBytes(byte[] data);
    public static Entity[] Append(World world, byte[] data, Godot.Vector3 posOffset,
        int[] srcIndices, int[] srcVersions);                                  // additive; returns created
    public static void CaptureCanonicalKeys(World world, out int[] indices, out int[] versions);
    public static string[] GetComponentTypeNames(byte[] data);                 // header-only; sorted
}
  • Save serializes archetypes with at least one non-empty chunk (shared+enableable columns are rejected); per chunk it writes entity keys, the enableable mask (ceil(count/8) bytes per enableable column, bit r = row r), and the component blob.
  • Load is two-pass: fully validates (magic, version, table, counts, row caps, masks, blob sizes, no trailing bytes) before mutating anything, then destroys all live entities, restores Tick, recreates rows in canonical order, and remaps internal Entity fields (including inside nested value-type fields, depth ≤ 3) from saved keys to new entities. Corrupt input throws InvalidDataException without destroying the session. Snapshot TypeIndex values must already be registered — touch TypeRegistry<T> for every component before loading, or the load reports which index is missing. Size mismatches against the registry also throw.
  • Append (used by prefabs) never clears and never touches Tick: it creates entities in canonical row order, remaps internal entity references using the caller-supplied srcIndices/srcVersions (see CaptureCanonicalKeys; null or length-mismatched tables skip the remap but still append), and adds posOffset to LocalTransform/PrevTransform when non-zero. Returns created entities in canonical row order.
  • Hash includes the tick — identical content at different ticks hashes differently. Use it for replay-determinism tests.
  • GetComponentTypeNames reads only the header and returns distinct sorted names; unregistered indices surface as "T<index>".

EntityScene

csharp
public partial class EntityScene : Godot.Resource
{
    public byte[] Data { get; set; }
    public int EntityCount { get; set; }
    public string[] ComponentNames { get; set; }
    // Origin keys in canonical row order for internal Entity-reference remap.
    // Absent or length-mismatched => Instantiate skips the remap (still appends + offsets).
    public int[] SourceIndices { get; set; }
    public int[] SourceVersions { get; set; }
    public static EntityScene FromWorld(World world);
    public Entity Instantiate(World world, in Godot.Vector3 pos);  // first created, or Entity.Null
}

A Godot Resource wrapping a snapshot plus metadata, so prefabs stay editor-inspectable. FromWorld captures Data, live count, component names, and canonical keys. Instantiate appends without clearing and returns the first created entity (Entity.Null when empty). Cold path — allocations are fine.

SaveSlots

csharp
public static class SaveSlots
{
    public const string SaveDir = "user://saves";
    public const string Extension = ".gecs";
    public const int MaxNameLength = 64;
    // Keep [A-Za-z0-9_-] (rest -> '_'), cap length, trim '_'; empty => "slot".
    public static string SanitizeSlotName(string slot);
    public static string SlotPath(string slot);              // SaveDir + sanitized + Extension
    public static double SaveSlot(World world, string slot); // returns elapsed ms (serialize + I/O)
    public static double LoadSlot(World world, string slot); // returns elapsed ms; missing => throw
}

File-backed slots under user://saves/*.gecs. Names are sanitized (no traversal, no paths, no .., never empty). Both operations time serialization plus disk I/O with a stopwatch and return milliseconds. SaveSlot creates the directory recursively and throws InvalidOperationException when creation or opening for write fails; LoadSlot throws when the slot is absent or unreadable.

GodotECS docs — version 0.1.0. All rights reserved until a license is added.