Correct stale store-routing, SoA, and generator docs

The README and the Rust README described mirrors as C# behavior; the parity
scope claimed the C# SoA table; the generator README described DatabaseEntry
formatters that no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-13 16:01:32 +02:00
commit 486fe560af
4 changed files with 64 additions and 100 deletions

View file

@ -237,41 +237,23 @@ public class ItemData : DatabaseEntry, INamedEntry
- backing stores are persistence adapters
- indexes and name maps are maintained inside the cache, not inside the store
### Important: Multiple Backing Stores
### Multiple Backing Stores
When multiple backing stores are added, behavior depends on how they are registered.
If a store is added with domain types:
Every document type has exactly one home store. There are no mirrors.
```csharp
cache.AddBackingStore(playerStore, typeof(PlayerData));
cache.AddBackingStore(settingsStore, typeof(AppSettings));
cache.AddBackingStore(catalogStore, typeof(ItemData), typeof(Faction));
cache.AddBackingStore(runStore, typeof(SavedGame));
```
then that store becomes the direct persistence target for those types.
- the home is chosen by the document's runtime type: the most derived routed type wins, otherwise the untyped store
- at most one untyped store; a type claimed by two stores is an error
- a write with no home is an error once any store is attached
- attaching a store reads it; attach routed stores first and the untyped store (if any) last, because a record's home cannot move after it is loaded
- loading never writes: opening and flushing without a mutation leaves every file byte-identical
- a read-only store refuses writes and is skipped by flush
If a store is added without domain types:
```csharp
cache.AddBackingStore(primaryStore);
cache.AddBackingStore(mirrorStore);
```
then the first generic store acts as the primary writable store for non-domain-specific entries. Additional generic stores subscribe to the existing stores and mirror their change events.
Implications:
- order matters for generic stores
- `AddAsync` writes to the type-specific store when one exists
- otherwise `AddAsync` writes to the first generic store
- later generic stores do not become co-primaries; they mirror earlier stores
- `PullAllBackingStoresAsync` pulls from every registered store
Recommended patterns:
- use one generic primary store if you want simple persistence
- use domain-specific stores when different entry types belong in different persistence layers
- treat additional generic stores as mirrors or downstream replicas, not independent write targets
The contract is `src/GameCult.Caching/Contracts/cultcache-store-composition.md`. Until the C# cache implements it, `AddBackingStore` takes no home types and every attached store receives every record; attach one store per cache.
## Example: Cache + Networking

View file

@ -21,16 +21,17 @@ Cross-runtime parity has three layers:
transport helpers instead of assembling raw maps and channel strings.
SoA performance parity is deliberately not a cross-runtime requirement. C#
owns the CPU-local SoA table and shared-memory streaming body path. Other
runtimes may add native hot paths when their runtime role earns them, but they
do not need to mimic Unity/C# memory layout to speak CultNet or participate in
CultMesh.
owns the shared-memory streaming body path. The C# CultCache SoA table is not a
supported surface: nothing outside tests consumes it, and it is being parked at
a tag rather than carried. Other runtimes may add native hot paths when their
runtime role earns them, but they do not need to mimic Unity/C# memory layout
to speak CultNet or participate in CultMesh.
## Current Targeted Runtimes
| Runtime | Role | Claimed parity | Not claimed |
| --- | --- | --- | --- |
| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
| C# | Reference implementation and production game/server substrate | CultCache typed documents, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
| Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe |
| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack |
| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade or production game-session server. `packages/cultmesh-rs` now holds a Rust stream and session surface -- node, document sets, RUDP document publication, stream descriptors, negotiation, stream catalog, and shared-memory frame rings -- but no interop lane exercises it, so it carries no cross-runtime parity claim |

View file

