SaveSystem (Core)

Namespace: Vortex.Core.SaveSystem, Vortex.Core.SaveSystem.Bus, Vortex.Core.SaveSystem.Abstraction, Vortex.Core.SaveSystem.Model Сборка: ru.vortex.save Платформа: .NET Standard 2.1+


Назначение

Система сохранения и загрузки данных. Две независимые шины:

  • SaveController — слоты сохранения: процессы save/load, реестр ISaveable-модулей, асинхронный сбор/раздача данных. Данные хранятся как Dictionary<string, Dictionary<string, string>> — иерархия модуль → ключ → значение (строки).
  • GlobalSaveController — глобальное хранилище межсессионных данных, которые переживают слоты, новые игры и перезапуски. Описано в разделе Глобальное хранилище.

Возможности слотов:

  • SaveController — шина: Save(), Load(), Remove(), GetIndex()
  • Очередь операций: Save/Load/Remove исполняются строго по одной, со слиянием однотипных операций одного GUID
  • ISaveable — интерфейс для модулей, данные которых подлежат сохранению
  • Асинхронный сбор данных при Save, асинхронная раздача при Load (UniTask)
  • SaveProcessData — двухуровневый прогресс (глобальный + модульный)
  • События: OnSaveRequest, OnSaveStart, OnSaveComplete, OnLoadStart, OnLoadComplete, OnRemove
  • SaveSummary — сводка сохранения (имя, дата, версия приложения, необязательные метаблоки, XML-сериализуемая)
  • Метаблоки сводки (SaveController.AddMeta) — превью, маркеры и прочие данные от независимых контроллеров, без правки имени сейва
  • SaveReactor — блок коррекции устаревшего сейва: правка сырой строки до разбора в окне версий
  • Автогенерация GUID для новых сохранений

Вне ответственности:

  • Физическое хранение (PlayerPrefs, файлы) — Unity-слой (драйверы)
  • Сжатие тела слота — Unity-слой (драйвер слотов); контейнер глобального хранилища кодирует Core-контроллер
  • Шифрование — не выполняется; для данных глобального хранилища — забота модуля
  • UI-отображение прогресса — Unity-слой

Зависимости

Зависимость Назначение
Vortex.Core.System SystemController<T, TD>, ProcessData
Vortex.Core.Extensions Crypto.GetNewGuid(), DictionaryExt.AddNew()
Vortex.Core.LoggerSystem Log.Print() при ошибках
Vortex.Core.ComplexModelSystem ComplexModel<T> — основа GlobalModel
Vortex.Core.AppSystem App.OnStateChanged (немедленная запись в Unfocused/Stopping), App.Exit() (fail-fast)
Vortex.Core.SettingsSystem Settings.Data() — число копий и fail-fast; расширение SettingsModel через asmref
UniTask UniTask, CancellationToken (в ISaveable, IProcess)

Использование

Реализация ISaveable

public class InventoryController : ISaveable
{
    private ProcessData _processData = new("Inventory");

    public string GetSaveId() => "Inventory";

    public async UniTask<Dictionary<string, string>> GetSaveData(CancellationToken ct)
    {
        var data = new Dictionary<string, string>();
        data["items"] = JsonUtility.ToJson(items);
        data["gold"] = gold.ToString();
        return data;
    }

    public ProcessData GetProcessInfo() => _processData;

    public async UniTask OnLoad(CancellationToken ct)
    {
        var data = SaveController.GetData("Inventory");
        if (data.TryGetValue("items", out var json))
            items = JsonUtility.FromJson<ItemList>(json);
        if (data.TryGetValue("gold", out var g))
            gold = int.Parse(g);
    }
}

Сохранение / загрузка

// Регистрация
SaveController.Register(inventoryController);

// Сохранение (новый GUID). Операция встаёт в очередь; await — до её завершения
var guid = await SaveController.Save("Quick Save");   // null — отклонено, отменено или не удалось

// Сохранение (перезапись)
await SaveController.Save("Quick Save", existingGuid);

// Загрузка
await SaveController.Load(guid);

// Список сохранений
var saves = SaveController.GetIndex();
foreach (var (guid, summary) in saves)
    Debug.Log($"{summary.Name} — {summary.Date}");

// Удаление
SaveController.Remove(guid).Forget();

