From c26870b803c146a50ef2056a8d5e69539d3900fb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 8 May 2026 02:19:27 +0100 Subject: [PATCH] Generate explicit CultCache payload codecs --- .../CultDocumentMessagePackGenerator.cs | 96 ++++++++++++++++--- .../CultDocumentMessagePackSerialization.cs | 15 +++ src/GameCult.Caching/CultCache.cs | 10 ++ .../CultGeneratedDocumentMetadata.cs | 6 ++ src/GameCult.Networking/PlayerData.cs | 1 - .../BackingStoreTests.cs | 21 +++- .../Program.cs | 1 - 7 files changed, 135 insertions(+), 15 deletions(-) diff --git a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs index 91814f99..7a92cdc4 100644 --- a/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs +++ b/src/GameCult.Caching.MessagePack.Generator/CultDocumentMessagePackGenerator.cs @@ -24,7 +24,7 @@ namespace GameCult.Caching.MessagePack.Generator var compilationAndShapes = context.CompilationProvider.Combine(documentShapes.Collect()); context.RegisterSourceOutput( compilationAndShapes, - static (spc, pair) => EmitProvider(spc, pair.Left.AssemblyName ?? "GameCult", pair.Right)); + static (spc, pair) => EmitProvider(spc, pair.Left, pair.Right)); } private static DocumentShape? BuildDocumentShape(GeneratorSyntaxContext context) @@ -142,7 +142,7 @@ namespace GameCult.Caching.MessagePack.Generator : null; } - private static void EmitProvider(SourceProductionContext context, string assemblyName, ImmutableArray shapes) + private static void EmitProvider(SourceProductionContext context, Compilation compilation, ImmutableArray shapes) { var documents = shapes .Where(shape => shape != null) @@ -156,7 +156,11 @@ namespace GameCult.Caching.MessagePack.Generator return; } - var providerName = $"GeneratedCultDocumentMetadataProvider_{SanitizeIdentifier(assemblyName)}"; + var canEmitPayloadCodecs = + compilation.GetTypeByMetadataName("MessagePack.MessagePackWriter") != null && + compilation.GetTypeByMetadataName("GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization") != null; + + var providerName = $"GeneratedCultDocumentMetadataProvider_{SanitizeIdentifier(compilation.AssemblyName ?? "GameCult")}"; var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); @@ -181,6 +185,12 @@ namespace GameCult.Caching.MessagePack.Generator builder.AppendLine(document.NameAccessorMethodName == null ? " null," : $" {document.NameAccessorMethodName},"); + builder.AppendLine(canEmitPayloadCodecs + ? $" {document.PayloadSerializerMethodName}," + : " null,"); + builder.AppendLine(canEmitPayloadCodecs + ? $" {document.PayloadDeserializerMethodName}," + : " null,"); builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentIndexAccessor[]"); builder.AppendLine(" {"); foreach (var indexAccessor in document.IndexAccessors) @@ -190,12 +200,12 @@ namespace GameCult.Caching.MessagePack.Generator builder.AppendLine(" },"); builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentMemberDefinition[]"); builder.AppendLine(" {"); - foreach (var member in document.Members) + foreach (var member in document.Members.OrderBy(member => member.Slot)) { builder.AppendLine(" new global::GameCult.Caching.CultGeneratedDocumentMemberDefinition("); builder.AppendLine($" \"{Escape(member.Name)}\","); builder.AppendLine($" {member.Slot},"); - builder.AppendLine($" \"{Escape(member.TypeName)}\","); + builder.AppendLine($" \"{Escape(member.SchemaTypeName)}\","); builder.AppendLine($" {(member.IsReference ? "true" : "false")},"); builder.AppendLine($" {(member.IsMany ? "true" : "false")},"); builder.AppendLine(member.TargetSchemaName == null @@ -219,6 +229,12 @@ namespace GameCult.Caching.MessagePack.Generator foreach (var document in documents) { + if (canEmitPayloadCodecs) + { + EmitPayloadSerializer(builder, document); + EmitPayloadDeserializer(builder, document); + } + foreach (var member in document.Members.Where(member => member.AccessorMethodName != null)) { builder.AppendLine($" private static string? {member.AccessorMethodName}(object document)"); @@ -247,6 +263,49 @@ namespace GameCult.Caching.MessagePack.Generator context.AddSource($"{providerName}.g.cs", SourceText.From(builder.ToString(), Encoding.UTF8)); } + private static void EmitPayloadSerializer(StringBuilder builder, DocumentShape document) + { + builder.AppendLine($" private static byte[] {document.PayloadSerializerMethodName}(object document)"); + builder.AppendLine(" {"); + builder.AppendLine($" var typed = ({document.DocumentTypeName})document;"); + builder.AppendLine(" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options;"); + builder.AppendLine(" var buffer = new global::System.Buffers.ArrayBufferWriter();"); + builder.AppendLine(" var writer = new global::MessagePack.MessagePackWriter(buffer);"); + builder.AppendLine($" writer.WriteArrayHeader({document.Members.Length});"); + foreach (var member in document.Members.OrderBy(member => member.Slot)) + { + builder.AppendLine($" global::MessagePack.FormatterResolverExtensions.GetFormatterWithVerify<{member.TypeSyntaxName}>(options.Resolver).Serialize(ref writer, typed.{member.Name}, options);"); + } + builder.AppendLine(" writer.Flush();"); + builder.AppendLine(" return buffer.WrittenSpan.ToArray();"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + + private static void EmitPayloadDeserializer(StringBuilder builder, DocumentShape document) + { + builder.AppendLine($" private static object {document.PayloadDeserializerMethodName}(byte[] payload)"); + builder.AppendLine(" {"); + builder.AppendLine(" var options = global::GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options;"); + builder.AppendLine(" var reader = new global::MessagePack.MessagePackReader(payload);"); + builder.AppendLine(" var count = reader.ReadArrayHeader();"); + builder.AppendLine($" var value = new {document.DocumentTypeName}();"); + foreach (var member in document.Members.OrderBy(member => member.Slot)) + { + builder.AppendLine($" if (count > {member.Slot})"); + builder.AppendLine(" {"); + builder.AppendLine($" value.{member.Name} = global::MessagePack.FormatterResolverExtensions.GetFormatterWithVerify<{member.TypeSyntaxName}>(options.Resolver).Deserialize(ref reader, options);"); + builder.AppendLine(" }"); + } + builder.AppendLine($" for (var index = {document.Members.Length}; index < count; index++)"); + builder.AppendLine(" {"); + builder.AppendLine(" reader.Skip();"); + builder.AppendLine(" }"); + builder.AppendLine(" return value;"); + builder.AppendLine(" }"); + builder.AppendLine(); + } + private static string Escape(string value) { return value @@ -345,7 +404,8 @@ namespace GameCult.Caching.MessagePack.Generator { private MemberShapeSeed( string name, - string typeName, + string schemaTypeName, + string typeSyntaxName, int? explicitSlot, string sortKey, bool isName, @@ -356,7 +416,8 @@ namespace GameCult.Caching.MessagePack.Generator string? targetSchemaName) { Name = name; - TypeName = typeName; + SchemaTypeName = schemaTypeName; + TypeSyntaxName = typeSyntaxName; ExplicitSlot = explicitSlot; SortKey = sortKey; IsName = isName; @@ -368,7 +429,8 @@ namespace GameCult.Caching.MessagePack.Generator } public string Name { get; } - public string TypeName { get; } + public string SchemaTypeName { get; } + public string TypeSyntaxName { get; } public int? ExplicitSlot { get; } public string SortKey { get; } public bool IsName { get; } @@ -384,7 +446,8 @@ namespace GameCult.Caching.MessagePack.Generator return new MemberShape( Name, slot, - TypeName, + SchemaTypeName, + TypeSyntaxName, IsReference, IsMany, TargetSchemaName, @@ -418,6 +481,7 @@ namespace GameCult.Caching.MessagePack.Generator return new MemberShapeSeed( member.Name, GetSchemaTypeName(memberType), + memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), GetExplicitSlot(member), BuildAccessorStem(member), GetAttribute(member, "GameCult.Caching.CultNameAttribute") != null, @@ -469,6 +533,9 @@ namespace GameCult.Caching.MessagePack.Generator IsGlobal = isGlobal; NameMember = nameMember; NameAccessorMethodName = nameAccessorMethodName; + var safeStem = SanitizeIdentifier(documentTypeName.Replace("global::", string.Empty).Replace(".", "_")); + PayloadSerializerMethodName = $"Serialize_{safeStem}_Payload"; + PayloadDeserializerMethodName = $"Deserialize_{safeStem}_Payload"; IndexAccessors = indexAccessors; Members = members; } @@ -479,6 +546,8 @@ namespace GameCult.Caching.MessagePack.Generator public bool IsGlobal { get; } public string? NameMember { get; } public string? NameAccessorMethodName { get; } + public string PayloadSerializerMethodName { get; } + public string PayloadDeserializerMethodName { get; } public ImmutableArray IndexAccessors { get; } public ImmutableArray Members { get; } } @@ -500,7 +569,8 @@ namespace GameCult.Caching.MessagePack.Generator public MemberShape( string name, int slot, - string typeName, + string schemaTypeName, + string typeSyntaxName, bool isReference, bool isMany, string? targetSchemaName, @@ -512,7 +582,8 @@ namespace GameCult.Caching.MessagePack.Generator { Name = name; Slot = slot; - TypeName = typeName; + SchemaTypeName = schemaTypeName; + TypeSyntaxName = typeSyntaxName; IsReference = isReference; IsMany = isMany; TargetSchemaName = targetSchemaName; @@ -525,7 +596,8 @@ namespace GameCult.Caching.MessagePack.Generator public string Name { get; } public int Slot { get; } - public string TypeName { get; } + public string SchemaTypeName { get; } + public string TypeSyntaxName { get; } public bool IsReference { get; } public bool IsMany { get; } public string? TargetSchemaName { get; } diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 0afddd3a..30d03467 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -45,11 +45,26 @@ public static class CultDocumentMessagePackSerialization public static byte[] SerializeUntyped(object value, Type type) { + if (value != null) + { + var descriptor = CultDocumentRegistry.Shared.GetRequired(type); + if (descriptor.GeneratedPayloadSerializer != null) + { + return descriptor.GeneratedPayloadSerializer(value); + } + } + return MessagePackSerializer.Serialize(type, value, Options); } public static object DeserializeUntyped(Type type, byte[] payload) { + var descriptor = CultDocumentRegistry.Shared.GetRequired(type); + if (descriptor.GeneratedPayloadDeserializer != null) + { + return descriptor.GeneratedPayloadDeserializer(payload); + } + return MessagePackSerializer.Deserialize(type, payload, Options); } } diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index d9139cda..d107d682 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -50,6 +50,8 @@ namespace GameCult.Caching bool isGlobal, string? nameMember, Func? nameAccessor, + Func? generatedPayloadSerializer, + Func? generatedPayloadDeserializer, IReadOnlyDictionary> indexAccessors, IReadOnlyList members) { @@ -62,6 +64,8 @@ namespace GameCult.Caching IsGlobal = isGlobal; NameMember = nameMember; NameAccessor = nameAccessor; + GeneratedPayloadSerializer = generatedPayloadSerializer; + GeneratedPayloadDeserializer = generatedPayloadDeserializer; IndexAccessors = indexAccessors; Members = members; } @@ -75,6 +79,8 @@ namespace GameCult.Caching public bool IsGlobal { get; } public string? NameMember { get; } internal Func? NameAccessor { get; } + public Func? GeneratedPayloadSerializer { get; } + public Func? GeneratedPayloadDeserializer { get; } internal IReadOnlyDictionary> IndexAccessors { get; } internal IReadOnlyList Members { get; } @@ -292,6 +298,8 @@ namespace GameCult.Caching type.GetCustomAttribute() != null, nameMember?.Member.Name, nameMember?.GetterNullable, + null, + null, indexAccessors, descriptorMembers); } @@ -327,6 +335,8 @@ namespace GameCult.Caching definition.IsGlobal, definition.NameMember, definition.NameAccessor, + definition.SerializePayload, + definition.DeserializePayload, definition.IndexAccessors.ToDictionary(accessor => accessor.Alias, accessor => accessor.Accessor, StringComparer.Ordinal), descriptorMembers); } diff --git a/src/GameCult.Caching/CultGeneratedDocumentMetadata.cs b/src/GameCult.Caching/CultGeneratedDocumentMetadata.cs index c4a9b355..af241811 100644 --- a/src/GameCult.Caching/CultGeneratedDocumentMetadata.cs +++ b/src/GameCult.Caching/CultGeneratedDocumentMetadata.cs @@ -31,6 +31,8 @@ namespace GameCult.Caching bool isGlobal, string? nameMember, Func? nameAccessor, + Func? serializePayload, + Func? deserializePayload, IReadOnlyList indexAccessors, IReadOnlyList members) { @@ -44,6 +46,8 @@ namespace GameCult.Caching IsGlobal = isGlobal; NameMember = nameMember; NameAccessor = nameAccessor; + SerializePayload = serializePayload; + DeserializePayload = deserializePayload; IndexAccessors = indexAccessors ?? Array.Empty(); Members = members ?? Array.Empty(); } @@ -54,6 +58,8 @@ namespace GameCult.Caching public bool IsGlobal { get; } public string? NameMember { get; } public Func? NameAccessor { get; } + public Func? SerializePayload { get; } + public Func? DeserializePayload { get; } public IReadOnlyList IndexAccessors { get; } public IReadOnlyList Members { get; } } diff --git a/src/GameCult.Networking/PlayerData.cs b/src/GameCult.Networking/PlayerData.cs index 3c17d635..1d2ecf6d 100644 --- a/src/GameCult.Networking/PlayerData.cs +++ b/src/GameCult.Networking/PlayerData.cs @@ -5,7 +5,6 @@ using MessagePack; namespace GameCult.Networking { [CultDocument("gamecult.player_data", "gamecult.player_data.v1")] - [MessagePackObject] public class PlayerData { [Key(0)] diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index dbed5daf..46e84041 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -175,6 +175,26 @@ namespace GameCult.Caching.Tests Assert.That(parentMember.TypeName, Does.Contain("CultRecordRef")); } + [Test] + public void GeneratedMetadataProvider_Emits_Payload_Codecs_For_Plain_CultDocuments() + { + var descriptor = CultDocumentRegistry.Shared.GetRequired(); + var original = new NamedTestEntry + { + Name = "Teeth", + Value = "slot-array" + }; + + Assert.That(descriptor.GeneratedPayloadSerializer, Is.Not.Null); + Assert.That(descriptor.GeneratedPayloadDeserializer, Is.Not.Null); + + var payload = descriptor.GeneratedPayloadSerializer!(original); + var roundTrip = (NamedTestEntry)descriptor.GeneratedPayloadDeserializer!(payload); + + Assert.That(roundTrip.Name, Is.EqualTo("Teeth")); + Assert.That(roundTrip.Value, Is.EqualTo("slot-array")); + } + [Test] public void Registry_CanonicalSchemaJson_Tracks_Reference_Metadata() { @@ -185,7 +205,6 @@ namespace GameCult.Caching.Tests } [CultDocument("tests.named_entry", "tests.named_entry.v1")] - [MessagePackObject(AllowPrivate = true)] internal sealed class NamedTestEntry { [Key(0)] diff --git a/tests/GameCult.Networking.InteropPeer/Program.cs b/tests/GameCult.Networking.InteropPeer/Program.cs index e107ae60..13761363 100644 --- a/tests/GameCult.Networking.InteropPeer/Program.cs +++ b/tests/GameCult.Networking.InteropPeer/Program.cs @@ -666,7 +666,6 @@ static class InteropPeerShared } [CultDocument(InteropPeerShared.InteropDocumentType, InteropPeerShared.InteropSchemaVersion)] -[MessagePackObject] public sealed class CultNetInteropNote { [Key(0)] public string SchemaVersion { get; set; } = InteropPeerShared.InteropSchemaVersion;