← RFC

MeshCore Routing Analysis

Scope: How MeshCore routes packets, how its path-learning / advert system works, where it breaks, and its hidden-node behaviour. Citations are to /workspace/reference/meshcore/src/ and helpers/ at the checked-out commit.


1. The core idea: flood once to discover a path, then source-route

MeshCore is fundamentally different from Meshtastic. It is a path-vector / source-routing system. The first time A talks to B, the packet floods and accumulates the chain of relay node-hashes it traversed in a path field. B reverses that path and sends a PAYLOAD_TYPE_PATH return directly back along it. Thereafter, traffic between A and B is ROUTE_TYPE_DIRECT: the full hop list travels in the header and each relay just pops itself off and forwards. No flooding, no per-relay routing table.

This trades flood cost (paid once, at discovery) for a larger per-packet header (the path), and it is the central design lever Loomwave must weigh.


2. Packet format and route types

Packet.h defines a 1-byte header with bit-packed fields:

// Packet.h:8
#define PH_ROUTE_MASK   0x03   // bits 0-1: route type
#define PH_TYPE_SHIFT   2
#define PH_TYPE_MASK    0x0F   // bits 2-5: payload type
#define PH_VER_SHIFT    6
#define PH_VER_MASK     0x03   // bits 6-7: version

Route types (Packet.h):

#define ROUTE_TYPE_TRANSPORT_FLOOD   0x00
#define ROUTE_TYPE_FLOOD             0x01   // flood; 'path' is built up as it propagates
#define ROUTE_TYPE_DIRECT            0x02   // source-routed; 'path' supplied
#define ROUTE_TYPE_TRANSPORT_DIRECT  0x03

Payload types include PAYLOAD_TYPE_ADVERT (0x04), PAYLOAD_TYPE_PATH (0x08) (the reverse path return), PAYLOAD_TYPE_REQ/RESPONSE, PAYLOAD_TYPE_TXT_MSG (0x02), PAYLOAD_TYPE_ACK (0x03), PAYLOAD_TYPE_TRACE (0x09).

Sizes (MeshCore.h):

MAX_PACKET_PAYLOAD  184      MAX_PATH_SIZE  64       MAX_TRANS_UNIT  255
PUB_KEY_SIZE  32             SIGNATURE_SIZE 64       MAX_HASH_SIZE   8
CIPHER_BLOCK_SIZE 16         CIPHER_MAC_SIZE 2       MAX_ADVERT_DATA_SIZE 32

The path field is variable-length, encoded into a single path_len byte:

// Packet.h:79
uint8_t getPathHashSize()  const { return (path_len >> 6) + 1; }   // upper 2 bits: 1..3 byte hashes
uint8_t getPathHashCount() const { return path_len & 63; }         // lower 6 bits: 0..63 hops

So a path is up to 63 hops, each represented by a 1–3 byte truncated node-hash prefix (default 1 byte). A 5-hop path with 1-byte hashes costs 5 bytes of header overhead.


3. Flood routing and path accumulation

When a flood packet arrives and is not a duplicate, the node appends its own hash to the path and re-floods at decreasing priority:

// Mesh.cpp:330  routeRecvPacket()
uint8_t n = packet->getPathHashCount();
if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit()
    && (n+1)*packet->getPathHashSize() <= MAX_PATH_SIZE && allowPacketForward(packet)) {
    self_id.copyHashTo(&packet->path[n*packet->getPathHashSize()], packet->getPathHashSize());
    packet->setPathHashCount(n+1);
    uint32_t d = getRetransmitDelay(packet);
    return ACTION_RETRANSMIT_DELAYED(packet->getPathHashCount(), d);  // lower priority as it spreads
}

Duplicate detection is via a MeshTables::hasSeen() interface (Mesh.h:16), checked in onRecvPacket() (Mesh.cpp:254). The packet identity is a SHA-256 over payload (and path_len for TRACE), Packet::calculatePacketHash() (Packet.cpp:41) — note this is content-based, unlike Meshtastic's (sender,id).

