Skip to content

lrcsim scenario testbed

Status: NORMATIVE. This is the shipped behavior of lrcsim, sim/, and tests/scenarios/. The design rationale and forward-looking (Mode B, nightly seed regression) material lives in research/SCENARIO_TESTBED.md, which stays a design doc — read it for why; read this page for what ships.

lrcsim is a unix-native binary that runs whole-network scenarios — topology, physics, mobility, faults, and assertions — against the real lrc::Node (the same compiled logic that ships in lrcd and firmware), driven by a virtual clock over a geometric field model. It extends the test ladder in TESTING.md one tier above ChaosNet: where ChaosNet answers “is this behavior correct,” lrcsim answers “how does the network behave” under geometry, mobility, and asymmetric links a flat-broadcast harness can’t express.

Terminal window
cmake --build build -j --target lrcsim
./build/lrcsim run tests/scenarios/hilltop_asym.scn
./build/lrcsim run tests/scenarios/hilltop_asym.scn --trace out.jsonl # JSONL trace
./build/lrcsim check tests/scenarios/hilltop_asym.scn # parse-only
./build/lrcsim credit-sweep --payload-bytes 55 --class chat # see §5

Exit code is 0 iff every assert in the scenario passed. --seed N overrides the scenario’s own seed line (still deterministic, just a different draw).

sim::Field extends tests/virtual_radio.h’s per-pair SNR override into full geometry, and is the piece Mode A and a future Mode B share (see SCENARIO_TESTBED.md §2 — Mode B is not built this wave, but the field model was written with zero dependency on sockets or lrc::Node specifically so a future lrcbridge can hold the same Field instance).

  • Per-node geometry: (x, y, z) meters, TX power (dBm), antenna gain (dBi). A node without an explicit set_node() call (or .scn x=/y=/ grid=) has no geometry and every link to/from it is an open link (clears every preset floor) — the same default VirtualRadio uses, so a minimal scenario with no field/coordinates still runs.
  • Log-distance path loss: snr_db = tx_dbm + gains - noise_floor_dbm - (ref_loss_db + 10 * n * log10(distance)) + height_gain + shadow, where n is the scenario’s pathloss exponent (2.7–3.5 typical) and shadow is an optional seeded log-normal draw. Height gain is a small 5*log10(combined height / 3m) term, clamped at 0 (never penalizes below the 1.5m baseline).
  • Directional overrides are first-class: set_link_override(from, to, snr_x10) pins an exact SNR (or the kSnrOff sentinel — never clears any floor) independent of the reverse direction. This is the mechanism behind hilltop_asym’s “hears 5, heard by 200” — asymmetry is not an approximation layered on top of a symmetric model, it’s a first-class input.
  • Mobility: set_waypoints(i, {(t_ms, x, y), ...}) gives a node a piecewise-linear path; position_at() interpolates between the bracketing waypoints and holds at the endpoints outside the range (no teleport, no extrapolation). Setting waypoints implies geometry, even without a separate set_node() call.
  • Determinism: every shadowing draw is seeded per-(seed, from, to, t_ms) — not from a shared stream position — so repeated or out-of-order queries at the same link/time always agree. A scenario run is therefore a pure function of (scenario file, seed): the same inputs always produce a byte-identical trace, and a CI failure is reproducible with nothing but the checked-in .scn file.

Unit tests: tests/test_sim_field.cpp.

1a. Co-channel interference (sim/air.h) — the WS-SPECTRUM host slice

Section titled “1a. Co-channel interference (sim/air.h) — the WS-SPECTRUM host slice”

LoRa spreading factors are only quasi-orthogonal: a co-channel transmission at a different SF is interference attenuated by a finite rejection figure, not an invisible neighbor. The medium models this with a parameterized per-preset-pair rejection matrix (SfRejection — keyed by preset pair, not raw SF pair, because presets fix the whole (SF, BW, CR) triple and cross-BW same-SF pairs like P5-SF7/250 vs P6-SF7/500 are distinct chirp rates with their own partial rejection) plus a capture threshold. Defaults are the planning numbers — 0 dB same-preset, 16 dB cross-preset, 6 dB capture — and they are deliberately parameters: the roadmap calls multi-SF sharing “a measured power-discipline design, not an assumption”, and the Wave 4 RF bench campaign is expected to overwrite them with measured per-pair values (Runner::set_rejection(), or the .scn scalar knobs) and re-run the scenario suite.

