CultLib/src/GameCult.Caching
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Meta Vens 19f3a5edbb Cacheless single-file API: WriteDocument encodes the declared type, the registry is the call's argument, DecodeDocument moves to CultCodec, ReadStoreSnapshot's gate is pinned
SerializePayload takes the declared type. A call attaches its registry under the store's gate and restores the previous one, so no call leaves a registry on the store. Not yet run; the next commit set is verified on Yggdrasil.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 01:38:16 +02:00
..
Contracts Docs: cacheless single-file access names WriteDocument and DecodeDocument; CultElementIds is not public 2026-10-23 00:15:28 +02:00
CultAssetGuidKey.cs Studio: read and write sub-asset Addressables keys, warn on stale values 2026-09-17 19:48:45 +02:00
CultCache.cs Cacheless single-file API: WriteDocument encodes the declared type, the registry is the call's argument, DecodeDocument moves to CultCodec, ReadStoreSnapshot's gate is pinned 2026-10-23 01:38:16 +02:00
CultDocumentContracts.cs C#: a document type declares the older schema ids its records may sit under 2026-09-30 11:30:05 +02:00
CultElementIds.cs Caching: SingleFileBackingStore.WriteDocument and DecodeDocument own element-id planning; CultElementIds is internal again 2026-10-23 00:15:27 +02:00
CultInspectorAttributes.cs Add model-owned record grouping: declaration, tree, create-in-group, ref labels 2026-09-29 12:09:25 +02:00
CultInspectorGrouping.cs Add model-owned record grouping: declaration, tree, create-in-group, ref labels 2026-09-29 12:09:25 +02:00
CultInspectorModel.cs Make group node id escaping injective 2026-09-29 14:03:58 +02:00
CultManagedDocument.cs Carry order as data: per-cache change Sequence, no delivery scheduler 2026-09-13 20:43:11 +02:00
CultSchemaConflictException.cs C# write set: one re-mint for every store kind, and a foreign refusal carries its reload failure 2026-10-03 06:05:41 +02:00
CultSchemaTypeNames.cs Delete the CultCache MessagePack source generator 2026-09-13 23:38:08 +02:00
CultStoreUnreadableException.cs C#: compatible schema ids publish a schema on open, Mesh takes only the older layout, the refusal names its file 2026-09-30 06:54:17 +02:00
CultVariants.cs Cacheless single-file API: WriteDocument encodes the declared type, the registry is the call's argument, DecodeDocument moves to CultCodec, ReadStoreSnapshot's gate is pinned 2026-10-23 01:38:16 +02:00
CultWriteConflictException.cs C# write set: one refusal path that reloads, and header-only staleness in a variant store 2026-10-03 05:04:20 +02:00
GameCult.Caching.csproj Caching: public ReadStoreSnapshot and WriteRecord replace Mesh's InternalsVisibleTo reach 2026-10-22 23:52:33 +02:00
README.md Element ids: string keys read from the members MessagePack writes; AllowPrivate, inherited-formatter and unwritten-string-key pins; README states the whole-value exception 2026-10-16 02:16:32 +02:00
ReflectionExtensions.cs Rewrite CultCache as attribute-first storage 2026-05-08 01:13:15 +01:00

GameCult.Caching

GameCult.Caching is an attribute-driven typed cache with a canonical single-file MessagePack store. Domain models stay clean. The cache owns record keys, schema ids, timestamps, and persistence metadata instead of smearing that sludge across every class.

Authoring Model

Cacheable models are plain classes annotated with cache intent.

using GameCult.Caching;
using MessagePack;

