---
title: Message Definitions
description: Define messages in Protocol Buffer .proto files for cross-language serialization code generation.
---

Messages are defined in Protocol Buffer (.proto) files. Struct Frame uses these definitions to generate serialization code for each target language.

## Why Proto Files

Proto files provide:
- Language-neutral message definitions
- Type safety across language boundaries
- Familiar syntax for developers who know Protocol Buffers
- Tooling support (syntax highlighting, linting)

Struct Frame uses proto syntax but generates different code than Google's Protocol Buffers. Messages are fixed-size packed structs, not variable-length encoded.

## Packages

Packages group related messages and prevent name collisions:

```proto
package sensor_system;

message SensorReading {
  option msgid = 1;
  float value = 1;
}
```

Generated code uses the package name as a prefix or namespace depending on language.

## Messages

Messages define the structure of data to be serialized:

```proto
message DeviceStatus {
  option msgid = 1;
  uint32 device_id = 1;
  float battery = 2;
  bool online = 3;
}
```

### Message Options

**msgid** (required for top-level messages)

```proto
message Heartbeat {
  option msgid = 42;
  uint64 timestamp = 1;
}
```

Message IDs must be unique within a package (range 0-255).

**variable** (optional, enables variable-length encoding)

```proto
message SensorData {
  option msgid = 1;
  option variable = true;  // Encode only used bytes
  repeated uint8 readings = 1 [max_size=100];
}
```

With variable encoding, arrays and strings only transmit actual used bytes instead of the full max_size. This reduces bandwidth when fields are partially filled.

You can also enable variable-length encoding for **every** message in the project at code-generation time by passing the `--no_packed` CLI flag. This is equivalent to setting `option variable = true;` on every message and additionally drops the packed-struct (`#pragma pack(1)`) wrappers in the generated C/C++ output. Use it on platforms that don't support packed structs or that have different endianness/alignment requirements. See the [CLI reference](/reference/cli-reference/) for details.

**pkgid** (optional package-level option)

```proto
package sensors;
option pkgid = 5;
```

Enables extended message addressing with 16-bit message IDs (256 packages × 256 messages = 65,536 total).

## Data Types

| Type | Size | Description |
|------|------|-------------|
| int8 | 1 byte | Signed -128 to 127 |
| uint8 | 1 byte | Unsigned 0 to 255 |
| int16 | 2 bytes | Signed -32768 to 32767 |
| uint16 | 2 bytes | Unsigned 0 to 65535 |
| int32 | 4 bytes | Signed integer |
| uint32 | 4 bytes | Unsigned integer |
| int64 | 8 bytes | Signed large integer |
| uint64 | 8 bytes | Unsigned large integer |
| float | 4 bytes | IEEE 754 single precision |
| double | 8 bytes | IEEE 754 double precision |
| bool | 1 byte | true or false |

All types use little-endian byte order.

## Strings

Strings require a size specification.

**Fixed-size string**

```proto
string device_name = 1 [size=16];
```

Always uses 16 bytes, padded with nulls if shorter.

**Variable-size string**

```proto
string description = 1 [max_size=256];
```

Stores up to 256 characters plus a 1-byte length prefix.

## Arrays

All repeated fields must specify a size. Arrays can contain primitive types, enums, strings, or nested messages.

**Fixed arrays**

```proto
repeated float matrix = 1 [size=9];  // Always 9 floats (3x3 matrix)
```

**Bounded arrays (variable count)**

```proto
repeated int32 readings = 1 [max_size=100];  // 0-100 integers
```

Includes a count prefix before the array data. Arrays with `max_size` ≤ 255 use a 1-byte (`uint8`) count; arrays with `max_size` > 255 use a 2-byte (`uint16`) count.

**String arrays**

```proto
repeated string names = 1 [max_size=10, element_size=32];
```

Array of up to 10 strings, each up to 32 characters.

**Arrays of nested messages**

```proto
message Waypoint {
  double lat = 1;
  double lon = 2;
}

message Route {
  option msgid = 5;
  string name = 1 [size=32];
  repeated Waypoint waypoints = 2 [max_size=20];
}
```

Array of up to 20 waypoint messages. Each Waypoint is embedded inline.

## Enums

```proto
enum SensorType {
  TEMPERATURE = 0;
  HUMIDITY = 1;
  PRESSURE = 2;
}

message SensorReading {
  option msgid = 1;
  SensorType type = 1;
  float value = 2;
}
```