How adjudication works (full detail + documented simplifications in sim/air.h’s header comment):

  • Every radiated frame occupies its real airtime window (airtime_ms(preset, bytes)) on its freq_slot — a ~2.5 s anchor frame genuinely spans virtual ticks and interferes with later ticks’ traffic. Different freq_slots are fully isolated.
  • Reception survives iff desired − (strongest interferer − rejection[d][i]) ≥ capture. The strongest single post-rejection interferer decides, and the trace names it (sim_drop reason "collision", §6).
  • Same-preset overlaps between mutually-audible transmitters are skipped: CAD (carrier sense) would have serialized them. CAD cannot detect a different preset’s chirp rate, so cross-preset overlaps get no such protection — which is exactly why the rejection math exists.
  • Same-preset hidden pairs collide only when a scenario opts in (field co_sf_collisions 1). Off by default: the canonical library’s accelerated gossip cadences (2 s RTRSYNC on multi-second anchor airtimes) deliberately outrun physical airtime and would self-jam; making that honest needs contention-window modeling, which is its own future workstream (SCENARIO_TESTBED.md §4).
  • Known simplifications, deliberate and documented: one-directional in-tick adjudication (a frame sees interferers recorded before it in the same tick plus everything still on air from earlier ticks — cascade order is a causal order); no half-duplex (a transmitting node can still receive that tick); no adjacent-channel leakage; no power summing across interferers.

The headline numbers, pinned by tests/test_sim_air.cpp on one fixed 4-node co-channel topology (desired signals +12 dB, cross-pair interference +8 dB, senders hidden — every run identical except one variable):

Configuration Pair A Pair B
Both pairs same SF (baseline) 0/1 0/1 — SIR 4 dB < 6 dB capture
Pair B layered onto SF9 1/1 1/1 — SIR becomes 20 dB
Layered, interferer +30 dB (near-far) 1/1 0/1 — rejection is not orthogonality
Layered, interferer at exactly +22 dB 1/1 1/1 — break-even, inclusive
Layered, interferer at +23 dB 1/1 0/1 — one dB past break-even

The break-even formula a deployment’s power discipline must honor: a co-channel different-SF neighbor may run up to (rejection − capture) = 10 dB hotter than the desired signal; at the same SF it must stay 6 dB quieter. Layering buys a 16 dB swing — exactly the rejection parameter, no more. tests/scenarios/multi_sf_share.scn gates CI on both halves: the disciplined pairs deliver 1/1 (ratio>=1.0) and the near-far victim pair provably starves (ratio<=0.0).

2. The .scn scenario file format (sim/scn.h)

Section titled “2. The .scn scenario file format (sim/scn.h)”

Flat, diff-friendly, hand-rolled parser (no TOML/YAML dependency), in the spirit of tests/lrctest.h. One construct per line; # starts a trailing comment only when it begins a new word (so chan=#field is not treated as a comment — a channel name is itself #-prefixed). Blank lines and full-line comments are ignored.

