2026-05-08 01:13:15 +01:00
using System ;
2026-05-08 02:31:06 +01:00
using System.Buffers ;
2026-09-13 21:12:53 +02:00
using System.Collections.Concurrent ;
using System.Linq ;
using System.Reflection ;
2026-05-08 01:13:15 +01:00
using GameCult.Caching ;
using MessagePack ;
using MessagePack.Formatters ;
using MessagePack.Resolvers ;
namespace GameCult.Caching.MessagePack ;
public sealed class CultDocumentResolver : IFormatterResolver
{
public static readonly CultDocumentResolver Instance = new ( ) ;
private CultDocumentResolver ( ) { }
public IMessagePackFormatter < T > ? GetFormatter < T > ( )
{
var type = typeof ( T ) ;
if ( type . IsGenericType & & type . GetGenericTypeDefinition ( ) = = typeof ( CultRecordRef < > ) )
{
2026-05-08 01:50:15 +01:00
var formatterType = typeof ( global :: GameCult . Caching . CultRecordRefFormatter < > ) . MakeGenericType ( type . GetGenericArguments ( ) [ 0 ] ) ;
2026-05-08 01:13:15 +01:00
return ( IMessagePackFormatter < T > ) Activator . CreateInstance ( formatterType ) ! ;
}
return null ;
}
}
public static class CultDocumentMessagePackSerialization
{
2026-05-08 02:31:06 +01:00
private const int PersistedRecordFieldCount = 4 ;
2026-05-09 11:45:46 +01:00
private const int SchemaCatalogEntryFieldCount = 7 ;
private const int SchemaCatalogMemberFieldCount = 8 ;
2026-05-08 02:31:06 +01:00
private const int StoreSnapshotFieldCount = 3 ;
2026-09-13 21:12:53 +02:00
public static readonly MessagePackSerializerOptions Options = Compose ( Array . Empty < IFormatterResolver > ( ) ) ;
private static readonly ConcurrentDictionary < Assembly , MessagePackSerializerOptions > AssemblyOptions = new ( ) ;
public static MessagePackSerializerOptions OptionsFor ( Assembly documentAssembly )
{
if ( documentAssembly = = null ) throw new ArgumentNullException ( nameof ( documentAssembly ) ) ;
return AssemblyOptions . GetOrAdd ( documentAssembly , static assembly = >
{
var resolvers = assembly . GetCustomAttributes < CultCacheFormatterResolverAttribute > ( )
. Select ( attribute = > CreateResolver ( attribute . ResolverType ) )
. ToArray ( ) ;
return resolvers . Length = = 0 ? Options : Compose ( resolvers ) ;
} ) ;
}
private static MessagePackSerializerOptions Compose ( IFormatterResolver [ ] consumerResolvers )
{
return MessagePackSerializerOptions . Standard
2026-05-08 01:13:15 +01:00
. WithResolver ( CompositeResolver . Create (
2026-09-13 21:12:53 +02:00
consumerResolvers . Append ( CultDocumentResolver . Instance ) . Append ( StandardResolver . Instance ) . ToArray ( ) ) )
. WithSecurity ( CultMessagePackSecurity . Instance ) ;
}
private static IFormatterResolver CreateResolver ( Type resolverType )
{
var instance = resolverType . GetField ( "Instance" , BindingFlags . Public | BindingFlags . Static ) ? . GetValue ( null )
? ? resolverType . GetProperty ( "Instance" , BindingFlags . Public | BindingFlags . Static ) ? . GetValue ( null )
? ? ( resolverType . GetConstructor ( Type . EmptyTypes ) ! = null ? Activator . CreateInstance ( resolverType ) : null ) ;
return instance as IFormatterResolver
? ? throw new InvalidOperationException (
$"Formatter resolver {resolverType.FullName} needs a public static Instance or a public parameterless constructor and must implement {nameof(IFormatterResolver)}." ) ;
}
2026-05-08 01:13:15 +01:00
public static byte [ ] SerializeUntyped ( object value , Type type )
{
2026-07-20 01:48:56 +01:00
return SerializeUntyped ( value , type , CultDocumentRegistry . Shared ) ;
}
public static byte [ ] SerializeUntyped ( object value , Type type , CultDocumentRegistry registry )
{
if ( registry = = null ) throw new ArgumentNullException ( nameof ( registry ) ) ;
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
if ( value ! = null ) registry . GetRequired ( type ) ;
2026-09-13 21:12:53 +02:00
return MessagePackSerializer . Serialize ( type , value , OptionsFor ( type . Assembly ) ) ;
2026-05-08 01:13:15 +01:00
}
public static object DeserializeUntyped ( Type type , byte [ ] payload )
{
2026-07-20 01:48:56 +01:00
return DeserializeUntyped ( type , payload , CultDocumentRegistry . Shared ) ;
}
public static object DeserializeUntyped ( Type type , byte [ ] payload , CultDocumentRegistry registry )
{
if ( registry = = null ) throw new ArgumentNullException ( nameof ( registry ) ) ;
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
registry . GetRequired ( type ) ;
2026-09-13 21:12:53 +02:00
return MessagePackSerializer . Deserialize ( type , payload , OptionsFor ( type . Assembly ) )
2026-05-09 11:14:23 +01:00
? ? throw new InvalidOperationException ( $"MessagePack returned null for Cult document type {type.FullName}." ) ;
2026-05-08 01:13:15 +01:00
}
2026-05-08 02:31:06 +01:00
public static byte [ ] SerializePersistedRecord ( CultPersistedRecord record )
{
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
WritePersistedRecord ( ref writer , record ) ;
writer . Flush ( ) ;
return buffer . WrittenSpan . ToArray ( ) ;
}
public static CultPersistedRecord DeserializePersistedRecord ( byte [ ] payload )
{
var reader = new MessagePackReader ( payload ) ;
return ReadPersistedRecord ( ref reader ) ;
}
public static byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot )
{
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
writer . WriteArrayHeader ( StoreSnapshotFieldCount ) ;
writer . Write ( snapshot . FormatVersion ) ;
writer . WriteArrayHeader ( snapshot . SchemaCatalog . Length ) ;
foreach ( var entry in snapshot . SchemaCatalog )
{
WriteSchemaCatalogEntry ( ref writer , entry ) ;
}
writer . WriteArrayHeader ( snapshot . Records . Length ) ;
foreach ( var record in snapshot . Records )
{
WritePersistedRecord ( ref writer , record ) ;
}
writer . Flush ( ) ;
return buffer . WrittenSpan . ToArray ( ) ;
}
public static CultPersistedStoreSnapshot DeserializeSnapshot ( byte [ ] payload )
{
var reader = new MessagePackReader ( payload ) ;
var fieldCount = reader . ReadArrayHeader ( ) ;
var snapshot = new CultPersistedStoreSnapshot ( ) ;
if ( fieldCount > 0 )
{
snapshot . FormatVersion = reader . ReadString ( ) ? ? "cultcache.store.v1" ;
}
if ( fieldCount > 1 )
{
var catalogCount = reader . ReadArrayHeader ( ) ;
snapshot . SchemaCatalog = new CultSchemaCatalogEntry [ catalogCount ] ;
for ( var index = 0 ; index < catalogCount ; index + + )
{
snapshot . SchemaCatalog [ index ] = ReadSchemaCatalogEntry ( ref reader ) ;
}
}
if ( fieldCount > 2 )
{
var recordCount = reader . ReadArrayHeader ( ) ;
snapshot . Records = new CultPersistedRecord [ recordCount ] ;
for ( var index = 0 ; index < recordCount ; index + + )
{
snapshot . Records [ index ] = ReadPersistedRecord ( ref reader ) ;
}
}
for ( var index = StoreSnapshotFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return snapshot ;
}
private static void WritePersistedRecord ( ref MessagePackWriter writer , CultPersistedRecord record )
{
writer . WriteArrayHeader ( PersistedRecordFieldCount ) ;
writer . Write ( record . Key ) ;
writer . Write ( record . SchemaId ) ;
writer . Write ( record . StoredAt ) ;
writer . Write ( record . Payload ) ;
}
private static CultPersistedRecord ReadPersistedRecord ( ref MessagePackReader reader )
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var record = new CultPersistedRecord ( ) ;
if ( fieldCount > 0 )
{
record . Key = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 1 )
{
record . SchemaId = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
record . StoredAt = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
record . Payload = reader . ReadBytes ( ) ? . ToArray ( ) ? ? Array . Empty < byte > ( ) ;
}
for ( var index = PersistedRecordFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return record ;
}
private static void WriteSchemaCatalogEntry ( ref MessagePackWriter writer , CultSchemaCatalogEntry entry )
{
writer . WriteArrayHeader ( SchemaCatalogEntryFieldCount ) ;
writer . Write ( entry . SchemaId ) ;
writer . Write ( entry . SchemaName ) ;
writer . Write ( entry . SchemaVersion ) ;
writer . Write ( entry . ContentHash ) ;
writer . Write ( entry . CanonicalSchemaJson ) ;
writer . WriteArrayHeader ( entry . CompatibleSchemaIds . Length ) ;
foreach ( var schemaId in entry . CompatibleSchemaIds )
{
writer . Write ( schemaId ) ;
}
2026-05-09 11:45:46 +01:00
writer . WriteArrayHeader ( entry . Members . Length ) ;
foreach ( var member in entry . Members )
{
WriteSchemaCatalogMember ( ref writer , member ) ;
}
2026-05-08 02:31:06 +01:00
}
private static CultSchemaCatalogEntry ReadSchemaCatalogEntry ( ref MessagePackReader reader )
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var entry = new CultSchemaCatalogEntry ( ) ;
if ( fieldCount > 0 )
{
entry . SchemaId = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 1 )
{
entry . SchemaName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
entry . SchemaVersion = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
entry . ContentHash = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 4 )
{
entry . CanonicalSchemaJson = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 5 )
{
var compatibleCount = reader . ReadArrayHeader ( ) ;
entry . CompatibleSchemaIds = new string [ compatibleCount ] ;
for ( var index = 0 ; index < compatibleCount ; index + + )
{
entry . CompatibleSchemaIds [ index ] = reader . ReadString ( ) ? ? string . Empty ;
}
}
2026-05-09 11:45:46 +01:00
if ( fieldCount > 6 )
{
var memberCount = reader . ReadArrayHeader ( ) ;
entry . Members = new CultSchemaMemberCatalogEntry [ memberCount ] ;
for ( var index = 0 ; index < memberCount ; index + + )
{
entry . Members [ index ] = ReadSchemaCatalogMember ( ref reader ) ;
}
}
2026-05-08 02:31:06 +01:00
for ( var index = SchemaCatalogEntryFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return entry ;
}
2026-05-09 11:45:46 +01:00
private static void WriteSchemaCatalogMember ( ref MessagePackWriter writer , CultSchemaMemberCatalogEntry member )
{
writer . WriteArrayHeader ( SchemaCatalogMemberFieldCount ) ;
writer . Write ( member . Slot ) ;
writer . Write ( member . MemberName ) ;
writer . Write ( member . TypeName ) ;
writer . Write ( member . IsReference ) ;
writer . Write ( member . IsMany ) ;
writer . Write ( member . TargetSchemaName ) ;
writer . Write ( member . IsName ) ;
writer . Write ( member . IndexAlias ) ;
}
private static CultSchemaMemberCatalogEntry ReadSchemaCatalogMember ( ref MessagePackReader reader )
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var member = new CultSchemaMemberCatalogEntry ( ) ;
if ( fieldCount > 0 )
{
member . Slot = reader . ReadInt32 ( ) ;
}
if ( fieldCount > 1 )
{
member . MemberName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
member . TypeName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
member . IsReference = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 4 )
{
member . IsMany = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 5 )
{
member . TargetSchemaName = reader . ReadString ( ) ;
}
if ( fieldCount > 6 )
{
member . IsName = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 7 )
{
member . IndexAlias = reader . ReadString ( ) ;
}
for ( var index = SchemaCatalogMemberFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return member ;
}
2026-05-08 01:13:15 +01:00
}
public class SingleFileMessagePackBackingStore : SingleFileBackingStore
{
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
public SingleFileMessagePackBackingStore ( string filePath , bool readOnly = false ) : base ( filePath , readOnly )
2026-05-08 01:13:15 +01:00
{
}
protected override byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot )
{
2026-05-08 02:31:06 +01:00
return CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ;
2026-05-08 01:13:15 +01:00
}
protected override CultPersistedStoreSnapshot DeserializeSnapshot ( byte [ ] data )
{
2026-05-08 02:31:06 +01:00
return CultDocumentMessagePackSerialization . DeserializeSnapshot ( data ) ;
2026-05-08 01:13:15 +01:00
}
protected override byte [ ] SerializePayload ( object document )
{
2026-07-20 01:48:56 +01:00
return CultDocumentMessagePackSerialization . SerializeUntyped ( document , document . GetType ( ) , Registry ) ;
2026-05-08 01:13:15 +01:00
}
protected override object DeserializePayload ( Type documentType , byte [ ] payload )
{
2026-07-20 01:48:56 +01:00
return CultDocumentMessagePackSerialization . DeserializeUntyped ( documentType , payload , Registry ) ;
2026-05-08 01:13:15 +01:00
}
}