Enums are stored as uint8 (1 byte).

### Enum to String Conversion

Enums can be converted to strings using language built-in features or generated helper functions.

**C** (generated helper function)
```c
SerializationTestSensorType type = SENSOR_TYPE_TEMPERATURE;
const char* type_str = SerializationTestSensorType_to_string(type);
// Returns: "TEMPERATURE"
```

**C++** (generated helper function)
```cpp
SerializationTestSensorType type = SerializationTestSensorType::TEMPERATURE;
const char* type_str = SerializationTestSensorType_to_string(type);
// Returns: "TEMPERATURE"
```

**Python** (built-in Enum)
```python
from sensor_system import SerializationTestSensorType

type = SerializationTestSensorType.TEMPERATURE
# Use the built-in .name property
type_str = type.name
# Returns: "TEMPERATURE"
```

**TypeScript** (built-in reverse mapping)
```typescript
import { SerializationTestSensorType } from './sensor_system.structframe';

const type = SerializationTestSensorType.TEMPERATURE;
// TypeScript numeric enums support reverse mapping
const typeStr = SerializationTestSensorType[type];
// Returns: "TEMPERATURE"
```

**JavaScript** (Object.keys lookup)
```javascript
const { SerializationTestSensorType } = require('./sensor_system.structframe');

const type = SerializationTestSensorType.TEMPERATURE;
// Find the key by value
const typeStr = Object.keys(SerializationTestSensorType).find(
  key => SerializationTestSensorType[key] === type
);
// Returns: "TEMPERATURE"
```

**C#** (built-in ToString)
```csharp
using StructFrame.SensorSystem;

SerializationTestSensorType type = SerializationTestSensorType.TEMPERATURE;
// Use the built-in enum ToString method
string typeStr = type.ToString();
// Returns: "TEMPERATURE"
```

## Nested Enums

Enums can be defined inside a message body, scoping them to that message. This keeps the type closely associated with the message that uses it.

```proto
message MotorCommand {
  option msgid = 42;

  enum Direction {
    FORWARD = 0;
    REVERSE = 1;
    BRAKE = 2;
  }

  Direction dir = 1;
  uint16 speed_rpm = 2;
}
```

Enums are stored as `uint8` regardless of where they are defined.


## Nested Messages

```proto
message Position {
  double lat = 1;
  double lon = 2;
}

message Vehicle {
  option msgid = 1;
  uint32 id = 1;
  Position pos = 2;
}
```

Nested messages are embedded inline.

## Import Statements

Import proto definitions from other files:

```proto
// types.proto
package common;

message Position {
  double lat = 1;
  double lon = 2;
}
```

```proto
// vehicle.proto
import "types.proto";

package fleet;

message Vehicle {
  option msgid = 1;
  uint32 id = 1;
  common.Position pos = 2;
}
```

## Flatten Option

Flatten nested message fields into the parent (Python/GraphQL only):

```proto
message Status {
  Position pos = 1 [flatten=true];
  float battery = 2;
}
```

Access: `status.lat` instead of `status.pos.lat`

## Oneof (Union Types)

The `oneof` construct creates a union type where only one of the fields can be populated at a time. All fields within the `oneof` share the same memory, with the total size equal to the largest field.

### Basic Oneof

```proto
message SensorReading {
  option msgid = 10;
  uint32 timestamp = 1;
  
  oneof value {
    float temperature = 1;
    int32 pressure = 2;
    uint16 humidity = 3;
  }
}
```

**Memory Layout:**
- The `value` union occupies 4 bytes (size of the largest type: `float` or `int32`)
- Only one field (`temperature`, `pressure`, or `humidity`) should be set at a time
- A `uint8` field order discriminator is automatically generated (stores 1, 2, or 3 based on which field is active)

### Oneof with Message Types

When all fields in a `oneof` are messages with `msgid`, an auto-discriminator using message IDs is generated:

```proto
message CommandA {
  option msgid = 100;
  uint8 param1 = 1;
}

message CommandB {
  option msgid = 101;
  uint16 param1 = 1;
  uint16 param2 = 2;
}

message CommandWrapper {
  option msgid = 200;
  uint32 sequence = 1;
  
  oneof command {
    CommandA cmd_a = 1;
    CommandB cmd_b = 2;
  }
}
```