seed 42
field pathloss 3.0 shadow 2.0
node hill role=router x=0 y=0 z=150 tx_dbm=27
node plain[1..200] role=client grid=1000x1000 tx_dbm=14
link hill->plain[*] snr=+8
link plain[6..200]->hill snr=off
link plain[1..5]->hill snr=-14
at 60s chanmsg from=plain[137] chan=#field text="can anyone hear me"
at 300s kill hill
at 420s revive hill
mobility plain[42] waypoints (0,0)@0s (900,900)@600s
assert converged chan=#field by=900s
assert delivered from=plain[137] to=plain[1..200] ratio>=0.95
assert duty_legal all
assert no_dup_delivery all
  • seed N — required; the u32 seed a run derives from. Missing it is a parse error (“runs would not be reproducible”).
  • field pathloss N shadow N [duty_bp N] [duty_window_ms N] [region NAME] [region_freq_khz N] [region_window_ms N] [sf_rejection N] [capture N] [co_sf_collisions 0|1] — optional; defaults are pathloss=3.0, shadow=0 (off), no enforcement, sf_rejection=16 dB, capture=6 dB, co_sf_collisions=0. duty_bp (basis points, 0–10000, matches lrc::AirtimePolicy::duty_bp exactly) + duty_window_ms apply the medium’s own uniform duty budget; region + region_freq_khz (+ optional region_window_ms) drive Node’s real regulatory gate instead — the two are mutually exclusive (§4). sf_rejection/capture/co_sf_collisions drive the co-channel interference model — see §1a. field parameters are space-separated key value pairs (not key=value — this is the one construct that differs from the rest of the grammar, matching the spec’s own golden example).
  • node <name-or-range> [role=router|client] [x=N y=N | grid=WxH] [z=N] [tx_dbm=N] [preset=<0..7|NAME>] [freq_slot=N] — declares one or more nodes. name[a..b] expands to nameA … nameB; name[k] is single-index shorthand. grid=WxH places the expanded group on a deterministic near-square grid over that box (indexed by position within the group, so adding a different group never reshuffles this one). x=/y= and grid= are mutually exclusive. Omitting both leaves the node without geometry (open-link default). preset= is the node’s initial lane placement (WS-SPECTRUM: SF layering is lane placement) — an index or a ladder name (ANCHORBURST, validated against lrc::kPresets); freq_slot= the frequency slot (0–255). Both default to the anchor lane, slot 0 — pre-lane-syntax scenarios are unchanged. A reboot (revive) re-adopts the declared placement, not a mid-run lane retune. rdist= (0–15; ERB.rprog is a 4-bit wire field) is the gradient-descent router-distance the node claims (Node::RoutingDistanceContext) — the v2 credit-routing relay predicate compares against it; default 0 (“co-located with a router”).
  • link <from>-><to> snr=<dB>|off — a directional override. Both sides accept a bare name, a range (plain[1..5]), or [*] (every already- declared node whose name has that prefix). snr=off uses the kSnrOff sentinel.
  • at <time> <verb> ... — a timed event. Time accepts Ns/Nms/Nm/ Nh suffixes (bare integers are seconds). Verbs:
    • chanmsg from=<node> chan=<#chan> text="..." / dm from=<node> to=<node> text="..." — drives the node’s local IRC session (PRIVMSG).
    • join from=<node> chan=<#chan> — an explicit JOIN, for scenarios that want to test join timing itself (every node auto-joins every channel the scenario ever references at t=0 by default — see §3 — so this verb is for mid-run joins, not baseline membership).
    • kill <node-or-group> / revive <node-or-group> — tears down/rebuilds the node’s lrc::Node instance. Revive keeps the node’s persisted stores (seed, seq, channel records, mailboxes — “reboot with persistence”) but gets a fresh boot-id, exactly like a real reboot.
    • partition <groupA> <groupB> / heal <groupA> <groupB> — cuts/restores every link between two node groups (pairwise, both directions).
    • lane <node-or-group> preset=<0..7|NAME> [freq_slot=N] — a mid-run lane retune (WS-SPECTRUM). Emits the lane trace kind with reason:"scenario" (§6).
    • inject from=<node> [class=chat|discovery|control] [credit=N] [rprog=N] [bytes=N] — radiates a synthetic v2 DM (AC stamped from credit_initial_budget() unless credit= overrides; rprog defaults to the sender’s rdist=; dst is a UID no node owns so the frame is never final-local). A laboratory origination seam — Node does not originate v2 frames itself yet; every RELAY decision the injection triggers is the real credit_forward() gate in core/src/node.cpp. credit_depth.scn is built on it.
    • skew <node> by=<offset> — accepted by the parser (grammar completeness) but not yet wired to Node’s clock seam — see §7.
  • mobility <node> waypoints (x,y)@Ts (x,y)@Ts ... — see §1. Waypoints must be time-ordered.
  • assert <kind> ... — see §3.

