-
Notifications
You must be signed in to change notification settings - Fork 11
Features Serialization Wallstop Proto Public Survey
- 2,166 serialized member declarations were catalogued across four public Unity codebases that annotate protobuf-net contracts. 2,151 of them (99.31%) are shapes WallstopProto already encodes.
- The 15 remaining members (0.69%) all produce a
WPROTO###build error naming the type, the member and the fix. None of them fails silently. - Only 5 members (0.23%) need the contract restructured rather than merely annotated: four
interface-typed members and one
Dictionary<K, Nullable<V>>. - The real risk is not at the member level. It is that
WPROTO030, the migration diagnostic, never fires for 80 of the 485 contracts surveyed (16.5%), because it matchesProtoBuf.ProtoContractAttributeby exact name and those contracts are declared with[DataContract]or with a protobuf-net build whose root namespace was renamed. Those types fall through to the reflection path with nothing said.
Surveyed 2026-08-29. No repository was cloned. Every file was read through the GitHub REST API
(git/trees?recursive=1 for enumeration) and raw.githubusercontent.com (for content), pinned to
the commit named in each section, then catalogued locally with a regular-expression pass that pairs
each [ProtoMember] / [DataMember] with the member declaration that follows it, and each
[ProtoContract(ImplicitFields = ...)] with every public member of its type.
Type kinds (is ResourceType an enum or a contract?) were resolved from the corpus itself where the
declaration was in scope; by fetching the declaring file for the 35 types whose declaration was
outside the scanned subset; and, for the remaining names, all of which are UnityEngine or TextMeshPro
enums reached from RTSL, by checking each name against those APIs. A type
counts as a contract only when a contract attribute is in the attribute block immediately preceding
its declaration, so a type carrying only [DataMember]s is scored as not a contract, which is
what both serializers see.
Coverage of each repository:
| Repository | Commit |
.cs files read |
Scope |
|---|---|---|---|
| SubnauticaNitrox/Nitrox | 80003b1b |
1,784 | complete tree |
| OpenHellion/Client | 6caf4223 |
1,839 | complete tree |
| WheatDew/WheatCabin | b9a33385 |
252 | vendored Battlehub RTSL directories only |
| QQ1273459693/EmpireOnlineclient | 642ad82b |
7 | generated GameProtocol directory only |
| Ailtop/RustDocuments | 842a0445 |
3,114 | complete tree |
| egametang/ET | 743c635d |
4,261 | complete tree |
How the corpora were found. Nitrox and Battlehub RTSL were already named as the two dominant
public Unity protobuf-net corpora. The other four came from GitHub code search:
"using ProtoBuf" "using UnityEngine" extension:cs surfaced OpenHellion and RustDocuments;
"[ProtoContract]" path:Assets extension:cs NOT Battlehub surfaced EmpireOnlineclient; ET was added
as the largest Unity C# game framework on GitHub (9.9k stars) to test whether a framework of that
size still uses protobuf-net at all. Two searches were run specifically to price the features
WallstopProto does not have: "ProtoMember" "AsReference" extension:cs path:Assets returns exactly
one hit, a vendored copy of protobuf-net's own attribute source, and
"DataFormat.ZigZag" OR "DataFormat.FixedSize" "UnityEngine" extension:cs returns zero.
RTSL is redistributed verbatim: the same generated Persistent* files appear in
ETdoFresh/UDevelop and
llhswwha/RuntimeEditor_HDRP, so one copy was
catalogued and it stands for all three.
The authoritative sources are the WPROTO### descriptors (40 on the day of the survey) in
Generator~/WallstopStudios.UnityHelpers.Proto.Generator/WProtoDiagnostics.cs, the runtime under
Runtime/Core/Serialization/WallstopProto/, and the
serialization guide.
| Shape | Supported | Proof |
|---|---|---|
Integer / floating-point scalars, bool, char, string, byte[]
|
Yes | OracleDifferentialTests.cs |
| Enums, including enum dictionary keys | Yes | MapDifferentialTests.cs |
Nullable<T> as a member
|
Yes | OracleDifferentialTests.cs |
Nullable<T> as a collection element or map value |
No — WPROTO003 |
DiagnosticTests.cs pins int?[], int?[][], int?[,]
|
DateTime, TimeSpan, Guid, decimal, Uri
|
Yes |
WProtoBclFormatters.cs registers five |
DateTimeOffset, IntPtr, UIntPtr, Type
|
No — WPROTO003, by design |
DiagnosticTests.cs |
1-D arrays, List<T>, HashSet<T>, any ICollection<T> + Add
|
Yes | RepeatedFormatterTests.cs |
LinkedList, Queue, Stack, ReadOnlyCollection, the interfaces |
Yes | RepeatedFormatterTests.cs |
Nested / jagged (int[][], List<List<int>>, List<Dictionary<…>>) |
Yes — superset of protobuf-net | NestedCollectionTests.cs |
| Rectangular arrays of any rank, in any collection position | Yes — superset of protobuf-net | RectangularArrayTests.cs |
| Collection nesting deeper than 64 | No — WPROTO032 |
descriptor |
| Dictionaries and the dictionary interfaces | Yes | MapDifferentialTests.cs |
byte[] or message dictionary key |
No — WPROTO003 |
MapMember.cs |
Two- and three-component ValueTuple
|
Yes, via shipped surrogates | WProtoValueTupleMarshalRegistrations.cs |
Inheritance via [WProtoInclude], or [WProtoSubtype] on the subtype |
Yes |
IncludeDifferentialTests.cs, SubtypeDeclarationTests.cs
|
| Include on a non-direct subtype, or an undeclared subtype | No — WPROTO013 / WPROTO018 |
descriptors |
| Surrogates, including open generic pairs | Yes | SurrogateDifferentialTests.cs |
SkipConstructor |
Yes (WPROTO033 warns on lost initializers) |
SkipConstructorTests.cs |
| Four lifecycle hooks | Yes (WPROTO034 warns on a subtype hook) |
HookDifferentialTests.cs |
| Generic contracts, closed over constructions found in source | Yes | GenericDifferentialTests.cs |
| A contract nested inside a generic type | No — WPROTO009 |
descriptor |
Immutable contracts (readonly fields, get-only properties) |
Yes | ImmutableDifferentialTests.cs |
| Immutable members plus includes | No — WPROTO015 |
descriptor |
| Interfaces served at the root | Yes, via [WProtoDeclaredRoot]
|
DeclaredRootTests.cs |
| An interface-typed member | No — WPROTO003 |
stated in the guide |
IsRequired |
Yes | WProtoMemberAttribute.cs |
OverwriteList |
Yes | WProtoMemberAttribute.cs |
DataFormat = ZigZag |
Yes (WPROTO037 on a type without that encoding) |
ZigZagDifferentialTests.cs |
DataFormat = FixedSize or Group
|
No — the enum has two members | WProtoDataFormat.cs |
AsReference, DynamicType
|
No, and deliberately unnamed |
WProtoMemberAttribute.cs has no such property |
ImplicitFields |
No — every member needs [WProtoMember] |
WProtoContractAttribute.cs |
Vector2, Vector3, Vector2Int, Vector3Int, Quaternion, Color, Color32, Rect, RectInt, Bounds, BoundsInt, Resolution
|
Yes — exactly 12 shipped surrogates | WProtoUnitySurrogateRegistrations.cs |
Vector4, Matrix4x4, LayerMask, AnimationCurve, Keyframe, Gradient, BoneWeight
|
No shipped surrogate — WPROTO003 (a consumer can write one) |
same file; Vector4 has a JSON converter only |
UnityEngine.Object references |
No — an asset reference has no wire identity | no formatter, no surrogate |
A [ProtoContract] with no [WProtoContract]
|
Announced by WPROTO030 (Info) |
WProtoGenerator.cs |
Runtime RuntimeTypeModel configuration (Add, AddSubType, SetSurrogate) |
No equivalent, and no diagnostic | compile-time attributes only |
Nitrox is the largest active public Unity project that still writes protobuf-net payloads: it reads
and writes Subnautica's own save format through the game-shipped protobuf-net.dll, referenced by
Nitrox.Server.Subnautica.csproj.
| Measure | Count |
|---|---|
.cs files scanned |
1,784 |
| files carrying any serialization annotation | 120 |
| files declaring a contract | 72 |
[DataContract] |
71 |
[DataMember] |
224 |
[ProtoContract] |
9 |
[ProtoMember] |
25 |
[ProtoInclude] |
78 (across 8 declaring types) |
| deepest single include list | 41 (EntityMetadata) |
runtime SetSurrogate registrations |
6 |
AsReference / DynamicType / DataFormat / SkipConstructor / ImplicitFields / IsRequired
|
0 |
| member declarations catalogued | 234 |
Two findings matter more than the counts.
Nitrox's contracts are [DataContract], not [ProtoContract], by 71 to 9. protobuf-net accepts
the WCF-style attributes as an alternative contract source, and Nitrox uses them because the same
types are also serialized by its own Nitrox.BinaryPack.
Nitrox's protobuf-net is namespaced ProtoBufNet, not ProtoBuf. GlobalUsings.cs carries
global using ProtoBufNet; — a rebuilt protobuf-net whose root namespace was renamed so it does not
collide with the game's own ProtoBuf. Their server serializer explicitly handles three attribute
families: System.Runtime.Serialization.DataContractAttribute, ProtoBufNet.ProtoContractAttribute,
and the game's ProtoBuf.ProtoContractAttribute (matched by string, in HasUweProtoContract).
Five generic contracts appear (ThreadSafeList<T>, ThreadSafeSet<T>, ThreadSafeQueue<T>,
ThreadSafeDictionary<K,V>, Optional<T>), and at least two of them are simultaneously a contract
and an ICollection<T> — the exact shape WPROTO012 exists to refuse until the author says which
encoding they meant.
RTSL's generated Persistent* classes are the single largest body of [ProtoContract] code in
public Unity repositories, and they are redistributed verbatim across at least three repositories.
| Measure | Count |
|---|---|
.cs files scanned (three RTSL directories) |
252 |
| files declaring a contract | 179 |
[ProtoContract] |
205 |
[ProtoMember] |
1,169 |
[ProtoInclude] |
0 |
[ProtoAfterDeserialization] |
2 |
[ProtoBeforeSerialization] |
1 |
[ProtoIgnore] |
1 |
IsRequired = true |
4 |
| member declarations catalogued | 1,168 |
Every one of the 205 contracts is spelled [ProtoContract] with no arguments. RTSL is generic-
contract-dominated: 238 of its members are a closed Persistent*<TID>, 87 are the bare type
parameter TID, and 41 more are arrays of one or the other.
RTSL declares no polymorphism in attributes. It configures the model at run time instead, in one
322-line file: 40 RuntimeTypeModel.Add calls, 14 AddSubType calls, 5 SetSurrogate calls, 12
MakeGenericType closures over a settings-driven IDTypes array, one AutoAddMissingTypes, one
CompileInPlace, and a DynamicTypeFormatting handler that maps an unresolvable formatted type name
onto a NilContainer. On a player build it loads a precompiled RTSLTypeModel.dll, which is the
classic protobuf-net answer to IL2CPP.
The 14 runtime AddSubType calls are the part with no attribute equivalent in protobuf-net's own
model, because RTSL generates each Persistent* file independently and a base cannot list subtypes
it has not seen. The subtype-side [WProtoSubtype] declaration fits that layout exactly: each
generated file would name its own base and field number, and no generator pass would have to revisit
the base.
| Measure | Count |
|---|---|
.cs files scanned |
1,839 |
| files declaring a contract | 151 |
[ProtoContract(ImplicitFields = ImplicitFields.AllPublic)] |
162 — every contract |
[ProtoMember] |
0 |
[ProtoInclude] |
96 (across 3 declaring types) |
| deepest single include list | 72 (NetworkData) |
| public members carried implicitly | 666 |
Nullable<T> members |
61 |
OpenHellion assigns no field numbers at all: the entire wire contract is protobuf-net's implicit
declaration-order numbering. It is also the only corpus that puts [ProtoContract] on an
interface: IAuxDetails is an empty interface carrying eight [ProtoInclude]s, and four members
are typed as it.
| Measure | Count |
|---|---|
.cs files scanned |
7 |
| files declaring a contract | 6 |
[ProtoContract] |
38 |
[ProtoMember] |
98 |
[ProtoIgnore] |
10 |
IsRequired = true |
10 |
[ProtoInclude] |
0 |
| distinct member type spellings | 3 |
This is what code generated from a .proto-shaped schema looks like in a Unity client: scalars,
nested contracts, and List<T> of nested contracts. Nothing else.
Rust (Ailtop/RustDocuments, a decompilation of the shipped Unity game) references 98 distinct
ProtoBuf.* message types across 290 of its 3,114 files, and declares zero contracts: its DTOs
are generated into a separate assembly with static Serialize/Deserialize methods. A shipped Unity
game at Rust's scale does not use the attribute-and-reflection path at all.
ET (egametang/ET, 9.9k stars, the largest public Unity C# client-and-server framework) has
zero protobuf-net references across 4,261 files. It has migrated to MemoryPack. Nitrox has
migrated most of its own traffic to Nitrox.BinaryPack and now uses protobuf-net only where the game
format demands it.
2,151 of 2,166 catalogued members. Aggregated by shape:
| Shape | Members |
|---|---|
Scalars (int, float, bool, string, …) |
1,197 |
| Closed generic contract constructions | 239 |
| Arrays (of scalars, enums, contracts, generics) | 175 |
| Enums | 173 |
| Nested contracts | 114 |
Bare generic type parameters (T, TID) |
88 |
List<T> / HashSet<T> and the interfaces |
64 |
Nullable<T> members |
62 |
DateTime / Guid
|
12 |
| Dictionaries | 10 |
byte[] |
9 |
| Unity structs with a shipped surrogate, direct | 8 |
The rows are disjoint and sum to 2,151. A Vector3[] is counted once, in the array row; 17 of the
members inside the array and collection rows hold a surrogated Unity struct.
Every category above is pinned by a test in
Generator~/WallstopStudios.UnityHelpers.Proto.Generator.Tests/. Two of them —
List<Dictionary<K,V>>-style nesting and rectangular arrays — WallstopProto encodes and protobuf-net
refuses outright, so this corpus understates the gap in WallstopProto's favour.
All 15 remaining members. This is the intended outcome: the developer is told at build time.
| Shape | Members | Diagnostic | Fix |
|---|---|---|---|
NitroxTechType (has [DataMember]s but no contract attribute) |
6 | WPROTO003 |
add [WProtoContract]
|
IAuxDetails — an interface-typed member |
4 | WPROTO003 |
restructure to an abstract base + declared root |
Matrix4x4[], Keyframe[], BoneWeight[]
|
3 | WPROTO003 |
write a surrogate (none is shipped) |
List<PeerId> (a readonly record struct) |
1 | WPROTO003 |
write a surrogate |
Dictionary<int, float?> |
1 | WPROTO003 |
hold the value in a contract, or drop the Nullable<T>
|
Of these, 10 are fixed by adding an attribute or a surrogate — the ordinary port step. 5 (0.23%)
require the contract's shape to change, and both of those shapes come from the same repository:
OpenHellion's interface members and its single Nullable<T> map value.
Two contract-level features are also refused rather than supported, though neither costs a member:
ImplicitFields (the 666 members OpenHellion's 162 contracts carry implicitly would each need a
[WProtoMember] number written by hand, reproducing protobuf-net's declaration-order numbering
exactly or every existing save is misread), and
[ProtoContract] on an interface (IAuxDetails), which has to become
[assembly: WProtoDeclaredRoot(typeof(IAuxDetails), typeof(AuxDetailsBase))] over an abstract base.
These are the dangerous ones. Nothing in the build says a word, and the type falls through to protobuf-net's reflection path, which does not work under IL2CPP.
1. WPROTO030 does not fire for a [DataContract] protobuf-net contract. The generator matches
ProtoBuf.ProtoContractAttribute by exact display name in WProtoGenerator.cs. Nitrox declares 71
contracts with [DataContract] / [DataMember(Order = n)], which protobuf-net honours and this
diagnostic ignores.
2. WPROTO030 does not fire for a renamed-namespace protobuf-net build. Nitrox's remaining 9
[ProtoContract] types resolve to ProtoBufNet.ProtoContractAttribute, so the exact-name match
misses them as well.
Together those are 80 of the 485 contracts surveyed (16.5%) that get no migration signal at all:
| Corpus | Contracts | Announced by WPROTO030
|
|---|---|---|
| Nitrox | 80 | 0 |
| Battlehub RTSL | 205 | 205 |
| OpenHellion | 162 | 162 |
| EmpireOnlineclient | 38 | 38 |
| Total | 485 | 405 (83.5%) |
3. A type registered only through RuntimeTypeModel is invisible to every compile-time
diagnostic. RTSL's 40 Add, 14 AddSubType and 5 SetSurrogate calls, and Nitrox's
assembly-scanning RegisterAssemblyClasses loop, describe contracts that no attribute names.
WallstopProto has no runtime registration surface to migrate them onto and no way to see them, so a
project built this way ports by rewriting its model configuration as attributes — with nothing
telling it which types it missed.
Note that (1) and (2) are diagnostic-coverage gaps, not encoding gaps: once such a type is annotated
with [WProtoContract], its members are ordinary members and land in the 99.31%. (3) is a genuine
absence of a feature.
"do we support the majority, if not all of them?"
Yes — 99.31% of them, measured over 2,166 serialized member declarations in four public Unity
codebases (Nitrox, Battlehub RTSL, OpenHellion, EmpireOnlineclient) at the commits above. That is
the denominator: individual [ProtoMember] / [DataMember] / implicit-field declarations, not
types, not repositories.
Restated over the other two denominators, so the number cannot be read as flattering:
-
By member: 2,151 / 2,166 = 99.31% encode today. 15 refuse, all with a
WPROTO###error. 5 (0.23%) need the contract's shape changed rather than merely annotated. -
By contract: 485 surveyed. All 485 can be ported. 405 (83.5%) are told to port by
WPROTO030; 80 (16.5%) are told nothing. - By repository: 4 of 6. Two of the six repositories that were expected to use protobuf-net no longer do (ET moved to MemoryPack; Rust never used the attribute path). The trend in this corpus is away from protobuf-net, not toward it.
The three protobuf-net features WallstopProto deliberately does not have — AsReference,
DynamicType and non-ZigZag DataFormat — appear zero times across all four attribute-bearing
corpora, and a targeted GitHub code search for each of them alongside UnityEngine returns one
vendored copy of protobuf-net's own source and nothing else. They were the right things to leave out.
The one feature that would move the number is ImplicitFields: it costs no members today because
OpenHellion's 666 implicit members are individually supported, but it makes that repository's port a
666-line hand transcription in which one wrong number silently breaks every save.
The public-project survey is complete and its answer is unambiguous. Two follow-ups are worth tracking separately, because neither is a shape question:
-
Widen
WPROTO030to[DataContract]and to anyProtoContractAttributeregardless of namespace. This is the only finding here that silently costs a shipped player, it affects the most prominent public consumer, and the fix is a predicate change in one file. -
Decide explicitly whether
ImplicitFieldsis in scope, or document that it is not and that a port must number the members by hand in declaration order. -
Consider shipping the missing Unity struct surrogates.
Matrix4x4,KeyframeandBoneWeighteach cost a member in RTSL, andVector4has a JSON converter but no protobuf surrogate on either path — so this is existing protobuf-net parity rather than a WallstopProto regression, and four more surrogate structs would close it.
-
Serialization Overview — the WallstopProto guide and the full
WPROTO###list - Serializable Types — the Unity-side serializable containers
-
Performance Analyzers — the separate
WUH###family
📦 Unity Helpers | 📖 Documentation | 🐛 Issues | 📜 MIT License
- Inspector Button
- Inspector Conditional Display
- Inspector Grouping Attributes
- Inspector Inline Editor
- Inspector Overview
- Inspector Selection Attributes
- Inspector Settings
- Inspector Validation Attributes
- Utility Components
- Visual Components
- Data Structures
- Helper Utilities
- Math And Extensions
- Pooling Guide
- Random Generators
- Reflection Helpers
- Singletons