A single-process network simulator for MeshCore LoRa mesh networks. Runs unmodified MeshCore firmware code against a configurable virtual radio layer with realistic RF propagation, collisions, and half-duplex constraints. Supports running multiple firmware versions simultaneously in the same simulation — useful for testing backward compatibility and mixed-version meshes.
orchestrator/ Multi-node simulator engine (C++) — main tool
Orchestrator.cpp Simulation loop, physics, collision detection
JsonConfig.cpp Config parser
LuaEngine.cpp Lua scripting bindings and event dispatch
FirmwarePlugin.cpp Plugin loader (dlopen/dlsym)
firmware/ Firmware plugin sources (node factories, exports)
shims/ Platform shim layer (Arduino, FS, crypto, radio)
simple_repeater/ Standalone single-repeater binary
companion_radio/ Standalone single-companion binary
MeshCore/ MeshCore firmware sources (not in repo; fetched by
`tools/firmware.py init`, never modified)
MeshCore-*/ Alternate firmware trees (e.g. -1.13, -default-scope)
used for multi-firmware testing — see docs/MULTI_FIRMWARE.md
topology_generator/ Python module — ITM-based topology generation from live network
tools/ Python helpers — convert, inject, grid-generate, run, analyze
visualization/ Python + HTML — swim-lane + map viewer (visualize.py)
webapp/ Browser UI (FastAPI + vanilla JS + Docker)
test/ t*.json test configs, run_tests.sh harness, Lua integration tests
simulation/ Generated topology + scenario configs (most regeneratable;
see "Working with Real Topologies" below)
docs/ Config format, radio model, Lua API, multi-firmware guide
delay_optimization_v2/ Experimental sweep harness for delay parameter tuning
across network densities; produces CSV result tables
Requires CMake 3.16+, a C++17 compiler, OpenSSL development libraries, Python 3.10+, and liblua5.4-dev (for Lua scripting, enabled by default).
# Install system dependencies (Debian/Ubuntu)
sudo apt install build-essential cmake libssl-dev liblua5.4-dev python3 python3-venv
# Clone the repo
git clone https://github.com/stachuman/meshcore_simv2.git
cd meshcore_simv2
# Set up Python virtual environment
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Clone all MeshCore firmware sources (from firmware.json registry)
python3 tools/firmware.py init
# Build orchestrator + all firmware plugins
python3 tools/firmware.py build
# Quick run - simulation and then visualization
./tools/run_sim.sh ./test/t34_delay_bench_butterflyFor production use, build with optimization flags:
NativeRelease (maximum performance, local machine only):
cmake -S . -B build-native -DCMAKE_BUILD_TYPE=NativeRelease
cmake --build build-native -j$(nproc)- Uses
-O3 -march=native -flto=auto -ffast-math - 4-6x faster than unoptimized builds
- CPU-specific (not portable to different CPUs)
- Used automatically by delay_optimization scripts
Release (portable, Docker-friendly):
cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release
cmake --build build-release -j$(nproc)- Uses
-O3 -flto=auto - 2-3x faster than unoptimized builds
- Portable across x86_64 CPUs
- Used by Docker builds
All MeshCore firmware sources are tracked in firmware.json. Use tools/firmware.py to manage them:
python3 tools/firmware.py list # show registered sources
python3 tools/firmware.py status # detailed git/build status
python3 tools/firmware.py add myfork https://github.com/user/MeshCore.git --branch dev
python3 tools/firmware.py update # git pull all sources
python3 tools/firmware.py build --build-type Release -j$(nproc)To use a different MeshCore tree without the registry, point MESHCORE_DIR directly:
cmake -S . -B build -DMESHCORE_DIR=/path/to/MeshCore-forkFor multi-firmware testing (running nodes with different MeshCore versions simultaneously), see docs/MULTI_FIRMWARE.md.
The Python venv must be active when running visualization or topology tools (source venv/bin/activate).
Lua scripting is enabled by default (-DENABLE_LUA=ON). To disable it (removes the liblua5.4-dev dependency):
cmake -S . -B build -DENABLE_LUA=OFFSee docs/LUA_SCRIPTING.md for the full API reference, examples, and usage guide.
This produces three binaries:
build/orchestrator/orchestrator-- the multi-node simulator (main tool)build/simple_repeater/simple_repeater-- standalone single-repeater binarybuild/companion_radio/companion_radio-- standalone single-companion binary
build/orchestrator/orchestrator test/t02_hot_start_msg.jsonNDJSON event log goes to stdout, summary and assertions to stderr.
tools/run_sim.sh test/t06_msg_stats.jsonThis runs the orchestrator, saves events as t06_msg_stats_events.ndjson, and opens an interactive swim-lane visualizer in the browser (plus a topology map view if the config has coordinates).
bash test/run_tests.shDiscovers all test/t*.json files and reports pass/fail.
Step through the simulation manually and use Lua for ad-hoc queries:
build/orchestrator/orchestrator -i test/t01_hot_start_neighbors.json[ 0.000s] > step 6000
> {"events":0,"finished":false,"stepped_to_ms":6000}
[ 6.000s] > lua sim:for_each_repeater(function(n,s) log(n..": "..s.neighbor_count.." neighbors") end)
[lua] relay1: 2 neighbors
[ 6.000s] > lua local s = sim:msg_stats("alice"); log("sent="..s.total_sent)
[lua] sent=0
Drive the simulation entirely from a Lua script:
build/orchestrator/orchestrator --lua test/lua/test_basic.lua test/t01_hot_start_neighbors.jsonSee docs/LUA_SCRIPTING.md for the full Lua API.
Configs are JSON files with these sections:
{
"simulation": {
"duration_ms": 90000,
"step_ms": 4,
"warmup_ms": 5000,
"hot_start": true
},
"nodes": [
{ "name": "alice", "role": "companion" },
{ "name": "relay1", "role": "repeater" },
{ "name": "bob", "role": "companion" }
],
"topology": {
"links": [
{ "from": "alice", "to": "relay1", "snr": 8.0, "rssi": -80.0, "bidir": true },
{ "from": "relay1", "to": "bob", "snr": 8.0, "rssi": -80.0, "bidir": true }
]
},
"commands": [
{ "at_ms": 6000, "node": "alice", "command": "msg bob hello" }
],
"expect": [
{ "type": "cmd_reply_contains", "node": "alice", "command": "msg bob", "value": "msg sent to bob" }
]
}hot_start-- injects mutual node awareness at t=0 (skips the slow advert exchange)warmup_ms-- instant packet delivery during warmup (no collisions/physics)message_schedule-- auto-generates periodicmsg/msgacommands (supports"ack": true)
See docs/CONFIG_FORMAT.md for full reference.
Generate simulation configs from real-world node data using the topology generator -- a two-stage pipeline:
topology_generator-- fetches live node positions from the MeshCore network API, computes RF links using the ITM propagation model, and outputs a topology configinject_test.py-- places companion nodes, generates message schedules, and produces a ready-to-run simulation config
The included prepare_gdansk_test.sh runs the full pipeline:
bash prepare_gdansk_test.sh
build/orchestrator/orchestrator simulation/gdansk_test.jsonThis fetches ~100 repeaters from the Gdansk/Pomerania region, computes ITM-based RF links, places 4 companions on well-connected repeaters, and generates a 15-minute test with direct messages and channel broadcasts.
# Step 1: Generate topology from live network data
python3 -m topology_generator \
--region 53.7,17.3,54.8,19.5 \
--freq-mhz 869.618 --tx-power-dbm 20.0 --antenna-height 5.0 \
--sf 8 --bw 62500 --cr 4 \
--max-distance-km 40 --min-snr -10.0 \
--max-edges-per-node 12 --link-survival 0.4 \
--clutter-db 6.0 \
-v -o simulation/gdansk_topology.json
# Step 2: Inject companions and message schedules
python3 tools/inject_test.py simulation/gdansk_topology.json \
--companions 4 --companion-names alice,bob,carol,dave \
--min-neighbors 2 \
--auto-schedule --channel \
--msg-interval 70 --msg-count 5 \
--chan-interval 80 --chan-count 4 \
--duration 900000 \
-v -o simulation/gdansk_test.json
# Step 3: Run and visualize
tools/run_sim.sh simulation/gdansk_test.json -vKey topology generator flags:
| Flag | Default | Description |
|---|---|---|
--region |
(required) | Bounding box: lat_min,lon_min,lat_max,lon_max |
--freq-mhz |
869.618 | LoRa carrier frequency |
--max-distance-km |
40 | Skip node pairs beyond this range |
--min-snr |
-10.0 | Drop links below this SNR |
--link-survival |
1.0 | Stochastic link survival probability (0.4 = realistic sparsity) |
--clutter-db |
6.0 | Additional attenuation for urban/suburban clutter |
--max-edges-per-node |
12 | Safety cap on neighbor count |
--api-cache |
none | Cache API responses to avoid repeated downloads |
See docs/TOPOLOGY_GENERATOR.md for full documentation.
Nodes in a single simulation can run different MeshCore firmware versions simultaneously. Each firmware version is compiled into its own shared-library plugin (fw_<name>.so); nodes pick their plugin per-config via the firmware field. This is the core feature for testing backward compatibility, rollout transitions, and mixed-version mesh behavior.
# Clone the variants alongside this repo (firmware trees are gitignored; use
# whatever MeshCore branches/forks you care about)
git clone -b v1.13 https://github.com/ripplebiz/MeshCore.git MeshCore-1.13
git clone -b default-scope https://github.com/ripplebiz/MeshCore.git MeshCore-default-scope
# Build the orchestrator with two extra plugins on top of fw_default
cmake -S . -B build \
-DFIRMWARE_PLUGINS="v113=$(pwd)/MeshCore-1.13;scope=$(pwd)/MeshCore-default-scope"
cmake --build build -j$(nproc)This produces three plugins next to the orchestrator binary: fw_default.so, fw_v113.so, fw_scope.so. A config can then mix them:
"nodes": [
{ "name": "alice", "role": "companion", "firmware": "fw_v113" },
{ "name": "relay", "role": "repeater", "firmware": "fw_scope" },
{ "name": "bob", "role": "companion" }
]Alice runs v1.13, relay runs the default-scope branch, bob runs whatever fw_default was built from — all three exchange packets over the same virtual radio. A test can require specific plugins via the top-level _requires_plugins field; the test runner skips (with a clear message) when they aren't built.
See docs/MULTI_FIRMWARE.md for the full story: per-plugin include isolation, how dlopen keeps symbol tables separate, version-string detection, and troubleshooting.
For a simpler "my fork vs. stock" test (not mixed simulation), build a separate tree with MESHCORE_DIR:
git clone https://github.com/YOUR_USER/MeshCore.git ../MeshCore-fork
cmake -S . -B build-fork -DMESHCORE_DIR=$(pwd)/../MeshCore-fork
cmake --build build-fork
build/orchestrator/orchestrator test/t06_msg_stats.json 2>/tmp/stock.txt
build-fork/orchestrator/orchestrator test/t06_msg_stats.json 2>/tmp/fork.txt
diff /tmp/stock.txt /tmp/fork.txtMESHCORE_DIR is cached — after switching branches in the fork tree, just cmake --build build-fork again, no reconfigure needed.
The simulator models realistic LoRa radio behavior:
- LoRa airtime -- exact symbol/preamble/payload timing for any SF/BW/CR combination
- Half-duplex -- nodes cannot transmit while receiving (and vice versa)
- Collisions -- 3-stage survival: capture effect (6dB), preamble grace window, FEC tolerance
- Listen-before-talk -- preamble detection delay, SNR-gated channel busy notifications
- SNR variance -- per-link Gaussian sampling (
snr_std_dev) - Stochastic loss -- per-link drop probability (
loss) - Adversarial modes -- per-node packet drop, bit corruption, or delayed replay
- Message fate tracking -- follows each message through the relay chain, counting per-message collisions, drops, and ACK/PATH copies reaching the sender to diagnose delivery failures and ack efficiency
The delay_optimization_v2/ directory contains Lua-based scripts for sweeping MeshCore delay parameters (e.g. FLOOD_DELAY_MS, ADV_INTERVAL_MS) across different network densities (sparse, medium, dense, very dense). Each density has a pre-built topology and test config; run_sweep.sh drives the parameter grid search and outputs CSV results.
The visualizer serves two interactive views:
python3 visualization/visualize.py events.ndjson --config config.json- Swim-lane view (port 8000) -- timeline of TX/RX/collision events per node, with packet tracing
- Map view (port 8001) -- geographic topology with link SNR coloring (requires
--configwith node coordinates)
Controls: scroll to zoom, drag to pan, click packets for details, press T for spread-tree trace.
Generate grid topology tests with configurable dimensions:
python3 tools/gen_grid_test.py --rows 5 --cols 5 -n 4 -o test/t_custom_grid.jsonCreates a repeater grid with companion nodes at the corners, auto-generates cross-grid messaging commands and discovery assertions.
A browser-based UI for the full simulation workflow -- from topology creation through simulation execution to result visualization.
Features:
- Topology Creator -- generate topologies from the live MeshCore network API with ITM propagation modeling, or create manually
- Topology Editor -- selection-driven node/link editing with an interactive map, SNR-colored links, drag-to-reposition
- Scenario Editor -- visual config builder: define nodes, links, commands, message schedules, and assertions
- Batch Simulations -- run simulations with real-time progress via SSE, view results in swim-lane and map visualizations
- Interactive Simulations -- step-through investigation with swim-lane + live map side-by-side, scrubbable history, per-TX outcome overlays, collision/radio-busy heatmaps, TX effectiveness bars, and animated flood-propagation trees. See below for details.
- Config Library -- save, duplicate, import/export simulation configs
No database (filesystem-only storage), no build step (CDN-served JS libraries).
Live step-through investigation of a running sim with a swim-lane and map side-by-side. Send commands, advance the clock, watch the network react event-by-event. Screenshot at the top of this README.
Live visualization:
- Swim-lane timeline (left) with per-node event lanes, airtime bars, packet tracing, and filterable event log
- Geographic map (right) with live node state (idle / TX / RX / collision flashes)
- Per-TX overlay lines colored by outcome:
- green solid = delivered to this receiver
- orange dashed = collision (with dark halo for visibility; tooltip shows interferer + capture margin)
- red dashed = SNR below demod threshold
- magenta dashed = stochastic link-loss roll
- gray dashed = receiver was half-duplex busy
- Hover any overlay for per-receiver SNR / margin / interferer details
History + scrubbable replay:
- Rolling 2-minute event buffer in the frontend, with a density timeline (histogram of events/bucket) below the map
- Click or drag the timeline to scrub to any past moment — the map freezes, overlays re-render as they were at that time
- "Resume live" button returns to following the current frontier
- Scrub-time syncs with the swim-lane viewport automatically
Analytical overlays (toggle independently in the map legend):
- Radio busy — bars — per-node % of radio occupation (TX + RX), vertical bar at each node with color thresholds (green <10%, amber 10-30%, red >30%)
- Radio busy — heatmap — same data as diffuse blue→purple spatial blobs, distinct palette so it composes cleanly with other overlays
- Collision heatmap — amber→red hot zones on nodes that suffered collisions, plus an exact per-node collision count badge (log-scaled intensity so small counts stay visible)
- TX effectiveness — % of each node's transmissions that reached ≥1 neighbor, inverse color (green = healthy, red = wasted airtime); answers "is this node talking into the void?"
Flood-propagation tree:
- Click any TX overlay (on the map or in the swim-lane) to trace its full hop-by-hop spread
- Animated expansion at real sim-time pacing: ★ marks the root sender, numbered ● dots appear at each arrival node in order with timing labels ("Hop 3 at relay1 +412 ms")
- Cyan lines for relay hops, purple lines for response (PATH-return) hops
- "Clear trace" button or click another TX to replace
Cross-view sync:
- Click a TX box / node label in the swim-lane → the map flies to that node with a pulse ring
- Drag the map's time scrubber → swim-lane viewport recenters on the scrubbed time
- Trace a packet in either view → the other view renders the same tree
Sessions auto-terminate when you navigate away; a 60-second idle fallback handles unclean shutdowns.
cd webapp
pip install -r requirements.txt
uvicorn server.main:app --port 8000Build requires the Release orchestrator binary and firmware plugins first:
# 1. Build orchestrator + all firmware plugins (Release)
python3 tools/firmware.py build --build-dir build-release --build-type Release -j$(nproc)
# 2. Build the Docker image
cd webapp
docker compose buildRun locally:
docker compose up# On the build machine — save image to a tarball
docker save meshcore-sim:latest | gzip > meshcore-sim.tar.gz
# Copy to target
scp meshcore-sim.tar.gz user@nas-ip:/share/docker/
# On the target machine — load and run
docker load < meshcore-sim.tar.gz
docker run -d --name meshcore-sim \
-p 8000:8000 \
-v meshcore-data:/app/data \
--restart unless-stopped \
meshcore-sim:latestThe -v meshcore-data:/app/data volume persists simulation data across container restarts.
Note: The image is x86_64. The target machine must have an Intel/AMD CPU (most QNAP/Synology NAS models do).