The parser does not stop at the first error: a malformed scenario reports every problem found in one pass, each with a line number (sim::ScnError::to_string()"line N: message").

Unit tests: tests/test_sim_scn.cpp (includes the spec’s own golden example as a parse vector).

sim::Runner is the N-node generalization of tests/test_chaos.cpp’s ChaosNet, specialized for .scn files. It wires one real lrc::Node per scenario node through the field model as a radio-only medium — the exact pattern ChaosNet::use_virtual_radio() established (out_to_peers/ out_to_peer unwired, out_to_radio → the field-backed medium, on_radio_frame the only ingest) — so a scenario exercises the same Node/LaneSchedule/AirtimeLedger code that ships, not a parallel model. Every node auto-JOINs every channel the scenario ever references (in a chanmsg event or an assert ... chan= clause) at t=0, so to= addressing in a delivered assertion has somewhere to land without a redundant join line per node.

  • converged chan=#x by=Ts — every alive node with a non-empty membership view of #x counts as converged; passes if every joined node converged (or there are none). by= is accepted for readability but not currently a hard deadline check — convergence is evaluated once, at the end of the run (after a 40-tick/1500ms settle pass, mirroring ChaosNet::settle()).
  • delivered from=<node> to=<node-group-or-all> ratio<op>N [text="..."] — checks what fraction of to’s members received a given message. to= accepts a name, a range, [*], or the word all (every declared node, matching duty_legal all’s vocabulary); a to= that resolves to zero real receivers fails with a “vacuous assert” detail rather than passing on an empty denominator (a typo’d group name used to green-wash). The ratio operator is part of the claim: ratio>=N asserts delivery, ratio<=N asserts NON-delivery (starvation/collision-loss claims — multi_sf_share.scn’s pair C, the region-gate starvation tests), ratio=N pins an exact fraction. Without text=, the checked message is the first chanmsg/dm event whose from= matches (the common case: one message per sender). With text=, it checks a specific message explicitly — needed once a sender speaks more than once in the same scenario (see reboot_storm.scn).
  • duty_legal all — every configured AirtimeLedger (see §4) must never have exceeded its budget; checked via remaining_duty_bp(), which is never negative by construction if admit()/charge() are used correctly.
  • no_dup_delivery all — no node’s local IRC session ever saw the same PRIVMSG line more than once (mirrors test_chaos.cpp’s chaos_duplicate_storm_delivered_once pattern, at the IRC-observable layer).
  • path_len_max N / path_len_min N — the deepest v2 relay path any single (SRC, MSGID) frame took this run (counted from the credit_debit tap — dedup guarantees a node relays a frame at most once, so per-frame relay events = path depth for a chain, relay-set size for a flood) is ≤ / ≥ N.
  • credit_drop_max N / credit_drop_min N — total credit drops this run (credit.drop_exhausted + credit.drop_progress telemetry deltas from run start) is ≤ / ≥ N; the detail line reports the two components separately.
  • anchor_occupancy_max — still accepted-but-skipped (no occupancy data source in Mode A yet); the trace makes the skip visible (detail starts with "skipped: ") rather than silently passing.

Unit tests: tests/test_sim_runner.cpp.

4. Regulatory enforcement (two layers, use exactly one)

Section titled “4. Regulatory enforcement (two layers, use exactly one)”