Ждать операцию не обязательно: Forget() оставляет её в очереди, порядок исполнения от этого не меняется.

Метаданные сводки

Независимый контроллер подписывается на событие сохранения и пишет свой метаблок — точке сохранения о нём знать не нужно:

public static class DifficultySaveMeta
{
    public const string MetaKey = "difficulty";

    [RuntimeInitializeOnLoadMethod]
    private static void Init()
    {
        SaveController.OnSaveRequest -= AddMeta;
        SaveController.OnSaveRequest += AddMeta;
    }

    private static void AddMeta() => SaveController.AddMeta(MetaKey, Difficulty.Current.ToString());
}

// Чтение — из сводки, без загрузки сейва
var difficulty = summary.GetMeta(DifficultySaveMeta.MetaKey);   // null — блока нет

Сохранение может исполниться не сразу, а после операций, стоящих в очереди раньше. Поэтому окон записи метаблока два:

Событие Когда AddMeta пишет Для чего
OnSaveRequest синхронно внутри Save, до постановки в очередь в метаданные запроса — они едут с операцией и попадают в сводку при исполнении состояние момента запроса: превью, режим, тип сейва
OnSaveStart на исполнении, после BeginSummary и метаданных запроса прямо в сводку исполняемого цикла состояние момента записи

Вне этих окон AddMeta бесполезен — запись сбросится следующим циклом. Повторный ключ в OnSaveStart перезаписывает значение из запроса. Отклонённый запрос OnSaveRequest не поднимает. Вызывать Save из обработчика OnSaveRequest нельзя — запрос отклоняется с ошибкой в лог. Выключенные в настройках блоки драйвер отбрасывает (Unity-слой, SaveSettings).


Архитектура

SaveController : SystemController<SaveController, IDriver>
  ├── SaveDataIndex: Dictionary<string, Dictionary<string, string>>
  │    └── модуль (SaveId) → { ключ → значение }
  ├── Saveables: HashSet<ISaveable>
  ├── State: SaveControllerStates
  ├── ProcessData: SaveProcessData
  │
  ├── Save(name, guid?) → UniTask<string>       ← постановка в очередь
  │    ├── загрузка в очереди/исполняется → null
  │    ├── OnSaveRequest                  ← метаданные запроса (AddMeta)
  │    └── слияние с ожидающим Save того же GUID или новая операция
  ├── Load(guid) → UniTask                      ← постановка в очередь
  ├── Remove(guid) → UniTask                    ← постановка в очередь; отменяет ожидающие Save GUID
  │
  ├── Очередь (SaveController.Queue.cs): по одной операции, FIFO
  │    ├── Save:   State = Saving, Driver.BeginSummary(), метаданные запроса, OnSaveStart,
  │    │           foreach ISaveable → GetSaveData() → SaveDataIndex, Driver.Save(name, guid)
  │    ├── Load:   State = Loading, OnLoadStart, Driver.Load(guid), foreach ISaveable → OnLoad()
  │    ├── Remove: Driver.Remove(guid)
  │    └── очередь пуста → State = Idle; событие завершения; результат ожидающим
  │
  ├── GetData(id) → Dictionary<string, string>
  ├── GetIndex() → Driver.GetIndex()
  ├── AddMeta(key, metadata) → метаданные запроса (в OnSaveRequest) или Driver.AddMeta
  ├── Register(ISaveable) / UnRegister(ISaveable)
  └── GetProcessData() → SaveProcessData

ISaveable
  ├── GetSaveId() → string
  ├── GetSaveData(CancellationToken) → UniTask<Dictionary<string, string>>
  ├── GetProcessInfo() → ProcessData
  └── OnLoad(CancellationToken) → UniTask

IDriver : ISystemDriver
  ├── Save(name, guid)
  ├── Load(guid)
  ├── Remove(guid)
  ├── SetIndexLink(Dictionary<string, Dictionary<string, string>>)
  ├── GetIndex() → Dictionary<string, SaveSummary>
  ├── GetNumberLastSave() → int
  ├── BeginSummary()                      ← необязательный (пустая реализация по умолчанию)
  └── AddMeta(key, metadata)              ← необязательный (пустая реализация по умолчанию)