@ -230,15 +230,17 @@ cache.add_backing_store(
);
```
When writing a `PlayerData`, the cache checks type-specific stores first. If none
match, it writes to the first generic store. Later matching stores are mirrors.
The contract, shared with every runtime, is one home store per document type
(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib):
This mirrors the C# behavior:
- a type-specific store owns its types; otherwise the generic store is the home
- at most one generic store, and no type claimed by two stores
- a write touches exactly one store; there are no mirrors
- specific domain stores own their domain
- the first generic store is the primary generic write target
- later generic stores mirror writes
- this is not multi-master
This crate does not yet enforce it: `add_backing_store` accepts overlapping
routes and a second generic store, and writes are pushed to every matching
store after the first. Attach one generic store, or type-specific stores whose
types do not overlap.
## Persistence Semantics

View file

@ -1,73 +1,52 @@
# GameCult.Caching.MessagePack.Generator
`GameCult.Caching.MessagePack.Generator` is a Roslyn incremental source generator that emits MessagePack formatters for concrete `DatabaseEntry` subclasses.
A Roslyn incremental source generator that emits CultCache document metadata,
and MessagePack payload codecs where it can, for classes marked
`[CultDocument]`.
## Scope
This project is infrastructure for the caching stack. Application code usually does not reference it directly.
Its job is to:
- find non-abstract subclasses of `DatabaseEntry`
- generate concrete `IMessagePackFormatter<T>` implementations
- generate resolver mappings consumed by `DatabaseEntryResolver`
Infrastructure for the caching stack. Application code does not reference it
directly; it arrives through `GameCult.Caching.MessagePack.Analyzers`. Without
it, `CultDocumentRegistry` builds the same descriptors by reflection.
## What It Generates
For each discovered concrete `DatabaseEntry` subtype, the generator emits:
One `GeneratedCultDocumentMetadataProvider_<Assembly>` per assembly, registered
with `[assembly: CultGeneratedDocumentMetadataProvider]`, holding for each
`[CultDocument]` class:
- a formatter type
- a resolver mapping entry
- schema name, schema version, and whether the type is `[CultGlobal]`
- member definitions (name, slot, persisted type name, reference and
cardinality, `[CultName]`, `[CultIndex]`)
- name and index accessors
- a payload serializer, and a deserializer when the type has a public or
internal parameterless constructor
Member slot assignment is:
Members are the public, non-static, non-`[IgnoreMember]` fields and settable
properties **declared on the class itself**; inherited members are not
discovered. Members with `[Key(n)]` take slot `n`; unkeyed members follow the
highest key in ordinal name order. Annotate every persisted member: source
order is not a schema.
1. `ID` in slot `0`
2. members with `[Key(...)]` in their exact key slot
3. remaining non-ignored instance fields and properties appended after the
highest occupied slot as legacy convenience
Payload codecs are emitted only when slots are dense (`0..n-1`) and the
compilation references MessagePack and `GameCult.Caching.MessagePack`.
Otherwise the definition carries no codec and serialization falls back to the
reflective path. A generated payload is an array of the members in slot order;
the deserializer leaves missing trailing slots at their initializer values and
skips unknown extra slots. Generated codecs read
`CultDocumentMessagePackSerialization.Options` at call time.
The generated formatter writes a MessagePack array sized to the highest occupied
slot and writes nil for unused gaps. Deserialization tolerates shorter older
arrays by leaving missing members at their constructor/field-initializer values,
and skips unknown extra slots. That makes `[Key(n)]` a durable wire contract:
deleted fields can leave their slot reserved and new fields can use new keys
without a store migration.
For schema-stable cache entries, annotate every persisted member. Unkeyed
members are supported for older code, but they are not a good long-term wire
contract because source member order is not a schema.
## Example
Given:
```csharp
[MessagePackObject]
public class PlayerData : DatabaseEntry, INamedEntry
{
[Key(1)] public string Email = string.Empty;
[Key(2)] public string PasswordHash = string.Empty;
[Key(3)] public string Username = string.Empty;
[IgnoreMember]
public string EntryName
{
get => Username;
set => Username = value;
}
}
```
the generator emits the formatter plumbing needed for MessagePack serialization of `PlayerData` without manually implementing an `IMessagePackFormatter<PlayerData>`.
The generator has no `[Union]` handling.
## When You Need To Think About This Package
Usually only when:
- formatter generation is not happening
- a cache-entry type is not serializing as expected
- you are debugging build-time source generation behavior
- a document's descriptor or schema id differs between generated and
reflective builds
- a document serializes through the reflective path when you expected a codec
- you are debugging build-time source generation
## Distribution
The generator is packaged for consumers through [../GameCult.Caching.MessagePack.Analyzers/README.md](../GameCult.Caching.MessagePack.Analyzers/README.md).
Packaged for consumers through
[../GameCult.Caching.MessagePack.Analyzers/README.md](../GameCult.Caching.MessagePack.Analyzers/README.md).