Regulatory enforcement is REAL in core now: WS-REGION wired duty/dwell/LBT into lrc::Node’s radio TX path (core/src/node.cpp’s emit_radio_frame gate, active when a RegionTxContext is set) and into the firmware radio loop. A scenario can exercise either of two enforcement layers — and per node.h’s double-charge rule, exactly ONE layer per embedding owns enforcement, so the .scn grammar makes them mutually exclusive:

  • field region NAME region_freq_khz N [region_window_ms N] — drives Node’s real gate: the runner calls Node::set_region_tx_context() with the named lrc::kRegionPlans row (validated at parse time), the carrier frequency (required — the gate resolves the legal sub-band from it, and an unresolvable frequency refuses every TX, never falls back), the node’s current preset (refreshed on lane retunes — preset is the airtime currency), and the window (default: the regulatory hour). A refused frame never reaches the medium: no packet_tx in the trace, and the refusal lands on the real tx_duty_defer counter. lrcsim is exactly the embedder class node.h assigns this layer to (synchronous out_to_radio).
  • field duty_bp N duty_window_ms N — the medium’s own ledger: a uniform lrc::AirtimePolicy per node via a standalone lrc::AirtimeLedger at the sim-medium level, useful when a scenario wants budget pressure without naming a real region (the budget is a free parameter, not a table row). A deferred frame shows as sim_drop with reason:"duty_deferred" in the trace, and remaining_duty_bp() backs the duty_legal assertion.

In BOTH layers a deferred frame is dropped, not queued for retry — there is no “wait for budget, then send” queue at these layers (recovery of a starved message, when it happens, is CHANSYNC noticing the gap once a later frame exposes it). eu868_duty_starve.scn asserts both halves of the real gate’s contract under g1 pressure: the budget admits while headroom exists (first message delivered) and never over-admits (duty_legal).

Per-node or per-sub-band budgets in one scenario (a router juggling EU868’s g/g1/g2/g3 sub-bands independently — AirtimeLedger’s per-(lane, sub_band) keying supports it; a single Node context already resolves the sub-band from its one carrier) need a per-node grammar extension this wave doesn’t add.

5. Credit sweeps and the v2 freeze evidence

Section titled “5. Credit sweeps and the v2 freeze evidence”

With the v2 wire batch, AC is live on Node’s relay path, and credit_depth.scn (canonical library, CI-gated) measures reach on REAL relay traffic via the inject verb + the credit_debit trace tap. Three harnesses serve different purposes:

  • lrcsim run tests/scenarios/credit_depth.scn — real-traffic reach per preset through core/src/node.cpp’s actual credit_forward() branch. Headline numbers at the inject reference frame (~87 B): ANCHOR debits 82/hop → 1 relay then exhaustion; REACH 9/hop → 13 relays, the budget binds mid-gradient; TRUNK 1/hop → all 15 gradient-legal relays with 105 units unspent. On fast lanes the binding per-cell constraint is the 4-bit ERB.rprog gradient (15 levels per re-origination domain), not the budget — AC’s job there is bounding cumulative airtime, not hop count.
  • lrcsim credit-sweep — pure-decision-layer per-preset walk (sim/credit_sweep.h), unconstrained by the 4-bit gradient, for budget-arithmetic questions.
  • lrcsim credit-evidence unit|min-debit|duty|lbt — the deterministic sweep tables committed as the v2 freeze evidence (docs/research/traces/credit-freeze/, cited by PROTOCOL.md’s frozen-constants table). Byte-reproducible with exactly these commands.

