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 payloadpacket_size − 4 bytes364 bytes with standard large packets
Wirehair max blocks64,000Per fountain
Last-stream flag0x80Set on stream_type of the last stream header 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 headers start immediately after the transfer_id. The last header has the 0x80 flag set on its stream_type byte so the parser knows when to stop reading headers.

0x00  71 00                              // "q\0" marker (u16 LE)
0x02  xx xx                              // transfer_id & 0x7fff (u16 LE, high bit = 0)
0x04  // --- stream headers (8 bytes each, start here) ---
  // --- packets, packed in stream order ---

Squeezed frame

The squeezed format reduces header overhead for the common case of a single transfer stream. It is identified by the transfer_id having its high bit set (frame[3] & 0x80 != 0, or transfer_id & 0x8000 != 0). Since there is no explicit stream header section, the receiver infers that 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.

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 headers

Stream headers appear only in full frames, starting immediately after the transfer_id. Each header block is exactly 8 bytes long. The 0x80 flag on stream_type marks the final stream header section.

Header Layout (8 bytes)

0x00  tt                                 // stream_type (u8). 0x80 = last stream 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) differentiates between kinds of streams, such as file data transfer streams or network metadata streams. The high bit (0x80) is used to indicate that this is the last stream header before packet data.

Stream ID is a small integer to differentiate multiple active streams in the same transfer. The type of a stream ID must remain constant across all frames within a transfer.

Packet size in bytes is calculated as packet_size_words × 8. The packet size remains uniform within a given stream, but distinct streams within the same frame can utilize different packet sizes.

Packet size selection

Important constraint: Encoders should avoid mixing Wirehair packet sizes within a single stream, as each size variant represents a separate fountain. If the packet size is changed mid-transfer, it restarts the stream.

Small payloads that fit entirely within a single packet (source_total_bytes <= packet_size) are sent in direct mode: each packet holds the source buffer verbatim, padded with trailing zero bytes to packet_size length.

Packets

The total length of an evaluated packet always matches the target stream's declared 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 & Fountains

QRTP employs Wirehair fountain codes to safely rebuild lost items from arbitrary subsets of blocks. The underlying block payload space equals packet_size − 4 bytes.

If the stream is longer than 64000 * payload_bytes, the stream is split into multiple fountains. In the multi-fountain mode, 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.

The total number of fountains and the size of each fountain in bytes can be calculated from the stream header fields as follows:

max_fountain_size_bytes = payload_bytes * 64000
fountain_count = ceil(source_total_bytes / max_fountain_size_bytes)
is_last_fountain = current_fountain_index == 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

Transfer descriptor

Transfer metadata — filename, absolute size, and context fields — is managed via a length-prefixed JSON string prefixed right before the payload binary stream:

0x00  ll ll ll ll       // metadata_length (u32 LE)
0x04  7b 22 6e ..       // JSON: {"name":"hello.txt","size":12345,"sha256",...,"enc":"gzip"}
      ff d8 ff ..       // raw payload bytes

If the optional "enc" field is present, it indicates the encoding applied to the payload. Currently only gzip encoding is supported. The optional "sha256" field is the SHA-256 hash of the raw payload bytes, used for integrity verification before decoding. The receiver can compute the SHA-256 hash of the raw payload and compare it against this value to confirm successful transfer. If the hashes do not match, the receiver should discard the payload and report a transfer failure.

Implementation

The core protocol operation is parsing a QRTP frame: identify the frame format, extract stream headers, and enumerate packets. The reference code blocks below provide complete, compliant implementation strategies across all supported environments.

Parse frame header

Reads the initial frame components to classify the envelope type (Squeezed vs Full) and collect sizing offsets.

/// Parsed Stream info context structure
pub struct StreamInfo {
    pub stream_type: u8,
    pub stream_id: u8,
    pub packet_count: u8,
    pub packet_size: usize,
    pub source_total_bytes: u32,
}