The retransmit delay (Mesh.cpp:17) is randomised and proportional to airtime:

uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) {
    uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52/50) / 2;
    return _rng->nextInt(0,5) * t;     // 0..4 × (~airtime/2)
}

The flood priority of adverts is deliberately the lowest (sendFlood(), Mesh.cpp:625: pri = 3 for adverts, 1 otherwise), so control traffic yields to data.


4. Path learning and reversal — the heart of MeshCore

When B (the destination) receives a flooded packet, it has the full forward path in packet->path. It immediately sends a reciprocal return path, directly:

// Mesh.cpp:163  (inside onRecvPacket processing)
if (onPeerPathRecv(...)) {
    if (pkt->isRouteFlood()) {
        // send a reciprocal return path to sender, but send DIRECTLY!
        mesh::Packet* rpath = createPathReturn(&src_hash, secret, pkt->path, pkt->path_len, 0, NULL, 0);
        if (rpath) sendDirect(rpath, path, path_len, 500);
    }
}

Now both ends know the path. Subsequent direct packets are routed by popping: when a DIRECT packet arrives, if this node's hash matches the front of the path, it removes itself and forwards:

// Mesh.cpp:77  (direct route handling)
if (pkt->isRouteDirect() && pkt->getPathHashCount() > 0) {
  if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) && allowPacketForward(pkt)) {
    if (!_tables->hasSeen(pkt)) {
        removeSelfFromPath(pkt);                     // pop front hash
        uint32_t d = getDirectRetransmitDelay(pkt);
        return ACTION_RETRANSMIT_DELAYED(0, d);
    }
  }
}
// Mesh.cpp:320  removeSelfFromPath()
pkt->setPathHashCount(pkt->getPathHashCount()-1);
uint8_t sz = pkt->getPathHashSize();
for (int k=0; k < pkt->getPathHashCount()*sz; k += sz)
    memcpy(&pkt->path[k], &pkt->path[k+sz], sz);     // shift remaining path forward

So a DIRECT packet visits exactly the named relays, each forwarding once. A 5-hop path costs 5 transmissions total — versus a flood that costs ~N. There is also a zero-hop mode (sendZeroHop(), Mesh.cpp:705): path_len = 0, DIRECT, single transmission to immediate neighbours only — used for local/neighbour traffic.

Path-prefix collisions

Because path entries are truncated hashes (default 1 byte), isHashMatch() can match the wrong node (1/256 per hop with 1-byte prefixes). MeshCore can widen the hash to 2–3 bytes (getPathHashSize) to trade header bytes for collision resistance. This is a real, quantifiable robustness/airtime knob Loomwave should note.


5. Adverts — signed identity beacons

Adverts are how nodes announce their existence and public key. Construction (createAdvert(), Mesh.cpp:392):

advert payload = pub_key(32) | timestamp(4) | Ed25519 signature(64) | app_data(≤32)
// Mesh.cpp:392 (abridged)
packet->header = (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT);
memcpy(&payload[len], id.pub_key, PUB_KEY_SIZE); len += 32;
memcpy(&payload[len], &emitted_timestamp, 4);    len += 4;
uint8_t* signature = &payload[len];              len += 64;   // filled below
memcpy(&payload[len], app_data, app_data_len);   len += app_data_len;
id.sign(signature, message /*pubkey+ts+app_data*/, msg_len);  // Ed25519

On receipt (Mesh.cpp:241), the node verifies the Ed25519 signature before acting:

is_ok = id.verify(signature, message, msg_len);   // Identity.cpp:17 -> Ed25519::verify
if (is_ok) { onAdvertRecv(...); action = routeRecvPacket(pkt); }  // valid -> flood onward
else       { /* "forged signature" -> dropped */ }

