Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Broadcast behaviour: Python-RNS parity reference

This document is the source-of-truth reference that our Rust reticulum-core broadcast code must match. It records what Python-Reticulum does for every broadcast-related mechanism, citing vendor/Reticulum/RNS/Transport.py (and neighbouring files) by line. The companion mapping table at the end records the Rust-side implementation or intentional divergence for each item.

The rule (Lew, 2026-04-15): Leviculum matches Python-RNS exactly for on-wire packet counts, packet types, and protocol semantics. Timing may diverge — jitter-window shape and interface pacing are free — as long as the counts and types stay identical.

1. Overview: what can appear on the wire

Python-Reticulum emits five distinct packet classes that can be broadcast or unicast:

ClassPacket typeScopeWho originates
Self-announcePacket.ANNOUNCEBroadcastDestination.announce()
Forwarded announcePacket.ANNOUNCEBroadcastTransport relay on received announce
Path-requestPacket.DATA with transport_type = BROADCASTBroadcastTransport.request_path() or client call
Path-responsePacket.ANNOUNCE with context = PATH_RESPONSETargetedTransport answering a path-request
Link-requestPacket.LINKREQUESTUnicastLink.__init__ on initiator

Everything below walks each class.

2. Self-originated announce

Trigger

Destination.announce(app_data, path_response=False, ...) at vendor/Reticulum/RNS/Destination.py:243. Builds an announce packet, calls announce_packet.send() once at line 322.

On-wire behaviour

Packet.send() at vendor/Reticulum/RNS/Packet.py:273-299 calls Transport.outbound(self) exactly once and returns a receipt (or False). There is no retry loop on the send path. A second call on the same packet raises IOError (Packet.py guard).

Fan-out across interfaces

Inside Transport.outbound() at vendor/Reticulum/RNS/Transport.py:1025-1167: for broadcast packets (the “else” branch after the targeted-path and transport-id branches), the code iterates Transport.interfaces (line 1027) and transmits on each. There is no if interface != packet.receiving_interface filter in the announce path. For self-originated announces receiving_interface is None anyway (the packet was created locally) so the question is moot, but the point is relevant when we contrast with the forwarded-announce path below.

Mode-based filtering is applied in this loop at lines 1040-1084 for MODE_ACCESS_POINT, MODE_ROAMING, MODE_BOUNDARY. These modes suppress the rebroadcast on specific interfaces depending on where the destination sits in the mesh. Bandwidth-cap logic (lines 1089-1162) defers transmissions when the interface is saturated.

Summary: Python self-announce = exactly 1 on-wire broadcast per call, via one-shot Packet.send(). Count = 1.

3. Received-for-forwarding announce

Reception

Transport.inbound(data, interface) at Transport.py:1179+ is the entry point for everything received on an interface. The packet-hash dedup check at line 1227 is:

if not packet.packet_hash in Transport.packet_hashlist and
   not packet.packet_hash in Transport.packet_hashlist_prev:
    return True

Transport.packet_hashlist at Transport.py:99 is set(). Transport.packet_hashlist_prev at line 100 is the rolling previous window used to keep the dedup memory constant-bounded. A duplicate return here bails out of inbound() before any announce-specific handling. This is the only mechanism that prevents the same packet from being processed twice — critical for the broadcast-back-to-source echo pattern that B1 relies on.

Insertion into announce_table

For announces (packet.packet_type == ANNOUNCE) that pass dedup, the code path at Transport.py:1722-1764 initialises an announce_table entry:

retries            = 0                                # line 1722
local_rebroadcasts = 0                                # line 1724
block_rebroadcasts = False                            # line 1725
attached_interface = None                             # line 1726
retransmit_timeout = now + (RNS.rand() * PATHFINDER_RW)  # line 1728

PATHFINDER_RW = 0.5 (seconds) at line 69, so the first retransmission is scheduled within 0–500 ms of receipt.

Line 1748-1752 is the special case for announces that arrived from a local client (shared-instance peer over the local socket):

if Transport.from_local_client(packet):
    retransmit_timeout = now
    retries = Transport.PATHFINDER_R