BeginSummary и AddMeta — расширение контракта с реализацией по умолчанию: драйвер без поддержки метаданных компилируется без правок и пишет сейвы без метаблоков. Драйвер с поддержкой держит сводку текущего цикла: BeginSummary заводит новую сводку с новым списком (копия прошлой в индексе сейвов ссылается на прежний список — очистка стёрла бы его), AddMeta пишет прямо в неё, Save дописывает имя, дату и версию и записывает.

Формат данных

SaveDataIndex: Dictionary<string, Dictionary<string, string>>
  └── "ModuleA" → { "key1" → "json1", "key2" → "json2" }
  └── "ModuleB" → { "key1" → "json1" }

Каждый ISaveable возвращает свой GetSaveId() (идентификатор модуля) и Dictionary<string, string> (ключ → JSON-строка). SaveController агрегирует все модули в SaveDataIndex.

Очередь операций

Save, Load и Remove не исполняются сразу: они ставят операцию в очередь и возвращают задачу, которая завершится вместе с ней. Операции исполняются строго по одной, в порядке постановки; следующая начинается после завершения текущей. Так save и load не пересекаются, а сбор данных не идёт поверх загрузки.

Батчинг — по последней операции того же GUID (исполняемая операция сливается только там, где это сказано):

Запрос Что происходит
Save(guid), последняя операция по GUID — ожидающий Save Слияние: имя и метаданные — от нового запроса, результат общий для всех ожидающих
Save() без GUID Новый сейв, ни с чем не сливается
Save при исполняемой или ожидающей загрузке Отклоняется: null и предупреждение в лог. После загрузки состояние уже другое — записывать его под запрошенным именем бессмысленно. Сохранения, запрошенные до загрузки, исполняются до неё
Load(guid), последняя операция всей очереди (ожидающая или исполняемая) — Load того же GUID Слияние. С более ранней загрузкой того же GUID не сливается: загрузка меняет состояние всей игры, перестановка (A, B, A → B) недопустима
Remove(guid) Сначала отменяются ожидающие Save этого GUID с конца очереди до первой иной операции по нему (их результат — null): запись перед удалением бессмысленна, а сохранения до загрузки определяют, что она загрузит. Затем слияние с ожидающим или исполняемым Remove того же GUID

Операции, поставленные из обработчиков событий и из продолжений await завершённых операций, подхватывает тот же цикл обработки — порядок остаётся FIFO. Продолжения ожидающих выполняются синхронно, сразу после события завершения, до старта следующей операции.

Исключение в обработчике любого события логируется с именем обработчика и не мешает остальным подписчикам и очереди.

Жизненный цикл Save

  1. Запрос (синхронно внутри Save): загрузка в очереди или исполняется → null; иначе OnSaveRequest собирает метаданные запроса; операция ставится в очередь или сливается
  2. Исполнение: State = Saving
  3. Driver.BeginSummary() — новая сводка: метаблоки прошлого цикла, в т.ч. неудачного, не переносятся
  4. Метаданные запроса → Driver.AddMeta
  5. OnSaveStart — метаблоки момента исполнения через AddMeta
  6. SaveDataIndex.Clear()
  7. Для каждого ISaveable — await GetSaveData(token) → добавление в SaveDataIndex
  8. Генерация GUID если не передан
  9. Driver.Save(name, guid) — физическое сохранение вместе со сводкой цикла
  10. Очередь пуста → State = Idle; OnSaveComplete; результат (GUID, при сбое — null) ожидающим

Жизненный цикл Load

  1. Запрос: слияние с хвостом очереди или постановка
  2. Исполнение: State = Loading, OnLoadStart
  3. Driver.Load(guid) — драйвер читает тело сейва, прогоняет реакторы коррекции (см. ниже) и заполняет SaveDataIndex
  4. Для каждого ISaveable — await OnLoad(token) (модуль читает из SaveController.GetData())
  5. Очередь пуста → State = Idle; OnLoadComplete

Жизненный цикл Remove

  1. Запрос: отмена ожидающих Save GUID, слияние или постановка
  2. Исполнение: Driver.Remove(guid); State не меняется
  3. Очередь пуста → State = Idle; при успехе — OnRemove

Если очередь пуста, Remove исполняется синхронно в стеке вызывающего — как до появления очереди.

