Skip to content

Trace format specification

The binary trace format is the contract between any capture engine and Tenet. As long as a tool emits a compliant .bin file, Tenet can analyze it—QBDITrace is the official implementation, not the only option.

The authoritative C definition is tools/qbditrace/TraceFormat.h. This document restates the format for implementors of custom collectors.

536 bytes, written once at the start of the file. All multi-byte integers are little-endian (native AArch64).

Offset Size Field Description
0x000 4 magic 0x51424454 (“QBDT”)
0x004 4 version 4 (baseline), 5 (PC-delta), 6 (old-value), 7 (FPR)
0x008 4 arch Architecture tag: 1 = AArch64, 2 = x86_64
0x00C 4 num_gpr GPR count in anchor snapshots (AArch64: 34)
0x010 8 module_base Runtime base address of the traced module
0x018 8 module_slide ASLR slide (runtime − preferred static base)
0x020 8 trace_start Runtime start of traced range (inclusive)
0x028 8 trace_end Runtime end of traced range (exclusive)
0x030 8 flags Header flags (see below)
0x038 256 module_name NUL-terminated module path
0x138 4 target_os 0 = Unknown, 1 = iOS, 2 = Android, 3 = Linux
0x13C 220 reserved Padding to 536 bytes
static_address = runtime_address − module_slide

Tenet normalizes all runtime PCs back to the static image so they match IDA/Ghidra.

The flags field at offset 0x030 controls record layout and compression. Flags are orthogonal to the version tag: the version is set by the highest flag that changes the on-disk layout.

Bit Name Effect
0 HF_HAS_MEM Records may carry memory accesses
1 HF_HAS_NZCV INST records carry NZCV flags
2 HF_REG_DIFF INST records store register diffs (not full snapshots)
3 HF_HAS_CODE Stream contains REC_CODE records
4 HF_PC_DELTA v5: INST header uses 2-byte signed PC delta instead of 8-byte absolute PC
5 HF_OLD_VALUE v6: Memory access records carry pre-write values (48 B each)
6 HF_HAS_FPR v7: ANCHOR carries 512 B FPR snapshot; INST carries FPR diffs
7 HF_COMPRESSED Legacy: entire record stream is one zstd frame (historical)
8 HF_BLOCK_COMPRESSED 1 MiB zstd blocks with tail index for random access (default)
9 HF_BRANCH_HINT nzcv high nibble encodes CALL/RET/BRANCH class
Version Gate flag Key change
v4 baseline INST head 16 B, ANCHOR 288 B, REC_CODE introduced
v5 HF_PC_DELTA INST head shrinks to 6 B (int16 PC delta); decoder maintains running PC
v6 HF_OLD_VALUE Memory access records grow to 48 B (pre-write old_value fields); writer removed
v7 HF_HAS_FPR ANCHOR gains 512 B FPR block (total 800 B); INST gains FPR diff section

Readers that do not recognise a flag must reject the file.

Every record begins with a single-byte kind tag:

Kind Value Description
REC_INST 0 Per-instruction register/memory diff
REC_ANCHOR 1 Full register snapshot (rebuild checkpoint)
REC_THREAD 2 Thread context marker
REC_EVENT 3 VM event (sequence entry/exit, cross-boundary transfer)
REC_SVC 4 Supervisor call / syscall site
REC_OBJC 5 ObjC msgSend-family dispatch
REC_CODE 6 Raw instruction bytes (emitted once per distinct PC)
REC_CAPICALL 7 C/platform API call with semantic argument captures

The most frequent record. Stores only the changes an instruction produced.

v4 layout (HF_PC_DELTA not set):

qbditrace_inst_head_t 16 bytes
kind u8
nzcv u8 (low 4 bits = N/Z/C/V; high 4 bits = branch hint when HF_BRANCH_HINT)
n_regdiff u8
n_mem u8
pc u64 (runtime absolute PC)
n_regdiff × regdiff_t 16 bytes each
reg_index u8 (QBDI GPR ordering: 0=x0 … 30=lr, 31=sp, 32=nzcv, 33=pc)
[padding] 7 B
value u64 (value after the instruction executed)
n_mem × memacc_t 32 bytes each
mem_type u8 (bit0 = read, bit1 = write)
mem_flags u8 (bit0 = unknown_size, bit1 = minimum_size, bit2 = unknown_value)
size u16
addr u64
value u64 (low 8 bytes)
value_hi u64 (high 8 bytes for 9–16 byte stores; 0 otherwise)

v5 layout (HF_PC_DELTA set): identical except pc is replaced by pc_delta (int16 LE). The decoder maintains running_pc += pc_delta. If |delta| > 32767, emit an ANCHOR first (which resets running_pc with its absolute pc), then use delta = 0. Header size: 6 bytes.

