~/posts/binary/msvc-xfg-type-hash-encoding.md

MSVC XFG: from type encoding to call-site hashes

Reconstruct the x64 C type-hash pipeline in an MSVC 19.28 preview build. Validate two prototypes and separate normalization, recursive digests, back-end masks, entry markers, and unsupported type cases.

date[31:24]
read[23:16]
8 min
cat[15:8]
Binary
Contents
  1. 0x00Separate the call-site constant from the entry marker
  2. 0x01What the front end and back end each decide
  3. 0x02The field order in a function prototype
  4. 0x03Normalization precedes recursive type hashing
  5. 0x04Back-end masking does not preserve the digest unchanged
  6. 0x05Validate the chain with two prototypes
  7. 0x06Find the earliest point of disagreement
  8. 0x07References

A 64-bit constant near an x64 indirect call need not be an address or a digest of machine code. XFG encodes type information at the call site and target. Explaining a match requires following type normalization, front-end serialization, back-end masking, and the runtime's treatment of marker bits.

The scope here is the C compilation path in Visual Studio 2019 16.8.0 Preview 2.1, with both c1.dll and c2.dll at 19.28.29213.0. These internal encodings describe that preview implementation, not a stable interface for later toolchains or a complete model of C++ types handled by c1xx.dll.

Separate the call-site constant from the entry marker

CFG's basic boundary is whether an indirect-call target is valid. The XFG path examined here adds a function-type comparison. The C prototype used to observe it is:

example.cc
typedef float (*FPTR)(float, float);

float difference(float a, float b) {
    return b - a;
}

int main(void) {
    FPTR fn = difference;
    return fn(1.00001f, 2.00002f) > 0;
}

The case uses the preview toolchain's x64 developer command prompt and cl /Zi /guard:xfg example.c. This is a version-specific compilation input, not a promise about arbitrary current MSVC options. If optimization folds the indirect call into a direct call, first check whether the intended call site still exists.

Two encodings for float(float, float)3 rows
Location Value Role
Call-site R10 0x99743F3270D52870 Expected function type
Call-site RAX Target function address Indirect-call destination
Eight bytes before target entry 0x99743F3270D52871 Type value with its low-bit marker

Dispatch proceeds through __guard_xfg_dispatch_icall_fptr. The relevant comparison can be reduced to the two instructions below. They are not a complete, contiguous dispatcher listing; target-address checks also occur between them.

XFG comparison excerptasm
or  r10, 1
cmp r10, [rax-8]

The one-bit difference is therefore not a type conflict. Compare the forms the dispatcher actually uses, rather than testing two constants from the file for direct equality.

What the front end and back end each decide

From a type to an emitted constant5 rows
Stage Component or function Result
Prototype collection c1.dll!XFGHelper__ComputeHash_1 Parameters, calling convention, and return type
Type encoding XFGHasher, XFGTypeHasher Ordered bytes and nested type digests
Front-end digest XFGHasher::get_hash First eight bytes of SHA-256
Back-end encoding c2.dll!XfgIlVisitor::visit_I_XFG_HASH Masked call-site constant
Target-side marker Data before function entry Call-site form with the low bit set

XFGHasher::add_function_type appends the parameter count, each parameter's type digest, a variadic flag, and the calling convention. add_type then appends the return-type digest. A nested digest enters the next hash as bytes; little-endian interpretation becomes relevant when those bytes are read as a 64-bit integer.

XFGHelper__GetHashForType can reuse a cached result associated with Type_t. Caching changes the cost, not the encoded content. The object under analysis is a type representation, not a digest of the function body, variable names, or source file.

The field order in a function prototype

Let H(x) = SHA256(x)[:8]. An ordinary C prototype builds its front-end input in this order:

Function-prototype serialization5 rows
Field Width Encoding
Parameter count 4 bytes Normalized u32_le count
Parameter types 8 bytes each Type digests concatenated in declaration order
Variadic flag 1 byte Zero for an ordinary nonvariadic function
Calling convention 4 bytes u32_le(convention & 0x0f)
Return type 8 bytes Return-type digest

