Reference

Wire format

Normative description of what a Porter QR frame contains.

This is the one document two independent implementations must agree on.

ImplementationFiles
Rust senderrust-sender/src/chunker.rs, src/fountain.rs
Flutter receiverflutter/lib/services/chunk_parser.dart, fountain_codec.dart

If this document and the code disagree, the code is wrong — but fix both.

Frame types

Every frame is a single QR code whose payload is a UTF-8 string with |-separated fields. Three shapes exist.

Sequential data frame

index|total|mode|id|payload
  • index — 1-based frame number.
  • total — total data frames, excluding the CHECKSUM frame.
  • modeT plain text, B base64 binary, C gzip+base64.
  • id — 2-character transfer id.
  • payload — the chunk body. May itself contain |, so parsers must split on the first four separators only.

Fountain data frame

F|seq|K|fileSize|id|payload
  • seq — 0-based symbol sequence number; determines (degree, indices).
  • K — number of source blocks.
  • fileSize — original length in bytes. The last source block is zero-padded and the receiver trims the assembled output to this.
  • id — 2-character transfer id.
  • payload — base64 of exactly blockSize bytes.

blockSize is not transmitted. The receiver infers it from the decoded payload length, since every symbol is exactly one block.

Checksum frame

CHECKSUM|T|id|sha256

Lowercase hex digest of the original file. Fountain always sends this frame; sequential sends it only with --verify.

Transfer id

Two characters derived from the first two bytes of the file's SHA-256:

value = (digest[0] << 8) | digest[1]
id    = ALPHABET[(value >> 6) & 0x3f] + ALPHABET[value & 0x3f]
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"
The id identifies content, not a session. The same file sent twice gets the same id — and re-sending at a different QR version yields the same id with a different K and blockSize, producing two mutually undecodable streams. A receiver must key a fountain transfer on (id, K, blockSize), never on id alone. Mixing them corrupts the output.

Symbol derivation

Sender and receiver independently derive each symbol's source-block indices from seq. This must stay bit-identical; any drift silently breaks decoding.

PRNG — xorshift32

state = seq XOR 0x9e3779b9      // if 0, use 0x9e3779b9
next():
  state ^= state << 13
  state ^= state >> 17
  state ^= state << 5
  return state                   // unsigned 32-bit, wrapping

Dart must mask to 32 bits explicitly; Rust's u32 wraps natively.

Degree table

Integer weights only, so the same table and the same draw-by-modulo produce identical degrees everywhere.

if K <= 2:  every symbol has degree 1

weights[1] = 1
weights[i] = floor(K / (i * (i - 1)))     for i = 2..K

S = max(2, floor(sqrt(K)))
weights[i] += max(1, floor(S / i))        for i = 1..S-1
weights[S] += S

cum_weights[i] = sum(weights[1..i])
total          = cum_weights[K]

Two things that look like bugs and are not:

  • weights[i] is not floored to 1. It reaches 0 once i*(i-1) > K (around i > sqrt(K)), which is what caps the maximum degree near sqrt(K). Flooring to 1 would give every high degree equal weight and make large-K transfers effectively undecodable.
  • i * (i - 1) must be computed in 64-bit. It overflows u32 once i > ~65536, which a large file reaches. This was a real bug: it wrapped to a bogus divisor and corrupted the distribution.

Drawing indices

r      = next() mod total
degree = smallest d >= 1 with cum_weights[d] > r
indices = {}
while |indices| < degree and |indices| < K:
    indices.add((next() mod K) + 1)      // 1-based
return sorted(indices)

The symbol's effective degree is |indices|, which can be less than degree if the PRNG repeats an index.

Symbol value

XOR of the source blocks at indices, each zero-padded to blockSize.

Redundancy

The sender emits N = max(K + 20, ceil(K * 3)) symbols, then the checksum frame. The 3× factor is empirical: enough for full peeling recovery from the complete pool, with margin for scan loss.

A receiver needs materially more than K distinct symbols before peeling completes — measured 1.33×–1.89× K across K=50..70,965. Progress UI should scale against ~2×K, or it reads as ~99% complete with a third of the scanning left.

Sizing

Not part of the wire format — a receiver never needs it — but it explains why K and blockSize change between runs of the same file.

version   = clamp(((rows - buffer) * 2 - 17 - 4) / 4, 1, 40)
capacity  = byte-mode capacity for (version, ecc)
blockSize = floor((capacity - headerReserve) * 0.75)     // fountain
K         = ceil(fileSize / blockSize)

Because version depends on terminal height, resizing mid-transfer changes K and blockSize and forks the stream. Senders recompute layout on resize; receivers should detect the change rather than merge the two.

The Rust sender computes the header reserve exactly and forces a single Byte-mode QR segment, because the crate's "optimal" segmentation can charge more bits than the byte-mode capacity tables assume — a payload under the table limit could still be rejected.

Copyright © 2026