This sets retries = 1 right away. Combined with the retry-loop guard below, this makes local-client-sourced announces fire only 1 time from the scheduler, not 2.

Retry loop

The periodic job at Transport.py:519-532 walks announce_table:

for destination_hash in Transport.announce_table:
    announce_entry = Transport.announce_table[destination_hash]
    if announce_entry[IDX_AT_RETRIES] > 0 and
       announce_entry[IDX_AT_RETRIES] >= Transport.LOCAL_REBROADCASTS_MAX:
        # "local rebroadcast limit reached"
        completed_announces.append(destination_hash)
    elif announce_entry[IDX_AT_RETRIES] > Transport.PATHFINDER_R:
        # "retry limit reached"
        completed_announces.append(destination_hash)
    else:
        if time.time() > announce_entry[IDX_AT_RTRNS_TMO]:
            announce_entry[IDX_AT_RTRNS_TMO] =
                time.time() + Transport.PATHFINDER_G + Transport.PATHFINDER_RW
            announce_entry[IDX_AT_RETRIES] += 1
            # ... build rebroadcast packet and send

With the constants:

ConstantValueCitation
PATHFINDER_R1Transport.py:67
PATHFINDER_G5 sTransport.py:68
PATHFINDER_RW0.5 sTransport.py:69
LOCAL_REBROADCASTS_MAX2Transport.py:76

Deterministic walk — non-local-client source

Entry inserted with retries = 0, retransmit_at = now + rand*0.5s.

Tickretries inGuard AGuard BActionretries out
100 > 0 && 0 >= 2 = false0 > 1 = falsefire, schedule next1
211 > 0 && 1 >= 2 = false1 > 1 = falsefire, schedule next2
322 > 0 && 2 >= 2 = trueremove

Count = 2 rebroadcasts per received non-local-client announce.

Deterministic walk — local-client source

Entry inserted with retries = 1, retransmit_at = now.

Tickretries inGuard AGuard BActionretries out
111 > 0 && 1 >= 2 = false1 > 1 = falsefire, schedule next2
222 > 0 && 2 >= 2 = trueremove

Count = 1 rebroadcast per received local-client-sourced announce.

Immediate local-client forward

Lines 1788-1833: after the table insertion, Python also emits the announce immediately to every local-client interface that is not the receiving interface:

for local_interface in Transport.local_client_interfaces:
    if packet.receiving_interface != local_interface:
        new_announce = RNS.Packet(...)
        new_announce.send()

This is the only place in the announce path where receiving_interface filtering happens. It only applies to local-client interfaces — the fanout onto LoRa, TCP, UDP interfaces is unfiltered. This confirms that for the mixed LoRa-Serial + LoRa-RF topology our tests care about, Python does not skip the received interface when rebroadcasting.

Fan-out per rebroadcast fire

Each fire builds a new announce packet (lines 540-561), calls send()Transport.outbound(), which applies the mode filtering and bandwidth-cap logic. The receiving interface is implicitly included in the for interface in Transport.interfaces loop (no exclusion check). Echoes are absorbed by the packet_hashlist check at line 1227 when they arrive back.

Block-rebroadcasts path

announce_entry[IDX_AT_BLCK_RBRD] set to True (indices at line 557 of the retry loop) reroutes the rebroadcast as a PATH_RESPONSE packet (announce_context = PATH_RESPONSE, line 537). This is how path-responses ride the same scheduler.

4. Path-request

Trigger

Transport.request_path(destination_hash, ...) at Transport.py:2541 is the main producer. Clients call into it via Destination.request_path() or explicit transport calls.

On-wire behaviour

At line 2561-2587: builds a Packet with packet_type = Packet.DATA and transport_type = Transport.BROADCAST, then calls packet.send() once. Same one-shot pattern as self-announce.

Fan-out goes through the same Transport.outbound() broadcast loop at Transport.py:1025-1167.

Count = 1 on-wire broadcast per path-request call. No retries in the scheduler for path-requests.

Rate-limiting

Path-requests are subject to PATH_REQUEST_MI = 20 seconds minimum interval per destination (Transport.py:81) — clients requesting the same path more often are throttled upstream of Transport.outbound().

5. Path-response

Trigger

