Skip to content

QUIC

QUIC is an excellent wirespec showcase. Its variable-length integer encoding (VarInt) exercises computed types and bits[N] matches. Its frame format exercises nearly every wirespec field kind: optional fields, derived fields, arrays, and bytes[remaining]. Together, varint.wspec and frames.wspec cover most of the language.

VarInt

QUIC uses a 2-bit prefix to encode integers of 6, 14, 30, or 62 bits. A VarInt occupies 1, 2, 4, or 8 bytes on the wire depending on the magnitude of the value.

wire
module quic.varint

@strict
type VarInt = {
    prefix: bits[2],
    value: match prefix {
        0b00 => bits[6],
        0b01 => bits[14],
        0b10 => bits[30],
        0b11 => bits[62],
    },
}

Features demonstrated:

FeatureWhere
Computed typetype VarInt = { ... } with braces
bits[N] sub-byte fieldprefix: bits[2]
match on a fieldmatch prefix { 0b00 => ... }
Binary literals0b00, 0b01, 0b10, 0b11
@strict annotationNon-canonical encodings rejected at parse time

How it works. The prefix field occupies the top 2 bits of the first byte. The match dispatches on its value to determine how many bits follow. 0b00 means 6 more bits in the same byte (total 1 byte). 0b11 means 62 more bits across 7 more bytes (total 8 bytes).

@strict and canonical encoding. RFC 9000 requires that VarInt values use the shortest possible encoding. The @strict annotation enforces this: if the parser reads a value that could have been encoded in fewer bytes, it returns WIRESPEC_ERR_NONCANONICAL instead of silently accepting it. Without @strict, non-canonical encodings are accepted.

Compile:

bash
wirespec compile examples/quic/varint.wspec -o build/

Generated C API:

c
wirespec_result_t quic_varint_var_int_parse(
    const uint8_t *buf, size_t len,
    quic_varint_var_int_t *out, size_t *consumed);

wirespec_result_t quic_varint_var_int_serialize(
    const quic_varint_var_int_t *in,
    uint8_t *buf, size_t cap, size_t *written);

The generated struct holds both the raw prefix and value fields. The serializer reconstructs the prefix from the value magnitude automatically.

RFC 9000 Appendix A test vectors. The wirespec test suite for VarInt includes all vectors from RFC 9000 Appendix A, covering each encoding width and boundary values.


QUIC Frames

QUIC frames use a VarInt tag to dispatch to over a dozen frame types. Each frame type has its own field layout, and many use optional fields, derived fields, and length-delimited byte arrays.

wire
module quic.frames
@endian big
import quic.varint.VarInt

const MAX_CID_LENGTH: u8 = 20

packet LengthPrefixedCid {
    length: u8,
    value: bytes[length],
    require length <= MAX_CID_LENGTH,
}

frame QuicFrame = match frame_type: VarInt {
    0x00 => Padding {},
    0x01 => Ping {},

    0x02..=0x03 => Ack {
        largest_ack: VarInt,
        ack_delay: VarInt,
        ack_range_count: VarInt,
        first_ack_range: VarInt,
        ack_ranges: [AckRange; ack_range_count],
        ecn_counts: if frame_type == 0x03 { EcnCounts },
    },

    0x06 => Crypto {
        offset: VarInt,
        length: VarInt,
        data: bytes[length: length],
    },

    0x08..=0x0f => Stream {
        stream_id: VarInt,
        offset_raw: if frame_type & 0x04 { VarInt },
        length_raw: if frame_type & 0x02 { VarInt },
        data: bytes[length_or_remaining: length_raw],
        let offset: u64 = offset_raw ?? 0,
        let fin: bool = (frame_type & 0x01) != 0,
    },

    0x18 => NewConnectionId {
        sequence: VarInt,
        retire_prior: VarInt,
        cid_length: u8,
        cid: bytes[cid_length],
        reset_token: bytes[16],
    },

    0x1c..=0x1d => ConnectionClose {
        error_code: VarInt,
        offending_frame_type: if frame_type == 0x1c { VarInt },
        reason_length: VarInt,
        reason_phrase: bytes[reason_length: reason_length],
    },

    0x1e => HandshakeDone {},

    0x30..=0x31 => Datagram {
        length: if frame_type & 0x01 { VarInt },
        data: bytes[length_or_remaining: length],
    },

    _ => Unknown { data: bytes[remaining] },
}

packet AckRange  { gap: VarInt, ack_range: VarInt }
packet EcnCounts { ect0: VarInt, ect1: VarInt, ecn_ce: VarInt }

