ReFS Reference

Metadata Log (MLog)

The MLog implements write-ahead logging for atomic metadata updates. It is redo-only: on crash recovery, committed transactions are replayed and there is no undo mechanism — copy-on-write keeps prior pages intact, so undo is unnecessary. Each MLog data page is one LogCore data record nesting four layers: a record header, an entry header, a redo block, and the inner redo records that carry the operations of a single transaction.

Record structure — four layers

Each MLog data page is a single LogCore data record. The on-disk format is four nested layers, not two. All offsets are version-invariant — the only version difference is the entry-header payload-offset value (Layer 2 +0x28).

page/record
 ├─ 0x00 Layer 1: LogCore record header (0x78 = 120 bytes)
 ├─ 0x78 Layer 2: entry header (56 bytes; 64 on Insider)
 ├─ 0xB0 Layer 3: redo block _SmsRedoHeader (0xB8 on Insider)
 └─ Layer 4: _SmsRedoRecord entries

Layer 1 — LogCore record header (record+0x00, 120 bytes)

OffsetSizeFieldDescription
0x004Signature"MLog" (0x676f4c4d)
0x044Format magicPer-volume constant (validated by equality), not a checksum
0x084Version1
0x0C4Log block size0x1000 (4K) always — not the volume cluster size
0x1016UUIDLog-instance UUID (per volume)
0x204CounterSmall per-record counter
0x244Reserved0
0x288LSNThis record’s log sequence number = (generation « 32) | log-block offset
0x308Previous LSNLSN of the prior record; prevLSN[n] == LSN[n-1] until the circular buffer wraps
0x384Total lengthWhole record, in 4K log blocks
0x3C4Header lengthNon-payload span, in 4K log blocks (≤ 0x38)
0x544Entry-header offsetConstant 0x78 (anchor to Layer 2)

Layer 2 — entry header (record+0x78)

56 bytes on v3.4–v3.14, 64 bytes on Insider 29574. Offsets are entry-relative (absolute page offset in parentheses):

OffsetSizeFieldDescription
0x00 (0x78)8LSNCopy of record+0x28
0x08 (0x80)8Checksum8-byte XOR-fold — the real per-record integrity value; varies per record
0x18 (0x90)8Previous LSNCopy of record+0x30
0x20 (0x98)4Payload lengthRedo-block bytes (control records carry 0xe48)
0x28 (0xA0)4Payload offsetFrom entry base = 0x38 (v3.4–v3.14) / 0x40 (Insider)
0x2C (0xA4)4Entry region sizeRecord bytes − 0x78
0x30 (0xA8)4Record type2 = data record (carries a redo block), 1 = control record

The redo block starts at record + 0x78 + payload_offset = record+0xB0 (v3.4–v3.14) / record+0xB8 (Insider). Robust, version-safe pointer: redo = record + le32(record,0x54) + le32(record + le32(record,0x54), 0x28), taken only when the entry type (== 2) is a data record.

Layer 3 — redo block _SmsRedoHeader

OffsetSizeFieldDescription
0x004total_size (u32)Total bytes in this block including header
0x044first_record_offset (u32)Offset to the first inner record (= 8)

Each data page holds exactly one redo block — typically all records of a single transaction (start → operations → commit).

Layer 4 — _SmsRedoRecord (minimum 0x38 = 56 bytes)

OffsetSizeFieldDescription
0x004record_size (u32)Size of this inner record
0x044opcode (u32)Redo operation type
0x084table_key_path_length (u32)0 = no path, >0 = has table path
0x104value_component_count (u32)Number of value data segments following key components
0x208object_id (u64)Target OID (from ObjectIdFromRedoHelper)
0x2C4flags (u32)Bit 0 = transaction start, bit 1 = commit, bit 16 = special
0x38+varpayloadKey data, value data (opcode-specific)

Note (the equally-sized 56-byte structures): the Layer 2 entry header and the Layer 4 _SmsRedoRecord are both 56 bytes but are distinct. The entry header carries the per-record checksum (at +0x08); the _SmsRedoRecord does not (it starts with record_size/opcode). The literature’s “56-byte log record header with checksum” is the entry header; the “120-byte entry header” is the Layer 1 record header.

Iteration logic (from ForEachRedoInBlock)

remaining = total_size - first_record_offset
ptr = header + first_record_offset
if (total_size < first_record_offset + 8) or (total_size < 0x38): error
while (record.size <= remaining) and (record.size != 0):
 process(record)
 remaining -= record.size
 if remaining < 0x38: break
 ptr += record.size

