A frozen spec is only meaningful if it's paired with the corpus that was current at release time — otherwise a v0.1.0 reader follows "see corpus/payloads/ack/" and lands on a file whose test vectors may have changed since v0.1.0 was cut. Three changes: 1. `cut-version` now copies BOTH src/ AND corpus/ into the snapshot directory. A release is the pair, not just the text. 2. The corpus URL rewriting in cut-version now retargets /tree/main/corpus -> /tree/main/versions/<v>/corpus (and the blob variant). This lets the link resolve on GitHub from main alone — no git tag required, since the frozen copy is a real path under main. Downgrades the "tag is REQUIRED" message from last commit back to "optionally tag the commit". 3. The frozen corpus's spec_ref fields get rewritten too: from `src/04-payload-ack.md` to `versions/<v>/04-payload-ack.md`, so a reader following a test vector lands on the spec text the vector was paired with at release time. versions/v0.1.0/ regenerated from scratch via `just cut-version v0.1.0` to pick up the corpus snapshot + the new sed patterns. The release is now self-consistent: v0.1.0 spec links go to v0.1.0 corpus, which links back to v0.1.0 spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
5.7 KiB
Markdown
141 lines
5.7 KiB
Markdown
# MeshCore Protocol Specification
|
||
|
||
## Section 1: Wire Format
|
||
|
||
### Overview
|
||
|
||
A MeshCore packet is the fundamental transmission unit. It consists of a 1-byte
|
||
header, an optional 4-byte transport codes field, a 1-byte path length, a
|
||
variable-length path, and a variable-length payload. The maximum packet size on
|
||
the wire is 255 bytes (MAX_TRANS_UNIT).
|
||
|
||
### Packet Layout
|
||
|
||
```
|
||
0 1 2
|
||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 ...
|
||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||
| Header | Transport Codes (opt, 4B) |
|
||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||
| Path Length | Path (variable) |
|
||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||
| Payload (variable) |
|
||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||
```
|
||
|
||
The fields appear in this order on the wire:
|
||
|
||
```
|
||
[header(1)][transport_codes(4)?][path_len(1)][path(0-64)][payload(1-184)]
|
||
```
|
||
|
||
### Field Summary
|
||
|
||
| Field | Size | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| Header | 1 byte | Yes | Route type, payload type, protocol version (see [Section 2](02-header.md)) |
|
||
| Transport Codes | 4 bytes | Conditional | Two uint16_le values; present only when route type is `TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03) |
|
||
| Path Length | 1 byte | Yes | Encoded hash count and hash size (see [Section 3](03-path.md)) |
|
||
| Path | 0-64 bytes | Yes | Sequence of node hashes; length = hash_count × hash_size |
|
||
| Payload | 1-184 bytes | Yes | Packet data; structure depends on payload type |
|
||
|
||
### Transport Codes
|
||
|
||
Transport codes are present if and only if the route type (header bits 0-1) is
|
||
`TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03).
|
||
|
||
When present, transport codes consist of two little-endian 16-bit unsigned
|
||
integers, for a total of 4 bytes:
|
||
|
||
```
|
||
0 1 2 3
|
||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||
| Code 1 (uint16_le) | Code 2 (uint16_le) |
|
||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||
```
|
||
|
||
- **Code 1**: Calculated from regional scope parameters
|
||
- **Code 2**: Reserved (typically 0)
|
||
|
||
When transport codes are not present (route types `FLOOD` and `DIRECT`), the
|
||
implementation MUST set both transport code values to zero internally.
|
||
|
||
### Encoding Algorithm
|
||
|
||
To encode a packet to wire format:
|
||
|
||
1. Write the header byte.
|
||
2. If the route type is `TRANSPORT_FLOOD` or `TRANSPORT_DIRECT`:
|
||
a. Write transport_codes[0] as uint16_le (2 bytes).
|
||
b. Write transport_codes[1] as uint16_le (2 bytes).
|
||
3. Write the path_len byte.
|
||
4. Calculate path byte length as `hash_count × hash_size` (see [Section 3](03-path.md)).
|
||
5. Write `path_byte_length` bytes from the path array.
|
||
6. Write `payload_len` bytes from the payload array.
|
||
|
||
The total wire length is:
|
||
```
|
||
1 + (hasTransportCodes ? 4 : 0) + 1 + path_byte_length + payload_len
|
||
```
|
||
|
||
This MUST NOT exceed MAX_TRANS_UNIT (255) bytes.
|
||
|
||
### Decoding Algorithm
|
||
|
||
To decode a packet from wire format:
|
||
|
||
1. Read 1 byte as the header. Extract route type from bits 0-1.
|
||
2. If route type is `TRANSPORT_FLOOD` (0x00) or `TRANSPORT_DIRECT` (0x03):
|
||
a. Read 2 bytes as transport_codes[0] (uint16_le).
|
||
b. Read 2 bytes as transport_codes[1] (uint16_le).
|
||
Otherwise, set both transport codes to zero.
|
||
3. Read 1 byte as path_len.
|
||
4. Validate path_len (see [Section 3](03-path.md)):
|
||
a. Extract hash_size = (path_len >> 6) + 1. If hash_size is 4, the packet
|
||
is INVALID.
|
||
b. Extract hash_count = path_len & 63.
|
||
c. If hash_count × hash_size > MAX_PATH_SIZE (64), the packet is INVALID.
|
||
5. Calculate path_byte_length = hash_count × hash_size.
|
||
6. Read path_byte_length bytes as the path.
|
||
7. Let `i` be the current read position. If `i >= total_length`, the packet is
|
||
INVALID (payload MUST contain at least 1 byte).
|
||
8. Calculate payload_len = total_length - i.
|
||
9. If payload_len > MAX_PACKET_PAYLOAD (184), the packet is INVALID.
|
||
10. Read payload_len bytes as the payload.
|
||
|
||
### Size Constraints
|
||
|
||
| Constraint | Value | Enforcement |
|
||
|-----------|-------|-------------|
|
||
| Maximum wire length | 255 bytes | Encoder MUST NOT produce packets exceeding this |
|
||
| Maximum payload | 184 bytes | Decoder MUST reject payloads exceeding this |
|
||
| Maximum path | 64 bytes | Decoder MUST reject paths exceeding this |
|
||
| Minimum payload | 1 byte | Decoder MUST reject packets with zero-length payload |
|
||
| Minimum packet | 3 bytes | Header (1) + path_len (1) + payload (1 minimum) |
|
||
|
||
### Error Conditions
|
||
|
||
A conforming decoder MUST reject packets with any of the following:
|
||
|
||
1. Total wire length less than 3 bytes (no room for header + path_len + payload)
|
||
2. Total wire length less than minimum required for the route type:
|
||
- 3 bytes for FLOOD or DIRECT
|
||
- 7 bytes for TRANSPORT_FLOOD or TRANSPORT_DIRECT (adds 4 transport code bytes)
|
||
3. Invalid path_len encoding (see [Section 3](03-path.md))
|
||
4. Path byte length exceeds remaining bytes
|
||
5. Zero-length payload after header, transport codes, path_len, and path
|
||
6. Payload length exceeding MAX_PACKET_PAYLOAD (184)
|
||
|
||
### Cross-References
|
||
|
||
- [Section 2: Header](02-header.md) — Header byte encoding details
|
||
- [Section 3: Path](03-path.md) — Path length encoding and path field
|
||
- Test vectors: [`corpus/wire-format/framing/`](https://github.com/swaits/meshcore-spec/tree/main/versions/v0.1.0/corpus/wire-format/framing/), [`corpus/wire-format/invalid/`](https://github.com/swaits/meshcore-spec/tree/main/versions/v0.1.0/corpus/wire-format/invalid/)
|
||
|
||
### Reference Implementation
|
||
|
||
- `Packet::writeTo()` in `src/Packet.cpp` — Encoding
|
||
- `Packet::readFrom()` in `src/Packet.cpp` — Decoding
|
||
- `Packet::getRawLength()` in `src/Packet.cpp` — Wire length calculation
|