~/readme

PurrSave

PurrSave is a DTO-first save/load framework for Unity.

You define stable save data types, register how they are packed, then write values into a scoped SaveStore.

Quick Start

1. Define Save Data

using PurrNet.Saving;
using UnityEngine;

[SaveKey("game.player")]
public struct PlayerSave
{
    public Vector3 position;
    public int health;

    [BitLoader(0)]
    public static PlayerSave LoadV0(GamePacker packer)
    {
        return new PlayerSave
        {
            position = packer.Read<Vector3>(),
            health = packer.Read<int>()
        };
    }

    [BitSaver]
    public static void Save(GamePacker packer, PlayerSave value)
    {
        packer.Write(value.position);
        packer.Write(value.health);
    }
}

SaveKey is the stable identity for the data type. Do not rename it casually; old save files depend on it.

2. Save Data

using PurrNet.Saving;
using UnityEngine;

public sealed class PlayerSaveExample : MonoBehaviour
{
    SaveSlot _slot;

    void Awake()
    {
        _slot = SaveSlot.Create("slot_1");
    }

    public void SavePlayer(int health)
    {
        using var store = SaveStore.Open(_slot);
        store.Write(new PlayerSave
        {
            position = transform.position,
            health = health
        });

        if (!store.TryCommit(out var report))
            Debug.LogError(report.GetDebugMessage());
    }
}

SaveSlot.Create("slot_1") resolves a folder under Application.persistentDataPath/Saves.

Scopes let one slot store many copies of the same DTO type:

var scope = SaveScope.Create("region", "0_1");

using var store = SaveStore.Open(slot);
store.Write(scope, chunkRegionSave);
store.Commit();

Each scope is stored as its own GameStream file under the slot folder.

3. Load Data

var slot = SaveSlot.Create("slot_1");

if (!SaveStore.TryOpen(slot, out var store, out var report))
{
    if (report.hasBackup)
    {
        // Prompt the player before loading backup.
        // A backup manifest may represent older progress.
        store = SaveStore.OpenBackup(slot);
    }
    else
        return;
}

using (store)
{
    if (store.TryRead(out PlayerSave player, out var readReport))
    {
        transform.position = player.position;
    }
}

SaveStore.Open never loads a backup automatically. Backup loading is explicit so the game can communicate possible lost progress.

Versioning

When a save data format changes, add a new loader and update the saver.

[BitLoader(1)]
public static PlayerSave LoadV1(GamePacker packer)
{
    return new PlayerSave
    {
        position = packer.Read<Vector3>(),
        health = packer.Read<int>()
    };
}

Keep old loaders when possible. They are how old files migrate into the current DTO shape.

Recovery

Normal loads are strict. They reject corrupt entries or corrupt scope files.

Partial recovery is explicit:

using var stream = new GameStream();
if (stream.TryRecoverFromData(scopeBytes, out var recoveryReport))
{
    Debug.Log($"Recovered entries: {recoveryReport.recoveredCount}");
    Debug.Log($"Skipped entries: {recoveryReport.skippedCount}");
}

Recovery can skip entries with checksum mismatches or decompression failures when the stream structure is still readable.

Roslyn Support

PurrSave ships a Roslyn analyzer/source generator that:

  • Registers valid [BitLoader], [BitSaver], and [SaveKey] methods.
  • Reports invalid loader/saver signatures.
  • Reports missing or invalid SaveKey attributes.
  • Offers quick fixes and generation/refactoring actions where supported by the IDE.

Docs

~/versions

Log in and subscribe to the Studio plan to access this package.

Log In