Element ids part 2: one recogniser; shapes the walk does not own are whole values

Delete Hidden, HiddenProblem, UnreadKeyedMembers/UnreadMembers, Settle's
[MessagePackFormatter] refusals and the door's written-as-object branch.
Classify gains Leaf for attribute-formatted, [DataContract] and string-keyed
types; MembersOf honours the most derived AllowPrivate; the door refuses a
null StandardResolver formatter with the throwing path's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-16 00:54:37 +02:00
commit 19f475ca92
5 changed files with 297 additions and 147 deletions

View file

@ -84,8 +84,8 @@ public static class CultDocumentMessagePackSerialization
var generic = GetFormatterMethod.MakeGenericMethod(type);
// A resolver that hands back MessagePack's own formatter for the type writes its [Key] layout: that is no claim, and the
// composite stops there, so no later resolver is asked. StandardResolverAllowPrivate's formatter writes the same layout
// plus non-public [Key] members, and registration refuses a non-public member on the way to an object list, so it
// claims nothing either.
// plus non-public [Key] members; those extra members are whole values the walk does not own, so it claims nothing
// either.
object? standard;
try
{
@ -93,15 +93,14 @@ public static class CultDocumentMessagePackSerialization
}
catch (Exception exception)
{
return $"Cult document {documentType.Name}: MessagePack cannot build a formatter for {type.Name}, which is on the path to a list whose elements carry ids ({(exception.InnerException ?? exception).Message}).";
return CannotBuild(documentType, type, (exception.InnerException ?? exception).Message);
}
if (WrittenAgainstWalk(type, generic, standard))
{
return CultElementIds.WalksAsOwner(type)
? $"Cult document {documentType.Name}: MessagePack writes {type.Name} as a collection, but the element-id walk reads it by its [Key] layout, so element ids would be lost or invented. A [MessagePackObject] type that is also a collection (ICollection<T>, IList, IDictionary) is written as a collection; make it one or the other."
: $"Cult document {documentType.Name}: MessagePack writes {type.Name} as an object, but the element-id walk reads it as a collection, so element ids would be lost or invented. A [DataContract] type that is also enumerable is written by its members; mark it [MessagePackObject] with integer [Key]s, or drop the contract.";
}
if (standard == null)
return CannotBuild(documentType, type, "StandardResolver has no formatter for it");
if (WrittenAsCollection(type, generic, standard))
return $"Cult document {documentType.Name}: MessagePack writes {type.Name} as a collection, but the element-id walk reads it by its [Key] layout, so element ids would be lost or invented. A [MessagePackObject] type that is also a collection (ICollection<T>, IList, IDictionary) is written as a collection; make it one or the other. Or declare [MessagePackFormatter] on the type or member, which makes it one whole value with no element ids.";
var allowPrivateAsked = false;
object? allowPrivate = null;
@ -136,23 +135,22 @@ public static class CultDocumentMessagePackSerialization
if (allowPrivate != null && ReferenceEquals(formatter, allowPrivate))
break;
return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} formats {type.Name}, which is on the path to a list whose elements carry ids, so its element ids would never reach disk. A resolver may not claim such a type; write it by its [Key] layout or keep it out of the document's lists.";
return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} formats {type.Name}, which is on the path to a list whose elements carry ids, so its element ids would never reach disk. A resolver may not claim such a type; write it by its [Key] layout, keep it out of the document's lists, or declare [MessagePackFormatter] on the type or member, which makes it one whole value with no element ids.";
}
}
return null;
}
// The door refuses only a proven mismatch: StandardResolver's formatter being the very instance the contrary resolver's
// public singleton hands out (one is cached per type). An owner is contradicted by the generic resolver (a keyed
// ICollection<T> or dictionary), a collection by the object resolver (a [DataContract] enumerable). Union types walk as
// owners and the immutable and builtin resolvers claim only framework types, so neither can contradict. Any other
// formatter, a source-generated one among them, passes: the walk owns which types carry ids and this door is its safety net.
private static bool WrittenAgainstWalk(Type type, MethodInfo generic, object? standard)
{
var contrary = CultElementIds.WalksAsOwner(type) ? (IFormatterResolver)DynamicGenericResolver.Instance : DynamicObjectResolver.Instance;
return standard != null && ReferenceEquals(Formatter(contrary, generic), standard);
}
private static string CannotBuild(Type documentType, Type type, string reason) =>
$"Cult document {documentType.Name}: MessagePack cannot build a formatter for {type.Name}, which is on the path to a list whose elements carry ids ({reason}).";
// The door verifies the walk and never decides ownership. It refuses a proven mismatch: a type the walk reads by key
// (an owner) whose StandardResolver formatter is the very instance the generic resolver's singleton hands out (a keyed
// ICollection<T> or dictionary). Union types walk as owners and the immutable and builtin resolvers claim only framework
// types, so neither can contradict. Any other formatter, a source-generated one among them, passes.
private static bool WrittenAsCollection(Type type, MethodInfo generic, object? standard) =>
CultElementIds.WalksAsOwner(type) && ReferenceEquals(Formatter(DynamicGenericResolver.Instance, generic), standard);
private static object? Formatter(IFormatterResolver resolver, MethodInfo generic)
{

View file

@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Security.Cryptography;
using System.Text;
using MessagePack;
@ -109,10 +110,8 @@ namespace GameCult.Caching
// can sit in a cycle, so the path is a fixed point over these edges and not a stack.
private sealed class Graph
{
// Hidden is true when the member that starts the entry is non-public: MessagePack writes it only under AllowPrivate,
// and the walk reads public members only.
public List<(Type Owner, MemberInfo? Member, Type Child, Type[] Through, bool Hidden)> Edges { get; } = new();
public List<(Type Owner, MemberInfo Member, Type Element, List<Type> Concrete, Type[] Through, bool Hidden)> Lists { get; } = new();
public List<(Type Owner, MemberInfo? Member, Type Child, Type[] Through)> Edges { get; } = new();
public List<(Type Owner, MemberInfo Member, Type Element, List<Type> Concrete, Type[] Through)> Lists { get; } = new();
// Keyed types that also enumerate objects: the walk reads them by key, so the codec must confirm MessagePack does too.
public List<Type> Enumerating { get; } = new();
@ -140,17 +139,15 @@ namespace GameCult.Caching
if (EnumeratesObjects(type))
graph.Enumerating.Add(type);
foreach (var (member, memberType) in KeyedMembers(type))
VisitMemberType(documentType, type, member, memberType, Array.Empty<Type>(), false, seen, survey, graph);
foreach (var (member, memberType) in UnreadKeyedMembers(type))
VisitMemberType(documentType, type, member, memberType, Array.Empty<Type>(), true, seen, survey, graph);
VisitMemberType(documentType, type, member, memberType, Array.Empty<Type>(), seen, survey, graph);
foreach (var union in type.GetCustomAttributes<UnionAttribute>(true))
{
graph.Edges.Add((type, null, union.SubType, Array.Empty<Type>(), false));
graph.Edges.Add((type, null, union.SubType, Array.Empty<Type>()));
VisitObject(documentType, union.SubType, seen, survey, graph);
}
}
private static void VisitMemberType(Type documentType, Type owner, MemberInfo member, Type memberType, Type[] through, bool hidden, HashSet<Type> seen, Survey survey, Graph graph)
private static void VisitMemberType(Type documentType, Type owner, MemberInfo member, Type memberType, Type[] through, HashSet<Type> seen, Survey survey, Graph graph)
{
memberType = Nullable.GetUnderlyingType(memberType) ?? memberType;
// A sequence or map whose element is itself (an unkeyed Node : IEnumerable<Node>) ends the type walk here; without
@ -161,17 +158,17 @@ namespace GameCult.Caching
switch (kind)
{
case Kind.Leaf:
// A shape the walk does not own is one whole value, never refused (q3 amended).
return;
case Kind.Map:
VisitMemberType(documentType, owner, member, inner, through.Append(memberType).ToArray(), hidden, seen, survey, graph);
VisitMemberType(documentType, owner, member, inner, through.Append(memberType).ToArray(), seen, survey, graph);
return;
case Kind.Sequence when IsElement(inner):
var concretes = ConcreteTypes(inner).ToList();
graph.Lists.Add((owner, member, inner, concretes, through.Append(memberType).ToArray(), hidden));
graph.Lists.Add((owner, member, inner, concretes, through.Append(memberType).ToArray()));
foreach (var concrete in concretes)
{
// A hidden list is refused as a member (Settle), not once per element type.
if (hidden || !survey.Judged.Add(concrete))
if (!survey.Judged.Add(concrete))
continue;
var problem = IsElement(concrete) ? IdProblem(concrete) : UnkeyedElementProblem;
if (problem != null)
@ -181,10 +178,10 @@ namespace GameCult.Caching
VisitObject(documentType, inner, seen, survey, graph);
return;
case Kind.Sequence:
VisitMemberType(documentType, owner, member, inner, through.Append(memberType).ToArray(), hidden, seen, survey, graph);
VisitMemberType(documentType, owner, member, inner, through.Append(memberType).ToArray(), seen, survey, graph);
return;
default:
graph.Edges.Add((owner, member, memberType, through, hidden));
graph.Edges.Add((owner, member, memberType, through));
VisitObject(documentType, memberType, seen, survey, graph);
return;
}
@ -193,24 +190,21 @@ namespace GameCult.Caching
private const string UnkeyedElementProblem =
"is written by a formatter the cache does not own, so its element id would never reach disk; mark it [MessagePackObject] with [Key] members, or keep it out of lists.";
// The id path: every type that reaches an object list, the list's element types and the concrete types under them.
// [MessagePackFormatter] on any of them, or on a member the walk descends through, would hand the bytes to a formatter
// that does not write the [Key] layout the id lives in.
// The id path: every type that reaches an object list through members MessagePack writes by key, the lists' element and
// concrete types, and the keyed types that enumerate objects. The codec verifies that MessagePack writes each of them the
// way Classify reads it; it never decides ownership.
private static void Settle(Type documentType, Survey survey, Graph graph)
{
var lists = graph.Lists.Where(list => !list.Hidden).ToArray();
var lists = graph.Lists;
var reaching = new HashSet<Type>(lists.Select(list => list.Owner));
var members = new List<MemberInfo>(lists.Select(list => list.Member));
var through = new List<Type>(lists.SelectMany(list => list.Through));
for (var grew = true; grew;)
{
grew = false;
foreach (var (owner, member, child, via, hidden) in graph.Edges)
foreach (var (owner, _, child, via) in graph.Edges)
{
if (hidden || !reaching.Contains(child))
if (!reaching.Contains(child))
continue;
if (member != null && !members.Contains(member))
members.Add(member);
through.AddRange(via);
grew |= reaching.Add(owner);
}
@ -239,30 +233,9 @@ namespace GameCult.Caching
foreach (var type in graph.Enumerating)
Add(type);
foreach (var type in path)
{
survey.IdPath.Add(type);
if (type.IsDefined(typeof(MessagePackFormatterAttribute), true))
survey.Problems.Add($"Cult document {documentType.Name}: {type.Name} is on the path to a list whose elements carry ids and declares [MessagePackFormatter], so its element ids would never reach disk; remove the attribute or keep this type out of the document's lists.");
}
foreach (var member in members)
{
if (Declares(member, typeof(MessagePackFormatterAttribute)))
survey.Problems.Add($"Cult document {documentType.Name}: {member.DeclaringType!.Name}.{member.Name} leads to a list whose elements carry ids and declares [MessagePackFormatter], so its element ids would never reach disk; remove the attribute.");
}
// The fixed point has settled which types reach a list through public members. A non-public [Key] member that starts
// a list, or leads to a type that reaches one, is refused: nothing reads or checks the ids it holds.
foreach (var list in graph.Lists.Where(list => list.Hidden))
survey.Problems.Add(HiddenProblem(documentType, list.Owner, list.Member));
foreach (var edge in graph.Edges.Where(edge => edge.Hidden && reaching.Contains(edge.Child)))
survey.Problems.Add(HiddenProblem(documentType, edge.Owner, edge.Member!));
survey.IdPath.AddRange(path);
}
private static string HiddenProblem(Type documentType, Type owner, MemberInfo member) =>
$"Cult document {documentType.Name}: {owner.Name}.{member.Name} is a non-public [Key] member on the way to a list whose elements carry ids; MessagePack writes it only under AllowPrivate and the element-id walk reads public members, so its ids could never be checked. Make it public, or keep lists out of it.";
// The types an element can actually be: the element type itself when it can be instantiated, and every union subtype
// under it. Shapes are built from these, so registration judges exactly what minting will meet.
private static IEnumerable<Type> ConcreteTypes(Type element)
@ -302,14 +275,14 @@ namespace GameCult.Caching
if (KeyOf(id) is not { } idKey)
return $"declares [CultElementId] on {id.Name}, which has no integer [Key], so it is never persisted.";
if (MembersOf(element).FirstOrDefault(member => member.Name == id.Name && member.MemberType == id.MemberType) is not { } written)
return $"declares [CultElementId] on {id.Name}, which MessagePack does not write ([IgnoreMember], non-public, or a property whose getter is not public); the id would never reach disk.";
return $"declares [CultElementId] on {id.Name}, which MessagePack does not write ([IgnoreMember], or non-public on a type without [MessagePackObject(AllowPrivate = true)]); the id would never reach disk.";
id = written;
var sharing = KeyedMembers(element).FirstOrDefault(entry => entry.Member != id && KeyOf(entry.Member) == idKey);
if (sharing.Member != null)
return $"declares [CultElementId] on {id.Name} with [Key({idKey})], which {sharing.Member.Name} also uses; an id needs a key of its own.";
if (id is PropertyInfo { SetMethod: null } or FieldInfo { IsInitOnly: true })
return $"declares [CultElementId] on {id.Name}, which cannot be assigned; the cache mints into it.";
if (id is PropertyInfo { SetMethod.IsPublic: false })
if (id is PropertyInfo { SetMethod.IsPublic: false } && !AllowsPrivate(element))
return $"declares [CultElementId] on {id.Name}, whose setter is not public: MessagePack writes the id but does not read it back, so every load would mint a different one. Make the setter public (init is fine).";
var source = id.GetCustomAttribute<CultElementIdAttribute>(true)!.DerivedFrom;
if (source != null)
@ -551,10 +524,20 @@ namespace GameCult.Caching
return shape;
}
// The members MessagePack's StandardResolver writes for an integer-keyed type: public instance fields, and public
// instance properties with a public getter, none of them [IgnoreMember]. TheWalkReadsTheMembersMessagePackWrites checks
// this set against MessagePack's own member discovery.
// The members MessagePack's StandardResolver writes for an integer-keyed type, none of them [IgnoreMember]. Without
// AllowPrivate: public instance fields, and public instance properties with a public getter. With it, read from the most
// derived [MessagePackObject] declaration (a derived type without it is written without it, probe NoApDerived): every
// instance field and indexer-free property up the base chain at any visibility, the most derived of a name.
// TheWalkReadsTheMembersMessagePackWrites checks this set against MessagePack's own member discovery.
private static IEnumerable<MemberInfo> MembersOf(Type type) =>
AllowsPrivate(type)
? AllInstanceMembers(type).Where(member => !Declares(member, typeof(IgnoreMemberAttribute)))
.GroupBy(member => member.Name).Select(group => group.First())
: PublicMembersOf(type);
private static bool AllowsPrivate(Type type) => type.GetCustomAttribute<MessagePackObjectAttribute>(true)?.AllowPrivate == true;
private static IEnumerable<MemberInfo> PublicMembersOf(Type type) =>
type.GetFields(BindingFlags.Instance | BindingFlags.Public).Cast<MemberInfo>()
.Concat(type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(property => property.GetIndexParameters().Length == 0 && property.GetMethod is { IsPublic: true }))
@ -573,36 +556,17 @@ namespace GameCult.Caching
}
}
// The members the walk reads, as MessagePack writes them, for the oracle test that checks both against MessagePack.
internal static IEnumerable<MemberInfo> WrittenKeyedMembers(Type type) => KeyedMembers(type).Select(entry => entry.Member);
// The [Key] members MessagePack writes only under AllowPrivate, for the same test.
internal static IEnumerable<MemberInfo> UnreadMembers(Type type) => UnreadKeyedMembers(type).Select(entry => entry.Member);
// The [Key] members MessagePack writes, for the oracle test that checks them against MessagePack.
internal static IEnumerable<MemberInfo> WrittenKeyedMembers(Type type) => MembersOf(type).Where(member => KeyOf(member) != null);
private static IEnumerable<(MemberInfo Member, Type Type)> KeyedMembers(Type type) =>
MembersOf(type).Where(member => KeyOf(member) != null).Select(member => (member, TypeOf(member)));
WrittenKeyedMembers(type).Where(member => !Declares(member, typeof(MessagePackFormatterAttribute)))
.Select(member => (member, TypeOf(member)));
// An attribute a member declares, or inherits through an override: MemberInfo.IsDefined ignores inherit for properties, so an
// override of a base virtual member would not see its base's attribute.
private static bool Declares(MemberInfo member, Type attribute) => Attribute.IsDefined(member, attribute, inherit: true);
// [Key] members MessagePack writes only under AllowPrivate: non-public fields and properties, and public properties whose
// getter is not public, up the base chain. An [IgnoreMember] member is never written, so it is not one of them.
private static IEnumerable<(MemberInfo Member, Type Type)> UnreadKeyedMembers(Type type)
{
for (var level = type; level != null && level != typeof(object); level = level.BaseType)
{
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
foreach (var member in level.GetFields(flags).Where(field => !field.IsPublic).Cast<MemberInfo>()
.Concat(level.GetProperties(flags).Where(property =>
property.GetIndexParameters().Length == 0 && property.GetMethod is { IsPublic: false })))
{
if (KeyOf(member) != null && !Declares(member, typeof(IgnoreMemberAttribute)))
yield return (member, TypeOf(member));
}
}
}
// True when the walk reads the type by its [Key] members: the codec compares this with how MessagePack writes it.
internal static bool WalksAsOwner(Type type) => Classify(Nullable.GetUnderlyingType(type) ?? type).Kind == Kind.Owner;
@ -623,18 +587,23 @@ namespace GameCult.Caching
// [MessagePackObject] or [Union] type is read by key even when it is also IEnumerable<T>; the codec checks, for every type
// on the id path, that MessagePack writes it the way this model reads it, and refuses the document when it does not.
// Inner is a map's value type, a sequence's element type, or the type itself for an owner.
// Leaf a value written whole that holds no element list: strings (an IEnumerable<char> MessagePack writes whole),
// a struct, class or interface with no [MessagePackObject] or [Union] (primitives, enums, float2, DateTime,
// and object, which is untyped on disk).
// The owned shapes are a closed list (Owner, Sequence, Map); every other shape is Leaf: one whole value with no element
// ids, replaced whole by a variant (q3 amended 2026-10-16). When in doubt the answer is Leaf.
// Leaf strings (an IEnumerable<char> MessagePack writes whole), a type written whole (IsWrittenWhole: attribute-
// formatted, [DataContract], string-keyed), a struct or a class or interface with no [MessagePackObject] or
// [Union] and no collection shape (primitives, enums, float2, DateTime, ImmutableArray, ArraySegment, Memory,
// KeyValuePair, tuples, Lazy, unkeyed classes), and object, which is untyped on disk.
// Map a class or interface implementing IDictionary<,> or IReadOnlyDictionary<,>.
// Sequence an array or an IEnumerable<T>.
// Owner a class, interface or struct MessagePack serializes by key. A keyed struct is walked as an owner; it is
// never an element (IsElement), since a boxed copy cannot carry a minted id.
// Sequence an array or an IEnumerable<T> class or interface.
// Owner a class, interface or struct MessagePack serializes by integer key. A keyed struct is walked as an owner;
// it is never an element (IsElement), since a boxed copy cannot carry a minted id.
private static (Kind Kind, Type Inner) Classify(Type declared) => Classes.GetOrAdd(declared, static type =>
{
type = Nullable.GetUnderlyingType(type) ?? type;
if (type == typeof(string))
return (Kind.Leaf, type);
if (IsWrittenWhole(type))
return (Kind.Leaf, type);
if (IsKeyed(type))
return (Kind.Owner, type);
if (type.IsValueType)
@ -646,6 +615,15 @@ namespace GameCult.Caching
return (Kind.Leaf, type);
});
// A type MessagePack writes as one value the walk cannot look into: its own formatter ([MessagePackFormatter] on the type,
// inherited), [DataContract] on a type that is not [MessagePackObject] (the object resolver writes its members), or string
// keys (KeyAsPropertyName, or any [Key] with a string key). Such a type is Leaf, even when it is also enumerable.
private static bool IsWrittenWhole(Type type) =>
type.IsDefined(typeof(MessagePackFormatterAttribute), true) ||
(type.IsDefined(typeof(DataContractAttribute), true) && !type.IsDefined(typeof(MessagePackObjectAttribute), true)) ||
type.GetCustomAttribute<MessagePackObjectAttribute>(true)?.KeyAsPropertyName == true ||
AllInstanceMembers(type).Any(member => member.GetCustomAttribute<KeyAttribute>(true)?.StringKey != null);
// A type MessagePack serializes by key: [MessagePackObject] or [Union].
private static bool IsKeyed(Type type) =>
type.IsDefined(typeof(MessagePackObjectAttribute), true) || type.IsDefined(typeof(UnionAttribute), true);

View file

@ -1,7 +1,9 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using GameCult.Caching;
using MessagePack;
@ -302,16 +304,27 @@ namespace GameCult.Caching.Tests
Assert.That(Problems(typeof(HValueList)), Is.Empty, "a list of a non-keyed type is a value list");
}
// [MessagePackFormatter] on an element type, an owner, a collection class or a member makes that value one whole value: it
// has no element ids and is off the id path. The attribute on the id member itself is the one refusal that stays.
[Test]
public void AFormatterAttributeOnTheIdPathIsRefused()
public void AFormatterAttributeMakesAWholeValueExceptOnTheId()
{
Assert.That(Problems(typeof(HFormattedElement)).Single(), Does.Contain("FormattedElement").And.Contain("[MessagePackFormatter]"));
Assert.That(Problems(typeof(HFormattedList)).Single(), Does.Contain("Items").And.Contain("[MessagePackFormatter]"));
Assert.That(Problems(typeof(HFormattedId)).Single(), Does.Contain("Id").And.Contain("[MessagePackFormatter]"));
Assert.That(Problems(typeof(HFormattedOwner)).Single(), Does.Contain("FormattedOwner").And.Contain("[MessagePackFormatter]"));
Assert.That(Problems(typeof(HFormattedMember)).Single(), Does.Contain("Inner").And.Contain("[MessagePackFormatter]"));
foreach (var (type, holder) in new (Type, object)[]
{
(typeof(HFormattedElement), new HFormattedElement { Items = { new FormattedElement() } }),
(typeof(HFormattedList), new HFormattedList { Items = { new EIded() } }),
(typeof(HFormattedOwner), new HFormattedOwner { Owner = { Items = { new EIded() } } }),
(typeof(HFormattedMember), new HFormattedMember { Inner = { Items = { new EIded() } } }),
(typeof(HFormattedDerived), new HFormattedDerived { Items = { new FormattedDerived() } }),
})
{
Assert.That(Problems(type), Is.Empty, type.Name);
Assert.That(CultElementIds.Inspect(type).IdPath, Is.Empty, type.Name);
Assert.That(Assign(holder, "r", deterministic: false), Is.EqualTo(0), type.Name);
}
Assert.That(Problems(typeof(HFormattedField)), Is.Empty, "a formatted type with no list below it is a field");
Assert.That(Problems(typeof(HFormattedDerived)).Single(), Does.Contain("FormattedDerived").And.Contain("[MessagePackFormatter]"), "the attribute is inherited");
}
// The collection types between a member and its object list are written by some formatter too, so they sit on the id path.
@ -331,18 +344,19 @@ namespace GameCult.Caching.Tests
}
[Test]
public void AFormatterOnACollectionClassOfAnObjectListIsRefused()
public void AFormatterOnACollectionClassOfAnObjectListIsAWholeValue()
{
Assert.That(Problems(typeof(HRoles)).Single(), Does.Contain("Roles").And.Contain("[MessagePackFormatter]"));
Assert.That(Problems(typeof(HRoles)), Is.Empty);
Assert.That(Assign(new HRoles { Items = { new EIded() } }, "r", deterministic: false), Is.EqualTo(0), "its elements carry no ids and are never minted");
Assert.That(Problems(typeof(HFormattedValueCollection)), Is.Empty, "a collection class of a non-keyed element is a value list");
}
// MemberInfo.IsDefined ignores inherit for properties: an override sees its base's attribute only through Declares.
[Test]
public void AnInheritedFormatterOnAnOverriddenMemberIsRefused()
public void AnInheritedFormatterOnAnOverriddenMemberIsHonoured()
{
Assert.That(Problems(typeof(HOverFormattedId)).Single(), Does.Contain("Id").And.Contain("[MessagePackFormatter]"));
Assert.That(Problems(typeof(OwnerOverFormatted)).Single(), Does.Contain("Items").And.Contain("[MessagePackFormatter]"));
Assert.That(Problems(typeof(OwnerOverFormatted)), Is.Empty, "the override inherits its base's formatter, so the member is a whole value");
Assert.That(Problems(typeof(HIdOverride)), Is.Empty, "a base [CultElementId] is the override's id");
var root = new HIdOverride { Items = { new IdOverride() } };
@ -467,22 +481,28 @@ namespace GameCult.Caching.Tests
Assert.That(Assign(arm, "r", deterministic: false), Is.EqualTo(1));
}
// MessagePack writes a non-public [Key] member only under AllowPrivate, and the element-id walk reads public members, so
// an object list a non-public member leads to could never be checked. It is refused; a non-public member that reaches no
// object list is nobody's business.
// MessagePack writes a non-public [Key] member only under AllowPrivate, so a non-public member of a type without it is nobody's
// id business; under AllowPrivate it is written, read and minted like any other member.
[Test]
public void ANonPublicKeyedMemberOnTheWayToAnObjectListIsRefused()
public void ANonPublicMemberIsWalkedOnlyUnderAllowPrivate()
{
foreach (var (type, member) in new[]
foreach (var type in new[] { typeof(HHiddenList), typeof(HHiddenOwner), typeof(HHiddenFromBase) })
{
(typeof(HHiddenList), "Stash"), (typeof(HHiddenOwner), "stash"), (typeof(HOptHidden), "stash"), (typeof(HHiddenFromBase), "stash"),
})
Assert.That(Problems(type).Single(), Does.Contain(member).And.Contain("non-public"), type.Name);
Assert.That(Problems(type), Is.Empty, type.Name);
Assert.That(CultElementIds.Inspect(type).IdPath, Is.Empty, type.Name);
}
Assert.That(Problems(typeof(HOptHidden)), Is.Empty);
Assert.That(CultElementIds.Inspect(typeof(HOptHidden)).IdPath, Is.EquivalentTo(new[] { typeof(HOptHidden), typeof(List<EIded>), typeof(EIded) }));
var opt = new HOptHidden();
opt.Peek.Add(new EIded());
opt.Peek.Add(new EIded());
Assert.That(Assign(opt, "r", deterministic: false), Is.EqualTo(2));
Assert.That(opt.Peek.Select(item => item.Id), Has.All.Match("^[0-9a-f]{12}$"));
Assert.That(Problems(typeof(HHiddenLeaf)), Is.Empty);
Assert.That(CultElementIds.Inspect(typeof(HHiddenLeaf)).IdPath, Is.EquivalentTo(new[] { typeof(HHiddenLeaf), typeof(List<EIded>), typeof(EIded) }),
"a hidden member adds nothing to the id path");
Assert.That(CultElementIds.Inspect(typeof(HHiddenList)).IdPath, Is.Empty);
"a non-public member adds nothing to the id path");
}
// MessagePack writes a [MessagePackObject] class that is only IEnumerable<T> by its keys, so the walk reads it by key. A
@ -515,26 +535,24 @@ namespace GameCult.Caching.Tests
Assert.That(CultElementIds.Holds(typeof(KBagIded), bag), Is.True);
}
// The walk reads the members MessagePack writes: a public field or a property with a public getter, no [IgnoreMember].
// Anything else that carries a [Key] is MessagePack's AllowPrivate business and, on the way to an object list, is refused.
// The walk reads the members MessagePack writes: without AllowPrivate a public field or a property with a public getter, with
// it every member up the base chain, the most derived [MessagePackObject] declaration deciding; no [IgnoreMember].
[Test]
public void TheWalkReadsTheMembersMessagePackWrites()
{
Assert.That(Problems(typeof(PubIgn)), Is.Empty, "an ignored member is never written, so its list is nobody's business");
Assert.That(Problems(typeof(ApIgn)), Is.Empty, "an ignored private member is not a hidden list");
var held = Problems(typeof(PgHold)).Single();
Assert.That(held, Does.Contain("Items").And.Contain("non-public"));
Assert.That(Problems(typeof(ApIgn)), Is.Empty, "an ignored private member is not written");
Assert.That(Problems(typeof(PgHold)), Is.Empty, "a list behind a private getter is not written without AllowPrivate");
foreach (var type in new[] { typeof(EIded), typeof(HHiddenLeaf), typeof(PubIgn), typeof(ApIgn), typeof(PgHold), typeof(MemberMix) })
foreach (var type in new[] { typeof(EIded), typeof(HHiddenLeaf), typeof(PubIgn), typeof(ApIgn), typeof(PgHold), typeof(MemberMix), typeof(HOptHidden), typeof(ApDerived), typeof(NoApDerived) })
{
var written = MessagePackMembers(type, allowPrivate: false);
Assert.That(CultElementIds.WrittenKeyedMembers(type).Select(member => member.Name), Is.EquivalentTo(written), type.Name);
var readable = MessagePackMembers(type, allowPrivate: true);
Assert.That(CultElementIds.UnreadMembers(type).Select(member => member.Name), Is.EquivalentTo(readable.Except(written)), type.Name);
var allowPrivate = type.GetCustomAttribute<MessagePackObjectAttribute>(true)!.AllowPrivate;
Assert.That(CultElementIds.WrittenKeyedMembers(type).Select(member => member.Name),
Is.EquivalentTo(MessagePackMembers(type, allowPrivate)), type.Name);
}
Assert.That(CultElementIds.UnreadMembers(typeof(MemberMix)).Select(member => member.Name), Is.EquivalentTo(new[] { "priv" }));
Assert.That(CultElementIds.UnreadMembers(typeof(PgHold)).Select(member => member.Name), Is.EquivalentTo(new[] { "Items" }));
Assert.That(CultElementIds.WrittenKeyedMembers(typeof(NoApDerived)).Select(member => member.Name), Is.EquivalentTo(new[] { "N" }),
"the derived declaration decides, so an AllowPrivate base adds nothing");
}
// The names of the members MessagePack's own discovery reads for a type, by reflection into its internal type: the
@ -599,6 +617,69 @@ namespace GameCult.Caching.Tests
}
}
// Every shape the walk does not own is one whole value: no problem, off the id path, never minted into and never counted.
[Test]
public void AShapeTheWalkDoesNotOwnIsOneWholeValue()
{
const string Set = "aaaaaaaaaaaa";
EIded E(string id) => new() { Id = id };
var holders = new (Type Type, Func<string, object> Make)[]
{
(typeof(Whole<ImmutableArray<EIded>>), id => new Whole<ImmutableArray<EIded>> { Value = ImmutableArray.Create(E(id)) }),
(typeof(Whole<ImmutableArray<EIded>?>), id => new Whole<ImmutableArray<EIded>?> { Value = ImmutableArray.Create(E(id)) }),
(typeof(Whole<ArraySegment<EIded>>), id => new Whole<ArraySegment<EIded>> { Value = new ArraySegment<EIded>(new[] { E(id) }) }),
(typeof(Whole<Memory<EIded>>), id => new Whole<Memory<EIded>> { Value = new[] { E(id) } }),
(typeof(Whole<ReadOnlyMemory<EIded>>), id => new Whole<ReadOnlyMemory<EIded>> { Value = new[] { E(id) } }),
(typeof(Whole<KeyValuePair<string, List<EIded>>>), id => new Whole<KeyValuePair<string, List<EIded>>> { Value = new("k", new List<EIded> { E(id) }) }),
(typeof(Whole<(List<EIded>, int)>), id => new Whole<(List<EIded>, int)> { Value = (new List<EIded> { E(id) }, 1) }),
(typeof(Whole<Tuple<List<EIded>, int>>), id => new Whole<Tuple<List<EIded>, int>> { Value = Tuple.Create(new List<EIded> { E(id) }, 1) }),
(typeof(Whole<Lazy<List<EIded>>>), id => new Whole<Lazy<List<EIded>>> { Value = new Lazy<List<EIded>>(() => new List<EIded> { E(id) }) }),
(typeof(Whole<NkHold>), id => new Whole<NkHold> { Value = new NkHold { Items = { E(id) } } }),
(typeof(Whole<SkHold>), id => new Whole<SkHold> { Value = new SkHold { Items = { E(id) } } }),
(typeof(Whole<DcHold>), id => new Whole<DcHold> { Value = new DcHold { Items = { E(id) } } }),
(typeof(HRoles), id => new HRoles { Items = { E(id) } }),
(typeof(HFormattedList), id => new HFormattedList { Items = { E(id) } }),
(typeof(HHiddenList), id => new HHiddenList()),
};
foreach (var (type, make) in holders)
{
Assert.That(Problems(type), Is.Empty, type.ToString());
Assert.That(CultElementIds.Inspect(type).IdPath, Is.Empty, type.ToString());
Assert.That(CultElementIds.CanHoldIds(type), Is.False, type.ToString());
Assert.That(Assign(make(string.Empty), "r", deterministic: false), Is.EqualTo(0), type.ToString());
Assert.That(Assign(make(string.Empty), "r", deterministic: true), Is.EqualTo(0), type.ToString());
Assert.That(CultElementIds.Holds(type, make(Set)), Is.False, type.ToString());
}
// Elements of a string-keyed type carry no id either, and an owned list of a bare element is still refused.
Assert.That(Problems(typeof(Whole<List<SkElem>>)), Is.Empty);
RefusesBare(typeof(Whole<List<EBare>>));
}
// MessagePack writes an AllowPrivate type with every member up its base chain, and the most derived [MessagePackObject]
// declaration decides whether that is so.
[Test]
public void AnAllowPrivateTypeIsWalkedByTheMembersMessagePackWrites()
{
Assert.That(CultElementIds.WrittenKeyedMembers(typeof(ApDerived)).Select(member => member.Name), Is.EquivalentTo(new[] { "baseStash", "own", "N", "Pg" }));
Assert.That(CultElementIds.WrittenKeyedMembers(typeof(NoApDerived)).Select(member => member.Name), Is.EquivalentTo(new[] { "N" }));
var options = GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options;
var held = new HOptHidden();
held.Peek.Add(new EIded());
Assert.That(Assign(held, "r", deterministic: false), Is.EqualTo(1));
var back = MessagePackSerializer.Deserialize<HOptHidden>(MessagePackSerializer.Serialize(held, options), options);
Assert.That(back.Peek.Single().Id, Is.EqualTo(held.Peek.Single().Id), "the minted id of a private list survives a round trip");
Assert.That(Problems(typeof(HApPrivSet)), Is.Empty, "MessagePack reads an AllowPrivate type's private setter back");
var privSet = new HApPrivSet { Items = { new EApPrivSet(), new EApPrivSet() } };
Assert.That(Assign(privSet, "r", deterministic: false), Is.EqualTo(2));
var round = MessagePackSerializer.Deserialize<HApPrivSet>(MessagePackSerializer.Serialize(privSet, options), options);
Assert.That(round.Items.Select(item => item.Id), Is.EqualTo(privSet.Items.Select(item => item.Id)));
Assert.That(round.Items.Select(item => item.Id), Has.All.Match("^[0-9a-f]{12}$"));
Assert.That(Problems(typeof(HPrivSet)).Single(), Does.Contain("setter is not public"), "without AllowPrivate it is still refused");
}
private static string?[] Ids(object holder) => holder switch
{
HInit init => init.Items.Select(item => item.Id).ToArray(),
@ -885,12 +966,24 @@ namespace GameCult.Caching.Tests
#pragma warning disable CS0169, CS0414, CS0649
[MessagePackObject] public class HHiddenList { [Key(0)] private List<EIded> Stash { get; set; } = new(); }
[MessagePackObject] public class HHiddenOwner { [Key(0)] private HHasIds stash = new(); }
[MessagePackObject(AllowPrivate = true)] public class HOptHidden { [Key(0)] private List<EIded> stash = new(); }
[MessagePackObject(AllowPrivate = true)] public class HOptHidden { [Key(0)] private List<EIded> stash = new(); [IgnoreMember] public List<EIded> Peek => stash; }
[MessagePackObject] public class HHiddenBase { [Key(0)] private List<EIded> stash = new(); }
[MessagePackObject] public class HHiddenFromBase : HHiddenBase { [Key(1)] public int N { get; set; } }
[MessagePackObject] public class HHiddenLeaf { [Key(0)] public List<EIded> Items { get; set; } = new(); [Key(1)] private int secret = 7; }
[MessagePackObject] public class ApBase { [Key(0)] private List<EIded> baseStash = new(); }
[MessagePackObject(AllowPrivate = true)] public class ApDerived : ApBase { [Key(1)] private List<EIded> own = new(); [Key(2)] public int N { get; set; } [Key(3)] public List<EIded> Pg { private get; set; } = new(); }
[MessagePackObject(AllowPrivate = true)] public class ApBase2 { [Key(0)] private List<EIded> baseStash = new(); }
[MessagePackObject] public class NoApDerived : ApBase2 { [Key(1)] public int N { get; set; } }
#pragma warning restore CS0169, CS0414, CS0649
[MessagePackObject] public class Whole<T> { [Key(0)] public T? Value { get; set; } }
public class NkHold { public List<EIded> Items = new(); }
[MessagePackObject] public class SkHold { [Key("items")] public List<EIded> Items = new(); }
[MessagePackObject] public class SkElem { [Key("x")] public string X { get; set; } = ""; }
[System.Runtime.Serialization.DataContract] public class DcHold { [System.Runtime.Serialization.DataMember(Order = 0)] public List<EIded> Items = new(); }
[MessagePackObject(AllowPrivate = true)] public class EApPrivSet { [Key(0)] [CultElementId] public string? Id { get; private set; } }
[MessagePackObject] public class HApPrivSet { [Key(0)] public List<EApPrivSet> Items { get; set; } = new(); }
[MessagePackObject] public class EDerivedText { [Key(0)] public string? Name { get; set; } [Key(1)] [CultElementId(nameof(Name))] public string Id { get; set; } = ""; }
[MessagePackObject] public class HDerivedText { [Key(0)] public List<EDerivedText> Items { get; set; } = new(); }

View file

@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Reflection;
@ -356,6 +357,48 @@ namespace GameCult.Caching.Tests
private static byte[] OldShapedReels(string label) =>
MessagePackSerializer.Serialize(new List<OldReel> { new() { Label = label, Marks = { new OldMark { Text = "x" }, new OldMark { Text = "y" } } } });
// A holder the walk does not own (ImmutableArray here) is one whole value: its elements carry no element ids, so it never
// marks the store, and a variant replaces it whole (q3 amended 2026-10-16).
[Test]
public void AWholeListHolderNeverMarksTheStoreAndAVariantReplacesItWhole()
{
Assert.That(CultElementIds.CanHoldIds(typeof(IdWholeHolder)), Is.False);
var path = PathOf("whole-holder.cc");
using (var cache = OpenWith(path, false, typeof(IdWholeHolder)))
{
cache.Commit(batch => batch.Upsert(typeof(IdWholeHolder),
new IdWholeHolder { Name = "base", Reels = ImmutableArray.Create(new IdReel { Label = "set", Id = HexA }, new IdReel { Label = "unset" }) },
new CultRecordKey("base")));
}
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "ids inside a whole value mark nothing");
using (var fresh = OpenWith(path, false, typeof(IdWholeHolder)))
{
fresh.Commit(batch => batch.Upsert(typeof(IdWholeHolder), new IdWholeHolder { Name = "other" }, new CultRecordKey("other")));
var back = fresh.Get<IdWholeHolder>(new CultRecordKey("base"))!;
Assert.That(back.Reels.Select(reel => reel.Id), Is.EqualTo(new[] { HexA, "" }), "set ids are plain values, unset ids stay unset");
}
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"));
using (var cache = OpenWith(path, false, typeof(IdWholeHolder)))
{
var replacement = ImmutableArray.Create(new IdReel { Label = "v", Id = "eeeeeeeeeeee" });
cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"),
new[] { cache.Override<IdWholeHolder>(nameof(IdWholeHolder.Reels), replacement) }));
Assert.That(cache.Get<IdWholeHolder>(new CultRecordKey("v"))!.Reels.Select(reel => reel.Id), Is.EqualTo(new[] { "eeeeeeeeeeee" }), "replaced whole");
Assert.That(cache.Get<IdWholeHolder>(new CultRecordKey("base"))!.Reels, Has.Length.EqualTo(2), "the base is untouched");
}
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "a variant makes v2, and nothing in it holds element ids");
using var again = OpenWith(path, false, typeof(IdWholeHolder));
Assert.That(again.Get<IdWholeHolder>(new CultRecordKey("v"))!.Reels.Single().Id, Is.EqualTo("eeeeeeeeeeee"));
var step = new CultVariantOverride(CultOverrideOp.Set, new[] { new CultPathStep(1, HexA) }, string.Empty, MessagePackSerializer.Serialize("x"));
var refused = Assert.Catch<Exception>(() => again.Commit(batch => batch.UpsertVariant(new CultRecordKey("p"), new CultRecordKey("base"), new[] { step })))!;
Assert.That(refused.ToString(), Does.Contain("top-level Set overrides only"));
}
[Test]
public void AStoreThatCanHoldElementIdsCarriesTheV3MarkerAndOneThatCannotStaysV1()
{
@ -1287,6 +1330,14 @@ namespace GameCult.Caching.Tests
[Key(1)] public int Depth { get; set; }
}
[CultDocument("tests.element_id_whole_holder", "tests.element_id_whole_holder.v1")]
[MessagePackObject]
public sealed class IdWholeHolder
{
[Key(0)] [CultName] public string Name { get; set; } = "";
[Key(1)] public ImmutableArray<IdReel> Reels { get; set; } = ImmutableArray<IdReel>.Empty;
}
[CultDocument("tests.element_id_deck", "tests.element_id_deck.v1")]
[MessagePackObject]
public sealed class IdDeck

