No description
  • Zig 99.9%
  • Nix 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 02adf6b5d8
Some checks are pending
test / docs (push) Blocked by required conditions
test / test (push) Has started running
Rename the project to zig-dump1090
The Forgejo repository has always been zig-dump1090; everything else said
dump1090-zig. Now they agree: the README title, the documentation site the
workflow publishes to, the flake description, REUSE's SPDX-PackageName,
the version banner, and the Radicle identity.

The Zig package name in build.zig.zon stays `dump1090`, and so do the
module, library and executable names in build.zig -- the program is called
dump1090, which is upstream's name for it and what anyone running it
types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LihwT2HJSoj6WWcUABgdSJ
2026-09-07 20:44:10 -05:00
.forgejo/workflows Rename the project to zig-dump1090 2026-09-07 20:44:10 -05:00
LICENSES Port the dump1090 decode path to Zig 2026-09-07 13:38:55 -05:00
src Rename the project to zig-dump1090 2026-09-07 20:44:10 -05:00
tools Port the dump1090 decode path to Zig 2026-09-07 13:38:55 -05:00
.gitignore init 2026-09-07 11:48:13 -05:00
build.zig Replace librtlsdr with zig-rtlsdr 2026-09-07 20:01:47 -05:00
build.zig.zon Move to the new Forgejo host and documentation site 2026-09-07 20:37:05 -05:00
flake.lock init 2026-09-07 11:48:13 -05:00
flake.nix Rename the project to zig-dump1090 2026-09-07 20:44:10 -05:00
README.md Rename the project to zig-dump1090 2026-09-07 20:44:10 -05:00
REUSE.toml Rename the project to zig-dump1090 2026-09-07 20:44:10 -05:00

zig-dump1090

A port of FlightAware's dump1090 — a Mode S and ADS-B decoder for software defined radios — to Zig 0.16.

The API documentation is generated from the doc comments in the source and published at https://jeff.jcollie.page/zig-dump1090/.

Where it lives

git clone ssh://git@git.jcollie.dev/jeff/zig-dump1090.git

It is also on Radicle, where a repository is findable only by its ID, so here is that ID — rad:z2N42qvZ6qBHsJsfUzv2JXDo2iUyH — and the command that fetches it:

rad clone rad:z2N42qvZ6qBHsJsfUzv2JXDo2iUyH

Status

Complete, in the sense that everything in scope is ported and checked: the decoder, the tracker, the demodulator, the four network outputs and two network inputs, the JSON files, the statistics, the adaptive gain control, and every command-line option upstream accepts, and it has been run against real air traffic on a live radio.

Each module is checked against the C it was ported from before the next is started, by running both over the same inputs and diffing the results field by field. Where the table below says a module agrees with upstream, that is what it means.

