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
- QRTP is unidirectional: the receiver cannot acknowledge, so reliability comes from fountain coding rather than retransmission.
- Full frames carry explicit stream descriptors; squeezed frames omit them because a single-stream transfer lets the receiver infer
stream_id = 0. - The
transfer_idis 15 bits wide; the high bit of the same 16-bit field flags a squeezed frame. - Packets are 368 bytes in large frames and 40 bytes in small ones; Wirehair mode spends 4 bytes on a
block_id, leavingpacket_size − 4bytes of coded payload. - A source that fits in one packet skips fountain coding: direct mode sends it verbatim, zero-padded to
packet_size. - Streams in one frame may use different packet sizes, but a stream must not change size mid-transfer.
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 term | Width | Where it lives | What it carries |
|---|---|---|---|
transfer_id | 16 bits (low 15 used; high bit = squeezed) | First 2 bytes of every frame | Which transfer the frame belongs to |
stream_type | 8 bits (high bit = last descriptor, 0x40 = segmented) | Full-frame descriptor | 0 for data streams, 1 for the network metadata stream |
stream_id | 8 bits; implicitly 0 when squeezed | Full-frame descriptor | Which stream a packet belongs to |
segment_id | 32 bits, optional | Full-frame descriptor when segmented | Which source message of a segmented stream a packet belongs to |
packet_size | packet_size_words × 8, or derived from frame size when squeezed | Descriptor or frame | Bytes per packet: 368 or 40 in normal use |
packet_count | 8 bits | Full-frame descriptor | How 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:
- Data stream —
stream_type = 0,stream_id = 0. Carries the transfer payload (metadata plus file bytes) in 368-byte packets, taking the remaining frame budget; for a single-file transfer this is the whole transfer. - Network metadata stream —
stream_type = 1,stream_id = 1. Carries a small JSON document describing how to reach the sender over a second, bidirectional channel, such as a PeerJS peer id and secret, on every frame with a 16-byte packet — well under 1% of the frame budget, yet recoverable within a few frames.
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
- What is QR Send? — QR Send moves files and live media between devices by streaming fountain-coded QR codes, so the transfer works with no network at all.
- How a stream of QR codes carries a whole file — A QR code holds at most a few kilobytes. Here is how thousands of them carry an arbitrary file, and why lost frames do not matter.
- Fountain codes in a stream of QR codes — Why QRTP uses Wirehair instead of retransmission, and what that buys when frames are blurred, dropped or read out of order.