Overall layout

The MLog is split into a control area and a data area.

ComponentDescription
Control areaTwo alternating control pages (identified by the MLog signature) with log metadata: head/tail pointers, sequence numbers, data-area bounds
Data areaCircular buffer of 4 KiB log blocks, each carrying one redo block (one transaction)

MLog location

VersionPhysical LCNNotes
v3.40x30 (cluster 48)Immediately after SUPB
v3.140x8000 (cluster 32,768)128 MiB offset for 4 KiB clusters; container-aligned for I/O locality

The MLog is located by its fixed physical LCN, not by file path. The MLog Logfile Information Table (OID 0x9, accessed via the Object Table, plus duplicate OID 0xA — see System OIDs) stores the control and data area LCN range.

Data area addressing is physical. Unlike most ReFS structures, MLog data area LCNs (from the Logfile Information Table key=1 row) are physical cluster addresses — no container translation is needed.

Data-area block packing (4 KiB log blocks). The data area is addressed in volume clusters, but the MLog’s own I/O unit — the log block, which is exactly one LogCore record — is 4 KiB always, independent of the cluster size (it is record+0x0C, hard-set to 0x1000 by MlLogOpenLog). Consequences:

Volume clusterLog blocks per clusterLSN low-32
4 KiB1 (block = cluster)= cluster offset within the data area
64 KiB16 (cluster = 16 log blocks)= the 4 KiB-block index within the data area

On a 64K-cluster volume each cluster holds 16 consecutive log records with consecutive LSNs, so a scanner must iterate 4 KiB blocks, not clusters — otherwise a 64K-cluster log exposes only 1/16 of its records. On 4 KiB-cluster volumes the two are identical.

Control page layout — ~252 bytes (18 fields decoded)

OffsetSizeFieldDescription
0x004Signature"MLog" ASCII (0x676f4c4d)
0x044Format magicPer-volume constant (copied from the log handle, validated by equality), not a checksum. Identical for every control page and data record in a volume; differs between volumes. The real per-record checksum is the XOR-fold at the entry header +0x08 (page+0x80).
0x084VersionAlways 1
0x0C4Log block size0x1000 (4K) always — the log’s I/O unit, NOT the volume cluster size (it stays 0x1000 even on 64K-cluster volumes).
0x1016UUIDVolume-specific log instance identifier
0x208Sequence number (u64)Alternation counter (increments each write)
0x28-0x3716ZeroAll-zero
0x388Two 0x1 dwordsConstant 01 00 00 00 01 00 00 00 (likely a {state,version} pair)
0x40-0x5320ZeroAll-zero
0x5440x78 constantEntry-header offset (=120); the payload is reached via this (see below)
0x58-0x7F40ZeroAll-zero
0x804Valid checksumValidation field (per-volume value)
0x84-0xAFZeroAll-zero up to the payload base
payload (~0xB0)Variable-offset payload fieldsLocated via entry header at 0x54

The remaining fields are NOT at fixed page offsets. They live in a payload block accessed by reading entry_header_offset at page+0x54, then payload_offset at entry_header+0x28. The field offsets below are relative to the payload base (data_off = entry_header_offset + payload_offset):

Payload+OffsetSizeFieldDescription
+0x008SequenceLog sequence counter
+0x088Data start LCNPhysical start of data area
+0x108Data end LCNPhysical end of data area
+0x188LSN oldestOldest active LSN
+0x208GenerationLog generation counter
+0x388Write counterCumulative write operations
+0x488Total entriesCumulative entries since format
+0x5016Control UUIDSecondary UUID (matches header UUID)

18 field positions decoded (16 with assigned meaning). The payload base typically falls around page offset 0xB0 (the entry-header offset at 0x54 is 0x78/120) but is not fixed — always dereference via the entry header.

The control area is a compact header. Although the control area reserves 0xE48 (3656) bytes (self-described by the 0x98 length word), it is populated only through ≈byte 0xF8; everything from ~0xF8 to 0xE48 (≈93% of the area) is zero. Those bytes are not hidden fields — they are the zero gaps between the small fixed descriptors above plus the zero tail. The header is stable per volume (magic@0x04, UUID@0x10 echoed at payload+0x28, area-length@0x98, the data-area bounds, and the LSN/generation pair do not change between captures of the same volume); the dynamic log progression lives in the data-area records, not the control header.