Module Upstream State
src/modes.zig dump1090.h Frame lengths and downlink formats
src/crc.zig crc.c Done; syndrome tables verified identical to upstream's
src/cpr.zig cpr.c Done; 600k random decodes agree with upstream's to 1e-9 degrees
src/bits.zig mode_s.h Field extraction, numbered from 1 as the standards are
src/ais.zig ais_charset.c The six-bit callsign character set
src/icao_filter.zig icao_filter.c Done
src/mode_ac.zig mode_ac.c Done; all 69,837 conversions agree with upstream's
src/message.zig dump1090.h The decoded-message type and its vocabulary
src/comm_b.zig comm_b.c Done; 400k random replies decode identically to upstream
src/mode_s.zig mode_s.c Done; 300k frames × 3 option sets decode identically to upstream
src/geo.zig track.c Great-circle distance and bearing
src/track.zig track.c Done; 300k messages × 2 receiver configurations track identically to upstream
src/dsp.zig dsp/ Done; the generated SIMD dispatch replaced by @Vector. All 65,536 table entries and 200k magnitudes identical
src/convert.zig convert.c Done
src/mag_buf.zig fifo.h The sample block and its overlap
src/demod_2400.zig demod_2400.c Done; 4M samples demodulate identically, messages and counters alike
src/fifo.zig fifo.c Done; pthreads replaced by std.Io
src/sdr.zig sdr.c, sdr_ifile.c, sdr_stub.c Done; file, no-radio and RTL-SDR inputs
src/output.zig net_io.c The AVR, Beast, BaseStation and Stratux wire formats
src/net.zig net_io.c TCP output and input services, with upstream's write batching, heartbeats and Beast settings commands
src/input.zig net_io.c Reading the Beast and AVR formats back
src/json.zig net_io.c aircraft.json, receiver.json, stats.json and the history files
src/stats.zig stats.c Done; the report and its windows
src/sdr_rtlsdr.zig sdr_rtlsdr.c Done, on zig-rtlsdr rather than librtlsdr; streams from a live R820T dongle at the full 2.4 MSPS with no drops
src/adaptive.zig adaptive.c Done; eight synthetic scenarios and a live tuner drive the gain identically to upstream, log lines included
src/fmt.zig Printing floats the way C's printf does, ties to even
src/display.zig mode_s.c Done; --raw, --onlyaddr and the full verbose display
src/main.zig dump1090.c Done; every option upstream accepts is accepted, and the same lifecycle logs, signals, watchdog and exit status

Scope

This is not a complete parity port. It targets the RTL-SDR, the file/stdin reader used for testing, and the no-radio stub; the bladeRF, HackRF, LimeSDR and SoapySDR back ends are out of scope, as are --interactive, view1090 and faup1090 — and with faup1090 goes upstream's FATSV output service, which nothing else creates. The options that configure those are still accepted, with a warning, so that an existing command line keeps running. What it does keep is the wire behaviour: the Beast, raw, BaseStation and JSON outputs are meant to be byte-for-byte what upstream produces, because that is what everything downstream of dump1090 parses.

Structurally it is not a transliteration. The generated SIMD dispatch under upstream's dsp/ is replaced by Zig's @Vector, the pthreads and the bundled anet.c by std.Io, and C's sentinel returns by error sets and tagged unions. There is no C left at all: librtlsdr, the last of it, is replaced by zig-rtlsdr, so the build links nothing and the binary is static.

Checking it against upstream

The port is checked by running both programs over the same recording and diffing the output. On synthetic recordings of a few million samples, with real frames planted at every sub-sample phase and amplitude, --raw output is byte-for-byte identical across every combination of --fix, --fix-2bit, --no-fix, --no-fix-df, --mlat and --modeac, in both UC8 and SC16 — including the noisy recordings where error correction changes what gets decoded. The three network outputs are compared the same way, by attaching a reader to each port of both programs and diffing what arrives, and the JSON files by diffing the snapshots the two programs write, including stats.json and the --stats report. The network inputs are checked by feeding the same Beast or AVR stream into both as --net-only receivers and comparing everything that comes out the other side. The Beast settings commands are checked by connecting to the Beast output port of both programs, sending the same commands, and diffing what each sends back: every combination of verbatim, local and Mode A/C produces the same bytes. The Stratux output is checked the same way over a synthetic feed of twelve hundred frames, chosen to reach every field it reports: all 1,188 objects the two programs emit agree apart from the wall-clock timestamp each stamps its own run with. The adaptive gain control cannot be driven from a recording, since it needs a gain knob to turn, so it is checked by linking upstream's adaptive.c against a synthetic tuner and driving both implementations through the same few hundred blocks of samples: eight scenarios, chosen so that between them they reach every branch of the state machine, produce the same gain steps, the same counters, the same noise floor to six decimal places, and the same running commentary on standard error.

