Extent Descriptors
Extent descriptors (type 0x40) map a file’s logical cluster offsets (VCNs) to virtual LCNs (VLCNs). A VLCN is not a physical address: it must be translated through the Container Table to obtain the physical cluster where the data actually lives. Every extent — even a single contiguous run — is a fixed 24-byte entry.
Do not sanity-check a VLCN against the volume’s cluster count. The virtual address space is wider than the physical one — it starts above the first container and runs roughly twice as far — so a perfectly valid extent can name a cluster number larger than the volume has. The only sound test is whether the Container Table can translate it. Getting this wrong is quietly destructive: a decoder that walks extents in order and stops at the first “implausible” one throws the whole map away, and the file then looks as though it had no extents at all. On one 4 GB volume that mistake hid the contents of 583 of its 629 files, every one of which read back byte-perfect once the check was corrected.
Extent Entry — 24 bytes
| Offset | Size | Field | Description |
|---|---|---|---|
| 0x00 | 8 | Virtual LCN (VLCN) (u64) | Requires Container Table for physical translation |
| 0x08 | 2 | Flags (u16) | See Extent Flags below |
| 0x0A | 2 | Record size (u16) | 24 for a plain run; 24 + run_length*4 when the run carries per-cluster checksums, the trailing 4-byte values being them |
| 0x0C | 4 | File VCN (u32) | Cluster index within file |
| 0x10 | 4 | Padding (u32) | Always zero |
| 0x14 | 4 | Run length (u32) | Number of contiguous clusters |
Extent Flags
The flags occupy two bytes at extent+0x08; the two bytes above them are the record size. Read as one
32-bit value the pair reads (record_size << 16) | flags, which is why the constants seen in the wild all
begin 0x18 — that leading 0x18 is 24, the size of a plain record, not a flag bit.
| Flags | Record size | Meaning |
|---|---|---|
| 0x0040 | 24 | Data run, allocated but not written as of this row version (bit 0x10 clear) — never read; the range is zero-filled |
| 0x0050 | 24 | Data run, written (bit 0x10 set) — its clusters hold the file’s bytes |
| 0x0060 / 0x0064 | 24 | Sparse hole — identified by bit 0x20. Its run is a zero-filled hole, never read from disk (see below) |
| 0x00d0 | 24 + run*4 | Per-cluster checksums present (bit 0x80 set, over the 0x0050 run flags): each cluster of the run is followed by its CRC32-C (Castagnoli poly 0x82f63b78) |
Measured across 48,474 extent records on 40 volumes: the record size is 24 + (bit 0x80 ? run*4 : 0)
in every case, with no exception. Observed flag values are 0x0040, 0x0050, 0x0060 (plain) and
0x00d0 (checksummed).
Run cardinality is carried explicitly by the Run length field at extent+0x14, not by the flag bits: both 0x0040 and 0x0050 appear with single-cluster and multi-cluster runs.
Bit 0x10 means “written”, as of the row version that carries it. A run whose flags word is known and has bit 0x10 clear was allocated but not yet written when that row version was committed, and its clusters are not the file’s content: they are zero-filled, never read. Three measurements fix the meaning — a purpose-built lab volume whose files extract byte-exact only under this rule (7 of 7), a real installation where the clear-bit runs of pre-allocated files hold another file’s bytes (693 runs, among them readable text), and the inverse control, a file whose genuinely written zeros must still be read.
Two qualifications follow from that measurement:
- The bit belongs to the row version, not to the cluster. A deleted remnant is often an earlier
version of a file that is still live, committed between allocation and the write. For the same VLCN the
live row can carry
0x00D0(written) while the remnant carries0x0040(not written). Recovering that remnant zero-fills the run — which is what the file held at that moment — even though the clusters hold data now, and a carve reports how many non-zero bytes lie under the zeros it wrote. - A flags word of 0 is unknown, not “unwritten”. The heuristic marker-scan decoder recovers no flags, so its runs carry 0; treating 0 as “not written” would zero real content. Only a positively known flags word with bit 0x10 clear marks a run unwritten.
Records are 8-byte aligned, so the step from one record to the next is record_size rounded up to a
multiple of 8 — the size and the step are different quantities, and a reader must step by the second.
A checksummed single-cluster record is 28 bytes (24 + one 4-byte CRC32-C) and is followed 32 bytes later;
a three-cluster one is 36 bytes, followed 40 bytes later. The bytes in between are padding, not a field.
A plain record is already a multiple of 8 and needs none. Measured on the single-node extent maps of
win11refstestmftecmd: stepping by the aligned step reaches all 142 checksummed clusters in them,
stepping by the record size only 127, and wherever a record follows a 28-byte one it starts at +32, never at
+28. A node’s records are the ones its key index names — a node can keep a superseded record in a slot the
index no longer points at — so a complete reader follows the index, and descends into child pages where the
map spans more than one node: that is how integrity reads them, 55,586 checksummed clusters in 189 files
on that volume, every one matching its stored CRC-32C.
Checksummed and plain records share the same header format and both point at real file data; they differ only in the trailing checksums and the size that implies.
No single-extent “shortcut” form
There is no compact 16-byte single-extent form. A contiguous file uses the standard 24-byte extent
entry (one entry, run_length = the whole contiguous run). Non-resident files resolve via the 24-byte
stride only; sampled single-extent files content-match (24-byte VLCN@0x00 → PLCN holds the file’s bytes).
This 16-byte region is the embedded $DATA sub-record header: the
bytes 02000080 80000e00 … decode as the multi-instance marker 0x80000002 followed by the $DATA
descriptor 0x000E0080 — i.e. the “VLCN” is the descriptor, not a cluster. See the Embedded $DATA section
below.
VCN ordering
Extents may be stored out of order on disk. A parser must sort by file_VCN before reassembling file
content — roughly half of multi-extent entries observed are unsorted.
VLCN to PLCN translation
A VLCN must be resolved through the Container Table to reach the physical disk address:
physical_LCN = container_phys_start + (vlcn & (CPC - 1))
Where CPC (clusters per container) is read from the Container Table at value + 0x18, and the container
index is computed as vlcn >> CPC.bit_length() (shift = 15 for 4 KiB clusters, 11 for 64 KiB).
See Container Table for the full address-translation formula and the failover/checksum details.
Embedded extent sub-record header — 40 bytes
When $DATA (type 0x80) appears as an embedded multi-instance sub-record in a file value, the extent list is
preceded by two headers. This is the extent-bearing $DATA stream summary (marker 0x80000002, descriptor
0x000E0080) — distinct from the smaller resident-SI $DATA record.
$DATA sub-record header (offsets are the v3.7+ layout)
| Offset (v3.7+) | Size | Field | Description |
|---|---|---|---|
| 0x00 | 4 | Inner header size (u32) | Typically 0x88 (all versions) |
| 0x0C | 4 | Summary size (u32) | 0x200 on v3.14+, 0x1A0 on v3.4–v3.10 (the resident-SI $DATA record uses 0x30) |
| 0x2C | 4 | Data offset (u32) | 0x28 |
| 0x30 | 8 | Total allocated size (u64) | – |
| 0x38 | 8 | Stream size (u64) | Logical file size |
| 0x40 | 8 | Valid data length (u64) | – |
| 0x48 | 8 | Disk allocated size (u64) | 0 = inline; >0 = non-resident |
| 0x50 | 8 | Version count (low 31) + sparse flag (bit 31) (u64) | low31 = stream version count (=1 for a single-version file, N for an N-version snapshotted file); bit 31 = sparse flag |
On ReFS v3.4 the four size fields shift +4 (total allocated 0x34, stream size 0x3C, valid data length 0x44); the disk-allocated slot (0x4C) is 0 and the allocation is carried by the total-allocated field. The inner header size (0x88) and the extent sub-record at +0x88 are unchanged across versions.
Extent sub-record header (at inner_header_size offset)
| Offset | Size | Field | Description |
|---|---|---|---|
| 0x00 | 4 | Sub-record size (u32) | Always 0x28 |
| 0x04 | 4 | Extent area end (u32) | 0x28 + extent_count x 24 |
| 0x0C | 4 | Flags (u32) | 0xe00 = non-resident extents; 0x600 = resident/empty |
| 0x14 | 4 | Extent count (u32) | Number of 24-byte extent entries following |
Extent entries following this header use the same 24-byte format as the Extent Entry table above. Snapshot and copy-on-write $DATA reuse this identical extent format (the driver routes them through the same allocation-lookup routine as ordinary file reads).
Where the extent records live — a B+-tree node
A file’s extent records are not a bare list. They are the rows of a small B+-tree node held inside the
file’s own $DATA record, at offset 0x88 of that record. A file with a single run looks like a plain
array because the node holds one row; a fragmented or large file needs more, and then the node framing has
to be read.
The node begins with a 40-byte header:
| Offset | Size | Field | Description |
|---|---|---|---|
| +0x00 | 4 | Header size | 0x28 — identifies the node |
| +0x0C | 1 | Level | 0 = leaf: the rows are extent records. Non-zero = the rows point at a child page |
| +0x10 | 4 | Key-index array start | Offset, relative to the node, of the row-pointer array |
| +0x14 | 4 | Row count | Rows on this node |
| +0x20 | 4 | Key-index array end | One past the last entry |
| +0x24 | 4 | — | 0 on every node measured |
This is the same node header the B+-tree page uses, and the same invariant applies:
(u32@+0x20 − u32@+0x10) / 4 == u32@+0x14. Read the array’s start from +0x10; deriving it by
subtracting 4 × row_count from a value at +0x20 gives the same answer on a well-formed node — which is
why that reading went unnoticed — but it cannot tell a damaged header from a good one, and it treats +0x20
as an 8-byte field when +0x24 is a separate word. Verified on 480 extent nodes with 0 violations.
Rows are addressed by that trailing index of 4-byte slots — a 2-byte row offset plus a 2-byte word that
is a B+-tree upper-bound sentinel, not the row’s file VCN. Read the file VCN from inside the row. The
advice is unchanged; the reason for it is: the word reads 0xFFFF on 1,364 leaf rows whose own file VCN is
below 0xFFFF (against 433 where it happens to equal fvcn & 0xFFFF), so it is not a saturated VCN. Worked
case: win1121h2test OID 0x701, sub_id 0x1000, count 1, key-index bytes 28 00 ff ff — row offset 0x28,
sentinel 0xFFFF, and that row’s file VCN is 0 with run 1.
Two further consequences for anyone walking these rows:
- The extent area is not
0x28 + 24 × count.u32@ihdr+0x04disagrees with that formula on 486 of 2,172 extent-node headers. Onwin11refs2g_sha256checksumsOID 0x707 the header says count 2 and end0x70, and the area holds three 24-byte slots (0x28,0x40,0x58) of which the key index lists only two — the slot at0x28is unindexed. - Rows are not a contiguous array in key-index order on 961 of 1,865 multi-row nodes.
An unindexed slot still holds a well-formed row. That matters for recovery: a deletion can drop a row from the index while the bytes remain, so a reader bounded strictly to the index will miss content that is still on disk, and a reader that ignores the index will list rows twice.
When the map outgrows one node, the level byte becomes non-zero and each row’s value is a 48-byte node
reference holding four cluster numbers. Those are virtual cluster numbers — translate them through the
Container Table — and together they address one 16 KiB page. That page carries the same node structure
again, at offset 0x50 + <the 32-bit value at page+0x50>, so the walk repeats unchanged.
Picking the right record. A file that has stream snapshots carries one $DATA record per version, all
under the same key and all the same size. They are told apart by a sub-stream id in the key at key+0x10:
0x1000 is the live stream, and 0x1001, 0x1002 and so on are the snapshots, oldest first. A reader
that picks by size or takes the first match will read a snapshot’s extents and hand back an older version of
the file as though it were the current one.
How often each map form occurs
A volume does not use one extent-map form. Across 102 images and 109,588 extent-backed streams, three forms occur, and which one a stream uses is a property of the record, not of the ReFS version:
| map form | streams | share |
|---|---|---|
| a contiguous array of sub-records | 59,574 | 54.4 % |
| a B+-tree node walked structurally (the form described above) | 40,665 | 37.1 % |
| an embedded index array | 9,349 | 8.5 % |
Where the $DATA holder sits is a separate question, and it does not determine the map form:
| holder location | streams | share |
|---|---|---|
| the object’s embedded attribute tree | 41,362 | 37.7 % |
| a separate record in the home object’s tree | 38,187 | 34.8 % |
| a separate record in the directory’s own tree | 27,482 | 25.1 % |
| inline in the name row | 2,557 | 2.3 % |
Both separate-record locations carry a mix of the contiguous array and the index array, so a reader cannot choose how to parse the map from where the record lives — it has to look at what the record contains.
Which form a reader should trust. The node form above is authoritative: its rows are addressed by the node’s index array, and a row the index array does not reference is dead space. A reader that walks the record’s bytes positionally instead can pick such a row up, or match a value that merely looks like an entry — and the usual guard, that the runs exactly cover the file’s allocation, does not catch it, because a wrong single run of the right length covers just as well. Reading the node structurally is what distinguishes them.
By version. ReFS 3.4 uses the embedded index array for every stream. The contiguous array appears only on 3.14. The B+-tree node form is not a 3.14 feature: it already carries the majority of streams on 3.7 (295 of 469) and 3.9 (1,052 of 1,502).
Maps that outgrow one node. 139 streams have so many runs that the node becomes an index node whose rows point at child pages, as described above. Rare, but the reason the form exists: no flat array walk reaches those extents.
Forensic notes
- Two-level translation (VCN → VLCN → PLCN) is fundamental to ReFS. A parser that treats VLCN values as direct physical addresses — a common mistake for NTFS-trained tools — will read wrong data.
- Sorting by
file_VCNis mandatory before content reassembly. - Every extent (including a single contiguous run) is a 24-byte entry; there is no 16-byte single-extent
form. A
0x80000002marker in this region is the embedded $DATA sub-record header, not an extent.
Cross-references
- Container Table — VLCN-to-PLCN translation
- Directory Entries — non-resident file values link to type 0x40 extent rows
- Resident Storage — small files are stored inline, not in extents
Sparse files: the ratio is the signal
A sparse region is unallocated, so the gap between a file’s logical size and its allocated size is
what identifies one. A 64 KiB file with a punched middle was measured at 65,536 logical against 8,192
allocated — two clusters backing a sixteen-cluster file — carrying Archive|SparseFile. A fully
sparse file goes further: its allocated size is 0. Both measured on format 3.14.
Extraction fills the hole with zeros, which is what the volume holds there. On a sparsely-stored raw image those zeros are indistinguishable from a region the acquisition never captured — see the note on image holes in what survives.
Evidence
The map-form and holder-location counts are a raw-disk census of 102 images and 109,588 extent-backed streams, every one attributed. The census was taken while the marker scan was consulted first, which is how forefst read these records up to and including v1.11.3; from v1.12.0 the structural readings are consulted first and the scan is a logged fallback, which corrected 23 streams across 11 files and gave 593 files a decoded extent map that had none before.
The 24-byte extent entry layout (VLCN@0x00, flags@0x08, file_VCN@0x0C, padding@0x10, run_length@0x14) is
raw-disk decoded and corroborated in the driver: CmsStream::LookupAllocation (with
AddAllocation / DeleteAllocation) reads run+0x00 VLCN, +0x0C file_vcn, +0x14 run_length. The
VLCN→PLCN translation formula is confirmed in the CmsVolumeContainer container subsystem and verified
on disk across the corpus.
The extent-flag meanings are raw-disk verified: flags 0x0040 is the standard data run, 0x00d0 carries
per-cluster checksums, and the 0x0050 vs 0x0040 distinction (bit 0x10) is not run cardinality — both
occur with single- and multi-cluster runs. A plain record is 24 bytes; the 16-byte region at the descriptor is
the embedded $DATA sub-record header.
Snapshot/CoW DATA uses the identical 24-byte extent format (same
CmsStream::LookupAllocation routine).
The checksum appended to an integrity record is CRC32-C (Castagnoli, poly 0x82f63b78) of the 4 KiB
cluster: confirmed in the driver — the crc32c_4096 kernels via ComputeOneChecksum (which uses the
4096-byte path only when the span is one cluster) — and on disk, 886 checksummed clusters recomputed
across three integrity volumes with 0 mismatches (a cross-algorithm control ruled out plain CRC-32 and CRC64).
The sparse-hole entry is identified by flags bit 0x20, and that bit alone: a hole’s VLCN mirrors its
file VCN, so it is usually non-zero (for example file_vcn=1, VLCN=0x1, run=25343, flags=0x180060).
Measured across all six extent-decoding paths on four volumes — 8,864 accepted records — every record
carrying VLCN == 0 also carries bit 0x20, and records carry bit 0x20 without a zero VLCN, so the flag is
the discriminator. A hole’s run is zero-filled and never read from disk.
VLCN == 0 remains a read-time floor rather than a hole test: VLCN 0 → PLCN 0 is the boot region on every
volume, so cluster 0 is never a file’s content. An entry with a zero VLCN and no 0x20 flag is a
malformed record — it is rejected, so the surrounding map fails its cover check cleanly, rather than being
served as a run of zeros.
A sparse file’s entries tile only its allocated clusters, at their logical positions, with no entry for the range between them. A file of 16 clusters with 2 allocated appears as one entry at file VCN 0 and one at file VCN 15; the uncovered range is the hole.
The remaining field-level statements on this page are registered as — each with its own evidence tier and witness in the claim register. The logical-versus-allocated signal is confirmed, and the alloc_size = 0 case for a fully sparse file is confirmed. The extent-record key shape that identifies a non-inline stream’s own extent sub-record — key offset 0x10, key size 0x50 — is confirmed.