/// Parse a QRTP frame, returning structured stream info and the packet data start offset.
pub fn parse_qrtp_frame_header(frame: &[u8]) -> Result<(Vec<StreamInfo>, u16, usize), String> {
    if frame.len() < 4 || frame[0..2] != [b'q', 0x00] {
        return Err("Missing or invalid QRTP frame marker".to_string());
    }

    let transfer_word = u16::from_le_bytes(frame[2..4].try_into().unwrap());
    let transfer_id = transfer_word & 0x7fff;
    let is_squeezed = (transfer_word & 0x8000) != 0;

    if is_squeezed {
        if frame.len() < 8 {
            return Err("Squeezed frame missing source size field".to_string());
        }
        let source_total_bytes = u32::from_le_bytes(frame[4..8].try_into().unwrap());
        let payload_len = frame.len() - 8;
        let packet_size = if payload_len >= 368 { 368 } else { 40 };
        
        let packet_count = (payload_len / packet_size) as u8;
        let squeezed_stream = StreamInfo {
            stream_type: 0,
            stream_id: 0,
            packet_count,
            packet_size,
            source_total_bytes,
        };
        Ok((vec![squeezed_stream], transfer_id, 8))
    } else {
        let mut offset = 4;
        let mut streams = Vec::new();
        
        loop {
            if offset + 8 > frame.len() {
                return Err("Malformed frame: stream headers truncated".to_string());
            }
            let stream_type_raw = frame[offset];
            let stream_id = frame[offset + 1];
            let packet_count = frame[offset + 2];
            let packet_size_words = frame[offset + 3];
            let source_total_bytes = u32::from_le_bytes(frame[offset+4..offset+8].try_into().unwrap());
            
            let is_last = (stream_type_raw & 0x80) != 0;
            let stream_type = stream_type_raw & 0x7f;
            let packet_size = (packet_size_words as usize) * 8;
            
            streams.push(StreamInfo {
                stream_type,
                stream_id,
                packet_count,
                packet_size,
                source_total_bytes,
            });
            offset += 8;
            if is_last { break; }
        }
        Ok((streams, transfer_id, offset))
    }
}
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);
    const transferId = tw & 0x7fff;
    const isSqueezed = (tw & 0x8000) !== 0;

    if (isSqueezed) {
        if (pkt.length < 8) return null;
        const src = (pkt[4] | (pkt[5] << 8) | (pkt[6] << 16) | (pkt[7] << 24)) >>> 0;
        const payloadLen = pkt.length - 8;
        const pktSz = payloadLen >= 368 ? 368 : 40;
        const count = Math.floor(payloadLen / pktSz);
        
        return {
            transferId,
            squeezed: true,
            payloadOffset: 8,
            streams: [{
                streamType: 0,
                streamId: 0,
                packetCount: count,
                packetSizeBytes: pktSz,
                sourceTotalBytes: src
            }]
        };
    }

    let offset = 4;
    const streams = [];
    while (offset + 8 <= pkt.length) {
        const stRaw = pkt[offset];
        const streamId = pkt[offset + 1];
        const packetCount = pkt[offset + 2];
        const pw = pkt[offset + 3];
        const src = (pkt[offset+4] | (pkt[offset+5] << 8) | (pkt[offset+6] << 16) | (pkt[offset+7] << 24)) >>> 0;
        
        streams.push({
            streamType: stRaw & 0x7f,
            streamId,
            packetCount,
            packetSizeBytes: pw * 8,
            sourceTotalBytes: src
        });
        offset += 8;
        if ((stRaw & 0x80) !== 0) break;
    }
    return { transferId, squeezed: false, streams, payloadOffset: offset };
}
import struct

def parse_qrtp_frame_header(data: bytes):
    if len(data) < 4 or data[0:2] != b'q\x00':
        return None

    tw = struct.unpack_from('<H', data, 2)[0]
    transfer_id = tw & 0x7fff
    is_squeezed = bool(tw & 0x8000)

    if is_squeezed:
        if len(data) < 8:
            return None
        src = struct.unpack_from('<I', data, 4)[0]
        payload_len = len(data) - 8
        pkt_size = 368 if payload_len >= 368 else 40
        count = payload_len // pkt_size
        return {
            'transfer_id': transfer_id,
            'squeezed': True,
            'payload_offset': 8,
            'streams': [{
                'stream_type': 0, 'stream_id': 0, 'packet_count': count,
                'packet_size': pkt_size, 'source_total_bytes': src
            }]
        }

    offset = 4
    streams = []
    while offset + 8 <= len(data):
        st_raw, stream_id, packet_count, pw = struct.unpack_from('<BBBB', data, offset)
        src = struct.unpack_from('<I', data, offset + 4)[0]
        
        streams.append({
            'stream_type': st_raw & 0x7f,
            'stream_id': stream_id,
            'packet_count': packet_count,
            'packet_size': pw * 8,
            'source_total_bytes': src
        })
        offset += 8
        if st_raw & 0x80:
            break
            
    return {'transfer_id': transfer_id, 'squeezed': False, 'streams': streams, 'payload_offset': offset}