Two paths produce a PATH_RESPONSE:

  1. Active answer: Transport receives a path-request, has the path, calls Destination.announce(path_response=True, tag=...) with the matching identity. This produces a Packet.ANNOUNCE with context = PATH_RESPONSE (Destination.py:309-310, 319-322) and sends it once.
  2. Rebroadcast with block_rebroadcasts: the retry loop at Transport.py:519-540 emits path-responses when announce_entry[IDX_AT_BLCK_RBRD] is set. Same 2-fire count as a regular received-announce rebroadcast.

On-wire semantics

Path-responses are a packet-type subset of announces. The fan-out logic is the same as announces. Consumers distinguish by packet.context == PATH_RESPONSE.

Special routing

In Transport.outbound() at lines 1167+ (targeted-transport branch), a packet with transport_id set AND a known next-hop in path_table is routed to a single specific interface via SendPacket, not broadcast. This is what happens when a path-response is specifically addressed to the path-requester rather than broadcast. In our Rust code this corresponds to the target_interface: Some(idx) branch at transport.rs:4055-4070.

Trigger

Link.__init__(destination=...) on the initiator. Internally calls Packet(destination, link_data, Packet.LINKREQUEST, ...) and sends it.

On-wire behaviour

Packet.LINKREQUEST (Packet.py:62) is unicast, not broadcast. At Transport.py:1938: local-destination link requests are dispatched to the destination’s attached interface directly. Non-local paths route through next-hop. There is no broadcast fanout.

Count = 1 unicast packet per link initiation. Not relevant to broadcast parity directly, but enumerated here for completeness.

7. Dedup (packet_hashlist)

ItemValueCitation
Storageset()Transport.py:99
Previous-window storageset()Transport.py:100
Max size1 000 000 entriesTransport.py:145
Check siteline 1227Transport.py
Rotationhalf-cleared when reaches hashlist_maxsize/2approximate, see cull job

The dedup check is the only mechanism that prevents the self-heard echo when we (Rust) stop excluding the receiving interface from the rebroadcast fanout. Verifying the check fires reliably is a hard requirement for B1.

8. ANNOUNCE_CAP — per-interface rate limiter

Constants

ConstantValueCitation
Reticulum.ANNOUNCE_CAP2 (percent of bandwidth)Reticulum.py:116

Interface instances set interface.announce_cap = Reticulum.ANNOUNCE_CAP/100.0 = 0.02 at Reticulum.py:731. Each interface also has interface.bitrate (bps).

Logic

The rate limiter is consulted only for forwarded announces (packet.hops > 0). Self-originated announces bypass it because they only fire once and are not worth deferring.

At Transport.py:1091-1161:

if (packet.hops > 0):
    if not hasattr(interface, "announce_cap"): ...
    if not hasattr(interface, "announce_allowed_at"):
        interface.announce_allowed_at = 0

    if time.time() >= interface.announce_allowed_at and interface.bitrate:
        tx_time    = len(packet.raw) * 8 / interface.bitrate
        wait_time  = tx_time / interface.announce_cap
        interface.announce_allowed_at = time.time() + wait_time
        # proceed with immediate TX
    else:
        # queue for later
        if not len(interface.announce_queue) >= Reticulum.MAX_QUEUED_ANNOUNCES:
            interface.announce_queue.append(packet)

wait_time = tx_time / 0.02 = 50 × tx_time: each forwarded announce “books” 50× its own airtime on the interface before the next forwarded announce is allowed immediate TX.

Queue drain

When announce_allowed_at rolls past and there are queued announces, the interface’s process_announce_queue() pops the next one and emits it. This is a per-interface deferred-send mechanism, not a transport-wide one.

9. LOCAL_REBROADCASTS_MAX

Covered in section 3 (retry loop). The enforcement sites are:

  • Transport.py:523: retry-loop guard A. Prevents emission when retries >= LOCAL_REBROADCASTS_MAX.
  • Transport.py:1588: secondary site that removes an entry from announce_table when a duplicate announce arrives and the local rebroadcast counter has saturated. This is the “I’m hearing too many copies of this announce from others, stop my own rebroadcast too” path.

10. Management announce keepalive

