Сериализация

Сериализация — механизм создания бинарных снимков мира целиком или отдельных сущностей, кластеров, чанков. Для бинарной сериализации используется StaticPack.


Настройка компонентов

Для поддержки сериализации компонентов необходимо:

  1. Указать Guid при регистрации (стабильный идентификатор типа)
  2. Реализовать хуки Write и Read на компоненте

Хуки Write и Read обязательны для сериализации через EntitiesSnapshot (для всех типов компонентов, включая unmanaged). Для снимков мира/кластера/чанка non-unmanaged типы также всегда используют эти хуки.

Unmanaged компонент:

public struct Position : IComponent, IComponentConfig<Position> {
    public float X, Y, Z;

    public ComponentTypeConfig<Position> Config() => new(
        guid: new Guid("b121594c-456e-4712-9b64-b75dbb37e611")
    );

    public void Write<TWorld>(ref BinaryPackWriter writer, World<TWorld>.Entity self)
        where TWorld : struct, IWorldType {
        writer.WriteFloat(X);
        writer.WriteFloat(Y);
        writer.WriteFloat(Z);
    }

    public void Read<TWorld>(ref BinaryPackReader reader, World<TWorld>.Entity self, byte version, bool disabled)
        where TWorld : struct, IWorldType {
        X = reader.ReadFloat();
        Y = reader.ReadFloat();
        Z = reader.ReadFloat();
    }
}

W.Types().Component<Position>();

Non-unmanaged компонент (содержит ссылочные поля):

public struct Name : IComponent, IComponentConfig<Name> {
    public string Value;

    public ComponentTypeConfig<Name> Config() => new(
        guid: new Guid("531dc870-fdf5-4a8d-a4c6-b4911b1ea1c3")
    );

    public void Write<TWorld>(ref BinaryPackWriter writer, World<TWorld>.Entity self)
        where TWorld : struct, IWorldType {
        writer.WriteString16(Value);
    }

    public void Read<TWorld>(ref BinaryPackReader reader, World<TWorld>.Entity self, byte version, bool disabled)
        where TWorld : struct, IWorldType {
        Value = reader.ReadString16();
    }
}

W.Types().Component<Name>();

Блочное копирование для unmanaged типов:

Для снимков мира/кластера/чанка unmanaged компоненты автоматически сериализуются блоком памяти вместо поэлементных вызовов Write/Read.

UnmanagedPackArrayStrategy<T> выполняет прямое копирование памяти — значительно быстрее поэлементной сериализации. Работает только для unmanaged типов. При несовпадении версий (миграция данных) система автоматически переключается на хуки Read. Стратегия по умолчанию определяется автоматически: UnmanagedPackArrayStrategy<T> для unmanaged типов, StructPackArrayStrategy<T> в остальных случаях.

Мульти-компоненты и Links хранят значения в общем сегментном хранилище. Стратегии блочной сериализации сегментов применяются автоматически для unmanaged типов значений. Для переопределения GUID или другой конфигурации реализуйте соответствующий интерфейс на типе:

// Мульти-компонент с кастомной конфигурацией
public struct Item : IMultiComponent, IMultiComponentConfig<Item> {
    public int Id;

    public ComponentTypeConfig<W.Multi<Item>> Config<TWorld>()
        where TWorld : struct, IWorldType => new(
        guid: new Guid("...")
    );

    public IPackArrayStrategy<Item> ElementPackStrategy()
        => new UnmanagedPackArrayStrategy<Item>();
}

W.Types().Multi<Item>();

// Links с кастомной конфигурацией
public struct MyLinkType : ILinksType, ILinksConfig<MyLinkType> {
    public ComponentTypeConfig<W.Links<MyLinkType>> Config<TWorld>()
        where TWorld : struct, IWorldType => new(
        guid: new Guid("...")
    );
}

W.Types().Links<MyLinkType>();

Полная конфигурация:

public struct Position : IComponent, IComponentConfig<Position> {
    public float X, Y, Z;