Against a live radio — a Realtek RTL2838 with an R820T tuner — the port streams at the full 2.4 MSPS with no dropped samples, and reports the same tuner gain steps as upstream. The adaptive gain control, which cannot be driven from a recording at all, finds the same gain limits (0.0 to 58.6 dB across thirty steps), makes the same decision to scan upwards, stops at the same place, and logs the same lines. Its noise floor measurement agrees exactly: at a pinned gain, over repeated captures, both programs report -35.6 dBFS at 28.0 dB of gain and -30.2 dBFS at 49.6 dB. Off the air, forty-five seconds of 1090 MHz recorded from the antenna is kept as a fixture and replayed through both programs like any other recording. It is a poor location — everything arrives weak enough to need error correction — but it contains real extended squitters from real aircraft and a few hundred Mode A/C replies, and it found two things a synthetic recording never had.

The first was ours: the RSSI line was written outside the test that guards it, so a message with no measured signal level printed -inf dBFS where upstream prints nothing.

The second is upstream's. demodulate2400AC clears its message once per block of samples where the Mode S loop clears it once per message, and the Mode A/C decoder only ever sets an altitude, never clears one — so a reply whose own code carries no altitude is reported with whatever the previous reply in that block had. In this recording that put a wrong altitude, belonging to another aircraft, on nineteen of the hundred and eighty-three Mode A/C replies that reported one. This port builds a fresh message per reply and so reports no altitude for those nineteen, which is the one place its output deliberately differs from upstream's.

One caution about the method: --ifile stamps each block of samples with the wall clock as it is read, and the tracker refuses an update whose message timestamp precedes the field's last one, so a recording read at a different speed decodes the same messages but resolves a slightly different number of positions. Comparisons have to be made at the same optimization level, or with --throttle, or they will show a difference that is not there.

The verbose display — every decoded field of every message, some ten thousand lines a run — matches to the column, as does position decoding when the receiver's location is configured.

Continuous integration

A Forgejo workflow runs on every push: reuse lint, zig fmt --check, zig build test and zig build. The formatting check excludes zig-pkg, the in-tree cache the Zig package manager fetches dependencies into: it is gitignored, so a fresh checkout does not have it, but a working tree does and nobody wants to be told about the formatting of a dependency. From main, and only once the tests have passed, it also builds the API documentation and publishes it to https://jeff.jcollie.page/zig-dump1090/.

What the workflow cannot do is the differential testing described above, which needs upstream's C built alongside this and a good deal of scratch space; that is run by hand, and it is worth being plain that a test suite nothing runs automatically is a gap rather than a tidy-up.

Building

zig build          # build zig-out/bin/dump1090
zig build test     # run the tests
zig build docs     # build the API documentation into zig-out/docs
zig build docs-serve  # ...and read it at http://localhost:8000/

A Nix flake provides the toolchain: nix develop puts the right Zig, reuse and the documentation publisher on the path. There is nothing else to install — the build has no C in it and links nothing, so what comes out is a static binary.

Talking to the dongle is zig-rtlsdr, a port of librtlsdr onto Zig that reaches Linux's usbfs directly rather than going through libusb. One consequence is worth knowing: it takes the dongle away from the kernel's dvb_usb_rtl28xxu driver when it opens the device and gives it back when it closes, so there is no module to blacklist and no dongle to unbind by hand. Tested on a device with that driver bound: upstream's dump1090, built against the librtlsdr nixpkgs ships, fails with usb_claim_interface error -6 and tells you to blacklist the module; this opens it, streams, and leaves /dev/dvb/adapter0 working again afterwards.

Being written against usbfs makes it Linux-only, so the RTL-SDR input is compiled in only for Linux targets. -Drtlsdr=false leaves it out explicitly; everything else works in such a build, and asking for --device-type rtlsdr fails with NotSupported rather than silently doing nothing.

Licence

dump1090 is GPL-2.0-or-later, so this port is too — a translation is a derivative work, and no amount of retyping changes that. Some of the code descends from Salvatore Sanfilippo's original BSD-licensed dump1090 by way of Malcolm Robb's and Oliver Jowett's forks, and those files name him in their copyright headers. Licensing is recorded in the REUSE form, so reuse lint will confirm every file's provenance.