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.
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.
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.
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.
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.
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.
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.
MeshCore's Dispatcher does listen-before-talk via channel-activity detection and, notably,
maintains an explicit duty-cycle token budget — something Meshtastic lacks.
// 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.
// 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.
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.
getEstAirtimeFor() (helpers/radiolib/RadioLibWrappers.cpp:139) = getTimeOnAir(len)/1000
— the standard LoRa ToA. Loomwave airtime tables (see mac-layer-options.md) apply
directly.
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.
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.
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.
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.
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.
MeshCore's hidden-node story is materially better than Meshtastic's but still not a solution:
updateTxBudget), limiting how often a
hidden node can fire and thus the collision probability at a shared receiver.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.
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.
| 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 |