Part 12 of Protocol Decoders — the finale. Every post in this series has leaned on a claim: “the framing is verified,” “the state machine is functional,” “this locks.” Those claims are only worth anything if they’re *tested, and you can’t put an SDR and a live trunked system in a CI runner. This closing post shows how GopherTrunk tests every decoder with no radio at all — and why that same discipline is what let Part 11 honestly say the alias cipher is not verified.*
TL;DR: GopherTrunk tests decoders by synthesizing a control channel: build a known dibit stream, modulate it through the real C4FM transmit chain to IQ, hand it to a mock SDR, and assert the production daemon locks and emits the right events. A per-protocol pipeline registry makes every decoder pluggable and testable; golden fixtures (including committed real-air captures) pin behavior; and a strict regression discipline — a failing-first test for every fix — is what makes “verified” a fact rather than a hope.
Key takeaways
- Decoders are tested end-to-end with a mock SDR replaying synthesized IQ — no hardware, fully deterministic, runs in CI.
- Synthesis goes through the production TX chain, so the test exercises the real demod, clock recovery, slicer, and state machine.
- A registry of
PipelineFactoryper protocol makes decoders pluggable and gives tests a clean seam (SetTestFactory). - Regression discipline — failing-first tests and committed fixtures — is the
reason a claim like
CipherVerifiedcan be trusted.
Cheat sheet
| Piece | Where | Role |
|---|---|---|
| Synthesized dibits | buildP25LockedIQDibits |
known FSW + NID + TSBK stream |
| TX chain | demod.ModulateP25C4FM |
dibits → IQ through the real modulator |
| Mock SDR | sdr.MockDriver |
replays an IQ .cfile as a fake device |
| Pipeline registry | ccdecoder.factories |
Protocol → PipelineFactory dispatch |
| Test seam | ccdecoder.SetTestFactory |
swap a factory for one protocol in a test |
| Golden fixtures | integration_cc_*_test.go |
per-protocol end-to-end assertions |
In this post
- The problem — why a live radio is the worst test rig.
- Synthesizing a control channel — dibits to IQ and back.
- The registry — decoders as pluggable factories.
- Two levels of test — full-chain lock vs. stubbed grant chain.
- Golden fixtures and regression discipline — and the series wrap.
The problem: a radio is the worst test rig
Everything in this series is a decoder, and a decoder is only correct if it decodes. The obvious way to check is to point it at a real system — which is also the worst possible test. A live signal is non-deterministic (fades, drifts, disappears), needs hardware CI can’t have, and can’t be replayed identically to reproduce a failure. You can’t gate a merge on “go stand near a trunked tower.”
So GopherTrunk tests decoders the way it analyzes captures in Signal Lab: offline, deterministic, and against the same production code the live daemon runs. The trick is to manufacture the signal.
Synthesizing a control channel
The flagship integration test, TestDaemonCCDecodesP25Phase1, is the “lights up
live trunked reception” check — with no radio in the room. It builds a P25 Phase 1
dibit stream by hand: a warmup pattern cycling through every symbol so the clock
recovery has transitions to lock onto, then repeated frames of frame-sync word, NID,
and a trellis-encoded TSBK, with the P25 status symbols a real transmitter
interleaves:
// cmd/gophertrunk/integration_cc_test.go (shape)
frame = append(frame, phase1.FrameSyncWord[:]...)
nidBits := phase1.EncodeNIDBits(nac, phase1.DUIDTrunkingSignaling)
// ... pack NID dibits ...
tsbk := phase1.AssembleTSBK(phase1.TSBK{LB: true, Opcode: phase1.OpRFSSStatusBroadcast})
frame = append(frame, phase1.EncodeTSBKChannel(tsbk)...)
frame = phase1.InjectControlStatusSymbols(frame) // status symbol every 36 dibits
Then — and this is the important part — it modulates those dibits through the real transmit chain, not a shortcut:
// 48 kHz @ 10 sps = 4800 baud (the spec rate); 1800 Hz peak deviation (TIA-102).
iq := demod.ModulateP25C4FM(dibits, sampleRateHz, deviationHz)
writeIQToU8File(iqPath, iq) // interleaved u8, the mock SDR's format
sdr.Register(&sdr.MockDriver{Files: []string{iqPath}})
ModulateP25C4FM runs the spec P25 C4FM TX path — impulse train, P25 transmit pulse
shape (RRC), FM modulator — so the resulting IQ is a faithful C4FM signal. The
MockDriver registers as an SDR device that simply replays that .cfile. From the
daemon’s perspective it’s a radio. The test then boots the wired production
daemon and asserts the full chain recovers the lock: IQ → C4FM demod →
Mueller-Müller clock recovery → 4-level slice → dibits → FSW + NID + TSBK trellis →
control-channel state machine → cc.locked on the bus → /api/v1/scanner reporting
state=locked → the gophertrunk_control_channel_locked gauge reaching 1.
The registry: decoders as factories
What makes every protocol testable the same way is that decoders are pluggable.
The ccdecoder package keeps a registry mapping a trunking.Protocol to a
PipelineFactory, and every pipeline satisfies one small contract:
// internal/scanner/ccdecoder/pipelines.go (shape)
type ProtocolPipeline interface {
Process(iq []complex64) // consume one IQ chunk
Reset() // clear symbol-domain state on re-sync
Close() error // idempotent
}
type PipelineFactory func(PipelineOptions) (ProtocolPipeline, error)
var factories = map[trunking.Protocol]PipelineFactory{ /* p25, tetra, dmr-tier3, ... */ }
This registry is the same seam the Signal Lab protocol picker enumerates
(RegisteredProtocols) and the same one the daemon dispatches through at retune. For
tests it exposes a deliberately narrow hook, SetTestFactory, which swaps the
factory for a single protocol and hands back a restore you defer. It exists
specifically so an out-of-package integration test can pump a known-good dibit stream
through the daemon’s real decoder without owning a working modulator — and it’s
documented “integration tests only; production must never call this.”
Two levels of test
There’s a subtlety worth calling out, because it’s a nice example of testing at the right altitude. The full-chain test above proves IQ → dibit → lock works. But asserting a multi-frame grant chain — status → identifier-update → group-voice-grant — through the live clock loop is unreliable: the Mueller-Müller loop reliably lands the first frame-sync word (enough to lock), but converging well enough to extract every subsequent FSW + NID + 98-dibit TSBK trellis window in one streaming pass is a tuning exercise orthogonal to the grant wiring.
So TestDaemonCCDecodesP25Phase1GrantChain uses SetTestFactory to install a stub
pipeline that ignores the IQ and pumps the synthesized dibits straight into the
real phase1.ControlChannel:
// internal/scanner/ccdecoder pipeline stub (shape)
func (p *p25Phase1StubPipeline) Process(iq []complex64) {
if p.consumed { return }
p.consumed = true
p.cc.Process(p.dibits, 0) // real state machine, band plan, bus, engine
}
Everything above IQ→dibit — factory dispatch, the state machine, the band-plan
resolution, the bus publication, the trunking engine, the supervisor, the API, the
metrics handler — runs through production code, and the test asserts the resolved
grant frequency exactly (base + spacing × channel = 851_062_500). One test owns the
demod; the other owns the message chain; neither is flaky because each isolates the
one thing it’s proving.
Golden fixtures and regression discipline
That synthesis-plus-mock-SDR pattern is repeated per protocol — there’s an
integration_cc_*_test.go for P25 Phase 2, DMR (Tier 1/2/3), NXDN, dPMR, D-STAR,
YSF, EDACS, LTR, MPT-1327, Motorola Type II, and TETRA. Some go further and commit a
real-air capture (integration_cc_tetra_realair_test.go,
integration_cc_nxdn_realair_test.go) so the decoder is pinned against a genuine
signal, not just a self-consistent synthesis — the strongest kind of golden fixture,
because a synthesizer and a decoder written by the same author can agree on the same
mistake.
This is where the series closes the loop with its own capstone. The reason Part 11
could honestly report the alias cipher as not verified is the exact same
discipline: CipherVerified flips to true only alongside a committed regression
fixture mapping real encoded bytes to correct plaintext. It’s the project’s
issue-closing policy
in code form — a claim isn’t true until a failing-first test passes and the reality
is reproduced. The whole point of testing without radios isn’t convenience; it’s that
a deterministic, reproducible fixture is the only thing that can turn “it seems to
work” into “it is verified.”
How that principle shaped the Go code
- The
MockDriveris a realsdr.Driver. It registers exactly like a physical device, so the daemon under test is unmodified — no test-only branches in production paths. - The registry has one narrow test seam.
SetTestFactoryis the only way tests perturb decoder construction, and it restores itself, so a test can’t leak state into the next one. - Metrics are polled, not scraped once. Because the metrics collector is a separate bus subscriber, counters lag the test’s own subscription; the helpers poll to a deadline instead of racing a single scrape — determinism all the way down.
- Fixtures are committed, not generated on the fly. A real-air
.cfilein the repo is a permanent regression anchor; the decoder that passes it today must keep passing it forever.
The series, wrapped
Twelve parts ago this series started with a single control-channel decoder and the
promise that every protocol — P25 Phase 1 and 2, DMR, NXDN, dPMR, TETRA, and the
legacy EDACS/LTR/MPT-1327 family — reduces to the same shape: symbols in, a
protocol-neutral Grant out, published on a bus the
Trunking Engine consumes
without caring which protocol produced it. We followed that shape through π/4-DQPSK
and channel coding, through distributed trunking with no control channel at all,
through conventional squelch and wideband channelization, and into the one field that
still resists us — the Motorola talker alias, GopherTrunk’s white whale, honestly
gated and honestly unsolved. And we end where every one of those claims is settled:
in a test that needs no radio. That’s the through-line — decoders that are decoupled,
composable, and provable offline. Thanks for reading.
FAQ
How does GopherTrunk test a decoder without an SDR?
It synthesizes a known dibit stream, modulates it to IQ through the production C4FM
transmit chain, and replays that IQ through a MockDriver that registers as a fake
SDR. The real daemon demodulates it and the test asserts the lock, grant, API state,
and metrics — deterministically, in CI, with no hardware.
Why modulate through the real TX chain instead of feeding dibits directly? So the test exercises the actual demodulator, clock recovery, and slicer, not just the state machine. GopherTrunk does both: a full IQ→lock test proves the DSP path, and a stub-factory test that injects dibits directly proves the multi-frame message chain without depending on the clock loop converging perfectly.
What is the pipeline registry?
A map from trunking.Protocol to a PipelineFactory that builds that protocol’s
receiver pipeline (Process/Reset/Close). It’s how the daemon dispatches decode
at retune, how Signal Lab enumerates protocols, and — via SetTestFactory — how
integration tests swap in a controlled pipeline for one protocol.
What makes a claim like “verified” trustworthy here?
A committed, failing-first regression fixture. A fix ships with a test that fails
without it and passes with it; a real-air capture pins a decoder against genuine
signal; and a gate like CipherVerified only flips alongside such a fixture. Without
that, the project keeps the claim open — which is exactly why the alias cipher is
still marked unverified.
Series navigation
Part 12 of 12 · ← Part 11: The Alias Hunt II