    public ComponentTypeConfig<Position> Config() => new(
        guid: new Guid("b121594c-456e-4712-9b64-b75dbb37e611"),
        version: 1,                  // версия схемы данных для миграции (по умолчанию — 0)
        noDataLifecycle: true        // отключить управление данными фреймворком (по умолчанию — false)
        // стратегия сериализации определяется автоматически: UnmanagedPackArrayStrategy<T> для unmanaged, StructPackArrayStrategy<T> в остальных случаях
    );

    // ... хуки Write/Read ...
}

W.Types().Component<Position>();

Настройка тегов

Теги настраиваются через реализацию ITagConfig<T>:

public struct IsPlayer : ITag, ITagConfig<IsPlayer> {
    public TagTypeConfig<IsPlayer> Config() => new(
        guid: new Guid("3a6fe6a2-9427-43ae-9b4a-f8582e3a5f90")
    );
}

public struct IsDead : ITag, ITagConfig<IsDead> {
    public TagTypeConfig<IsDead> Config() => new(
        guid: new Guid("d25b7a08-cbe6-4c77-bd8e-29ce7f748c30")
    );
}

W.Types()
    .Tag<IsPlayer>()
    .Tag<IsDead>();

Полная конфигурация:

public struct Poisoned : ITag, ITagConfig<Poisoned>,
                         ITrackableAdded, ITrackableDeleted {
    public TagTypeConfig<Poisoned> Config() => new(
        guid: new Guid("A1B2C3D4-...") // стабильный идентификатор для сериализации (по умолчанию — автоматически из имени типа)
    );
}

W.Types().Tag<Poisoned>();

Отслеживание изменений включается реализацией интерфейсов-маркеров (ITrackableAdded, ITrackableDeleted) на самом типе — см. Отслеживание изменений.

Все типы автоматически получают стабильный GUID, вычисленный из имени типа. Для переопределения реализуйте ITagConfig<T> на структуре тега с пользовательским guid.


Настройка событий

Для событий реализуется IEventConfig<T> — аналогично компонентам:

public struct OnDamage : IEvent, IEventConfig<OnDamage> {
    public float Amount;

    public EventTypeConfig<OnDamage> Config() => new(
        guid: new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
    );

    public void Write(ref BinaryPackWriter writer) {
        writer.WriteFloat(Amount);
    }

    public void Read(ref BinaryPackReader reader, byte version) {
        Amount = reader.ReadFloat();
    }
}

W.Types().Event<OnDamage>();

Снимок мира (World Snapshot)

Сохраняет полное состояние мира: все сущности, компоненты, теги, события и состояние отслеживания изменений.

Снимок мира сохраняет тик и всю историю трекинга. В поток записываются CurrentTick, CurrentLastTick и все TrackingBufferSize + 1 слотов истории — для фильтров AllAdded<T> / AllChanged<T> / AllDeleted<T>, методов HasAdded/HasChanged/HasDeleted на сущности, а также мирового трекинга создания (HasCreated). После загрузки tick-based запросы (включая использующие fromTick) возвращают те же результаты, что и до сохранения.

Конфигурация должна совпадать при загрузке. Значения TrackingBufferSize и TrackCreated целевого мира должны быть равны тем, что были при сохранении. Любое несовпадение приводит к StaticEcsException. Эти значения задаются в WorldConfig при создании мира — менять их между сохранением и загрузкой нельзя.

Каждый снимок начинается с 2-байтового заголовка версии формата (FormatVersion = 2) и 8-байтового размера снимка. Загрузка снимка, созданного несовместимой версией, приводит к StaticEcsException с понятным сообщением.

Сохранение и загрузка после инициализации:

byte[] worldSnapshot = W.Serializer.CreateWorldSnapshot();
W.Destroy();

CreateWorld();
W.Initialize();
// Все существующие сущности и события удаляются перед загрузкой
W.Serializer.LoadWorldSnapshot(worldSnapshot);

Дополнительные параметры:

// Сохранение в файл
W.Serializer.CreateWorldSnapshot("path/to/world.bin");

// С GZIP сжатием
byte[] compressed = W.Serializer.CreateWorldSnapshot(gzip: true);