package qrtp

import (
	"encoding/binary"
	"errors"
)

type StreamInfo struct {
	StreamType       uint8
	StreamID         uint8
	PacketCount      uint8
	PacketSize       int
	SourceTotalBytes uint32
}

func ParseQrtpFrameHeader(data []byte) ([]StreamInfo, uint16, int, error) {
	if len(data) < 4 || data[0] != 'q' || data[1] != 0 {
		return nil, 0, 0, errors.New("invalid protocol header marker")
	}

	tw := binary.LittleEndian.Uint16(data[2:4])
	transferID := tw & 0x7fff
	isSqueezed := (tw & 0x8000) != 0

	if isSqueezed {
		if len(data) < 8 {
			return nil, 0, 0, errors.New("truncated squeezed metadata")
		}
		src := binary.LittleEndian.Uint32(data[4:8])
		payloadLen := len(data) - 8
		pktSize := 40
		if payloadLen >= 368 {
			pktSize = 368
		}
		count := payloadLen / pktSize
		return []StreamInfo{{
			StreamType:       0,
			StreamID:         0,
			PacketCount:      uint8(count),
			PacketSize:       pktSize,
			SourceTotalBytes: src,
		}}, transferID, 8, nil
	}

	offset := 4
	var streams []StreamInfo
	for offset+8 <= len(data) {
		stRaw := data[offset]
		src := binary.LittleEndian.Uint32(data[offset+4 : offset+8])
		
		streams = append(streams, StreamInfo{
			StreamType:       stRaw & 0x7f,
			StreamID:         data[offset+1],
			PacketCount:      data[offset+2],
			PacketSize:       int(data[offset+3]) * 8,
			SourceTotalBytes: src,
		})
		offset += 8
		if stRaw&0x80 != 0 {
			break
		}
	}
	return streams, transferID, offset, nil
}

Process packet payloads

Processes sequentially packed chunks based on target stream attributes, handling differences between Wirehair and Direct mode.

// Process packet buffers directly after resolving header state
// 'offset' begins exactly where parse_qrtp_frame_header finished
pub fn process_payload_packets(frame: &[u8], streams: &[StreamInfo], mut offset: usize) {
    for stream in streams {
        let pkt_size = stream.packet_size;
        let src_total = stream.source_total_bytes as usize;
        
        for _ in 0..stream.packet_count {
            if offset + pkt_size > frame.len() { break; }
            let pkt = &frame[offset..offset + pkt_size];
            
            // Wirehair mode condition: packet_size <= source_total_bytes
            let use_wirehair = pkt_size <= src_total;
            if use_wirehair && pkt_size >= 12 {
                let block_id = u32::from_le_bytes(pkt[..4].try_into().unwrap());
                let payload = &pkt[4..];
                // Pass components into Wirehair decoding routine...
            } else {
                // Direct mode verification: extract raw data verbatim
                let actual_data_len = std::cmp::min(pkt_size, src_total);
                let valid_data = &pkt[..actual_data_len];
                // Record the plaintext payload chunk directly...
            }
            offset += pkt_size;
        }
    }
}
export function processPayloadPackets(pkt, streams, startOffset) {
    let offset = startOffset;
    for (const stream of streams) {
        const pktSize = stream.packetSizeBytes;
        const srcTotal = stream.sourceTotalBytes;
        
        for (let i = 0; i < stream.packetCount; i++) {
            if (offset + pktSize > pkt.length) break;
            const packetBuffer = pkt.slice(offset, offset + pktSize);
            
            const useWirehair = pktSize <= srcTotal;
            if (useWirehair && pktSize >= 12) {
                const blockId = new DataView(packetBuffer.buffer, packetBuffer.byteOffset, packetBuffer.byteLength).getUint32(0, true);
                const payload = packetBuffer.slice(4);
                // processWirehairBlock(blockId, payload);
            } else {
                const validLen = Math.min(pktSize, srcTotal);
                const directData = packetBuffer.slice(0, validLen);
                // processDirectBlock(directData);
            }
            offset += pktSize;
        }
    }
}
def process_payload_packets(data: bytes, streams: list, start_offset: int):
    offset = start_offset
    for stream in streams:
        pkt_sz = stream['packet_size']
        src_total = stream['source_total_bytes']
        
        for _ in range(stream['packet_count']):
            if offset + pkt_sz > len(data):
                break
            pkt = data[offset : offset + pkt_sz]
            
            use_wirehair = pkt_sz <= src_total
            if use_wirehair and pkt_sz >= 12:
                block_id = struct.unpack_from('<I', pkt, 0)[0]
                payload = pkt[4:]
                # feed_wirehair_decoder(block_id, payload)
            else:
                valid_len = min(pkt_sz, src_total)
                direct_data = pkt[:valid_len]
                # process_direct_data(direct_data)
            offset += pkt_sz
