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