// Фильтрация по кластерам
W.Serializer.CreateWorldSnapshot(clusters: new ushort[] { 0, 1 });

// Стратегия записи чанков
W.Serializer.CreateWorldSnapshot(strategy: ChunkWritingStrategy.SelfOwner);

// Без событий
W.Serializer.CreateWorldSnapshot(writeEvents: false);

// Без кастомных данных
W.Serializer.CreateWorldSnapshot(withCustomSnapshotData: false);

// Загрузка из файла (gzip определяется автоматически)
W.Serializer.LoadWorldSnapshot("path/to/world.bin");

// Загрузка сжатых данных (gzip определяется автоматически)
W.Serializer.LoadWorldSnapshot(compressed);

Все компоненты и теги автоматически получают стабильный Guid, вычисленный из имени типа. Вы можете переопределить Guid через конфигурацию для обеспечения стабильности при переименовании типов.


Снимок сущностей (Entities Snapshot)

Позволяет сохранять и загружать отдельные сущности с гранулярным контролем.

Сохранение сущностей:

// Создаём писатель сущностей
using var writer = W.Serializer.CreateEntitiesSnapshotWriter();

// Записываем конкретные сущности
foreach (var entity in W.Query().Entities()) {
    writer.Write(entity);
}

// Или записываем все сущности сразу
// writer.WriteAllEntities();

// Создаём снимок
byte[] snapshot = writer.CreateSnapshot();

// Или сохраняем в файл
// writer.CreateSnapshot("path/to/entities.bin");

Запись с одновременной выгрузкой:

using var writer = W.Serializer.CreateEntitiesSnapshotWriter();

// Записать и выгрузить — экономит память при стриминге
foreach (var entity in W.Query().Entities()) {
    writer.WriteAndUnload(entity);
}

// Или все сущности сразу
// writer.WriteAndUnloadAllEntities();

byte[] snapshot = writer.CreateSnapshot();

Загрузка сущностей (entitiesAsNew):

Параметр entitiesAsNew определяет, как загружаются сущности:

  • entitiesAsNew: false (по умолчанию) — сущности восстанавливаются в те же слоты (тот же EntityGID). Если слот уже занят — ошибка в DEBUG.
  • entitiesAsNew: true — сущности загружаются в новые слоты с новыми EntityGID. Связи между сущностями (Link, Links) могут указывать на неверные сущности.
// Загрузка в оригинальные слоты
W.Serializer.LoadEntitiesSnapshot(snapshot, entitiesAsNew: false);

// Загрузка как новые сущности
W.Serializer.LoadEntitiesSnapshot(snapshot, entitiesAsNew: true);

// С колбеком для каждой загруженной сущности
W.Serializer.LoadEntitiesSnapshot(snapshot, entitiesAsNew: true, onLoad: entity => {
    Console.WriteLine($"Loaded: {entity.PrettyString}");
});

Сохранение связей между сущностями (GID Store):

Чтобы корректно загружать сущности с entitiesAsNew: false, нужно сохранить хранилище глобальных идентификаторов:

// 1. Сохраняем сущности и GID Store
using var writer = W.Serializer.CreateEntitiesSnapshotWriter();
writer.WriteAllEntities();
byte[] entitiesSnapshot = writer.CreateSnapshot();
byte[] gidSnapshot = W.Serializer.CreateGIDStoreSnapshot();
W.Destroy();

// 2. Восстанавливаем мир с GID Store
CreateWorld();
W.Initialize();
W.Serializer.RestoreFromGIDStoreSnapshot(gidSnapshot);

// Новые сущности не займут слоты сохранённых
var newEntity = W.NewEntity<Default>();
newEntity.Set(new Position { X = 1 });

// 3. Загружаем сущности в оригинальные слоты — все связи корректны
W.Serializer.LoadEntitiesSnapshot(entitiesSnapshot, entitiesAsNew: false);

GID Store хранит информацию обо всех выданных идентификаторах. Это гарантирует, что новые сущности не займут слоты выгруженных сущностей, и все связи (Link, Links, EntityGID в данных) останутся корректными.