v7 layout (HF_HAS_FPR set): after the GPR diffs, an FPR diff section is interleaved before memory accesses:

inst_head (v7 or v7+v5 variant)
n_regdiff × regdiff_t
n_fprdiff × fprdiff_t 24 bytes each
fpr_index u8 (0..31 = v0..v31)
[padding] 7 B
lo u64 (bits 63:0)
hi u64 (bits 127:64)
n_mem × memacc_t

Periodic full-register checkpoint. The decoder uses the most recent anchor to reconstruct state at any instruction.

v4/v5/v6 layout:

anchor_head_t 16 bytes
kind u8
[reserved] 3 B
pc u64 (absolute runtime PC)
num_gpr × u64 272 bytes (34 × 8 B for AArch64)

Total: 288 bytes.

v7 layout (HF_HAS_FPR set): after the GPR block, a 512-byte FPR snapshot is appended:

anchor_head_t + GPR block 288 bytes
fpr_lo[32] 256 bytes (bits 63:0 of v0..v31)
fpr_hi[32] 256 bytes (bits 127:64 of v0..v31)

Total: 800 bytes.

thread_head_t 16 bytes
kind u8
[reserved] 3 B
thread_id u64

Emitted at the start of each per-thread stream. Currently Tenet assumes single-thread traces.

event_head_t 24 bytes
kind u8
event u8 (1=SEQ_ENTRY, 2=SEQ_EXIT, 3=XFER_CALL, 4=XFER_RETURN)
[reserved] 2 B
addr u64 (for XFER_CALL: target address; for XFER_RETURN: return target)
site u64 (for XFER_CALL: call-site PC; 0 otherwise)
svc_head_t 72 bytes
kind u8
[reserved] 3 B
pc u64 (address of the svc instruction)
syscall_no u64 (x16 on Darwin, x8 on Linux/Android)
args[6] 6×u64 (x0..x5)
objc_head_t 80 bytes
kind u8
variant u8 (0=msgSend, 1=msgSendSuper, 2=msgSendSuper2, 3=msgSend_stret)
[reserved] 2 B
pc u64 (call-site PC)
recv u64 (x0: receiver or super struct pointer)
sel u64 (x1: selector; x2 for *Super variants)
cls[48] (NUL-terminated runtime class name; "" if unavailable)

Raw instruction bytes for one distinct PC. Emitted once on first execution, deduplicated by PC. Enables offline disassembly without an external image.

code_head_t 16 bytes
kind u8
size u8 (instruction length; 4 on AArch64)
[reserved] 2 B
pc u64 (runtime address)
bytes[size] (raw little-endian encoding)

Captures a call to a well-known C/platform API function. Contains a fixed 88-byte header plus 0–4 variable-length inline string captures.

capicall_head_t 88 bytes
kind u8
api_id u8 (numeric ID from the capi_id enum)
arg_count u8 (valid entries in args[], 0..8)
str_count u8 (inline string captures that follow, 0..4)
pc u64 (call-site PC)
target u64 (runtime address of the called function)
args[8] 8×u64 (x0..x7 at call time)

Followed by str_count variable-length captures:

capicall_str_t 4 bytes
str_arg_idx u8 (which arg this string came from, 0..7)
str_len u8 (content byte length, not counting NUL; max 255)
str_flags u8 (bit0=truncated, bit1=binary, bit2=safe-read-failed)
[_reserved] u8
str_data[str_len + 1] (NUL-terminated; padded to 4-byte alignment)

Default for all new traces. The data after the 536-byte header is divided into independent 1 MiB zstd frames. A tail index at the end of the file enables O(1) random access.

File layout:

[file header: 536 B, uncompressed]
[block 0: zstd frame]
[block 1: zstd frame]
...
[block N: zstd frame]
[block index table: N × 24 B entries]
[block footer: 16 B]

Block index entry (24 bytes each):

Field Size Description
original_offset u64 Logical (decompressed) byte offset from record start
compressed_offset u64 Physical file offset of the compressed block
original_size u32 Decompressed byte count
compressed_size u32 Compressed byte count on disk

Block footer (last 16 bytes of file):

Field Size Description
index_offset u64 File offset of the first index entry
block_count u32 Number of compressed blocks
magic u32 0x51424249 (“QBBI”)

Historical format: the entire record stream after the header is a single zstd frame. No random access. HF_COMPRESSED and HF_BLOCK_COMPRESSED are mutually exclusive.

When HF_BRANCH_HINT is set, the high nibble of the nzcv byte in REC_INST encodes the instruction’s branch class:

High nibble Value Meaning
0b0000 0 Ordinary instruction (not a branch)
0b0001 1 CALL (BL / BLR)
0b0010 2 RET (RET / RETAA / RETAB)
0b0100 4 BRANCH (B / BR — unconditional non-call jump)

This encoding costs zero additional bytes and lets the decoder skip the pc_kind table for ~95–98% of instructions. Backward-compatible: old traces never set the high nibble, so old readers see no change.

GPR indices (used in regdiff_t.reg_index and anchor snapshots):

Index Register
0–28 x0–x28
29 fp (x29)
30 lr (x30)
31 sp
32 nzcv
33 pc

FPR indices (used in fprdiff_t.fpr_index):

Index Register
0–31 v0–v31 (128-bit NEON)

Tenet opens any trace with QBDT magic and version ≥ 4. Missing records degrade gracefully—only the passes whose inputs are present will run.

Required: valid QBDT magic, version ≥ 4, REC_INST records with PC, and at least one REC_ANCHOR.

Result: basic instruction listing and address normalization.

Add: HF_REG_DIFF, HF_HAS_NZCV, HF_HAS_MEM, HF_HAS_CODE, HF_BRANCH_HINT, REC_EVENT.

Result: full CFG recovery, function boundary detection, call graphs, loop analysis, register reconstruction, memory model, taint analysis.

Add: REC_SVC, REC_OBJC, REC_CAPICALL.

Result: syscall naming, ObjC message resolution, C API argument capture and string inspection.

Add: HF_HAS_FPR (v7).

Result: NEON/vector register reconstruction, crypto algorithm identification.

Common alternatives to QBDITrace include Frida Stalker (emit REC_INST + REC_ANCHOR + REC_CODE from Stalker.follow), DynamoRIO, Pin, or custom emulators such as Unicorn—all that matters is conformance to the binary format described above.

  1. Write the 536-byte file header with magic = 0x51424454, version = 4, and the appropriate flags.
  2. Emit REC_ANCHOR records periodically (every 1024 instructions is a good default). Each anchor must contain the full GPR snapshot.
  3. For each executed instruction, emit a REC_INST record with the PC (absolute or delta), NZCV, register diffs, and memory accesses.
  4. Optionally emit REC_CODE records for each distinct PC on first execution.
  5. Use block compression for efficient random access: compress in 1 MiB chunks and append the index table and footer.
  6. Set module_base and module_slide correctly so Tenet can normalize runtime addresses back to the static image.
// Write header
qbditrace_file_header_t hdr = {0};
hdr.magic = 0x51424454;
hdr.version = 4;
hdr.arch = 1; // AArch64
hdr.num_gpr = 34;
hdr.module_base = runtime_base;
hdr.module_slide = slide;
hdr.trace_start = range_start;
hdr.trace_end = range_end;
hdr.flags = QBDITRACE_HF_REG_DIFF | QBDITRACE_HF_HAS_NZCV
| QBDITRACE_HF_HAS_MEM | QBDITRACE_HF_HAS_CODE
| QBDITRACE_HF_BLOCK_COMPRESSED | QBDITRACE_HF_BRANCH_HINT;
strncpy(hdr.module_name, path, 255);
write(fd, &hdr, 536);
// Per instruction:
qbditrace_inst_head_t inst = {0};
inst.kind = 0;
inst.nzcv = current_nzcv;
inst.n_regdiff = count_written_regs;
inst.n_mem = count_mem_accesses;
inst.pc = runtime_pc;
write(fd, &inst, 16);
write(fd, reg_diffs, inst.n_regdiff * 16);
write(fd, mem_accesses, inst.n_mem * 32);
// Periodically:
qbditrace_anchor_head_t anchor = {0};
anchor.kind = 1;
anchor.pc = runtime_pc;
write(fd, &anchor, 16);
write(fd, gpr_snapshot, num_gpr * 8);

The QBDITrace dylib exposes a stable C ABI for programmatic capture:

int qbditrace_configure(const qbditrace_config_t *cfg);
int qbditrace_run_call(uint64_t *ret_out, uint64_t fn,
uint32_t argc, const uint64_t *argv);
int qbditrace_run_at(uint64_t *ret_out, uint64_t start_pc,
const uint64_t *gpr31, uint64_t sp, uint64_t lr);
void qbditrace_stop(void);
int qbditrace_force_anchor(void);
const char *qbditrace_output_path(void);
int qbditrace_dump_pcring(const char *path);
const qbditrace_stats_t *qbditrace_stats_page(void);

See Capture > Configuration for the Python loader that wraps these calls.