[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
public sealed class ItemData
{
    [Key(0)]
    [CultName]
    public string Name = string.Empty;

    [Key(1)]
    [CultIndex]
    public string Category = string.Empty;

    [Key(2)]
    public int Value;
}

Available cache attributes:

  • CultDocument(schemaName, schemaVersion)
  • CultName
  • CultIndex(alias?)
  • CultGlobal
  • CultReference(targetType?, many: false)

CultDocumentRegistry builds every document descriptor by reflection over these attributes and MessagePack's [Key]/[IgnoreMember]; it is the only descriptor authority.

Main Concepts

Record keys and handles

CultCache gives each stored document a cache-owned CultRecordKey. You work with typed handles instead of forcing storage identity into the domain object.

var cache = new CultCache();
var handle = await cache.AddAsync(new ItemData
{
    Name = "Potion",
    Category = "Consumable",
    Value = 50
});

var potion = cache.Get<ItemData>(handle.Key);

Name and index lookups

Name and index lookups are driven from attributes, not manual registration.

var byName = cache.GetByName<ItemData>("Potion");
var byIndex = cache.GetByIndex<ItemData>("Category", "Consumable");

Explicit references

References stay explicit in the domain. The cache will resolve them on demand, but it will not quietly build an entire haunted object graph behind your back.

[CultDocument("gamecult.quest_data", "gamecult.quest_data.v1")]
public sealed class QuestData
{
    [Key(0)] public string Title = string.Empty;
    [Key(1)] [CultReference] public CultRecordRef<ItemData> Reward;
}

Persistence Shape

CultCache persistence is store-shaped, not entry-shaped:

  1. store header
  2. embedded schema catalog
  3. records

Each persisted record carries:

  • key
  • schemaId
  • storedAt
  • payload

The domain payload stays free of storage metadata.

Backing Store

CultCache now has one sanctioned persistence format:

  • SingleFileMessagePackBackingStore

It persists the whole store as:

  1. store format version
  2. embedded schema catalog
  3. persisted records

Each persisted record carries:

  • key
  • schemaId
  • storedAt
  • payload

Happy Path

using GameCult.Caching;
using GameCult.Caching.MessagePack;

var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");

var handle = await cache.UpsertAsync(new ItemData
{
    Name = "Potion",
    Category = "Consumable",
    Value = 50
});

await cache.FlushAsync();

If you want the explicit assembly rite, the lower-level CultCache + SingleFileMessagePackBackingStore path is still there. The helper is just the blessed front door.

Managed Documents And SoA Storage

Gameplay code keeps authoring normal typed documents. CultCache owns the record identity, reactive presentation, and CPU-local structure-of-arrays table behind that POCO surface.

[CultDocument("game.player_transform", "game.player_transform.v1")]
public sealed class PlayerTransform
{
    [CultName] public string Name = string.Empty;
    public float PositionX;
    public float PositionY;
    public int Health;
}

var player = cache.Document<PlayerTransform>(new CultRecordKey("player:alice"));

await player.ReplaceAsync(new PlayerTransform
{
    Name = "Alice",
    PositionX = 4,
    PositionY = 8,
    Health = 100
});

using var subscription = player.Watch().Subscribe(current => Render(current));

player.Value!.Health = 90;
await player.CommitAsync();

var table = cache.Soa<PlayerTransform>();

Span<float> x = table.Column<float>(nameof(PlayerTransform.PositionX)).Span;
Span<float> y = table.Column<float>(nameof(PlayerTransform.PositionY)).Span;
Span<int> health = table.Column<int>(nameof(PlayerTransform.Health)).Span;

for (var row = 0; row < table.Count; row++)
{
    x[row] += 0.016f;
}

Document<T>() is the standard presentation. Soa<T>() exposes the cache-owned physical table for hot loops and diagnostics; it is not a second domain model callers have to maintain.

Element ids

A document type that holds a list of objects gives every object type in that list (union arms included) a string member marked [CultElementId], next to its [Key]. The id is what lets a variant override one list element instead of the whole list. Lists of strings, numbers and other non-object values need none.

The rule covers the shapes the id walk owns: arrays, IEnumerable<T> classes and interfaces, dictionary values, and integer-keyed [MessagePackObject] and [Union] types. Any other holder is one whole value: ImmutableArray<T>, ArraySegment<T>, Memory<T>, KeyValuePair, tuples, Lazy<T>, a class without [MessagePackObject], a [DataContract] or string-keyed type, and a type or member declaring [MessagePackFormatter]. Its elements carry no element ids: a [CultElementId] member inside it is a plain string, never minted or checked, and a variant can only replace the holder whole.

[MessagePackObject]
public sealed class Line
{
    [Key(0)] [CultElementId] public string Id = string.Empty;
    [Key(1)] public string Text = string.Empty;
}

Leave Id empty and the cache mints a random id when the record is written. An id you set yourself must be exactly 12 lowercase hex characters (0123456789ab); anything else is refused with a CultElementIdException.

For readable ids, derive them: [CultElementId(nameof(Name))] makes an unset id the text of that member (a string or number on the same type, unique within its list), and a derived id may be any text. A readable name set by hand on a non-derived list id is refused; the same member outside a list is unconstrained.

Persistence discipline

Persistence stays manual by default. Mutations mark the cache and attached backing stores dirty; nothing silently flushes every object write just because that felt convenient in the moment.

Useful surfaces:

  • CultCacheMessagePack.OpenAsync(path)
  • cache.IsDirty
  • store.IsDirty
  • cache.Document<T>(key)
  • cache.Soa<T>()
  • cache.UpsertAsync(document)
  • cache.FlushAllBackingStores()
  • cache.FlushAsync()
  • cache.FlushBackingStore(store)
  • cache.PrepareForReloadOrShutdown()
  • cache.PrepareForReloadOrShutdownAsync()
  • cache.FlushAttachedStoresOnDispose
  • store.FlushOnDispose

This is built for callers like Aquarium that want durable runtime state while still choosing when to pay the write cost.

Single-file MessagePack remains a single-writer format. The atomic replace path protects against partial writes, not competing writers trampling each other.

Schema migration diagnostics

CultCache does not quietly fall back from exact schemaId resolution to "close enough, probably." When a persisted schema drifts, the cache now emits a typed migration report describing:

  • exact vs compatible-drift resolution
  • ignored extra slots
  • defaulted missing slots
  • warning codes/messages

Compatibility rules and canonical fixture receipts live in Contracts/cultcache-schema-compatibility.md.