Part 3 of From Spec to Shipping, a 14-part series on how a protocol decoder actually gets written — from standards documents and independent references to code you can trust on air. Part 2 assembled the reference stable and ended on a warning: a reference quoted in a comment decorates your code, while a reference wired into your tests disagrees with it. This part is the wiring. It opens with the cleanest specimen of the series villain this project owns — a P25 parser that read a field one byte early for as long as it existed, under a green test — and turns the fix into a rule you can apply to any wire format: literal vectors, not round-trips.
TL;DR: GopherTrunk’s P25 SCCB parser (TSBK opcode 0x39,
internal/radio/p25/phase1/opcodes.go) read secondary channel B from payload bytes 4–5 instead of 5–6 — splicing the service-class byte into the channel field and inventing a phantom control channel — and its round-trip test passed the whole time, because the test’s assembler encoded the same wrong layout. Encode∘decode = identity proves consistency, never correctness. The fix as method: pin parsers with literal byte vectors cross-checked against an independent decoder or a real capture — like the four on-air LOC_REG_RSP payloads intsbk_test.go, or the SmartNet tests that pin sync bits, the stride-19 interleave permutation and the0xCC38/0x0D5XOR masks against OP25’s literals — and keep the vectors as bytes in the test, never generated.
Key takeaways
- A round-trip test has zero external information. Parse(Assemble(x)) == x holds for every self-consistent layout, including wrong ones — the assembler and parser are the same hypothesis written twice.
- Literal vectors import a fact from outside. A byte string from a capture, a spec example, or another decoder’s output constrains your parser against the world instead of against itself.
- Reference-literal tests are the only tests that catch constant drift. A sync word, an interleave stride, an XOR mask — if the test computes the expected value from the same constant, it verifies nothing.
- Vectors live in the test as bytes. The moment a “vector” is generated by code under test — or by a sibling that shares its tables — it stops being evidence.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| The specimen bug | SCCB channel B read at bytes 4–5, not 5–6 | internal/radio/p25/phase1/opcodes.go (ParseSecondaryControlChannelBroadcast) |
| On-air vectors | four real LOC_REG_RSP payloads pin the layout | phase1/tsbk_test.go (TestParseLocationRegistrationResponse) |
| Constant pins | sync bits, interleave permutation, XOR masks vs OP25 literals | internal/radio/motorola/frame_test.go |
| Opcode pins | RPC ids vs upstream SoapyRemoteDefs.hpp literals |
internal/sdr/soapyremote/driver_test.go (TestOpenSetAntennaUsesUpstreamOpcode) |
| Independent re-derivation | scrambler rebuilt from the recurrence, not the LFSR | internal/radio/framing/scramble_tetra_test.go (refScrambleTetraETSI) |
| The postmortem twin | the trap’s greatest hits, told as failures | From the Issue Tracker Part 20 |
In this post
- The SCCB story in full — one byte early, green the whole time.
- Why round-trips can’t catch it — the geometry of self-consistency.
- Literal vectors, and where to harvest them — captures, references, spec examples, independent re-derivations.
- Pinning constants, not just layouts — sync words, permutations, masks, opcodes.
- The rules — five habits that transfer to any wire format.
The SCCB story in full
P25’s Secondary Control Channel Broadcast (TSBK opcode 0x39) announces
alternate control-channel frequencies for a site. Its 8-byte payload packs
two channels: RFSS and site IDs, channel A in bytes 2–3, channel A’s
service class in byte 4, channel B in bytes 5–6, channel B’s service class
in byte 7 — per TIA-102.AABC and SDRTrunk’s
SecondaryControlChannelBroadcast.
GopherTrunk’s parser read channel B from bytes 4–5. One byte early: the high byte of its “channel” was actually service class A, and the field that should have been read as class B was half of a channel number. The result was a phantom secondary control channel — a plausible-looking but nonexistent frequency fed into the control-channel hunt machinery. The comment now in the tree tells the rest:
// internal/radio/p25/phase1/opcodes.go (shape)
// The previous working model read channel B from bytes 4-5 — one byte
// early, splicing service class A into the channel field and producing a
// phantom secondary CC; the round-trip test passed because the assembler
// encoded the same wrong layout (the self-consistent-synthetic trap).
func ParseSecondaryControlChannelBroadcast(p [8]byte) SecondaryControlChannelBroadcast {
cA := binary.BigEndian.Uint16(p[2:4])
cB := binary.BigEndian.Uint16(p[5:7]) // was p[4:6]
/* … */
}
Note what the bug was not: not a logic error, not a misunderstanding of the protocol, not a weak-signal artifact. A transcription slip — the kind Part 1 warned lives in exact bit offsets — protected from discovery by the exact test that appeared to cover it.
Why round-trips can’t catch it
The SCCB test called Parse(Assemble(x)) and compared the result to x.
It passed before the fix and passes after, because assembler and parser
were written by the same person from the same (wrong) reading, and each is
the other’s inverse by construction. A round-trip test verifies one
proposition: “these two functions agree.” It says nothing about whether
either agrees with the air.
This is not an isolated species. The same trap, at other layers of this one codebase: the fabricated SmartNet framing decoded its own synthetic fixtures perfectly while no real system could feed it (#1143); the SoapyRemote fake server switched on the same opcode constant the client sent, so a wrong opcode moved both sides together (From the Issue Tracker Part 13); a TETRA DMO descramble skip survived because the test’s encode side skipped scrambling under the same condition (TETRA End to End Part 12). The postmortem edition collects the wreckage; this part is the constructive mirror.
Round-trips still earn their keep — they catch asymmetric typos, shift errors that don’t invert, mask mismatches between the two directions. Keep them. Just never let one be the only test on a layout, because the class of bug it cannot see is precisely the class that ships.
Literal vectors, and where to harvest them
A literal vector is a byte string whose correct decode is known from somewhere other than the code under test. Four harvesting grounds, in rising order of authority:
Spec worked examples. Some standards include encoded examples in annexes. Cheap when they exist; remember Part 1’s caveat that informative annexes can lag normative text.
An independent decoder’s output. Run OP25, SDRTrunk, Wireshark or osmo-tetra over a stream and transcribe what they print. This is how the SCCB-class layouts get their second opinion — SDRTrunk’s field offsets are effectively vectors in prose form.
An independent re-derivation. When no external implementation is
available, write a second implementation from the spec by a different
route, structured so it cannot share the failure mode. GopherTrunk’s
scrambler cross-check is the template: refScrambleTetraETSI builds the
sequence directly from the §8.2.5.2 recurrence “precisely so it can not
share a shift-direction bug with the production ScramblerTetra,” which is
a register-shift LFSR. Same spec, different mechanics — a shared mistake
now requires two independent misreadings to align.
A real capture’s decoded bytes. The gold standard. When GopherTrunk’s LOC_REG_RSP layout needed confirmation, the pin became four payloads lifted from a live Motorola P25 site, asserted field by field:
// internal/radio/p25/phase1/tsbk_test.go (shape) — TestParseLocationRegistrationResponse
// All four name the same camped site — RFSS 1, Site 1 — with plausible
// group addresses and 24-bit registering-unit IDs, which is how the
// layout (response, group, RFSS@p3, Site@p4, target) was confirmed.
vectors := []struct {
payload [8]byte
group uint16
target uint32
}{
{[8]byte{0x00, 0x05, 0x79, 0x01, 0x01, 0x02, 0x45, 0x01}, 0x0579, 0x024501},
{[8]byte{0x00, 0x05, 0x15, 0x01, 0x01, 0x02, 0x13, 0x4A}, 0x0515, 0x02134A},
/* … two more on-air payloads … */
}
The bytes are written out. Not built by an assembler, not loaded from a
fixture generator — typed into the test. The expected values carry their
own plausibility argument (same site in all four, sensible unit IDs), so
the vector documents its provenance alongside its content. The adjacent
TestParseAdjacentSiteStatusBroadcast does the same with an on-air
ADJ_STS_BCST payload from the Mt Anakie captures — the same site the
#764
investigation verified against.
Pinning constants, not just layouts
Layouts are one drift surface; bare constants are the other, and they need
the same treatment. The rebuilt SmartNet decoder
(Part 8’s
case study) ships three reference-literal tests in
internal/radio/motorola/frame_test.go, one per constant class:
| Pin | Against | Why a weaker test fails |
|---|---|---|
sync bits: 0xAC MSB-first |
OP25 frame_sync_magics.h literal |
any self-consistent sync “works” in a round-trip |
| deinterleave permutation | OP25’s mapping, as the explicit sequence {1, 20, 39, 58, 2, 21, 40, 59, …} | encode/decode interleavers invert each other at any stride |
XOR masks 0xCC38 / 0x0D5 |
rx_smartnet.h ID_XOR 0x33C7 / CMD_XOR 0x32A, complemented |
masks applied symmetrically cancel in a loop |
And the pattern is not radio-specific. When the SoapyRemote antenna bug
was fixed, the regression pinned sixteen RPC opcodes against upstream
SoapyRemoteDefs.hpp values — with the comment doing the teaching:
“Written as literals on purpose: comparing the constant to itself proves
nothing” (TestOpenSetAntennaUsesUpstreamOpcode). An enum copied from a
.hpp is exactly as much a wire constant as a sync word;
Part 9
runs that story in full.
The test smell to hunt in review: an expected value that mentions the
production constant’s name. want := OutboundSyncHex verifies the
compiler; want := 0xAC // OP25 frame_sync_magics.h verifies the world.
The rules
Distilled, and none of them are about radio:
- Every parser gets at least one literal vector whose expected decode comes from outside the codebase — a capture, an independent implementation, or a spec example. Round-trips are supplements.
- Vectors are bytes in the test file. Generated input is code under test wearing a disguise. If a helper builds it, the helper must not share tables, masks or layout constants with the parser.
- Wire constants are pinned against upstream literals, re-typed by hand at the pin site, with the source named in a comment.
- Record the vector’s provenance in the test. “Four payloads from a live Motorola site, all naming the camped site” is what lets a future maintainer trust — or correctly distrust — the pin when the layout question reopens.
- When you fix a layout bug, the failing vector goes in first. The SCCB fix, the SmartNet rebuild and the DMO descramble fix each landed with a test that fails against the old code — the failing-first discipline Part 12 makes general, and the habit /learn/testing/ teaches from the ground up.
Where this goes next
Literal vectors pin fields and constants — discrete facts. But a vocoder or a channel-coding chain is thousands of arithmetic operations whose correctness only shows in the aggregate, and for those the vector becomes a stream and the assertion becomes bit-identical output against a reference implementation. Part 4 builds that harness around the ETSI ACELP codec — same bitstream into both decoders, zero sample mismatches allowed — and meets the LP64 gotcha that makes the reference itself lie on a modern machine.
FAQ
Are round-trip tests worthless, then? No — they cheaply catch asymmetries: a shift that doesn’t invert, a mask applied on one side only, a field dropped in reassembly. The rule is about sufficiency: a round-trip must never be the only test on a wire layout, because the shared-hypothesis bug class is invisible to it by construction.
Where do I get literal vectors for an encrypted or rare protocol?
Work down the authority ladder: an independent decoder’s output over any
sample you can find, a second in-house implementation built by a different
route (the refScrambleTetraETSI pattern), or spec worked examples. Even
one confirmed vector beats a thousand round-trips — it is the only test in
the file carrying outside information.
How many vectors does a parser need? Enough to make each field’s offset load-bearing — usually two to four well-chosen ones. GopherTrunk pinned LOC_REG_RSP with four on-air payloads because they jointly confirm response, group, RFSS, site and target occupy distinct, correct bytes; one all-zeros vector would pin almost nothing.
Should vectors live in testdata files instead of source?
Short vectors belong inline — visible in review, diffed with the code,
impossible to regenerate accidentally. Bulk streams (a captured control
channel, an IQ fixture) belong in testdata/ with a checksum or a
documented origin; the danger is not the file, it’s any pipeline that
can silently rebuild it from the code under test.
What’s the fastest way to audit an existing codebase for this trap? Grep the tests for calls to your own assemblers and encoders. Every parser whose only inputs are produced by its own inverse is unpinned — list them, then harvest one literal vector each, worst-consequences first. The SCCB bug lived precisely in that unpinned set, and only an independent cross-check dislodged it.
Series navigation
Part 3 of 14 · ← Part 2: Choosing Reference Implementations You Can Trust · Next → Part 4: The Conformance Harness — Bit-Identical or Bust