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
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
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
}Saveserializes 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, bitr= rowr), and the component blob.Loadis two-pass: fully validates (magic, version, table, counts, row caps, masks, blob sizes, no trailing bytes) before mutating anything, then destroys all live entities, restoresTick, recreates rows in canonical order, and remaps internalEntityfields (including inside nested value-type fields, depth ≤ 3) from saved keys to new entities. Corrupt input throwsInvalidDataExceptionwithout destroying the session. SnapshotTypeIndexvalues must already be registered — touchTypeRegistry<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 touchesTick: it creates entities in canonical row order, remaps internal entity references using the caller-suppliedsrcIndices/srcVersions(seeCaptureCanonicalKeys; null or length-mismatched tables skip the remap but still append), and addsposOffsettoLocalTransform/PrevTransformwhen non-zero. Returns created entities in canonical row order.Hashincludes the tick — identical content at different ticks hashes differently. Use it for replay-determinism tests.GetComponentTypeNamesreads only the header and returns distinct sorted names; unregistered indices surface as"T<index>".
EntityScene
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
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.