Persisting ScriptableObjects with Remember Components

Crystal Save's Remember components are the bridge between a runtime mirror of your ScriptableObject data and the asset that lives on disk. This guide assumes you already have a mirror component that knows how to populate itself from a ScriptableObject and expose a ApplyToAsset (or equivalent) method that writes the mirrored values back. We will focus on authoring the Remember component that:

  • Pulls the current values out of the mirror at save time.

  • Serializes those values into the Crystal Save payload.

  • Restores the serialized data into the mirror and pushes the data back into the original ScriptableObject during load.

Please note: There are also other ways like directly implementing ISaveable/SaveableComponent into your mirror class but this makes Crystal Save a dependcy, and it is in my opinion, not a designer-friendly workflow.

Example: Anatomy of RememberGC2GlobalNameVariablesList

RememberGC2GlobalNameVariablesList is a production-ready example that synchronizes Game Creator 2 Global Name Variables. It provides a concrete walkthrough of the lifecycle described above.

Other Examples: RememberPlaymakerGlobalVariable, RememberACGlobalVariables, RememberCozyWeather3, RememberGC2StateMachineVariables, RememberGC2GlobalListVariables etc. from the optional Crystal Save Modules (Adventure Creator, Game Creator 2, Playmaker, Statemachine for GC2, Cozy Weather 3)

Capture data from the mirror

At save time, the component gathers runtime values by asking the mirror (Game Creator's GlobalNameVariablesManager) for each configured asset/name pair. It supports an optional cached snapshot so no payload is generated when nothing changed:

protected override byte[] SerializeComponentData()
{
    if (!TryCaptureCurrentState(out var currentSnapshot, logWarnings: true))
    {
        // When capture fails, clear the cache (if any) and abort the save.
        if (!skipSavingWhenUnchanged)
        {
            hasCachedSnapshot = false;
            cachedSnapshot = null;
        }

        return null;
    }

    if (skipSavingWhenUnchanged && hasCachedSnapshot && cachedSnapshot != null)
    {
        if (AreSnapshotsEqual(cachedSnapshot, currentSnapshot))
        {
            return null;
        }
    }

    var wrapper = CreateWrapperFromSnapshot(currentSnapshot);
    byte[] serializedData = SaveDataSerializer.Instance.Serialize(wrapper);

    // Refresh the cache so subsequent saves can skip unchanged data.
    if (skipSavingWhenUnchanged)
    {
        cachedSnapshot = DeepCloneSnapshot(currentSnapshot);
        hasCachedSnapshot = cachedSnapshot != null;
    }
    else
    {
        cachedSnapshot = null;
        hasCachedSnapshot = false;
    }

    return serializedData;
}

The helper TryCaptureCurrentState performs the actual mirror read, iterating over the GlobalNameVariables assets assigned in the inspector and collecting a list of GlobalNameVariableEntry structures (name, typed value, and type hint). Any missing assets or unsupported values are logged so designers know why a value was skipped.【F:Assets/Plugins/CrystalSave/Modules/GameCreator2/Remember/Core/RememberGC2GlobalNameVariablesList.cs†L175-L277】

Serialize using the mirror's data contract

The snapshot is converted into a lightweight wrapper (GlobalNameVariablesListData) before being serialized. Because the component sits on top of an existing mirror, it does not need to reinvent how each value is represented -> MemoryPack handles the nested typed objects, and the wrapper is just a transport structure for Crystal Save.【F:Assets/Plugins/CrystalSave/Modules/GameCreator2/Remember/Core/RememberGC2GlobalNameVariablesList.cs†L280-L341】

Push data back into the ScriptableObject on load

On load, the component deserializes the payload, walks the configured assets again, and forwards the restored values into Game Creator's mirror adapter. That adapter is responsible for calling ApplyToAsset (Game Creator's OnLoad) so the ScriptableObject matches the saved snapshot:

protected override void DeserializeComponentData(byte[] data)
{
    if (data == null || data.Length == 0)
    {
        Logger.Log("DeserializeComponentData: No data to deserialize. Skipping.", LogLevel.Info);
        return;
    }

    if (targetGlobalNameVariablesList == null || targetGlobalNameVariablesList.Count == 0)
    {
        Logger.Log("DeserializeComponentData failed: No GlobalNameVariables assets assigned.", LogLevel.Warning);
        return;
    }

    if (GlobalNameVariablesManager.Instance == null)
    {
        Logger.Log("GlobalNameVariablesManager.Instance is null. Cannot deserialize GlobalNameVariables.", LogLevel.Warning);
        return;
    }

    var deserializedWrapper = SaveDataSerializer.Instance.Deserialize<GlobalNameVariablesListData>(data);
    int count = Mathf.Min(deserializedWrapper.VariableSets.Count, targetGlobalNameVariablesList.Count);
    for (int i = 0; i < count; i++)
    {
        var asset = targetGlobalNameVariablesList[i];
        var varsData = deserializedWrapper.VariableSets[i];
        if (asset == null)
        {
            Logger.Log($"DeserializeComponentData skipped index {i}: asset reference is null.", LogLevel.Warning);
            continue;
        }

        IGlobalNameVariablesAdapter adapter = new MemoryPackGlobalNameVariablesAdapter(asset);
        adapter.OnLoad(varsData);
    }
}