Хранилище GID (GID Store)

// Сохранить GID Store
byte[] gidSnapshot = W.Serializer.CreateGIDStoreSnapshot();

// С GZIP сжатием
byte[] gidCompressed = W.Serializer.CreateGIDStoreSnapshot(gzip: true);

// В файл
W.Serializer.CreateGIDStoreSnapshot("path/to/gid.bin");

// Со стратегией записи чанков
W.Serializer.CreateGIDStoreSnapshot(strategy: ChunkWritingStrategy.SelfOwner);

// Фильтрация по кластерам
W.Serializer.CreateGIDStoreSnapshot(clusters: new ushort[] { 0, 1 });

// Восстановление GID Store в уже инициализированном мире
// Все сущности удаляются, состояние сбрасывается
CreateWorld();
W.Initialize();
W.Serializer.RestoreFromGIDStoreSnapshot(gidSnapshot);

Снимки кластеров и чанков

Кластер:

// Сохранить кластер
byte[] clusterSnapshot = W.Serializer.CreateClusterSnapshot(clusterId: 1);

// С данными для загрузки как новые сущности
byte[] clusterWithEntities = W.Serializer.CreateClusterSnapshot(
    clusterId: 1,
    withEntitiesData: true  // необходимо для entitiesAsNew при загрузке
);

// Выгрузить кластер из памяти
ReadOnlySpan<ushort> clusters = stackalloc ushort[] { 1 };
W.Query().BatchUnload(EntityStatusType.Any, clusters: clusters);

// Загрузить кластер из снимка
W.Serializer.LoadClusterSnapshot(clusterSnapshot);

// Загрузить как новые сущности в другой кластер
W.Serializer.LoadClusterSnapshot(clusterWithEntities,
    new EntitiesAsNewParams(entitiesAsNew: true, clusterId: 2)
);

Чанк:

// Сохранить чанк
byte[] chunkSnapshot = W.Serializer.CreateChunkSnapshot(chunkIdx: 0);

// Выгрузить чанк из памяти
ReadOnlySpan<uint> unloadChunks = stackalloc uint[] { 0 };
W.Query().BatchUnload(EntityStatusType.Any, unloadChunks);

// Загрузить чанк из снимка
W.Serializer.LoadChunkSnapshot(chunkSnapshot);

По умолчанию снимки кластеров и чанков не хранят данные идентификаторов сущностей (только данные компонентов). Если нужно загружать их как новые сущности (entitiesAsNew: true), при создании снимка укажите withEntitiesData: true.

Снимки кластеров и чанков не сохраняют данные отслеживания изменений. В отличие от снимка мира, эти частичные снимки предназначены для стриминга и миграции, когда у целевого мира свой независимый тик и своё состояние трекинга. Загрузка снимка кластера или чанка не изменяет CurrentTick, CurrentLastTick и историю трекинга целевого мира — восстанавливаются только сущности, компоненты и теги. Если нужна согласованная история трекинга через частичные снимки — используйте снимок мира.


Комплексный пример стриминга:

void PrintCounts(string label) {
    Console.WriteLine($"{label} — Всего: {W.CalculateEntitiesCount()} | Загружено: {W.CalculateLoadedEntitiesCount()}");
}

// Сохраняем отдельные сущности
using var writer = W.Serializer.CreateEntitiesSnapshotWriter();
foreach (var entity in W.Query().Entities()) {
    writer.WriteAndUnload(entity);
}
byte[] entitiesSnapshot = writer.CreateSnapshot();
PrintCounts("После выгрузки сущностей"); // Всего: 2 | Загружено: 0

// Создаём кластер и наполняем его
const ushort ZONE_CLUSTER = 1;
W.RegisterCluster(ZONE_CLUSTER);
struct ZoneEntityType : IEntityType { }
W.NewEntities<ZoneEntityType>(count: 2000, clusterId: ZONE_CLUSTER);
PrintCounts("После создания кластера"); // Всего: 2002 | Загружено: 2000