Constants

ConstantValueCitation
mgmt_announce_interval7 200 s (2 h)Transport.py:162
Initial-fire tricklast_mgmt_announce = now - interval + 15Transport.py:247

Behaviour

Transport.py:247 runs at startup and sets last_mgmt_announce to 15 seconds ago minus the full interval, so the next check at Transport.py:835 fires ~15 s after startup. Each fire walks Transport.mgmt_destinations (a list of transport-control destinations like probe responders and blackhole destinations, populated at lines 220-241, 367 during Transport.start()) and announces each.

After each successful batch the code updates Transport.last_mgmt_announce = time.time().

Purpose

Without this keepalive, a node that loses its initial one-shot Destination.announce() is unreachable until the next manual announce. The 2-h re-announce gives the mesh a periodic refresh without flooding the network with announce traffic.

11. Interface modes

Python-Reticulum distinguishes five interface modes (Interfaces/Interface.py:45-50):

ModeConstantIntent
MODE_FULL0x01Default. Fully participating transport node.
MODE_POINT_TO_POINT0x02Directed link, no announce flooding.
MODE_ACCESS_POINT0x03Gateway to clients. Special path expiry.
MODE_ROAMING0x04Mobile node. Selective rebroadcast.
MODE_BOUNDARY0x05Edge between mesh segments. Selective rebroadcast.
MODE_GATEWAY0x06Inter-mesh gateway.

These are consulted in Transport.outbound() at lines 1040-1084 to suppress rebroadcast on specific interfaces. block_rebroadcasts at the announce-table entry level is a related per-entry flag.

Leviculum does not implement interface modes. All interfaces behave as MODE_FULL. This is a documented divergence that Phase A audit records; if a future scenario surfaces that requires mode behaviour, a separate task lands them. Until then, our fanout is “unfiltered over the broadcast-capable interface set”, which is behaviourally equivalent to Python with all interfaces in MODE_FULL.

12. Rust ↔ Python parity matrix

Legend: ✓ matches, ≈ matches in count/semantics with timing or structural divergence, ⚠ gap not yet addressed, ✗ does not match.

MechanismPython referenceRust todayStatusNotes
Self-announce one-shotDestination.py:322, Packet.py:294transport.rs:1256-1280 schedules 3 retries beyond the initialFixed in B3
Self-announce on-wire count14 (1 + 3 retries)B3 brings to 1
Self-announce fanoutall interfaces (MODE_FULL assumed)send_on_all_interfaces(exclude=None)transport.rs:1243-1255
Received-announce rebroadcast count2 (non-local-client), 1 (local-client)4 at retries=1 init + PATHFINDER_RETRIES=3B2 brings to 2
Received-announce fanoutall interfaces; echo dedup’d on RXsend_on_all_interfaces (no exclude)Matches Python. B1 verified by test_announces_forwarded_through_transport.
Packet-hash dedup on RXTransport.py:1227transport.rs:1179Identical semantics, rolling window
PATHFINDER_G grace5 s5 000 msconstants.rs:117
PATHFINDER_RW jitter0.5 s500 ms (+ optional airtime factor)Option α permitted timing divergence
LOCAL_REBROADCASTS_MAX22constants.rs:133; enforcement at transport.rs:3945
ANNOUNCE_CAP2 %2 %constants.rs:246; impl at transport.rs:287-296, 4125
announce_queue / deferred-sendinterface.announce_queueInterfaceAnnounceCap.queueSame intent, Rust-side uses Vec
mgmt_announce_interval7 200 s7 200 000 msconstants.rs:148; node/mod.rs:988-1048
mgmt-announce initial 15 s trickTransport.py:247node/mod.rs:75 + constantVerified by B4 audit
mgmt-announce iterates all destsPython walks mgmt_destinationscheck_mgmt_announces walks mgmt_destinationsVerified by B4 audit
Path-request one-shot broadcastTransport.py:2541-2587transport.rs (to verify in B7)B7 audit
Path-response targetedtransport.rs:4055-4070same mechanismPreserved
Interface modes (FULL/ROAMING/…)5 modesnone (all = FULL)Documented gap; separate task
block_rebroadcastsper-entry flagAnnounceEntry.block_rebroadcastsVerified by B7 audit