func ProcessPayloadPackets(data []byte, streams []StreamInfo, startOffset int) {
	offset := startOffset
	for _, stream := range streams {
		pktSz := stream.PacketSize
		srcTotal := int(stream.SourceTotalBytes)
		
		for i := 0; i < int(stream.PacketCount); i++ {
			if offset+pktSz > len(data) {
				break
			}
			pkt := data[offset : offset+pktSz]
			
			useWirehair := pktSz <= srcTotal
			if useWirehair && pktSz >= 12 {
				blockID := binary.LittleEndian.Uint32(pkt[:4])
				payload := pkt[4:]
				// ingestWirehair(blockID, payload)
				_, _ = blockID, payload
			} else {
				validLen := pktSz
				if srcTotal < validLen {
					validLen = srcTotal
				}
				directData := pkt[:validLen]
				_ = directData
			}
			offset += pktSz
		}
	}
}

Create frames (sender side)

Generates standardized frame vectors, opting for Squeezed compression configurations where single-stream rules allow.

pub fn create_qrtp_frame(
    transfer_id: u16, 
    streams: &[StreamInfo], 
    packets_payloads: &[Vec<u8>]
) -> Vec<u8> {
    let mut out = Vec::new();
    out.extend_from_slice(b"q\x00");

    // Squeezed logic checks: single stream, stream_id == 0, packet size matches standard values
    if streams.len() == 1 && streams[0].stream_id == 0 && (streams[0].packet_size == 368 || streams[0].packet_size == 40) {
        out.extend_from_slice(&((transfer_id & 0x7fff) | 0x8000).to_le_bytes());
        out.extend_from_slice(&streams[0].source_total_bytes.to_le_bytes());
        for pkt in packets_payloads {
            out.extend_from_slice(pkt);
        }
        return out;
    }

    // Default Full Frame Execution fallback path
    out.extend_from_slice(&(transfer_id & 0x7fff).to_le_bytes());
    for (i, stream) in streams.iter().enumerate() {
        let is_last = i == streams.len() - 1;
        let stream_type_byte = if is_last { stream.stream_type | 0x80 } else { stream.stream_type };
        
        out.push(stream_type_byte);
        out.push(stream.stream_id);
        out.push(stream.packet_count);
        out.push((stream.packet_size / 8) as u8);
        out.extend_from_slice(&stream.source_total_bytes.to_le_bytes());
    }
    
    for pkt in packets_payloads {
        out.extend_from_slice(pkt);
    }
    out
}
export function createQrtpFrame(transferId, streams, packetsPayloads) {
    const buffers = [];
    
    // Check squeezed criteria optimization
    if (streams.len === 1 && streams[0].streamId === 0 && (streams[0].packetSizeBytes === 368 || streams[0].packetSizeBytes === 40)) {
        const markerAndId = new Uint8Array(4);
        markerAndId[0] = 0x71; markerAndId[1] = 0x00;
        const tw = (transferId & 0x7fff) | 0x8000;
        markerAndId[2] = tw & 0xff; markerAndId[3] = (tw >> 8) & 0xff;
        buffers.push(markerAndId);

        const srcBuf = new Uint8Array(4);
        const src = streams[0].sourceTotalBytes;
        srcBuf[0] = src & 0xff; srcBuf[1] = (src >> 8) & 0xff;
        srcBuf[2] = (src >> 16) & 0xff; srcBuf[3] = (src >> 24) & 0xff;
        buffers.push(srcBuf);
        
        for (const pkt of packetsPayloads) buffers.push(pkt);
        return mergeBuffers(buffers);
    }

    const header = new bytearray([0x71, 0x00, transferId & 0xff, (transferId >> 8) & 0x7f]);
    buffers.push(header);

    for (let i = 0; i < streams.length; i++) {
        const s = streams[i];
        const isLast = i === streams.length - 1;
        const stByte = isLast ? (s.streamType | 0x80) : s.streamType;
        const sHeader = new Uint8Array(8);
        sHeader[0] = stByte; sHeader[1] = s.streamId; sHeader[2] = s.packetCount; sHeader[3] = Math.floor(s.packetSizeBytes / 8);
        sHeader[4] = s.sourceTotalBytes & 0xff; sHeader[5] = (s.sourceTotalBytes >> 8) & 0xff;
        sHeader[6] = (s.sourceTotalBytes >> 16) & 0xff; sHeader[7] = (s.sourceTotalBytes >> 24) & 0xff;
        buffers.push(sHeader);
    }

    for (const pkt of packetsPayloads) buffers.push(pkt);
    return mergeBuffers(buffers);
}

 Gentile function helper to simplify buffer array composition
