Assume CultRecordRef<T> formattable for MessagePack's source generator consumers

The package ships MessagePackAssumedFormattable(typeof(CultRecordRef<>)) as an
MSBuild AssemblyAttribute in buildTransitive, so generated resolvers defer the
ref to CultDocumentResolver. Geometry imports the targets and drops its three
per-member [MessagePackFormatter] workarounds. GeneratorConsumer is the
generator-on twin of ConsumerBuild.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-27 23:59:13 +01:00
commit 5c55f9cb15
9 changed files with 162 additions and 3 deletions

View file

@ -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

View file

@ -11,5 +11,6 @@
<PackageReference Include="System.Text.Json" Version="9.0.10" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
<None Include="buildTransitive\GameCult.Caching.MessagePack.targets" Pack="true" PackagePath="buildTransitive\" />
</ItemGroup>
</Project>

View file

@ -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<T>` 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`,

View file

@ -0,0 +1,9 @@
<Project>
<!-- MessagePack's source generator invents a formatter for CultRecordRef<T> instead of asking the resolver chain (MessagePack-CSharp#2133); assuming it formattable leaves the ref to CultDocumentResolver. -->
<ItemGroup>
<AssemblyAttribute Include="MessagePack.MessagePackAssumedFormattableAttribute">
<_Parameter1>typeof(global::GameCult.Caching.CultRecordRef&lt;&gt;)</_Parameter1>
<_Parameter1_IsLiteral>true</_Parameter1_IsLiteral>
</AssemblyAttribute>
</ItemGroup>
</Project>

View file

@ -161,7 +161,6 @@ namespace GameCult.Geometry
public string RequestId { get; set; } = string.Empty;
[Key(1)]
[MessagePackFormatter(typeof(CultRecordRefFormatter<CultGeometryDomainDocument>))]
public CultRecordRef<CultGeometryDomainDocument> DomainKey { get; set; }
[Key(2)]
@ -236,7 +235,6 @@ namespace GameCult.Geometry
public string CutId { get; set; } = string.Empty;
[Key(1)]
[MessagePackFormatter(typeof(CultRecordRefFormatter<CultGeometryBuildRequest>))]
public CultRecordRef<CultGeometryBuildRequest> RequestKey { get; set; }
[Key(2)]
@ -325,7 +323,6 @@ namespace GameCult.Geometry
public string ChunkId { get; set; } = string.Empty;
[Key(1)]
[MessagePackFormatter(typeof(CultRecordRefFormatter<CultGeometrySelectedCutManifest>))]
public CultRecordRef<CultGeometrySelectedCutManifest> CutKey { get; set; }
[Key(2)]

View file

@ -15,6 +15,8 @@
<PackageReference Include="System.Text.Json" Version="9.0.10" />
</ItemGroup>
<Import Project="..\GameCult.Caching.MessagePack\buildTransitive\GameCult.Caching.MessagePack.targets" />
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="ISOSURFACE-PROVENANCE.md" Pack="true" PackagePath="\" />

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup Condition="'$(Aot)' == 'true'">
<PublishAot>true</PublishAot>
<IsTrimmable>true</IsTrimmable>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup Condition="'$(Aot)' == 'true'">
<TrimmerRootAssembly Include="GameCult.Caching.GeneratorConsumer" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" />
</ItemGroup>
<Import Project="..\..\src\GameCult.Caching.MessagePack\buildTransitive\GameCult.Caching.MessagePack.targets" />
</Project>

View file

@ -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<RefTarget>(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<RefTarget> 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<CultRecordRef<RefTarget>, 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<RefTarget> Target { get; set; }
[Key(2)] public Dictionary<CultRecordRef<RefTarget>, float> Standing { get; set; } = new();
}

View file

@ -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<T>` members and `CultRecordRef<T>` 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