Every run can emit a newline-delimited JSON event stream (--trace FILE). This is a contract: CI attaches it as a failure artifact (.github/workflows/scenarios.yml), tuning sweeps read distributions off it, and a future web observatory (CHIRPSCOPE) replays it. The schema is reconciled with WS-SCOPE’s live daemon SSE feed (docs/OBSERVATORY.md) so one viewer can replay both a live network and a simulated one:

  • Envelope on every line: v (schema version, currently 1), mono_ms (u64 — the scenario’s virtual clock, not wall time), clock_kind (always "virtual" here — WS-SCOPE’s live feed uses the same field name for its real process-monotonic clock, distinguished by its own clock_kind), kind (event kind).
  • packet_tx / packet_rx — match WS-SCOPE’s kind names and packet- header field set exactly: type (the PacketType C++ enumerator name verbatim — "ChanMsg", "Dm", "RtrSync", …), flags, ttl, hops, msgid, src/dst (lowercase hex, explicit null — not omitted — when the packet’s type group doesn’t carry that field), scope/seq24 (hex CHAN4 / u32, null for non-group-A types), via_tcp, payload_len, plus sim-specific preset/freq_slot/snr_x10 (rx only) and x/y (null when the node has no geometry).
  • sim_drop — the broadcast medium didn’t deliver a frame (reason: below_floor|wrong_lane|duty_deferred|collision| link_down). Sim-only — a live daemon doesn’t see “the medium refused this,” so this kind is prefixed to guarantee it never collides with a future live-feed kind. For collision (co-channel interference, §1a) the line also carries interferer (the node whose overlapping transmission won) and interferer_snr_x10 (its post-rejection effective SNR at the listener); snr_x10 is the desired signal — the drop means their difference fell under the capture threshold.
  • lane — a node’s active lane changed: preset, freq_slot, reason ("scenario" for a lane-event retune, today’s only producer). Named without the sim_ prefix deliberately: a live daemon’s lane retune is the same observable, so the kind is shared with the future live feed rather than forked.
  • sim_node_state — a scenario-driven up/down transition (kill/ revive) or the skew verb’s accepted-but-unwired no-op notice. Sim-only.
  • sim_assert — one assertion’s outcome at run end: assert_kind (not kind, to avoid colliding with the envelope’s own kind:"sim_assert"), line, pass, detail. Sim-only.
  • credit_debit — one line per v2 relay decision, fed from Node::Config::on_event’s NodeEvent.credit/debit_units fields (the shared observability seam the live SSE feed also reads — PROTOCOL.md §Observability — so lrcsim never re-decodes the AC byte off the wire): node, type, src (hex), msgid, credit_in (AC on arrival), debit_units (this hop’s charge), credit_out.
  • Reserved, not yet emitted: dedup; registered/deregistered/router_liveness (WS-SCOPE’s kind names — would be emitted verbatim, not sim_-prefixed, once Mode A surfaces HELLO/WELCOME/BYE/liveness transitions as trace events — a known gap, see §7).
  • Mode B (lrcbridge, real lrcd processes over UDP) is not built. The field model (sim/field.h) has zero dependency on sockets or lrc::Node specifically, by design, so a future lrcbridge can hold the same Field instance — but the broker itself, process spawn/kill, and the SIGKILL mid-write persistence torture are unbuilt.
  • skew (clock skew injection) parses but does nothing. No seam into Node’s clock exists in the .scnRunner path yet.
  • No per-node/per-sub-band regulatory budget. See §4 — both enforcement layers are scenario-uniform today.
  • Lane placement is scenario-driven, not Node-decided. node preset= and at .. lane (§2) place and retune lanes, but the runner does not yet drive LaneSchedule’s own promotion ladder — a scenario can’t yet express “this router PROMOTES clients to a faster lane” as emergent behavior, only as a scripted retune.
  • Same-preset hidden-node collisions are opt-in (field co_sf_collisions 1 — §1a). The default keeps the historical contention-window-managed behavior because the canonical library’s accelerated gossip cadences would self-jam under honest same-preset airtime accounting; contention-window calibration is its own future workstream (SCENARIO_TESTBED.md §4). Cross-preset interference is always on. Also per §1a: in-tick adjudication is one-directional, and half-duplex is not modeled.
  • inject is a laboratory origination seam — Node relays v2 frames (the real credit_forward() gate) but does not originate them; when v2 origination lands in Node, credit_depth.scn should switch from inject to organic traffic. See §5.
  • The radio medium is O(n²) per transmission round (Runner:: emit_radio scans every node). metro_500.scn is scaled to 60 clients (from the spec’s literal 500) for CI tractability; see that file’s own header comment for measurements and what optimizing this would take (spatial partitioning, or a proper multicast group instead of an all-pairs scan).
  • No nightly randomized-seed regression job (SCENARIO_TESTBED.md §6’s regression-seeds list) — only the fixed seeds baked into each canonical .scn run in CI today.