function mergeBuffers(arr) {
    let total = arr.reduce((acc, b) => acc + b.length, 0);
    let out = new Uint8Array(total), offset = 0;
    for (const b of arr) { out.set(b, offset); offset += b.length; }
    return out;
}
def create_qrtp_frame(transfer_id, streams, packets_payloads):
    buf = bytearray(b'q\x00')

    # Optimization path validation for squeezed targets
    if len(streams) == 1 and streams[0]['stream_id'] == 0 and streams[0]['packet_size'] in (368, 40):
        buf += struct.pack('<HI', (transfer_id & 0x7fff) | 0x8000, streams[0]['source_total_bytes'])
        for pkt in packets_payloads:
            buf += pkt
        return bytes(buf)

    # Standard full layout execution flow
    buf += struct.pack('<H', transfer_id & 0x7fff)
    for i, stream in enumerate(streams):
        is_last = (i == len(streams) - 1)
        st_byte = (stream['stream_type'] | 0x80) if is_last else stream['stream_type']
        
        buf += struct.pack('<BBBB', st_byte, stream['stream_id'], stream['packet_count'], stream['packet_size'] // 8)
        buf += struct.pack('<I', stream['source_total_bytes'])
        
    for pkt in packets_payloads:
        buf += pkt
    return bytes(buf)
func CreateQrtpFrame(transferID uint16, streams []StreamInfo, packetsPayloads [][]byte) []byte {
	var buf []byte
	buf = append(buf, 'q', 0)

	if len(streams) == 1 && streams[0].StreamID == 0 && (streams[0].PacketSize == 368 || streams[0].PacketSize == 40) {
		tw := (transferID & 0x7fff) | 0x8000
		tBytes := make([]byte, 2)
		binary.LittleEndian.PutUint16(tBytes, tw)
		buf = append(buf, tBytes...)

		sBytes := make([]byte, 4)
		binary.LittleEndian.PutUint32(sBytes, streams[0].SourceTotalBytes)
		buf = append(buf, sBytes...)

		for _, pkt := range packetsPayloads {
			buf = append(buf, pkt...)
		}
		return buf
	}

	tBytes := make([]byte, 2)
	binary.LittleEndian.PutUint16(tBytes, transferID&0x7fff)
	buf = append(buf, tBytes...)

	for i, stream := range streams {
		isLast := i == len(streams)-1
		stByte := stream.StreamType
		if isLast {
			stByte |= 0x80
		}
		buf = append(buf, stByte, stream.StreamID, stream.PacketCount, uint8(stream.PacketSize/8))
		sBytes := make([]byte, 4)
		binary.LittleEndian.PutUint32(sBytes, stream.SourceTotalBytes)
		buf = append(buf, sBytes...)
	}

	for _, pkt := range packetsPayloads {
		buf = append(buf, pkt...)
	}
	return buf
}