2026-04-18 13:01:24 +01:00
|
|
|
# GameCult.Caching
|
|
|
|
|
|
2026-05-08 02:42:11 +01:00
|
|
|
`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.
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
## Authoring Model
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
Cacheable models are plain classes annotated with cache intent.
|
2026-04-18 13:01:24 +01:00
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
using GameCult.Caching;
|
2026-05-08 01:13:15 +01:00
|
|
|
using MessagePack;
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
[CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
|
|
|
|
|
public sealed class ItemData
|
2026-04-18 13:01:24 +01:00
|
|
|
{
|
2026-05-08 01:13:15 +01:00
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
2026-04-18 13:01:24 +01:00
|
|
|
public string Name = string.Empty;
|
2026-05-08 01:13:15 +01:00
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultIndex]
|
2026-04-18 13:01:24 +01:00
|
|
|
public string Category = string.Empty;
|
|
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
[Key(2)]
|
|
|
|
|
public int Value;
|
2026-04-18 13:01:24 +01:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
Available cache attributes:
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
- `CultDocument(schemaName, schemaVersion)`
|
|
|
|
|
- `CultName`
|
|
|
|
|
- `CultIndex(alias?)`
|
|
|
|
|
- `CultGlobal`
|
|
|
|
|
- `CultReference(targetType?, many: false)`
|
2026-04-18 13:01:24 +01:00
|
|
|
|
Delete the CultCache MessagePack source generator
The reflective CultDocumentRegistry is now the only descriptor authority,
and payloads always go through MessagePackSerializer with
OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed,
Unity always ran the reflective path, and no benchmark showed a gain,
while the generator duplicated the registry's shape rules.
Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution,
project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata
(provider attribute, interface, definitions, loader), the generated descriptor
and codec branches in CultCache and CultDocumentMessagePackSerialization,
DocumentShapeSweepTests and the generator half of the unkeyed test, the
Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo.
The registry rejects, with a message naming the fix, what MessagePackSerializer
cannot serialize and what the generator used to paper over: a document without
[MessagePackObject], a non-public document without AllowPrivate = true, and a
non-public member of an AllowPrivate document that is not [IgnoreMember].
DocumentShapeTests pins every rejection message and the accepted shapes'
round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin).
Rejected shapes are emitted into a dynamic assembly, which registry
discovery skips.
Nested member type names use reflection's Outer+Inner form, not the
generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned
(bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc
stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj
excluded) found no member type name naming a nested type in either form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
|
|
|
`CultDocumentRegistry` builds every document descriptor by reflection over
|
|
|
|
|
these attributes and MessagePack's `[Key]`/`[IgnoreMember]`; it is the only
|
|
|
|
|
descriptor authority.
|
2026-05-08 01:50:15 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
## Main Concepts
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
### Record keys and handles
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
`CultCache` gives each stored document a cache-owned `CultRecordKey`. You work
|
|
|
|
|
with typed handles instead of forcing storage identity into the domain object.
|
2026-04-18 13:01:24 +01:00
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
var cache = new CultCache();
|
2026-05-08 01:13:15 +01:00
|
|
|
var handle = await cache.AddAsync(new ItemData
|
2026-04-18 13:01:24 +01:00
|
|
|
{
|
|
|
|
|
Name = "Potion",
|
|
|
|
|
Category = "Consumable",
|
|
|
|
|
Value = 50
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
var potion = cache.Get<ItemData>(handle.Key);
|
2026-04-18 13:01:24 +01:00
|
|
|
```
|
|
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
### Name and index lookups
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
Name and index lookups are driven from attributes, not manual registration.
|
2026-04-18 13:01:24 +01:00
|
|
|
|
|
|
|
|
```csharp
|
2026-05-08 01:13:15 +01:00
|
|
|
var byName = cache.GetByName<ItemData>("Potion");
|
|
|
|
|
var byIndex = cache.GetByIndex<ItemData>("Category", "Consumable");
|
2026-04-18 13:01:24 +01:00
|
|
|
```
|
|
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
### Explicit references
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
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.
|
2026-04-18 13:01:24 +01:00
|
|
|
|
|
|
|
|
```csharp
|
2026-05-08 01:13:15 +01:00
|
|
|
[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;
|
|
|
|
|
}
|
2026-04-18 13:01:24 +01:00
|
|
|
```
|
|
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
## Persistence Shape
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
CultCache persistence is store-shaped, not entry-shaped:
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
1. store header
|
|
|
|
|
2. embedded schema catalog
|
|
|
|
|
3. records
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
Each persisted record carries:
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
- `key`
|
|
|
|
|
- `schemaId`
|
|
|
|
|
- `storedAt`
|
|
|
|
|
- `payload`
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 01:13:15 +01:00
|
|
|
The domain payload stays free of storage metadata.
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 02:42:11 +01:00
|
|
|
## Backing Store
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 02:42:11 +01:00
|
|
|
CultCache now has one sanctioned persistence format:
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 02:42:11 +01:00
|
|
|
- `SingleFileMessagePackBackingStore`
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 02:42:11 +01:00
|
|
|
It persists the whole store as:
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 02:42:11 +01:00
|
|
|
1. store format version
|
|
|
|
|
2. embedded schema catalog
|
|
|
|
|
3. persisted records
|
|
|
|
|
|
|
|
|
|
Each persisted record carries:
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-08 02:42:11 +01:00
|
|
|
- `key`
|
|
|
|
|
- `schemaId`
|
|
|
|
|
- `storedAt`
|
|
|
|
|
- `payload`
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-09 13:32:30 +01:00
|
|
|
## Happy Path
|
2026-04-18 13:01:24 +01:00
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
using GameCult.Caching;
|
2026-05-08 01:13:15 +01:00
|
|
|
using GameCult.Caching.MessagePack;
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-09 13:32:30 +01:00
|
|
|
var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");
|
|
|
|
|
|
|
|
|
|
var handle = await cache.UpsertAsync(new ItemData
|
|
|
|
|
{
|
|
|
|
|
Name = "Potion",
|
|
|
|
|
Category = "Consumable",
|
|
|
|
|
Value = 50
|
|
|
|
|
});
|
2026-04-18 13:01:24 +01:00
|
|
|
|
2026-05-09 13:32:30 +01:00
|
|
|
await cache.FlushAsync();
|
2026-04-18 13:01:24 +01:00
|
|
|
```
|
2026-05-09 11:45:46 +01:00
|
|
|
|
2026-05-09 13:32:30 +01:00
|
|
|
If you want the explicit assembly rite, the lower-level `CultCache` +
|
|
|
|
|
`SingleFileMessagePackBackingStore` path is still there. The helper is just the
|
|
|
|
|
blessed front door.
|
|
|
|
|
|
2026-06-13 01:44:05 +01:00
|
|
|
## Managed Documents And SoA Storage
|
2026-06-12 20:38:41 +01:00
|
|
|
|
2026-06-13 01:44:05 +01:00
|
|
|
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.
|
2026-06-12 20:38:41 +01:00
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
[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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 01:44:05 +01:00
|
|
|
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>();
|
2026-06-12 20:38:41 +01:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2026-06-13 01:44:05 +01:00
|
|
|
`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.
|
2026-06-12 20:38:41 +01:00
|
|
|
|
2026-05-09 11:45:46 +01:00
|
|
|
## 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:
|
|
|
|
|
|
2026-05-09 13:32:30 +01:00
|
|
|
- `CultCacheMessagePack.OpenAsync(path)`
|
2026-05-09 11:45:46 +01:00
|
|
|
- `cache.IsDirty`
|
|
|
|
|
- `store.IsDirty`
|
2026-06-13 01:44:05 +01:00
|
|
|
- `cache.Document<T>(key)`
|
|
|
|
|
- `cache.Soa<T>()`
|
2026-05-09 13:32:30 +01:00
|
|
|
- `cache.UpsertAsync(document)`
|
2026-05-09 11:45:46 +01:00
|
|
|
- `cache.FlushAllBackingStores()`
|
2026-05-09 13:32:30 +01:00
|
|
|
- `cache.FlushAsync()`
|
2026-05-09 11:45:46 +01:00
|
|
|
- `cache.FlushBackingStore(store)`
|
|
|
|
|
- `cache.PrepareForReloadOrShutdown()`
|
2026-05-09 13:32:30 +01:00
|
|
|
- `cache.PrepareForReloadOrShutdownAsync()`
|
2026-05-09 11:45:46 +01:00
|
|
|
- `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`.
|