diff --git a/CultLib.sln b/CultLib.sln index fbc20922..217f2d1f 100644 --- a/CultLib.sln +++ b/CultLib.sln @@ -49,6 +49,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.GeneratorConsumer", "tests\GameCult.Caching.GeneratorConsumer\GameCult.Caching.GeneratorConsumer.csproj", "{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -275,6 +277,18 @@ Global {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.Build.0 = Release|Any CPU {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.ActiveCfg = Release|Any CPU {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.Build.0 = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x64.Build.0 = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x86.Build.0 = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|Any CPU.Build.0 = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x64.ActiveCfg = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x64.Build.0 = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x86.ActiveCfg = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -299,5 +313,6 @@ Global {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C} {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C} {D3138F3C-9531-4906-A5D3-74BD857D0685} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} EndGlobalSection EndGlobal diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index c2588e24..6261ca3d 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -11,5 +11,6 @@ + diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index dfc7bff6..d34490f7 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -59,6 +59,13 @@ await cache.PullAllBackingStoresAsync(); - The raw CultNet document lane should treat these payload bytes as already blessed, not decode and re-encode them for sport. +## MessagePack source generator + +NuGet consumers get `[assembly: MessagePackAssumedFormattable(typeof(CultRecordRef<>))]` from the package, so a +`CultRecordRef` member or dictionary key compiles with MessagePack's source generator on and is formatted by +CultLib's resolver; no per-member `[MessagePackFormatter]` is needed. Unity and `ProjectReference` consumers that turn the generator +on write that line themselves (or import `buildTransitive/GameCult.Caching.MessagePack.targets`). + ## CultMath Value Shapes Every public CultMath value type has one MessagePack shape (`CultMathResolver`, diff --git a/src/GameCult.Caching.MessagePack/buildTransitive/GameCult.Caching.MessagePack.targets b/src/GameCult.Caching.MessagePack/buildTransitive/GameCult.Caching.MessagePack.targets new file mode 100644 index 00000000..99388bb9 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/buildTransitive/GameCult.Caching.MessagePack.targets @@ -0,0 +1,9 @@ + + + + + <_Parameter1>typeof(global::GameCult.Caching.CultRecordRef<>) + <_Parameter1_IsLiteral>true + + + diff --git a/src/GameCult.Geometry/CultGeometryDocuments.cs b/src/GameCult.Geometry/CultGeometryDocuments.cs index 21383073..169b9ae1 100644 --- a/src/GameCult.Geometry/CultGeometryDocuments.cs +++ b/src/GameCult.Geometry/CultGeometryDocuments.cs @@ -161,7 +161,6 @@ namespace GameCult.Geometry public string RequestId { get; set; } = string.Empty; [Key(1)] - [MessagePackFormatter(typeof(CultRecordRefFormatter))] public CultRecordRef DomainKey { get; set; } [Key(2)] @@ -236,7 +235,6 @@ namespace GameCult.Geometry public string CutId { get; set; } = string.Empty; [Key(1)] - [MessagePackFormatter(typeof(CultRecordRefFormatter))] public CultRecordRef RequestKey { get; set; } [Key(2)] @@ -325,7 +323,6 @@ namespace GameCult.Geometry public string ChunkId { get; set; } = string.Empty; [Key(1)] - [MessagePackFormatter(typeof(CultRecordRefFormatter))] public CultRecordRef CutKey { get; set; } [Key(2)] diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index cc66cba9..9736e1b7 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -15,6 +15,8 @@ + + diff --git a/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj b/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj new file mode 100644 index 00000000..6d443a4f --- /dev/null +++ b/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj @@ -0,0 +1,25 @@ + + + Exe + net10.0 + enable + false + + + + true + true + true + + + + + + + + + + + + + diff --git a/tests/GameCult.Caching.GeneratorConsumer/Program.cs b/tests/GameCult.Caching.GeneratorConsumer/Program.cs new file mode 100644 index 00000000..bfd86525 --- /dev/null +++ b/tests/GameCult.Caching.GeneratorConsumer/Program.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +// The generator-on twin of ConsumerBuild: this project compiling is the first test, and running it checks that the generated +// formatters are the ones in use and that the bytes are the ones the dynamic formatter wrote. +var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(RefDoc).Assembly); +var target = new CultRecordRef(new CultRecordKey("p")); +var documents = new (Type Type, object Value)[] +{ + (typeof(MemberDoc), new MemberDoc { Name = "r", Target = target }), + (typeof(KeyedDoc), new KeyedDoc { Name = "r", Standing = { [target] = 0.5f } }), + (typeof(RefDoc), new RefDoc { Name = "r", Target = target, Standing = { [target] = 0.5f } }), +}; + +foreach (var (type, _) in documents) +{ + var formatter = options.Resolver.GetFormatterDynamic(type)!.GetType(); + if (formatter.Assembly.IsDynamic || !(formatter.FullName ?? "").Contains("GeneratedMessagePackResolver")) + return Fail($"{type.Name} is formatted by {formatter.FullName}, not a generated formatter"); +} + +var hex = Convert.ToHexString(MessagePackSerializer.Serialize(typeof(RefDoc), documents[2].Value, options)); +Console.WriteLine(hex); +if (hex != "93A172A17081A170CA3F000000") + return Fail("RefDoc payload is " + hex); + +var aot = !RuntimeFeature.IsDynamicCodeSupported; +if (aot) + Console.WriteLine("SKIP ref-keyed dictionary under NativeAOT: AOT cut 3 (map A6)"); +foreach (var (type, value) in documents) +{ + if (aot && type != typeof(MemberDoc)) + continue; + var path = Path.Combine(Path.GetTempPath(), "generator-consumer-" + Guid.NewGuid().ToString("N") + ".cc"); + try + { + var registry = CultDocumentRegistry.ForTypes(new[] { type, typeof(RefTarget) }); + using (var cache = new CultCache(registry)) + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + cache.Upsert(type, value, new CultRecordKey("k")); + cache.FlushAllBackingStores(); + } + using var reopened = new CultCache(registry); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + reopened.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + var read = reopened.Get(new CultRecordKey("k")); + if (read == null || !Convert.ToHexString(MessagePackSerializer.Serialize(type, read, options)).Equals(Convert.ToHexString(MessagePackSerializer.Serialize(type, value, options)))) + return Fail($"{type.Name} did not round-trip through the store"); + } + finally + { + File.Delete(path); + } +} +return 0; + +static int Fail(string message) +{ + Console.Error.WriteLine(message); + return 1; +} + +[CultDocument("tests.generator_target", "tests.generator_target.v1")] +[MessagePackObject] +public sealed class RefTarget +{ + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; +} + +[CultDocument("tests.generator_member", "tests.generator_member.v1")] +[MessagePackObject] +public sealed class MemberDoc +{ + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public CultRecordRef Target { get; set; } +} + +[CultDocument("tests.generator_keyed", "tests.generator_keyed.v1")] +[MessagePackObject] +public sealed class KeyedDoc +{ + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public Dictionary, float> Standing { get; set; } = new(); +} + +[CultDocument("tests.generator_both", "tests.generator_both.v1")] +[MessagePackObject] +public sealed class RefDoc +{ + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public CultRecordRef Target { get; set; } + [Key(2)] public Dictionary, float> Standing { get; set; } = new(); +} diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index f031e599..126fcfa5 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -35,6 +35,10 @@ Releases as 3.0.0 (major). - `CultElementIdAttribute` declares an element id (derived from a named member when given), `CultForeignRecord` is a record a store carries untouched because no registered type reads it, and `CultElementIdException`, `CultSchemaConflictException`, `CultStoreUnreadableException` and `CultWriteConflictException` are the refusals named above. - A store written by 1.0.x with a record-reference list in a slot still opens (from 2.0.1). +### Fixed + +- A consumer with MessagePack's source generator on can declare `CultRecordRef` members and `CultRecordRef` dictionary keys. The NuGet package injects the assembly attribute that makes this compile; Unity consumers add one line (see the MessagePack README). Bytes are unchanged. + ## [2.0.1] - 2026-10-22 ### Fixed