Redo opcodes

The opcode is read at _SmsRedoRecord + 0x04 and dispatched by CmsLogRedoQueue::PerformRedo. Opcode counts vary by version.

VersionHandlersOpcode valuesRangeNon-handler values
v3.4 (Win10 17134)26290x00–0x1C (contiguous)none
v3.14 (Win11 26100)~39440x00–0x2B (contiguous)1 (0x17 → explicit error)

The dispatched opcode ranges are contiguous; the only in-range value that is not a handler is v3.14 0x17 (returns NTSTATUS 0xC0000427, the same generic unhandled-opcode error as out-of-range opcodes). Every in-range value branches to a named handler; the only in-range value that is not a handler is v3.14 0x17, which returns the generic unhandled-opcode error.

Cross-version: the v3.4 core (0x00–0x1C) carries forward almost unchanged (4 renamed, 0x17 turned into an error, 0 removed); v3.14 adds 0x1D–0x2B (15 new values, mainly stream / table-set / refcount).

v3.14 dispatch table (44 opcode values, ~39 handlers)

OpcodeHandlerCategory
0x00OpenTableFromTablePathTable mgmt
0x01RedoInsertRowB+-tree core
0x02RedoDeleteRowB+-tree core
0x03RedoUpdateRowB+-tree core
0x04RedoUpdateDataWithRootB+-tree core
0x05RedoReparentTableTable mgmt
0x06RedoAllocateAllocator
0x07RedoFreeAllocator
0x08RedoSetRangeStateAllocator
0x09RedoSetRangeState (shared)Allocator
0x0ARedoDuplicateExtentsDedup/clone
0x0BRedoModifyStreamExtentStream
0x0CCmsStream::StripAllChecksumsIntegrity
0x0DCmsBPlusTable::SetIntegrityInformationIntegrity
0x0ERedoSetParentIdNamespace
0x0FRedoDeleteTableTable mgmt
0x10CmsBPlusTable::SetObjectRecordPayloadB+-tree core
0x11RedoAddSchemaSchema
0x12RedoMoveContainer (shared)Container
0x13RedoAddContainerContainer
0x14RedoMoveContainer (shared)Container
0x15RedoMoveContainer (shared)Container
0x16RedoSetRangeState (range variant)Allocator
0x17ERROR (NTSTATUS 0xC0000427, generic unhandled-opcode)
0x18RedoContainerCompactionContainer
0x19RedoDeleteCompressionUnitOffsetsCompression
0x1ARedoAddCompressionUnitOffsetsCompression
0x1BRedoGhostExtentsDedup/clone
0x1CRedoCompactionUnreserveContainer
0x1DCmsBPlusTable::UnlinkParentObjectIdHard links
0x1ECmsTableSetBase::PrepareEntryForMergeTable set
0x1FRedoUpdateStreamSummaryStream
0x20CmsStream::UpdateStreamUserPayloadStream
0x21RedoStreamPersistFastRunInsertionStream
0x22RedoTableSetSummaryUpdateTable set
0x23RedoTableSetShadowTreeUpdateTable set
0x24RedoTableSetCommitMergeTable set
0x25RedoTableSet callback (vtbl +0x08)Table set
0x26RedoTableSetStrongRefMergeTable set
0x27RedoSetDefaultCompressionParametersCompression
0x28CmsBlockRefcount::BreakWeakReferencesDedup/clone
0x29RedoDuplicateClusterDedup/clone
0x2ARedoChangeRangeEncryptedStateEncryption
0x2BRedoTableSet callback (vtbl +0x18)Table set

Only v3.14 0x17 falls through to NTSTATUS 0xC0000427 (the same generic unhandled-opcode error as out-of-range opcodes). Opcodes 0x25/0x2B dispatch through the CmsTableSetBase v-table (indirect; concrete handler not statically resolvable).

Full dispatch tables with cross-version mapping are maintained in the project’s redo-opcode reference.

Transaction structure

Each MLog data page holds one redo block containing the records for a single atomic transaction. A transaction typically follows the pattern: start (flags bit 0) → operations → commit (flags bit 1).

Concrete actions — grouping redo opcodes into what a user did