SaveProcessData — двухуровневый прогресс

Уровень Поле Описание
Глобальный Global.Progress / Global.Size Текущий модуль / всего модулей
Модульный Module.Progress / Module.Size Прогресс внутри текущего модуля

Структуры данных

Тип Назначение
SaveData struct: Id, Data — единица данных
SaveFolder struct: Id, SaveData[] DataSet — папка модуля
SaveSummary struct: Name, Date, UnixTimestamp, Version, Meta — сводка сохранения (XML-сериализуемая). Version — строковая версия приложения (Application.version), которая была активна на момент создания записи. Полезна для миграций и фильтрации старых сейвов в UI. Meta — необязательный список метаблоков (null у старых сейвов); чтение — GetMeta(key), запись — SetMeta(key, value) (повторный ключ перезаписывает)
SaveMeta struct: Key, Value — метаблок сводки; в XML — <Meta><Entry Key="…">значение</Entry></Meta>
SaveControllerStates enum: Idle, Saving, Loading — тип исполняемой операции; Idle — очередь пуста. Между операциями очереди Idle не выставляется

Контракт

Вход

  • ISaveable-модули регистрируются через Register()
  • Save(name, guid?) / Load(guid) / Remove(guid) ставят операции в очередь

Выход

  • GetData(id) — данные модуля после загрузки
  • GetIndex() — все существующие сохранения (Dictionary<string, SaveSummary>)
  • События: OnSaveRequest, OnSaveStart, OnSaveComplete, OnLoadStart, OnLoadComplete, OnRemove

API

Метод Описание
SaveController.Save(name, guid?) Сохранение в очередь, UniTask<string>: GUID записанного сейва; null — отклонено (загрузка в очереди или исполняется), отменено удалением этого сейва или не удалось
SaveController.Load(guid) Загрузка в очередь, UniTask — завершается после загрузки
SaveController.Remove(guid) Удаление в очередь, UniTask — завершается после удаления
SaveController.GetData(id) Данные модуля по SaveId
SaveController.GetIndex() Все сохранения
SaveController.AddMeta(key, metadata) Метаблок: в OnSaveRequest — в метаданные запроса, в OnSaveStart — в сводку исполняемого сохранения
SaveController.Register(ISaveable) Регистрация модуля
SaveController.UnRegister(ISaveable) Отмена регистрации
SaveController.GetNumberLastSave() Номер-инкремент последнего сохранения
SaveController.GetProcessData() Данные прогресса

Ограничения

Ограничение Причина
Операции исполняются по одной Очередь FIFO; повтор не отклоняется, а ждёт своей очереди или сливается
Save при загрузке отклоняется Состояние после загрузки другое — см. Очередь операций
Данные — только строки Dictionary<string, string>, JSON-сериализация на стороне модуля
Save/Load/Remove возвращают UniTask Можно ждать через await либо запускать fire-and-forget через .Forget(). Исключения логируются внутри
Нового сейва (без GUID) очередь не дедуплицирует Два запроса — два сейва. Защита от повторного клика — на точке сохранения
Нет таймаута операции Зависший GetSaveData/OnLoad останавливает всю очередь
CancellationToken объявлен, но не используется Зарезервирован на будущее

Граничные случаи

Ситуация Поведение
Save во время сохранения Встаёт в очередь; ожидающий Save того же GUID — слияние
Save во время загрузки или при ожидающей загрузке Отклоняется: null, предупреждение в лог, OnSaveRequest не поднимается
Load во время загрузки Встаёт в очередь; тот же GUID в хвосте очереди — слияние
Load после ожидающего Remove того же GUID Исполняется после удаления — драйвер не найдёт сейв (ошибка в лог), OnLoadComplete вызывается
Remove при ожидающем Save того же GUID Save отменяется (результат null), затем удаление
Save из обработчика OnSaveRequest Отклоняется с ошибкой в лог
Исключение в обработчике события Ошибка в лог с именем обработчика; остальные подписчики и очередь продолжают
GetData с несуществующим id Log.Print(Error), возвращает пустой Dictionary
GUID не передан в Save Генерируется Crypto.GetNewGuid()
Исключение в GetSaveData / OnLoad Log.Print(Error), событие Complete вызывается; Save возвращает null
Сбой записи, проглоченный драйвером Save возвращает GUID: Core о сбое не узнаёт — обработка ошибок на стороне драйвера
ISaveable не зарегистрирован Данные не собираются/не раздаются
SaveSummary — XML-сериализация Date как UnixTimestamp (long), DateTime.FromFileTimeUtc
Сводка без <Meta> (старый сейв, драйвер без метаданных) Meta == null, GetMeta возвращает null
Новая сводка в старой сборке Неизвестный элемент <Meta> пропускается XML-сериализатором
AddMeta вне окон OnSaveRequest / OnSaveStart Пишет в черновик сводки, который заменится следующим BeginSummary — в сейв не попадёт
AddMeta с повторным ключом Значение перезаписывается
Сбой сохранения до Driver.Save Сводка цикла не пишется; следующий цикл начинает с новой

