Skip to content

C API Reference

wirespec generates C11 code with no heap allocation. All memory is stack-allocated or provided by the caller.


Naming Convention

For a module named mod.sub and a type named FooBar, all generated symbols use the prefix mod_sub_foo_bar_:

ModuleTypeC prefix
quic.framesQuicFramequic_frames_quic_frame_
quic.varintVarIntquic_varint_var_int_
ble.attAttPduble_att_att_pdu_
mqttMqttPacketmqtt_mqtt_packet_

The module path components and type name are each converted to snake_case and joined with _.


Generated Functions

For every packet, frame, or capsule named Foo in module mod:

c
/* Parse: reads from buf[0..len], fills *out, sets *consumed to bytes read. */
wirespec_result_t mod_foo_parse(
    const uint8_t *buf, size_t len,
    mod_foo_t     *out, size_t *consumed);

/* Serialize: writes to buf[0..cap], sets *written to bytes written. */
wirespec_result_t mod_foo_serialize(
    const mod_foo_t *val,
    uint8_t *buf, size_t cap, size_t *written);

/* Returns the exact number of bytes serialize() will write. */
size_t mod_foo_serialized_len(const mod_foo_t *val);

Parse contract:

  • Returns WIRESPEC_OK and advances *consumed on success.
  • Returns an error code and leaves *out in an unspecified state on failure.
  • Never reads beyond buf + len.

Serialize contract:

  • Returns WIRESPEC_OK and sets *written on success.
  • Returns WIRESPEC_ERR_SHORT_BUFFER if cap < mod_foo_serialized_len(val).
  • Never writes beyond buf + cap.

Usage pattern:

c
#include "quic_frames.h"
#include "wirespec_runtime.h"

uint8_t buf[4096];
size_t  len = read_packet(buf, sizeof(buf));

quic_frames_quic_frame_t frame;
size_t consumed;
wirespec_result_t rc = quic_frames_quic_frame_parse(buf, len, &frame, &consumed);
if (rc != WIRESPEC_OK) {
    handle_error(rc);
    return;
}

/* Round-trip: serialize back */
uint8_t out[4096];
size_t  written;
rc = quic_frames_quic_frame_serialize(&frame, out, sizeof(out), &written);

Memory Model — Three Tiers

wirespec never allocates heap memory. Fields are mapped to one of three tiers:

TierWhatStrategyC Representation
Abytes[...] fieldsZero-copy: pointer + length into the input bufferwirespec_bytes_t
B[scalar; N] arraysMaterialized: memcpy + byte-swap per elementFixed-size C array
C[composite; N] arraysMaterialized: parse each struct elementStruct array + count field

Tier A — Zero-Copy Bytes

bytes[N], bytes[length: expr], bytes[remaining], and bytes[length_or_remaining: expr] all map to wirespec_bytes_t:

c
typedef struct {
    const uint8_t *ptr; /* points into the original input buffer */
    size_t         len;
} wirespec_bytes_t;

ptr is a slice into the parse input buffer. The caller must keep the input buffer alive for as long as the parsed struct is used.

Tier B — Scalar Arrays

[u16le; count] and similar scalar arrays are materialized as fixed-size C arrays with a separate count field:

c
uint16_t items[WIRESPEC_MAX_ARRAY_ELEMENTS]; /* or @max_len capacity */
size_t   items_count;

Byte-swapping for endianness is performed during parse and serialize.

Tier C — Composite Arrays

[AckRange; count] and similar struct arrays are materialized as fixed-size arrays of the element struct type:

c
quic_frames_ack_range_t ack_ranges[WIRESPEC_MAX_ARRAY_ELEMENTS];
size_t                  ack_ranges_count;

Array Capacity

The default capacity for all array fields is WIRESPEC_MAX_ARRAY_ELEMENTS (default: 64).

Override globally for a translation unit:

bash
gcc -DWIRESPEC_MAX_ARRAY_ELEMENTS=128 ...

Override per field with @max_len(N) in the .wspec source.

If the parsed element count exceeds the field capacity, parse returns WIRESPEC_ERR_CAPACITY.


Optional Field Pattern

if COND { T } optional fields expand to a boolean presence flag plus the value:

c
/* wire source: ecn_counts: if frame_type == 0x03 { EcnCounts } */
bool                         has_ecn_counts;
quic_frames_ecn_counts_t     ecn_counts;

When has_ecn_counts is false, the ecn_counts field is zero-initialized and must not be read as meaningful data.

The ?? coalesce operator in wirespec source becomes a conditional expression in generated C: offset_raw ?? 0(has_offset_raw ? offset_raw : 0).


Frame and Capsule Union Pattern

Tagged union types (frame, capsule) generate a tag enum plus a discriminated union:

c
/* Generated for: frame QuicFrame = match frame_type: VarInt { ... } */

typedef enum {
    QUIC_FRAMES_QUIC_FRAME_TAG_PADDING    = 0,
    QUIC_FRAMES_QUIC_FRAME_TAG_PING       = 1,
    QUIC_FRAMES_QUIC_FRAME_TAG_ACK        = 2,
    QUIC_FRAMES_QUIC_FRAME_TAG_CRYPTO     = 6,
    QUIC_FRAMES_QUIC_FRAME_TAG_STREAM     = 8,
    QUIC_FRAMES_QUIC_FRAME_TAG_UNKNOWN    = -1,
} quic_frames_quic_frame_tag_t;