// Сохраняем и выгружаем кластер
byte[] clusterSnapshot = W.Serializer.CreateClusterSnapshot(ZONE_CLUSTER);
ReadOnlySpan<ushort> zoneClusters = stackalloc ushort[] { ZONE_CLUSTER };
W.Query().BatchUnload(EntityStatusType.Any, clusters: zoneClusters);
PrintCounts("После выгрузки кластера"); // Всего: 2002 | Загружено: 0

// Создаём чанк и наполняем его
var chunkIdx = W.FindNextSelfFreeChunk().ChunkIdx;
W.RegisterChunk(chunkIdx, clusterId: 0);
for (int i = 0; i < 100; i++) {
    W.NewEntityInChunk<ZoneEntityType>(chunkIdx: chunkIdx);
}
PrintCounts("После создания чанка"); // Всего: 2102 | Загружено: 100

// Сохраняем и выгружаем чанк
byte[] chunkSnapshot = W.Serializer.CreateChunkSnapshot(chunkIdx);
ReadOnlySpan<uint> unloadChunks = stackalloc uint[] { chunkIdx };
W.Query().BatchUnload(EntityStatusType.Any, unloadChunks);
PrintCounts("После выгрузки чанка"); // Всего: 2102 | Загружено: 0

// Сохраняем GID Store и пересоздаём мир
byte[] gidSnapshot = W.Serializer.CreateGIDStoreSnapshot();
W.Destroy();

CreateWorld();
W.Initialize();
W.Serializer.RestoreFromGIDStoreSnapshot(gidSnapshot);

// Загружаем в любом порядке
W.Serializer.LoadClusterSnapshot(clusterSnapshot);
PrintCounts("После загрузки кластера"); // Всего: 2102 | Загружено: 2000

W.Serializer.LoadEntitiesSnapshot(entitiesSnapshot);
PrintCounts("После загрузки сущностей"); // Всего: 2102 | Загружено: 2002

W.Serializer.LoadChunkSnapshot(chunkSnapshot);
PrintCounts("После загрузки чанка"); // Всего: 2102 | Загружено: 2102

Миграция данных

Версионирование компонентов:

Параметр version в хуке Read позволяет мигрировать данные между версиями схемы:

public struct Position : IComponent, IComponentConfig<Position> {
    public float X, Y, Z;

    public ComponentTypeConfig<Position> Config() => new(
        guid: new Guid("b121594c-456e-4712-9b64-b75dbb37e611"),
        version: 1  // была версия 0, теперь 1
    );

    public void Write<TWorld>(ref BinaryPackWriter writer, World<TWorld>.Entity self)
        where TWorld : struct, IWorldType {
        writer.WriteFloat(X);
        writer.WriteFloat(Y);
        writer.WriteFloat(Z);
    }

    public void Read<TWorld>(ref BinaryPackReader reader, World<TWorld>.Entity self, byte version, bool disabled)
        where TWorld : struct, IWorldType {
        X = reader.ReadFloat();
        Y = reader.ReadFloat();
        // В версии 0 не было Z — задаём значение по умолчанию
        Z = version >= 1 ? reader.ReadFloat() : 0f;
    }
}

// Регистрация
W.Types().Component<Position>();

Миграция удалённых типов:

Если компонент, тег или событие было удалено из кода, по умолчанию данные пропускаются автоматически. Для кастомной обработки:

// Миграция удалённого компонента
W.Serializer.SetComponentDeleteMigrator(
    new Guid("guid-удалённого-компонента"),
    (ref BinaryPackReader reader, W.Entity entity, byte version, bool disabled) => {
        // Прочитать ВСЕ данные и выполнить кастомную логику
    }
);

// Миграция удалённого тега
W.Serializer.SetMigrator(
    new Guid("guid-удалённого-тега"),
    (W.Entity entity) => {
        // Кастомная логика
    }
);

// Миграция удалённого события
W.Serializer.SetEventDeleteMigrator(
    new Guid("guid-удалённого-события"),
    (ref BinaryPackReader reader, byte version) => {
        // Прочитать ВСЕ данные и выполнить кастомную логику
    }
);

При добавлении новых типов старые снимки загружаются корректно — новые компоненты просто отсутствуют на загруженных сущностях.


