-
Notifications
You must be signed in to change notification settings - Fork 341
XUBC7 Low Level Format
Written by AI from the reference source code; work in progress.
Copyright (C) 2025-2026 Binomial LLC. All rights reserved except as granted under the Apache 2.0 LICENSE. Also see our NOTICE file. If you modify the Basis Universal source code, specifications, or wiki documents and redistribute the files, you must cause any modified files to carry prominent notices stating that you changed the files (see Apache 2.0 §4(b)).
This document specifies the XUBC7 bitstream in enough detail to write an independent, bit-exact decoder. It is the low-level companion to the XUBC7 File Format overview, which covers containers, the coding model, and encoder options at a conceptual level; that document is the better starting point. Everything here is normative unless marked (informative).
The reference implementation is transcoder/basisu_xbc7_decoder.h (shared definitions, the DCT, and the decoder API) and transcoder/basisu_xbc7_decoder.inl (the decode procedure). Where this document and the reference implementation disagree, the implementation is correct and this document has a bug — please file an issue.
Conformance. A conforming decoder MUST, for every well-formed stream, produce exactly the same sequence of logical BC7 blocks as the reference decoder, and MUST reject every stream the reference decoder rejects. Both halves matter: XUBC7 is a predictive codec, so a decoder that reconstructs one block differently will corrupt every later block that predicts from it, and an over-permissive decoder can be steered by a hostile stream into referencing blocks the encoder never intended.
Determinism. All arithmetic in this specification is integer. No step uses floating point, and none may be reordered in a way that changes rounding. The DCT is fixed-point specifically so that decodes are bit-identical across compilers, platforms, and optimization levels.
An XUBC7 decoder does not emit pixels. It emits logical BC7 blocks: the fields of a standard 128-bit BC7 block, unpacked into a struct. The caller packs them into physical BC7 blocks, or transcodes them.
struct log_bc7_block
{
int8_t m_mode; // 0..7 (-1 = invalid/uninitialized)
uint8_t m_num_partitions; // 1..3 subsets
uint8_t m_pattern_bits; // 0, 4, or 6
uint8_t m_pattern_index; // partition pattern index
uint8_t m_num_planes; // 1 or 2
uint8_t m_dp_rotation_index; // 0..3 for modes 4/5, else 0
uint8_t m_mode4_index_selector; // 0 or 1 for mode 4, else 0
uint8_t m_endpoint_bits[2]; // [0] = RGB bits, [1] = alpha bits (0 if none)
uint8_t m_endpoints[3][2][4]; // [subset][lo/hi][channel], quantized field values
uint8_t m_weight_bits[2]; // [plane]; [0] is 2..4, [1] is 2..3 for dual-plane modes and 0 otherwise
uint8_t m_weights[2][16]; // [plane][texel], quantized indices
uint8_t m_num_pbits; // total p-bits across all subsets, 0..6
bool m_shared_pbits; // true = one p-bit per endpoint pair
uint8_t m_pbits[6];
};Texel index order is raster: index_from_xy(x, y) = x + y*4, with x and y in [0,4).
Endpoint values are stored as quantized field values (the raw bits that go into the BC7 block), not expanded 8-bit colors. Expansion to 8 bits is a separate step (§1.3), needed in three places during decoding — none of which involve producing pixels for output.
init_log_blk(blk, mode) zero-fills the struct and then sets these fields from mode. All other fields (endpoints, weights, p-bits, pattern index, rotation, selector) remain zero and are filled in by later steps.
| mode | subsets | pattern bits | planes | RGB bits | A bits | p-bits/subset | shared p-bits | weight bits [0] | weight bits [1] |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 3 | 4 | 1 | 4 | 0 | 2 | no | 3 | 0 |
| 1 | 2 | 6 | 1 | 6 | 0 | 1 | yes | 3 | 0 |
| 2 | 3 | 6 | 1 | 5 | 0 | 0 | – | 2 | 0 |
| 3 | 2 | 6 | 1 | 7 | 0 | 2 | no | 2 | 0 |
| 4 | 1 | 0 | 2 | 5 | 6 | 0 | – | 2 | 3 |
| 5 | 1 | 0 | 2 | 7 | 8 | 0 | – | 2 | 2 |
| 6 | 1 | 0 | 1 | 7 | 7 | 2 | no | 4 | 0 |
| 7 | 2 | 6 | 1 | 5 | 5 | 2 | no | 2 | 0 |
Derived: m_num_pbits = m_num_partitions * pbits_per_subset; m_shared_pbits = (pbits_per_subset == 1); m_num_planes = 2 for modes 4 and 5 only.
Note that m_weight_bits[1] is nonzero only for dual-plane modes. Modes 6 and 7 have alpha but a single plane, so their alpha is interpolated with m_weight_bits[0].
Helper predicates used throughout:
is_dual_plane() = (m_num_planes == 2)-
get_num_comps() = m_endpoint_bits[1] ? 4 : 3— the number of endpoint channels -
get_color_component_selector()(the "ccs"):-1if single-plane, else(m_dp_rotation_index + 3) & 3 -
get_endpoint_channel_weight_plane(c):0if single-plane; else ifc == 3then1 - m_mode4_index_selector, elsem_mode4_index_selector
g_bc7_weights2[4] = { 0, 21, 43, 64 };
g_bc7_weights3[8] = { 0, 9, 18, 27, 37, 46, 55, 64 };
g_bc7_weights4[16] = { 0, 4, 9, 13, 17, 21, 26, 30, 34, 38, 43, 47, 51, 55, 60, 64 };
dequant_weight(w, n) = g_bc7_weights{n}[w]; // n in {2,3,4}The inverse, quant_weight(v, n), maps a value in [0,64] (silently clamped) to the nearest index:
quant_weight(v, n):
v = clamp(v, 0, 64)
best_idx = 0; best_err = UINT32_MAX
for idx in [0, 2^n):
err = |v - dequant_weight(idx, n)|
if err < best_err: // STRICT: ties keep the LOWER index
best_err = err; best_idx = idx
return best_idxThe strict < matters: for n = 2, v = 32 is equidistant from index 1 (21) and index 2 (43) — err 11 both ways — and MUST resolve to 1. A decoder that rounds ties upward will diverge.
This is presented as a search for clarity, but it defines a 3×65 lookup table that a decoder MUST build once at initialization, not evaluate per texel.
Expanding a quantized endpoint field to 8 bits. This is needed in three places: the endpoint DPCM prediction (§7.5.3 step 1), the DCT's endpoint-span computation (§8.2.2), and solid-block prediction (§9).
bc7_dequant(val, pbit, val_bits): // modes with p-bits
total = val_bits + 1
v = (val << 1) | pbit
v <<= (8 - total)
return v | (v >> total)
bc7_dequant(val, val_bits): // modes without p-bits
v = val << (8 - val_bits)
return v | (v >> val_bits)unpack_endpoints(blk, ep[2], subset) produces two 8-bit RGBA colors:
ep[0].a = ep[1].a = 255 // default; overwritten only if the mode has alpha
for e in [0,2):
for c in [0, get_num_comps()):
bits = m_endpoint_bits[c == 3 ? 1 : 0]
if m_num_pbits:
p = m_pbits[m_shared_pbits ? subset : (subset*2 + e)]
ep[e][c] = bc7_dequant(m_endpoints[subset][e][c], p, bits)
else:
ep[e][c] = bc7_dequant(m_endpoints[subset][e][c], bits)For a mode with no alpha channel this leaves alpha at 255 — which several prediction paths depend on.
Five tables belong to the standard BC7 format rather than to XUBC7, so they are not reproduced here. They are normative all the same — a transcription error in any of them produces wrong output with no diagnostic — so take them from a source you can verify rather than retyping them by hand.
| table | size | used by |
|---|---|---|
g_bc7_partition2 |
64 patterns × 16 subset indices | §9 (texel subset selection); §11.2 (enumerating a subset's texels) |
g_bc7_partition3 |
64 patterns × 16 subset indices | same; mode 0 uses only the first 16 patterns |
g_bc7_table_anchor_index_second_subset |
64 | §11.1, §11.2 (2-subset modes) |
g_bc7_table_anchor_index_third_subset_1 |
64 | §11.1, §11.2 (3-subset modes) |
g_bc7_table_anchor_index_third_subset_2 |
64 | §11.1, §11.2 (3-subset modes) |
All five are needed on the pack path (§11.2) as well as the decode path: canonicalization locates each subset's anchor from the anchor tables, then uses the partition table to enumerate that subset's texels.
Easiest source — the reference implementation. All five are plain const uint8_t arrays in transcoder/basisu_transcoder.cpp, defined consecutively at roughly these lines (search the names; the line numbers drift between releases):
g_bc7_partition2[64 * 16] ~14592
g_bc7_partition3[64 * 16] ~14604
g_bc7_table_anchor_index_second_subset[64] ~14616
g_bc7_table_anchor_index_third_subset_1[64] ~14618
g_bc7_table_anchor_index_third_subset_2[64] ~14623
Authoritative source — the BC7 format definition itself: Microsoft's BC7 format and BC7 format mode reference pages, and the Khronos Data Format Specification's BC7 section. These describe the same tables the reference uses.
Terminology. This document and the BC7/BPTC standard use several different words for the same things. If you are reading both, map them as follows:
| this document | BPTC / Microsoft |
|---|---|
m_pattern_index, m_pattern_bits
|
partition number / partition set ID |
m_num_partitions (1..3) |
number of subsets (NS) |
| "16 or 64 patterns" | "16 or 64 partitions" |
| anchor index | anchor index (BPTC); fix-up index (Microsoft) |
| weight plane 0 / plane 1 | primary / secondary index set (IB / IB2) |
| weight, weight bits | index, index bits |
The m_num_partitions collision is the one that bites: mode 0 has m_num_partitions == 3 here, while Microsoft describes mode 0 as having "16 partitions" — those are different quantities.
Note also that §1.2's quant_weight is not part of BC7. The standard defines only dequantization; the quantizer and its tie-break rule are XUBC7's own and are normative for this format alone.
Indexing conventions. An external source will not state these in XUBC7's terms:
- Partition tables are indexed
pattern_index * 16 + i, wherei = x + y*4is the raster texel index; the value is that texel's subset number. - Anchor tables are indexed by
pattern_indexalone; the value is the texel index whose weight is stored with one bit fewer (§11.1). - Mode 0 carries only 4 pattern bits, so it uses just the first 16 patterns of
g_bc7_partition3— that is, entries 0..255, since each pattern occupies 16 entries.
Verify after transcribing. These are cheap to assert once at startup, and each one guards a property §11.1 or §11.2 silently depends on:
| assertion | what breaks without it |
|---|---|
| no entry in any of the three anchor tables is 0 | §11.1's 128-bit accounting — blocks become 129 or 130 bits |
third_subset_1[p] != third_subset_2[p] for all 64 patterns |
same |
partition2[p*16] == 0 and partition3[p*16] == 0 for all 64 patterns |
"subset 0's anchor is always texel 0" (§11.1, §11.2) |
partition2[p*16 + anchor2[p]] == 1 |
§11.2 canonicalizing the wrong subset |
partition3[p*16 + anchor3_1[p]] == 1 and partition3[p*16 + anchor3_2[p]] == 2
|
same |
every g_bc7_partition2 entry < 2, every g_bc7_partition3 entry < 3 |
out-of-range subset indices |
The third row is the easy one to miss: BPTC guarantees the anchor of partition zero is index zero, but that holds only because texel 0 lies in subset 0 in every pattern. A mistranscribed partition table that moved texel 0 into another subset would leave all the anchor-table assertions passing while §11.1's i == 0 reduced-width test and §11.2's subset-0 rule both became wrong.
The first byte of a slice payload selects the form:
| byte | form |
|---|---|
0xB7 |
blob container (§3) |
0xB8 |
tiny mip, no alpha (§11) |
0xB9 |
tiny mip, has alpha (§11) |
Any other leading byte MUST be rejected. A zero-length payload MUST be rejected.
Decoding requires Zstd. In the reference decoder the Zstd guard runs before the leading-byte dispatch, so a build without Zstd support fails every form — including tiny mips, which carry no compressed data at all.
[u8 0xB7]
[u8 num_blobs]
repeat num_blobs times:
[u8 id_and_flag] bits 0-6 = blob id; bit 7 = 1 if Zstd-compressed
if compressed: [varint uncompressed_size] [varint stored_size]
else: [varint uncompressed_size]
[stored_size or uncompressed_size bytes of data]
[u8 0x6A]
Varints are LEB128, little-endian groups of 7 bits, high bit = continue. A conforming reader MUST reject encodings longer than 5 bytes, and MUST reject a 5th byte greater than 0x0F (which would overflow 32 bits).
Rejection rules, all mandatory:
- Payload shorter than 3 bytes.
- Leading byte is not
0xB7(for this form). - Any varint that runs off the end of the buffer, or violates the length limits above.
-
uncompressed_size == 0for any blob. Empty blobs are never stored. -
stored_size == 0for a compressed blob. -
stored_size >= uncompressed_sizefor a compressed blob. The encoder never sets the compressed flag unless compression strictly shrinks the blob, so equality is malformed. (It is in fact stricter still — blobs larger than 128 bytes must save at leastsize/400before it keeps the compressed form, since a raw blob is zero-copy at decode. That is an encoder policy; decoders enforce only the strict-inequality rule.) - A blob id appearing more than once.
- A blob's data running past the end of the buffer.
- The total inflated size of all compressed blobs exceeding the decoder's cap (the reference decoder uses 1 GiB).
- The end marker
0x6Anot landing on exactly the final byte of the payload. This single check rejects truncation, trailing garbage, and any disagreement between the directory and the data. - Zstd reporting an error while inflating a compressed blob, or producing a byte count different from the blob's declared
uncompressed_size. The size equality matters on its own: a frame that legitimately decodes to fewer bytes than declared would otherwise leave part of the arena uninitialized, and a decoder that skips this check will read those bytes. - Any directory read that would fall outside the payload. Every byte the directory walk consumes MUST be bounds-checked before it is read — the entry byte (
id_and_flag), each individual varint byte, and the end-marker byte, for which position is tested first and value second. Rules 3 and 10 describe those bytes' contents and are evaluated after the read, so they do not protect it. Two payloads make the distinction concrete:B7 02 00 01 AAparses one complete blob and then reads the second entry byte one past the end before rule 3 fires;B7 01 00 FFends with a varint byte carrying the continue bit and nothing after it, so a reader that consumes up to five varint bytes and validates afterward over-reads by four.
Directory capacity. num_blobs is a single byte, so a decoder MUST accept up to 255 directory entries even though only 128 blob ids exist. Duplicate ids are rejected by rule 7, but only after an entry has been recorded, so sizing a fixed directory array by 128 overflows it: 129 entries with ids 0..127 followed by a repeat of id 0 writes the 129th entry before the duplicate check runs.
Arithmetic widths are normative here, for the same reason they are in §5.3. The directory walk is the only place in the format that sizes a heap write. Every one of these MUST be computed in an unsigned type of at least 64 bits:
- the running payload offset, and the
offset + stored_bytestest of rule 8; - the sum of declared
uncompressed_sizevalues checked against the cap in rule 9.
Both are attackable at 32 bits, including via size_t on a 32-bit build. Two compressed blobs each declaring 2 GiB are individually legal — 0x80000000 is a valid 5-byte LEB128, and each passes rules 5, 6 and 8 — but a 32-bit total wraps to zero, sails past any cap, and yields a zero-byte arena that Zstd is then handed a 2 GiB destination capacity for. Likewise a raw blob declaring 0xFFFFFF00 at a nonzero offset wraps rule 8's sum back inside the payload, publishing a blob whose recorded size spans ~4 GB of a few-hundred-byte buffer.
Relatedly, bound the inflate destination by the blob's actual slice of the arena rather than by its declared uncompressed_size. Passing the declared size is safe only if the arena total was computed exactly.
Blob ids are 0..127 by construction (bit 7 is the compression flag). Ids the decoder does not recognize MUST be tolerated — they are simply never read. This is the format's forward-compatibility mechanism.
Raw blobs may be referenced in place; only compressed blobs need inflating. The reference does exactly that, and so does the tiny-mip path (§11), which keeps a bare pointer to the block data. If a decoder aliases the input this way, the caller's payload buffer MUST outlive every decode call that touches it. State this in your API or copy the data — it is a use-after-free waiting to happen for anyone who adopts the streaming decoder shape of §12 without noticing the aliasing.
Blob 0 MUST be present and MUST be exactly 7 bytes, and this MUST be checked before those 7 bytes are read. A raw blob 0 of declared size 3 satisfies every rule in §3; a decoder that copies sizeof(xbc7_header) first and validates after reads 4 bytes past the blob's declared extent, and up to 3 bytes past the caller's payload buffer, which raw blobs alias. (Not the full 4 past the buffer: rule 10 forces the end marker to be the final byte, so at least one payload byte always follows blob 0's data.)
struct xbc7_header // packed, little-endian
{
uint16_t m_width_in_texels;
uint16_t m_height_in_texels;
uint8_t m_dct_q;
uint8_t m_flags;
uint8_t m_num_stripes;
};Validation, in this order:
-
m_width_in_texels != 0andm_height_in_texels != 0. - Both dimensions
<= 16384. -
1 <= m_dct_q <= 100. -
(m_flags & ~1) == 0. Bit 0 isXBC7_FLAG_HAS_ALPHA; every other bit is reserved and MUST be zero.
Then derive the block geometry:
num_blocks_x = (width + 3) / 4
num_blocks_y = (height + 3) / 4
total_blocks = num_blocks_x * num_blocks_y
and continue validating:
-
m_num_stripes >= 1. -
m_num_stripes <= num_blocks_y. -
m_num_stripes <= 16.
Structural gate. Before allocating anything sized from the header, a decoder MUST verify that blob 1 (Commands) is present and its size is exactly total_blocks bytes. Since there is exactly one command byte per block, this is a necessary condition for any valid stream, and it means the header's declared dimensions cannot exceed what the payload actually carries data for.
Be precise about what this does and does not bound. It gates allocations sized from the header — principally the logical block array of §5.4. It does not bound the inflate arena of §3, which is sized from the blob directory's declared sizes and allocated before the header is even read. Nor is it a cheap bound: the commands blob is one byte per block and highly compressible, so a payload of roughly a kilobyte can legitimately declare 16384×16384 and gate a block array of tens of bytes per block — well over a gigabyte.
A decoder MUST therefore also:
- Bound the inflate arena against the cap of §3 rule 9. Note that the cap is an accept/reject threshold, so it is part of conformance: a decoder that tightens it — for instance deriving it from the payload length — will reject streams the reference accepts, and that is a deliberate deviation to document rather than a free hardening. Implementations that need it should take it knowingly. Be aware of what the reference's fixed 1 GiB admits: roughly 10⁷:1 amplification from a ~50-byte payload, repeated per mip level.
- Treat any allocation failure as a decode rejection, returning an error rather than aborting. This matters more than it looks: the reference's block-array allocation uses a container whose
resizecallsstd::terminate()on failure rather than returning it, so on a 32-bit build, a memory-constrained device, or under a container memory limit, a hostile slice can take down the host process. Do not reproduce that; use a checked allocation.
The stripe count must be in the header because the solid-block predictor is implicit — see §9.
Block rows are divided into m_num_stripes contiguous ranges, as evenly as possible, with the remainder distributed to the earliest stripes:
base = num_blocks_y / num_stripes
extra = num_blocks_y % num_stripes
row = 0
for i in [0, num_stripes):
stripe[i].first_row = row
stripe[i].num_rows = base + (i < extra ? 1 : 0)
row += stripe[i].num_rows
// row == num_blocks_yEncoder and decoder MUST derive this identically.
Each stripe defines an inclusive block bounding box that gates every causal reference made while decoding it:
tile = { x0 = 0, y0 = first_row, x1 = num_blocks_x - 1, y1 = first_row + num_rows - 1 }
tile.contains(bx, by) = (bx >= x0 && bx <= x1 && by >= y0 && by <= y1)
No reference of any kind — neighbor, diagonal, XY-delta, endpoint block index, weight predictor — may resolve outside the tile. A reference that does MUST cause rejection, not clamping.
Blob ids 1 through 25 are per-stripe streams: each stripe's data occupies a contiguous range within the blob, and stripes appear in order. Three of them are bit streams whose offsets are measured in bits:
- 9 (CoeffSigns)
- 10 (PBits)
- 19 (EPRaw)
All others are byte streams. Bit streams carry no padding at stripe seams — the encoder appends each stripe's partial bit buffer directly, so a stripe can begin mid-byte.
Define seek(id, s) = start offset of stripe s in blob id, and seek(id, num_stripes) = the end sentinel, which is the blob's byte size (byte streams) or byte_size * 8 (bit streams). Stripe s reads exactly the range [seek(id, s), seek(id, s+1)).
When num_stripes == 1 there is no seek table; stripe 0 spans each blob entirely.
Written if and only if num_stripes > 1.
When num_stripes > 1, blob 26 MUST be present and its size MUST be exactly num_stripes * 25 * 4 bytes, and this MUST be checked before any indexed read into it. This is a hard rejection, and the ordering is part of it: the transposed reassembly below indexes up to 4 * num_entries - 1 (1599 at 16 stripes), so a decoder that reads first and validates after will run off the end of a short blob. A raw blob 26 of declared size 1 satisfies every rule in §3 — nonzero, in-bounds, unique id — and yields roughly a 1.6 KB over-read past the caller's payload buffer, which raw blobs alias.
The "only if" half is different: it is a statement about the encoder, not an enforced check. When num_stripes == 1 the reference never looks at blob 26 at all, so a stray or wrongly-sized seek table in a single-stripe slice is silently ignored under the unknown-blob rule. A conforming decoder MUST likewise not reject it. That carve-out applies only to the single-stripe case.
Entries are 32-bit deltas from the previous stripe's start, stripe-major, with 25 entries per stripe (ids 1..25 in order). Entry index for stripe st and blob id is:
e = st * 25 + (id - 1)
Entries are stored byte-plane transposed (structure-of-arrays), not interleaved. With num_entries = num_stripes * 25, byte b of entry e lives at:
pT[b * num_entries + e] // b in [0,4)
so the buffer is all the byte-0s of every entry, then all the byte-1s, and so on. Deltas are small, so the upper planes are nearly all zero and compress far better than an interleaved layout would. Reassemble each little-endian delta as:
delta = (uint64)pT[e] | ((uint64)pT[num_entries + e] << 8)
| ((uint64)pT[2*num_entries + e] << 16) | ((uint64)pT[3*num_entries + e] << 24)The casts are required, not stylistic. pT[] is uint8_t, which integer-promotes to int; shifting a byte ≥ 0x80 left by 24 sets the sign bit of a 32-bit int, which is undefined behavior and in practice yields a negative value. Widen each byte to an unsigned 64-bit type before shifting.
Reconstruct absolute offsets with a running prefix sum per blob id, validating as you go:
for id in [1, 26):
blob_end = (id is a bit stream) ? (uint64)size(id)*8 : (uint64)size(id)
uint64 running = 0 // MUST be at least 40 bits, unsigned
for st in [0, num_stripes):
delta = <as above>
if st == 0 and delta != 0: reject // stripe 0 always starts at 0
running += delta
if running > blob_end: reject // offset past end of blob
seek(id, st) = runningThe accumulator width is normative. running sums up to 16 unsigned 32-bit deltas, and for a bit stream blob_end is size * 8, which needs 35 bits. A 32-bit accumulator can wrap — and a wrapped value is smaller, so it sails through the running > blob_end test. Deltas {0, 64, 0xFFFFFFCA} against an 8-byte bit blob (blob_end = 64) produce 32-bit prefix sums 0, 64, 10: every check passes, and stripe 1 is handed the inverted bit range [64, 10). Section 8.1 explains why an inverted range is dangerous. With a 64-bit accumulator this is unreachable, which is why the reference is safe.
The width requirement covers the whole chain, not just the accumulator: offsets MUST be stored and compared at 64 bits, and the bit cursor and its end bound (§8.1) MUST be 64-bit as well. Mixing widths reintroduces the bug in a form the accumulator rule does not catch — a 64-bit cursor tested against a 32-bit-truncated end still passes for every read.
This is the natural mistake to make, because byte offsets and bit offsets differ here. A byte offset is bounded by the blob size and so fits in 32 bits; the reference's byte cursors are uint32_t and narrow the seek values explicitly, which is safe. Bit offsets are size * 8 and need 35 bits: a blob larger than 512 MiB — legal under §3's 1 GiB cap — has a blob_end in bits exceeding 2³². Narrow byte offsets if you like; never narrow bit offsets.
Given consistent widths, monotonicity follows from the deltas being unsigned; no separate check is needed, and one would not help against a truncated cursor anyway.
All of these checks MUST be performed before any block is decoded. They are what keeps a corrupt table from producing a wild cursor.
Stripes are fully independent and MAY be decoded concurrently. Each stripe's cursors are bounded to its own ranges, and every reference is gated by its tile, so no stripe reads another's blocks or bytes.
This has a consequence for the caller that a decoder MUST document. Under concurrent decode, a per-block sink is invoked from multiple threads at once, and never in global raster order — though always exactly once per block, and always for distinct (bx, by). A sink written for single-threaded raster delivery (an appending vector, a running hash, a row-at-a-time writer) is a data race.
Two rules follow for the decoder's own state:
- The logical block array is written concurrently, and MUST be fully allocated before the first stripe starts. Stripes own disjoint block-row ranges, so their writes land in distinct elements and the array is safe to share — but only if it already exists at full size. Allocating or growing it lazily on first block use is a race. This is a different requirement from the scratch rule below: the array is shared and mutable, and that is fine precisely because of the row disjointness.
- All other per-decode scratch MUST be per-stripe — the DCT work buffer, the coefficient/symbol list, the 16-entry prediction array, and every stream cursor. These are the tempting things to hoist into member state for reuse, and hoisting any of them turns §5.4's sanctioned concurrency into a data race on a heap container: two stripes resizing the same work vector or appending to the same coefficient list is heap corruption, not merely wrong pixels. No malformed input is needed; any multi-stripe file does it.
- Immutable tables MAY be shared, but MUST be fully constructed before the first stripe starts — §1.2's weight-quantization table, §9.1's solid endpoint table, the DCT tables. Building them lazily on first use races for the same reason.
A decoder MUST still maintain a full-image logical block array, because within a stripe, prediction reads already-decoded neighbors.
That array does not need to be initialized, and the reference leaves it indeterminate. The safety of that rests entirely on two properties established elsewhere in this document: every reference is gated by the stripe tile (§5.1), and every reference is causal by construction (§7.7), so only already-decoded blocks are ever read. Anyone who changes the gating — narrower tiles, a new predictor, a reference that is not strictly earlier in the stripe's raster order — breaks that argument and gets uninitialized reads with no other warning. Zero-initialize if you are not certain the invariant holds in your implementation.
Note that decoding a stripe without its predecessors is not such a change: that is exactly what concurrent decode does, and tile gating is what makes it safe.
After a stripe's last block, every cursor MUST be exactly consumed:
- byte cursors: offset equals the stripe's end offset, exactly.
- bit cursors: fewer than 8 bits remain relative to that stripe's end offset.
Note the asymmetry: byte cursors must land exactly, but bit cursors tolerate up to 7 unconsumed bits — and they do so at the end of every stripe, not only the last. A decoder that demands exact bit consumption will reject streams the reference accepts, violating the conformance requirement to agree on validity.
This slack is not hypothetical. At interior seams the encoder appends each stripe's unflushed partial bit buffer directly, so no padding appears there. But after merging, each bit stream is flushed, padding its trailing partial byte, while the end sentinel is byte_size * 8. So whenever a bit blob's total bit count is not a multiple of 8 — the common case — the final stripe ends with 1 to 7 pad bits. In a single-stripe file that is stripe 0.
Any cursor that is not fully consumed indicates a desync and MUST cause rejection. These checks are the format's main defense against a stream that decodes plausibly but drifts.
| id | name | kind | consumed by |
|---|---|---|---|
| 0 | Header | fixed | once, up front |
| 1 | Commands | byte | one per block, always |
| 2 | BC7BlockConfig | byte | NewConfig commands |
| 3 | Partition2 | byte | blocks whose mode has 2 subsets |
| 4 | Partition3 | byte | blocks whose mode has 3 subsets |
| 5 | WeightPredictors | byte | every full-block command |
| 6 | DCCoeffsSmall | byte | every DCT-coded plane |
| 7 | DCCoeffsLarge | byte | unused in v2.50 — reserved |
| 8 | ACCoeffs | byte | every DCT-coded plane |
| 9 | CoeffSigns | bit | AC signs, plus DC sign when predicted |
| 10 | PBits | bit | p-bit residuals, DPCM endpoint modes |
| 11..14 | EPDeltaFine R/G/B/A | byte | endpoint residuals, RGB bits >= 6 |
| 15..18 | EPDeltaCoarse R/G/B/A | byte | endpoint residuals, RGB bits < 6 |
| 19 | EPRaw | bit | Raw endpoint mode |
| 20 | EPBlockIndex | byte | endpoint mode 5 |
| 21 | RawWeightBits | byte | DPCM weights, Absolute predictor |
| 22 | SolidRGBADeltas | byte | SolidDPCM commands |
| 23..25 | DPCMWeightResid 2/3/4 | byte | DPCM weights, real predictor, by bit width |
| 26 | StripeSeekTable | fixed | once, up front |
| 27..127 | reserved | – | – |
Blob 7 carries no data in v2.50. All DC coefficients go to blob 6 regardless of the plane's weight depth. A decoder MUST read every DC from blob 6. Blob 7's id is reserved against a future split.
Blob 7 is nonetheless inside the seek table's id range, so it receives seek entries like every other id in 1..25. But no cursor is ever created for it, and consequently the end-of-stripe consumption check in §5.5 does not cover it: a stream carrying a non-empty blob 7 is accepted with that data entirely unread. Decoders MUST match this behavior rather than rejecting such a stream.
Any blob a stripe never consumes may be absent entirely.
Within a stripe, blocks are decoded in raster order: by from first_row to last_row, and bx from 0 to num_blocks_x - 1 inside each row.
At the top of each block, resolve the four named causal neighbors, each NULL if outside the tile:
left = tile.contains(bx-1, by ) ? &blk(bx-1, by ) : NULL
upper = tile.contains(bx, by-1) ? &blk(bx, by-1) : NULL
left_diag = tile.contains(bx-1, by-1) ? &blk(bx-1, by-1) : NULL
right_diag = tile.contains(bx+1, by-1) ? &blk(bx+1, by-1) : NULL
Read one byte from blob 1.
bits 0-2 : command (0..7)
bits 3-5 : endpoint mode (0..7)
bit 6 : weight mode (0 = DPCM, 1 = DCT)
bit 7 : reserved MUST be 0
If bit 7 is set, reject. (It is reserved for a future P-frame flag.)
| cmd | name |
|---|---|
| 0 | RepeatLast |
| 1 | RepeatUpper |
| 2 | SolidDPCM |
| 3 | NewConfig |
| 4 | ReuseConfigLeft |
| 5 | ReuseConfigUpper |
| 6 | ReuseConfigLeftDiagonal |
| 7 | ReuseConfigRightDiagonal |
Canonical form. For commands 0, 1, and 2, the entire byte MUST equal the command value — the endpoint-mode and weight-mode fields MUST be zero. A decoder MUST reject cmd_byte != cmd for these three. This prevents a stream from carrying two different encodings of the same block.
RepeatLast: if left is NULL, reject. Otherwise copy the entire logical block from left.
RepeatUpper: if upper is NULL, reject. Otherwise copy the entire logical block from upper.
SolidDPCM: see §9. Unlike the two Repeat commands, this one does consume further stream data — has_alpha ? 4 : 3 bytes from blob 22, per block.
RepeatLast and RepeatUpper consume nothing beyond the command byte. All three emit the block immediately, without touching config, partition, endpoint, or weight streams.
NewConfig (3): read one byte from blob 2.
bits 0-2 : BC7 mode
bits 3-4 : dual-plane rotation
bit 5 : mode 4 index selector
bits 6-7 : reserved, MUST be 0
Reject if bits 6-7 are nonzero. Then init_log_blk(blk, mode) and:
- If
m_num_planes == 2, setm_dp_rotation_index = rot. Otherwise, ifrot != 0, reject. - If
mode == 4, setm_mode4_index_selector = sel. Otherwise, ifsel != 0, reject.
ReuseConfig (4..7): select the source neighbor — Left, Upper, LeftDiagonal, RightDiagonal for commands 4, 5, 6, 7 respectively. If it is NULL, reject. Then init_log_blk(blk, src->m_mode) and copy m_dp_rotation_index and m_mode4_index_selector from the source.
Note that reuse copies mode, rotation, and selector — but not the partition index, which is always sent explicitly.
Read only if the mode is partitioned:
-
m_num_partitions == 2: read a byte from blob 3. Reject if>= 64. Store asm_pattern_index. -
m_num_partitions == 3: read a byte from blob 4. Reject if>= (1 << m_pattern_bits)— that is,>= 16for mode 0 and>= 64for mode 2. Store asm_pattern_index. -
m_num_partitions == 1: nothing is read.
The endpoint mode is bits 3-5 of the command byte.
Read from the bit stream, blob 19, LSB-first (§8):
for subset in [0, m_num_partitions):
for c in [0, get_num_comps()):
for e in [0, 2):
m_endpoints[subset][e][c] = read_bits(m_endpoint_bits[c == 3 ? 1 : 0])
for pb in [0, m_num_pbits):
m_pbits[pb] = read_bits(1)Note the loop order: subset, then channel, then endpoint. The p-bit loop runs over m_num_pbits, the total across all subsets, after all endpoints.
Resolve the predictor block and subset:
| mode | predictor block | predictor subset |
|---|---|---|
| 1 | left | 0 |
| 2 | upper | 0 |
| 3 | left_diag | 0 |
| 4 | right_diag | 0 |
| 5 | XY-delta reference (below) | 0 |
| 6 | left | 1 |
| 7 | upper | 1 |
For mode 5, read one byte from blob 20. Reject if >= 32 (the top 3 bits are reserved-zero). Index g_xbc7_xy_deltas (§7.7) with it, compute (bx + dx, by + dy), and reject if that is outside the tile. This is a mandatory check: the encoder never emits a cross-stripe reference, and permitting one would let a hostile stream read another stripe's rows.
Reject if the resolved predictor block is NULL. For modes 6 and 7, additionally reject if the predictor block has fewer than 2 subsets.
Then, per subset:
fine = (m_endpoint_bits[0] >= 6)
num_residuals = get_num_comps() * 2
// mode 6 in a file without alpha: alpha residuals are never transmitted
if (!has_alpha) and (m_mode == 6):
num_residuals = 6
residuals[6] = residuals[7] = 0
for i in [0, num_residuals) step 2:
chan = i >> 1
stream = fine ? (11 + chan) : (15 + chan)
residuals[i+0] = read_byte(stream)
residuals[i+1] = read_byte(stream)
for pb in [0, pbits_per_subset): // NOT m_num_pbits
residual_pbits[pb] = read_bit(blob 10)
endpoint_dpcm_decode(predictor_block, predictor_subset, blk, subset,
residuals, residual_pbits)
if (!has_alpha) and (m_mode == 6):
m_endpoints[0][0][3] = m_endpoints[0][1][3] = 127The fine/coarse split is decided by the RGB endpoint bit count, and applies to all four channels including alpha. In terms of the BC7 mode (not the endpoint mode indexed by the table above): BC7 modes 1, 3, 5, and 6 have m_endpoint_bits[0] >= 6 and use the fine streams; BC7 modes 0, 2, 4, and 7 use the coarse streams.
The mode-6-without-alpha rule is a format rule, not an optimization: the encoder omits the two alpha residual bytes, and the decoder pins both alpha endpoint fields to 127 afterward, overriding whatever the prediction produced. (In a fully opaque image every predictor's alpha expands to 254 or 255, both of which quantize to field value 127 in mode 6's 7-bit-plus-p-bit lattice, so the reconstruction is exact.) Note this asymmetry: the Raw endpoint path in §7.5.1 codes alpha in full for the same block.
endpoint_dpcm_decode(pred_blk, pred_subset, blk, subset, residuals[8], rp[2]):
// 1. Expand the predictor's endpoints to 8-bit RGBA
unpack_endpoints(pred_blk, pred_ep, pred_subset)
// 2. Undo the predictor's dual-plane channel rotation, then apply ours
if pred_blk.is_dual_plane():
i = pred_blk.get_color_component_selector()
swap(pred_ep[0][i], pred_ep[0][3]); swap(pred_ep[1][i], pred_ep[1][3])
if blk.is_dual_plane():
i = blk.get_color_component_selector()
swap(pred_ep[0][i], pred_ep[0][3]); swap(pred_ep[1][i], pred_ep[1][3])
// 3. Quantize the prediction into OUR mode's endpoint domain
pack_endpoints_int(blk.m_mode, pred_ep, packed_ep, packed_pbits)
// 4. Identify the decorrelation-exempt channels
g_channel = 1; a_channel = 3
if blk.is_dual_plane():
ccs = blk.get_color_component_selector()
a_channel = ccs
if ccs == 1: g_channel = 3
// 5. Undo the green decorrelation (wrapping, 8-bit)
t = residuals[0 .. num_comps*2)
for c in [0, num_comps):
if c == g_channel or c == a_channel: continue
t[c*2+0] = (uint8)(t[c*2+0] + t[g_channel*2+0])
t[c*2+1] = (uint8)(t[c*2+1] + t[g_channel*2+1])
// 6. Add, wrapping in the field width
for c in [0, num_comps):
bits = blk.m_endpoint_bits[c == 3 ? 1 : 0]
mask = (1 << bits) - 1
blk.m_endpoints[subset][0][c] = (t[c*2+0] + packed_ep[0][c]) & mask
blk.m_endpoints[subset][1][c] = (t[c*2+1] + packed_ep[1][c]) & mask
// 7. P-bit residuals are parity-added
for p in [0, pbits_per_subset):
blk.m_pbits[subset * pbits_per_subset + p] = (rp[p] + packed_pbits[p]) & 1Step 5 is why R and B are cheaper to code than G: the encoder subtracts the G residual from them. Alpha and the green channel itself are exempt. For dual-plane modes the roles move: the channel occupying the scalar plane takes alpha's exemption, and if that channel is green, then channel 3 takes green's role.
Quantizes an 8-bit RGBA pair into a mode's endpoint domain, choosing p-bits to minimize squared error.
pack_endpoints_int(mode, src[2], dst[2], dst_pbits[2]):
fmt = mode table entry
num_comps = fmt.a_bits ? 4 : 3
dst_pbits[0] = dst_pbits[1] = 0
if fmt.pbits_per_subset == 0:
for e in [0,2), for c in [0,4):
bits = (c == 3) ? fmt.a_bits : fmt.rgb_bits
dst[e][c] = bits ? quant_endpoint(src[e][c], bits) : 0
else if fmt.pbits_per_subset == 1:
determine_shared_pbits_int(num_comps, fmt.rgb_bits, src[0], src[1], dst[0], dst[1], dst_pbits)
else:
determine_unique_pbits_int(num_comps, fmt.rgb_bits, src[0], src[1], dst[0], dst[1], dst_pbits)quant_endpoint(v8, nbits): // no-p-bit modes
maxv = (1 << nbits) - 1
return (v8 * maxv * 2 + 255) / 510
quant_endpoint_pbit(v8, p, iscalep): // p-bit modes; iscalep = (1 << (bits+1)) - 1
k = (v8 * iscalep + 255 - 255*p) / 510
return clamp(k*2 + p, p, iscalep - 1 + p)determine_unique_pbits_int(total_comps, comp_bits, xl[4], xh[4], out_lo, out_hi, out_p[2]):
total_bits = comp_bits + 1
iscalep = (1 << total_bits) - 1
best_err0 = best_err1 = UINT64_MAX
for p in [0, 2):
for c in [0, 4):
xMin[c] = quant_endpoint_pbit(xl[c], p, iscalep)
xMax[c] = quant_endpoint_pbit(xh[c], p, iscalep)
sL[c] = (xMin[c] << (8 - total_bits)); sL[c] |= sL[c] >> total_bits
sH[c] = (xMax[c] << (8 - total_bits)); sH[c] |= sH[c] >> total_bits
err0 = sum over i in [0, total_comps) of (sL[i] - xl[i])^2
err1 = sum over i in [0, total_comps) of (sH[i] - xh[i])^2
if err0 < best_err0: // STRICT: p = 0 wins ties
best_err0 = err0; out_p[0] = p
for j in [0,4): out_lo[j] = xMin[j] >> 1
if err1 < best_err1:
best_err1 = err1; out_p[1] = p
for j in [0,4): out_hi[j] = xMax[j] >> 1The shared-p-bit variant (BC7 mode 1 only) scores a single p-bit against both endpoints jointly:
determine_shared_pbits_int(total_comps, comp_bits, xl[4], xh[4], out_lo, out_hi, out_p[2]):
total_bits = comp_bits + 1
iscalep = (1 << total_bits) - 1
best_err = UINT64_MAX
for p in [0, 2):
for c in [0, 4):
xMin[c] = quant_endpoint_pbit(xl[c], p, iscalep)
xMax[c] = quant_endpoint_pbit(xh[c], p, iscalep)
sL[c] = (xMin[c] << (8 - total_bits)); sL[c] |= sL[c] >> total_bits
sH[c] = (xMax[c] << (8 - total_bits)); sH[c] |= sH[c] >> total_bits
err = sum over i in [0, total_comps) of ((sL[i] - xl[i])^2 + (sH[i] - xh[i])^2)
if err < best_err: // STRICT: p = 0 wins ties
best_err = err
out_p[0] = out_p[1] = p
for j in [0,4):
out_lo[j] = xMin[j] >> 1
out_hi[j] = xMax[j] >> 1The difference from the unique variant is structural, not cosmetic: here a single error sum covers both endpoints, so one p-bit is chosen for the pair and written to both outputs, and both endpoints' quantized values are taken from that same winning iteration.
The strict < is load-bearing. Exact ties are common rather than exotic — for mode 1's comp_bits = 6, roughly a fifth of uniformly-distributed endpoint pairs tie — and a decoder using <= would select p = 1 on every one of them. That flips both the reconstructed p-bit and the packed prediction the residuals are added to, corrupting the block and everything that later predicts from it.
Two things to note. First, the two endpoints choose their p-bits independently in the unique case. Second, the p-bit path quantizes alpha at comp_bits — the RGB width — not at the alpha width. That is correct only because every p-bit-carrying mode has equal RGB and alpha widths (modes 0/1/3 have no alpha; mode 6 is 7/7; mode 7 is 5/5), while the modes where they differ (4 is 5/6, 5 is 7/8) carry no p-bits and take the first branch, which does distinguish c == 3.
Every full-block command (3..7) reads one byte from blob 5, for both weight modes:
cand_index = pred_byte % 50
amp_code = pred_byte / 50
Reject if pred_byte >= 200. Reject if amp_code != 0 while cand_index == 0 (there is no prediction to scale).
cTotalCandidates is 50: candidate 0 is Absolute, 1..17 are synthetic predictors, and 18..49 are the 32 XY-delta block copies.
For each plane p in [0, m_num_planes), if cand_index != 0, evaluate the predictor (§7.7) to obtain 16 predicted weight values in [0,64]. If evaluation fails — any required neighbor missing or outside the tile — reject the stream. If cand_index == 0 there is no prediction, and the reconstruction uses 0 for every predicted value.
Note that the Absolute predictor also selects a different stream in the DPCM path (blob 21 rather than 23..25) and suppresses the DC sign bit in the DCT path. Neither is keyed on whether a prediction array happens to be present. The DPCM stream choice tests cand_index == 0; the DC sign tests the joint byte, pred_byte != 0 (§7.9). Those two tests are equivalent only because amp_code != 0 with cand_index == 0 was already rejected above — a decoder that drops that rejection will misread the sign stream.
The predictor is evaluated per plane, so a dual-plane block evaluates it twice, once for each plane's weights.
All predictors read dequantized weights in [0,64] from already-decoded blocks:
fetch_w(blk, plane, w):
sp = blk.is_dual_plane() ? plane : 0
return dequant_weight(blk.m_weights[sp][w], blk.m_weight_bits[sp])A single-plane neighbor therefore supplies the same plane-0 weights to both planes of a dual-plane block.
Every predictor has required neighbors; if any is missing (outside the tile), evaluation fails and the stream MUST be rejected.
| # | name | requires | source block |
|---|---|---|---|
| 0 | Absolute | – | none |
| 1 | LeftEdge | left | left |
| 2 | UpperEdge | upper | upper |
| 3 | LUBlend | left, upper | left |
| 4 | ReflectLeft | left | left |
| 5 | ReflectUpper | upper | upper |
| 6 | LUAvg | left, upper | left |
| 7 | LUBlendStrong | left, upper | left |
| 8 | Gradient | left, upper, left_diag | left |
| 9 | GradientDamped | left, upper, left_diag | left |
| 10 | DiagAvg | left_diag, right_diag | left_diag |
| 11 | DiagEdgeBlend | left_diag, right_diag | left_diag |
| 12 | UpperDiagEdgeBlend | upper, left_diag, right_diag | left_diag |
| 13 | MED | left, upper, left_diag | left |
| 14 | GAB | left, upper, left_diag | left |
| 15 | PlaneFit | left, upper | left |
| 16 | DDL | upper, right_diag | upper |
| 17 | DDR | left, upper, left_diag | left |
| 18..49 | XY-delta copy | referenced block in tile | that block |
The "source block" column is the block whose 16 weights are loaded into the working array w[] before the per-predictor transform below; orig[] denotes that initial copy.
Let L[y] = orig[index_from_xy(3, y)] (the left block's right edge), U[x] = fetch_w(upper, p, index_from_xy(x, 3)) (the upper block's bottom edge), and C = fetch_w(left_diag, p, index_from_xy(3, 3)) (the upper-left block's bottom-right corner).
0 — Absolute. No prediction.
1 — LeftEdge. w[x,y] = orig[3,y].
2 — UpperEdge. w[x,y] = orig[x,3].
3, 6, 7 — LUBlend / LUAvg / LUBlendStrong. With l = L[y], u = U[x]:
LUBlend: wl = 4-x, wu = 4-y, den = wl+wu, pred = (wl*l + wu*u + den/2) / den
LUAvg: pred = (l + u + 1) >> 1
LUBlendStrong: wl = (4-x)^2, wu = (4-y)^2, den = wl+wu, pred = (wl*l + wu*u + den/2) / den
4 — ReflectLeft. w[x,y] = orig[3-x, y].
5 — ReflectUpper. w[x,y] = orig[x, 3-y].
8, 9, 13, 14 — Gradient / GradientDamped / MED / GAB. With l = L[y], u = U[x]:
Gradient: pred = clamp(l + u - C, 0, 64)
GradientDamped: grad = clamp(l + u - C, 0, 64)
wl = 4-x, wu = 4-y, den = wl+wu
blend7 = (wl*l + wu*u + den/2) / den
pred = (grad + blend7 + 1) >> 1
MED: mn = min(l,u), mx = max(l,u)
if C >= mx: pred = mn
elif C <= mn: pred = mx
else: pred = l + u - C
pred = clamp(pred, 0, 64)
GAB: wl = |l - C| + 1, wu = |u - C| + 1, den = wl + wu
pred = (wl*l + wu*u + den/2) / den
10 — DiagAvg. w[i] = (orig[i] + fetch_w(right_diag, p, i) + 1) >> 1 for all 16 texels.
11 — DiagEdgeBlend. With l = orig[3,y] (upper-left block's right edge) and r = fetch_w(right_diag, p, index_from_xy(0, y)) (upper-right block's left edge):
w[x,y] = ((3-x)*l + x*r + 1) / 3
12 — UpperDiagEdgeBlend. Blends the upper edge with the same lateral diagonal interpolation:
diag = ((3-x)*orig[3,y] + x*fetch_w(right_diag, p, index_from_xy(0,y)) + 1) / 3
wu = 4-y, wd = 1+y, den = 5
w[x,y] = (wu*U[x] + wd*diag + 2) / 5
15 — PlaneFit. Least-squares plane through the left and upper edges:
sum_u = U[0]+U[1]+U[2]+U[3]
sum_l = L[0]+L[1]+L[2]+L[3]
gx10 = -3*U[0] - U[1] + U[2] + 3*U[3]
gy10 = -3*L[0] - L[1] + L[2] + 3*L[3]
base = 5 * (sum_u + sum_l)
num = base + gx10*(4x - 1) + gy10*(4y - 1)
t = num + 20
pred = (t >= 0) ? (t / 40) : -((-t + 39) / 40) // floor division, not truncation
w[x,y] = clamp(pred, 0, 64)
The explicit floor is required: num can be negative when the slopes are, and C-style / truncates toward zero.
16 — DDL (diagonal down-left). Build an 8-entry extended top row from the upper block's bottom edge and the upper-right block's bottom edge:
T[x] = fetch_w(upper, p, index_from_xy(x,3)) for x in [0,4)
T[4+x] = fetch_w(right_diag, p, index_from_xy(x,3)) for x in [0,4)
d = x + y // 0..6
if d == 6: w[x,y] = (T[6] + 3*T[7] + 2) >> 2
else: w[x,y] = (T[d] + 2*T[d+1] + T[d+2] + 2) >> 2
17 — DDR (diagonal down-right). Build a 9-entry array from the left column (bottom to top), the corner, and the top row:
A[3-y] = orig[index_from_xy(3, y)] for y in [0,4)
A[4] = fetch_w(left_diag, p, index_from_xy(3,3))
A[5+x] = fetch_w(upper, p, index_from_xy(x,3)) for x in [0,4)
d = 4 + x - y // 1..7
w[x,y] = (A[d-1] + 2*A[d] + A[d+1] + 2) >> 2
18..49 — XY-delta copy. delta = g_xbc7_xy_deltas[cand_index - 18]; the referenced block is (bx + dx, by + dy). If it is outside the tile, evaluation fails. Otherwise w[i] = fetch_w(ref, p, i) for all 16 texels — a plain copy, with no per-candidate transform. (The amplitude code of §7.7.1 still applies afterward, as it does to every candidate.)
struct { int8_t dx, dy; } g_xbc7_xy_deltas[32] = {
{-1, 0}, {-2, 0}, {-3, 0}, {-4, 0},
{ 3,-1}, { 2,-1}, { 1,-1}, { 0,-1}, {-1,-1}, {-2,-1}, {-3,-1}, {-4,-1},
{ 3,-2}, { 2,-2}, { 1,-2}, { 0,-2}, {-1,-2}, {-2,-2}, {-3,-2}, {-4,-2},
{ 3,-3}, { 2,-3}, { 1,-3}, { 0,-3}, {-1,-3}, {-2,-3}, {-3,-3}, {-4,-3},
{ 3,-4}, { 2,-4}, { 1,-4}, { 0,-4}
};Every entry is causal by construction: dy < 0, or dy == 0 and dx < 0. This is the same table used by endpoint mode 5.
If amp_code != 0, the 16 predicted values are transformed about their own mean. This scales the prediction's AC content while leaving its DC to the DC coefficient, which is why it is not a plain complement.
sum = sum of the 16 predictions
mean = (sum + 8) >> 4
amp_code == 1: v = clamp(2*mean - w, 0, 64) // flip about the mean
amp_code == 2: v = (w + mean + 1) >> 1 // half contrast
amp_code == 3: f = clamp(2*mean - w, 0, 64) // flip, then half contrast
v = (f + mean + 1) >> 1The mean is computed from the predictions before any transform, and the transform is applied to all 16 values.
Per plane:
n = m_weight_bits[p]
mask = (1 << n) - 1
stream = (cand_index == 0) ? blob 21 : blob (23 + n - 2)Read 16 symbols, byte-packed LSB-first:
-
n == 2: 4 symbols per byte —b&3,(b>>2)&3,(b>>4)&3,b>>6. 4 bytes per plane. -
n == 3orn == 4: 2 symbols per byte —b&0xFthenb>>4. 8 bytes per plane. Forn == 3, both nibbles MUST be <= 7; bit 3 of each nibble is reserved-zero and a nonzero value MUST be rejected.
Then reconstruct:
for i in [0,16):
pred_index = have_prediction ? quant_weight(predictions[i], n) : 0
m_weights[p][i] = (syms[i] + pred_index) & maskThe prediction is re-quantized to the plane's bit depth before the modular add, so this is exact in both directions. With the Absolute predictor, pred_index is 0 and the stream carries the raw indices.
Each plane occupies a whole number of bytes, so planes never straddle a byte boundary.
Per plane:
DC. Read one byte from blob 6 — the magnitude. If pred_byte != 0 (i.e. the predictor is not Absolute), read one sign bit from blob 9; if set, negate. With the Absolute predictor the DC is unsigned by construction and no sign bit is present.
ACs. Loop with a zig-zag cursor zig_idx starting at 1:
while zig_idx < 16:
b = read_byte(blob 8)
if b == 0xFF:
emit EOB (run = 16 - zig_idx, coeff = END)
break
run = b
if zig_idx + run > 15: reject // a real coefficient must land at index <= 15
mag = read_byte(blob 8)
if mag == 0: reject // zero coefficients are never coded
sign = read_bit(blob 9)
emit (run zeros, then coeff = sign ? -mag : +mag)
zig_idx += run + 10xFF is the end-of-block marker: it means "all remaining coefficients in the scan are zero". It is unambiguous because a legal run can never exceed 15.
Inverse transform. See §8.2. The reconstructed weights are:
m_weights[p][i] = quant_weight(clamp(round(idct[i] + prediction[i]), 0, 64), n)with prediction[i] = 0 when the predictor is Absolute.
Bit streams (blobs 9, 10, 19) are LSB-first: the first bit written occupies bit 0 of byte 0, and multi-bit values may cross byte boundaries. To read n bits at bit offset o:
byte_idx = o >> 3
bit_idx = o & 7
gather ceil((bit_idx + n) / 8) bytes little-endian into a 64-bit accumulator
value = (acc >> bit_idx) & ((1 << n) - 1)
o += nA read that would pass the stripe's end offset MUST fail (and therefore reject the stream).
Guard the range before subtracting. The natural way to write that test — and the way the reference writes it — is if (n > (end_bit - bit_ofs)) return false; with both operands unsigned. If a malformed seek table ever yields start > end (§5.3), that subtraction underflows to an enormous value, the test passes for every read, and the multi-byte gather above walks straight off the end of the blob — for as many bits as the block loop asks for, which on a large image is a multi-megabyte sequential over-read. A decoder MUST therefore either validate start <= end when seeking a cursor, or write the test in a form that cannot underflow. The same applies to the "fully consumed" test in §5.5.
Byte cursors are incidentally safe from this, because the natural formulation (offset >= end) compares rather than subtracts — but only incidentally. Guard both.
XUBC7 uses a 4x4 orthonormal DCT-II (forward) / DCT-III (inverse) in Q15.16 fixed point. Only the inverse is needed to decode.
fixed16_16 is a signed 32-bit value with 16 fractional bits; ONE = 65536. Products accumulate as raw int64 and are rounded once per output (from_sum), not after each multiply. This is what makes the transform bit-identical regardless of evaluation order — int64 addition is associative where float addition is not.
Rounding convention. Every rounding operation in this specification — from_sum, the fixed-point multiply and divide, round_to_int, and mul_round_to_int — rounds half away from zero. This is not incidental: IDCT outputs and butterfly intermediates are routinely negative, so half-away-from-zero, half-up, and banker's rounding all produce different weights. A decoder using a different convention will not be bit-exact.
The one exception is the integer square root in get_max_span_len (§8.2.2), which rounds to nearest with ties rounding down.
from_sum converts a Q32 int64 accumulator back to Q15.16 by rounding right-shift of 16 bits, half away from zero. mul_round_to_int (§8.2.2) shifts the same Q32 product right by 32 to land on an integer.
The 4x4 case uses a radix-2 butterfly whose constants are the exact Q15.16 quantizations of the general table entries:
HALF = 32768 // 0.5
C1 = 42813 // cos(pi/8)/sqrt(2) ~ 0.653281
C3 = 17734 // cos(3pi/8)/sqrt(2) ~ 0.270598
inverse_ortho(y[4], x[4]): // 1-D, int64 partial sums, one rounding per output
b0 = y[0]*HALF + y[2]*HALF
b1 = y[0]*HALF - y[2]*HALF
t0 = y[1]*C1 + y[3]*C3
t1 = y[1]*C3 - y[3]*C1
x[0] = round(b0 + t0); x[3] = round(b0 - t0)
x[1] = round(b1 + t1); x[2] = round(b1 - t1)The 2-D inverse applies inverse_ortho down each of the 4 columns, then across each of the 4 rows.
(Informative) These constants are generated at compile time from an integer Q30 cosine (range reduction plus a nested Taylor series) and an exact integer square root, rather than from cosf/sqrtf, precisely so no libm difference can perturb them. Debug builds hash the generated tables for all supported sizes against a golden FNV-1a constant.
The base 4x4 quantization matrix (natural order, index x + y*4):
1.0 3.5 24.0 51.0
3.5 12.0 40.0 78.0
24.0 40.0 68.0 103.0
51.0 78.0 103.0 120.0
It is symmetric — the "rotationally invariant" property — and stored as fixed16_16.
The per-block scale depends on the global quality and the block's endpoint span:
get_max_span_len(blk, plane):
if blk.is_dual_plane():
unpack_endpoints(blk, ep, 0) // dual-plane modes have 1 subset
ssq = sum over c in [0,4) where get_endpoint_channel_weight_plane(c) == plane
of (ep[1][c] - ep[0][c])^2
else:
ssq = 0
for s in [0, blk.m_num_partitions):
unpack_endpoints(blk, ep_s, s) // per subset
ssq = max(ssq, sum over c in [0,4) of (ep_s[1][c] - ep_s[0][c])^2)
return isqrt_q16(ssq)Note the asymmetry: the dual-plane branch sums over the channels belonging to this plane, while the single-plane branch takes the maximum over subsets of each subset's full 4-channel sum. Endpoints here are the 8-bit expanded values from §1.3, so ssq <= 4 * 255^2 = 260100.
The square root produces a Q15.16 value and is computed on the scaled input, not on ssq directly:
isqrt_q16(ssq):
x = (uint64)ssq << 32 // scale first: the result is Q15.16
f = isqrt_floor(x) // largest f with f*f <= x
f += (x - f*f > f) // round to nearest; TIES ROUND DOWN
return raw_q16(f)Computing isqrt(ssq) and then converting to Q15.16 is wrong — it discards the fractional bits and yields a different adaptive_factor, hence a different quantization table.
compute_level_scale(q, span_len, n): // n = weight bits
q = clamp(q, 1, 100)
level_scale = (q < 50) ? (5000 / q) : (200 - 2*q)
level_scale = level_scale / 100
adaptive = 64 / max(span_len, 14)
adaptive = adaptive * g_scale_quant_steps[n - 2]
return level_scale * adaptive
g_scale_quant_steps[3] = { 1.35588217, 1.24573100, 1.15431654 } // n = 2, 3, 4
// raw Q15.16: 88859, 81640, 75649All of this is fixed16_16 arithmetic, including the divisions.
compute_quant_table(q, level_scale, tab[16]):
tab[0] = 1
if q >= 100:
tab[i] = 1 for all i; return
for y in [0,4):
for x in (y ? y : 1) .. 3: // upper triangle, mirrored
s = mul_round_to_int(g_base_quant[x + y*4], level_scale)
s = max(1, s)
if (x + y) == 1: s = min(s, 73) // caps AC(1,0) and AC(0,1)
tab[x + y*4] = s
tab[y + x*4] = sThe cap applies to the two first-order AC coefficients — (1,0) and (0,1) — not to (1,1).
mul_round_to_int(a, b) is not round_to_int(a * b). It forms the exact int64 product of the two raw values and rounds that to an integer once, with no fixed-point intermediate:
mul_round_to_int(a, b):
p = (int64)a.raw * (int64)b.raw // Q32, exact
return rounded_rshift(p, 32) // half away from zeroUsing the fixed-point multiply and then rounding is wrong twice over. It double-rounds — operator* already rounds the product down to Q15.16 — and, more seriously, it overflows: at q = 1 with a low-contrast block (span_len <= 14), level_scale reaches roughly 310, so base = 120 gives about 37200, well past Q15.16's range of ±32767.99. The reference asserts on this in debug builds.
The DC uses a plain uniform quantizer with a step that depends on the plane's weight depth:
get_dc_quant(n) = 1 << (6 - n) // n = 2 -> 16, n = 3 -> 8, n = 4 -> 4
dct[0] = dc * get_dc_quant(n) // as fixed16_16, from_intBuild-alike warning. The reference implementation gates this behind two compile-time flags,
g_xbc7_quantize_dcandg_xbc7_dc_quant_per_weight_bits, bothtruein v2.50. They are not signalled in the stream. A decoder MUST implement the behavior above (both flags true) to interoperate with v2.50 encoders.
The ACs use a dead-zone quantizer, except for the two first-order coefficients, which use plain scaling:
dequant(q, L, x, y):
if (x,y) == (1,0) or (0,1):
return sat(q * L * ONE) // no dead zone; raw Q15.16
if q == 0 or L <= 0:
return 0
aq = |q|
mag = alpha.raw * L + aq * L * ONE // alpha = 0.5; see width note below
return sat(q < 0 ? -mag : +mag)alpha is DEADZONE_ALPHA_FIXED = 0.5 (raw 32768). The reconstruction point is the center of the nonzero bin, tau + |q|*L with tau = alpha*L.
Both branches return a raw Q15.16 value, not an integer. sat() takes a raw quantity, which is why the * ONE appears in both — the first branch scales q * L into Q15.16, and the second is already there by construction (alpha.raw is 32768, and aq * L is scaled explicitly). Dropping * ONE from the first branch divides the two lowest-frequency ACs by 65536. The largest value that survives is 255 * 73 raw, about 0.28 in Q15.16, contributing at most ~0.09 per texel through the IDCT — so it rounds away in round(idct + pred) and those coefficients are effectively lost. Those are the most frequently non-zero coefficients in the stream — the very reason they are singled out for dead-zone exemption — and the error is silent: no cursor desyncs and no §10 check fires, so the stream is accepted and the image is simply wrong.
The first branch's product also stays inside 32 bits, but only just, and only because of the 73 cap of §8.2.2: 255 * 73 * 65536 ≈ 1.22 × 10⁹ against a limit of 2.15 × 10⁹. That is a coincidence of the cap's value, not a property of the format — compute it in 64 bits alongside the second branch.
The second branch's product needs at least 40 bits and MUST be computed in a signed 64-bit type. It is reachable from a legal-to-parse stream, not just a hostile one: at m_dct_q = 1 with a low-contrast block, L at the highest-frequency coefficient reaches roughly 37,000 — the 73 cap applies only to (1,0) and (0,1) — so with an AC magnitude byte of 255, aq * L * ONE is about 6.2 × 10¹¹. In 32 bits that is signed overflow, and sat() clamping the resulting garbage does not make it defined.
sat() saturates to ±2048 in Q15.16. Legal streams never approach this — a nonzero coefficient requires |d| > tau, bounding the result near ±768 — but a decoder MUST saturate rather than wrap or trap, so that hostile input stays inside the transform's safe range.
q throughout §8.2 is the header's m_dct_q converted to a fixed16_16 integer value — from_int(m_dct_q), i.e. raw m_dct_q << 16 — not a scaled or normalized quality.
inverse(q, plane, predictions, syms, blk):
span = get_max_span_len(blk, plane)
scale = compute_level_scale(q, span, blk.m_weight_bits[plane])
compute_quant_table(q, scale, tab)
dct[0..15] = 0
dct[0] = from_int(syms.dc * get_dc_quant(blk.m_weight_bits[plane]))
zig_idx = 1
for each (run, coeff) in syms.ac:
if run + zig_idx > 16: return false
zig_idx += run
if zig_idx >= 16: break
if coeff == END: return false // EOB may only terminate via zig_idx >= 16
x = g_zigzag[zig_idx][0]; y = g_zigzag[zig_idx][1]
dct[x + y*4] = dequant(coeff, tab[x + y*4], x, y)
zig_idx += 1
idct = inverse_2d(dct)
for i in [0,16):
pred = predictions ? predictions[i] : 0
blk.m_weights[plane][i] = quant_weight(clamp(round(idct[i] + pred), 0, 64), n)The zig-zag scan order, as (x, y) pairs:
g_zigzag4x4_xy[16][2] = {
{0,0}, {1,0}, {0,1}, {0,2}, {1,1}, {2,0}, {3,0}, {2,1},
{1,2}, {0,3}, {1,3}, {2,2}, {3,1}, {3,2}, {2,3}, {3,3}
};This is the classical zig-zag scan restricted to a 4x4 grid — anti-diagonals traversed in alternating direction — written as (x, y) pairs rather than the (row, col) form JPEG descriptions usually use. The natural-order index is x + y*4.
The decoder MUST be total over the DC magnitude: any byte 0..255 is accepted, and the sat() bound plus the transform's gain keep every intermediate inside Q15.16.
SolidDPCM (command 2) codes a block as a single color, predicted from the decoded pixels of its neighbors.
The prediction is implicit — nothing in the stream identifies which neighbors were used. Encoder and decoder must agree exactly, which is why the stripe count is in the header: the encoder could not see across a stripe seam, so the decoder must not either.
preds[4] = {0,0,0,0}; num_preds = 0
if left: // left block's right edge column
for y in [0,4):
px = decode_texel(left, 3, y) // standard BC7 texel decode
preds[c] += px[c] for each c
num_preds += 4
if upper: // upper block's bottom edge row
for x in [0,4):
px = decode_texel(upper, x, 3)
preds[c] += px[c] for each c
num_preds += 4
if num_preds:
for c in [0,4): preds[c] = (preds[c] + num_preds/2) / num_preds
for c in [0, has_alpha ? 4 : 3):
delta = read_byte(blob 22)
color[c] = (uint8)(delta + preds[c]) // wrapping
if !has_alpha: color.a = 255
create_solid_blk(blk, color)upper is already NULL above a stripe's first row, so the seam clamp falls out of the tile test. When neither neighbor exists (the first block of a stripe), num_preds is 0 and the prediction is zero, so the deltas are the color itself.
decode_texel is ordinary BC7 texel reconstruction, but it is specified here rather than delegated to the BC7 format documentation. This is the one place where pixel decoding feeds back into bitstream reconstruction: its output becomes a solid block's color, and that block is then a legal prediction source for every later block. A one-LSB error here corrupts the stream, not merely the image. The MS documentation also describes decoding a physical block, whereas what is needed is the mapping from this document's log_bc7_block — which is exactly where an independent implementation goes wrong.
decode_texel(blk, x, y) -> color_rgba:
i = x + y*4
// 1. Subset from the standard BC7 partition tables
subset = 0
if blk.m_num_partitions == 2: subset = g_bc7_partition2[blk.m_pattern_index*16 + i]
if blk.m_num_partitions == 3: subset = g_bc7_partition3[blk.m_pattern_index*16 + i]
unpack_endpoints(blk, ep, subset) // §1.3
// 2. RGB always interpolates with the plane the mode 4 selector names
vp = blk.m_mode4_index_selector // 0 for every mode except mode 4
for c in [0,3):
res[c] = bc7_interp(ep[0][c], ep[1][c], blk.m_weights[vp][i], blk.m_weight_bits[vp])
// 3. Alpha
res.a = 255
if blk.get_num_comps() == 4:
if blk.m_num_planes == 2: // modes 4, 5: the other plane
sp = 1 - blk.m_mode4_index_selector
res.a = bc7_interp(ep[0][3], ep[1][3], blk.m_weights[sp][i], blk.m_weight_bits[sp])
else: // modes 6, 7: same plane as RGB
res.a = bc7_interp(ep[0][3], ep[1][3], blk.m_weights[0][i], blk.m_weight_bits[0])
// 4. Dual-plane channel rotation, applied AFTER interpolation
if blk.m_dp_rotation_index:
swap(res[3], res[blk.m_dp_rotation_index - 1])
return resbc7_interp(l, h, w, bits):
W = dequant_weight(w, bits) // §1.2
return (l * (64 - W) + h * W + 32) >> 6g_bc7_partition2 and g_bc7_partition3 are the standard BC7 partition tables — 64 patterns each, 16 subset indices per pattern in raster order. See §1.4 for where to obtain them and which invariants to assert after transcribing.
Two subtleties worth stating explicitly, because both are easy to get wrong:
- The struct always stores the 2-bit index set in plane 0 and the 3-bit set in plane 1, regardless of
m_mode4_index_selector. The selector chooses which plane RGB reads from; it does not reorder the sets. An implementation that instead stores "index set as read from the block" will swap mode 4's weights. - The rotation in step 4 is applied to the decoded texel, after interpolation — not to the endpoints. Note the contrast with §7.5.3, where the endpoint path applies its channel swap to the endpoints before quantizing. The two are different operations on different data.
The resulting block MUST be constructed exactly as follows, because later blocks may predict from it:
create_solid_blk(blk, c):
init_log_blk(blk, 5) // mode 5: 1 subset, 2 planes, 7-bit RGB, 8-bit A, no p-bits
for ch in [0,3):
blk.m_endpoints[0][0][ch] = g_bc7_mode_5_optimal_endpoints[c[ch]].lo
blk.m_endpoints[0][1][ch] = g_bc7_mode_5_optimal_endpoints[c[ch]].hi
blk.m_endpoints[0][0][3] = blk.m_endpoints[0][1][3] = c.a // 8-bit, exact
memset(blk.m_weights[0], 1, 16) // plane 0 (RGB, 2-bit): index 1 everywhere
// plane 1 (alpha, 2-bit): all 0, from init_log_blkThe RGB endpoint table is derived, not baked. It is a 256-entry table that a decoder MUST compute once at initialization — the search below is 4.2 million iterations, and create_solid_blk is a per-block operation, so evaluating it inline would hang on any real image. For each 8-bit target c, the entry is the (lo, hi) pair in mode 5's 7-bit space whose interpolation at weight index 1 best reproduces c:
for c in [0, 256):
best_err = UINT16_MAX
for l in [0, 128): // ascending
low = (l << 1) | (l >> 6)
for h in [0, 128): // ascending
high = (h << 1) | (h >> 6)
k = (low * (64 - 21) + high * 21 + 32) >> 6 // 21 = g_bc7_weights2[1]
err = (k - c)^2
if err < best_err: // STRICT: lowest l, then lowest h, wins ties
best_err = err; lo = l; hi = hThe iteration order and strict comparison are normative — several targets have ties, and a different tie-break yields a different logical block, which would then propagate through any prediction referencing it.
A conforming decoder MUST reject a stream on any of the following. This list is exhaustive for the per-block path of the blob-container form. Rejections outside that path live in: §2 (unknown leading byte, zero-length payload), §3 (the twelve container rules), §4 (blob 0's presence and exact 7-byte size, the seven header validations, and the structural gate), §5.3 (the seek table's size, its stripe-0 delta, and its running-offset bound), and §11 (the tiny-mip form).
| check | § |
|---|---|
| command byte bit 7 nonzero | 7.1 |
| simple command (0..2) with nonzero endpoint/weight-mode bits | 7.1 |
| RepeatLast with no left neighbor; RepeatUpper with no upper neighbor | 7.2 |
| config byte bits 6-7 nonzero | 7.3 |
| nonzero rotation on a single-plane mode | 7.3 |
| nonzero index selector on a mode other than 4 | 7.3 |
| ReuseConfig naming a neighbor outside the tile | 7.3 |
| 2-subset partition index >= 64 | 7.4 |
3-subset partition index >= 1 << m_pattern_bits
|
7.4 |
| endpoint block-index byte >= 32 | 7.5.2 |
| endpoint block-index resolving outside the tile | 7.5.2 |
| endpoint DPCM predictor block missing | 7.5.2 |
| endpoint mode 6 or 7 whose predictor has < 2 subsets | 7.5.2 |
| weight predictor byte >= 200 | 7.6 |
| nonzero amplitude code with the Absolute predictor | 7.6 |
| weight predictor whose required neighbors are missing | 7.7 |
| 3-bit DPCM weight nibble > 7 | 7.8 |
| AC run placing a coefficient past zig-zag index 15 | 7.9 |
| AC magnitude byte == 0 | 7.9 |
| any stream read past the stripe's end offset | 8.1 |
| any stream not fully consumed at end of stripe | 5.5, 8.1 |
The reference decoder contains two further failure paths that this table omits because no valid or invalid stream can reach them: a neighbor texel decode failing during solid-block prediction (the neighbor is inside the tile, hence already decoded with a valid mode), and the inverse DCT's own two internal rejections (the AC parser in §7.9 has already enforced both conditions). An implementation may keep them as assertions.
Rejection means failing the decode. A decoder MUST NOT clamp, substitute, or otherwise repair a malformed stream — doing so would make it disagree with the reference decoder about which streams are valid, and can silently change the reconstruction of every subsequent block.
For the smallest mip levels the blob container's fixed overhead can exceed a raw BC7 encoding, so the encoder emits a raw form instead. The choice is purely by size: whichever of the two is smaller.
[u8 marker] 0xB8 = no alpha, 0xB9 = has alpha
[u8 num_blocks_x] > 0
[u8 num_blocks_y] > 0
[16 bytes per block] standard packed BC7, raster order
Reject a payload shorter than 3 bytes before reading the block counts — otherwise evaluating the length rule below requires reading bytes 1 and 2 that may not exist, and a one-byte payload of {0xB8} becomes a two-byte over-read. Section 2's zero-length check is not sufficient here, and §3's 3-byte minimum is scoped to the blob-container form.
Then: both block counts MUST be nonzero, and the payload length MUST be exactly 3 + num_blocks_x * num_blocks_y * 16 — compute that product in 64 bits.
Decoding is a straight unpack_bc7 of each physical block, in raster order. There is no prediction, no DCT, and no compression.
This is the inverse of the standard BC7 bit layout, and it is specified here rather than delegated, for the same reason decode_texel is (§9): conformance is measured on the resulting log_bc7_block, so every field order and bit width is normative.
Bits are read LSB-first across the 16 bytes: bit 0 is the low bit of byte 0, and a field may cross one byte boundary. Call this fetch(n), advancing a cursor initialized as described below.
Two bounds are mandatory here, and both bite on well-formed blocks, not just hostile ones:
-
Gather only the bytes you need. Read
ceil(((cursor & 7) + n) / 8)bytes, exactly as §8.1 specifies for the stripe bit streams. A naive unconditional two-byte gather reads byte 16 of every 16-byte block, because every mode's final field starts in bits [120,128). That is a one-byte over-read on every block of every tiny mip, and since the tiny-mip path aliases the caller's payload buffer (§3), it runs past the caller's allocation. -
Bound the cursor at 128. A
fetchthat would carry the cursor past 128 MUST fail. Every mode consumes exactly 128 bits when the tables below are correct, so this can only fire on an implementation error — which is precisely why it is worth keeping.
Mode. The mode is unary-coded in the low bits: m zero bits followed by a 1. So the mode is the number of trailing zero bits in byte 0, and the bit cursor starts at mode + 1.
A block whose entire first byte is zero has no mode. XUBC7 rejects such a block, failing the whole slice. Note this is an XUBC7 payload rule, not BC7 decode behavior: the BC7 standard reserves the all-zero low byte and BC7 hardware returns a block of all zeroes (transparent black) rather than signalling an error. A general-purpose BC7 unpacker should follow the standard; a tiny-mip payload containing such a block is malformed and must not decode.
Set the fixed per-mode fields from the §1.1 table, then read:
Modes 0 and 2 (3 subsets):
pattern_index = fetch(pattern_bits) // 4 bits mode 0, 6 bits mode 2
for c in [0,3): for s in [0,3): for e in [0,2): // CHANNEL-major
m_endpoints[s][e][c] = fetch(endpoint_bits[0])
for p in [0, m_num_pbits): m_pbits[p] = fetch(1) // 6 for mode 0, none for mode 2
for i in [0,16):
reduced = (i == 0) or (i == anchor3_1[pattern_index]) or (i == anchor3_2[pattern_index])
m_weights[0][i] = fetch(reduced ? weight_bits[0]-1 : weight_bits[0])
Modes 1, 3 and 7 (2 subsets):
pattern_index = fetch(6)
num_comps = (mode == 7) ? 4 : 3
for c in [0,num_comps): for s in [0,2): for e in [0,2): // CHANNEL-major
m_endpoints[s][e][c] = fetch(endpoint_bits[0]) // note: mode 7 alpha is also 5 bits
for p in [0, m_num_pbits): m_pbits[p] = fetch(1) // 2 shared (mode 1) or 4 unique
for i in [0,16):
reduced = (i == 0) or (i == anchor2[pattern_index])
m_weights[0][i] = fetch(reduced ? weight_bits[0]-1 : weight_bits[0])
Modes 4 and 5 (dual plane):
m_dp_rotation_index = fetch(2)
m_mode4_index_selector = (mode == 4) ? fetch(1) : 0 // mode 5 has no selector bit
for c in [0,4): for e in [0,2):
m_endpoints[0][e][c] = fetch(endpoint_bits[c == 3 ? 1 : 0])
for plane in [0,2): // PLANE-major
for i in [0,16):
m_weights[plane][i] = fetch(i == 0 ? weight_bits[plane]-1 : weight_bits[plane])
Mode 6:
for c in [0,4):
m_endpoints[0][0][c] = fetch(7) // lo then hi, per channel
m_endpoints[0][1][c] = fetch(7)
for p in [0,2): m_pbits[p] = fetch(1)
for w in [0,16): m_weights[0][w] = fetch(w == 0 ? 3 : 4)
Every mode consumes exactly 128 bits.
Two traps worth calling out, because both silently corrupt rather than fail:
-
The physical endpoint order is channel-major —
c, then subset, then endpoint. This is the opposite nesting from §7.5.1's raw endpoint stream, which is subset-major. §7.5.1 is the only endpoint ordering stated elsewhere in this document; reusing it here produces wrong endpoints for every 2- and 3-subset block. -
Anchor indices are stored with one bit fewer. Weight index 0 always is, and so is each subset's anchor index.
anchor2,anchor3_1andanchor3_2are the standard BC7 anchor-index tables (64 entries each, indexed by partition pattern). See §1.4 for where to obtain them.
The "exactly 128 bits" property depends on two invariants of those anchor tables: no entry is 0, and for every pattern anchor3_1[p] != anchor3_2[p]. Together they guarantee the reduced-width index count is always exactly 2 for a 2-subset mode (index 0 plus one anchor) and 3 for a 3-subset mode. Both hold for the real tables, but a single mistranscribed entry — a 0, or a duplicated pair — silently changes the block's bit length to 129 or 130 with no other symptom. Verify them when you transcribe the tables, and keep the cursor bound above as the backstop.
The encoder selects this form only when all of the following hold: num_blocks_x <= 255, num_blocks_y <= 255, and the tiny-mip length is strictly less than the serialized blob-container length. Ties go to the blob form.
Two consequences:
-
Tiny mips carry no exact texel dimensions. The decoder reports
num_blocks_x * 4bynum_blocks_y * 4. The container (KTX2 level index or .basis slice descriptor) holds the true dimensions. A decoder that validates the payload against container metadata MUST compare block counts, not texel dimensions. -
m_dct_qis reported as 0, a value the regular header would reject. Callers must tolerate it.
Because the reconstruction round-trips through pack_bc7/unpack_bc7 (§11.2), the logical blocks a tiny mip yields may differ in representation from those the encoder started with — an endpoint swap with complemented weights decodes to identical texels. This is the same canonicalization caveat noted in the overview document.
This is the caller's step, not the decoder's. An XUBC7 decoder emits logical blocks (§1) and conformance is measured on those, so packing is strictly outside it. It is specified here anyway for two reasons: almost every consumer wants a physical BC7 texture to upload, and one part of it is not obvious.
The bit layout is exactly §11.1 in reverse — same field order and widths, same reduced-width anchor indices, LSB-first. Reading order genuinely is writing order here, so inverting §11.1 field by field is sufficient, with one exception and one caveat.
The exception is the mode prefix, which §11.1 gives you as a predicate (the mode is the count of trailing zero bits in byte 0) rather than a value to write. The write form is: emit the single value 1 << mode in mode + 1 bits, i.e. mode zero bits followed by a 1. Getting this wrong does not fail loudly — a block written with the wrong prefix re-parses cleanly as mode 0 and yields garbage.
Preconditions. pack_bc7 assumes a valid logical block and does not validate one. Every field must be in range for the mode: m_pattern_index < (1 << m_pattern_bits), each endpoint < (1 << m_endpoint_bits[c == 3]), each weight < (1 << m_weight_bits[plane]), p-bits 0 or 1. Violate any of them and fields overlap in the packed block — an out-of-range mode-0 pattern index collides with the first red endpoint, an out-of-range weight spills into the next one — producing a block that decodes to something else entirely, with no error. A conforming XUBC7 decoder cannot emit such a block (§7.4 bounds the pattern index, §7.5.3 masks endpoints, §7.8 and §7.9 mask weights), so this only concerns callers that synthesize or edit logical blocks themselves. Those callers should validate first.
If the block's mode is invalid (m_mode < 0, which §1 permits), packing fails: the reference returns false and zeroes the 16 output bytes.
What §11.1 does not tell you at all is that a logical block is not always directly packable, and fixing that changes the fields.
The anchor-MSB rule. BC7 stores each subset's anchor weight with one bit fewer, which is only valid if that weight's most significant bit is zero. Nothing in XUBC7's weight reconstruction guarantees that. So before packing, each subset is canonicalized independently:
For single-plane modes (everything except 4 and 5), canonicalize per subset using plane 0:
n = m_weight_bits[0] // single-plane: always plane 0
msb = 1 << (n - 1)
mask = (1 << n) - 1
// anchor index per subset: subset 0 is always texel 0
// 2-subset modes: subset 1 -> anchor2[m_pattern_index]
// 3-subset modes: subset 1 -> anchor3_1[m_pattern_index]
// subset 2 -> anchor3_2[m_pattern_index]
for each subset s:
if m_weights[0][anchor(s)] & msb:
swap(m_endpoints[s][0], m_endpoints[s][1]) // all channels
if the mode has UNIQUE p-bits:
swap(m_pbits[s*2], m_pbits[s*2 + 1])
// shared p-bits (mode 1) are per-subset, not per-endpoint: do NOT move them
for each texel i whose subset is s:
m_weights[0][i] ^= maskAfter this, every anchor weight's MSB is zero and the block packs.
Dual-plane modes (4 and 5) canonicalize per plane, not per subset. They have a single subset, each plane's anchor is texel 0, and the two planes are independent: the plane carrying RGB swaps only the RGB endpoints and complements only its own weights; the plane carrying alpha swaps only the alpha endpoints and complements only its own. Which plane is which follows m_mode4_index_selector (§1.1). Neither mode has p-bits, so that clause does not apply.
Consequence. The swap is lossless in the texel domain — the weight tables satisfy W[mask - i] == 64 - W[i], so swapping endpoints and complementing weights reconstructs identical pixels exactly, not approximately — but it changes the logical fields. So unpack_bc7(pack_bc7(blk)) is pixel-identical to blk but not necessarily field-identical to it. This is not a rare edge case: the anchor MSB is set often enough that the swap fires on roughly half of single-subset blocks and three quarters of multi-subset ones.
Two things follow, both of which matter if you compare blocks field-by-field (§13):
- The round-trip is field-identical to the canonicalized block — canonicalization is idempotent, so packing an already-canonical block and unpacking it returns exactly what went in.
- Packing also discards every field the mode does not use, and unpacking returns those as zero: alpha endpoints on modes 0–3,
m_weights[1][*]on single-plane modes,m_pbits[]entries pastm_num_pbits. Blocks frominit_log_blkalready have these zeroed, so a decoder-produced block is unaffected — but a hand-built one may differ on fields that never reached the bitstream at all.
This is why the tiny-mip encoder stores the unpacked form as its coded reference rather than the block it started with, and why the overview document notes that a losslessly-coded XUBC7 file may emit BC7 blocks whose bit patterns differ from the source encoder's while decoding to the same texels.
The reference decoder is callback-streaming and owns no image storage:
-
init()— dispatch on the first byte, parse the blob directory, inflate compressed blobs into a single arena, validate the header, rebuild stripe geometry, validate the seek table, allocate the logical block array, and fire the init callback with block counts, texel dimensions,m_dct_q, and the alpha flag. -
decode_stripe(s)— decode one stripe, firing the per-block callback once per block. Safe to call concurrently for distinct stripes. -
decode_all()— calldecode_stripefor every stripe in order.
A failed init() MUST poison the decoder: decode_stripe() and decode_all() MUST refuse to run after it, in all builds rather than under an assertion. This is not defensive padding. Header validation can fail after the stripe geometry has been computed but before the block array is allocated — a two-stripe stream with a wrongly-sized seek table does exactly that — so a caller that ignores the bool and proceeds finds a populated stripe list and a zero-sized block array.
The blob reader itself makes exactly one allocation — a single arena holding every inflated blob, or none at all if nothing was compressed; raw blobs are referenced in place. The decoder as a whole additionally allocates the logical block array, the stripe range list, and the seek table. Each stripe then allocates its own small scratch (DCT work buffer, coefficient list) for the duration of that stripe; per §5.4 this scratch MUST NOT be hoisted into shared state.
A useful property when bringing up an implementation: the reference encoder decodes every stream it produces and compares all blocks, so any encoder/decoder disagreement in the reference itself surfaces at encode time rather than as a corrupt file.
The most direct test of an independent decoder is differential: encode a corpus with basisu -xubc7, decode with both the reference and the new decoder, and compare logical blocks field by field — not just decoded pixels, since two different logical blocks can decode to identical texels and only the field-level comparison catches a divergence that will later corrupt a prediction.
Coverage worth ensuring:
-
-quality 100(all weights DPCM) and a range of lower qualities (mixed DPCM/DCT per block). - Opaque and alpha images — the mode-6 alpha rule (§7.5.2) only fires on opaque ones.
-
-xubc7_num_stripes 1and larger values, to exercise both the no-seek-table path and the seek table. - Images with dimensions that are not multiples of 4, and small mips that trigger the tiny-mip form.
-
-xubc7_rdo_levelabove 0, which produces many more Repeat and Solid commands. - High
-effort, which widens the predictor search and exercises more of the bank; low effort exercises only a subset.
For robustness, fuzzing the container and per-block paths is worthwhile: every check in §10 should be reachable, and no malformed input should crash, hang, or read out of bounds.
- XUBC7 File Format — the overview: containers, coding model, encoder options
- XUBC7 Usage Guide — command line and API usage
- KTX2 File Format Support Technical Details — how XUBC7 slices are carried in .KTX2
- XUASTC LDR Specification — the sibling ASTC-domain codec