Validated on the air
How MeshBench's LoRa coding chain was checked against a real SX1262 - not against a paper, not against another simulator, but against what an actual chip put on the air. This is the experiment behind the golden vectors in internal/rf/lora/testdata/, run on 18 August 2026, and the method is repeatable whenever the chain changes.
The problem it solves: the LoRa PHY's bit-level details - the sync word, the parity equations, the whitening sequence, the CRC - are not published by Semtech. Everything anyone implements comes from reverse-engineering literature, and the implementations in the wild disagree with each other on several of them. A simulator that is merely *self-consistent* decodes its own transmissions perfectly and proves nothing. The only referee is silicon.
The rig#
Two pieces of ordinary MeshCore hardware, neither modified:
- A KISS modem - MeshCore's own
kiss_modemfirmware on an SX1262 board, plugged into the dev machine over USB. Its SetHardware extensions let MeshBench set the radio (frequency, bandwidth, SF, coding rate) and transmit an arbitrary raw payload, and it reportsTxDonewhen the frame has left the antenna. - An RTL-SDR on a mast, about ten metres away, exposed over the network as a standard
rtl_tcpserver. Its antenna is not even tuned for 868 MHz; at ten metres that does not matter.
A repeater that normally lives next to the mast was put in monitor-only mode for the runs, so the test frames would not be relayed back over themselves.
The method#
One run is one commanded transmission:
goldencapprograms the modem's radio - the UK/EU narrow preset, 869.618 MHz, 62.5 kHz, SF8, CR 4/8 - and confirms the settings took by reading them back.- It opens the
rtl_tcpstream, tuned 150 kHz below the channel so the dongle's DC spike stays out of the signal, and starts buffering IQ. - It transmits a known payload and waits for the modem's
TxDone. - It finds the burst in the capture, mixes it to baseband, decimates to one sample per chip - the rate every piece of MeshBench's DSP speaks - and runs the simulator's own receiver over it: preamble search, SFD lock, CFO correction, per-symbol FFT.
- Every demodulated symbol is compared against what
internal/rf/lorasays that payload should encode to. Any difference is a place where the simulator and the chip disagree about LoRa itself.
The receiver being MeshBench's own is the point. The experiment does not ask "can something decode this" - it asks whether *MeshBench's* transmit chain and *MeshBench's* receive chain, pointed at real silicon, agree with it bit for bit.
What the air showed#
The first capture's raw structure, read window by window off the dechirped spectrum, confirmed the frame's shape exactly as modelled - and corrected one value:
Then the symbol diff did something better than pass or fail: it *localised* the disagreement. Within every eight-symbol interleaver block, the first four symbols matched the simulator exactly and the last four did not. In LoRa's diagonal interleaver, the first four columns carry the data bits of every codeword and the last four carry the parity bits - so the diff itself said: whitening, Gray coding, the interleaver and the header layout are all exactly right, and only the parity equations are wrong.
With the known data nibbles on one side and the captured parity bits on the other, each parity bit is an unknown XOR of data bits - sixteen possible masks, checked against seventy observations. The chip's answer:
p0 = d0 ^ d1 ^ d2 p1 = d1 ^ d2 ^ d3
p2 = d0 ^ d1 ^ d3 p3 = d0 ^ d2 ^ d3Four three-input XORs - not the textbook Hamming-plus-overall-parity a first implementation reaches for. The same technique, applied to the frame's own CRC field against the known payload, settled the last convention: the payload CRC is CCITT from a zero seed over all but the final two bytes, with those two bytes then XORed straight into the result.
After those three corrections - sync word, parity matrix, CRC - a real SX1262 frame decodes end to end through MeshBench's receive chain: sync lock, frequency correction, demodulation, error correction, dewhitening, header, CRC, and the exact payload out the far side.
What the capture tooling defends against#
A live capture can mislead in four specific ways, and the tooling defends against each:
- A DC spike beats a chirp in a naive spectrum search. A chirp's spectrum is a plateau, not a peak, so "find the strongest bin" lands anywhere in the occupied band - or on the dongle's DC offset. The search now takes the midpoint of the occupied band, away from DC.
- Half a chip of timing smears every bin. The decimator's sampling phase relative to symbol boundaries is luck; near a half-sample, every bin reads as a smear between neighbours. The tool now tries every decimation phase and keeps the one whose frame decodes best - sub-chip timing recovery by exhaustive search.
- A live channel damages frames. A single flipped bit - a collision or a fade, on a frequency other repeaters genuinely use - is enough. A reference vector carrying channel damage would teach the test the wrong bits, so the tool refuses to write a vector from any frame the error correction had to repair. Recapture instead.
- The chip pads with garbage. The final interleaver block's padding nibbles decode to nothing deterministic - uninitialised buffer contents no receiver ever reads. Golden comparison stops at the last block made entirely of meaningful nibbles, and requires the whole air frame to decode instead.
What stands afterwards#
Two clean captured frames, at different payload lengths and therefore different block layouts, are checked into internal/rf/lora/testdata/ as golden vectors. The test suite holds the encoder to them symbol by symbol - at each symbol's own coded rate, since reduced-rate symbols only carry their top bits - and requires each captured frame to decode to its payload with a valid CRC. The chain cannot drift from silicon without a test saying so.
To repeat the experiment after a chain change, or to add vectors at other spreading factors:
go run ./tools/goldencap -probe
go run ./tools/goldencap -run -payload "a known payload" \
-out capture.iq -golden internal/rf/lora/testdata/golden-sfX-crY.json-probe says who is on the serial port and how its radio is set; -run does a full transmit-capture-diff; -analyze re-runs the analysis on a saved capture without transmitting anything.