Колбеки

Глобальные колбеки:

// Вызываются при всех типах снимков (World, Cluster, Chunk, Entities)

// Перед созданием снимка
W.Serializer.RegisterPreCreateSnapshotCallback(param => {
    Console.WriteLine($"Создание снимка типа: {param.Type}");
});

// После создания снимка
W.Serializer.RegisterPostCreateSnapshotCallback(param => {
    Console.WriteLine($"Снимок создан: {param.Type}");
});

// Перед загрузкой снимка
W.Serializer.RegisterPreLoadSnapshotCallback(param => {
    Console.WriteLine($"Загрузка снимка: {param.Type}, AsNew: {param.EntitiesAsNew}");
});

// После загрузки снимка
W.Serializer.RegisterPostLoadSnapshotCallback(param => {
    Console.WriteLine($"Снимок загружен: {param.Type}");
});

Фильтрация по типу снимка:

W.Serializer.RegisterPreCreateSnapshotCallback(param => {
    if (param.Type == SnapshotType.World) {
        Console.WriteLine("Сохранение мира");
    }
});

Колбеки для каждой сущности:

// После сохранения каждой сущности
W.Serializer.RegisterPostCreateSnapshotEachEntityCallback((entity, param) => {
    Console.WriteLine($"Сохранена: {entity.PrettyString}");
});

// После загрузки каждой сущности
W.Serializer.RegisterPostLoadSnapshotEachEntityCallback((entity, param) => {
    Console.WriteLine($"Загружена: {entity.PrettyString}");
});

Кастомные данные в снимках

Глобальные кастомные данные:

// Добавить произвольные данные в снимок (например, данные систем или сервисов)
W.Serializer.SetSnapshotHandler(
    new Guid("57c15483-988a-47e7-919c-51b9a7b957b5"), // уникальный guid типа данных
    version: 0,
    writer: (ref BinaryPackWriter writer, SnapshotWriteParams param) => {
        writer.WriteDateTime(DateTime.Now);
    },
    reader: (ref BinaryPackReader reader, ushort version, SnapshotReadParams param) => {
        var savedTime = reader.ReadDateTime();
        Console.WriteLine($"Время сохранения: {savedTime}");
    }
);

Кастомные данные для каждой сущности:

W.Serializer.SetSnapshotHandlerEachEntity(
    new Guid("68d26594-1a9b-48f8-b2de-71c0a8b068c6"),
    version: 0,
    writer: (ref BinaryPackWriter writer, W.Entity entity, SnapshotWriteParams param) => {
        // Записать дополнительные данные для сущности
    },
    reader: (ref BinaryPackReader reader, W.Entity entity, ushort version, SnapshotReadParams param) => {
        // Прочитать дополнительные данные для сущности
    }
);

Сериализация событий

// Сохранить события
byte[] eventsSnapshot = W.Serializer.CreateEventsSnapshot();

// С GZIP сжатием
byte[] eventsCompressed = W.Serializer.CreateEventsSnapshot(gzip: true);

// В файл
W.Serializer.CreateEventsSnapshot("path/to/events.bin");

// Загрузить события
W.Serializer.LoadEventsSnapshot(eventsSnapshot);

// Из файла
W.Serializer.LoadEventsSnapshot("path/to/events.bin");

При использовании CreateWorldSnapshot события сохраняются автоматически (если не указано writeEvents: false). Отдельная сериализация событий нужна при использовании EntitiesSnapshot.


Сериализация ресурсов

IResource имеет четыре опциональных метода с дефолтной реализацией. Переопределите Guid(), чтобы подключить ресурс к автоматической сериализации; остальные нужны только если тип не unmanaged.

public interface IResource {
    public Guid? Guid()                                              => null;
    public byte  Version()                                            => 0;
    public void  Write(ref BinaryPackWriter writer)                   {}
    public void  Read(ref BinaryPackReader reader, byte version)      {}
}

