Skip to content

XUBC7 Low Level Format

Richard Geldreich edited this page Aug 31, 2026 · 10 revisions

XUBC7 Low-Level Format

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

Scope

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.

1. The Decoder Output Model

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.

1.1 Mode table

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"): -1 if single-plane, else (m_dp_rotation_index + 3) & 3
  • get_endpoint_channel_weight_plane(c): 0 if single-plane; else if c == 3 then 1 - m_mode4_index_selector, else m_mode4_index_selector

1.2 Weight dequantization

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_idx

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

1.3 Endpoint expansion

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.

2. Slice Dispatch

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.

3. The Blob Container

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

  1. Payload shorter than 3 bytes.
  2. Leading byte is not 0xB7 (for this form).
  3. Any varint that runs off the end of the buffer, or violates the length limits above.
  4. uncompressed_size == 0 for any blob. Empty blobs are never stored.
  5. stored_size == 0 for a compressed blob.
  6. stored_size >= uncompressed_size for 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 least size/400 before 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.)
  7. A blob id appearing more than once.
  8. A blob's data running past the end of the buffer.
  9. The total inflated size of all compressed blobs exceeding the decoder's cap (the reference decoder uses 1 GiB).
  10. The end marker 0x6A not 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.
  11. 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.
  12. 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 AA parses one complete blob and then reads the second entry byte one past the end before rule 3 fires; B7 01 00 FF ends 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_bytes test of rule 8;
  • the sum of declared uncompressed_size values 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.

4. The Header Blob (id 0)

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 caller's payload buffer, which raw blobs alias.

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:

  1. m_width_in_texels != 0 and m_height_in_texels != 0.
  2. Both dimensions <= 16384.
  3. 1 <= m_dct_q <= 100.
  4. (m_flags & ~1) == 0. Bit 0 is XBC7_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:

  1. m_num_stripes >= 1.
  2. m_num_stripes <= num_blocks_y.
  3. 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 resize calls std::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.

5. Stripes and the Seek Table

5.1 Stripe geometry

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_y

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

5.2 Per-stripe stream ranges

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.

5.3 The seek table (blob 26)

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) = running

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

5.4 Stripe independence

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.

5.5 End-of-stripe validation

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.

6. Stream Inventory

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.

7. Per-Block Decode

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

7.1 The command byte

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.

7.2 Simple commands (0..2)

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.

7.3 Config (commands 3..7)

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, set m_dp_rotation_index = rot. Otherwise, if rot != 0, reject.
  • If mode == 4, set m_mode4_index_selector = sel. Otherwise, if sel != 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.

7.4 Partition index

Read only if the mode is partitioned:

  • m_num_partitions == 2: read a byte from blob 3. Reject if >= 64. Store as m_pattern_index.
  • m_num_partitions == 3: read a byte from blob 4. Reject if >= (1 << m_pattern_bits) — that is, >= 16 for mode 0 and >= 64 for mode 2. Store as m_pattern_index.
  • m_num_partitions == 1: nothing is read.

7.5 Endpoints

The endpoint mode is bits 3-5 of the command byte.

7.5.1 Raw (mode 0)

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.

7.5.2 DPCM (modes 1..7)

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] = 127

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

7.5.3 endpoint_dpcm_decode

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]) & 1

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

7.5.4 pack_endpoints_int

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

The 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] >> 1

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

7.6 The weight predictor byte

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.

7.7 The weight predictor bank

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.

7.7.1 The amplitude code

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

The mean is computed from the predictions before any transform, and the transform is applied to all 16 values.

7.8 Weights — DPCM path (weight mode 0)

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 == 3 or n == 4: 2 symbols per byte — b&0xF then b>>4. 8 bytes per plane. For n == 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) & mask

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

7.9 Weights — DCT path (weight mode 1)

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

0xFF 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.

8. Bit and Byte Streams

8.1 Bit order

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 += n

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

8.2 The weight-grid DCT

XUBC7 uses a 4x4 orthonormal DCT-II (forward) / DCT-III (inverse) in Q15.16 fixed point. Only the inverse is needed to decode.

8.2.1 Fixed-point conventions

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.

8.2.2 Quantization

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

All 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] = s

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

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

8.2.3 Dequantization

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_int

Build-alike warning. The reference implementation gates this behind two compile-time flags, g_xbc7_quantize_dc and g_xbc7_dc_quant_per_weight_bits, both true in 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.

8.2.4 The inverse procedure

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.

9. Solid Blocks

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 res
bc7_interp(l, h, w, bits):
    W = dequant_weight(w, bits)                   // §1.2
    return (l * (64 - W) + h * W + 32) >> 6

g_bc7_partition2 (64 patterns) and g_bc7_partition3 (64 patterns) are the standard BC7 partition tables, 16 subset indices per pattern in raster order, exactly as defined by the BC7 specification. They are not reproduced here.

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.

9.1 create_solid_blk

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_blk

The 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 = h

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

10. Reserved-Bit and Range Checks

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.

11. Tiny Mips

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.

11.1 unpack_bc7 — physical block to logical block

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 fetch that 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 a block whose first byte is zero has no valid mode and MUST be rejected. The bit cursor then starts at mode + 1.

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-majorc, 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_1 and anchor3_2 are the standard BC7 anchor-index tables (64 entries each, indexed by partition pattern), defined by the BC7 specification alongside the partition tables of §9.

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 * 4 by num_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_q is reported as 0, a value the regular header would reject. Callers must tolerate it.

Because the reconstruction round-trips through pack_bc7/unpack_bc7, 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.

12. Decoder Structure (informative)

The reference decoder is callback-streaming and owns no image storage:

  1. 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.
  2. decode_stripe(s) — decode one stripe, firing the per-block callback once per block. Safe to call concurrently for distinct stripes.
  3. decode_all() — call decode_stripe for 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.

13. Conformance Testing (informative)

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 1 and 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_level above 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.

See Also

Clone this wiki locally