delta-pack
Binary serialization with delta compression for real-time state synchronization.
Delta-Pack is a cross-language serialization framework optimized for networked applications where you need to efficiently synchronize state between clients and servers. It provides both full encoding and delta encoding (only transmitting what changed).
Features
- Compact binary format - Smaller than JSON, MessagePack, and often Protobuf
- Delta compression - Encode only the differences between two states
- Cross-language - Compatible with TypeScript and C# implementations
- Zero-copy strings - String dictionary deduplication within each message
- Serde integration - Types work with
Serialize/Deserializefor JSON interop
Installation
Add to your Cargo.toml:
[]
= "0.1"
Usage
There are two ways to define a schema: as a #[derive(DeltaPack)] on native Rust types, or as a YAML file fed through the delta-pack CLI. Both paths expand through the same proc-macro and emit byte-identical output, so pick whichever fits your workflow.
- Derive mode — schema lives in Rust, no build step, no committed generated files. Recommended for Rust-only projects.
- Codegen mode — schema lives in YAML, shared across Rust / TypeScript / C#. Required when the same schema drives multiple languages.
Derive mode
Annotate a native Rust type with #[derive(DeltaPack)]:
use ;
use ;
Field names are snake_case in Rust and camelCase in the schema — the derive reverses one to the other automatically. Use #[serde(rename = "…")] to keep the camelCase shape in JSON when you also want serde interop.
Codegen mode
Define the schema in YAML:
# schema.yml
HairColor:
- BLACK
- BROWN
- BLOND
- RED
Address:
street: string
city: string
zip: string
User:
id: string
name: string
age: uint
weight: float(precision=0.01)
hairColor: HairColor
address: Address?
tags: string[]
metadata: <string, string>
Then generate Rust:
The CLI emits struct and enum skeletons with #[derive(DeltaPack)] — not hand-rolled impls — so everything below applies identically to both modes.
State synchronization (SyncSession<T>)
For ongoing state sync between two endpoints (server ↔ client, peer ↔ peer), use SyncSession<T>. It handles the full-encode bootstrap and every subsequent diff internally, and keeps both sides aligned even when the sender's state gets mutated in ways that reorder internal collections.
use DeltaPack;
// Server — one SyncSession per connected peer
let mut session = create_sync_session;
peer.send; // first call: full; subsequent calls: diff
// Client
let mut session = create_sync_session;
let user = session.decode;
SyncSession is the recommended API for real-time sync. create_sync_session() is a default method on the DeltaPack trait, available for any T: DeltaPack + Clone — both are satisfied automatically by #[derive(DeltaPack)] on types that also #[derive(Clone)].
Low-level encode / decode / diff (advanced)
For custom protocols (ack-based history, multi-baseline diffs, UDP-style packet loss handling, etc.), use the trait methods directly:
use DeltaPack;
let user1 = User ;
// Full encode / decode
let bytes = user1.encode;
let decoded = decode;
assert!;
// Delta encoding — only send what changed
let user2 = User ;
let diff = encode_diff;
let reconstructed = decode_diff;
assert!;
When using encode_diff / decode_diff directly, the a argument must exactly match the peer's wire view, including IndexMap insertion order — not just key-value equality. Mismatch causes silent corruption. SyncSession maintains this invariant for you.
Attributes
#[delta_pack(...)] on a field accepts:
| Attribute | Target | Effect |
|---|---|---|
range(min = N, max = M) |
integer field | bit-packs into ceil(log2(M-N+1)) bits when ≤ 8 bits |
range(min = N) / range(max = M) |
integer field | sets one bound only |
precision = X |
f32 field |
quantizes to multiples of X, encoded as a bit-packed integer |
API
SyncSession<T> (recommended for state sync)
Stateful handle for one side of a sync stream. Handles full-vs-diff internally and keeps sender and receiver views aligned.
| Method | Description |
|---|---|
T::create_sync_session() |
Default method on DeltaPack (for T: Clone). Returns a new session. |
SyncSession::<T>::new() |
Equivalent direct constructor if you prefer turbofish syntax. |
.encode(&state) -> Vec<u8> |
First call emits a full encode; subsequent calls emit diffs. View updates internally. |
.decode(&bytes) -> &T |
First call expects a full encode; subsequent calls expect diffs. Returns the updated view. |
.current() -> Option<&T> |
The current view, or None if neither encode nor decode has been called. |
DeltaPack trait (low-level)
Every type implementing DeltaPack provides these primitives. Use SyncSession for ordinary sync; use these directly for custom protocols.
| Method | Description |
|---|---|
encode(&self) -> Vec<u8> |
Serialize to binary |
decode(buf: &[u8]) -> Self |
Deserialize from binary |
encode_diff(a: &Self, b: &Self) -> Vec<u8> |
Encode only the differences from a to b |
decode_diff(a: &Self, diff: &[u8]) -> Self |
Apply a diff to a to produce b |
equals(&self, other: &Self) -> bool |
Deep equality (respects float precision) |
Types also get a derived impl Default. The user is responsible for Clone, Debug, Serialize, Deserialize (CLI-generated code derives them; derive-mode users add whichever they need).
Schema Types
Primitives
| Schema | Rust Type | Notes |
|---|---|---|
string |
String |
UTF-8, dictionary-compressed |
int |
i64 |
Signed, varint-encoded |
uint |
u64 |
Unsigned, varint-encoded |
int(min=0, max=100) |
u64 |
Bounded, more compact |
float |
f32 |
32-bit IEEE 754 |
float(precision=0.01) |
f32 |
Quantized for smaller diffs |
boolean |
bool |
1 bit, RLE-compressed |
Containers
| Schema | Rust Type |
|---|---|
T[] |
Vec<T> |
T? |
Option<T> |
<K, V> |
IndexMap<K, V> |
Named Types
# Enum (list of strings)
Direction:
- up
- down
- left
- right
# Object (key-value properties)
Player:
name: string
score: uint
position: Position
# Union (list of type references)
Message:
- ChatMessage
- MoveMessage
- AttackMessage
Self-References
Recursive types are supported. In YAML they're implicit; in derive mode wrap the inner type in Box<Self>:
TreeNode:
value: int
children: TreeNode[] # Generates Vec<Box<TreeNode>>
Binary Format
[data section][RLE section][numRleBits: reverse varint]
- Data section: Primitives encoded sequentially (strings with dictionary, varints, floats)
- RLE section: Run-length encoded bits (booleans, optional flags, change indicators)
- Reverse varint: Bit count stored at end for streaming decode
Performance
Benchmarks comparing encode throughput (higher is better):
| Schema | DeltaPack | JSON | MessagePack |
|---|---|---|---|
| Primitives | 34.1M ops/s | 11.1M | 10.1M |
| GameState | 5.8M ops/s | 673K | 816K |
| User | 5.3M ops/s | 1.8M | 2.0M |
Run benchmarks:
Faster Decoding with mimalloc
Decode performance is allocation-bound. Using mimalloc instead of the system allocator improves decode throughput by ~30%:
# Cargo.toml
[]
= "0.1"
// main.rs
static GLOBAL: MiMalloc = MiMalloc;
Cross-Language Compatibility
Delta-Pack ensures binary compatibility across Rust, TypeScript, and C#:
- Same schema produces interoperable binary format
- Conformance tests verify encode/decode compatibility
- Diff encoding uses sorted keys for deterministic output
All languages use insertion-order-preserving maps (IndexMap in Rust, Map in TypeScript, OrderedDict in C#), producing deterministic encoding for the same insertion order.
License
MIT