Patrick Lidstone
Self-hosted

WSJT-X message packing: the shared 77-bit and 72-bit payload codecs

How FT8, FT4, FST4 and Q65 squeeze a human-readable exchange — two callsigns and a grid, report, or 13 free-text characters — into exactly 77 bits, and how the older JT4/JT9/JT65 modes do the same in 72 bits.

Rafe project · app/radio/ftx/{pack,unpack,text}.py (77-bit) · app/radio/jtx/{pack,unpack}.py (72-bit) · a clean-room port of ft8_lib / WSJT-X


Abstract

Every weak-signal WSJT-X mode transmits the same short message: a "to" callsign, a "from" callsign, and one trailing token (a Maidenhead grid, a signal report, a roger/acknowledgement token, or nothing) — or, as an escape hatch, up to thirteen characters of arbitrary text. What differs between FT8, FT4, FST4 and Q65 is only the channel code and the waveform; the source message codec is shared, and it compresses that exchange into a fixed 77-bit payload (ten bytes, the low three bits of the tenth unused). This document specifies that codec exactly, to the bit, so it can be reimplemented from nothing. It covers the three-bit i3 message- type field and its i3.n3 sub-typing; the base-37 packing of a standard callsign into 28 bits; the 22/12/10-bit callsign hash (a single 64-bit multiply) used for compound and nonstandard calls; the 15-bit grid/report field; and the base-42 packing of 13 free-text characters into a 71-bit integer. It then specifies the legacy 72-bit JT4/JT9/JT65 codec — a different, older packing with a base-37 callsign field, a Maidenhead-to-degrees grid quantiser, and no hashing — which the jtx modes still use. Both codecs are given with every constant, every character set quoted in full, and step-by-step encode and decode for each supported message type, followed by byte-exact worked examples validated against the in-repo ft8_lib reference and WSJT-X's own ft8code/jt65code utilities.

The two codecs are independent and are not interoperable — a 77-bit FT8 payload and a 72-bit JT65 payload of the same message have completely different bits.


1. Background

1.1 Why 77 bits

The original JT4/JT65/JT9 modes (2003–2012) packed a message into 72 bits (§7). That format was frugal but rigid: two callsigns plus a grid or a small set of report tokens, with awkward, lossy support for compound callsigns and free text. In 2018, for FT8 and FT4, WSJT-X adopted a redesigned 77-bit source-encoded message (K. Somberg / J. Taylor, pack77.f90). The five extra bits buy a proper message-type field, so the same 77 bits can mean a standard QSO exchange, a contest exchange (ARRL Field Day, RTTY Roundup, EU VHF, WW Digi), a DXpedition multi-slot message, machine telemetry, or free text — the receiver reads the type first and then knows how to parse the rest. 77 bits also happens to fit the LDPC codes cleanly: FT8/FT4 append a 14-bit CRC to make 91 bits, which is the K of the LDPC(174, 91) code; FST4 and Q65 wrap the same 77-bit payload in their own codes.

Ten bytes hold the payload (FTX_PAYLOAD_LENGTH_BYTES = 10, refs/ft8_lib_src/message.h); bits are numbered 0..76 MSB-first, bit 0 being the top bit of byte 0. Bits 77, 78, 79 (the low three bits of byte 9) are unused padding and must be zero before the CRC is taken.

1.2 The i3 / n3 type field

The message type lives in the last three real bits of the payload:

  • i3 = bits 74, 75, 76 = (payload[9] >> 3) & 0x07 (app/radio/ftx/unpack.py, refs/ft8_lib_src/message.c).
  • When i3 == 0, a further three-bit sub-type n3 = bits 71, 72, 73 = ((payload[8] << 2) & 0x04) | ((payload[9] >> 6) & 0x03) (app/radio/ftx/unpack.py, refs/ft8_lib_src/message.c).

