QRTP is a stream-oriented codec to send data over lossy one-way channels such as QR code streams. It is designed to be compact and efficient to parse, while supporting large file transfers with minimal overhead. All numbers are encoded as little-endian.
QR code transfers are inherently unidirectional — the receiver has no back-channel to request retransmission. To work around this, QRTP uses fountain codes (Wirehair) that let the receiver recover the original data from any sufficiently large subset of packets, regardless of order or which specific packets were lost.
QR codes have severely limited capacity: a Level 40 QR code with low ECC carries at most 2,953 bytes of binary data, and Level 3 carries only 53 bytes. This forces a trade-off between header overhead and packet payload. QRTP provides two frame formats to optimize for different scenarios:
| Name | Value | Notes |
|---|---|---|
| Frame marker | 0x71 0x00 | ASCII "q\0" |
| Large packet | 368 bytes | QR level ≥ 13 with low ECC (Level 17 with high ECC) |
| Small packet | 40 bytes | QR level ≤ 12 |
| Wirehair header | 4 bytes | u32 block_id prefix |
| Wirehair block payload | 364 bytes | 368 − 4 (with large packets) |
| Wirehair max blocks | 64,000 | Per fountain |
| Last-descriptor flag | 0x80 | Set on stream_type of the last descriptor in a full frame |
| Max frame buffer | 2,953 bytes | Max binary payload per QR (Level 40, low ECC) |
The full frame format is the most general. It can carry multiple streams with different packet sizes in a single QR code. The transfer_id distinguishes different transfers; only the bottom 15 bits are used (transfer_id & 0x7fff). The high bit is zero for full frames, differentiating them from squeezed frames.
Stream descriptors start immediately after the transfer_id — there is no descriptor count byte. The last descriptor has the 0x80 flag set on its stream_type byte so the parser knows when to stop reading descriptors.
0x00 71 00 // "q\0" marker (u16 LE) 0x02 xx xx // transfer_id & 0x7fff (u16 LE, high bit = 0) 0x04 // --- stream descriptors (8 bytes each, start here) --- // --- packets, in stream order ---
The squeezed format reduces header overhead for the common case of a single transfer stream. It is identified by transfer_id & 0x8000 != 0. Since there is no explicit stream descriptor, the receiver infers parameters: stream_id = 0.
Squeezed format packet_size depends on the total frame size. If frame_size − 8 ≥ 368, packet_size = 368. Otherwise, packet_size = 40. In QR code terms, large packet size is used from level 13 and up with low ECC (L17 with high ECC), and small packet size for levels below that.
QR codes smaller than 48 bytes are not supported by the current QRTP implementation, though there is a minimal frame spec in progress.
0x00 71 00 // "q\0" marker (u16 LE) 0x02 xx xx // transfer_id | 0x8000 (u16 LE) 0x04 sS sS sS sS // source_total_bytes (u32 LE) 0x08 // --- packets, packet_size from frame size ---
Stream descriptors appear only in full frames, starting immediately after the transfer_id. Each descriptor is 8 bytes. The 0x80 flag on stream_type marks the last descriptor in the frame.
0x00 tt // stream_type (u8). 0x80 = last descriptor flag 0x01 si // stream_id (u8) 0x02 pc // packet_count (u8) 0x03 pw // packet_size_words (u8, packet_size = pw * 8) 0x04 sS sS sS sS // source_total_bytes (u32 LE)
Stream type (bits 0–6) is used to differentiate between different kinds of streams, such as data transfer streams and network metadata streams. The high bit (0x80) is the last-descriptor flag.
Stream ID is a small integer to differentiate between multiple streams in the same transfer. Stream IDs and types should be consistent across frames in the same transfer.
Packet size in bytes is calculated as packet_size_words × 8. The packet size is the same for all packets in a stream, but different streams in the same frame can have different packet sizes.
Important constraint: A transfer must not mix squeezed frames where the packet size is below 368 bytes with full frames or squeezed frames using a different packet size. Because squeezed frames lack an explicit descriptor, the receiver infers the packet size from the frame dimensions. If the packet size changes mid-transfer, the Wirehair decoder is reset because the block payload size changed, preventing the transfer from progressing. The sender must use a consistent frame format throughout a single transfer stream.
For transfers that fit entirely in a single packet (packet_size > source_total_bytes), the data is fully resilient: each QR frame carries the complete source verbatim. The packet transport is reliable on an all-or-nothing basis — if the QR code is scanned successfully, the entire file is received.
The total length of a packet is the stream's packet_size.
packet_size ≥ source_total_bytes): The entire source is placed verbatim in the packet, zero-padded to fill the packet. No FEC, no block_id — fully resilient since the QR transport is all-or-nothing.packet_size < source_total_bytes): Each packet is a Wirehair-encoded block prefixed by a 4-byte block_id. The minimum wirehair packet size is 12 bytes. The receiver collects unique blocks until it can reconstruct the source.// Wirehair: block_id | payload 0x00 bb bb bb bb // block_id (u32 LE) 0x04 pp pp pp … // wirehair payload (packet_size - 4 bytes) // Direct: source | zero-pad 0x00 dd dd dd … // source bytes verbatim 00 00 00 … // zero-padding to fill packet_size
QRTP uses Wirehair, a fast fountain code that can recover the original message from a small overhead of received blocks. The Wirehair block payload size is packet_size − 4 bytes (the 4 bytes being the block_id).
When the source data exceeds the Wirehair fountain limit of 64,000 blocks per encoder, the source is split into multiple fountains. In that case, the block_id is interpreted as a fountain-aware block id, where the fountain id is block_id % fountain_count and the fountain block id is block_id / fountain_count. Packets from different fountains are interleaved in the stream.
The total number of fountains and the size of each fountain can be calculated from the stream header fields:
max_fountain_size_bytes = payload_bytes * 64000 fountain_count = ceil(source_total_bytes / max_fountain_size_bytes) is_last_fountain = source_id == fountain_count - 1 if is_last_fountain: fountain_size_bytes = source_total_bytes % max_fountain_size_bytes else: fountain_size_bytes = max_fountain_size_bytes
You should not mix Wirehair packet sizes of different sizes in the same stream, as each is a separate fountain. If the QR code size is changed during a transfer, it effectively restarts the transfer with a new fountain, and the receiver should be prepared to handle that.
Transfer metadata — filename, size, and other descriptor fields — is encoded as a length-prefixed JSON blob prepended to the raw payload:
0x00 ll ll ll ll // metadata_length (u32 LE) 0x04 7b 22 6e 61 … // JSON: {"name":"hello.txt","size":12345} ff d8 ff e0 … // raw payload bytes
The core protocol operation is parsing a QRTP frame: identify the frame format, extract stream descriptors, and enumerate packets. The snippets below are protocol-oriented reference implementations that follow PROTOCOL.md.
Reads the first few bytes of a frame to determine format (squeezed or full) and extract routing information: logical transfer ID, source message bytes, and inferred packet sizes.
/// Parse a QRTP frame, returning structured descriptors and packets.
pub fn parse_qrtp_frame(frame: &[u8]) -> Result<ParsedQrtpFrame, String> {
if frame.len() < 4 || frame[0..2] != QRTP_STREAM_HEADER {
return Err("missing QRTP frame header");
}
let transfer_word = u16::from_le_bytes(
frame[2..4].try_into().unwrap());
let transfer_id = transfer_word & 0x7fff;
// Squeezed frame: high bit set on transfer word
if (transfer_word & 0x8000) != 0 {
let source_bytes = u32::from_le_bytes(
frame[4..8].try_into().unwrap());
let payload_len = frame.len() - 8;
let pkt_size = if payload_len >= LARGE_PACKET {
if payload_len % LARGE_PACKET != 0 {
return Err("squeezed payload not a multiple of 368");
}
LARGE_PACKET
} else {
if payload_len % SMALL_PACKET != 0 {
return Err("squeezed payload not a multiple of 40");
}
SMALL_PACKET
};
let count = (payload_len / pkt_size) as u8;
// ... read count packets of pkt_size bytes ...
}
// Full frame: descriptors start at offset 4, no count byte.
// Iterate until the last-descriptor flag (0x80) is seen.
let mut offset = 4;
loop {
let stream_type_byte = frame[offset];
let last = (stream_type_byte & 0x80) != 0;
let stream_type = stream_type_byte & 0x7f;
let stream_id = frame[offset + 1];
let pkt_count = frame[offset + 2];
let pkt_words = frame[offset + 3];
let src_bytes = u32::from_le_bytes(
frame[offset+4..offset+8].try_into().unwrap());
// ... record descriptor ...
offset += 8;
if last { break; }
}
// ... read packets in stream order ...
}
export function parseQrtpFrameHeader(data) {
const pkt = new Uint8Array(data);
if (pkt.length < 4 || pkt[0] !== 0x71 || pkt[1] !== 0) return null;
const tw = pkt[2] | (pkt[3] << 8);
// Squeezed frame
if (tw & 0x8000) {
const src = (pkt[4] | (pkt[5] << 8) |
(pkt[6] << 16) | (pkt[7] << 24)) >>> 0;
const payload = pkt.length - 8;
const pktSz = payload >= 368
? (payload % 368 ? 0 : 368)
: (payload % 40 ? 0 : 40);
if (!pktSz) return null;
return {
transferId: tw & 0x7fff,
squeezed: true,
sourceMessageBytes: src,
packetSizeBytes: pktSz,
};
}
// Full frame: descriptors start at offset 4, read until
// last-descriptor flag (0x80) on stream_type.
let off = 4;
while (off < pkt.length) {
const last = !!(pkt[off] & 0x80);
const streamType = pkt[off] & 0x7f;
const pw = pkt[off + 3];
const srcBytes = (pkt[off+4] | (pkt[off+5] << 8) |
(pkt[off+6] << 16) | (pkt[off+7] << 24)) >>> 0;
// ... record descriptor ...
off += 8;
if (last) break;
}
// ...
}
import struct
MARKER = b'q\x00'
LARGE_PACKET = 368
SMALL_PACKET = 40
def parse_qrtp_frame(data: bytes):
if len(data) < 4 or data[:2] != MARKER:
return None
tw = struct.unpack_from('<H', data, 2)[0]
# Squeezed frame
if tw & 0x8000:
src = struct.unpack_from('<I', data, 4)[0]
payload = len(data) - 8
if payload >= LARGE_PACKET:
if payload % LARGE_PACKET:
return None
pkt_size = LARGE_PACKET
else:
if payload % SMALL_PACKET:
return None
pkt_size = SMALL_PACKET
return {'transfer_id': tw & 0x7fff, 'squeezed': True,
'source_bytes': src, 'packet_size': pkt_size}
# Full frame: descriptors start at offset 4, iterate until
# the last-descriptor flag (0x80) is set on stream_type.
off = 4
while off < len(data):
last = bool(data[off] & 0x80)
st = data[off] & 0x7f
pw = data[off + 3]
src = struct.unpack_from('<I', data, off + 4)[0]
# ... record descriptor ...
off += 8
if last:
break
# ...
const LargePacket = 368
const SmallPacket = 40
func ParseQrtpFrame(data []byte) ([]Descriptor, uint16, error) {
if len(data) < 4 || data[0] != 'q' || data[1] != 0 {
return nil, 0, errors.New("missing QRTP header")
}
tw := binary.LittleEndian.Uint16(data[2:4])
// Squeezed frame
if tw&0x8000 != 0 {
src := binary.LittleEndian.Uint32(data[4:8])
payload := len(data) - 8
pktSize := SmallPacket
if payload >= LargePacket {
pktSize = LargePacket
}
if payload%pktSize != 0 {
return nil, 0, errors.New("invalid squeezed frame payload")
}
return []Descriptor{{PacketCount: uint8(payload/pktSize),
PacketSize: uint16(pktSize),
SourceBytes: src}}, tw&0x7fff, nil
}
// Full frame: descriptors start at offset 4, iterate until
// last-descriptor flag (0x80) on stream_type.
off := 4
for off < len(data) {
last := data[off]&0x80 != 0
st := data[off] & 0x7f
pw := data[off+3]
src := binary.LittleEndian.Uint32(data[off+4:])
// ... record descriptor ...
off += 8
if last { break }
}
// ...
}
Descriptors start at offset 4 (right after the transfer_id). Iterate over them until the last-descriptor flag (0x80 on stream_type) is seen. Each descriptor is 8 bytes.
// Full frame descriptor iteration — no count byte.
let mut offset = 4;
loop {
let stream_type_byte = packet[offset];
let last = (stream_type_byte & 0x80) != 0;
let stream_type = stream_type_byte & 0x7f;
let pw = packet[offset + 3];
let src_bytes = u32::from_le_bytes(
packet[offset+4..offset+8].try_into().unwrap());
descriptors.push(QrtpStreamDescriptor {
stream_type,
stream_id: packet[offset + 1],
packet_count: packet[offset + 2],
packet_size_bytes: (pw as u16) * 8,
source_message_bytes: src_bytes,
});
offset += 8;
if last { break; }
}
# Full frame descriptor iteration — no count byte.
off = 4
while off < len(data):
st_byte = data[off]
last = bool(st_byte & 0x80)
stream_type = st_byte & 0x7f
pw = data[off + 3]
src_bytes = struct.unpack_from('<I', data, off + 4)[0]
descs.append({
'stream_type': stream_type, 'stream_id': data[off + 1],
'packet_count': data[off + 2], 'packet_size': pw * 8,
'source_bytes': src_bytes,
})
off += 8
if last: break
// Full frame descriptor iteration — no count byte.
off := 4
for off < len(data) {
last := data[off]&0x80 != 0
pw := data[off+3]
srcBytes := binary.LittleEndian.Uint32(data[off+4:])
descs = append(descs, Descriptor{
StreamType: data[off] & 0x7f,
StreamID: data[off+1],
PacketCount: data[off+2],
PacketSize: uint16(pw) * 8,
SourceBytes: srcBytes,
})
off += 8
if last { break }
}
Once descriptors are parsed, the packet payloads follow in stream order. Each packet is either direct or Wirehair-encoded. Wirehair is used whenever packet_size < source_total_bytes. The minimum Wirehair packet size is 12 bytes.
// Process packets after descriptors.
for entry in &mut descriptors {
let pkt_size = entry.descriptor.packet_size_bytes as usize;
let src_bytes = entry.descriptor.source_message_bytes as usize;
for _ in 0..entry.descriptor.packet_count {
let pkt = &packet[offset..offset + pkt_size];
let wirehair = pkt_size < src_bytes
&& pkt_size >= 12
&& entry.descriptor.stream_type == TRANSFER;
if wirehair {
let block_id = u32::from_le_bytes(
pkt[..4].try_into().unwrap());
// Feed pkt[4..] to Wirehair decoder
} else {
// Copy pkt verbatim as direct source data
}
offset += pkt_size;
}
}
for (const { descriptor, packets } of parsed.descriptors) {
const pktSize = descriptor.packetSizeBytes;
const srcBytes = descriptor.sourceMessageBytes;
for (const rec of packets) {
const wirehair = descriptor.streamType === 0
&& pktSize < srcBytes
&& pktSize >= 12;
if (wirehair) {
const blockId = new DataView(
rec.buffer, rec.byteOffset, rec.byteLength
).getUint32(0, true);
decoder.ingestWirehair(blockId, rec.slice(4));
} else {
decoder.ingestDirect(offset, rec);
offset += pktSize;
}
}
}
for desc in descriptors:
pkt_sz = desc['packet_size']
src_sz = desc['source_bytes']
for _ in range(desc['packet_count']):
pkt = data[offset : offset + pkt_sz]
wirehair = (desc['stream_type'] == 0
and pkt_sz < src_sz
and pkt_sz >= 12)
if wirehair:
block_id = struct.unpack_from('<I', pkt, 0)[0]
# feed pkt[4:] to wirehair decoder
else:
# copy pkt verbatim as direct source data
offset += pkt_sz
for _, desc := range descs {
pktSz := int(desc.PacketSize)
srcSz := int(desc.SourceBytes)
for i := 0; i < int(desc.PacketCount); i++ {
pkt := data[offset : offset+pktSz]
wirehair := desc.StreamType == 0 &&
pktSz < srcSz && pktSz >= 12
if wirehair {
blockID := binary.LittleEndian.Uint32(pkt[:4])
// feed pkt[4:] to Wirehair decoder
} else {
// copy pkt verbatim as direct source data
}
offset += pktSz
}
}
On the sender side, frames are assembled from stream descriptors and packet records. A conforming sender should choose the most compact valid format: squeezed for single-stream transfers with standard packet sizes, and full otherwise.
pub fn create_qrtp_frame(
frame_size: usize,
transfer_id: u16,
frame_flags: u8,
descriptors: &[QrtpDescriptorRecords],
) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
out.extend_from_slice(&QRTP_STREAM_HEADER); // "q\0"
// Try squeezed: single transfer stream, stream_id=0,
// packet_size == SMALL_PACKET or LARGE_PACKET.
let payload_space = frame_size.saturating_sub(8);
if descriptors.len() == 1 {
let d = &descriptors[0].descriptor;
if d.stream_type == TRANSFER
&& d.stream_id == 0
&& ((payload_space >= LARGE_PACKET
&& d.packet_size_bytes == LARGE_PACKET)
|| (payload_space < LARGE_PACKET
&& payload_space >= SMALL_PACKET
&& d.packet_size_bytes == SMALL_PACKET))
{
out.extend_from_slice(
&((transfer_id & 0x7fff) | 0x8000)
.to_le_bytes());
out.extend_from_slice(
&d.source_message_bytes.to_le_bytes());
for pkt in &descriptors[0].packets {
out.extend_from_slice(&pkt.packet_bytes);
}
return Ok(out);
}
}
// Full frame: descriptors start at offset 4, no count byte.
// Last descriptor has 0x80 flag on stream_type.
out.extend_from_slice(
&(transfer_id & 0x7fff).to_le_bytes());
for (i, entry) in descriptors.iter().enumerate() {
let last = i == descriptors.len() - 1;
let st_byte = entry.descriptor.stream_type
| if last { LAST_DESCRIPTOR_FLAG } else { 0 };
out.push(st_byte);
out.push(entry.descriptor.stream_id);
out.push(entry.descriptor.packet_count);
out.push((entry.descriptor.packet_size_bytes / 8) as u8);
out.extend_from_slice(
&entry.descriptor.source_message_bytes.to_le_bytes());
}
for entry in descriptors {
for pkt in &entry.packets {
out.extend_from_slice(&pkt.packet_bytes);
}
}
Ok(out)
}
def create_qrtp_frame(frame_size, transfer_id, descriptors):
buf = bytearray(b'q\x00')
# Try squeezed: single transfer stream, standard packet size.
payload_space = max(0, frame_size - 8)
if len(descriptors) == 1:
d = descriptors[0]['descriptor']
if (d['stream_type'] == 0 and d['stream_id'] == 0 and (
(payload_space >= LARGE_PACKET
and d['packet_size'] == LARGE_PACKET)
or (payload_space < LARGE_PACKET
and payload_space >= SMALL_PACKET
and d['packet_size'] == SMALL_PACKET))):
buf += struct.pack('<HI',
(transfer_id & 0x7fff) | 0x8000,
d['source_bytes'])
for pkt in descriptors[0]['packets']:
buf += pkt['packet_bytes']
return buf
# Full frame: descriptors at offset 4, no count.
buf += struct.pack('<H', transfer_id & 0x7fff)
for i, entry in enumerate(descriptors):
d = entry['descriptor']
st_byte = d['stream_type']
if i == len(descriptors) - 1:
st_byte |= 0x80
buf += struct.pack('<BBBB', st_byte,
d['stream_id'], d['packet_count'],
d['packet_size'] // 8)
buf += struct.pack('<I', d['source_bytes'])
for entry in descriptors:
for pkt in entry['packets']:
buf += pkt['packet_bytes']
return buf