← RFC

Loomwave Protocol Specification — Packet Format

Stage 3 / Checkpoint C deliverable. Status: Draft normative spec. Date: 2026-06-09 (wire v2 correction 2026-07-02). Supersedes: the byte-level sketch in design-decision.md §7 — this document freezes it.

Wire-v2 hand-correction (2026-07-02, issue #105). Sections 3, 5, 8.1, and 10 were hand-corrected to match the shipped wire v2 (VER = 0b010) — the fleet runs v2, but this published spec previously documented v1 (0b001). The authoritative source is the Rust crate infra/crates/proto/src/lib.rs (pub const VERSION = 0b010) and its firmware mirror client/src/loomwave_proto.h (constexpr uint8_t VERSION = 0b010); the field widths and byte order below were transcribed from those encode/decode paths. This correction supersedes the older auto-generated mirror of this file (the generator pulls from a private repo that is no longer reachable, so the correction is applied by hand and will not be overwritten). Reliable messaging / transport (ENV_TEXT + ARQ + downlink RX-window timing) is specified in the companion reliable-messaging.md.

Normative language. The key words MUST, MUST NOT, REQUIRED, SHALL, SHOULD, SHOULD NOT, MAY, and OPTIONAL are to be interpreted as in RFC 2119/RFC 8174. Byte and bit numbering: byte 0 is first on the wire; within a byte, bit 7 is most-significant. Multi-byte integers are big-endian (network order) unless stated. This document defines the wire contract every Loomwave node MUST implement; MAC/routing/security behaviour is specified in the companion docs (mac-spec.md, routing-spec.md, security-spec.md, node-roles-spec.md), which reference the fields defined here.


1. Scope and design constraints

A Loomwave frame is carried in a single LoRa PHY payload. The format is engineered against three hard constraints established in Stage 1/2:

  1. 255-byte ceiling. The SX127x/SX126x FIFO bounds a frame at 255 bytes total. Every byte of header is a byte stolen from payload, so the common-case header is justified field-by-field (§4).
  2. Airtime is the scarce resource. Header bytes cost airtime at the same rate as payload (phy.py, Semtech ToA). The format therefore spends zero bytes on anything derivable from context (the AEAD nonce is computed, not transmitted — §7) and packs sub-byte fields.
  3. No clock-sync assumption for correctness. Fields that feed replay protection (SEQ, EPOCH) MUST be meaningful without GPS or wall-clock agreement (security-spec.md).

This document is the contract between independent implementations: a frame built per this spec MUST be decodable by any conformant node, and a node MUST reject frames that violate the structural rules in §9.

2. Frame taxonomy

Loomwave defines four frame types, distinguished by TYPE (§3, byte 0 bits [1:0] in control frames; data frames are TYPE=0):

TYPE Name Direction Plane Purpose
0 DATA any → any scheduled or contention user/application payload (the common case)
1 BEACON INFRA → all scheduled TDMA schedule + cell modulation descriptor + time anchor
2 REGISTER client → INFRA scheduled (unicast) slot/capability request, link reports
3 ADVERT any → all contention neighbour/route/identity advertisement

DATA is specified in full first (§4) because it is ~95% of frames and defines the shared header all types reuse (§3).

3. Common header (all frame types)

Every Loomwave frame begins with a 2-byte common header:

 byte 0   VER_FLAGS
          ┌───────────────────────────────────────────────────────────┐
          │ 7 6 5 │ 4 3 2 │ 1 │ 0 │                                     │
          │  VER  │  TC   │ P │ A │                                     │
          └───────────────────────────────────────────────────────────┘
            [7:5] VER  protocol version (this spec = 0b010 = v2; v1 = 0b001 is retired)
            [4:2] TC   traffic class (§5)
            [1]   P    plane: 0 = contention, 1 = scheduled
            [0]   A    ack_req: 1 = unicast ACK requested

 byte 1   HOPS / TYPE
          ┌───────────────────────────────────────────────────────────┐
          │ 7 6 5 4 │ 3 2 │ 1 0 │                                       │
          │ HOPLIM  │ HUSE│ TYPE│                                       │
          └───────────────────────────────────────────────────────────┘
            [7:4] HOPLIM  hop limit / TTL (0–15; decremented per relay)
            [3:2] HUSE    hops used (0–3, saturating; diagnostics + loop hint)
            [1:0] TYPE    0=DATA 1=BEACON 2=REGISTER 3=ADVERT

Rationale — why TYPE in the clear. The receiver must route BEACON/REGISTER without first running AEAD (a BEACON is what carries the keying epoch context). Exposing 2 bits of type leaks nothing an observer cannot infer from timing/length and saves a decrypt-then-discard on every control frame.

4. DATA frame (TYPE = 0) — the common case

 off  field            size  notes
 0    VER_FLAGS         1     common header (§3); TYPE=0
 1    HOPS              1     common header (§3)
 2    SRC_ADDR          3     24-bit truncated self-certifying address (routing-spec §)
 5    DST_ADDR          3     24-bit; 0x000000 = broadcast/channel
 8    SEQ               1     per-(src,epoch) sequence number; feeds replay + nonce (§7)
 9    PAYLOAD           0–239 AEAD ciphertext (application data; §6 plaintext layout)
 …    AEAD_TAG          8     truncated AEAD tag over header+payload; nonce derived (§7)

4.1 The header is authenticated, not encrypted

The 9-byte header is transmitted in the clear but is included as AEAD associated data (AAD), so any bit-flip in VER_FLAGS, HOPS, SRC, DST, or SEQ fails tag verification. PAYLOAD is the only encrypted region. Relays therefore forward on cleartext header fields (they need HOPS/DST to route) while end-to-end integrity still covers the whole frame. (security-spec.md §).

5. Traffic classes (TC, 3 bits)

TC is set by the originator and MUST be preserved unchanged by relays. It costs 0 airtime (packed in byte 0) and drives MAC scheduling priority (mac-spec.md) and backhaul admission (mac-spec.md §, the downlink-governance rule).

TC Name Use Scheduling intent
0 EMERGENCY life-safety / SOS highest; pre-emptive slot grant
1 CONTROL beacons-relayed, route/ACK control high; protocol must not starve
2 INTERACTIVE direct messages, acks of user msgs above bulk; latency-sensitive
3 TELEMETRY periodic position/metrics default for sensors
4 BULK large/low-priority, store-forward catch-up lowest local priority
5 BACKHAUL_INJECT traffic sourced from an IP/MQTT bridge onto RF capped + deprioritized (§)
6–7 reserved MUST NOT be emitted; receivers treat as TELEMETRY (TrafficClass::from_bits)

Rationale — a dedicated BACKHAUL_INJECT class. Per the Checkpoint-B decision (design-decision.md §5), backhaul→RF traffic is admission-controlled and budget-capped. Giving it an explicit class lets the INFRA scheduler cap and deprioritize it by inspection of the cleartext header — no decrypt required — which is what structurally prevents the Meshtastic downlink_enabled congestion failure. Reserving 2 values (6–7) leaves room without a version bump.

Traffic-class use in messaging. Reliable user messaging (reliable-messaging.md) rides DATA frames; delivery receipts/acks are emitted at TC = INTERACTIVE so they are not starved by BULK/TELEMETRY, and a coordinator sourcing traffic from an IP bridge onto RF uses BACKHAUL_INJECT (also the class it uses to re-inject a client→client downlink).

6. PAYLOAD plaintext layout (informative here, normative in app spec)

After AEAD decryption, PAYLOAD begins with a 1-byte PORT discriminator (application/port number, Meshtastic-compatible numbering where practical) followed by port-specific bytes. The MAC/routing layers are payload-agnostic; PORT is defined by the application layer (out of scope here, noted so implementers reserve byte 0 of plaintext). Max application bytes = 239 − 1 (PORT) = 238 B.

7. Nonce derivation — zero wire bytes

The AEAD nonce is never transmitted. Both ends compute:

nonce = TRUNC_12( H( SRC_ADDR ‖ SEQ ‖ EPOCH ‖ DIR ) )

where H is the KDF/hash named in security-spec.md, EPOCH is the current keying epoch (from the latest BEACON), DIR is a 1-bit direction tag (0 = from address-owner, 1 = toward) preventing two-party nonce reuse, and TRUNC_12 takes the low 12 bytes (96-bit nonce). Because SRC, SEQ, EPOCH are all either on the wire (SRC, SEQ) or shared state (EPOCH), the receiver reconstructs the nonce with 0 dedicated bytes. Reuse-safety (the SEQ-wrap / EPOCH-rotation rule) is normative in security-spec.md.

8. Control frames

8.1 BEACON (TYPE = 1) — INFRA → all, scheduled

The coordinator's periodic frame; carries the schedule, the cell's modulation descriptor, the time anchor, and the keying epoch. Variable length; fields after the common header (from proto::Beacon::encode/decode):

 off  field            size  notes
 2    SRC_ADDR          3     coordinator address
 5    EPOCH             4     monotonic keying/schedule epoch (nonce + replay anchor; big-endian)
 9    CELL_DESC         3     byte0 [7:3]=SF [2:0]=BW ; bytes 1-2 = freq_offset (u16 BE, channel plan)
 12   FRAME_T           2     u16 BE. low 14 bits = superframe length ms; bit15 = PHY-change flag;
                              bit14 = per-link mixed-SF flag (see below)
 14   [PHY_CHANGE]      4     PRESENT iff FRAME_T bit15 set: announced (SF,BW,freq,frames) — see §8.1.1
 F    SLOTMAP_LEN       1     N = number of telemetry slot entries that follow (F = 14, or 18 if PHY_CHANGE)
 F+1  SLOTMAP          N×3    per-entry: slot_owner_addr(24b BE); index = slot #; 0x000000 = open slot
 …    NEXT_PTR          1     next free/contention-window pointer
 …    N_GRANTS          1     count of on-demand message-slot grants this frame (#63); 0 = telemetry-only
 …    GRANTS         Ng×3    granted member addr24 (BE), in message-slot order
 …    PACT_BLOCK        var   PACT-lite membership block — v2 only (§8.1.2); empty block on ESP32 coord
 …    SIG              64     Ed25519 signature over all preceding bytes (full; security-spec §6 erratum)

8.1.1 Announced PHY-change block (PHY_CHANGE, 4 bytes — present iff FRAME_T bit 15)

The coordinator advertises an upcoming switch so members pre-tune instead of blind-rescanning (#65 DFS / #66 adaptive SF; announced-phy-change-design.md). It sits immediately after FRAME_T, shifting SLOTMAP_LEN and everything after it by 4 bytes:

 off  field                size  notes
 +0   TARGET_SF/BW          1     [7:3]=target SF  [2:0]=target BW code (same packing as CELL_DESC byte 0)
 +1   TARGET_FREQ_OFFSET    2     u16 BE channel-plan offset (0 = SF-only change, leave frequency alone)
 +3   FRAMES_UNTIL_SWITCH   1     switch at switch_epoch = EPOCH + FRAMES_UNTIL_SWITCH

8.1.2 PACT-lite membership block (PACT_BLOCK — v2 only)

The v2 addition that decouples membership from per-frame airtime (membership-scaling.md; PACT-lite in the paper §5). The coordinator names a small rotating telemetry roster (only rostered members TX a keepalive this frame — idle members cost no airtime), a poll list of members invited to signal queued-message demand collision-free, and a per-member TIM downlink-pending bitmap (802.11ah-style). It is appended after the grant list, before SIG. The ESP32 coordinator path emits an empty block (all counts 0) so its beacon is still v2-valid; the Rust cell()/backbone() hub is the canonical PACT producer. Layout (PactBlock::encode_into):

 off  field         size    notes
 +0   SF_INDEX      1       frame index within the super-frame, 0..R-1
 +1   R             1       super-frame length R = ceil(K / P) in frames  (P = telemetry-pool slots)
 +2   N_ROSTER      1       number of roster entries that follow
 +3   ROSTER        Nr×E    per entry: owner_addr(24b BE) ‖ SLOT(1). E = 4 per-cell;
                            E = 6 in per-link mode (+SF byte +BW-code byte per entry — FRAME_T bit14)
 …    N_POLL        1       number of polled members that follow
 …    POLL          Np×3    addr24 (BE) invited to signal demand this frame
 …    N_TIM         1       number of TIM bitmap bytes that follow ( = ceil(K/8) )
 …    TIM           Nt×1    downlink-pending bitmap: bit i (of byte i/8, LSB-first) = pending for
                            member table position i  →  tim[i/8] & (1 << (i%8))

8.1.3 Request slot (deviation-2) — no new wire field

The dedicated request slot that lifts the PACT-lite first-message-latency breakpoint past any realistic hub (deviation2-request-slot.md) adds no new beacon field. Poll-naming is cheap (3 B per name in POLL); reserved request airtime is not — so the coordinator names many members for demand each frame (POLL_TOTAL = 16) but reserves only a small fixed request window (REQ_WINDOW = 2 micro-slots), whose geometry is derived on both sides from the beacon's POLL list plus the compiled-in REQ_WINDOW/REQ_W constants. A polled member with a queued message transmits a ~16-byte request in a randomly chosen micro-slot; the coordinator then grants it a message slot the next frame via the existing GRANTS list. The request path is an additive fast-path over the guaranteed roster-rotation fallback.

8.2 REGISTER (TYPE = 2) — client → INFRA, scheduled unicast

 off  field            size  notes
 2    SRC_ADDR          3     registering client
 5    DST_ADDR          3     target coordinator
 8    SEQ               1     replay (as DATA)
 9    CAP_FLAGS         1     role/capability bits (relay-capable, multi-radio, power class…)
 10   REQ_SLOT          1     requested slot / 0xFF = "assign me"
 11   LINK_REPORTS      M×4   per-INFRA: [31:8] peer_addr(24b) [7:0] snr_q (link budget report)
 …    AEAD_TAG          8     sealed to the coordinator (unicast)

8.3 ADVERT (TYPE = 3) — any → all, contention

 off  field            size  notes
 2    SRC_ADDR          3     advertiser
 8    SEQ               1     replay
 9    ADV_FLAGS         1     advert subtype (identity / neighbour / route / coordinator-claim)
 10   ADV_BODY          var   subtype-specific (e.g. coordinator-claim carries vantage/altitude proof)
 …    SIG              64     Ed25519, full (adverts are signed, not sealed; security-spec §6 erratum)

A coordinator-claim advert (subtype in ADV_FLAGS) is how a node asserts INFRA candidacy; its ADV_BODY carries the vantage/link-budget evidence the election in routing-spec.md evaluates. This is the wire hook for "coordinator membership by measured vantage, not config role."

9. Conformance — structural validation

A conformant receiver MUST, in order, before delivering or relaying any frame:

  1. Reject if fewer than 2 bytes (no common header).
  2. Reject if VER ≠ an implemented version.
  3. Dispatch on TYPE; reject if the frame is shorter than that type's fixed minimum (DATA ≥ 17 B: 9 header + 0 payload + 8 tag).
  4. For DATA/REGISTER: verify AEAD tag over (header ‖ payload) with the derived nonce (§7); reject on failure (this also rejects any header tampering, §4.1).
  5. For BEACON/ADVERT: verify SIG against the claimed SRC identity; reject on failure.
  6. Enforce replay: reject if (SRC, SEQ) falls outside the sliding window for the current EPOCH (security-spec.md).
  7. Routing/relay rules (HOPLIM, DST) apply only to frames that pass 1–6.

A node MUST NOT act on, forward, or surface to the application any frame failing 1–6.

10. Overhead summary (the byte budget, frozen)

Frame Fixed overhead Max payload Notes
DATA 16 B (9 hdr + 8 tag, −1 PORT in plaintext) 238 B app the common case
BEACON (v2) 86 B fixed (2 hdr + 3 src + 4 epoch + 3 cell + 2 frame_t + 1 slotmap_len + 1 next_ptr + 1 n_grants + 5 empty-PACT + 64 sig) + 3·slots + 3·grants + PACT entries + [4 if PHY-change] n/a rate-capped; signed (full Ed25519); PACT block is v2-only
REGISTER 19 B + 4·(link reports) n/a unicast, sealed; REQ_SLOT 0xFF = "assign me"
ADVERT 8 B + body + 64 sig n/a signed (identity/LSA/coord-claim/neighbors/time subtypes)

The empty PACT block is 5 bytesSF_INDEX(1) ‖ R(1) ‖ N_ROSTER=0(1) ‖ N_POLL=0(1) ‖ N_TIM=0(1) (PactBlock::wire_len with all lists empty). Each populated roster entry adds 4 B (6 B per-link), each poll adds 3 B, and the TIM bitmap adds ceil(K/8) B.

11. Resolved open items (from design-decision.md §8)

12. Still open → deferred to companion specs (not the wire format)

The wire format is frozen above. These behaviours reference these fields but are specified elsewhere: SEQ-wrap / EPOCH-rotation rule and replay window width (security-spec.md); slot/guard semantics and fallback schedule (mac-spec.md); address-claim collision procedure and coordinator election scoring (routing-spec.md); boundary-client cell association (mac-spec.md/[routing-spec.md`](routing-spec.md)).


This is the keystone Stage-3 document; mac-spec.md, routing-spec.md, security-spec.md, and node-roles-spec.md build on the fields defined here. Reviewed together at Checkpoint C before any firmware.