In this implementation, the default x64 convention's internal value 0x201 contributes low nibble 1; __vectorcall value 0x208 contributes 8. These are internal compiler encodings. Microsoft's x64 calling convention and __vectorcall documentation explain ABI semantics, not a contract for these enumeration values.

The count comes from internal representations such as RealNumberOfParameters(). Variadic and special-entry paths can adjust it, so a general encoder must use the normalized prototype rather than count commas in source text. The branch involving virtual information was not exercised by the C tests described here; its full meaning remains separately unverified.

Normalization precedes recursive type hashing

Parameter processing performs array or function decay and clears some top-level modifiers on the ordinary path, including const and volatile. It does not erase qualifiers at every nesting level.

In const void *, the qualifier belongs to the referenced void. In void * const, it belongs to the pointer. Identifying that level before recursive encoding is essential to reproducing the result.

A type sequence begins with a qualifier byte, a type-group byte, and group-specific data. Internal modifier bits 0x800 and 0x40 denote const and volatile; they become bits 0 and 1 of the qualifier byte. Neither qualifier produces 0; both produce 3.

Type branches in this preview implementation5 rows
Type branch Encoding rule Boundary
Primitive, group 1 Qualifier, 01, primitive code float=0x0b, void=0x0e, this model's size_t=0x88
Tagged type, group 2 Name contributes to the encoding Anonymous name is <unnamed>; full conditions for <local> remain unresolved
Ordinary pointer 0x102 or function pointer 0x106, group 3 Qualifier, 03, referenced-type digest, 02 Pointer and referenced object are separate layers
Function object 0x101, group 3 Prototype fields and return-type digest, ending in 01 Do not collapse it into a pointer to that object
Counted array, group 3 Qualifier, 03, u64_le(count), element digest, 06 A special branch omits the count; parameter arrays may decay first

Group selection uses internal type flags 0x100, 0x200, and 0x400 rather than direct C syntax-category numbers. A special generic path should not be continued as an ordinary primitive encoding. The tagged-type observations also mean that names, anonymous types, scope, and member layout must be evaluated through the actual encoder. Equal memory layouts alone do not establish equal hashes.

For the three explicit primitive examples, float has internal type value 0x26, void has 0x40, and the unsigned 64-bit size_t in this model has 0x4019. They map to the one-byte codes shown above. Thus void hashes input 00 01 0e, while const void hashes 01 01 0e; that difference propagates into the enclosing pointer digest.

Back-end masking does not preserve the digest unchanged

xfg_backend_encoding.pypython
encoded = (frontend & 0xFFFDBFFF7EDFFB70) | 0x8000060010500070
entry = encoded | 1

frontend is the truncated digest interpreted as a little-endian integer. The AND fixes some bits; the OR forces others to one. A 64-bit storage width therefore does not mean that 64 variable digest bits survive.

For these specific masks, the bits that can still vary with input are AND_MASK & ~OR_MASK: 0x7ffdb9ff6e8ffb00, with 44 set bits. The model below verifies this bitwise result.

Validate the chain with two prototypes

The memcpy prototype covers ordinary pointers, a pointee qualifier, an unsigned integer, and a pointer return type:

memcpy prototypec
void *memcpy(void *dest, const void *src, size_t count);

Its front-end input is 41 bytes long. The bytes below come from the following script's calculation. They are serialization-model output, not machine code extracted from an executable.

The 41-byte front-end input for memcpy
0000000003000000F597783E5B4A60B01780B8C0
000000105B1BD0D82314B4BA91C7F66A00010000
0000002000F597783E5B4A60B0
  1. parameter_count0x00–0x03
  2. H(void*)0x04–0x0B
  3. H(const void*)0x0C–0x13
  4. H(size_t)0x14–0x1B
  5. is_variadic0x1C
  6. calling_convention0x1D–0x20
  7. H(return_type)0x21–0x28

