Architecture

MeshBench is one binary on one machine. There is no service, no worker, no backend. The only things that cross the network are *data*: terrain tiles, map tiles, and the optional CoreScope, Beacon and MQTT feeds. Nothing in the simulation depends on anything remote.

Node A real MeshCore firmware its own process its own storage and identity Radio shim / SPI bytes The channel modulate to complex samples delay by distance / c path loss, terrain diffraction antenna gain, per direction sum every transmission in flight add thermal noise It decides nothing. No rule says "these two collide". samples Node B Demodulator decides what decodes capture effect emerges from the arithmetic, not from a rule real firmware, again Every GPU kernel has a CPU twin, and they are tested against each other. A wrong FFT does not crash. It produces a plausible waterfall and slightly wrong sensitivity, and nobody notices for months.
The channel sums waveforms and adds noise. Whether a packet decodes is the demodulator's business, which is why capture effect emerges rather than being coded as a rule.

The rules that keep it honest#

These are enforced by review and by tests, and each one exists because the alternative produces a plausible answer that is wrong.

The channel does not decide anything. It sums waveforms and adds noise. Never add a rule like "if two transmissions overlap, both fail": capture effect must *emerge*, or the simulator is a packet model with extra steps.

Every GPU kernel has a CPU twin, and the two are tested against each other.

Reachability is asymmetric. A can hear B while B cannot hear A: different antennas, heights, powers and noise figures. Every result states which direction, because one that does not is wrong even when the arithmetic is right.

Antenna gain is directional. The pattern is evaluated in the true direction to the far end, per direction. A scalar gain field is a bug.

Position uncertainty propagates. A node imported at ±5 km does not get a confident answer.

Airtime must match the firmware's own getEstAirtimeFor(). The firmware's CSMA timing is built on it. If the channel disagrees, the two desynchronise silently and every timing result is quietly wrong.

Determinism is a feature. Same seed, same scenario, same result. Counter-based RNG, never a stateful stream shared across goroutines.

Where the code is#

internal/ - a package imports its own layer and everything below it ui Gio: panels, the map, the shell app the store, the headless session, the control socket, MCP study coverage, margins, siting, why a link missed, validation sim the engine, the reception ledger, pcapng, replay world the scenario, live feeds, areas, basemap, the SDR observer firmware running real firmware against the radio: boards, native, QEMU, Renode mesh what a node is and says: the radio shim, the companion protocol, packets rf the channel, DSP, GPU twins, LoRa coding, terrain, buildings diag opt-in diagnostic logging, chosen by domain (MESHBENCH_LOG) imports point down So ui can reach the physics, and the physics cannot reach a widget. A test fails the build otherwise.
The order was read off the import graph rather than imposed on it. Making it true cost two packages that were each doing two jobs, and one interface moved down a level.

Nine layers, and the rule is mechanical: internal/layers_test.go walks every file and fails if an import points upward, or if a package appears outside the nine.

The engine loop#

The engine steps in fixed increments, 10 ms by default. On each step it advances every node's firmware, collects whatever those nodes handed to their radios, places the resulting transmissions in flight, and delivers to each receiver the sum of everything audible at its antenna.

advance every node’s firmware each gets its slice of the tick collect what they handed their radios bytes, through the shim or the SPI model place transmissions in flight with airtime the firmware agrees with deliver the sum at each antenna everything audible, plus noise one tick = 10 ms of simulated time Simulated time is not wall time: native runs race ahead of the wall on small networks; an emulated node pins it to the clock.
The engine’s whole job, once per tick. Nothing else touches the air.

Simulated time is not wall time. A native run can go faster than real time on a small network and slower on a large one, and neither changes the result. This is exactly what emulation gives up: an emulated node runs at the speed of the emulator, so simulated time is pinned to the wall clock and two runs of one seed do not agree.

The control socket#

The application listens on $XDG_RUNTIME_DIR/meshbench.sock, newline delimited JSON. Every verb drives the same code path a person clicks, so a driven session opens the same panels and shows the operator what happened. See the control socket reference.

Verbs are serviced on the frame thread. That is a real constraint rather than an implementation detail: it is why a headless mode is a separate mode rather than the application running without a window.

MeshBench documentation. Built from the running application, not from mock-ups. Screenshots are window-only captures; see CLAUDE.md for the rule that keeps them current. Edit this page.