Emulating a board
Emulation runs the published firmware image - the same file that is flashed onto hardware - on a model of the chip it was built for, against MeshBench's radio channel.
It answers a question the native path cannot: does this release come up on this board, configure itself, and put a correctly formed packet on the air.
Why the boundary is the SPI bus#
MeshCore reaches its radio through SPI: command opcodes, register reads and writes, a busy line and an interrupt line. Modelling the chip at that boundary means the firmware above it needs no modification, and what gets verified is the binary people flash rather than a re-implementation of what it should do.
The chip model#
radioserver presents the SX1262 interface and turns transmit commands into waveforms for the channel. It runs as a separate process and is shared by all three ways of running firmware:
| firmware | connection to the chip model |
|---|---|
| native build | linked in process, no socket |
| QEMU | Unix domain socket |
| Renode | TCP |
One model rather than three means the radio behaves the same whichever way a node is run.
ESP32, under QEMU#
The Xtensa path uses QEMU with an SX1262 device attached to the SPI controller. Two build requirements:
- The SX1262 device must be present in the tree being built. A build without it produces a QEMU that starts normally and reports no chip.
--enable-gcryptis required, or theesp32machine will not instantiate.
nRF52, under Renode#
The Cortex-M4 path models more of the chip, because more of it is touched before the radio is reached.
SPIM3 and EasyDMA#
The nRF52840 has four SPI master instances; these boards use SPIM3 at 0x4002F000. It is identifiable from its register offsets: 0x118 is EVENTS_END, 0x304 and 0x308 are INTENSET and INTENCLR.
SPIM has no byte-at-a-time data register. The firmware writes a buffer pointer and a length into TXD.PTR and TXD.MAXCNT, the same for RXD, and triggers TASKS_START. The peripheral model reads the transmit buffer out of guest memory, runs the transaction, writes the reply into the receive buffer and raises EVENTS_END. A model that does not implement EasyDMA transfers no bytes, and the firmware waits indefinitely for a reply.
Chip select is a GPIO#
An SX1262 transaction is delimited by NSS going low and then high. Renode's SPI infrastructure does not signal the end of a transaction to the peripheral, so NSS is wired as what it physically is: a GPIO pin. On the RAK4631 that is gpio1 pin 10, with the DIO1 interrupt on gpio1 pin 15.
These pin assignments are a property of the board and are recorded per board rather than per emulator.
SEVONPEND#
Published nRF52 firmware sets SEVONPEND and executes WFE, which requires the core to wake when an interrupt becomes pending whether or not it is enabled to fire. The emulator's CPU core must implement that distinction, and the wake path has three parts: the pending-interrupt query, the binding that exposes it to the core, and the call that wakes the sleeping thread. Without all three the firmware sleeps and the node is silent while appearing healthy.
Flash that survives a restart#
An nRF52 board keeps what its firmware stores from one start to the next, as a flashed board does. A preference set over the console, such as set af 7, reads back the same after node.stop and node.start. Two things make that work.
The factory registers give the flash's geometry: 256 pages of 4 kB, in CODEPAGESIZE and CODESIZE. The SoftDevice checks every erase and write against them, and with both reading zero it refuses every flash operation with NRF_ERROR_FORBIDDEN, so the filesystem never formats and nothing the firmware saves is written.
The filesystem's pages are kept outside the emulator. Renode builds flash from the image at every start, so whenever the flash controller returns to read-only after a write or an erase, the region the firmware keeps its files in (the Adafruit core's InternalFS, 28 kB at 0xED000) is written to the node's own directory, and loaded back over the image at the next start. Only that region is kept: anything written elsewhere in flash is rebuilt from the image.
The kept file is named for a hash of the image, so a different build starts with empty storage, as reflashing a board does. wipe every node's memory in the firmware library deletes it with the rest of each node's stored state.
Peripherals touched during boot#
| peripheral | why the firmware needs it |
|---|---|
TEMP | temperature read at start-up |
CLOCK | HFCLK and LFCLK start, waited on before anything proceeds |
SAADC | battery voltage |
TWIM | I2C, for displays and sensors on some boards |
Displays#
A board's display is drawn in the board view when its controller is modelled. Under Renode there are two kinds.
SPI panels, an ST7789 and an ST7735, share the radio's SPIM and are told apart by chip select, which is how they are wired in copper.
I2C displays, an SSD1306 or an SH1106, are registered on the first TWIM at their address, 0x3C on the RAK4631. A transfer to a registered address moves its bytes through EasyDMA (TXD and RXD PTR, MAXCNT, AMOUNT) and follows the shortcuts the driver sets. An address nothing claims is answered with an address NACK, so a firmware probing for a sensor the board does not carry gets an answer rather than waiting for one.
The display model takes the controller's control bytes, its column and page window, its addressing mode and display on and off. Other commands are consumed with their arguments and ignored, so a firmware that sets contrast or scrolling sees no error and no effect. The controller's memory is drawn as a one-bit frame, and an SH1106 is drawn from column 2, where its glass starts.
On a RAK4631 the published v1.17.1 repeater draws MeshCore's own boot screen: the logo, the version line and < Repeater >. The same frame is what board.screen returns.
| SX1262 over SPIM | the radio |
|---|
Which boards have been run#
Every row is a measurement rather than a claim: the firmware is the released image from MeshCore's own releases, and a blank cell means nobody has watched that board do that thing.
| Board | MCU | Emulator | build | boot | radio | tx | rx | flood | fem | power |
|---|---|---|---|---|---|---|---|---|---|---|
Generic_E22_sx1262 | ESP32 | QEMU | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Heltec_t114 | nRF52840 | Renode | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | ✓ |
Heltec_t096 | nRF52840 | Renode | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ? | ✓ |
RAK_4631 | nRF52840 | Renode | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | ✓ |
Xiao_nrf52 | nRF52840 | Renode | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | ✓ |
Heltec_mesh_solar | nRF52840 | Renode | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | ✓ |
Xiao_S3_WIO | ESP32-S3 | QEMU | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | ? |
Heltec_v3 | ESP32-S3 | QEMU | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | ✓ |
LilyGo_TDeck | ESP32-S3 | QEMU | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | ✓ |
Ebyte_EoRa-S3 | ESP32-S3 | QEMU | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | ✓ |
Heltec_E290 | ESP32-S3 | QEMU | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | – | |
heltec_tracker_v2 | ESP32-S3 | QEMU | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ||
Station_G2 | ESP32-S3 | - | ||||||||
Heltec_v2 | ESP32 | - |
✓ passed ✗ failed – not applicable ? not measurable yet blank not attempted
What the columns ask for:
| column | what it means |
|---|---|
| build | a published image whose digest checks out |
| boot | the emulator attached and the node did not spend the run restarting |
| radio, tx | it put its own unprompted advert on the air |
| rx | it heard another node |
| flood | it forwarded somebody else's packet, judged at the board itself |
| fem | a front-end module was switched in |
| power | it still answered after being left idle |
flood is the column worth reading closely, because it is the one that says the board is a working member of a mesh rather than a node that talks to itself. It is judged at the board: the packet has to arrive, be recognised as somebody else's, and go back out.
Station_G2 and Heltec_v2 have never been attempted rather than tried and failed. The Heltec_E290 runs and floods, but its e-paper panel is not drawn. The heltec_tracker_v2's front-end module is switched by the radio rather than by a GPIO, and its fem column has not been measured. The power column has not been measured for either board.
Measured one board at a time on an idle machine. Several emulators at once will make a twelve core machine fall behind the wall clock, and a board that is running late looks exactly like a board that is broken - so a row measured beside seven others is not a measurement.
Cost and limits#
Each emulated node is a separate emulator process running in real time, costing roughly one core and 150 MB.
Around eight nodes is the practical ceiling on a twelve core machine. Past it nothing reports an error: boot times stretch, simulated time falls behind the wall clock, and the network appears to go quiet.
Because execution is tied to the wall clock, two runs of the same seed do not produce the same result. Emulation is therefore used to verify that a release works, and native firmware is used for anything that is measured.
Lockstep under Renode#
An nRF52 board under Renode can instead be run from the engine's clock. It is opt-in, set in the environment the workbench starts from:
MESHBENCH_RENODE_LOCKSTEP=1 meshbench
With it set, three things change for every Renode board:
- The CPU advances only as far as the engine asks. A clock entry in the machine's own virtual time ticks the radio chip once a millisecond, then holds the CPU until the engine grants the next millisecond. Renode's virtual time comes from the instructions executed, so the CPU and the chip step with the engine rather than with the host.
- Idle time is skipped rather than waited out on the host clock, since in lockstep the engine is the clock.
- The machine's random numbers are seeded from the node. The nRF52's RNG peripheral is where MeshCore draws its jitter from, and unseeded it gives a fresh stream every run.
Two idle runs of two RAK4631 repeaters in lockstep give byte-identical event logs and identical chip counters.
It does not make a run reproducible, and sim.state still says so. Console input reaches the board's UART over a socket on the wall clock, so the lines provisioning types at boot and any console.type land wherever the machine happens to be, and a loaded machine moves them. Lockstep is also slower: about five times real time for two boards. QEMU boards are not affected by the setting and still run free.
Assigning an emulated build#
In the firmware library, board images are listed alongside native builds. use for role assigns one to every node of that role, and reports how many nodes that will be before it does. Each of those nodes becomes its own emulator.

Board images are listed and downloaded through GitHub's API, which allows an unauthenticated address sixty requests an hour: a handful of board probes. When the allowance is used up, the refusal says so and gives the time it resets:
GitHub's API allowance for this address is used up until 14:05; set GITHUB_TOKEN (export GITHUB_TOKEN=$(gh auth token)) to raise it. Already-downloaded images keep working without it
Requests are authenticated with GITHUB_TOKEN whenever it is set.