After a successful load, the cached snapshot is refreshed so subsequent saves continue to benefit from the skip-when-unchanged optimization.【F:Assets/Plugins/CrystalSave/Modules/GameCreator2/Remember/Core/RememberGC2GlobalNameVariablesList.cs†L150-L167】

Template: Remember<YourMirror>

Use the following skeleton when building a Remember component for your own mirror. The comments call out where to invoke mirror APIs. We assume our RememberYourMirror component below, talks to a mirror that already knows which ScriptableObject it is mirroring.

#if ARAWN_REMEMBERME && MEMORYPACK
using MemoryPack;
using UnityEngine;
using Arawn.CrystalSave.Runtime;
using Logger = Arawn.CrystalSave.Runtime.Logger;

[AddComponentMenu("Crystal Save/Remember Components/My Module/Remember <Your Mirror>")]
public sealed class RememberYourMirror : SaveableComponent
{
    [SerializeField] private YourMirrorComponent mirror;
    [SerializeField] private bool skipSavingWhenUnchanged = true;

    private MirrorSnapshot cachedSnapshot;
    private bool hasSnapshot;

    protected override void Awake()
    {
        base.Awake();
        if (skipSavingWhenUnchanged && TryCapture(out var initial, logWarnings: false))
        {
            cachedSnapshot = initial;
            hasSnapshot = true;
        }
    }

    protected override byte[] SerializeComponentData()
    {
        if (!TryCapture(out var current, logWarnings: true))
        {
            hasSnapshot = false;
            cachedSnapshot = default;
            return null;
        }

        if (skipSavingWhenUnchanged && hasSnapshot && MirrorSnapshotsEqual(cachedSnapshot, current))
        {
            return null; // Nothing changed.
        }

        byte[] payload = SaveDataSerializer.Instance.Serialize(current);
        if (skipSavingWhenUnchanged && payload != null)
        {
            cachedSnapshot = current; // Deep-clone if your snapshot contains reference types.
            hasSnapshot = true;
        }

        return payload;
    }

    protected override void DeserializeComponentData(byte[] data)
    {
        if (data == null || data.Length == 0 || mirror == null)
        {
            return;
        }

        MirrorSnapshot restored = SaveDataSerializer.Instance.Deserialize<MirrorSnapshot>(data);
        mirror.ApplyToRuntime(restored);      // Update the mirror instance.
        mirror.ApplyToAsset();                // Push the mirror state back to the ScriptableObject.

        if (skipSavingWhenUnchanged)
        {
            cachedSnapshot = restored;
            hasSnapshot = true;
        }
    }

    private bool TryCapture(out MirrorSnapshot snapshot, bool logWarnings)
    {
        snapshot = default;
        if (mirror == null)
        {
            if (logWarnings)
            {
                Logger.Log("RememberYourMirror: mirror is not assigned.", LogLevel.Warning);
            }
            return false;
        }

        snapshot = mirror.CreateSnapshot();   // Ask the mirror for its current runtime values.
        return snapshot != null;
    }

    private static bool MirrorSnapshotsEqual(MirrorSnapshot a, MirrorSnapshot b)
    {
        // Implement whatever comparison makes sense for your mirror.
        return Equals(a, b);
    }
}
#endif
  • Replace YourMirrorComponent, MirrorSnapshot, and the helper methods with the types provided by your mirror.

  • If your mirror already exposes a snapshot struct/class, prefer serializing that type directly as shown above.

  • If the snapshot contains reference types, deep-clone the cached copy so skip-when-unchanged can reliably compare value equality.

  • If the mirror is a shared service that needs you to provide the asset context—because you’re persisting multiple assets, pulling data from a manager keyed by asset, or applying changes back into assets that the mirror doesn’t own -> you should add serialized asset references the way the Game Creator 2, Playmaker, Adventure Creator & Cozy Weather integrations do.

Troubleshooting mirror-to-asset synchronization

  • Null asset references: Ensure every slot that the Remember component enumerates is assigned. RememberGC2GlobalNameVariablesList short-circuits when an entry is null, which prevents it from applying changes to the asset and produces the "asset reference is null" warning. Double-check inspector arrays before building the snapshot.【F:Assets/Plugins/CrystalSave/Modules/GameCreator2/Remember/Core/RememberGC2GlobalNameVariablesList.cs†L134-L145】

  • Unsupported field types: If the mirror cannot create a TypedObject (or your equivalent data wrapper), the value will be skipped during capture. Confirm that your mirror's serialization layer supports the field type and add custom adapters if required.【F:Assets/Plugins/CrystalSave/Modules/GameCreator2/Remember/Core/RememberGC2GlobalNameVariablesList.cs†L203-L257】

  • Initialization order: Mirrors that depend on singleton managers must be ready before DeserializeComponentData executes. RememberGC2GlobalNameVariablesList checks GlobalNameVariablesManager.Instance and logs a warning when it is not available; ensure your initialization pipeline spawns the mirror before Crystal Save restores data.【F:Assets/Plugins/CrystalSave/Modules/GameCreator2/Remember/Core/RememberGC2GlobalNameVariablesList.cs†L118-L121】

Last updated