QRTP — QR Transport Protocol

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.

Design rationale

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:

Constants

NameValueNotes
Frame marker0x71 0x00ASCII "q\0"
Large packet368 bytesQR level ≥ 13 with low ECC (Level 17 with high ECC)
Small packet40 bytesQR level ≤ 12
Wirehair header4 bytesu32 block_id prefix
Wirehair block payload364 bytes368 − 4 (with large packets)
Wirehair max blocks64,000Per fountain
Last-descriptor flag0x80Set on stream_type of the last descriptor in a full frame
Max frame buffer2,953 bytesMax binary payload per QR (Level 40, low ECC)

Frame formats

Full frame

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 ---

Squeezed frame

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

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.

Descriptor (8 bytes)

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.

Packet size selection

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.

Packets

The total length of a packet is the stream's packet_size.

// 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

Wirehair FEC

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 descriptor

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

Implementation

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.

Parse frame header

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 }
    }
    // ...
}

Read stream descriptors (full frame)

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 }
}

Process packet payloads

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
    }
}

Create frames (sender side)

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