**Memory Layout:**
- `command_discriminator` (uint16_t) - auto-generated, stores the message ID of the active field
- `command` union - size of the largest message type

### Discriminator Option

You can control discriminator behavior with the `discriminator` option inside a `oneof`:

```proto
oneof payload {
  option discriminator = field_order;  // or: msgid, none, auto
  CmdA cmd_a = 1;
  CmdB cmd_b = 2;
}
```

> **Field numbers in oneof:** variants are numbered `1..M` independently within the `oneof` — the same 1-based sequential rule that applies to message-level fields.

| Option | Discriminator Type | Size | Description |
|--------|-------------------|------|-------------|
| `auto` (default) | Depends on fields | varies | Uses `msgid` if all fields are messages with msgid, otherwise `field_order` |
| `msgid` | Message ID | uint16 (2 bytes) | Forces use of message IDs. **Error** if any field lacks msgid |
| `field_order` | Field order (1-based) | uint8 (1 byte) | Uses declaration order: 1st field = 1, 2nd = 2, etc. |
| `none` | No discriminator | 0 bytes | Disables discriminator. Application must track active field |

### Default Auto-Discriminator Behavior

| Oneof Field Types | Default Discriminator | Size |
|-------------------|----------------------|------|
| All messages with `msgid` | msgid (uint16) | 2 bytes |
| Mixed or all primitives/enums | field_order (uint8) | 1 byte |

**Examples:**

```proto
// Auto: Uses msgid discriminator (uint16) - all fields have msgid
oneof command {
  CommandA cmd_a = 1;  // msgid = 100
  CommandB cmd_b = 2;  // msgid = 101
}

// Auto: Uses field_order discriminator (uint8) - primitives don't have msgid
oneof value {
  float temperature = 1;  // discriminator = 1
  int32 pressure = 2;     // discriminator = 2
}

// Explicit: Force field_order even with messages that have msgid
oneof payload {
  option discriminator = field_order;
  CommandA cmd_a = 1;  // discriminator = 1
  CommandB cmd_b = 2;  // discriminator = 2
}

// Explicit: Disable discriminator entirely
oneof data {
  option discriminator = none;
  uint32 integer_val = 1;
  float float_val = 2;
}
```

### Using Discriminators

**With msgid discriminator:**
```cpp
// C++ - check which message is active by message ID
if (wrapper.command_discriminator == CommandA::MSG_ID) {
    // Access wrapper.command.cmd_a
}
```

**With field_order discriminator:**

For `field_order` discriminators, a typed enum is generated named `{MessageName}{OneofName}Field`:

```cpp
// C++ - use the generated enum for type-safe discriminator checks
if (sensor.value_discriminator == SensorValueField::TEMPERATURE) {
    // temperature is active
} else if (sensor.value_discriminator == SensorValueField::PRESSURE) {
    // pressure is active
}
```

### Multiple Oneofs

A message can contain multiple `oneof` fields:

```proto
message MultiUnionMessage {
  option msgid = 50;
  
  oneof input {
    float analog_value = 1;
    bool digital_value = 2;
  }
  
  oneof output {
    uint16 pwm_duty = 3;
    bool relay_state = 4;
  }
}
```

Each `oneof` is independent and contributes its own union size to the message.

## Variable Oneofs

When a parent message has `option variable = true;`, any `oneof` with a discriminator is serialized in **trimmed** mode by default: only the active variant's bytes are transmitted. For forward-compatible protocols, the oneof can additionally be marked `option variable = true;` to include a length prefix.

> **Requirement:** The parent message must have `option variable = true;` for either encoding mode to take effect.

### Trimmed Mode (Default for Variable Messages)

A discriminated `oneof` inside a variable message transmits only the active variant's bytes. The receiver derives the size from the discriminator value.

**Wire format:**
```
[discriminator: 1–2 bytes] [variant_bytes: active variant size]
```

```proto
message SensorCommand {
  option msgid = 30;
  option variable = true;

  uint8 header = 1;

  oneof data {
    option discriminator = "field_order";
    // No 'option variable = true' — trimmed mode (default)
    SmallPayload small = 1;   // 3 bytes on the wire when active
    LargePayload large = 2;   // 32 bytes on the wire when active
  }
}
```

### Variable Mode (Length-Prefixed)

