Status: DRAFT — not yet released.
C# port of the C++ serialize bitpacking
library. Produces bit-for-bit identical output to the C++ library (and to the Go and
Rust ports), so streams written in one language can be read by any other. Wire
compatibility is proven, not asserted: a golden wire test pins 72 bytes copied verbatim
from the C++ test suite, and a live interop harness cross-checks against the real
serialize.h compiled with clang++.
Family values: zero third-party dependencies (including test frameworks); malicious packet data never throws — reads fail cleanly with a sticky latched error (exceptions are reserved for API misuse); no unsafe code; zero allocation on serialization paths (strings on the read path are the documented exception).
src/Serialize.cs— the whole library, one file (mirrors the C++ single header):BitWriter,BitReader,IBitStream,WriteStream,ReadStream,MeasureStream,SerializeUtil,ISerializer, plus the C#-only batch layerWriteBatch/ReadBatch(see below).tests/— console test runner, no test framework: prints each test name, exit code is the verdict. Includes the golden wire test, an extended wire test pinning the 64-bit paths, and deterministic differential/hostile seeded tests.compat/— the cross-language interop harness:Compat.csproj(C# half) andcpp/compat.cpp(C++ half, built against the realserialize.h).scripts/interop.sh— the interop gate as one runnable command.STANDARD.md— the wire format spec, vendored verbatim from the C++ repo (family precedent: CI should diff it against upstream and fail on drift).
dotnet build src/Serialize.csproj # builds net8.0 + net10.0
dotnet run --project tests/Tests.csproj -f net10.0 # add "short" to skip the 320 MB test
dotnet run --project tests/Tests.csproj -f net8.0 # the LTS leg (needs the .NET 8
# runtime, or DOTNET_ROLL_FORWARD=LatestMajor)
dotnet run --project tests/Tests.csproj -f net10.0 -- golden # run only tests matching a substringThe library targets net8.0 (LTS game servers) and net10.0. A
netstandard2.1 target for Unity-class runtimes is an open deliverable: it needs
shims for BitOperations, the unsigned BitConverter bit casts, Rune
enumeration and Utf8.IsValid, plus an emulated 128 bit pair standing in for
Int128/UInt128 (mirroring the C++ emulated types' two's complement
semantics), each proven wire-neutral by the golden test per TFM before it ships.
The streams are heap objects, so even with the serialize methods inlined the JIT
reloads and stores the packer state (scratch, scratch bits, bits written) around
every call — heap fields cannot live in registers across calls, and on tiny
messages that traffic dominates. A batch lifts the state into the fields of a
ref struct at BeginBatch, serializes against locals with the same wire logic,
the same validation and the same latched error model — byte-for-byte identical
output, proven by a batch golden-wire test and randomized differential tests —
and stores the state back once at End:
WriteBatch batch = stream.BeginBatch(); // ReadStream: ReadBatch
batch.SerializeBits(ref value, 8);
// ... the same serialize surface as the stream ...
batch.End(); // always, on every path outThe contract is small: the batch owns the stream between BeginBatch and End
(stream calls or Reset while a batch is open are API misuse); always call End
on every path out — it is idempotent, and it is what publishes the batch's work
back to the stream. Fixed-size scalar operations up to 64 bits run
register-resident; everything else — bulk and variable-size operations
(SerializeBytes, strings, objects, SerializeIntRelative) and the 128 bit and
fixed point operations (SerializeInt128, SerializeUInt128, SerializeFixed) —
delegates to the class path and recaptures, byte identical. Batches are additive:
code that never begins one behaves exactly as before. IBitStream-based unified
serialize functions are unchanged — batches are for per-direction hot paths
(e.g. generated code) where tiny-message throughput matters.
Two measured rules (Apple M2, schema harness): pass a batch by ref only to
helpers marked AggressiveInlining — a real call taking ref WriteBatch
address-exposes the struct and kills enregistration for the whole scope,
measured slower than no batch at all; and batch scalar-dense bodies only — a
body dominated by one bulk op (length int + SerializeBytes) pays the batch
capture/restore without winning it back.
scripts/interop.sh path/to/serialize # or run the steps below by handclang++ -O2 -std=c++17 -ffp-contract=off -Wall -I path/to/serialize -o compat-cpp compat/cpp/compat.cpp
dotnet run --project compat/Compat.csproj -- write cs.bin
./compat-cpp write cpp.bin
cmp cs.bin cpp.bin # must be byte identical
dotnet run --project compat/Compat.csproj -- read cpp.bin
./compat-cpp read cs.bin-ffp-contract=off on the C++ build is required, not optional: strict IEEE
evaluation is the normative wire for compressed floats. Default clang/gcc on ARM64
contract the quantization normalized * maxInteger + 0.5f into a fused
multiply-add, which rounds differently within 1 ULP of a .5 boundary and shifts
the written integer by one wire quantum (~1 in 10^7 values). C# and Rust always
evaluate strictly; the compat sequence carries a value pinned on such a boundary
(0.005f in [0,10] res 0.01), so a contracted C++ build fails the cmp
instead of passing silently. (The Go port on ARM64 currently fuses and should be
flagged upstream.)
When CI is created, pin the C++ clone to one release tag — the Go and Rust ports
pin v1.4.3; the sibling clone here is currently at a later head — and pick one
tag for both the interop job and the spec-sync job.
The fixed point + 128 bit additions to the C++ library (its fixed-point branch)
are ported in full, on the native Int128/UInt128 — both TFMs are net7.0+, so
the C++ native/emulated distinction does not exist here; there is exactly one
representation:
SerializeUInt128— raw, always 128 bits on the wire: the low 64 bit half first, then the high half. When the stream is byte aligned the result is the 16 bytes of the value in little endian order.SerializeInt128— the ranged counterpart ofSerializeInt64:SerializeUtil.BitsRequired128(min,max)bits, computed and offset encoded in the unsigned domain (ranges wider than 2^127 are exact), written in 32 bit groups from least significant upward. Where the range fits 64 bits or fewer the bytes are identical toSerializeInt64over the same bounds, so a field can be widened from 64 to 128 bits without a wire change.SerializeFixed— Q format fixed point, one overload per integer storage type from 16 to 128 bits, signed and unsigned. The whole unit bounds are shifted to raw integer-exact bounds and the raw value is offset encoded in the minimal bits for the range; the codec touches no floats, so unlikeSerializeCompressedFloatthe round trip is exact and identical on every platform. For storage of 64 bits or fewer the wire is byte identical toSerializeInt64of the raw value over the raw bounds, andfractionBits = 0is a ranged integer.
Offsets smuggled into the bit headroom past the top of a range are rejected on read, never clamped. The Q format and bounds are trusted parameters validated as API misuse (see below), like every other range in the library.
The wire pins for all three operations were derived from STANDARD.md's text by an
independent oracle and cross-checked byte for byte against the pins in the C++
fixed-point test suite; test_fixed_wire_format pins the byte aligned fixed
point tail of the C++ 112 byte golden message, leaving the original 72 byte
golden pin untouched.
Errors are sticky: the first failure latches on the stream and later serialize calls
are no-ops that leave values unmodified. One rule follows: a value that controls a loop
must have its result checked before the loop uses it, otherwise a truncated or
malicious packet spins the loop forever. Use stream.Continue(ref more) /
stream.Until(ref done) for sentinel-driven loops, and check the result of any
serialized loop count before looping — on a reused stream a failed read leaves the
previous packet's count in place.
Two further rules:
- Ranges are trusted inputs.
min,max,resolution,bufferSizeand fixed point Q format (integerBits,fractionBits, whole unit bounds) parameters are validated as API misuse and throwArgumentException— even on a stream with a latched error. If you compute a range from previously decoded packet data, validate it before passing it in, or one malicious packet becomes an unhandled exception. - Compressed float ranges must have a finite difference. When
max - minoverflows to infinity (e.g.[-3.4e38, +3.4e38]), the read can decode NaN or infinity and still report success — behavior inherited from the C++ library for wire fidelity. Choose ranges whose difference is finite.
AGPL-3.0. See LICENSE.