Key properties: - Adverts are signed and verified — a node cannot spoof another's identity in an advert. This is a sharp contrast with Meshtastic, whose node info is unauthenticated. - Adverts flood (lowest priority, §3). The app_data carries name/role/location. - Cadence is application-driven, not protocol-mandated. The repeater / companion-radio examples (examples/) call sendFlood(createAdvert(...)) on a configurable timer (commonly minutes-to-hours). There is no built-in adaptive rate control beyond the airtime budget (§6) and the lowest-priority queueing.

The timestamp gives weak replay/freshness protection (a newer advert supersedes an older one for the same key), but there is no nonce; replay protection for data is separate.


6. MAC / contention: CAD + a real airtime budget

MeshCore's Dispatcher does listen-before-talk via channel-activity detection and, notably, maintains an explicit duty-cycle token budget — something Meshtastic lacks.

CAD / LBT

// Dispatcher.cpp:287  checkSend()
if (_radio->isReceiving()) {
    if (cad_busy_start == 0) cad_busy_start = _ms->getMillis();
    if (_ms->getMillis() - cad_busy_start > getCADFailMaxDuration() /*4000ms*/) { ... force }
    else { next_tx_time = futureMillis(getCADFailRetryDelay() /*200ms*/); return; }
}

Channel busy → retry in 200 ms, up to 4 s before forcing.

Airtime token budget

// Dispatcher.cpp:38  updateTxBudget()
float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor());   // factor=1 -> 50%
unsigned long max_budget = getDutyCycleWindowMs() * duty_cycle;
tx_budget_ms += elapsed * duty_cycle;                          // refill proportionally
if (tx_budget_ms > max_budget) tx_budget_ms = max_budget;

With the default getAirtimeBudgetFactor() = 1.0, a node may transmit at most 50% duty cycle over the window; the budget refills continuously. checkSend() (Dispatcher.cpp:274) defers transmission when the budget cannot cover a max-size packet. This is a genuine, self-enforced airtime fairness mechanism — better than Meshtastic's after-the-fact abort.

SNR-scored receive delay (flood suppression)

For flood reception, MeshCore delays re-processing based on link quality:

// Dispatcher.cpp:55
int Dispatcher::calcRxDelay(float score, uint32_t air_time) const {
    return (int)((pow(10, 0.85f - score) - 1.0) * air_time);
}

Higher score (stronger/closer) → shorter delay. Used at Dispatcher.cpp:242: floods with low computed delay are processed immediately; others are queued (capped at MAX_RX_DELAY_MILLIS). This is MeshCore's analogue of Meshtastic's SNR-weighted CW — same goal, different sign convention, similar effect: spread out and thin redundant rebroadcasts.

Airtime (for the budget math elsewhere)

getEstAirtimeFor() (helpers/radiolib/RadioLibWrappers.cpp:139) = getTimeOnAir(len)/1000 — the standard LoRa ToA. Loomwave airtime tables (see mac-layer-options.md) apply directly.


7. Where it breaks at scale

7.1 Discovery still floods — and floods are still N²

DIRECT routing is cheap, but every path must first be discovered by a flood, and every advert floods. With N nodes each adverting every A seconds, advert load alone is:

L_advert ≈ N² · ToA_advert / A

(Same broadcast-storm quadratic as data flooding — derivation + "why N²" intuition in meshtastic-routing-analysis.md §8.1.)

An advert payload is ≥ 32+4+64 = 100 B before app_data → ToA ≈ 0.9–1.0 s at LongFast. So:

N A=600 s A=3600 s
10 ~12% ~1.9%
50 ~290% (collapsed) ~49%
100 ~1170% ~194%
500 huge ~4900%

(Using ToA≈0.7 s; with full 100 B+ adverts it is worse.) Advert flooding has the same N² pathology as Meshtastic broadcast — MeshCore just confines flooding to control + first contact instead of all data. At 50+ nodes, advert cadence must be stretched to many minutes, which slows path discovery and recovery.

7.2 Path brittleness under churn