Правила валидации (проверяются при первом SetResource)

  • Ресурсы без Guid (дефолт null) молча не попадают в снапшот.
  • Если Guid не пуст, тип не unmanaged (ссылочный тип или struct, содержащий ссылки) и нет одного из Write/Read — выбрасывается StaticEcsException.
  • Дубликат Guid между двумя singleton-ресурсами разных типов ассертится в DEBUG.

Выбор формата

  • Unmanaged struct без Write/Read — фреймворк пишет/читает Unsafe.SizeOf<T>() сырых байт напрямую из Resources<TWorld, T>.Value или из box-а именованного ресурса.
  • Не-unmanaged тип, либо несовпадение версии для unmanaged — вызывается Read(ref reader, savedVersion) для миграции; на сохранение — Write(ref writer).

Примеры

// Unmanaged singleton-ресурс — Write/Read не требуются
public struct GameSettings : IResource {
    public float MasterVolume;
    public bool  Vsync;
    public Guid? Guid() => new("11111111-2222-3333-4444-555555555555");
}

// Не-unmanaged ресурс — Write/Read обязательны
public class AssetCache : IResource {
    public Dictionary<string, byte[]> Items = new();

    public Guid? Guid() => new("22222222-3333-4444-5555-666666666666");
    public byte  Version() => 1;

    public void Write(ref BinaryPackWriter writer) {
        writer.WriteInt(Items.Count);
        foreach (var kvp in Items) {
            writer.WriteString16(kvp.Key);
            writer.WriteByteArray(kvp.Value);
        }
    }

    public void Read(ref BinaryPackReader reader, byte version) {
        Items.Clear();
        var count = reader.ReadInt();
        for (var i = 0; i < count; i++) {
            var key = reader.ReadString16();
            Items[key] = reader.ReadByteArray();
        }
    }
}

Что попадает в снапшот

  • Singleton-ресурсы (SetResource<T>(value, …)) — ключ — Guid типа T.
  • Именованные ресурсы (SetResource<T>(key, value, …)) — ключ — Guid типа T плюс строковый ключ.

WorldSnapshot автоматически включает обе группы (между событиями и пользовательскими SnapshotHandlers). Чтобы сохранить или загрузить только ресурсы, есть отдельный API по образцу событий:

// Сохранить
byte[] snapshot = W.Serializer.CreateResourcesSnapshot();
W.Serializer.CreateResourcesSnapshot("resources.bin", gzip: true);

// Загрузить
W.Serializer.LoadResourcesSnapshot(snapshot);
W.Serializer.LoadResourcesSnapshot("resources.bin");

При загрузке записи, Guid которых не зарегистрирован сейчас, молча пропускаются (как и для удалённых компонентов или событий) — добавление или удаление типа ресурса между save и load forward-совместимо.


Сериализация систем

ISystem имеет те же четыре опциональных метода, что и IResource. Переопределите Guid(), чтобы подключить систему к сериализации.

public interface ISystem {
    public void Init()             { }
    public void Update()           { }
    public bool UpdateIsActive()   => true;
    public void Destroy()          { }

    public Guid? Guid()                                              => null;
    public byte  Version()                                            => 0;
    public void  Write(ref BinaryPackWriter writer)                   {}
    public void  Read(ref BinaryPackReader reader, byte version)      {}
}

Правила валидации (проверяются при Add<TSystem>)

  • Системы без Guid молча не попадают в снапшот.
  • Любая система, объявляющая Guid, обязана переопределить и Write, и Read — независимо от лэйаута. Unmanaged fast-path не применяется: инстансы систем хранятся упакованными в SystemData.System, фреймворк всегда вызывает хуки. Их отсутствие выбрасывает StaticEcsException из Add.
  • Дубликат Guid внутри одной группы Systems<TSystemsType> ассертится в DEBUG.

Пример

public class SpawnerSystem : ISystem {
    private int _nextId;
    private float _accumulator;

