Cyclone

EN/VI

Wire format specification · v1.0.0 · Draft

Cyclone Protocol

A deterministic binary wire format.
Nothing more. Nothing less.

Cyclone defines how logical data is transformed into bytes, and how bytes are transformed back into logical data.

Order

Specification First

The specification is the single source of truth. It is never changed to accommodate an implementation. Everything else is downstream of it.

The order Cyclone follows

  1. RFC
  2. Compiler
  3. SDK
  4. Application

Not this

  1. SDK
  2. Protocol

A protocol shaped by whatever its first library happened to do.

The test applied to every sentence of the specification: could somebody writing an encoder in assembly on an 8-bit microcontroller follow it? If not, that sentence describes an implementation, and it does not belong in the spec.

Definition

What is Cyclone?

Cyclone is a protocol.

Cyclone is not

  • × RPC
  • × Networking
  • × Runtime
  • × Serialization Library

Cyclone only specifies

  1. Model
  2. Bytes
  3. Model

Transport, retransmission, ordering, matchmaking, encryption, compression, checksums — all of it is built on top of Cyclone. The specification stops exactly where the bytes are formed.

Philosophy

Ten principles, two layers.

Seven are conformance conditions. Three are choices the Reference Implementation made. Mixing the two is how a protocol quietly turns into a framework.

Principles of the Specification

Violate one of these and it is not Cyclone.

01 Specification First The spec is the single source of truth. It is never changed to accommodate an implementation.
02 Protocol over Framework Cyclone defines how data appears on the wire — not an API, not a runtime.
03 Minimal Wire Format Send the data, never a description of the data. No field names, no type info, no tags, no padding.
04 Deterministic by Design One value, exactly one byte sequence. Two valid encodings of the same data must not exist.
05 Language Neutral No language is the reference language. Nothing in the format favours one type system.
06 Implementation Independent No mandated parser, macro, or generator. The spec describes bytes and stops there.
07 Implementation Replaceable The Reference Implementation is not the standard. Replace it entirely and the wire format does not move.

Principles of the Reference Implementation

Design choices, not conformance conditions. Another implementation may choose differently, as long as the bytes do not move.

08 User Owns the Model No mandatory DTO layer, no base class — the RI reads the fields you mark and ignores the rest.
09 Compile-time over Runtime Anything decidable at build time is decided at build time.
10 Zero Runtime Reflection The wire format permits it; the spec does not require it. AOT and bare-metal targets stay viable.

The decision rule, in two questions. One: is this statement observable from the byte stream? If not, it belongs to the Reference Implementation and must not enter the spec. Two: does it violate one of the seven above? If yes, it is not part of Cyclone.

Trade-offs

What Cyclone deliberately does not do

Each of these is the direct cost of a guarantee above. None of them is on a roadmap.

No schema evolution

Position is the only field identifier, so adding, removing or reordering a field is a breaking change. v1 has no version negotiation and no field migration.

No backward or forward compatibility

There is no unknown-field skipping and no defaults, so an old client cannot read a new server's message. Both ends deploy together, or not at all.

No metadata, not self-describing

The stream carries no field names, types or tags — the receiver must already know the field order and types. The dangerous corollary: decoding against the wrong definition usually raises no error at all. It produces wrong values, quietly.

No mandated IDL

The source of truth is the Schema, but the spec does not dictate how you write it down — no mandatory IDL, no mandatory .cyclone file. The Reference Implementation expresses the Schema as annotations on your existing type, extracting only three things: the type, its fields, and each field's Cyclone type. The trade: there is no standalone schema document to hand a third party — both ends are on their own to derive the same Schema.

No Optional, no nullable, no defaults

Every field carries a value. There is no absent-field encoding in v1 — which is precisely why there is no ambiguity about how absence would be written.

No transport, no framing, no crypto

Cyclone produces a byte sequence and stops. Message boundaries, retransmission, ordering, encryption and compression are all yours to build on top.

If any of these is unacceptable for your problem, a format with tag IDs and optional fields will serve you better. That is the right call, not a failure of Cyclone.

Specification

RFC-0002

Wire format