A source route is only valid while every named relay stays up and in place. If one intermediate moves or sleeps, the DIRECT packet dies at the broken hop. Recovery = fall back to a fresh flood (re-discovery), so mobility/churn converts cheap DIRECT traffic back into expensive floods exactly when the network is least stable. There is no link-state repair, no alternate-path table — just rediscover.

7.3 Stale paths and asymmetry

The learned path is the forward path reversed. LoRa links are frequently asymmetric (A hears B but B barely hears A); a path that worked outbound may be marginal on return. MeshCore has no per-link quality memory to prefer a better asymmetric route.

7.4 Truncated-hash ambiguity

1-byte path hashes (§4) risk mis-forwarding under dense topologies; widening them costs header airtime on every DIRECT packet, which compounds over multi-hop paths.

7.5 No global topology view

Because each node knows only the paths it has personally discovered, there is no way to compute optimal or load-balanced routes, detect partitions, or pre-position routes before traffic. This is the classic path-vector limitation.


8. Hidden-node behaviour

MeshCore's hidden-node story is materially better than Meshtastic's but still not a solution:

Net: MeshCore lowers the frequency of hidden-node collisions (less flooding) but does not coordinate transmissions, so the failure mode persists for discovery and at high density.


9. What to borrow / what to reject for Loomwave

Borrow: - Flood-to-discover, then source-route — this is the right macro-architecture and aligns with the brief's "hybrid: flood for discovery, scheduled for data" mandate. Confining floods to control traffic is the single biggest win over Meshtastic. - Signed adverts (Ed25519) — authenticated identity beacons are essential; adopt directly. Loomwave should also add a nonce/sequence for replay resistance. - Explicit airtime token budget (updateTxBudget, 50% duty) — a clean, proactive fairness primitive; far better than Meshtastic's after-the-fact abort. Make the factor role-dependent (INFRA higher, CLIENT lower). - SNR-scored flood-suppression delay (calcRxDelay) — keep for any residual flood traffic. - Variable-width path hashes — the size/collision/airtime tradeoff is a useful explicit knob.

Reject / improve: - Pure source routing with no repair — too brittle for the mobility/high-churn scenario. Loomwave needs link-quality memory and fast local repair, or an INFRA-assisted route table, so a single moved relay doesn't force full rediscovery. - Unbounded advert flooding — must be rate-controlled (Reticulum's ANNOUNCE_CAP and fewer-hops-first prioritisation are the model; see reticulum-routing-analysis.md). - Application-driven advert cadence with no adaptivity — cadence should respond to measured channel utilization and topology stability. - Content-hash dedup only — fine, but combine with sender/seq to bound table size predictably.


Source index (files cited)

Topic File:symbol
Header bit layout / route types Packet.h:8, route-type defines
Sizes MeshCore.h (MAX_PACKET_PAYLOAD etc.)
Path encode Packet.h:getPathHashSize/Count:79
Flood + path accumulate Mesh.cpp:routeRecvPacket:330
Dedup Mesh.cpp:254, Packet.cpp:calculatePacketHash:41, Mesh.h:16
Flood retx delay Mesh.cpp:getRetransmitDelay:17
Path return / reverse Mesh.cpp:163 (createPathReturn / sendDirect)
Direct pop-forward Mesh.cpp:77, removeSelfFromPath:320
Zero-hop Mesh.cpp:sendZeroHop:705, sendDirect:681
Advert build Mesh.cpp:createAdvert:392
Advert verify Mesh.cpp:241, Identity.cpp:verify:17
Advert flood priority Mesh.cpp:sendFlood:625
CAD / LBT Dispatcher.cpp:checkSend:287, retry/dur :59
Airtime budget Dispatcher.cpp:updateTxBudget:38, :274
SNR rx delay Dispatcher.cpp:calcRxDelay:55, :242
Airtime estimate helpers/radiolib/RadioLibWrappers.cpp:getEstAirtimeFor:139