nino-serialization · git:20261102.2ad19cf · 2026-11-02 · sha256 f37a0b378495cebc
nino-serialization git:20261102.2ad19cfA
Immutable. This exact content never changes and is served at /api/v1/blob/f37a0b378495cebc.
---
name: nino-serialization
description: "Write correct, fast C# with Nino v4, the source-generated binary serializer (NuGet Nino, Unity via OpenUPM): [NinoType] types, positional / version-tolerant / keyed formats for saves and messages that outlive a release, polymorphism, zero-allocation buffers and pooling, raw structs and Vector-of-T (NINO029), NinoSettings.Untrusted for data from players or the network, Unity/IL2CPP/Burst/HybridCLR, ASP.NET Core, and TypeScript, Go and Rust codecs from nino-gen. Use this skill whenever code uses or should use Nino - writing, reviewing, debugging or porting to it (from MemoryPack, MessagePack or Nino v3), fixing a NINO0xx/NINOGEN diagnostic or a NinoDataException - even when the user just says 'the serializer' in a project referencing Nino. Triggers: Nino, Nino.Core, [NinoType], [NinoMember], NinoSerializer, NinoDeserializer, NinoArrayBufferWriter, NinoSettings, NinoDataException, NINO0xx, nino-gen, application/x-nino, 用 Nino 序列化, Nino 反序列化, 给类型加 NinoType, Nino 存档, Nino 网络消息."
---
# Writing code with Nino v4
Nino v4 (namespace `Nino.Core`) generates a serializer for every `[NinoType]` at compile time. The bytes carry no
schema and no member names, so most mistakes are not compile errors: they are data that cannot be read after the next
release, objects that come back as the wrong type, or hostile input that reads without the checks it needed. The
decisions below prevent those. Docs: https://nino.xgamedev.net/en/docs/v4 (every page also as Markdown: add `.md`,
e.g. `https://nino.xgamedev.net/en/docs/v4/start.md`; index for models: https://nino.xgamedev.net/llms.txt).
Read a reference when the task reaches it:
| Reference | Read it when |
| --- | --- |
| [references/types.md](references/types.md) | a member type is unusual (BCL types, collections, `object`, pointers, `Vector<T>`), structs are copied raw, NINO007/NINO029, or data crosses runtimes (Mono, IL2CPP, 32-bit, other languages) |
| [references/performance.md](references/performance.md) | zero allocation, per-frame/per-tick messages, buffer reuse, reuse deserialization, `[NinoPooled]`, `[NinoFactory]`, several values in one buffer, build settings |
| [references/platforms.md](references/platforms.md) | Unity, IL2CPP, Burst, HybridCLR, Native AOT, Godot, ASP.NET Core |
| [references/custom-formatters.md](references/custom-formatters.md) | a member needs its own encoding, or a type Nino does not know |
| [references/diagnostics.md](references/diagnostics.md) | any `NINO0xx`/`NINOGEN` diagnostic or a runtime error message from Nino |
| [references/other-languages.md](references/other-languages.md) | TypeScript, Go or Rust must read or write the same bytes (nino-gen) |
## Install
- .NET and Godot 4: `dotnet add package Nino --prerelease` in **every project that declares `[NinoType]`s** (the
generator runs per project). v4 is a prerelease: without `--prerelease` NuGet installs 3.9.x, which cannot read or
write v4 data. Optional, also `--prerelease`: `Nino.Immutable`, `Nino.AspNetCore`, `Nino.Codegen`, the `nino-gen`
tool (`Nino.Codegen.Tool`).
- Unity 2022.3+: `openupm add com.jasonxudeveloper.nino`.
- Requirements: Roslyn 4.3+ and `LangVersion` 9+ in the declaring project (old .NET Framework projects default to
7.3). Every target framework, runtime and CPU writes identical bytes.
- Nothing to register or initialize: generated registries load on first use.
## Decide in this order
1. **How long does the data live?** Pick the format before anything is stored, because changing it later makes
existing data unreadable (table below). Messages between builds you ship together: positional. Saves or
messages between versions: `VersionTolerant` or `Keyed`.
2. **Who wrote the bytes?** Anything a player, client or other party could craft (network messages, shared saves,
uploads, request bodies) is untrusted: `NinoSettings.Untrusted = true` at startup and catch `NinoDataException`
([Untrusted input](#untrusted-input)).
3. **Is a slot polymorphic?** Seal every class without subtypes; serialize through the common `[NinoType]` base.
4. **Is it a hot path?** Reuse one buffer writer and deserialize into existing instances
([references/performance.md](references/performance.md)).
5. **Where does it run?** Unity and other pre-.NET 8 targets need `partial` types for private members; IL2CPP and AOT
need every type visible to the generator ([references/platforms.md](references/platforms.md)).
6. **Any structs, SIMD vectors or pointers?** Structs of raw fields are copied as memory in one canonical layout;
`Vector<T>` is refused (NINO029) because its size depends on the CPU
([references/types.md](references/types.md)).
## Declaring types
- `[NinoType]` on a `class`, `struct`, `record`, `record struct` or `interface`. The type and every type containing it
are `public` or `internal`, never a `static` class or `ref struct` (NINO001).
- Options, as named arguments: `[NinoType(autoCollect: true, includeNonPublicMembers: false, allowInheritance: true,
format: NinoFormat.Positional, trackReferences: false)]`.
- `autoCollect: true` (default) writes every public field and every public property with a getter and a `set`/`init`:
`[NinoType]` base classes first (root first), then the type's own members in declaration order. Get-only
auto-properties are skipped unless a constructor parameter has the same name (ignoring case), and so are explicit
interface implementations; a member of a type Nino cannot write is skipped with NINO007 (delegates, `Task`,
pointers, `Type`, `Span<T>`: add `[NinoIgnore]`).
- `[NinoIgnore]` drops a member. `[NinoType(false)]` writes only `[NinoMember(index)]` members, by index ascending
(`[property: NinoMember(n)]` on record parameters). Mixing them up: NINO010 to NINO013.
- Non-public members need `includeNonPublicMembers: true` (else NINO009). Before .NET 8 (Unity, .NET Framework,
`netstandard`), and for generic types before .NET 9, the type and every containing type must be `partial`
(NINO002): write `partial` anyway when the code may build for Unity.
- Subtypes inherit `[NinoType]` and its options from the nearest `[NinoType]` base or interface. A subtype's own
`[NinoType]` replaces the inherited options (repeat `format:`); `trackReferences` is inherited regardless.
`allowInheritance: false` makes subtypes declare their own (NINO003).
- Generic types work (`Box<T>`) when their type arguments are serializable. A closed generic type the process only
*reads* through a base and never names in code: `NinoPolymorphic.Register<Box<int>>()` at startup.
- Strings: one byte per char when every char is at most U+00FF, UTF-16 otherwise. `[NinoUtf8]` on a member writes
UTF-8: for mostly-ASCII text with some wider chars, not for mostly-CJK text.
- Structs have no header; an unmanaged struct of raw fields is copied as one block in a layout that is the same on
every runtime (details, padding, `bool` flags and pointers: [references/types.md](references/types.md)).
```csharp
[NinoType]
public sealed class Order
{
public long Id;
public string Customer { get; set; }
public List<OrderLine> Lines;
public DateTime? ShippedAt;
[NinoIgnore] public decimal CachedTotal;
}
[NinoType]
public struct OrderLine { public int ProductId; public int Quantity; } // raw: 8 bytes, one block per list
[NinoType(includeNonPublicMembers: true)]
public partial class Account { private long _balance; public int Id; }
```
### Constructors
Nino uses, in order: a constructor or static factory marked `[NinoConstructor]`; else, when every member is
assignable, a constructor callable without arguments (or a `[NinoFactory]`), then fills the members; else the
accessible constructor with the most parameters matching serialized members by name (ignoring case; a positional
record's primary constructor); structs fall back to `new T()`; otherwise NINO004.
- `[NinoConstructor(nameof(Id), nameof(Name))]` lists members when parameter names differ (NINO005); it must be
`public`/`internal` (NINO021). A read-only member that is not a parameter is read and dropped (NINO006).
- `required` members: a `[SetsRequiredMembers]` constructor, or every required member serialized and settable.
- Types re-created through a constructor (records, `init`/get-only/`readonly` members) cannot be filled in place,
tracked (NINO016) or pooled (NINO018). Give hot-path types a parameterless constructor and settable members.
### Formats: choose before data is stored
| | `Positional` (default) | `VersionTolerant` | `Keyed` |
| --- | --- | --- | --- |
| Change members | nothing (renaming is free: names are not written) | append to the most derived type only; renaming is free | add, remove, reorder; rename with `[NinoFormerName("Old")]` |
| Cost | none | 4 bytes per object | 4 per object + 8 per member |
| For | same-build messages, rebuildable caches | messages between versions, saves that only grow | config, documents, saves edited for years |
- Changing a type's `format` makes its existing data unreadable. Changing a member's type is never tolerated.
- Version-tolerant hierarchy: never append to a base class with subclasses (it shifts their members, silently); use
`Keyed`. Missing members keep their initializer or existing value.
- Keyed: two names hashing alike is NINO014; member `[NinoFormerName]` on a non-keyed type is NINO024.
- Renaming or moving a *type* changes its type id (written whenever the slot is not sealed): keep it with
`[NinoFormerName("Old.Namespace.Type")]` on the type, runtime form (`+` for nested, no `global::`), one per type
(NINO026), not on generic types written by type id (NINO027). `NinoTypeId.Of(typeof(T))` returns the id.
- `WEAK_VERSION_TOLERANCE` makes a project's positional types version-tolerant; prefer the per-type option.
- Every build records each type's layout as `[assembly: NinoTypeLayout]`; compare `layout.ToString()` between releases
in CI to catch byte changes.
### Polymorphism and references
- A slot declared as a non-sealed class, abstract class or interface writes a 4-byte type id (FNV-1a of the full
name) and reads back the runtime type; a sealed class writes only a presence byte. **Seal classes without
subtypes.**
- Serialize and deserialize through the common `[NinoType]` base (`Serialize<Message>(msg)`,
`Deserialize<Message>(bytes)`), never `object`. `SerializeBoxed(value, type)` / `DeserializeBoxed(data, type)` only
for a type known only as a `Type`, read with the same type.
- Subtypes in other assemblies (plugins, HybridCLR) are found once the assembly is loaded; the generator must run
there too.
- Without tracking, a shared object is written twice and a cycle throws `InvalidOperationException` while writing.
`[NinoType(trackReferences: true)]` on the shared or cyclic class (classes only, NINO015) keeps identity: 4 bytes
and a lookup per tracked object. Collections are never tracked. Trees need no tracking.
## The API
```csharp
byte[] bytes = NinoSerializer.Serialize(order); // a new byte[] of exactly the data's size
NinoSerializer.Serialize(order, bufferWriter); // any IBufferWriter<byte>: 0 B with a reused one
Order copy = NinoDeserializer.Deserialize<Order>(bytes); // from any ReadOnlySpan<byte>
NinoDeserializer.Deserialize(bytes, ref copy); // reuse: fills copy in place (null: creates it)
```
- Static and thread safe. There are no `Stream`, async, `ReadOnlySequence<byte>` or `out` overloads: read a stream
or pipe into one buffer first (a multi-segment sequence: copy it, or `First.Span` when `IsSingleSegment`).
- Hot paths: one `NinoArrayBufferWriter` per thread or connection, `ResetWrittenCount()` before each message, send
`WrittenSpan`, `Dispose()` at the end; deserialize into an existing instance. More, with pooling:
[references/performance.md](references/performance.md).
## Untrusted input
Nino is built for untrusted input: lengths are checked against the bytes left before allocating, the stack is
bounded, data names only type ids of declared subtypes (never type names), and **every invalid input throws
`NinoDataException`** (derives from `InvalidOperationException`, message starts with `Nino:`). Catch it at the
boundary. Caller mistakes (untracked cycle, too deep while writing) stay `InvalidOperationException`.
```csharp
// At startup, before the first Nino call on any thread: settings freeze on first use, setting one later throws.
NinoSettings.Untrusted = true; // adds only: seeded hashing of int/char/Guid/string keys,
// checks of values copied as raw bytes, no bytes after the value
NinoSettings.MaxCollectionLength = 100_000; // default int.MaxValue; the three limits apply in every mode
NinoSettings.MaxDepth = 512; // default 512, at least 128
NinoSettings.MaxHashCollisions = 100; // default 100, at least 1
```
- **Read anything an outsider could craft with `NinoSettings.Untrusted = true`.** The default mode already rejects a
`bool` or presence byte other than 0 or 1 in members, values and arrays, but it copies a raw struct as one block
(any plain unmanaged struct, including one with `bool` or `T?` fields), so a `bool` field or a `Nullable` flag
*inside* it is not checked. A byte other than 0 or 1 there is undefined behavior to the .NET JIT, not only a wrong
value (the struct can compare unequal to itself and be lost in a `Dictionary`). Untrusted mode rejects it, and also
bytes after the value and out-of-range `decimal`, `DateTime`, `DateOnly`, `TimeOnly` copied as bytes. It is one
process-wide setting: set it once at startup, not per call.
- Still your job: validate values (a negative health, an unnamed enum value, an array of the wrong length), cap frame
or request sizes before buffering, and version payloads yourself: the bytes carry no schema, so data for another
type with a compatible layout reads without error. Put a magic number and version in front of stored files.
## Pitfalls
- A bare `[NinoType]` is positional: adding, removing or reordering members breaks stored data.
- `System.Numerics.Vector<T>` anywhere in a serialized type: NINO029, an error. It is 16, 32 or 64 bytes by the CPU;
store 16 lanes of a 4-byte type (`float[16]`) and expose `Span<Vector<float>>` through a `[NinoIgnore]` member
([references/types.md](references/types.md#vectort-is-never-serializable-nino029)). `Vector128<T>` and the other
fixed-size vectors are fine.
- `Serialize<object>`, `object` members and the boxed API in message code: use a common `[NinoType]` base, and write
and read with the same declared type (`Serialize<Base>` then `Deserialize<Base>`).
- `NinoSettings` changed after the first Nino call throws.
- Sharing one `NinoArrayBufferWriter` between threads, or using `WrittenSpan` after the next write or reset.
- `int?[]` / `List<int?>` and `IList<int?>` write different bytes; so do `[NinoUtf8]` and the default; so does a raw
struct made `VersionTolerant`.
- v3 code: `Serializer.Serialize` / `Deserializer.Deserialize(data, out v)` are now `NinoSerializer.Serialize` /
`NinoDeserializer.Deserialize<T>(data)`; `[NinoExplicitOrder]` is gone (`[NinoType(false)]` + `[NinoMember]`). v4
cannot read v3 data. Beta names: `[NinoRefDeserialization]` is `[NinoFactory]`, `DeserializeRef` is
`Deserialize(data, ref value)`.
- Porting from MemoryPack: the bytes differ, so stored data needs a one-time conversion (reference both, read with
MemoryPack, write with Nino, add a file header). `[MemoryPackable] partial` → `[NinoType]`, `[MemoryPackIgnore]` →
`[NinoIgnore]`, `[MemoryPackOrder(n)]` → `[NinoType(false)]` + `[NinoMember(n)]`, `[MemoryPackConstructor]` →
`[NinoConstructor]`, `[MemoryPackUnion]` → nothing (subtypes inherit the base's `[NinoType]`),
`GenerateType.VersionTolerant` → `NinoFormat.Keyed`, `GenerateType.CircularReference` → `trackReferences: true`,
`Serialize(bufferWriter, value)` → `Serialize(value, bufferWriter)` (value first). Invalid data throws
`NinoDataException`. Full table: https://nino.xgamedev.net/en/docs/v4/vs-memorypack.md
Diagnostics: errors mean the type cannot work, warnings mean data is silently dropped or an attribute does nothing.
Never suppress NINO006 or NINO007 blindly. Each id and its fix: [references/diagnostics.md](references/diagnostics.md).
## What Nino does not do
- No `Stream`, async or multi-segment APIs, no compression, no callbacks, no `System.Type`, no schema in the bytes,
no human-readable output, no reading of v3 data.
- No runtime registration of formatters and no reflection fallback: the generator must see every type.
- No languages besides .NET, TypeScript/JavaScript, Go and Rust (use Protocol Buffers or MessagePack there).
## Example
```csharp
using System;
using System.Collections.Generic;
using Nino.Core;
[NinoType(format: NinoFormat.Keyed)] // stored: members may change later
public abstract class Message { public long Seq; }
public sealed class Ping : Message { public long SentAt; } // inherits [NinoType]
public sealed class Chat : Message { [NinoUtf8] public string Text; public List<int> Mentions; }
public static class Program
{
public static void Main()
{
NinoSettings.Untrusted = true; // startup, before any Nino call
using var buffer = new NinoArrayBufferWriter(); // one per thread or connection
NinoSerializer.Serialize<Message>(new Chat { Seq = 1, Text = "hi “there”", Mentions = new() { 7 } }, buffer);
Message received = null;
try
{
NinoDeserializer.Deserialize(buffer.WrittenSpan, ref received); // a Chat; reused by later calls
}
catch (NinoDataException e) // the only exception invalid data throws
{
Console.WriteLine($"bad message: {e.Message}");
return;
}
var chat = (Chat)received;
Console.WriteLine($"{chat.Seq} {chat.Text} {chat.Mentions[0]}");
buffer.ResetWrittenCount(); // reuse the same array
NinoSerializer.Serialize<Message>(new Ping { Seq = 2, SentAt = 42 }, buffer);
NinoDeserializer.Deserialize(buffer.WrittenSpan, ref received); // type changed: a new Ping
Console.WriteLine(received is Ping { SentAt: 42 });
}
}
```