Реакторы коррекции сейвов

Механизм правки устаревших сейвов. Формат данных знает модуль, а не система сохранений, поэтому Core даёт только шов: точку вызова, порядок и правило применимости. Конкретные правки пишет владелец данных.

Как работает

Драйвер слота при загрузке распаковывает тело сейва и до разбора отдаёт строку в SaveReactors.Apply(raw, saveVersion, reactors). Реакторы применяются по порядку списка, каждый возвращает строку после своих правок; результат уходит в разбор.

Правка по сырой строке выбрана осознанно: после разбора чинить нечего — сборка уже не прочитает снятый тип или переименованное поле. Типизированной стадии в системе сохранений нет: SavePreset состоит из строк, в объекты их разбирают сами модули.

Применимость

Окно задаётся версией сборки, которой записан сейв (SaveSummary.Version): реактор применяется, когда версия не старше minVersion и не новее maxVersion. Пустая граница — без ограничения с этой стороны.

Сравнение (SaveVersion.Compare) — посегментно по точке, число сегментов произвольное. Из сегмента берётся числовой префикс до первого нецифрового символа (260910-rc1 → 260910), недостающий сегмент считается нулём (1.0 = 1.0.0). Пустая версия — самая старая: так читаются сейвы, записанные до появления поля в сводке.

Реализация реактора

[Serializable]
public class RenameQuestField : SaveReactor
{
    public override string TransformRaw(string raw) =>
        raw.Replace("<Id>oldName</Id>", "<Id>newName</Id>");
}

Реактор добавляется в список SaveSettings (Unity-слой), там же задаются границы окна. Список по умолчанию пуст.

Гарантии и ограничения

  • Сейв на диске не переписывается: коррекция живёт только в загруженных данных. Версия в сводке меняется при следующем сохранении.
  • Коррекция выполняется на каждой загрузке — результат не кешируется.
  • Сейв, не попавший ни в одно окно (в том числе более новый, чем сборка), читается как есть.
  • Реактор не знает о других реакторах и о структуре чужих модулей — нужный кусок он находит в строке сам.
  • Исключение внутри реактора прекращает коррекцию: в лог уходит имя блока и стек, разбор идёт по строке, полученной до сбойного блока.
  • Глобальное хранилище механизмом не охвачено: у GlobalContainer версии нет, формат данных там — забота модуля.

Глобальное хранилище

GlobalSaveController хранит межсессионные данные: купленные дополнения, пройденные квесты, общую статистику, время в приложении. Данные делятся на модули (IGlobalData); хранилище находит их рефлексией, как GameModel — модули IGameData. Слоты (SaveController) глобальных данных не касаются: новая игра, загрузка и удаление сейва их не трогают, в списке сейвов хранилище не показывается.

Архитектура

GlobalSaveController : SystemController<GlobalSaveController, IGlobalSaveDriver>, IProcess  (partial)
  ├── GlobalModel : ComplexModel<IGlobalData>     ← все модули, найденные рефлексией
  ├── ключ модуля → модуль                        ← только модули с уникальным непустым ключом
  │
  ├── RunAsync()  (Loader, фаза Starting)
  │    ├── индекс ключей; модули → значения по умолчанию (в тех же экземплярах)
  │    ├── Driver.Read → Decompress → XML → папки модулей
  │    │    └── нечитаемо → самая свежая целая копия (при копиях > 0)
  │    ├── папки → существующие экземпляры модулей
  │    └── CallOnInit() → IsInit = true, шлюз OnInit
  │
  ├── Commit<T>()  → запись в конце кадра (Driver.ScheduleFlush); Unfocused/Stopping — сразу
  ├── Reset<T>() / Reset(Type) / ResetAll()  → значения по умолчанию на месте → запись сразу
  ├── OnChanged(IReadOnlyList<Type>)          ← одно уведомление на запись
  └── Stopping → запись незаписанного + резервная копия (одна за запуск)