Reading i3 (and n3 when i3 == 0) tells the decoder how to interpret the remaining 74 (or 71) bits. The canonical table, transcribed from the in-repo reference enum refs/ft8_lib_src/message.h (WSJT-X's pack77.f90), is in §3.2. Only a subset of these types is actually produced or parsed by the Rafe port (§10) — but a conformant decoder must at least read i3/n3 correctly to route the payload, which the port does (get_type, app/radio/ftx/unpack.py).

1.3 The message families

  • Standard message (i3 = 1, or i3 = 2 for the /P EU-VHF variant): two 28-bit callsign fields (each with a 1-bit /R|/P suffix flag) and a 15-bit grid/report field. This is the overwhelming majority of on-air traffic: CQ K1ABC FN42, K1ABC W9XYZ EN37, K1ABC W9XYZ -07, K1ABC W9XYZ RR73.
  • Nonstandard-call message (i3 = 4): one callsign sent in full as a 58-bit base-38 string, the other as a 12-bit hash, plus a roger token. Used when a call will not fit the standard base-call grammar (CQ PJ4/K1ABC, <W9XYZ> PJ4/KA1ABC RR73).
  • Free text (i3.n3 = 0.0): 13 characters from a 42-symbol alphabet packed into a 71-bit integer (TNX BOB 73 GL).
  • Telemetry (i3.n3 = 0.5): 71 arbitrary bits, shown as 18 hex digits.
  • Contest / DXpedition types (0.10.6, i3 = 3, i3 = 5): defined by WSJT-X, listed in §3.2, not implemented by this port (§10).

2. Character sets

All of the packing arithmetic is base-N conversion, where the "digits" are characters looked up in one of six fixed alphabets. In the C reference these are the ft8_char_table_e variants, whose nchar/charn functions compute the index algorithmically (refs/ft8_lib_src/text.c); the Rafe port collapses each to an explicit table string and uses str.find / indexing (app/radio/ftx/text.py). nchar(c, table) == table.find(c) (returns −1 if absent); charn(i, table) == table[i]. The six tables, quoted exactly from app/radio/ftx/text.py:

name (text.py) len string (exact, note leading space where present) used for
FULL 42 " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?" free text (§6)
ALNUM_SPACE_SLASH 38 " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/" pack58 & hash (§4)
ALNUM_SPACE 37 " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" base-call char 0
ALNUM 36 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" base-call char 1
NUMERIC 10 "0123456789" base-call char 2
LETTERS_SPACE 27 " ABCDEFGHIJKLMNOPQRSTUVWXYZ" base-call chars 3–5

These are byte-identical to the reference (refs/ft8_lib_src/text.h). Index 0 of every space-bearing table is the space character, which is what makes a right-aligned short callsign pad cleanly with leading spaces.


3. The 77-bit payload

3.1 Field-width map

The 77 bits are partitioned differently per type. Widths below are from the reference enum comments (refs/ft8_lib_src/message.h); the ones the port encodes/decodes are marked ✔.

i3.n3 fields (bit widths, MSB→LSB, before the trailing i3) port
1 n28a(28) ipa(1) n28b(28) ipb(1) ir(1) igrid4(15) → i3(3)
2 same layout as 1; /P suffix, EU VHF
4 n12(12) n58(58) iflip(1) nrpt(2) icq(1) → i3(3)
0.0 f71(71) → n3(3) i3(3)
0.5 t71(71) → n3(3) i3(3) decode→hex only
0.1 DXpedition: 28 28 10 5 not handled
0.2 EU VHF: 28 1 1 3 12 25 not handled
0.3 ARRL Field Day: 28 28 1 4 3 7 not handled
0.6 Contesting: 28 28 15 not handled
3 ARRL RTTY Roundup: 1 28 28 1 3 13 not handled
5 WWROF: 1 28 28 1 7 9 not handled

Note that i3 always occupies bits 74–76, and for i3 = 0 the sub-type n3 occupies bits 71–73, regardless of what the type actually is; this is why the type can always be read before parsing (§1.2).

3.2 The full i3.n3 type table

Transcribed verbatim from refs/ft8_lib_src/message.h (the WSJT-X message types) and the routing switch refs/ft8_lib_src/message.c:

i3 n3 type (ft8_lib enum) example routed by get_type
0 0 FREE_TEXT TNX BOB 73 GL → free text
0 1 DXPEDITION K1ABC RR73; W9XYZ <KH1/KH7Z> -12 → (unknown to port)
0 2 EU_VHF PA3XYZ/P R 590003 IO91NP → (unknown)
0 3 ARRL_FD WA9XYZ KA1ABC R 16A EMA → (unknown)
0 4 ARRL_FD (Field Day, second variant) → (unknown)
0 5 TELEMETRY 0123456789abcdef01 → telemetry (hex)
0 6 CONTESTING K1ABC RR73; CQ W9XYZ EN37 UNKNOWN (see note)
0 7 reserved → UNKNOWN
1 STANDARD WA9XYZ/R KA1ABC/R R FN42 → standard
2 STANDARD G4ABC/P PA9XYZ JO22 (EU VHF /P) → standard
3 ARRL_RTTY TU; W9XYZ K1ABC R 579 MA → (unknown)
4 NONSTD_CALL <WA9XYZ> PJ4/KA1ABC RR73 → nonstandard
5 WWROF TU; W9XYZ K1ABC R-07 FN → (unknown)

Note on n3 = 6: the enum names the CONTESTING type at 0.6, but the actual routing switch (refs/ft8_lib_src/message.c) handles only n3 0–5 and falls through to UNKNOWN for 6 and 7. So a 0.6 payload is recognised as a valid type name but not decoded. The Rafe port is stricter still: get_type (app/radio/ftx/unpack.py) returns "free" for 0.0, "telemetry" for 0.5, "std" for i3 ∈ {1,2}, "nonstd" for i3 = 4, and "other" for everything else; unpack77 returns None for "other" (app/radio/ftx/unpack.py).

3.3 Byte extraction cheat-sheet

For a decoder, these are the exact bit-extraction expressions (all from app/radio/ftx/unpack.py), payload indexed payload[0..9]:

i3      = (payload[9] >> 3) & 0x07                                     # bits 74..76
n3      = ((payload[8] << 2) & 0x04) | ((payload[9] >> 6) & 0x03)      # bits 71..73  (i3==0 only)

# standard (i3 = 1 or 2):  unpack.py
n29a    =  (payload[0] << 21) | (payload[1] << 13) | (payload[2] << 5) | (payload[3] >> 3)
n29b    = ((payload[3] & 0x07) << 26) | (payload[4] << 18) | (payload[5] << 10)
                                      | (payload[6] << 2) | (payload[7] >> 6)
ir      =  (payload[7] & 0x20) >> 5
igrid4  = ((payload[7] & 0x1F) << 10) | (payload[8] << 2) | (payload[9] >> 6)
n28a    = n29a >> 1 ; ipa = n29a & 1          # likewise n28b, ipb from n29b

# nonstandard (i3 = 4):  unpack.py
n12     =  (payload[0] << 4) | (payload[1] >> 4)
n58     = ((payload[1] & 0x0F) << 54) | (payload[2] << 46) | (payload[3] << 38)
        |  (payload[4] << 30) | (payload[5] << 22) | (payload[6] << 14)
        |  (payload[7] <<  6) | (payload[8] >> 2)
iflip   =  (payload[8] >> 1) & 0x01
nrpt    = ((payload[8] & 0x01) << 1) | (payload[9] >> 7)
icq     =  (payload[9] >> 6) & 0x01

The encoder writes the exact inverse; see the per-type steps in §7 and the byte-assignment lines in app/radio/ftx/pack.py (std, nonstd, and free).


4. Callsign encoding

A "to" or "from" field carries a callsign or a special token in 28 bits (pack28, app/radio/ftx/pack.py; unpack28, unpack.py), extended by a 1-bit suffix flag to the 29-bit n29 value packed into the payload. The 28-bit value space is partitioned by two constants (app/radio/ftx/pack.py):

NTOKENS = 2063592          # size of the token sub-range
MAX22   = 4194304          # = 2^22, size of the 22-bit hash sub-range

with the ranges (all inclusive of the low end, exclusive of the high):

n28 range meaning
0 .. NTOKENS-1 special tokens: DE, QRZ, CQ, CQ nnn, CQ abcd
NTOKENS .. NTOKENS+MAX22-1 22-bit hash of a nonstandard callsign
NTOKENS+MAX22 .. 2^28-1 standard base callsign (base-37 packed)

These partition the 28-bit space exactly: NTOKENS + MAX22 + NBASE = 2063592 + 4194304 + 262177560 = 268435456 = 2^28, where NBASE (§4.1) is the number of standard base calls.

4.1 Standard base callsigns — base-37 packing

A "base call" is up to six characters in the fixed grammar [A-Z0-9 ] [A-Z0-9] [0-9] [A-Z ] [A-Z ] [A-Z ] — i.e. an optional prefix character, a letter/digit, a mandatory digit in the third position, then up to three letters of suffix. The callsign is right-justified into a six-character buffer c6 so that the mandatory digit lands in position 2 (pack_basecall, app/radio/ftx/pack.py; reference refs/ft8_lib_src/message.c):

  • if callsign[2] is a digit and length ≤ 6 → copy as-is (AB0XYZ);
  • else if callsign[1] is a digit and length ≤ 5 → shift right by one, leading space (A0XYZ" A0XYZ").

Then each position is indexed in its own alphabet and the six indices are folded into one integer by mixed-radix Horner evaluation (app/radio/ftx/pack.py):

i0 = nchar(c6[0], ALNUM_SPACE)     # radix 37
i1 = nchar(c6[1], ALNUM)           # radix 36
i2 = nchar(c6[2], NUMERIC)         # radix 10
i3 = nchar(c6[3], LETTERS_SPACE)   # radix 27
i4 = nchar(c6[4], LETTERS_SPACE)   # radix 27
i5 = nchar(c6[5], LETTERS_SPACE)   # radix 27
n  = i0
n  = n*36 + i1
n  = n*10 + i2
n  = n*27 + i3
n  = n*27 + i4
n  = n*27 + i5

If any index is −1 (a character outside its position's alphabet), the callsign is not a standard base call and pack_basecall returns −1. The maximum value is NBASE - 1 = 37·36·10·27·27·27 - 1 = 262177559 (NBASE = 262177560, app/radio/jtx/pack.py uses the same product). The final n28 for a standard call is NTOKENS + MAX22 + n (pack.py).

Two prefix work-arounds are applied before packing (pack.py, reference message.c), because these prefixes break the "digit in position 2" rule:

  • Swaziland 3DA0XYZ → packed as 3D0XYZ (drop the A).
  • Guinea 3XA… (where char 2 is a letter) → packed as QA… (3XQ).

Decode reverses them: a decoded 3D0… with a non-space at index 3 becomes 3DA0…, and a decoded call beginning Q+letter becomes 3X+… (unpack.py, message.c).

Suffix flag. Before base-call packing, a trailing /P or /R is stripped and the 1-bit ip flag set (pack.py, message.c). On decode, if ip = 1, the suffix is reattached as /R when i3 = 1 or /P when i3 = 2 (unpack.py, message.c).

4.2 Special tokens

pack28 recognises these before attempting a base call (app/radio/ftx/pack.py; decode unpack.py):

token n28
DE 0
QRZ 1
CQ 2
CQ nnn (three digits 000999) 3 + nnn → range 3 .. 1002
CQ abcd (1–4 letters) 3 + 1000 + m where m = base-27 of the letters

The CQ nnn / CQ abcd value comes from parse_cq_modifier (pack.py, message.c): it scans up to five characters after CQ ; if it finds exactly three digits it returns the integer; if it finds up to four letters (and no digits) it returns 1000 + m, where m = 27·m + (letter − 'A' + 1) accumulated left-to-right. Decode splits the token sub-range back out (unpack.py): n28 ≤ 2DE/QRZ/CQ; n28 ≤ 1002CQ + three-digit number; n28 ≤ 532443CQ + up to four letters recovered base-27 in LETTERS_SPACE.

4.3 The callsign hash (22 / 12 / 10 bits)

A callsign that is not a standard base call — a compound call like PJ4/K1ABC, or one longer than the grammar allows — is represented by a hash. WSJT-X hashes the callsign to 22 bits (and derives 12- and 10-bit hashes for the narrower fields) with a single 64-bit multiply (HashStore.save, app/radio/ftx/pack.py; reference refs/ft8_lib_src/message.c):

n58 = 0
for i in 0..10:                                   # exactly 11 characters
    c = callsign[i] if i < len else ' '           # pad short calls with spaces
    j = nchar(c, ALNUM_SPACE_SLASH)               # radix-38 digit; -1 → fail
    n58 = 38 * n58 + j
n22 = (47055833459 * n58) >> (64 - 22)  &  0x3FFFFF     #  keep 22 bits
n12 = n22 >> 10                                        #  top 12 bits
n10 = n22 >> 12                                        #  top 10 bits
  • The callsign is treated as an 11-character, radix-38 number over ALNUM_SPACE_SLASH (space = 0, digits, letters, / = 37), padding short calls with trailing spaces. This gives a value n58 < 38^11 < 2^58.
  • The hash multiplier is the exact constant 47055833459 (0xAF4BF0973, app/radio/ftx/pack.py, message.c). The product 47055833459 * n58 is taken modulo 2^64, then the top 22 bits are kept (>> (64 - 22) = >> 42). This is a multiplicative (Fibonacci-style) hash: the multiplier is near 2^64 · φ-scaled, spreading callsigns uniformly across the 22-bit space.
  • n12 and n10 are simply the high 12 and 10 bits of n22 — so all three hashes of a call are prefixes of one another, and any hash width can be checked against a stored 22-bit value.

The hash is not invertible. A decoder recovers the original callsign only if it has seen that call in full earlier in the same decode pass and stored the mapping. HashStore (pack.py) does exactly this: save(call) records call → n22 and the reverse (22, n22) → call, (12, n12) → call, (10, n10) → call; lookup(bits, h) returns the stored callsign or None. Every place a full callsign passes through — pack28 base calls (pack.py), unpack28 (unpack.py), pack58/unpack58 (pack.py, unpack.py) — calls save, so within one slot the hashes resolve. A hash with no stored preimage decodes as the placeholder <...> (unpack.py, unpack.py); a resolved hash is rendered in angle brackets, e.g. <PJ4/K1ABC>.

When a standard message carries a nonstandard call, pack28 returns NTOKENS + n22 (pack.py) — i.e. the 22-bit hash placed in the token+hash sub-range — and the suffix flag ip is forced to 0 (pack.py).

4.4 Nonstandard-call message: the 58-bit full callsign

The i3 = 4 message sends one callsign in full and the other as a 12-bit hash, so that at least one end of a rare-call QSO is always transmitted losslessly. The full call is packed into 58 bits as an 11-character radix-38 number over ALNUM_SPACE_SLASH (pack58, app/radio/ftx/pack.py; unpack58, unpack.py; reference message.c):

result = 0
for each char of src (leading '<' skipped, stop at next '<' or after 11 chars):
    j = nchar(ch, ALNUM_SPACE_SLASH)     # -1 → fail
    result = result*38 + j

Unlike the hash, pack58 also saves the call so its 12-bit hash is available to the peer's field. Decode divides n58 back out digit-by-digit in radix 38 and strip()s the right-aligned result (unpack.py). The 58-bit value fits because 38^11 ≈ 2^57.7 < 2^58.


5. Grid and report encoding (15 bits + ir)

The trailing "extra" token of a standard message is packed into a 16-bit quantity: the top bit is the ir "R "-prefix flag (bit 0x8000), the low 15 bits are igrid4 (packgrid, app/radio/ftx/pack.py; unpackgrid, unpack.py; reference message.c). The boundary constant is:

MAXGRID4 = 32400        # app/radio/ftx/pack.py

Encoding rules, in order:

extra token igrid4 value
empty (no third token) MAXGRID4 + 1
RRR MAXGRID4 + 2
RR73 MAXGRID4 + 3
73 MAXGRID4 + 4
4-char grid AA00RR99 ([A-R][A-R][0-9][0-9]) ((g0−'A')·18 + (g1−'A'))·10·10 + (d2)·10 + d3, range 0 .. 32399
report beginning R (e.g. R-07, R+05) (MAXGRID4 + 35 + dd) | 0x8000 (ir = 1)
plain report (e.g. -07, +05) MAXGRID4 + 35 + dd (ir = 0)

The 4-character grid is a base-conversion of the Maidenhead locator: the two field letters run AR (18 values each), the two squares 09. Since the grid maximum is 17·18·100 + 99 = 32399 < MAXGRID4, any value ≤ MAXGRID4 is a grid and any value > MAXGRID4 is a token or report. Numeric reports store 35 + dd where dd is the signed report parsed by dd_to_int (text.py, message.c); the +35 offset keeps reports as low as −30 non-negative.

Decoding (unpack.py) mirrors this: igrid4 ≤ MAXGRID4 → recover the four grid characters by successive %10, %10, %18, %18 (prepending "R " if ir); irpt = igrid4 − MAXGRID4 selects "" (1), RRR (2), RR73 (3), 73 (4), or otherwise a signed two-digit report irpt − 35, prefixed with R if ir (int_to_dd, unpack.py, always with a + or sign).

Encoder limitation (inherited from ft8_lib). The R -prefixed 4-character grid form (e.g. K1ABC W9XYZ R FN42, four tokens) is not encodable: the parser only takes three whitespace-delimited tokens, so a fourth token routes the whole message to free text. This is an explicit ft8_lib TODO (refs/ft8_lib_src/message.c, "Check for R prefix before a 4 letter grid"). The ir=1 path in packgrid handles only R±dd reports, not R+grid. The decoder does reconstruct R <grid4>.


6. Free-text and telemetry encoding

6.1 Free text — 13 characters, base 42

Free text (i3.n3 = 0.0) carries up to 13 characters from the 42-symbol FULL alphabet " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?". The 13 characters form a base-42 integer that fits in 71 bits (13·log2(42) ≈ 70.1), stored big-endian in a 9-byte accumulator b71 (_encode_free, app/radio/ftx/pack.py; reference message.c):

b71 = 9 zero bytes
for idx in 0..12:                         # exactly 13 chars, space-padded
    cid = nchar(text[idx] or ' ', FULL)   # 0..41 ; -1 → fail (message too long/illegal)
    # multiply the 72-bit big-endian accumulator by 42 and add cid:
    rem = cid
    for i in 8 downto 0:
        rem     += b71[i] * 42
        b71[i]   = rem & 0xFF
        rem    >>= 8

After the loop b71 holds Σ cid_k · 42^(12−k). It is then left-shifted by one bit into payload[0..8], and payload[9] is set to 0 (which makes i3 = 0 and n3 = 0) — this is the shared "telemetry" packing (_encode_free → the shift at pack.py; reference ftx_message_encode_telemetry, message.c):

carry = 0
for i in 8 downto 0:
    payload[i] = (b71[i] << 1) | (carry >> 7)
    carry      = b71[i] & 0x80
payload[9] = 0

The one-bit left shift aligns the 71-bit field to bits 0..70 of the payload, so the following bits 71..76 (n3, i3) fall out as zero. Decode reverses both steps (_decode_free + _decode_telemetry_b71, app/radio/ftx/unpack.py; message.c): right-shift the payload by one bit to recover b71, then divide the big-endian integer by 42 thirteen times, emitting charn(rem, FULL) from the least-significant character upward, and strip() the result:

b71 = right-shift payload[0..8] by 1 bit           # unpack.py
c14 = 13 spaces
for idx in 12 downto 0:
    rem = 0
    for i in 0..8:                                  # divide b71 (big-endian) by 42
        rem   = (rem << 8) | b71[i]
        b71[i] = rem // 42
        rem   = rem % 42
    c14[idx] = FULL[rem]
text = "".join(c14).strip()

6.2 Telemetry — 71 raw bits

Telemetry (i3.n3 = 0.5) is 71 arbitrary bits using the identical shift packing; the port only decodes it, and only to a hex string: right-shift to recover b71, then emit the nine bytes as 18 uppercase hex digits (unpack.py, message.c). The port never produces telemetry.


7. Encode and decode, step by step

7.1 Top-level encode: pack77

pack77(message_text, hs) (app/radio/ftx/pack.py; reference ftx_message_encode, message.c):

  1. strip().upper() the text.
  2. Tokenize (_tokenize, pack.py, mirroring copy_token): if the text starts with CQ and the word after CQ matches the nnn/abcd modifier grammar, treat CQ <word> as a single call_to token; otherwise call_to = CQ. Then call_de is the next token, extra the one after, and any further tokens are leftover.
  3. If there is no leftover (≤ 3 tokens), try _encode_std; if that returns None, try _encode_nonstd.
  4. Otherwise (or if both structured encoders fail) try _encode_free.
  5. If nothing succeeds, raise MessageError.

The optional hs is a shared HashStore so hashes accumulate across a slot; if omitted a fresh one is created.

7.2 Standard message (i3 = 1 / 2) — _encode_std / _decode_std

Encode (app/radio/ftx/pack.py; reference message.c):

  1. n28a, ipa = pack28(call_to), n28b, ipb = pack28(call_de); fail if either < 0.
  2. i3 = 1; if either call ends /P, i3 = 2 (and fail if either also ends /R).
  3. If call_de is a slashed compound (/… at position ≥ 2, not /P|/R) and call_to is a CQ, return None → forces the type-4 path (pack.py, message.c).
  4. igrid4 = packgrid(extra).
  5. n29a = (n28a << 1) | ipa, n29b = (n28b << 1) | ipb; if call_to ends /R set n29a |= 1, if /P set n29a |= 1 and i3 = 2.
  6. Emit the ten bytes (pack.py):
payload[0] =  n29a >> 21
payload[1] =  n29a >> 13
payload[2] =  n29a >>  5
payload[3] = (n29a <<  3) | (n29b >> 26)
payload[4] =  n29b >> 18
payload[5] =  n29b >> 10
payload[6] =  n29b >>  2
payload[7] = (n29b <<  6) | (igrid4 >> 10)
payload[8] =  igrid4 >>  2
payload[9] = (igrid4 <<  6) | (i3 << 3)          # all &0xFF

Decode (unpack.py): extract n29a, n29b, ir, igrid4, i3 per §3.3; call_to = unpack28(n29a>>1, n29a&1, i3), call_de = unpack28(n29b>>1, n29b&1, i3); extra = unpackgrid(igrid4, ir); join the non-empty parts with spaces.

7.3 Nonstandard-call message (i3 = 4) — _encode_nonstd / _decode_nonstd

Encode (app/radio/ftx/pack.py; reference message.c):

  1. i3 = 4. icq = (call_to == "CQ" or startswith "CQ "). Fail if a non-CQ call_to is shorter than 3, or call_de shorter than 3.
  2. If not CQ: choose which call is sent in full. iflip = 1 if call_de is bracketed <…> (meaning call_de is the hashed one and call_to goes in full); else iflip = 0. call12 (hashed→ n12) and call58 (full) are chosen accordingly; save call12 to get its 12-bit hash. If CQ: iflip = 0, n12 = 0, call58 = call_de.
  3. n58 = pack58(call58).
  4. nrpt from extra: RRR→1, RR73→2, 73→3, else 0 (0 also when CQ).
  5. Emit (pack.py):
payload[0] =  n12 >> 4
payload[1] = (n12 << 4) | (n58 >> 54)
payload[2] =  n58 >> 46
payload[3] =  n58 >> 38
payload[4] =  n58 >> 30
payload[5] =  n58 >> 22
payload[6] =  n58 >> 14
payload[7] =  n58 >>  6
payload[8] = (n58 <<  2) | (iflip << 1) | (nrpt >> 1)
payload[9] = (nrpt << 7) | (icq << 6) | (i3 << 3)          # all &0xFF

Decode (unpack.py): extract n12, n58, iflip, nrpt, icq per §3.3; call_decoded = unpack58(n58); call_3 = <lookup(12, n12)> or <...>; the full/hashed pair is un-flipped by iflip. If icq: call_to = "CQ", no extra; else call_to = the first call and extra = {1:RRR, 2:RR73, 3:73}[nrpt]. call_de is the second call. Join non-empty parts.

7.4 Free text — _encode_free / _decode_free

Exactly §6.1. Encode returns None if the text exceeds 13 characters or contains a character absent from FULL. Decode always succeeds for a 0.0 payload.

7.5 The FT4 payload scramble

FT4 (only) XORs the 10 payload bytes with a fixed pseudo-random vector before CRC and FEC, to break up pathological all-zero payloads on its shorter frame (ft4_xor_payload, app/radio/ftx/pack.py; applied in app/radio/ftx/encode.py, undone in decode.py). The vector is FT4_XOR = (74, 94, 137, 180, 176, 138, 121, 85, 190, 40) (app/radio/ftx/constants.py). FT8, FST4 and Q65 do not scramble the payload.

7.6 Relationship to the CRC (for byte-exact validation)

Message packing produces the 77-bit payload only. FT8/FT4 then append a 14-bit CRC (compute_crc/add_crc, app/radio/ftx/crc.py) to form the 91-bit a91 that feeds the LDPC(174, 91) encoder. The CRC uses polynomial CRC_POLY = 0x2757, width CRC_WIDTH = 14 (app/radio/ftx/constants.py), computed over the 77 payload bits zero-extended to 82 bits (compute_crc(a91, 96-14), crc.py). The CRC is outside the scope of message packing but is required to reproduce ft8code output byte-for-byte (§9).


8. The legacy 72-bit format (JT4 / JT9 / JT65)

The older modes use a completely separate, 72-bit packing (app/radio/jtx/pack.py, unpack.py; a port of WSJT-X packjt.f90), consumed by jt4.py, jt9.py, jt65.py. (FST4 and Q65, despite living in jtx/, use the 77-bit ftx codec instead — fst4.py, q65.py.) It has no message-type field, no hashing, and only the standard two-call-plus-grid/report grammar; anything else is out of band (free text and prefix/suffix are TODO, jtx/pack.py).

The 72 bits are twelve 6-bit words dat[0..11], grouped as 28 + 28 + 16: callsign 1, callsign 2, grid/report. Constants (jtx/pack.py):

NBASE  = 37 * 36 * 10 * 27 * 27 * 27 = 262177560
NGBASE = 180 * 180                   = 32400

The word alphabet is _C = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ " (37 chars, digits first, space last — note this differs from the 77-bit tables, which put space first), jtx/unpack.py. nchar here maps 0-9→0-9, A-Z→10-35, anything else → 36 (space) (jtx/pack.py).

8.1 Callsign — 28-bit base-37 field (packcall)

packcall (app/radio/jtx/pack.py) applies the same Swaziland/Guinea prefix work-arounds (3DA0→3D0, 3X<letter>→Q), then handles tokens and base calls:

input 28-bit value
CQ NBASE + 1
QRZ NBASE + 2
CQ nnn NBASE + 3 + nnn (three digits)
DE 267796945 (a hard-coded token, jtx/pack.py)
base call base-37 fold (below)

The base call is normalised to six characters by _norm_call (jtx/pack.py): if char 2 is a digit keep as-is, else if char 1 is a digit prepend a space (" "+call[:5]), else reject; then pad to six with spaces. The fold (jtx/pack.py) uses the same radices as the 77-bit base call but subtracts 10 from the last-three letter indices (because _C has letters at 10–35, and the suffix positions want A→0):

n = nchar(tmp[0])                       # radix 37
n = 36*n + nchar(tmp[1])                # radix 36
n = 10*n + nchar(tmp[2])                # radix 10
n = 27*n + nchar(tmp[3]) - 10           # radix 27, letters offset by -10
n = 27*n + nchar(tmp[4]) - 10
n = 27*n + nchar(tmp[5]) - 10

Decode (unpackcall, jtx/unpack.py) inverts it: n < NBASE → six characters recovered by %27+10, %27+10, %27+10, %10, %36, %37 into _C, then trimmed; else the token values map back to CQ, QRZ, CQ nnn, DE.

8.2 Grid / report — 16-bit field (packgrid)

packgrid (app/radio/jtx/pack.py) is different from the 77-bit version: a real 4-character grid is converted to longitude/latitude degrees and then quantised, rather than base-converted directly. Special values first:

input value
empty NGBASE + 1
-01-30 NGBASE + 1 + n
R-01R-30 NGBASE + 31 + n
RO NGBASE + 62
RRR NGBASE + 63
73 NGBASE + 64
report -50+49 rewritten to a pseudo-grid KAnn/LAnn (LA if the token began R), then packed as a grid
4-char grid [A-R][A-R][0-9][0-9] ((lon+180)//2)·180 + lat (below)

The grid→degrees step (_grid2deg, jtx/pack.py) computes the sub-square centre (m = 12, the centre of the appended "mm") and returns (dlong, dlat); then lon = int(dlong), lat = int(dlat + 90), and the packed value is ((lon + 180) // 2) · 180 + lat (jtx/pack.py). Because the extended-report range −50..+49 is folded onto the KA/LA pseudo-grids, JT65 can carry reports a standard grid field could not. Decode (unpackgrid, jtx/unpack.py) reverses via _deg2grid and recognises the KA/LA grids as signed reports.

8.3 Word assembly (packmsg / unpackmsg)

packmsg (app/radio/jtx/pack.py) folds CQ/QRZ/DE and the OOO-suppression rule (a third token OOO is dropped, pack.py) into the two-call-plus-grid form, then distributes the three integers nc1 (28b), nc2 (28b), ng (16b) across the twelve 6-bit words (pack.py):

dat[0]  =  (nc1 >> 22) & 63
dat[1]  =  (nc1 >> 16) & 63
dat[2]  =  (nc1 >> 10) & 63
dat[3]  =  (nc1 >>  4) & 63
dat[4]  = 4*(nc1 & 15) + ((nc2 >> 26) & 3)
dat[5]  =  (nc2 >> 20) & 63
dat[6]  =  (nc2 >> 14) & 63
dat[7]  =  (nc2 >>  8) & 63
dat[8]  =  (nc2 >>  2) & 63
dat[9]  = 16*(nc2 & 3) + ((ng >> 12) & 15)
dat[10] =  (ng >>  6) & 63
dat[11] =   ng        & 63

msg_bits (pack.py) then serialises each word MSB-first to the 72-bit vector. unpackmsg (jtx/unpack.py) reassembles nc1, nc2, ng and, if ng ≥ 32768 (the plain-text / free-text flag, unpack.py), returns None (free text is not handled); otherwise unpacks the two calls and the grid.


9. Interoperability and validation

Round-trip. Both codecs are exact inverses within the port. Verified in-repo:

77-bit                        payload (hex)              decodes to
CQ K1ABC FN42                 000000204def1a8a1988       'CQ K1ABC FN42'
K1ABC W9XYZ EN37              09bde3506149dc085648       'K1ABC W9XYZ EN37'
K1ABC W9XYZ -07               09bde3506149dc1fab08       'K1ABC W9XYZ -07'
K1ABC W9XYZ R+05              09bde3506149dc3fae08       'K1ABC W9XYZ R+05'
K1ABC W9XYZ RR73              09bde3506149dc1fa4c8       'K1ABC W9XYZ RR73'
CQ DX K1ABC FN42              000046f04def1a8a1988       'CQ DX K1ABC FN42'
CQ 075 K1ABC FN42             000004e04def1a8a1988       'CQ 075 K1ABC FN42'
CQ PJ4/K1ABC                  000001a3a311caa00460       'CQ PJ4/K1ABC'         (i3=4)
TNX BOB 73 GL                 63edcee2a4ae07f50000       'TNX BOB 73 GL'        (i3.n3=0.0)

72-bit                        dat[0..11]
K1ABC W9XYZ EN37              [61,48,48,35,35,57,29,55,46,54,0,41]
CQ K1ABC FN42                 [62,32,32,49,39,55,3,2,14,5,33,40]

Worked decode of K1ABC W9XYZ EN3709 bd e3 50 61 49 dc 08 56 48. Applying §3.3: i3 = (0x48>>3)&7 = 1 (standard). n29a = 0x09<<21 | 0xbd<<13 | 0xe3<<5 | 0x50>>3 = 20429930n28a = 10214965, ipa = 0. Subtract NTOKENS+MAX22 = 6257896 → base-call index 3957069, which is exactly the base-37 fold of " K1ABC" (0·36+20=20; ·10+1=201; ·27+1=5428; ·27+2=146558; ·27+3=3957069), so call_to = K1ABC. n29b = 25503600n28b = 12751800 → index 6493904 = W9XYZ. ir = 0, igrid4 = (0x08<<10)|(0x56<<2)|(0x48>>6) = 8537 ≤ MAXGRID4, decoded %10→7, %10→3, %18→13='N', %18→4='E' = EN37.

Against WSJT-X. The reference C sources are vendored at refs/ft8_lib_src/message.c and text.c; the port is a line-for-line translation (constants, byte offsets and shift counts are identical — compare pack.py with message.c). For end-to-end byte-exactness the payloads above, after add_crc and LDPC encode, match the symbol streams emitted by WSJT-X's ft8code "message → channel symbols" utility; the 72-bit dat words match jt65code. The jtx package notes it is "validated behaviorally against the wsjtx *code/*sim reference utilities" (app/radio/jtx/__init__.py). To validate a fresh implementation: (1) confirm the 77-bit payload hex matches the table above; (2) run ft8code "K1ABC W9XYZ EN37" and compare the printed 77-bit payload / 174-symbol codeword; (3) for JT65 run jt65code and compare the 72 message bits.


10. Limitations — what is and is not handled

77-bit codec (ftx).

  • Encoded types: standard (i3 = 1/2), nonstandard call (i3 = 4), free text (0.0). These cover essentially all normal FT8/FT4/FST4/Q65 QSO traffic.
  • Decoded-only: telemetry (0.5) → 18-hex string.
  • Not handled at all: DXpedition (0.1), EU VHF contest (0.2), ARRL Field Day (0.3/0.4), Contesting (0.6), ARRL RTTY Roundup (i3 = 3), WWROF (i3 = 5). unpack77 returns None for these; pack77 cannot produce them.
  • R-prefixed 4-character grid (… R FN42, four tokens) is not encodable — an ft8_lib TODO (§5). The decoder does render it.
  • Hash resolution is slot-local. A hashed/<…> call decodes to its real callsign only if that call was seen in full earlier in the same decode pass and saved to the shared HashStore; otherwise it renders as <...>. There is no persistent hash database across slots.
  • A single nonstandard call inside an otherwise-standard message is sent as a 22-bit hash in a type-1 message (lossy to the peer until it has heard the call in full); the full-callsign type-4 message is used only when the standard grammar can't represent the layout (e.g. CQ <compound>).

72-bit codec (jtx).

  • Standard two-call-plus-grid/report only. Free text, and explicit prefix/suffix handling, are TODO (app/radio/jtx/pack.py); unpackmsg returns None on the free-text flag (ng ≥ 32768).
  • No callsign hashing — compound/nonstandard calls that don't fit the base-37 grammar cannot be sent.
  • Used by JT4/JT9/JT65; FST4 and Q65 do not use it (they use the 77-bit codec).

11. References

  1. J. Taylor K1JT, S. Franke K9AN, B. Somerville AE6Y et al., WSJT-X — the source codec is pack77.f90 / unpack77.f90 (77-bit) and packjt.f90 (72-bit) in the WSJT-X distribution. The i3.n3 type table originates there.
  2. K. Goba YL3JG, ft8_lib (MIT) — the C reference this port follows; vendored in-repo at refs/ft8_lib_src/ (message.c, message.h, text.c, text.h, crc.c, constants.c). The nonstandard-call (i3 = 4) decode was originally contributed by KD8CEC (message.c).
  3. S. Franke, W. Somerville, J. Taylor, "The FT4 and FT8 Communication Protocols," QEX, Jul/Aug 2020 — the design rationale for the 77-bit message and its types.
  4. In-repo implementation: app/radio/ftx/pack.py, app/radio/ftx/unpack.py, app/radio/ftx/text.py, app/radio/ftx/crc.py, app/radio/ftx/constants.py (77-bit); app/radio/jtx/pack.py, app/radio/jtx/unpack.py (72-bit). See also ../native-digimodes.md for the surrounding FEC / modulation layers, and the sibling protocol specs ft8-ft4.md, jt65.md, fst4.md, q65.md.