typedef struct {
    quic_frames_quic_frame_tag_t tag;
    union {
        quic_frames_quic_frame_padding_t  padding;
        quic_frames_quic_frame_ping_t     ping;
        quic_frames_quic_frame_ack_t      ack;
        quic_frames_quic_frame_crypto_t   crypto;
        quic_frames_quic_frame_stream_t   stream;
        quic_frames_quic_frame_unknown_t  unknown;
    } data;
} quic_frames_quic_frame_t;

Dispatch on the tag:

c
switch (frame.tag) {
    case QUIC_FRAMES_QUIC_FRAME_TAG_ACK:
        process_ack(&frame.data.ack);
        break;
    case QUIC_FRAMES_QUIC_FRAME_TAG_STREAM:
        process_stream(&frame.data.stream);
        break;
    default:
        break;
}

State Machine Pattern

State machines generate a tag enum, a data union, and a dispatch function:

c
/* Generated for: state machine PathState { ... } */

typedef enum {
    MPQUIC_PATH_PATH_STATE_TAG_INIT       = 0,
    MPQUIC_PATH_PATH_STATE_TAG_VALIDATING = 1,
    MPQUIC_PATH_PATH_STATE_TAG_ACTIVE     = 2,
    MPQUIC_PATH_PATH_STATE_TAG_STANDBY    = 3,
    MPQUIC_PATH_PATH_STATE_TAG_CLOSING    = 4,
    MPQUIC_PATH_PATH_STATE_TAG_CLOSED     = 5,
} mpquic_path_path_state_tag_t;

typedef struct {
    mpquic_path_path_state_tag_t tag;
    union {
        mpquic_path_path_state_init_t       init;
        mpquic_path_path_state_validating_t validating;
        mpquic_path_path_state_active_t     active;
        mpquic_path_path_state_standby_t    standby;
        mpquic_path_path_state_closing_t    closing;
        /* closed has no data */
    } data;
} mpquic_path_path_state_t;

/* Dispatch: applies event to state machine, transitions in place. */
wirespec_result_t mpquic_path_path_state_dispatch(
    mpquic_path_path_state_t  *sm,
    mpquic_path_path_event_tag_t event,
    mpquic_path_path_event_data_t *event_data);

On success, *sm holds the new state. On unhandled event, returns WIRESPEC_ERR_INVALID_STATE.


Runtime API (wirespec_runtime.h)

The runtime is a single header-only file (under 500 LOC) with no external dependencies.

Cursor

c
typedef struct {
    const uint8_t *buf;
    size_t         pos;
    size_t         len;
} wirespec_cursor_t;

void wirespec_cursor_init(wirespec_cursor_t *c,
                          const uint8_t *buf, size_t len);

Read Functions

c
wirespec_result_t wirespec_cursor_read_u8   (wirespec_cursor_t *c, uint8_t  *out);
wirespec_result_t wirespec_cursor_read_u16be(wirespec_cursor_t *c, uint16_t *out);
wirespec_result_t wirespec_cursor_read_u16le(wirespec_cursor_t *c, uint16_t *out);
wirespec_result_t wirespec_cursor_read_u32be(wirespec_cursor_t *c, uint32_t *out);
wirespec_result_t wirespec_cursor_read_u32le(wirespec_cursor_t *c, uint32_t *out);
wirespec_result_t wirespec_cursor_read_u64be(wirespec_cursor_t *c, uint64_t *out);
wirespec_result_t wirespec_cursor_read_u64le(wirespec_cursor_t *c, uint64_t *out);

/* Zero-copy: sets out->ptr into the cursor's buffer, advances pos by len. */
wirespec_result_t wirespec_cursor_read_bytes(wirespec_cursor_t *c,
                                             size_t len,
                                             wirespec_bytes_t *out);

/* Creates a sub-cursor for a within EXPR scope of exactly sub_len bytes. */
wirespec_result_t wirespec_cursor_sub(wirespec_cursor_t *parent,
                                      size_t sub_len,
                                      wirespec_cursor_t *sub);

Write Functions

c
wirespec_result_t wirespec_write_u8   (uint8_t  *buf, size_t cap, size_t *pos, uint8_t  val);
wirespec_result_t wirespec_write_u16be(uint8_t  *buf, size_t cap, size_t *pos, uint16_t val);
wirespec_result_t wirespec_write_u16le(uint8_t  *buf, size_t cap, size_t *pos, uint16_t val);
wirespec_result_t wirespec_write_u32be(uint8_t  *buf, size_t cap, size_t *pos, uint32_t val);
wirespec_result_t wirespec_write_u32le(uint8_t  *buf, size_t cap, size_t *pos, uint32_t val);
wirespec_result_t wirespec_write_u64be(uint8_t  *buf, size_t cap, size_t *pos, uint64_t val);
wirespec_result_t wirespec_write_u64le(uint8_t  *buf, size_t cap, size_t *pos, uint64_t val);

/* Writes bytes->len bytes from bytes->ptr. */
wirespec_result_t wirespec_write_bytes(uint8_t *buf, size_t cap, size_t *pos,
                                       const wirespec_bytes_t *bytes);

All read/write functions return WIRESPEC_ERR_SHORT_BUFFER if the cursor/buffer does not have sufficient space.


Including Generated Headers

Generated .h files include wirespec_runtime.h via a relative path. Ensure the runtime directory is on the include path:

bash
gcc -I path/to/runtime -o my_app my_app.c quic_frames.c quic_varint.c

The runtime header is self-contained — no .c file is needed for the runtime itself.


Rust Backend

wirespec also supports a Rust backend. Pass -t rust to generate a .rs file instead of .h/.c:

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

Generated Rust code uses the wirespec-rt crate (located at runtime/rust/wirespec-rt/) which provides Cursor, Writer, and Error types. See the Rust API reference for details.