IGlobalData  [POCO]
  └── GetGlobalKey() → string

IGlobalSaveDriver : ISystemDriver
  ├── Read(out data) → GlobalReadStatus (Ok / NoData / Error)
  ├── Write(data)                                  ← атомарно
  ├── WriteCopy(id, data) / GetCopies() / ReadCopy(id, out data) / DeleteCopy(id)
  └── ScheduleFlush(Action)                        ← конец кадра, повтор заменяет предыдущий

Драйвер — «глупое» хранилище строк: формат контейнера, выбор целой копии и ротацию копий знает только контроллер. Конец кадра — понятие движка, поэтому откладывание записи тоже делает драйвер. Драйверы — Unity-слой (GlobalFileDriver, GlobalPrefsDriver); принятый драйвер ставит контроллер в очередь Loader.

Готовность объявляет контроллер по завершении чтения, драйвер OnInit не поднимает. Поэтому IsInit означает «хранилище прочитано», а WaitingFor(typeof(GlobalSaveController)) в Loader ждёт именно чтения.

Формат

GlobalContainer — XML по образцу SavePreset: список SaveData «ключ модуля → строка сериализатора Vortex». Контейнер сжимается Compress(…, "global"), как тело слота. Шифрования нет: файл открывается архиватором, защита от правки — забота модуля. Версии приложения в контейнере нет: формат данных — забота модуля.

Жизненный цикл

  1. До загрузки Get<T>() отдаёт значения по умолчанию — те, что задал конструктор модуля.
  2. Загрузка (Starting). Сохранённые данные заливаются в существующие экземпляры модулей — ссылки, полученные раньше, остаются рабочими. Нет контейнера — первый запуск, значения по умолчанию. Нечитаемый контейнер при включённых копиях поднимается из самой свежей целой копии и сразу перезаписывается ею.
  3. Готовность. IsInit = true, срабатывают подписчики OnInit. Хранилище готово раньше загрузки любых слотов.
  4. Фиксация. Модуль меняет данные и вызывает Commit<T>(). Все фиксации кадра — одна запись и одно OnChanged. В Unfocused и Stopping запись и уведомление сразу: после этих переходов кадров может не быть, а порядок обработчиков Stopping не определён.
  5. Завершение. Незаписанное пишется сразу; при числе копий > 0 делается одна копия за запуск, самые старые удаляются.

Настройки

Где Параметр По умолчанию
DriverConfig, строка GlobalSaveController Драйвер: файл или PlayerPrefs — (без драйвера хранилище не загружается)
SaveSettings (Unity) → SettingsModel.GlobalSaveBackups Число резервных копий 0 — копий нет
SaveSettings (Unity) → SettingsModel.GlobalSaveFolder Папка файла (файловый драйвер) Global
DebugSettings (Unity) → SettingsModel.GlobalSaveFailFast Fail-fast для ошибок чтения, дублей и пустых ключей; только редактор, от DebugMode не зависит включён

Fail-fast: загрузка останавливается (App.Exit()), шлюз не открывается, запись заблокирована — испорченный файл не перезаписывается. В билде fail-fast всегда выключен: значения по умолчанию и лог.

API

Член Описание
Get<T>() Модуль с текущими значениями. До загрузки — значения по умолчанию
HasStoredData<T>() Данные модуля были в прочитанном контейнере — для миграций
Commit<T>() / Commit(Type) Зафиксировать изменения модуля. До загрузки — отклоняется с ошибкой. Перегрузка по типу — для инструментов
Reset<T>() / Reset(Type) Модуль — к значениям по умолчанию в том же экземпляре, запись сразу, уведомление
ResetAll() То же для всех модулей
OnInit / IsInit Шлюз и признак готовности (хранилище прочитано)
OnChanged Записаны изменения: типы модулей. Приходит независимо от успеха записи
Modules Модули по ключу — для инструментов