Little endian, always. Fixed widths, no varint. No tag, no header, no padding, no terminator. Position is the only identifier.

Primitive

Fixed width, little endian. A u32 is four bytes even when the value is zero.

u32   100        → [64 00 00 00]
u32   0x12345678 → [78 56 34 12]
i32   -1         → [FF FF FF FF]
u16   300        → [2C 01]
bool  true       → [01]
bool  0x02invalid
f32   3.14       → [C3 F5 48 40]
f32   -0.0       → [00 00 00 80]

Floats keep their IEEE 754 bit pattern exactly. No normalization, no canonical NaN.

String

UInt32 length, then UTF-8 bytes. Length counts bytes, never characters.

"abc"      → [03 00 00 00] [61 62 63]
"中"        → [03 00 00 00] [E4 B8 AD]
"Xin chào" → [09 00 00 00] [58 69 6E 20 63 68 C3 A0 6F]
""         → [00 00 00 00]

“中” is one character and three bytes. Length MUST be 3. The byte region must be valid UTF-8.

Bytes

UInt32 length, then raw bytes. Identical to String, minus the UTF-8 constraint.

[]         → [00 00 00 00]
[FF FE]    → [02 00 00 00] [FF FE]
[00 00]    → [02 00 00 00] [00 00]

The same bytes a String must reject. Type comes from the schema — never inferred from content.

Model

Fields concatenated in declaration order. No metadata, no field ID, no padding.

model Item { id: u32; name: String }
Item{ 42, "Sword" }
  → [2A 00 00 00] [05 00 00 00] [53 77 6F 72 64]

model Mixed { a: u8; b: u32; c: u8 }
Mixed{ 1, 2, 3 } → [01 02 00 00 00 03]   6 bytes
                   12 bytes = copying memory layout

A compiler may reorder fields in RAM. That must not change a single byte of output.

Array

UInt32 count, then each element in the byte shape of T. Count is elements, not bytes.

Array<u32> [1,2,3]
  → [03 00 00 00] [01 00 00 00] [02 00 00 00] [03 00 00 00]

Array<String> ["a","bc"]
  → [02 00 00 00] [01 00 00 00 61] [02 00 00 00 62 63]

Array<Array<u8>> [[1,2],[]]
  → [02 00 00 00] [02 00 00 00 01 02] [00 00 00 00]

Count for [1,2,3] is 3, not 12. An empty inner array is a count of 0 and no further bytes.

Enum

Always UInt32. Four bytes, regardless of how few members it declares.

enum PlayerState { Idle=0, Walk=1, Run=2, Jump=3 }

Idle → [00 00 00 00]
Walk → [01 00 00 00]
Jump → [03 00 00 00]
99   → invalid — outside the defined set

A protocol decision, not a storage optimization. There is nothing for two peers to negotiate.

A complete message, byte by byte

The example from RFC-0002 §15 — nested model, string, integers, bool — encodes to 31 bytes. Hover a field to light up the bytes it owns, or hover a byte to find its owner.

model Vector3 { x: f32; y: f32; z: f32 }

model GameMessage {
  playerId:   u32       = 42
  playerName: String    = "Knight"
  position:   Vector3   = { 10.5, 20.3, -5.1 }
  health:     u32       = 100
  isAlive:    bool      = true
}
Hex dump · 31 bytes Position is the only identifier
00000000 2A000000060000004B6E696768740000
00000010 28416666A2413333A3C06400000001
Total31 bytes · 0 bytes of metadata

Nothing separates the fields. No length prefix on the nested model, no delimiter between position and health, no terminator at the end. The decoder knows which field it is reading purely from how far the cursor has advanced — which is exactly why reordering a field is a breaking change, and why renaming one is free.

RFC-0003

Test vectors

A test vector is a pair of (logical value, byte sequence). Never a pair of (function call, return value) — a vector names no method, no language, no API.

InputExpected Bytes

Primitive