    public Guid? Guid() => new("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
    public byte  Version() => 1;

    public void Update() { /* логика спавна */ }

    public void Write(ref BinaryPackWriter writer) {
        writer.WriteInt(_nextId);
        writer.WriteFloat(_accumulator);
    }

    public void Read(ref BinaryPackReader reader, byte version) {
        _nextId = reader.ReadInt();
        _accumulator = reader.ReadFloat();
    }
}

Systems<TSystemsType>.Create принимает явный Guid группы

GameSys.Create(baseSize: 64);                                                // Guid = typeof(GameSystems).GuidFromAQN()
GameSys.Create(baseSize: 64, snapshotGuid: new("…stable-pipeline-guid…"));   // явный, переживает переименования namespace-а

Группа регистрируется в реестре снапшотов мира на Create и снимается на Destroy. WorldSnapshot обходит все зарегистрированные группы и пишет по секции на каждую; при загрузке секции с незарегистрированным Guid молча пропускаются.

Отдельный API

Зеркалирует Create/LoadEventsSnapshot. Обходит все зарегистрированные Systems<TSystemsType>-группы (вместе с их scoped-ресурсами):

// Сохранить
byte[] snapshot = W.Serializer.CreateSystemsSnapshot();
W.Serializer.CreateSystemsSnapshot("systems.bin", gzip: true);

// Загрузить
W.Serializer.LoadSystemsSnapshot(snapshot);
W.Serializer.LoadSystemsSnapshot("systems.bin");

Каждая секция группы содержит её scoped-ресурсы (singleton + named), затем все системы внутри неё, объявляющие Guid.


Пользовательский GUID для стабильности

Все типы автоматически получают стабильный Guid, вычисленный из имени типа (assembly-qualified name). При переименовании или перемещении типа автоматический GUID изменится — что нарушит совместимость с существующими снимками. Чтобы этого избежать, задайте фиксированный GUID:

// Пример: сохранение всех сущностей
using var writer = W.Serializer.CreateEntitiesSnapshotWriter();
writer.WriteAllEntities();
byte[] snapshot = writer.CreateSnapshot();
byte[] gidSnapshot = W.Serializer.CreateGIDStoreSnapshot();
byte[] eventsSnapshot = W.Serializer.CreateEventsSnapshot();

Сжатие (GZIP)

Все методы создания снимков поддерживают GZIP сжатие через gzip: true. Все методы загрузки (LoadWorldSnapshot, LoadClusterSnapshot, LoadChunkSnapshot, LoadEventsSnapshot, LoadResourcesSnapshot, LoadSystemsSnapshot, RestoreFromGIDStoreSnapshot) автоматически определяют gzip по байтовой последовательности — передавайте байты или путь напрямую без флага.

// Мир — gzip определяется автоматически при загрузке
byte[] snapshot = W.Serializer.CreateWorldSnapshot(gzip: true);
W.Serializer.LoadWorldSnapshot(snapshot);

// Кластер — gzip определяется автоматически при загрузке
byte[] cluster = W.Serializer.CreateClusterSnapshot(1, gzip: true);
W.Serializer.LoadClusterSnapshot(cluster);

// Чанк — gzip определяется автоматически при загрузке
byte[] chunk = W.Serializer.CreateChunkSnapshot(0, gzip: true);
W.Serializer.LoadChunkSnapshot(chunk);

// GID Store
byte[] gid = W.Serializer.CreateGIDStoreSnapshot(gzip: true);

// События — gzip определяется автоматически при загрузке
byte[] events = W.Serializer.CreateEventsSnapshot(gzip: true);
W.Serializer.LoadEventsSnapshot(events);

// Ресурсы — gzip определяется автоматически при загрузке
byte[] resources = W.Serializer.CreateResourcesSnapshot(gzip: true);
W.Serializer.LoadResourcesSnapshot(resources);

// Системы — gzip определяется автоматически при загрузке
byte[] systems = W.Serializer.CreateSystemsSnapshot(gzip: true);
W.Serializer.LoadSystemsSnapshot(systems);

// Файлы — для мира/кластера/чанка/событий/ресурсов/систем gzip определяется автоматически при загрузке
W.Serializer.CreateWorldSnapshot("world.bin", gzip: true);
W.Serializer.LoadWorldSnapshot("world.bin");

This site uses Just the Docs, a documentation theme for Jekyll.