Модуль данных: инструкция для авторов

public class QuestFlagsData : IGlobalData
{
    public List<string> Completed { get; internal set; } = new();

    public string GetGlobalKey() => "MyGame.QuestFlags";
}

// Изменение — только контроллер своего пакета
var flags = GlobalSaveController.Get<QuestFlagsData>();
if (!flags.Completed.Contains(questId))
{
    flags.Completed.Add(questId);
    GlobalSaveController.Commit<QuestFlagsData>();
}

// Реакция: после загрузки и на изменения
GlobalSaveController.OnInit += RefreshBackground;
GlobalSaveController.OnChanged += types =>
{
    if (types.Contains(typeof(QuestFlagsData)))
        RefreshBackground();
};

Правила:

  • Конструктор без параметров обязателен — он задаёт значения по умолчанию. Модуль без него не попадёт в реестр.
  • Ключ стабилен. Смена ключа после релиза — потеря данных модуля. Переименование и перенос класса модуля безопасны: при чтении маркер типа подменяется текущим типом. Имена вложенных POCO-типов — забота модуля.
  • Данные — свойства с getter и setter (правила сериализатора). Поля не сохраняются и при сбросе не сбрасываются.
  • Когда фиксировать, решает модуль: факт квеста — в момент прохождения, кэш покупок — при ответе платформы.
  • Смена формата — забота модуля: недостающие свойства берут значения по умолчанию, лишние игнорируются; словари сливаются с текущими, списки и массивы заменяются.
  • Миграции — через HasStoredData<T>(). Пример — AppTimeData в SDK GameCore: перенос из PlayerPrefs, старый ключ удаляется, когда следующий запуск прочитал перенесённое значение.
  • Шифрование — на модуле. Crypto.SetCryptoPack выполняет PBKDF2 на каждый вызов (порядка сотен миллисекунд), поэтому шифровать стоит только при изменении данных.
  • Ссылку на модуль держать можно: загрузка и сброс меняют значения в том же экземпляре.
  • Проверка модуля — окно Tools/Vortex/SaveData/Global Index (Unity SaveSystem): вне Play Mode покажет модуль, его ключ и проблемы, из-за которых хранилище его пропустит; в Play Mode — текущие значения с правкой на лету.

Ограничения

Ограничение Причина
Нет шифрования Защита данных — забота модуля; файл правится вручную
При числе копий 0 испорченный контейнер не восстанавливается Разработчик отказался от страховки
Данные отключённых пакетов при следующей записи пропадают Настройки пакетов структурообразующие: отключение считается окончательным
Копия делается только при Stopping Процесс, убитый без Stopping, копии за этот запуск не оставит
Облачная синхронизация не объединяет данные устройств Вне области пакета

Граничные случаи

Ситуация Поведение
Нет драйвера в DriverConfig Загрузки нет, IsInit = false; Get — значения по умолчанию, фиксации отклоняются с ошибкой
Первый запуск (контейнера нет) Значения по умолчанию; контейнер создаёт первая фиксация
Основной контейнер нечитаем, копии есть Самая свежая целая копия, предупреждение в лог, основной контейнер сразу перезаписывается ею
Нечитаем, целых копий нет (или копии выключены) Ошибка в лог, значения по умолчанию; первая фиксация перезаписывает контейнер. Редактор с fail-fast — загрузка останавливается
Нечитаемые данные одного модуля Модуль по умолчанию, ошибка в лог, остальные читаются; fail-fast — как выше
Два модуля с одним ключом Оба по умолчанию, не читаются и не пишутся; ошибка в лог; fail-fast — как выше
Пустой ключ или исключение в GetGlobalKey Модуль вне индекса; ошибка в лог; fail-fast — как выше
Данные в контейнере без модуля Предупреждение; не читаются, при следующей записи пропадают
Фиксация или сброс до загрузки Ошибка в лог, операция отклонена
Серия фиксаций за кадр Одна запись, одно OnChanged
Ошибка записи Ошибка в лог; данные в памяти, следующая фиксация повторит запись; OnChanged всё равно приходит
Рестарт без выгрузки домена Экземпляры модулей те же, значения сбрасываются к умолчанию перед чтением