Add `option variable = true;` to the oneof for a fully forward-compatible format. A `uint16 LE` length field is inserted before the variant bytes, allowing receivers to skip unknown variants without error.

**Wire format:**
```
[discriminator: 1–2 bytes] [payload_length: uint16 LE] [variant_bytes: payload_length bytes]
```

```proto
message CommandMessage {
  option msgid = 20;
  option variable = true;

  uint8 header = 1;

  oneof data {
    option discriminator = "field_order";
    option variable = true;   // Add length prefix for forward compatibility
    SmallPayload small = 1;   // 3 bytes on the wire
    LargePayload large = 2;   // 32 bytes on the wire
  }
}
```

### Oneof Size Controls

#### `option max_size = N;` — Reserve Space for Future Variants

**Valid for non-variable (trimmed) oneofs only.**

Increases the in-memory union buffer to at least `N` bytes without changing the wire size of existing variants. Use this when you plan to add larger variants in future schema versions.

```proto
oneof data {
  option discriminator = "field_order";
  // Trimmed mode — no 'option variable = true'
  option max_size = 64;   // Reserve 64 bytes in struct; future variants up to 64 bytes are safe
  SmallPayload small = 1; // Still 3 bytes on the wire
}
```

`max_size` is not applicable to variable oneofs (`option variable = true`), because variable encoding transmits each variant at its own size regardless of the in-memory union buffer.

#### `option min_size = N;` — Guarantee a Minimum Wire Payload Size

**Valid when the parent message is `variable = true`, and either:**
- **the oneof has `option variable = true;`**, or
- **the oneof is the only variable-length field in the message** (trimmed oneof as the trailing variable-size region).

Pads the serialized payload to at least `N` bytes. Use this when a receiver compiled against an older schema expects a fixed-length payload.

**On a variable oneof** — padding is inside the length prefix; the receiver reads `payload_length` and ignores trailing zeros:
```proto
oneof data {
  option discriminator = "field_order";
  option variable = true;
  option min_size = 8;    // Length prefix will report at least 8
  SmallPayload small = 1; // 3 bytes → wire sends [disc][0x08 0x00][3 bytes + 5 zero bytes]
  LargePayload large = 2; // 8 bytes → wire sends [disc][0x08 0x00][8 bytes]
}
```

**On a trimmed oneof** — padding extends the trimmed payload; the receiver derives the length from the known variant size:
```proto
oneof data {
  option discriminator = "field_order";
  // Trimmed mode
  option min_size = 8;    // Trimmed wire payload is always at least 8 bytes
  SmallPayload small = 1; // 3 bytes → padded to 8 bytes on the wire
  LargePayload large = 2; // 8 bytes → transmitted as-is
}
```

**Rules:**
- `min_size` must be ≤ the oneof's buffer size (`max(variant_sizes)`, or `max_size` if set). Use `max_size` to increase the buffer first if needed.
- Padding bytes are always zero.

### Option Summary

| Option | Applies to | Effect |
|--------|-----------|--------|
| `variable = true` | Any discriminated oneof in a variable message | Adds `uint16 LE` length prefix before variant bytes |
| `max_size = N` | Non-variable (trimmed) oneofs only | Sets minimum in-memory union buffer size |
| `min_size = N` | Variable oneofs, or trimmed oneof as the sole variable-length field in a variable message | Pads wire payload to at least N bytes |

## Envelope Messages (Oneof with is_envelope)

Envelope messages are container messages that wrap other messages for unified handling. They use the `is_envelope` option along with a `oneof` field to contain different message types.

### Why Envelope Messages?

Envelope messages are useful when:

- You have multiple command/response types but want a single message ID for routing
- You need envelope-level metadata (sequence numbers, timestamps, priorities)
- You want type-safe wrappers with SDK helper methods
- You're implementing a command/response protocol with multiple operation types

### Defining Envelope Messages

```proto
// Individual command messages (each must have a msgid)
message ADCCommand {
  option msgid = 112;
  uint8 channel = 1;
  uint16 sample_rate = 2;
  bool enable = 3;
}

message DACCommand {
  option msgid = 113;
  uint8 channel = 1;
  uint16 output_value = 2;
  bool enable = 3;
}

// Envelope message
message CommandEnvelope {
  option msgid = 200;
  option is_envelope = true;  // Enables envelope helper methods
  
  // Envelope-level fields (metadata)
  uint32 sequence_number = 1;
  uint8 priority = 2;
  bool run_immediately = 3;
  
  // Union of all command types - exactly one will be populated
  oneof command {
    ADCCommand adc = 1;
    DACCommand dac = 2;
  }
}
```