The script implements only the type combinations explicitly covered here. It does not parse C source or handle every special array, tag-scope rule, or C++ semantic case. A second assertion checks float(float, float) so that validation does not rest on one final constant alone.

xfg_prototype_model.pypython
import hashlib, struct

def h8(data):
    return hashlib.sha256(data).digest()[:8]

def primitive(code, qualifiers=0):
    return h8(bytes([qualifiers, 1, code]))

def pointer(referenced_hash, qualifiers=0):
    return h8(bytes([qualifiers, 3]) + referenced_hash + b"\x02")

def function_bytes(params, result, variadic=0, convention=1):
    return (struct.pack("<I", len(params)) + b"".join(params)
            + bytes([variadic]) + struct.pack("<I", convention & 0x0f)
            + result)

def callsite_hash(payload):
    front = int.from_bytes(h8(payload), "little")
    return (front & 0xFFFDBFFF7EDFFB70) | 0x8000060010500070

void_ptr = pointer(primitive(0x0e))
const_void_ptr = pointer(primitive(0x0e, qualifiers=1))
size_t_hash = primitive(0x88)
payload = function_bytes([void_ptr, const_void_ptr, size_t_hash], void_ptr)
front = int.from_bytes(h8(payload), "little")
callsite = callsite_hash(payload)
entry = callsite | 1
assert len(payload) == 41
assert front == 0x1da7d393d6b63a72
assert callsite == 0x9da5979356d63a70
f32 = primitive(0x0b)
assert callsite_hash(function_bytes([f32, f32], f32)) == 0x99743f3270d52870
assert void_ptr != const_void_ptr
free_mask = 0xFFFDBFFF7EDFFB70 & ~0x8000060010500070
assert free_mask.bit_count() == 44
print(f"serialized bytes: {len(payload)}")
print(f"frontend: 0x{front:016x}")
print(f"callsite: 0x{callsite:016x}")
print(f"entry:    0x{entry:016x}")
print("PASS: memcpy; float(float,float); pointee const; 44 variable mask bits")
Actual serialization-model output
$ python xfg_prototype_model.py
serialized bytes: 41
frontend: 0x1da7d393d6b63a72
callsite: 0x9da5979356d63a70
entry:    0x9da5979356d63a71
PASS: memcpy; float(float,float); pointee const; 44 variable mask bits

The calculated memcpy call-site value is 0x9da5979356d63a70, matching the case's R10 constant. The float prototype also produces 0x99743f3270d52870. These results check the model's byte order, type recursion, masks, and marker relationship. The executed test is the offline calculation above, not a fresh run of the historical Visual Studio preview toolchain.

Find the earliest point of disagreement

Checks for a mismatched result5 steps
  1. 1

    Pin the compiler and language

    Record c1.dll and c2.dll versions and the x64 C input. Exclude accidental use of another version or C++ behavior first.

  2. 2

    Inspect the normalized prototype

    Check array and function-parameter decay, and distinguish top-level qualifiers from qualifiers on referenced types.

  3. 3

    Compare each type digest

    Print the eight-byte parameter and return-type results separately, then check order, field widths, and calling convention.

  4. 4

    Compare the two encoding stages

    Compare the front-end digest before applying the back-end mask, then handle the entry marker. Do not infer every stage from the final constant alone.

  5. 5

    Check the actual call path

    Confirm that the binary retains an indirect call through the expected dispatcher rather than an inlined, devirtualized, or direct call.

The important part of XFG type hashing is not SHA-256 itself but the type information retained before hashing. Normalization defines the equivalence classes being compared; truncation, masking, and runtime markers determine how that information reaches the check.

References

Related compiler type-hash research: Francisco Falcon. The official material below supplies CFG and calling-convention background, not a stability guarantee for preview compiler internals.

NORMAL~/posts/binary/msvc-xfg-type-hash-encoding.md§--
0%en