Every byte layout the app puts on the air, with real hex dumps. The dumps were produced on 27 September 2026 by running the project’s own codecs (ChatPayloadCodec, QrFountainFrameCodec, AcousticFrameCodec, ReedSolomon, PacketCodec), so they match the implementation exactly.
Back to the documentation index.
┌──────────────────── APCM envelope ────────────────────┐
│ "APCM" │ type │ name │ MIME │ content bytes │
└──────────────────────────────────────────────────────┘
│ split into K blocks of blockLen
┌────────────────────────┼─────────────────────────────┐
▼ Light ▼ Sound ▼ Vibration / big Sound
┌─────────────────────┐ ┌─────────────────────────┐ ┌──────────────────────────┐
│ APCF v3 frame │ │ Acoustic frame │ │ Protocol packet │
│ 22 B hdr │ symbol │ │ │ 9 B hdr │ symbol │CRC16 │ │ 24 B hdr │ chunk │ CRC32 │
│ CRC-32 │ │ + Reed-Solomon parity │ │ (ACK / NACK / retry) │
└─────────┬───────────┘ └────────────┬────────────┘ └────────────┬─────────────┘
▼ ▼ ▼
QR code, byte mode, MT-FSK tones after a PWK pulses (vibration)
EC level L, mask 0 2-frame sync marker or legacy FSK tones
Byte order: the APCM envelope has no multi-byte integers. APCF and acoustic frames are big-endian. Protocol packets are little-endian.
Source: lib/core/chat/chat_payload_codec.dart.
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 4 | Magic | 41 50 43 4D = ASCII APCM |
| 4 | 1 | Type | Index of ChatMessageType: text 0, image 1, video 2, file 3, link 4 |
| 5 | 1 | nameLen |
0–255 |
| 6 | nameLen |
File name | UTF-8 |
| 6 + nameLen | 1 | mimeLen |
0–255 |
| 7 + nameLen | mimeLen |
MIME type | UTF-8 |
| 7 + nameLen + mimeLen | rest | Content | Raw bytes to the end of the envelope (there is no length field) |
Overhead = 7 + nameLen + mimeLen.
ChatPayloadCodec.encodeText and encodeLink leave the name and MIME type empty. The type byte already says what the content is, and on Sound the 21 bytes of message.txt / text/plain used to cost more airtime than a short message itself. Receivers have always read empty fields as absent, so older builds decode these envelopes unchanged.
41 50 43 4D 00 00 00 73 6F 73
└─ "APCM" ─┘ type name mime └"sos"┘
=text len=0 len=0
4 + 1 + 1 + 1 + 3 = 10 bytes, of which 7 are overhead.
41 50 43 4D 04 00 00 68 74 74 70 73 3A 2F 2F 65 78 61 6D 70 6C 65 2E 63 6F 6D
link =0 =0 └──────────────── "https://example.com" ──────────────┘
ChatPayloadCodec.overheadBytes() returns the overhead for given name and MIME strings without building the envelope; ComposePayload.envelopeBytes uses it to show the on-air size before sending.
A compressed photo is sent with the picked file name (or photo.jpg) and MIME image/jpeg. A photo called robot_lab_5kb.jpg has 7 + 17 + 10 = 34 bytes of overhead, and its content starts with FF D8 (the JPEG start-of-image marker).
looksComplete)A receiver delivers an envelope only if all of these hold:
APCM.nameLen and mimeLen fit inside the buffer.| Type | Requirement |
|---|---|
| text, link | at least 1 byte |
| image | at least 24 bytes and a JPEG (FF D8), PNG (89 50 4E 47), GIF (47 49 46) or WebP (52 49 … 57 45 at offsets 0, 1, 8, 9) signature |
| video | at least 512 bytes |
| file | at least 1 byte |
Buffers without the magic are treated as legacy plain UTF-8 text, or as a binary file named received.bin if they aren’t valid UTF-8.
Source: lib/core/physical/fountain/qr_fountain_frame.dart. One APCF frame is the complete content of one QR code, in QR byte mode.
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 4 | Magic | 41 50 43 46 = APCF |
| 4 | 1 | Version | 03. v3 changed the LT neighbour mapping, so v2 frames are rejected |
| 5 | 1 | Flags | bit 0 = gzip (defined, never set by the sender) |
| 6 | 4 | sessionId |
See §7 |
| 10 | 4 | symbolIndex |
0…K−1 systematic, ≥ K repair |
| 14 | 2 | K |
Number of source blocks |
| 16 | 2 | blockLen |
Bytes per symbol |
| 18 | 4 | fileLen |
Envelope length (trims padding on reassembly) |
| 22 | blockLen | Symbol payload | Output of LtEncoder.symbolAt(symbolIndex) |
| 22 + blockLen | 4 | CRC-32 | Over bytes 0 … 21 + blockLen |
Constants: qrFountainHeaderSize = 22, qrFountainCrcSize = 4, qrFountainOverhead = 26.
The 10-byte “sos” envelope is below 7 680 B, so Auto picks blockLen = 160 and K = ⌈10 / 160⌉ = 1.
Header (22 B):
41 50 43 46 03 00 FD 1C 1B 6E 00 00 00 00 00 01 00 A0 00 00 00 0A
└── APCF ──┘ v3 flags └─sessionId─┘ └symbolIndex┘ K=1 blk=160 fileLen=10
Payload (160 B): the envelope, then zero padding
41 50 43 4D 00 00 00 73 6F 73 00 00 00 …(150 × 00)
CRC-32 (4 B):
78 EE DD F5
Session ID FD1C1B6E = CRC32(envelope) ^ (160 × 0x9E3779B1) = 1FB015CE ^ E2AC0EA0, masked to 32 bits (see §7).
186 framed bytes fit QR version 8 at error-correction level L (capacity 192 bytes).
length ≥ 26 + blockLen.K < 1, blockLen < 1, the frame is too short, or the CRC doesn’t match.FQR3: + base64url(frame) exists for string-only decoders (the web preview sampler). The receiver also tries the raw code units and UTF-8 bytes of a text result.| blockLen | Framed bytes | QR version (EC-L) | Efficiency = blockLen / framed |
|---|---|---|---|
| 160 | 186 | 8 | 86.0% |
| 240 | 266 | 10 | 90.2% |
| 330 | 356 | 12 | 92.7% |
| 600 | 626 | 17 | 95.8% |
Source: lib/core/physical/acoustic/acoustic_fountain_frame.dart.
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 2 | symbolIndex |
u16 |
| 2 | 2 | K |
u16, must be ≥ 1 |
| 4 | 1 | blockLen |
u8; also confirms which profile the frame belongs to |
| 5 | 3 | fileLen |
u24 (up to 16 MiB) |
| 8 | 1 | sessionId |
u8: (millisecondsSinceEpoch ~/ 97) & 0xFF |
| 9 | L | Symbol payload | L = profile block length (32 / 48 / 64 audible, 24 Silent) |
| 9 + L | 2 | CRC-16/CCITT-FALSE | Over bytes 0 … 8 + L |
| 11 + L | P | Reed-Solomon parity | P = 20 or 24 audible, 16 Silent |
Constants: acousticHeaderSize = 9, acousticCrcSize = 2, acousticFrameOverhead = 11. Codeword length = 11 + L + P.
Standard uses L = 64 and P = 24. K = ⌈10 / 64⌉ = 1. The session ID below is 0x5C:
Header (9 B): 00 00 00 01 40 00 00 0A 5C
sym=0 K=1 L=64 fileLen=10 session
Payload (64 B): 41 50 43 4D 00 00 00 73 6F 73 00 … (54 × 00)
CRC-16 (2 B): 33 8A
RS parity (24 B):
0C 1F D7 D2 E4 3C 72 44 02 62 AF 01 0D F4 1F 8E E0 E4 F3 AC 60 94 9D B2
Error-correction behaviour of this exact codeword, measured with the real decoder:
| Damage | Result |
|---|---|
| 12 bytes corrupted (the maximum for P = 24 with no hints) | Repaired |
| 13 bytes corrupted, no reliability information | Rejected |
| The same 13 bytes, with the demodulator marking them as least reliable | Repaired by GMD erasure decoding |
Silent and Silent Robust use L = 24 and P = 16, so the same envelope still fits one frame:
Header (9 B): 00 00 00 01 18 00 00 0A 5C
sym=0 K=1 L=24 fileLen=10 session
Payload (24 B): 41 50 43 4D 00 00 00 73 6F 73 00 … (14 × 00)
CRC-16 (2 B): A6 FA
RS parity (16 B):
3D 8D FB 96 86 E0 22 BF 17 A0 36 F2 E2 1E 43 89
With the real decoder, 8 corrupted bytes are repaired, 9 are rejected, and the same 9 are repaired when the demodulator marks them as least reliable.
| Profile | L | P | Codeword | Bytes per symbol (G/2) | Data symbols ⌈codeword / (G/2)⌉ |
|---|---|---|---|---|---|
| Rugged | 32 | 20 | 63 | 3 | 21 |
| Safe | 48 | 24 | 83 | 3 | 28 |
| Standard | 64 | 24 | 99 | 4 | 25 |
| Fast | 64 | 24 | 99 | 4 | 25 |
| Silent / Silent Robust | 24 | 16 | 51 | 0.5 | 102 |
On air, each frame is a 2-frame sync marker (2 048 samples) followed by the data symbols. Audible markers play both sync tones together; Silent markers play bin 424 for the first 1 024 samples and bin 427 for the second. See Sound Channel.
Group g of symbol s carries nibble number s·G + g. Even nibbles are the high half of a byte and odd nibbles the low half. For Standard (G = 8), the first symbol carries bytes 0–3:
byte0 = 00 → g0 = 0 (bin 40), g1 = 0 (bin 56)
byte1 = 00 → g2 = 0 (bin 72), g3 = 0 (bin 88)
byte2 = 00 → g4 = 0 (bin 104), g5 = 0 (bin 120)
byte3 = 01 → g6 = 0 (bin 136), g7 = 1 (bin 153)
The tone bin is 40 + 16·g + value and its frequency is bin × 43.066 Hz. For example, bin 153 is 6 589.1 Hz.
Silent profiles have one group, so each symbol carries one nibble, high half first: byte 0x41 becomes tone value 4 (bin 431 + 2·4 = 439, 18 906 Hz), then value 1 (bin 433, 18 648 Hz). Silent tone bins are 431 + 2·value.
Source: lib/core/protocol/packet_codec.dart.
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | Protocol version (= 1) |
| 1 | 4 | sessionId |
| 5 | 4 | transferId |
| 9 | 1 | packetType |
| 10 | 1 | channelId: optical 1, acoustic 2, vibration 3 |
| 11 | 4 | sequenceNumber (data starts at 1) |
| 15 | 2 | payloadLength |
| 17 | 2 | totalPackets (0 = unknown; clamped to 65 535) |
| 19 | 5 | Reserved (zero) |
| 24 | N | Payload |
| 24 + N | 4 | CRC-32 over bytes 0 … 23 + N |
| Value | Type | Value | Type |
|---|---|---|---|
| 0 | discovery | 8 | retransmissionRequest |
| 1 | discoveryResponse | 9 | channelSwitchRequest |
| 2 | channelTest | 10 | channelSwitchAck |
| 3 | channelTestResponse | 11 | heartbeat |
| 4 | negotiation | 12 | transferStatus |
| 5 | data | 13 | transferComplete |
| 6 | ack | 14 | error |
| 7 | nack |
Header fields: session 0x11223344, transfer 0xAABBCCDD, type data, channel vibration, sequence 1, payload 3, total 1.
01 44 33 22 11 DD CC BB AA 05 03 01 00 00 00 03 00 01 00 00 00 00 00 00 73 6F 73 84 1B F4 4B
ver └─session──┘ └─transfer─┘ data vib └──seq=1──┘ len=3 tot=1 └─reserved──┘ "sos" └─CRC-32─┘
Notice the little-endian order: 0x11223344 is sent as 44 33 22 11.
| Channel | Payload bytes per packet | Source |
|---|---|---|
| Optical | 1 400 | hardwareOpticalPacketSize |
| Acoustic | 512 | hardwareAcousticPacketSize |
| Vibration | 48 | hardwareVibrationPacketSize |
| All (simulation) | 256 | simulation TransmissionConfig |
The discovery payload is the two bytes DC 01 (hardwareDiscoveryPayload).
| Checksum | Polynomial | Init | Reflect | Final XOR | Check value "123456789" |
Used by |
|---|---|---|---|---|---|---|
| CRC-32 (IEEE 802.3) | 0x04C11DB7 (reflected table 0xEDB88320) |
0xFFFFFFFF |
yes | 0xFFFFFFFF |
CBF43926 |
APCF frames, packets, envelope de-duplication, Light session IDs |
| CRC-16/CCITT-FALSE | 0x1021 |
0xFFFF |
no | none | 29B1 |
Acoustic frames |
Both check values were computed with the project’s computeCrc32 and crc16 and match the published standard values. That confirms the implementations are standard.
Why two different CRCs? A Light frame is 186–626 bytes, and the 4-byte CRC-32 is negligible there. A Sound frame carries only 32–64 payload bytes at tens of bytes per second, so 2 bytes matter. Reed-Solomon already rejects most damaged frames, which leaves the CRC-16 to catch rare miscorrections.
| Channel | Width | Derivation | Consequence |
|---|---|---|---|
| Light | 32 bit | (CRC32(envelope) ^ (blockLen × 0x9E3779B1)) & 0xFFFFFFFF, with 0 mapped to 1 |
Deterministic: the same file at the same density always gets the same session, so Resume and re-sends continue the same fountain |
| Sound | 8 bit | (millisecondsSinceEpoch ~/ 97) & 0xFF |
Enough to tell overlapping transmissions apart; a new session per send |
| Packets | 32 bit + 32 bit | Random().nextInt(0xFFFFFFFF) for session and transfer |
Distinguishes transfers for reassembly and de-duplication |
0x9E3779B1 is the 32-bit golden-ratio constant, the classic multiplicative-hash constant. Multiplying the block length by it spreads the densities across the ID space, so the same file at 160 B and at 330 B gets unrelated sessions.
These formats are kept for reuse and tests (see Legacy Modems).
| Format | Layout |
|---|---|
| APCS1 text QR | APCS1:<id>:<i>/<n>:<base64url chunk>, chunks of ≤ 1 400 bytes, shown sequentially at 220 ms per frame |
| CSK light | Preamble 00 55 AA FF, then a little-endian u16 length, then data; each byte is shown as a 2×2 mosaic of cells, red 00 / green 01 / blue 10 / white 11, most significant dibit first |
| Two-tone FSK | Preamble bits 10101010, then packet bytes MSB-first; 1 800 Hz = 0, 3 200 Hz = 1, 18 ms per bit |
| Vibration PWK | Preamble bits 0 1 0 1 0 1 1 0, then packet bytes MSB-first; 80 ms pulse = 0, 180 ms pulse = 1 |
| Message | Envelope | Light (Auto) | Sound (Standard) | Sound (Silent) |
|---|---|---|---|---|
| “sos” | 10 B | 1 frame × 186 B | 1 block; ≈4 frames × 99 B expected | 1 block; ≈4 frames × 51 B expected |
| 100-character text | 107 B | 1 frame × 186 B | 2 blocks; ≈5 frames expected | 5 blocks; ≈9 frames expected |
| 5 KB photo | ≈5.1 KB | 33 frames × 186 B | not recommended | not recommended |
| 78 KB video | ≈80 KB | 243 frames × 356 B (+ ≈2 extra) | not supported (over 8 KiB) | not supported |
“Expected” frames for Sound follow the sender’s progress target ⌈1.25·K⌉ + 2. For Light, the receiver needs K plus a couple of repair symbols (see Fountain Code).