One file operation is not one redo record. ReFS writes a file operation as a cluster of low-level B+-tree redo opcodes (open a table, insert a row, set a parent id, update a stream summary, allocate clusters…), often split across several transactions. forefst mlog --parse groups the opcodes of each transaction into a single concrete action, and decides the ambiguous ones from facts in the record (parent OIDs, which table is destroyed) — not from opcode presence alone. Every action is still backed by its raw redo records; mlog --parse -v prints each one as opcode name target_oid @PLCN+offset key, so any field can be verified against the raw disk bytes. Actions are reported in two groups.

File operations — what a user did:

ActionHow it is decidedNotes
CREATEa NEW name row is inserted (RedoInsertRow of a $FILE_NAME entry, no matching removal) — or the fragment that opens the table and sets its parentnew files, new directories, copies. A hard link also inserts a name and is not separable from CREATE per-transaction (the -v records distinguish it).
WRITEa data-record change on an EXISTING object (non-name InsertRow + SetObjectRecordPayload/UpdateDataWithRoot)pure data updates with no row insert show as MODIFY.
RENAMEold name entry removed and new name entry inserted, with the SAME parent-directory OIDshown as (same parent 0x..). See “MOVE vs RENAME” below.
MOVEold name entry removed and new name entry inserted, with a DIFFERENT parent OIDshown as (parent 0x<old> → 0x<new>).
DELETEthe object’s own B+-tree table is destroyed (RedoDeleteTable, 0x0F)shown as (object table destroyed). See “DELETE vs a row removal” below.

Low-level / metadata records — the B+-tree redo that accompanies the file operations (kept as facts):

GroupOpcodesMeaning
STREAM_UPDUpdateStreamSummary / UserPayload (0x1F/0x20)stream size/summary touch — accompanies almost every change
REPARENTRedoReparentTable (0x05) without both name entriesa reparent that belongs to a move OR a rename — undecidable from one transaction (see below)
ENTRY_REMOVERedoDeleteRow (0x02), no table destroyed, no matching insertthe OLD-name removal of a rename/move, or a hard-link unlink — not a file deletion
ALLOCATERedoAllocate/Free (0x06/0x07)cluster (de)allocation
CONTAINERMove/AddContainer (0x12–0x15)container-table change
DEDUPBreakWeakReferences / DuplicateCluster (0x28/0x29)block-clone / dedup
EXTENT_MODDuplicateExtents / ModifyStreamExtent / FastRunInsertion (0x0A/0x0B/0x21)extent-level change
MODIFYUpdateRow / UpdateData / SetRangeState / SetObjectRecordPayload / …metadata-or-data update with no new/removed name row (includes timestamp changes)
UPDATE / INSERT / OPa non-name row swap / a lone insert / an unclassified combination

MOVE vs RENAME — decided by the parent OID, not by an opcode

A rename and a move look almost identical in the log: both remove the old name entry (RedoDeleteRow) and insert the new one (RedoInsertRow), and ReFS emits RedoReparentTable for both — so a reparent opcode does not mean “move”. forefst instead compares the parent-directory table OID carried by the two name-entry records (parse_mlog_deep_record reads it at key-component[0] + 0x14):

  • old-name DeleteRow.target_oid == new-name InsertRow.target_oid → RENAME (renamed in place)
  • old-name DeleteRow.target_oid new-name InsertRow.target_oid → MOVE (reparented to another directory)

This is a fact from the bytes (verifiable with -v), so the label is trustworthy. When only one of the two name entries is present in a transaction, the parent cannot be compared and the record is reported as REPARENT rather than guessed. Validated against an independent operation log (Generate-FSActivity replay + the USN journal): on that single validation run, MOVE and RENAME agreed with ground truth on every resolvable object.

DELETE vs a rename/move’s row removal

RedoDeleteRow removes a single B+-tree row — and it fires on the OLD name entry of a rename and the source of a move, not only on a real deletion. A bare DeleteRow is therefore not evidence a file was deleted. A real deletion destroys the object’s own table with RedoDeleteTable (0x0F). forefst reports DELETE only when RedoDeleteTable is present; a DeleteRow with no table destroyed is ENTRY_REMOVE (the old-name cleanup of a rename/move, or a hard-link unlink). Reading a [DELETE] as “the file was deleted” without this rule is a classic false conclusion — the tool now makes the distinction explicit.

Timestamp extraction

MLog records do not have a dedicated timestamp field in their headers. Timestamps are embedded in the value data of records that modify file metadata:

Record typeLocationFields
InsertRow (0x01)v1 at offsets 0x28-0x40create_time, modify_time, access_time, change_time
UpdateDataWithRoot (0x04)v0 at offsets 0x00-0x18Timestamp update payload
UpdateRow (0x03)v1 at offsets 0x90, 0xA0, 0x30, 0x28, 0x08, 0x00$SI timestamp fields (multiple fallback positions)