13. Phase A resolutions of semantic ambiguities

B2 retry-count alignment

Question: PATHFINDER_R = 1 — does this mean 1 retry after the initial or 1 TX total?

Resolution (walking the Python loop, section 3): Python fires 2 times per received non-local-client announce, bounded by LOCAL_REBROADCASTS_MAX = 2 not by PATHFINDER_R. The PATHFINDER_R guard (retries > PATHFINDER_R) would fire at retries = 2 but LOCAL_REBROADCASTS_MAX fires first at retries >= 2. In other words, for the default constants the PATHFINDER_R guard is redundant with LOCAL_REBROADCASTS_MAX in the non-local-client path.

Rust equivalent target: 2 fires per received non-local-client announce. Achievable in two ways:

  • A. Set PATHFINDER_RETRIES = 1 and change the entry-insert at transport.rs:1973-1974 from retries: 1 to retries: 0. Guards at transport.rs:3944-3945 already read retries > PATHFINDER_RETRIES and local_rebroadcasts >= LOCAL_REBROADCASTS_MAX; both fire at the right count.
  • B. Set PATHFINDER_RETRIES = 2 and leave insert at retries: 1. Same on-wire count.

B2 commits path A — it more closely mirrors Python’s constants and counter semantics, so future upstream-audit readers see 1:1 constants.

B1 fanout alignment

Question: if we remove exclude_iface, can dedup reliably catch the self-echo, and does it play well with Python peers?

Resolution: yes. Outgoing broadcasts go through send_on_all_interfaces at transport.rs:1243-1255 which calls self.storage.add_packet_hash() before emitting the Action::Broadcast. The dedup check at transport.rs:1179 in process_incoming reads that set. The only edge case is the dedup window rollover at HASHLIST_MAXSIZE = 1 000 000 entries — a packet that is ~1M packets old could theoretically come back. Not a concern in practice for single-day bench runs.

Python interop subtlety (discovered 2026-04-15 when the B1 change was first landed, caused a 3-node TCP relay test to fail, then resolved by spacing out the test’s announce emissions): the Python reference has a per-interface ingress control at vendor/Reticulum/RNS/Interfaces/Interface.py:117-138. When two announces arrive on the same interface faster than IC_BURST_FREQ_NEW = 3.5/s (≈ 285 ms apart), Python activates burst mode for at least IC_BURST_HOLD = 60 s then penalises for IC_BURST_PENALTY = 300 s. Held announces are released by process_held_announces every interface_jobs_interval = 5 s, but only once the cooldown expires.

In a LoRa topology the multi-second airtime per transmit naturally spaces announces below this threshold, so ingress control never activates. In a TCP relay topology a Rust node that receives announces from both peers in rapid succession — and with B1 fans them both out on every interface, with only the retry scheduler’s 0-500 ms jitter spacing them — can trip Python’s ingress control on the receiving side.

This is not a Rust bug; it is Python’s intended rate-limit behaviour that naive TCP-only tests can expose. The regression guard test test_announces_forwarded_through_transport spaces its two announce_destination calls by two seconds to keep the spawned-peer interface’s ia_freq below 3.5 /s. Production scenarios where two daemons announce in tight succession through a Rust relay remain subject to Python’s ingress limits — exactly as they would be through a Python relay.

Mode-less Rust

Decision: Leviculum continues without interface modes. Documented as a deliberate scope reduction. Our scenarios and the Python peer we interop against all use MODE_FULL implicitly. A future Bug that requires MODE_ROAMING or similar gets its own task; this parity doc predates and outscopes that work.

14. Usage

This document is the audit target for both sides:

  • When we upgrade the vendored RNS/ tree to a new upstream release, the Python line numbers here are the first thing to re-verify. A changed line number is a hint the behaviour may have shifted; a changed mechanism is a new parity task.
  • When we add a new broadcast code path to reticulum-core, we extend the parity matrix (section 12) and add a test under reticulum-std/tests/rnsd_interop/ that verifies the new path matches what a live Python peer sees.

The parity matrix is the contract. Everything else in this document is the reading behind the entries.