Part 3 of Protocol Decoders. Part 2 delivered 12 clean bytes out of the
trellis; this post is what those bytes *say. We parse the TSBK, walk the opcode
taxonomy, follow a grant PDU all the way to the Grant the trunking engine
records, and open the Link Control / TDULC path — which is exactly where the
talker-alias mystery from Part 1 rides.*
TL;DR: A P25 Phase 1 TSBK is a 12-byte block: a header byte (last-block flag, protected flag, 6-bit opcode), an MFID, 8 payload bytes, and a 2-byte CRC. The opcode plus MFID pick a parser —
GRP_V_CH_GRANT(opcode 0x00) becomes the grant the engine turns into a recorded call;RFSS_STS_BCSTand friends backfill site identity; Motorola/Harris MFIDs remap the same opcode space to vendor messages like patches and the talker alias. Link Control words (in LDU1 and the TDULC terminator) carry per-call talkgroup, source, and alias fragments behind an RS-over-Hamming FEC stack.
Key takeaways
- The TSBK is uniform: same 12-byte frame, same CRC, every opcode. Only the 8 payload bytes change meaning.
- The CRC is augmented-codeword style (init 0, final XOR 0xFFFF, expect 0 over all 12 bytes) — the “obvious” CRC-CCITT/FALSE variant is wrong on-air (issue #275).
- MFID switches namespaces: the same 6-bit opcode decodes differently under Motorola (0x90) or Harris (0xA4).
- A group voice channel grant carries channel + talkgroup + source; that tuple
is the seed of a
Grant. - Link Control carries the per-call talkgroup/source under RS(24,12,13); the TDULC path is where the Motorola talker alias hides.
Cheat sheet
| Opcode | Mnemonic | Carries | Becomes |
|---|---|---|---|
0x00 |
GRP_V_CH_GRANT | channel, talkgroup, source | a Grant |
0x02 |
GRP_V_CH_GRANT_UPDT | two (channel, group) pairs | grant refresh |
0x04 |
UU_V_CH_GRANT | channel, target, source | private-call grant |
0x3B |
NET_STS_BCST | WACN, System ID | network identity |
0x3A |
RFSS_STS_BCST | RFSS, Site | camped-site identity |
0x3C |
ADJ_STS_BCST | neighbour site + CC | roaming target |
0x90/0x00 |
Motorola patch add | super-group + members | a regroup/patch |
0x90/0x15 |
Motorola talker alias | alias fragment | the mystery |
In this post
- The TSBK frame — header, MFID, payload, and the CRC that gates it.
- The opcode taxonomy — grants, updates, status broadcasts.
- From grant to Grant — the field that seeds a recorded call.
- Vendor namespaces — how MFID remaps the opcode space.
- Link Control & TDULC — per-call identity, and the alias carriage.
The TSBK frame
A TSDU (DUID 0x7) carries one to three TSBKs, each a 12-byte info block recovered by the trellis + interleaver from Part 2. The header byte is fixed-shape; the rest is opcode-dependent:
// internal/radio/p25/phase1/tsbk.go (shape)
type TSBK struct {
LB bool // Last Block in the TSDU sequence
P bool // Protected
Opcode Opcode // 6-bit opcode
MFID uint8 // 0x00 = standard, 0x90 = Motorola, 0xA4 = Harris
Payload [8]byte
}
ParseTSBK peels the header, copies the 8 payload octets, and verifies the trailer
CRC. That CRC is worth a full stop, because getting it wrong looked like a decode
failure for a long time:
// internal/radio/p25/phase1/tsbk.go (shape)
if framing.CRCCCITTAugmented(info) != 0 {
return t, CRCError // still returns the partial block for diagnostics
}
The prior implementation used CRC-CCITT/FALSE (init 0xFFFF, no final XOR) with an inverted trailer — an algorithm documented in many P25 references but not what the air uses. On the Mt Anakie capture it produced 195 of 197 TSBK CRC failures even when the trellis reported a clean path (metric 0). The fix (issue #275): the “augmented codeword” variant — init 0, final XOR 0xFFFF, run over all 12 bytes, expect 0. A clean trellis decode plus a failing CRC is the fingerprint of a wrong CRC algorithm, not a wrong signal.
The opcode taxonomy
The 6-bit opcode is a TIA-102.AABC-D Outbound Signalling Packet (OSP) type.
GopherTrunk names the full table with idiomatic Go identifiers while String()
carries the canonical spec mnemonic, so logs read in spec terms:
// internal/radio/p25/phase1/opcodes.go (shape)
const (
OpGroupVoiceChannelGrant Opcode = 0x00 // GRP_V_CH_GRANT
OpGroupVoiceChannelUpdate Opcode = 0x02 // GRP_V_CH_GRANT_UPDT
OpUnitToUnitVoiceChannelGrant Opcode = 0x04 // UU_V_CH_GRANT
OpNetworkStatusBroadcast Opcode = 0x3B // NET_STS_BCST
OpRFSSStatusBroadcast Opcode = 0x3A // RFSS_STS_BCST
OpAdjacentSiteStatusBroadcast Opcode = 0x3C // ADJ_STS_BCST
OpIdentifierUpdate Opcode = 0x3D // IDEN_UP
// …~40 opcodes across grants, registration, status, and identifiers
)
They fall into a few families. Grants (0x00, 0x02, 0x03, 0x04, 0x06…) announce
voice channels — the events that become calls. Status broadcasts (0x3A–0x3D)
carry the system’s identity and topology: the Network Status Broadcast is the
authoritative WACN + System ID; the RFSS Status Broadcast names the camped site;
the Adjacent Site Status Broadcast advertises neighbours to roam to. Identifier
updates (0x33/0x34/0x3D) carry the band plan that turns a grant’s abstract
(channel ID, channel number) into a real frequency. Registration/affiliation
responses (0x28, 0x2B, 0x2C…) track which radios are on which talkgroups —
the raw material for the affiliation features in the trunking engine.
From a grant to a Grant
The grant is the reason the decoder exists. Opcode 0x00’s 8 payload bytes decode to a channel reference, a talkgroup, and the source radio:
// internal/radio/p25/phase1/opcodes.go (shape)
type GroupVoiceChannelGrant struct {
ServiceOptions uint8
ChannelID uint8 // 4-bit band-plan identifier
ChannelNumber uint16 // 12-bit channel within that band
GroupAddress uint16 // the talkgroup
SourceID uint32 // 24-bit transmitting radio
}
The ServiceOptions byte is small but load-bearing: bit 7 is Emergency, bit 6 is
Protected (the encryption indicator the recorder consults before it wastes disk
on a call it can’t play), and the low three bits are call priority. The
(ChannelID, ChannelNumber) pair is resolved to a frequency through the band plan
the IDEN_UP opcodes maintain, and the resulting tuple — system, talkgroup,
frequency, source, encrypted flag, priority — is what the control-channel state
machine publishes as events.KindGrant. That event is the hand-off: the
trunking engine
picks it up, allocates a voice SDR, and starts a call. Everything in Part 2 and this
post exists to fill in that struct correctly.
A subtlety the decoder gets right: GRP_V_CH_GRANT_UPDT (0x02) carries two
(channel, group) pairs — it’s a compact refresh of currently-active calls, not a new
setup — while the explicit update (0x03) reuses the full grant layout including
source. Parsing them into the right shape is what keeps a single ongoing
transmission from fragmenting into phantom calls.
KindGrant the trunking engine turns into a call.Vendor namespaces: MFID changes the language
The same 6-bit opcode means different things under a manufacturer’s MFID. This is
handled with As* accessors that check the MFID before decoding, so a spec
correction stays local to one file:
// internal/radio/p25/phase1/tsbk_vendor.go (shape)
const (
MFIDMotorola uint8 = 0x90
MFIDHarris uint8 = 0xA4
OpMotorolaPatchGroupAdd Opcode = 0x00 // regroup/"patch" add
OpMotorolaPatchGroupChannelGrant Opcode = 0x02 // super-group voice grant
OpVendorTalkerAlias Opcode = 0x15 // alias fragment (MFID-agnostic)
)
func (t TSBK) AsMotorolaPatchGroup() (MotorolaPatchGroup, bool) {
if t.MFID != MFIDMotorola || t.Opcode != OpMotorolaPatchGroupAdd {
return MotorolaPatchGroup{}, false
}
// …super-group address + up to 3 member talkgroups
}
Motorola patches (group-regroup) aggregate member talkgroups under a super-group
address, and there’s a real-world wrinkle the accessor absorbs: for a
fewer-than-three-member patch, Motorola repeats the first member’s ID in the unused
slots rather than zeroing them (Mt Anakie showed 0x7EF5 0x7EF5 0x7EF5 for a
one-member patch), so AsMotorolaPatchGroup deduplicates. Those patches feed the
engine’s patch registry. And AsTalkerAliasFragment — MFID-agnostic, opcode 0x15 —
is one of the two doors the talker alias comes through.
Link Control and the TDULC — where the alias rides
Grants come from the control channel. But once a call is up on a voice channel, each LDU1 frame carries a 72-bit Link Control word that restates the call’s talkgroup and source, so a receiver that tuned in mid-call still knows who it’s hearing. That word is wrapped in a two-layer FEC stack:
// internal/radio/p25/phase1/link_control.go (shape)
// 24 shortened Hamming(10,6,3) codewords → 144 bits → 24 six-bit RS symbols;
// outer RS(24,12,13) corrects up to t=6 symbol errors the Hamming layer missed.
type LinkControl struct {
LCFormat uint8 // the LCO opcode
MFID uint8
ServiceOptions uint8
TalkgroupID uint16 // octets 4-5
SourceID uint32 // octets 6-8
}
The octet layout here is exact for a reason: an earlier working model placed the talkgroup at octets 2-3, which made it decode to the constant service-options byte (0x0400 = 1024) while the real talkgroup landed inside a misread source field. The on-air talkgroup then never matched the granted talkgroup, the voice composer’s foreign-talkgroup gate fired, and single transmissions shattered into dozens of 2-LDU1 fragments. One wrong byte offset, one very visible field bug.
Certain LCOs don’t carry a group-voice-user record at all — they carry alias
fragments. LCOTalkerAliasHeader (0x15) and the data blocks (0x16/0x17) assemble a
radio’s display name across the call. And here is the carriage twist that matters
for our mystery: on real Motorola systems those alias LCs ride primarily in the
TDULC (the terminator-with-link-control, DUID 0xF), not LDU1. The TDULC wraps
the same 72-bit LC word behind an even heavier FEC — 12 Golay(24,12,8) inner
codewords under an RS(24,12,13) outer layer:
// internal/radio/p25/phase1/tdulc.go (shape)
func ExtractTDULCLinkControl(ldu []byte) (lcf uint8, content [9]byte, errs int, err error) {
// …12 Golay codewords → 24 RS symbols → outer RS(24,12,13) → 9 LC octets
}
So the pipeline can recover those 9 alias octets cleanly, error-corrected twice over. The bytes arrive intact. What happens next — running them through the Motorola per-byte substitution cipher — is the wall we hit.
The mystery, lightly
↩ The alias, revisited. Feed those recovered LC octets into
MotorolaTalkerAliasBuf, let the header’s declared block count arrive, and you get a decoded string — and areliableflag that is always false. The decode runs throughmotorola.DecodeAlias, but GopherTrunk gates the result onmotorola.CipherVerified, which isfalse: the substitution table is a clean-room reverse-engineering guess, unconfirmed against a known-plaintext frame (issue #773). We can frame it, FEC-correct it, and reassemble it perfectly — and it still comes out as noise. That’s the same Mercury emitter the Crypto Lab series chases, and Part 11 is where we try to verify the table.
Where this goes next
Part 4 moves from FDMA to TDMA: P25 Phase 2’s two-slot MAC layer, the ISCH, superframes, and a nastier grant problem — compressed grants that arrive source-less. Grants decoded here feed the trunking engine’s grant handling, and the voice frames the Link Control rides on feed the Voice Coding series.
FAQ
What is a TSBK? A Trunking Signaling Block — the atomic unit of the P25 Phase 1 control channel. It’s a 12-byte block (header, MFID, 8 payload bytes, CRC) whose opcode selects a message type: grants, updates, status broadcasts, registrations.
Which TSBK becomes a call?
GRP_V_CH_GRANT (opcode 0x00) and its updates. They carry the channel, talkgroup,
and source; GopherTrunk resolves the channel to a frequency via the band plan and
publishes a KindGrant event the trunking engine turns into a recorded call.
Why does MFID matter for opcode parsing?
Because vendors reuse the 6-bit opcode space. Under MFID 0x90 (Motorola) opcode 0x00
is a patch-group add, not a standard grant. GopherTrunk dispatches on MFID first,
via As* accessors that return false for a mismatched manufacturer.
Where does the per-call talkgroup come from during a call? From the Link Control word in each LDU1 (and the TDULC terminator), decoded through a shortened-Hamming inner layer and an RS(24,12,13) outer layer. It restates the talkgroup and source so a mid-call tune-in still identifies the traffic.
Why can’t GopherTrunk read the Motorola talker alias?
The alias bytes reassemble and FEC-correct fine, but the final per-byte substitution
cipher is unverified (motorola.CipherVerified == false, issue #773), so the decoder
refuses to report the result as reliable. Parts 10–11 take that on.
Series navigation
Part 3 of 12 · ← Part 2: P25 Phase 1 Physical Layer · Next → Part 4: P25 Phase 2 TDMA — The MAC Layer