Timestamps are Windows FILETIME format (100-ns intervals since 1601-01-01). Not all transactions contain timestamps — system-level operations (allocator, container table) typically have no embedded time. Timestamps reflect the file operation time, not the log write time.

Key component layout

InsertRow records carry structured key data. Component[0] of the key path has this 28-byte layout:

OffsetSizeField
0x004schema0 (u32) — table schema (e.g. 0xe030 = ObjectTable)
0x044schema1 (u32) — attribute schema (e.g. 0x0130 = $FILE_NAME)
0x0812zeros
0x148target OID (u64) — the object being modified

The schema pair identifies which table and attribute type the record targets. Common pairs: 0xe030/0x0130 (ObjectTable/$FILE_NAME), 0xe030/0x0160 (ObjectTable/ReparseIndex).

Data area sizing

MetricObserved range
Log size1 MiB (minimal) to 4 MiB (heavy use)
Record counttens of records (fresh mini volume) up to many thousands per scan on heavily used volumes

Recovery process

The CmsRestarter class performs:

  1. Analysis pass: Scans from oldest needed LSN (stored at CHKP+0x70) to identify dirty pages and active transactions
  2. Redo pass: Replays all committed redo records in LSN order

There is no undo pass. Copy-on-write ensures uncommitted transactions never overwrote prior state.

Forensic value

  • The MLog does not carry pre-images (unlike NTFS $LogFile), so it cannot directly reconstruct previous file states
  • Opcodes reveal what operations occurred (inserts, deletes, modifications)
  • The LSN range and record count indicate volume activity level
  • The oldest log record ref in the CHKP ties the current checkpoint to the log
  • Transaction classification maps opcode sequences to human-readable actions (CREATE, DELETE, RENAME, MOVE, WRITE, etc.)
  • OID-to-path resolution links redo records to specific files and directories
  • Embedded timestamps in InsertRow and UpdateRow value data provide approximate operation times (most, but not all, transactions carry timestamps)
  • CSV export enables bulk analysis and timeline correlation with other forensic artifacts

Tooling

MLog parsing is integrated into forefst.py (parsing + display). Available modes via forefst.py <image> mlog:

ModeDescription
DefaultControl area summary + record statistics
-vPer-record detail (opcode + OID) plus a LogCore Record Headers table — per-record LSN, prevLSN, checksum, type, and payload offset, with !chain markers where prevLSN[n] != LSN[n-1] (circular-buffer wrap or a 64K-cluster multi-block page)
--parseDecoded transactions: action classification, file paths, timestamps
--csv [FILE]Transaction export as CSV (seq, timestamp, action, path, name, oid, opcodes)
--statsOpcode frequency histogram
--jsonMachine-readable JSON output
--infoReference: all actions, opcodes, timestamps, schema names
--raw-scanRaw page classification (debug)

Validated across v3.4 / v3.7 / v3.9 / v3.10 / v3.14 / Insider volumes — all PASS the LogCore framing check (entry header @0x78, type 2, single per-volume magic). The 4 KiB-block scanner gives full coverage on 64K-cluster volumes.

Cross-references

  • Checkpoint (CHKP) — oldest log record ref at CHKP+0x70
  • Page Header — MLog pages share the "MLog" signature at offset 0x00 but use a distinct header layout (per-volume format magic at 0x04 — not a CRC — not the common 80-byte format)
  • Copy-on-Write — why redo-only logging is sufficient
  • System OIDs — OID 0x9/0xA (Logfile Information Table) stores the MLog LCN range

Evidence

The four-layer record format was decoded byte-for-byte from LogCoreWriteDataRecord (write), LogCoreScanDataRecord / LogValidateEntryHeader (read/validate) and LogInitializeEntryHeader, cross-confirmed in v3.4 (Win10), v3.14 (Win11) and Insider 29574, and raw-disk verified across the corpus. The fixed log-block size (0x1000) is set by MlLogOpenLog. The redo opcode dispatch (each value branching to a PDB-named handler) is decompiled from CmsLogRedoQueue::PerformRedo; the 64K-cluster 4 KiB-block packing and the compact-control-header (≈93% zero) results are raw-disk decoded. The recovery passes come from the CmsRestarter class. The format magic is a per-volume constant, not a CRC.