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>
386 lines
13 KiB
C#
386 lines
13 KiB
C#
using System;
|
|
using System.Buffers;
|
|
using System.Collections.Concurrent;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
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<>))
|
|
{
|
|
var formatterType = typeof(global::GameCult.Caching.CultRecordRefFormatter<>).MakeGenericType(type.GetGenericArguments()[0]);
|
|
return (IMessagePackFormatter<T>)Activator.CreateInstance(formatterType)!;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public static class CultDocumentMessagePackSerialization
|
|
{
|
|
private const int PersistedRecordFieldCount = 4;
|
|
private const int SchemaCatalogEntryFieldCount = 7;
|
|
private const int SchemaCatalogMemberFieldCount = 8;
|
|
private const int StoreSnapshotFieldCount = 3;
|
|
|
|
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
|
|
.WithResolver(CompositeResolver.Create(
|
|
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)}.");
|
|
}
|
|
|
|
public static byte[] SerializeUntyped(object value, Type type)
|
|
{
|
|
return SerializeUntyped(value, type, CultDocumentRegistry.Shared);
|
|
}
|
|
|
|
public static byte[] SerializeUntyped(object value, Type type, CultDocumentRegistry registry)
|
|
{
|
|
if (registry == null) throw new ArgumentNullException(nameof(registry));
|
|
if (value != null) registry.GetRequired(type);
|
|
return MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly));
|
|
}
|
|
|
|
public static object DeserializeUntyped(Type type, byte[] payload)
|
|
{
|
|
return DeserializeUntyped(type, payload, CultDocumentRegistry.Shared);
|
|
}
|
|
|
|
public static object DeserializeUntyped(Type type, byte[] payload, CultDocumentRegistry registry)
|
|
{
|
|
if (registry == null) throw new ArgumentNullException(nameof(registry));
|
|
registry.GetRequired(type);
|
|
return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type.Assembly))
|
|
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
writer.WriteArrayHeader(entry.Members.Length);
|
|
foreach (var member in entry.Members)
|
|
{
|
|
WriteSchemaCatalogMember(ref writer, member);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
for (var index = SchemaCatalogEntryFieldCount; index < fieldCount; index++)
|
|
{
|
|
reader.Skip();
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
public class SingleFileMessagePackBackingStore : SingleFileBackingStore
|
|
{
|
|
public SingleFileMessagePackBackingStore(string filePath, bool readOnly = false) : base(filePath, readOnly)
|
|
{
|
|
}
|
|
|
|
protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot)
|
|
{
|
|
return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot);
|
|
}
|
|
|
|
protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
|
|
{
|
|
return CultDocumentMessagePackSerialization.DeserializeSnapshot(data);
|
|
}
|
|
|
|
protected override byte[] SerializePayload(object document)
|
|
{
|
|
return CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), Registry);
|
|
}
|
|
|
|
protected override object DeserializePayload(Type documentType, byte[] payload)
|
|
{
|
|
return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
|
|
}
|
|
}
|