View file

@ -381,9 +381,7 @@ namespace GameCult.Caching.Tests
Assert.That(back.Bag.Items.Single().Id, Is.EqualTo("00000000000d"));
}
// Every collection shape the walk reads is one MessagePack writes through a collection formatter, so none is refused. A
// [DataContract] list subclass is still written by the generic resolver; an object formatter the object resolver could also
// build (DcColl: a different instance) or fails to build (DcBad) is no mismatch.
// Every collection shape the walk reads is one MessagePack writes through a collection formatter, so none is refused.
[Test]
public void EveryCollectionShapeTheWalkReadsIsWrittenByACollectionFormatter()
{
@ -398,7 +396,6 @@ namespace GameCult.Caching.Tests
typeof(G<SortedDictionary<string, GHold>>), typeof(G<System.Collections.Concurrent.ConcurrentDictionary<string, GHold>>),
typeof(G<System.Collections.ObjectModel.ReadOnlyDictionary<string, GHold>>), typeof(G<System.Collections.Immutable.ImmutableDictionary<string, GHold>>),
typeof(G<SortedList<string, GHold>>), typeof(G<FbArm>), typeof(G<IGu>), typeof(G<GStruct>),
typeof(G<DcColl>), typeof(G<DcBad>),
})
{
if (holder != typeof(G<FbArm>))
@ -407,22 +404,45 @@ namespace GameCult.Caching.Tests
}
}
// The attribute formatter is of no known family, so the door passes it as it passes a source-generated one; registration
// owns the attribute.
// One recogniser decides what the walk owns and the door only verifies it: the same types give no registration problem and a
// passing door. A type formatted by attribute or written by the object resolver is one whole value, off the id path.
[Test]
public void AFormatterOfNoKnownFamilyPassesTheDoor()
public void OneRecogniserDecidesAndTheDoorAgrees()
{
Assert.DoesNotThrow(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(G<FormattedRoles>)));
var problems = CultElementIds.Inspect(typeof(G<FormattedRoles>)).Problems;
Assert.That(problems, Has.Count.EqualTo(1));
Assert.That(problems.Single(), Does.Contain("FormattedRoles").And.Contain("declares [MessagePackFormatter]"));
foreach (var holder in new[] { typeof(G<FormattedRoles>), typeof(G<ApTrueHold>), typeof(G<DcSeq>), typeof(G<DcColl>), typeof(G<DcBad>) })
{
Assert.That(CultElementIds.Inspect(holder).Problems, Is.Empty, holder.ToString());
Assert.DoesNotThrow(() => CultDocumentMessagePackSerialization.OptionsForDocument(holder), holder.ToString());
}
foreach (var whole in new[] { typeof(G<FormattedRoles>), typeof(G<DcSeq>), typeof(G<DcColl>), typeof(G<DcBad>) })
Assert.That(CultElementIds.Inspect(whole).IdPath, Is.Empty, whole.ToString());
var ap = CultElementIds.Inspect(typeof(G<ApTrueHold>)).IdPath;
Assert.That(ap, Does.Contain(typeof(ApTrueHold)).And.Contain(typeof(List<FbArm>)));
var seq = new G<DcSeq> { Value = new DcSeq { Items = { new FbArm() } } };
Assert.That(CultElementIds.Plan(seq.GetType(), seq, "r", deterministic: false).Count, Is.EqualTo(0));
}
// MessagePack throws for a type, or StandardResolver has no formatter for it (an interface or abstract class that is only
// IEnumerable<T>): such a type cannot be persisted, so the door refuses it with the same typed text as a throwing build.
[Test]
public void ACollectionMessagePackWritesAsAnObjectIsRefused()
public void ATypeMessagePackCannotBuildIsATypedRefusal()
{
var thrown = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(G<DcSeq>)))!;
Assert.That(thrown.Message, Does.Contain("MessagePack writes DcSeq as an object, but the element-id walk reads it as a collection"));
foreach (var (holder, name) in new[] { (typeof(G<IArms>), nameof(IArms)), (typeof(G<AbsArms>), nameof(AbsArms)) })
{
var thrown = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.OptionsForDocument(holder), holder.ToString())!;
Assert.That(thrown.Message, Does.Contain(name).And.Contain("cannot build a formatter"));
}
}
// A keyed type that enumerates lists is read by key and is on the id path, so the door checks it: MessagePack writes it as a
// collection, which loses the ids the walk would mint.
[Test]
public void AKeyedTypeThatEnumeratesListsIsCheckedByTheDoor()
{
Assert.That(CultElementIds.Inspect(typeof(G<KLL>)).IdPath, Does.Contain(typeof(KLL)));
var thrown = Assert.Throws<InvalidOperationException>(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(G<KLL>)))!;
Assert.That(thrown.Message, Does.Contain($"writes {nameof(KLL)} as a collection"));
}
// A resolver that throws while asked about an id-path type is a typed refusal, whether it is the AllowPrivate formatter
@ -519,6 +539,16 @@ namespace GameCult.Caching.Tests
public IEnumerator<FbArm> GetEnumerator() => Items.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
public interface IArms : IEnumerable<FbArm> { }
public abstract class AbsArms : IEnumerable<FbArm>
{
public abstract IEnumerator<FbArm> GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[MessagePackObject] public sealed class KLL : List<List<FbArm>> { [Key(0)] public int N; }
#pragma warning disable CS0169, CS0414, CS0649
[MessagePackObject(AllowPrivate = true)] public sealed class ApTrueHold { [Key(0)] private List<FbArm> items = new(); }
#pragma warning restore CS0169, CS0414, CS0649
[DataContract] public sealed class DcColl : List<FbArm> { [DataMember(Order = 0)] public int N; }
[DataContract] public sealed class DcBad : List<FbArm> { [DataMember(Order = 0)] public int A; [DataMember(Order = 0)] public int B; }