### Validation Rules

Envelope messages have these requirements:

- Must have `option is_envelope = true`
- Must have exactly one `oneof` field
- All types in the `oneof` must be message types (not primitives or enums)
- If using `msgid` discriminator (auto or explicit), all message types must have `msgid`

### Discriminator Behavior

Envelope messages use the same discriminator system as regular `oneof` fields (see [Discriminator Options](#discriminator-options) above), but with the following defaults:

| Discriminator Mode | When Used | Size | Value |
|-------------------|-----------|------|-------|
| `msgid` (default) | All inner messages have `msgid` | 2 bytes (uint16) | Message ID of wrapped payload |
| `field_order` | Any inner message lacks `msgid` | 1 byte (uint8) | 1-based field order index |

You can explicitly set the discriminator mode on the oneof:

```proto
message CommandEnvelope {
  option is_envelope = true;
  
  oneof command {
    option discriminator = "field_order";  // Force field_order even if all have msgid
    ADCCommand adc = 1;
    DACCommand dac = 2;
  }
}
```

**Wire Format:**
```
[envelope fields...] [discriminator (1-2 bytes)] [union payload (max field size)]
```

The discriminator is automatically set by the `wrap()` method and read during deserialization.

### Generated SDK Helper Methods

The SDK generates convenient helper methods for envelope messages:

**C++ (msgid discriminator)**
```cpp
// Wrap a command into an envelope - template ensures type safety
EnvelopeTestADCCommand adc_cmd{.channel = 1, .sample_rate = 1000, .enable = true};
auto envelope = EnvelopeTestCommandEnvelope::wrap(
    42,      // sequence_number
    1,       // priority
    true,    // run_immediately
    adc_cmd  // payload (type validated at compile-time via T::MSG_ID)
);

// Only valid payload types are accepted - invalid types cause compile error:
// auto bad = EnvelopeTestCommandEnvelope::wrap(42, 1, true, some_other_msg); // Won't compile!

// Get payload message ID to determine which type was wrapped
uint16_t payload_id = envelope.getPayloadMessageId();

// Access the wrapped payload directly via the oneof field
if (payload_id == EnvelopeTestADCCommand::MSG_ID) {
    auto& adc = envelope.command.adc;
    // Use adc.channel, adc.sample_rate, etc.
}
```

**C++ (field_order discriminator)**
```cpp
// Separate wrap() overloads for each payload type
auto envelope = MyEnvelope::wrap(42, 1, true, adc_cmd);  // Sets discriminator to 1
auto envelope2 = MyEnvelope::wrap(42, 1, true, dac_cmd); // Sets discriminator to 2

// Get field order (1-based) of the wrapped payload
uint8_t field_order = envelope.getPayloadFieldOrder();
```

**Python**
```python
# Wrap a command into an envelope
adc_cmd = EnvelopeTestADCCommand(channel=1, sample_rate=1000, enable=True)
envelope = EnvelopeTestCommandEnvelope.wrap(
    payload=adc_cmd,  # Runtime type check ensures valid payload
    sequence_number=42,
    priority=1,
    run_immediately=True
)

# Invalid payload types raise TypeError:
# envelope = EnvelopeTestCommandEnvelope.wrap(some_other_msg, ...)  # TypeError!

# Unwrap - returns the correct message type based on discriminator
payload = envelope.unwrap()
if payload:
    print(f"Got payload: {type(payload).__name__}")

# Get discriminator value (method name depends on discriminator type)
payload_id = envelope.get_payload_message_id()  # For msgid discriminator
# field_order = envelope.get_payload_field_order()  # For field_order discriminator
```

**TypeScript**
```typescript
// Wrap a command into an envelope - union type ensures type safety
const adcCmd = new EnvelopeTestADCCommand();
adcCmd.channel = 1;
adcCmd.sample_rate = 1000;
adcCmd.enable = true;

// payload type is: EnvelopeTestADCCommand | EnvelopeTestDACCommand | ...
const envelope = EnvelopeTestCommandEnvelope.wrap(adcCmd, 42, 1, true);

// Get payload message ID to determine which type was wrapped
const payloadId = envelope.getPayloadMessageId();
```

**C#**
```csharp
// Wrap a command into an envelope - overloads provide type safety
var adcCmd = new EnvelopeTestADCCommand { Channel = 1, SampleRate = 1000, Enable = true };
var envelope = EnvelopeTestCommandEnvelope.Wrap(adcCmd, 42, 1, true);

// Get payload message ID to determine which type was wrapped
var payloadId = envelope.GetPayloadMessageId();
```

## Hardcoded Magic Bytes

In normal operation the generator calculates two magic bytes for each message based on the types, positions, and sizes of its base fields. These bytes seed the checksum and act as a structural fingerprint for the message.

Use `option magic_bytes` to override this calculation with a fixed pair of values. This is useful when migrating an existing message across schema changes that would otherwise alter the magic bytes — for example, renaming an enum, moving an enum definition inline, or restructuring fields that are logically unchanged on the wire.

```proto
message JobStatusMessage {
  option msgid = 53;
  option magic_bytes = "0xA3, 0x7F";  // Preserve magic from original schema

  enum Status {
    JOB_START = 0;
    JOB_END   = 1;
    JOB_ABORT = 2;
    option extensions_start = 3;
    JOB_FAILED   = 3;
    JOB_END_WARN = 4;
  }

  Status status = 1;
  uint8  id       = 2;
  uint8  job_type = 3;
  double time     = 4;
}
```

**Rules:**
- Both values must be in the range `1–255` (zero bytes are rejected).
- Values can be written in hex (`0xAB`) or decimal (`171`).
- Exactly two comma-separated values are required.
- Changing the override value triggers regeneration (it is included in the generation hash).

> **When to use this:** Prefer keeping the calculated magic bytes — they serve as automatic structural validation. Only use `magic_bytes` when you intentionally need two schema revisions to be treated as wire-compatible by existing parsers, and you have verified that the wire layout is genuinely unchanged.

## Validation Rules

The generator enforces:

- Message IDs unique within package (0-255)
- Package IDs unique across packages (0-255)
- Field numbers must be `1..N` in declaration order (no gaps, no duplicates)
- All arrays must have size or max_size
- All strings must have size or max_size
- String arrays need both max_size and element_size
- Array max_size limited to 255 (count fits in 1 byte)
- Envelope messages must have exactly one oneof field
- Envelope oneof fields must be message types (not primitives/enums)
- Envelope oneof using `msgid` discriminator must have messages with msgid
- `extensions_start` must be ≥ 2 (at least one non-extension base field required)
- `extensions_start` must equal an existing field number in the same scope
- Message-level `extensions_start` cannot be combined with `oneof` fields — the wire serializes fields before oneofs, so extension fields would not be trailing. Use `option extensions_start` *inside* the `oneof` to add extension variants instead

## Wire Evolution (Extension Fields)

`option extensions_start = N;` allows a message (or a `oneof` inside a message) to be extended with new fields in the future without breaking older receivers.

### How it works

- Fields with number `< extensions_start` are **base fields** — always present on the wire, always covered by the magic-byte checksum seed.
- Fields with number `>= extensions_start` are **extension fields** — newer senders include them; older receivers that received a shorter payload zero-fill them on reception.
- The magic bytes (CRC seed) are computed only from base fields. Adding extension fields never changes the magic bytes, so older parsers still validate the base portion correctly.
- Extension bytes are still mixed into the full CRC *after* the magic seed, so corruption of extension data is detected.

> **Profile requirement:** Truncated/extended payloads can only be detected when the frame carries a length field. Use a length-bearing profile (Standard, Bulk, or Network). On profiles without a length field (Sensor, IPC) extension fields are treated as ordinary fixed bytes — both sender and receiver must agree on the full message size.

### Message-level extensions

Declare `option extensions_start = N;` anywhere in the message body, then list extension fields with numbers `>= N`. Messages containing a `oneof` cannot use message-level extension fields (the wire serializes fields before oneofs, so the extension bytes would not be trailing) — use [oneof-level extensions](#oneof-level-extensions-extension-variants) instead:

```proto
message StatusReport {
  option msgid = 10;

  // Base fields (always on the wire)
  uint32 device_id    = 1;
  uint8  status_code  = 2;
  uint16 error_flags  = 3;

  // Extension fields — present in v2+ firmware; older receivers zero-fill them
  option extensions_start = 4;
  uint32 uptime_seconds    = 4;
  float  cpu_temperature   = 5;
  string firmware_version  = 6 [max_size=32];
}
```

**What changes on the wire:**

| Sender | Receiver | Result |
|--------|----------|--------|
| v1 (base only) | v1 (base only) | ✓ Normal fixed-size frame |
| v2 (base + ext) | v1 (base only) | ✓ Receiver ignores extra trailing bytes |
| v1 (base only) | v2 (base + ext) | ✓ Receiver zero-fills extension fields |
| v2 (base + ext) | v2 (base + ext) | ✓ Full round-trip |

### Oneof-level extensions (extension variants)

A `oneof` block can also have extension variants. Older receivers that do not know about the new discriminator value simply ignore the unknown variant.

```proto
message AdcCommand {
  option msgid = 100;
  uint8 channel = 1;
}

message DacCommand {
  option msgid = 101;
  uint8 channel = 1;
}

// Added in v2
message PwmCommand {
  option msgid = 102;
  uint8 channel = 1;
  uint16 duty_cycle = 2;
}

// Added in v3
message I2cCommand {
  option msgid = 103;
  uint8 address = 1;
  uint8 reg = 2;
}

message JobMessage {
  option msgid = 200;

  bool run_immediately = 1;

  oneof job {
    option discriminator = "field_order";

    // Base variants — understood by all receiver versions
    AdcCommand adc = 1;
    DacCommand dac = 2;

    // Extension variants — receivers that do not know these simply report
    // an unknown discriminator and skip the payload.
    option extensions_start = 3;
    PwmCommand pwm = 3;
    I2cCommand i2c = 4;
  }
}
```

`extensions_start` inside a `oneof` works identically to the message-level option:

- **base_size** of the message uses the largest *base* variant size, not the largest extension variant size.
- Magic bytes are computed from base variants only.
- The discriminator field itself is always a base field (present in every frame).

### `BASE_SIZE` constant

The generator emits a `BASE_SIZE` constant alongside `MAX_SIZE` for every message. For messages without extensions `BASE_SIZE == MAX_SIZE`. You can use `BASE_SIZE` in your application to compute how many bytes a legacy sender would transmit:

**C**
```c
// WIRE_EVOLUTION_STATUS_REPORT_BASE_SIZE — non-extension payload bytes
// WIRE_EVOLUTION_STATUS_REPORT_MAX_SIZE  — full payload with all extensions
```

**Python**
```python
from struct_frame.generated.my_package import StatusReport

print(StatusReport.BASE_SIZE)  # 7  (device_id + status_code + error_flags)
print(StatusReport.MAX_SIZE)   # 43 (+ uptime_seconds + cpu_temperature + firmware_version)
```

### Complete wire-evolution example

```proto
package device_control;

// v1 command — base fields only
// v2 adds extension fields; v1 receivers ignore them gracefully
message DeviceCommand {
  option msgid = 1;

  // Base fields: always present, define the magic bytes
  uint8  device_id  = 1;
  uint8  command    = 2;
  uint16 param      = 3;

  // Extension fields: v2+ only; v1 receivers zero-fill on reception
  option extensions_start = 4;
  uint32 timeout_ms        = 4;
  uint8  retry_count       = 5;
}

// Envelope that adds new job types without breaking v1 dispatchers
message JobEnvelope {
  option msgid = 50;
  option is_envelope = true;

  uint32 sequence = 1;

  oneof job {
    // v1 job types
    DeviceCommand device_cmd = 1;

    // v2 job types — older dispatchers see an unknown discriminator
    option extensions_start = 2;
    DeviceCommand maintenance_cmd = 2;
  }
}
```

## Complete Example

```proto
package robot_control;

enum RobotState {
  IDLE = 0;
  MOVING = 1;
  ERROR = 2;
}

message Position {
  double lat = 1;
  double lon = 2;
  float altitude = 3;
}

message RobotStatus {
  option msgid = 1;
  
  uint32 robot_id = 1;
  string name = 2 [size=16];
  RobotState state = 3;
  Position current_pos = 4;
  float battery_percent = 5;
  repeated float joint_angles = 6 [size=6];
  string error_msg = 7 [max_size=128];
}
```

