B+-Tree Node (MSB+ Page)
All ReFS metadata is stored in B+-trees with the signature "MSB+". Each node occupies one metadata page: 16 KiB (4 consecutive clusters) on 4 KiB-cluster volumes, or 64 KiB (1 cluster) on 64 KiB-cluster volumes. An MSB+ page is built from four regions in order: the page header, a node header, the key-value data area, and a sorted key index.
B+-tree row header (16 bytes)
Each row in the data area begins with this header. The header points to where the key and value live within the row; both are measured from the start of the row.
| Offset | Size | Field | Description |
|---|---|---|---|
| 0x00 | 4 | Row size (u32) | Total byte size of the row, used as the stride to the next row. The value region is always last. (0x70 is the common value for a typical resident row, but it is a size, not a constant flag.) |
| 0x04 | 2 | Key offset (u16) | Byte offset from row start to key data |
| 0x06 | 2 | Key length (u16) | — |
| 0x08 | 2 | Reserved (u16) | — |
| 0x0A | 2 | Value offset (u16) | Byte offset from row start to value data |
| 0x0C | 2 | Value length (u16) | — |
| 0x0E | 2 | Reserved (u16) | — |
Rows are written sequentially in insertion order (not necessarily sorted); the sorted key index below restores order for lookups.
The root descriptor — a second structure on the same page
A root page carries two structures, and confusing them is the single most productive mistake in this area: an earlier correction refuted a prior-work claim by measuring the wrong one, and had to be retracted.
_SmsIndexRoot sits at a fixed page + 0x50 and describes the table. The node header below
describes one page of it. They overlap in offsets and disagree in meaning:
| Offset | _SmsIndexRoot descriptor (page+0x50, root pages only) | the node header at thoff |
|---|---|---|
| +0x00 | offset from here to the node header — this is what locates thoff | — |
| +0x04 | descriptor size, 0x28 | — |
| +0x0C | the table’s schema id (0xe0c0 Container Table, 0xe030 Object Table, 0x130 directory …) | node level: 0 = leaf |
| +0x14 | 2 | rows on this page |
| +0x18 | leaf-page / extent count for the whole table | a u64, 0 on disk |
| +0x20 | row count for the whole table | end of the row-pointer array |
The two row counts are the clearest illustration: on a multi-level table the descriptor’s +0x20 counts
every row in the table while the node header’s +0x14 counts only the current page — measured at 387
versus 10 on one object tree, and 245,759 versus 2 on a large container table. A reader that takes
the node figure for the table total under-reports by whatever the tree’s fan-out happens to be.
Read the descriptor when you want to know what a table is — its schema, its size — and the node header when you are walking one page.
Page layout
1. Page header (80 bytes)
The common metadata page header shared with SUPB and CHKP. See Page Header.
Key MSB+-specific header fields:
- LCN slots 0–3 (offsets 0x20–0x38): cluster addresses of this page’s extent. On 4 KiB-cluster volumes, all four slots are used with consecutive values (
[self, self+1, self+2, self+3]). On 64 KiB-cluster volumes (where a page is 1 cluster), only slot 0 is meaningful. - Table OID (16-byte identifier at 0x40–0x4F): the high half at 0x40 (
TableIdHigh) is always 0 in practice, because table OIDs are small integers; the numeric table OID (e.g. 0x02 = Object ID Table, 0x0B = Container Table) isTableIdLowat 0x48 — the field the driver reads and compares. It is retained on orphaned/CoW-discarded pages, enabling identification.
2. Node header
A per-page node header (_SmsIndexHeader) follows the page header on every node, root and non-root alike. It describes that page’s own row set:
- Data-area boundaries (the row-data high-water cursor and free-space size)
- Row-pointer (key index) array boundaries
- Node type at +0x0C (0 = leaf, nonzero = internal/index node) and node flags at +0x0D (bit 0 set marks an index page)
- The per-node row/key count at +0x14 — the number of rows on this page (children for an inner node, leaf rows for a leaf). This is not the whole-table total.
The header is reached at page + 0x50 + u32@(page + 0x50), and the fields a reader needs are:
| Offset | Size | Field | Notes |
|---|---|---|---|
| +0x0C | 1 | Node level | 0 = leaf; non-zero = internal / index node |
| +0x0D | 1 | Node flags | Bit 0 marks an index page (its rows point at child pages). Prior work also assigns 0x2 = root and 0x4 = stream; only bit 0 has been re-measured here, so treat the other two as unconfirmed |
| +0x10 | 4 | Key-index array start | Offset, relative to the header, of the row-pointer array |
| +0x14 | 4 | Row count | Rows on this page |
| +0x20 | 4 | Key-index array end | One past the last entry |
Each key-index entry is 4 bytes: a u16 offset to the row (relative to the header) followed by a
u16 marker.
That marker is not a count of anything — across the corpus it takes exactly two values, 0xFFFF on 842,529
entries and 0 on 879, and nothing else. 0 appears only on an index page, once per page, on the last
entry, and always on a row with an empty key whose value is a page reference: the tree’s rightmost
child, the one covering keys above every separator key. Every other entry, and every entry on a leaf page,
carries 0xFFFF.
It is also a version marker. Volumes from 3.10 onward write the 0 there; 3.4 through 3.9 leave
0xFFFF on that same entry (216 pages, no exception). An upgraded volume keeps the old form on the pages
it inherited — the same retention that governs directory-entry sizes, and a reminder
that the volume’s current version does not tell you how any individual page was written.
Invariant —
(u32@+0x20 − u32@+0x10) / 4 == u32@+0x14. The array length and the row count must agree. Measured across the whole corpus: 118,351 pages, 0 violations, on ReFS 3.4, 3.7, 3.9, 3.10, 3.14, 3.15 and Insider. It is the cheapest structural check for “is this really a node page”, and worth asserting before trusting any row on it.Note that a page is not one cluster: on a 4 KiB-cluster volume it is four, and the key-index array lives at the end of the page. Reading a single cluster leaves the array out of range and the row count reads as zero — a mistake that silently empties a scan rather than failing it.
On root nodes only, an index root descriptor (_SmsIndexRoot, Prade’s “Index Root”) precedes the node header at page+0x50, providing table-level metadata: the schema id, the leaf-extent count, and the total row count for the whole table (distinct from the per-node count above — on a multi-level tree they diverge cleanly). See the Schema Table for how the schema id selects key comparison rules.
3. Data area (key-value pairs)
Contains the actual row data: each row is the 16-byte row header above followed by its key and value bytes.
4. Key index (sorted offsets)
An array of offsets into the data area, maintained in sorted order according to the table’s key comparison rules (defined by the Schema Table). A binary search on this array locates a row without scanning the entire data area.
Inner vs leaf nodes
| Property | Inner Node | Leaf Node |
|---|---|---|
| Contains user data | No | Yes |
| Value content | Page references to child nodes | Actual key-value data |
| Key content | Separator keys (routing) | Full keys |
| Node-type byte (header +0x0C) | Nonzero | 0 |
Inner node values are page references (48, 72, or 104 bytes depending on checksum configuration) that point to child pages. The page reference format matches the one stored in the Checkpoint root pointer list.
Tree depth
Observed depths range from 1 (a root-only leaf, for small tables) to 2+ levels (a root inner node over leaf nodes). Tens of thousands of entries fit comfortably in a 2-level tree.
Orphaned pages
When copy-on-write replaces a page, the old page becomes orphaned but retains its original Table OID (the numeric value at header offset 0x48 = TableIdLow; the 0x40 high half is always 0). This enables forensic identification of discarded pages — they belong to a known table but are no longer reachable from the current checkpoint.
Driver functions
| Function | Purpose |
|---|---|
MsInsertRow | Inserts a key-value pair into a B+-tree. Handles page splits when a leaf is full. |
MsDeleteRow | Removes a key-value pair. Handles page merges when occupancy drops. |
MsLookupAllocation | Queries allocation state via B+-tree lookup. Core read path. |
EnumerateBPlusTable | Iterates all entries in a B+-tree in key order. |
MsAllocateRowWithBuffer | Allocates a B+-tree row with a pre-sized buffer for the value. |
Cross-references
- Page Header — the 80-byte header shared by all metadata pages
- Page References — the format used in inner node values
- Schema Table — defines key comparison rules per table type
- Checkpoint (CHKP) — root pointers to the top-level B+-trees
Evidence
The B+-tree storage engine and the MSB+ page model are confirmed in the driver (the CmsBPlusTable / CmsTable classes) and raw-disk verified across the corpus (every metadata page carries the MSB+ signature). The row header, node header, and index-root descriptor offsets are decompiled-confirmed and re-measured on disk: the node-type byte and the per-node count at header +0x14 hold on every leaf/inner page measured, the key-index array length agrees with that count on 118,351 pages across all seven versions with zero violations, and the index-root total-row count tracks an independent leaf-walk. The 0x40/0x48 table-OID split (always-0 high half, numeric low half) is the same. The insert/delete/lookup/enumerate/allocate functions are PDB symbols in the driver. The remaining field-level statements on this page are registered as — each with its own evidence tier and witness in the claim register. The _SmsIndexRoot descriptor is (size), (schema), (leaf-page count) and (whole-table rows), all confirmed both in the decompiled CmsBPlusTable::CreateIndex path and across 113 images / 34,207 tables; is the descriptor field that locates the node header. The row-header fields are confirmed, and the node-flags byte is — whose 0x2/0x4 bit meanings come from prior work and are not re-measured here.