Part 4 of Protocol Decoders. Phase 1 (Parts 2–3) was FDMA — one call per carrier. Phase 2 packs two calls onto one carrier with TDMA, and the signalling moves from standalone TSBKs to a MAC layer riding inside the traffic channel. We walk the ISCH, the superframe, and the MAC PDU — then hit the problem this design creates: grants that arrive with no source radio.
TL;DR: P25 Phase 2 is two-slot TDMA at 6000 sym/s, H-DQPSK. A 360 ms superframe holds 12 sub-frames; each sub-frame is prefixed by an ISCH (Golay-protected) that names it as voice or MAC. MAC sub-frames carry MAC PDUs — the Phase 2 analogue of TSBKs — whose opcode selects a message. Grants come in two shapes, and the compressed in-call form (
GroupVoiceChannelUserAbbreviated) carries no source RID: the call is granted source-less, and the source must be backfilled later — the exact seam the trunking engine’s source-RID recovery closes.
Key takeaways
- The ISCH is how a receiver tells a voice sub-frame from a MAC sub-frame without decoding the payload — a 12-bit field under Golay(24,12,8).
- The superframe grid (12 sub-frames, anchored on a sync match) is the whole slice geometry, derived from one anchor constant.
- MAC PDUs mirror TSBKs: opcode-first, MFID for vendor PDUs, opcode-specific payload — and the same grant, status, and identifier messages reappear.
- Phase 2’s descrambler needs a PN44 seed derived from WACN/System/Color-Code, which the Network Status Broadcast supplies.
- The problem we hit: compressed grants are source-less, so the source RID is recovered from a later in-call MAC PDU downstream in the engine.
Cheat sheet
| Concept | Value / shape | GopherTrunk file |
|---|---|---|
| Symbol rate | 6000 sym/s, H-DQPSK | phase2/receiver |
| Superframe | 360 ms, 12 sub-frames, 2160 dibits | phase2/superframe.go |
| Sub-frame | 30 ms, 180 dibits | phase2/superframe.go |
| ISCH | 12 data bits, Golay(24,12,8) | phase2/isch.go |
| MAC PDU | opcode + [MFID] + payload | phase2/mac.go |
| Grant (full) | opcode 0x44 — src present | phase2/mac.go |
| Grant (compressed) | opcode 0x01 — src = 0 | phase2/mac.go |
In this post
- Why TDMA changes the decode — signalling moves inside the traffic channel.
- The ISCH — the one field that routes a sub-frame.
- The superframe — 12 slots, one anchor.
- The MAC PDU layer — opcodes, grants, status, encryption sync.
- The source-less grant — the compression that costs a field.
Why TDMA changes the shape
In Phase 1, a control channel is a dedicated carrier streaming TSBKs. In Phase 2,
the two timeslots of a carrier are shared between voice and signalling: the same
360 ms superframe that carries digital voice also carries MAC PDUs in the
sub-frames that aren’t voice. So the decoder can’t just “read the control channel”
— it has to lock the superframe grid, decode a per-sub-frame slot-type field, and
route voice sub-frames one way and MAC sub-frames another. The SuperframeDecoder
does exactly this before the control-channel state machine ever sees a PDU.
The ISCH: one field that routes a sub-frame
The Inter-Slot Signalling Channel prefixes every sub-frame and answers one
question — what does this sub-frame carry? — cheaply, so the decoder never has to
speculatively parse a payload. It’s a 12-bit field (a 4-bit SlotType, a 4-bit
sub-frame counter, 4 reserved) protected by the extended Golay(24,12,8) code:
// internal/radio/p25/phase2/isch.go (shape)
func DecodeISCH(dibits []uint8) (ISCH, int, error) {
var cw uint32
for _, d := range dibits { cw = cw<<2 | uint32(d&3) }
data, errs := framing.GolayDecode24_12(cw) // errs < 0 ⇒ uncorrectable
if errs < 0 { return ISCH{}, -1, ErrISCHUncorrectable }
return ISCH{
SlotType: SlotType(data & 0x0F),
Counter: uint8((data >> 4) & 0x0F),
}, errs, nil
}
The SlotType enum splits cleanly into voice (Voice4V, Voice2V) and MAC
(MAC_PTT, MAC_ACTIVE, MAC_IDLE, MAC_HANGTIME, MAC_END…), with IsMAC()
and IsVoice() helpers the decoder branches on. All ISCH knowledge is deliberately
confined to this one file — the exact bit packing is a working model against
TIA-102.BBAB — so a spec correction is a local change and the rest of the pipeline
only ever sees a decoded SlotType. That containment discipline is the same one
Part 1 described for the whole registry, applied at field scale.
The superframe grid
The superframe geometry is almost entirely constants: 12 sub-frames of 180 dibits, 2160 dibits total (× 2 bits = 4320, matching the PN44 scrambler period). The one piece of real machinery is where the grid starts, and that’s anchored on a single constant — the sub-frame whose head carries the 20-dibit outbound sync word:
// internal/radio/p25/phase2/superframe.go (shape)
const (
SubframesPerSuperframe = 12
DibitsPerSubframe = 180
DibitsPerSuperframe = SubframesPerSuperframe * DibitsPerSubframe // 2160
SyncSubframeIndex = 0 // anchor the 0..11 grid on a sync match here
)
SuperframeDecoder.Process slides a sync detector over the H-DQPSK dibit stream,
anchors the grid on each match, decodes one ISCH per sub-frame, and hands the
MAC-bearing sub-frames to IngestSuperframe. If a real capture ever shows the sync
recurring at a different cadence, SyncSubframeIndex is the single fix — the slice
geometry derives entirely from it. This is the tidy end of the design; the messy
end is the descrambler.
Phase 2 scrambles the traffic channel with a PN44 sequence seeded from the system’s identity — WACN, System ID, and the Color Code (which equals the Phase 1 NAC per spec). The seed is computed from the Network Status Broadcast, so the same MAC PDU that tells you which system you’re on also unlocks the descrambler. Until an NSB arrives, a hand-configured seed stands in; when one arrives, the decoder recomputes and swaps the seed if it differs.
The MAC PDU layer
A MAC PDU is the Phase 2 counterpart of a TSBK, and its parse is deliberately the same shape: opcode first, an MFID octet only for manufacturer-specific opcodes, then opcode-specific payload:
// internal/radio/p25/phase2/mac.go (shape)
func ParseMACPDU(info []byte) (MACPDU, error) {
pdu := MACPDU{Opcode: Opcode(info[0])}
rest := info[1:]
if pdu.Opcode.IsManufacturerSpecific() && len(rest) >= 1 { // 0x80..0xBF
pdu.MFID = rest[0]
rest = rest[1:]
}
pdu.Payload = append([]byte(nil), rest...)
return pdu, nil
}
The familiar messages reappear under new opcode numbers: GroupVoiceChannelGrant
(0x44) and its update (0x40), the unit-to-unit grant (0x48), the RFSS and Network
Status Broadcast updates (0xFA/0xFB), the Identifier Update band plan (0x7D), and
the Encryption Sync (0x70) that surfaces algorithm and key IDs for a protected call
(GopherTrunk identifies encryption but does not decrypt). Each has an As*
accessor that returns false on an opcode mismatch, so the state machine probes a
PDU against several in turn. A full grant publishes exactly the trunking.Grant the
trunking engine consumes —
same event, same downstream — carrying the channel, talkgroup, source, encryption
flag, and the FEC-config snapshot the voice composer needs.
The problem we hit: source-less grants
Phase 2 has two ways to tell you a group call is up, and they cost different
amounts of airtime. The full GroupVoiceChannelGrant (0x44) carries service
options, channel, talkgroup, and the 24-bit source radio ID. But the in-call
GroupVoiceChannelUser broadcast comes in a compressed Abbreviated form
(opcode 0x01) that, in the field, arrives with the source compressed away —
SourceID decodes to 0. The Extended form (0x21) carries the full SUID, but the
abbreviated form is what a system emits most of the time to save slot space:
// internal/radio/p25/phase2/mac.go (shape)
type GroupVoiceChannelUser struct {
ServiceOptions uint8
GroupAddress uint16
SourceID uint32 // 24-bit — but 0 in the compressed abbreviated form
}
// opcodes: OpGroupVoiceChannelUserAbbreviated (0x01) · …Extended (0x21)
So the decoder faithfully reports a call on a talkgroup with no source radio. That’s not a bug in the parse — the source genuinely isn’t on the wire in that PDU. The talkgroup is enough to start and file the call, but “who was talking” has to come from somewhere else: a subsequent extended in-call PDU, or the full grant, seen later in the transmission. Resolving it is a stateful, cross-PDU backfill, and it belongs in the layer that owns call state, not the stateless decoder.
Source=0 from the compressed PDU; reconciling who was talking is the trunking engine's job, not the decoder's.How that principle shaped the Go code
- The decoder stays stateless about calls. It publishes what the PDU says — including a source of 0 — and does not try to remember prior transmissions to patch the gap. Keeping the decoder memoryless is what keeps it testable one PDU at a time.
- The backfill lives in the engine. The source RID is reconciled downstream,
where call state already exists, in the
trunking engine’s source-RID recovery.
The seam is the
SourceID == 0grant. - Both grant shapes publish one event type. Abbreviated, extended, full grant,
unit-to-unit — all normalise to
trunking.Grant, so subscribers never branch on Phase 1 vs Phase 2 or compressed vs full. - FEC config travels with the grant. The trellis/RS/interleave/scrambler state is snapshotted into the grant so the voice composer can run the same MAC dispatch on the voice-interleaved MAC sub-frames — which is how talker-alias fragments reach the receiver on Phase 2 systems that don’t emit them on the control channel.
That last point is our recurring thread surfacing again: on Phase 2, the Motorola
talker alias rides FACCH-S MAC PDUs interleaved with voice, reassembled and passed
through the same unverified per-byte cipher as Phase 1 — and gated the same way on
motorola.CipherVerified. The carriage differs; the wall is identical.
Where this goes next
Part 5 leaves P25 for DMR — another two-slot TDMA family, but with its own burst structure, EMB signalling, Full Link Control, and a reverse channel. The source-less-grant problem introduced here is picked up in full by the trunking engine, and the Phase 2 voice frames feed the Voice Coding series.
FAQ
How is P25 Phase 2 different from Phase 1? Phase 1 is FDMA (one call per 12.5 kHz carrier, C4FM). Phase 2 is two-slot TDMA (two calls per carrier, H-DQPSK at 6000 sym/s), and its signalling rides as MAC PDUs inside the traffic-channel superframe rather than as standalone TSBKs.
What does the ISCH do? It prefixes every sub-frame with a Golay-protected slot-type field so the receiver can route the sub-frame — voice to the composer, MAC to the control-channel state machine — without speculatively decoding the payload.
Why do some Phase 2 grants have no source radio?
Because the compressed, abbreviated in-call GroupVoiceChannelUser PDU (opcode 0x01)
omits the source to save slot space; its SourceID decodes to 0. The source is
recovered later from an extended PDU or the full grant, in the trunking engine.
What is the PN44 seed and where does it come from? It’s the scrambler seed for the Phase 2 traffic channel, derived from the system’s WACN, System ID, and Color Code — values carried in the Network Status Broadcast MAC PDU. Until an NSB is seen, a configured seed stands in.
Does GopherTrunk decrypt encrypted Phase 2 calls? No. It parses the Encryption Sync MAC PDU to identify the algorithm and key IDs (and flags the call protected via the grant’s service options), but it does not decrypt.
Series navigation
Part 4 of 12 · ← Part 3: P25 Phase 1 TSBKs & Link Control · Next → Part 5: DMR Tier 2/3 — Bursts, EMB & FLC