IDInputExpected bytes
P-002bool true01
P-011u8 255FF
P-021u16 3002C 01
P-032u32 0x1234567878 56 34 12
P-035i32 −214748364800 00 00 80
P-042u64 2⁶⁴−1FF FF FF FF FF FF FF FF
F-005f32 0x4048F5C3 · 3.14C3 F5 48 40
F-002f32 0x80000000 · −0.000 00 00 80
F-031f32 0x7FC00001 · NaN01 00 C0 7F
S-004String “中”03 00 00 00 E4 B8 AD
S-005String “Xin chào”09 00 00 00 58 69 6E 20 63 68 C3 A0 6F

P-032 catches endianness: a big-endian implementation writes 12 34 56 78 and fails here. F-031 catches NaN canonicalization — producing 00 00 C0 7F means user data was modified.

Model

IDInputExpected bytes
T-001Item{ id=42, name="Sword" }2A 00 00 00 05 00 00 00 53 77 6F 72 64
T-002Empty{}— 0 bytes
T-003Vector3{1.5, 2.5, 3.5}00 00 C0 3F 00 00 20 40 00 00 60 40
T-004Body{ pos=Vector3{1.5,2.5,3.5}, hp=100 }00 00 C0 3F 00 00 20 40 00 00 60 40 64 00 00 00
T-010Mixed{ a=1, b=2, c=3 }01 02 00 00 00 03
T-011Order{ z=1, a=2 }01 02

T-010 catches memory-layout copying (12 bytes) and compiler field reordering. T-011 catches implementations that sort fields alphabetically “for stability” — 02 01 is a bug.

Array

IDInputExpected bytes
A-001Array<u32> []00 00 00 00
A-002Array<u32> [1,2,3]03 00 00 00 01 00 00 00 02 00 00 00 03 00 00 00
A-003Array<String> ["a","bc"]02 00 00 00 01 00 00 00 61 02 00 00 00 62 63
A-004Array<Array<u8>> [[1,2],[]]02 00 00 00 02 00 00 00 01 02 00 00 00 00
A-005Array<bool> [true,false,true]03 00 00 00 01 00 01

A-002 catches the count-versus-byte-length bug: the correct count is 3, not 12.

Enum

IDInputExpected bytes
E-001PlayerState::Idle00 00 00 00
E-002PlayerState::Walk01 00 00 00
E-003PlayerState::Jump03 00 00 00
E-010Encoded size of any enumalways 4 bytes

E-010 catches implementations that “optimize” a four-member enum down to one byte.

Invalid Stream

IDInput bytesExpected result
N-001bool · 02reject · not 00/01
N-010u32 · 00 00 00reject · unexpected EOF
N-020String · 02 00 00 00 FF FEreject · invalid UTF-8
N-021String · 03 00 00 00 ED A0 80reject · encoded surrogate
N-023String · 64 00 00 00 41 6C 69 63 65reject · length 100, 5 bytes remain
N-030Array<u32> · 03 00 00 00 01 00 00 00reject · count 3, one element
N-031Array<u32> · FF FF FF FFreject · before allocating
N-040enum{0,1} · 63 00 00 00reject · value 99 undefined

Rejection is MUST; the error name is only SHOULD. N-023 and N-031 check one thing: is the length validated before allocating? Passing N-031 by crashing and catching the exception does not count as a pass.

The full set — including 8-byte integers, f64, round-trip and cross-implementation checks — is in RFC-0003.

Conformance

Cyclone Compatible

An implementation is Cyclone Compatible if and only if it passes 100% of the test vectors.

  • RFC-0002
  • Test Vectors
  • Deterministic Output

There is no “partially compatible”. There is no 98%. One failing vector means there exists data on which this implementation and another disagree — silently, in production.

An implementation 10× slower that passes 100% of the vectors is Cyclone Compatible.
The fastest implementation in the world that fails one vector is not.

Source

Tools

Official tools

Official Compiler Reads a schema, emits a codec. One of many valid ways to satisfy RFC-0002. In progress
Official CLI Inspect a byte stream, run the conformance vectors, produce a report. In progress
Reference SDK A worked encoder/decoder to read alongside the spec, not a dependency the spec assumes. In progress

These tools are reference implementations of the Cyclone Protocol. They are not part of the protocol itself.

Replace all three and the wire format does not move. Nothing in Cyclone requires you to use them — they exist to be read, and to be beaten.

Start here