What is the QRTP protocol?

Direct answer

QRTP, the QR Transport Protocol, is the framing and coding layer QR Send uses to move bytes over a one-way stream of QR codes. It splits each source message into Wirehair fountain-coded packets, packs one or more streams into every frame, and lets a receiver rebuild the original from any sufficiently large subset of packets in any order.

Key takeaways

Why the protocol assumes a one-way channel

A camera pointing at a screen cannot answer back. There is no return path and no way to request a missing byte, so acknowledgement-and-retransmission is off the table — which is why a QR code file transfer works where no other channel reaches.

The answer is a fountain code. Wirehair turns a fixed source message into an effectively unbounded sequence of coded blocks, each a combination of source blocks; the receiver collects them until it has enough independent ones and decodes, a process detailed in fountain codes for QR streams. Packet order does not matter, a lost frame costs time rather than correctness, and a receiver that starts scanning late still finishes.

Two frame layouts

Every frame opens with the two-byte marker q\0, so a scanner can tell QRTP content from other QR payloads. Numbers are little-endian.

Full frames

A full frame carries several streams, each with its own descriptor and packet size:

stream_type: u8 | stream_id: u8 | packet_count: u8 | packet_size_words: u8 | source_total_bytes: u32 [| segment_id: u32]

The high bit of stream_type marks the last descriptor. Setting the next bit down (0x40) marks the stream segmented and appends a 4-byte segment_id, letting one stream_id carry a sequence of source messages — the mechanism behind live media, where each MediaRecorder fragment is a new segment and the receiver resets its decoder when the id changes.

Squeezed frames

A single-file transfer with no metadata stream is the common case, and there the descriptors are dead weight:

qrtp: u16 | transfer_id: u16 | source_total_bytes: u32 | packets...

The receiver infers the rest: stream_type = 0, stream_id = 0, one stream, and a packet size taken from the frame — 368 bytes when at least 368 remain after the 8-byte header, otherwise 40. With no descriptor left to carry a flag, the layout marker moves into the top bit of transfer_id; the low 15 bits still identify the transfer, so it stays trackable across both layouts.

Wire termWidthWhere it livesWhat it carries
transfer_id16 bits (low 15 used; high bit = squeezed)First 2 bytes of every frameWhich transfer the frame belongs to
stream_type8 bits (high bit = last descriptor, 0x40 = segmented)Full-frame descriptor0 for data streams, 1 for the network metadata stream
stream_id8 bits; implicitly 0 when squeezedFull-frame descriptorWhich stream a packet belongs to
segment_id32 bits, optionalFull-frame descriptor when segmentedWhich source message of a segmented stream a packet belongs to
packet_sizepacket_size_words × 8, or derived from frame size when squeezedDescriptor or frameBytes per packet: 368 or 40 in normal use
packet_count8 bitsFull-frame descriptorHow many packets of this stream follow in this frame

What a packet contains

Streams pick a mode automatically. If packet_size is at least source_total_bytes, the stream uses direct mode: every packet carries the whole source, zero-padded to fill the packet, and one packet recovers everything — what a tiny metadata JSON uses. Otherwise the stream uses Wirehair mode, where each packet is a 4-byte block_id followed by packet_size − 4 bytes of coded payload; the block id identifies the linear combination, which is what makes out-of-order arrival harmless.

block_id also selects the fountain. One fountain tops out at 64,000 blocks, so a source larger than 64,000 × (packet_size − 4) bytes is split across several; the receiver derives the count from the header, reads block_id % fountain_count as the fountain index and block_id / fountain_count as the block within it, and must cope with interleaved fountains. Wirehair packets have a 12-byte minimum, which is why the 40-byte packet still leaves 36 bytes of payload.

full frame (transfer_id & 0x8000 == 0)
 0      2      4              10/16        ...
 +------+------+--------------+--------------+--------------------------+
 | qrtp | xfer | stream #1    | stream #2    | packets, in stream order |
 | u16  | u16  | 6 or 12 B    | 6 or 12 B    | #1 first, then #2        |
 +------+------+--------------+--------------+--------------------------+
   "q\0"    low 15 bits    type|sid|cnt|size|total[|segment_id]

squeezed frame (transfer_id & 0x8000 == 1)
 0      2      4              8
 +------+------+--------------+-----------------------------------------+
 | qrtp | xfer | source_total | packets, packet_size = 368, or 40 when  |
 | u16  | u16  | u32          | fewer than 368 bytes remain             |
 +------+------+--------------+-----------------------------------------+

packet, Wirehair mode (packet_size bytes in total)
 0              4                                  packet_size
 +--------------+----------------------------------------------------+
 | block_id u32 | coded payload, packet_size - 4 bytes               |
 +--------------+----------------------------------------------------+

Two streams share a frame

Streams are fountain-coded independently, and a QR Send transfer normally carries two:

That side stream is why QRTP can upgrade transparently: the optical link hands over the rendezvous information, the ends negotiate a WebRTC data channel, and the bulk bytes move over the network if one exists. If it does not, nothing is lost — a receiver that ignores the metadata stream still recovers the file optically. Both streams present means a full frame with two descriptors; with the network path off, the sender emits squeezed frames and reclaims the descriptor bytes for payload.

The terminal sender emits the same framing:

./qrsend --fps 15 --cols 2 --rows 2 --packets-per-qr 4 firmware.bin
./qrsend --fps 30 --cols 1 --rows 1 --invert --stream-id 0 - < update.tar.gz

Sizing the link

Four 368-byte packets per frame is 1,472 bytes of coded data; at 15 frames per second that is roughly 22 KB/s before any loss, and useful yield is lower, since a fountain needs slightly more blocks than the source has and not every frame is captured cleanly. Frame rate, packets per frame and code size are exposed as knobs precisely because they trade throughput against denser symbols. Every field above is specified in the QRTP protocol reference.

FAQ

What does QRTP stand for?

QR Transport Protocol. It is the framing and coding layer behind QR Send: a marker, a 16-bit transfer id, optional stream descriptors and fountain-coded packets inside ordinary QR symbols.

Does QRTP need a network connection?

No. The optical link is one-way and needs no back-channel. A network is used only when the metadata stream advertises a WebRTC rendezvous point and both ends accept the upgrade.

Why are there two packet sizes, 368 bytes and 40 bytes?

Packet size tracks how much data fits in a scannable symbol. Large symbols carry 368-byte packets; when a frame has less room, QRTP falls back to 40-byte packets instead of failing. In Wirehair mode 4 bytes go to the block_id, leaving 364 or 36 bytes of payload.

What happens if the camera misses half the frames?

The transfer still completes, just more slowly. Each received packet is a fresh combination rather than one specific missing piece, so lost frames are replaced by later ones; the receiver needs a window somewhat larger than the source, not every packet.

Can one frame carry more than one file?

Indirectly. Multi-file and folder transfers are packed into one archive first and sent as a single transfer stream, so a batch behaves like one file on the wire. Distinct streams in a frame serve the data and network-metadata roles, not one file each.

Related reading

References