Encode and Decode

Two styles — pick whichever fits:

fixed() struct (fill every field at once — compile error if a field is missing):

#![allow(unused)]
fn main() {
pub fn demo_fixed_heartbeat() -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    // Const length → stack array (no heap).
    let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
    let nanos: i64 = 1_720_000_000_000_000_000;
    // Buffer pre-sized via const compute_length_with_header; try_* still validates extent.
    let written = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)
        .unwrap()
        .fixed(&HeartbeatFixedFields {
            sequence: 7,
            timestamp: nanos as u64,
        })
        .encoded_length_with_header();

    let dec = HeartbeatDecoder::try_decode(&buf[..written], 0)?;
    assert_eq!(dec.sequence(), 7);
    let decoded_ts: DateTime<Utc> = dec.try_timestamp()?;
    assert_eq!(decoded_ts.timestamp_nanos_opt(), Some(nanos));
    Ok(buf[..written].to_vec())
}
}

(This code comes from the sbe-feature-tour sample crate.)

{Msg}FixedFields has no Default. Every required scalar must appear in the struct literal (optional fields use Option only when schema presence="optional"). That is intentional: zero-filling required IDs hides bugs. For large messages, build the literal next to the encode call in wire order.

On a fixed-only message (no groups or var-data), as_bytes_with_header, as_body_bytes, encoded_length*, and into_remaining_mut exist only after fixed(&FixedFields). Calling them on the value returned by wrap* is a type error — that is what stops a reused buffer from publishing leftover body bytes or packing the next message over a stale body.

Individual setters stay on the unfixed encoder after wrap* and also on raw_fixed() (body-relative offsets). Prefer fixed(&FixedFields) when you have every field; use raw_fixed() when you want a dedicated writer:

#![allow(unused)]
fn main() {
// Dedicated raw writer (setters also exist on the unfixed encoder).
// `as_bytes_with_header` is only on FieldsFixed after `fixed(&FixedFields)`.
let mut buf = [0u8; HeartbeatEncoder::compute_length_with_header()];
let mut w = HeartbeatEncoder::try_wrap_and_apply_header(&mut buf, 0)?
    .raw_fixed();
w.sequence(7);
w.timestamp_wire(0);
let dec = HeartbeatDecoder::try_from(&buf[..HeartbeatEncoder::ENCODED_LENGTH])?;
assert_eq!(dec.sequence(), 7);
}

(From book/examples/heartbeat-encode.rs — compiled against the feature-tour codec.)

raw_fixed() writes into the buffer you already sized. It does not mark the message complete: omitted required setters leave stale bytes, and as_bytes_with_header / encoded_length* / into_remaining_mut stay locked. Set every required field, then call fixed(&FixedFields) for the complete-message views. Slicing &buf[..ENCODED_LENGTH] yourself is possible but is not the completeness-checked path.

Optional fields and apply_nulls

try_wrap_and_apply_header / wrap_and_apply_header write the message header only — they do not fill optional fixed fields with schema null sentinels (sbe-tool parity). Unwritten optional bytes retain whatever was already in the buffer (often zero, sometimes stale).

fixed() closes that gap for you. Optional fields are Option<T> in the generated FixedFields struct, and fixed() writes the schema null wire image for every None — including fixed arrays and nested optional composite members. Since fixed() is the only route to a message's tails and to fixed-only complete byte views, the ordinary path never leaves a stale optional behind:

// `price` is optional; None writes the schema null image, not stale bytes.
// (Illustrative — use your schema's message name and optional field.)
let len = OrderEncoder::wrap_and_apply_header(&mut buf, 0)
    .fixed(&OrderFixedFields { symbol: *b"IBM     ", price: None })
    .encoded_length_with_header();

apply_nulls() remains on the unfixed encoder after wrap*, for the case where you set individual optional fields yourself and no FixedFields value describes which optionals are unset.

See Why NullVal Instead of Option.

Character arrays: fixed-width char fields become [u8; N]. Pass a shorter &str via the _str setter — auto-padded with NULs. On decode, copy_* copies the raw bytes into your buffer, or read the slice with vehicle_code():

#![allow(unused)]
fn main() {
let complete_len = CarEncoder::compute_length()
    .fuel_figures_ragged(0, |_| Ok(()))?
    .performance_figures_ragged(0, |_| Ok(()))?
    .manufacturer(5)?
    .model(5)?
    .activation_code(3)?
    .encoded_length_with_header();
const PAD: usize = 256;
assert!(complete_len <= PAD, "car length {complete_len} exceeds pad {PAD}");
let mut storage = [0u8; PAD];
let buf = &mut storage[..complete_len];
let fields = CarFixedFields {
    serial_number: 1234,
    model_year: 2013,
    available: true.into(),
    code: Model::A,
    some_numbers: [10, 20, 30, 40],
    vehicle_code: *b"ABCDEF",
    extras: OptionalExtras::default(),
    engine: Engine::new(2000, 4, *b"123", 0i8, false.into(), Booster::new(BoostType::TURBO, 210)),
};
let n = CarEncoder::try_wrap_and_apply_header(buf, 0)?
    .fixed(&fields)
    .fuel_figures(0, |_| Ok(()))?
    .performance_figures(0, |_| Ok(()))?
    .manufacturer(b"Honda")?
    .model(b"Civic")?
    .activation_code(b"abc")?
    .encoded_length_with_header();
assert_eq!(n, complete_len);

let car = CarDecoder::try_from(&buf[..n])?;
let mut dst = [0u8; 6];
assert_eq!(car.copy_vehicle_code(&mut dst), 6);
assert_eq!(&dst, b"ABCDEF");
assert_eq!(car.vehicle_code(), *b"ABCDEF");
}

(From book/examples/fixed-char-arrays.rs — compiled against the feature-tour codec.)

The Car encoder's fixed fields include vehicle_code: [u8; 6] (schema char array) and some_numbers: [u32; 4]. See the feature tour for the complete Car example with groups and var-data.

Start here for a full runnable map of features: sbe-feature-tour (cargo run --manifest-path samples/sbe-feature-tour/Cargo.toml).
More recipes: Recipes.