Features demonstrated:

FeatureWhere
frame tagged unionframe QuicFrame = match frame_type: VarInt { ... }
Importimport quic.varint.VarInt
constconst MAX_CID_LENGTH: u8 = 20
Pattern ranges0x02..=0x03, 0x08..=0x0f, 0x1c..=0x1d, 0x30..=0x31
Empty framesPadding {}, Ping {}, HandshakeDone {}
Arrays[AckRange; ack_range_count]
Optional fieldsif COND { T }
Bitwise conditionif frame_type & 0x04 { VarInt }
bytes[length: EXPR]data: bytes[length: length]
bytes[length_or_remaining: EXPR]data: bytes[length_or_remaining: length_raw]
bytes[remaining]Unknown { data: bytes[remaining] }
Fixed-length bytesreset_token: bytes[16]
Derived fields (let)let offset: u64 = offset_raw ?? 0
Coalesce operator (??)offset_raw ?? 0
require + constrequire length <= MAX_CID_LENGTH
Wildcard branch_ => Unknown { ... }

Pattern Ranges

A single frame branch can handle a range of tag values using ..= (inclusive):

wire
0x02..=0x03 => Ack { ... }

This matches both 0x02 (ACK without ECN) and 0x03 (ACK with ECN). Inside the branch, frame_type still holds the exact value, so the ECN field can be conditioned on frame_type == 0x03.

Optional Fields

Optional fields use if COND { T }. The condition is evaluated at parse time. If true, the field is parsed; otherwise it is absent.

wire
ecn_counts: if frame_type == 0x03 { EcnCounts },

In generated C, optional fields appear as a pair:

c
bool has_ecn_counts;
quic_frames_ecn_counts_t ecn_counts;

Bitwise conditions work the same way:

wire
offset_raw: if frame_type & 0x04 { VarInt },

The O bit (0x04) in the Stream frame type indicates whether an offset is present. If the bit is clear, has_offset_raw is false and the field is skipped.

bytes[length_or_remaining: EXPR]

Stream and Datagram frames use this special bytes spec. The expression is an Option[VarInt] — it is present when the L bit (0x02) is set, absent otherwise.

wire
data: bytes[length_or_remaining: length_raw],
  • If length_raw is present: read exactly that many bytes.
  • If length_raw is absent (null): consume all remaining bytes in the current scope.

This cleanly models the RFC 9000 rule: a Stream frame with the L bit clear extends to the end of the QUIC packet.

Derived Fields

let fields are computed from wire fields but are not present on the wire themselves:

wire
let offset: u64 = offset_raw ?? 0,
let fin: bool = (frame_type & 0x01) != 0,

The ?? coalesce operator unwraps an optional: offset_raw ?? 0 yields offset_raw if it is present, or 0 if it is absent. This lets you use a clean u64 everywhere downstream instead of checking has_offset_raw.

let fields appear in the generated C struct alongside wire fields:

c
uint64_t offset;  /* derived */
bool fin;         /* derived */

Multi-Module Compilation

frames.wspec imports VarInt from varint.wspec. Compile both together:

bash
wirespec compile examples/quic/frames.wspec \
    -I examples/ \
    -o build/

The -I examples/ flag tells the resolver where to find quic/varint.wspec. The compiler performs topological sorting and emits quic_varint.h before quic_frames.h so the include order is correct.

Alternatively, compile all files in the examples/quic/ directory at once:

bash
wirespec compile --recursive examples/quic/ -o build/

Generated C API:

c
wirespec_result_t quic_frames_quic_frame_parse(
    const uint8_t *buf, size_t len,
    quic_frames_quic_frame_t *out, size_t *consumed);

wirespec_result_t quic_frames_quic_frame_serialize(
    const quic_frames_quic_frame_t *in,
    uint8_t *buf, size_t cap, size_t *written);

The generated union discriminates on frame_type:

c
typedef struct {
    quic_varint_var_int_t frame_type;
    union {
        quic_frames_ack_t          ack;
        quic_frames_crypto_t       crypto;
        quic_frames_stream_t       stream;
        quic_frames_datagram_t     datagram;
        quic_frames_unknown_t      unknown;
        /* ... */
    };
} quic_frames_quic_frame_t;

What Next

  • Classic Protocols — UDP, TCP, Ethernet, IPv4 basics
  • BLE — little-endian, type aliases, enums, ATT protocol
  • MQTT — continuation-bit VarInt, TLV capsule, expression-based dispatch
  • TLS 1.3 — enum tags, u24, fill-within arrays