Skip to content
← All posts
· Delta1 Labs Glass.NETDecompilation.NETDeep Dive

Decompiling a switch on strings: the hidden hash jump table the compiler builds

A switch over an integer compiles to one IL switch instruction. A switch over a string cannot — there is no jump table for text — so once you have more than a handful of cases the C# compiler quietly rewrites it into a two-stage dispatch: hash the string with a synthesized ComputeStringHash, branch on the hash to a candidate, then confirm with an ordinal String.Equals before jumping to the arm. This post reads the real IL for a large string switch, shows the <PrivateImplementationDetails>.ComputeStringHash FNV helper and the second dense switch over a case index, explains why the equality check is not optional, and walks through how Glass.NET recognizes the whole shape and folds it back into switch (s) { case "...": } instead of leaving you a page of hash constants.

A switch over an int or an enum is one of the cleanest things in all of IL: the compiler emits a single switch instruction, a genuine jump table that reads an integer off the stack and branches to the Nth label in constant time. A switch over a string looks the same in source — case "build": sits right next to case 2: in the language — but it cannot compile to the same instruction, because there is no such thing as a jump table indexed by text.

So the compiler rewrites it. For two or three cases it does the obvious thing and emits a short ladder of String.Equals. But the moment a string switch grows past a handful of labels, Roslyn reaches for a different strategy that most people have never seen: it hashes the string, branches on the hash to find a candidate, confirms the candidate with an equality check, and then uses a real integer switch over a small case-index to reach the arm. The readable switch (command) you wrote becomes a page of hexadecimal hash constants and op_Equality calls — and a decompiler either understands that shape and gives you your switch back, or it hands you the hex.

commandthe input stringComputeStringHashFNV-1a → uint32stage 1branch on hash+ op_Equalitysets case indexstage 2switch (index)dense IL jump tablearm bodies 1..Ndefault arm

A switch big enough to trigger it

Here is an ordinary command dispatcher — a classic string switch with eight cases:

public static int Dispatch(string command)
{
    switch (command)
    {
        case "init":    return 1;
        case "build":   return 2;
        case "restore": return 3;
        case "run":     return 4;
        case "test":    return 5;
        case "pack":    return 6;
        case "publish": return 7;
        case "clean":   return 8;
        default:        return 0;
    }
}

Eight labels is comfortably past the threshold (Roslyn switches to the hashed form at roughly seven), so none of these case strings survive as a simple comparison ladder. Instead the compiler first injects a helper into a synthetic type named <PrivateImplementationDetails>:

.method assembly hidebysig static uint32
    '<PrivateImplementationDetails>'::ComputeStringHash(string s) cil managed
{
    // uint num = 2166136261;                 // FNV-1a offset basis (0x811C9DC5)
    // for (int i = 0; i < s.Length; i++)
    //     num = (s[i] ^ num) * 16777619;     // FNV-1a prime   (0x01000193)
    // return num;
}

That is a textbook 32-bit FNV-1a hash and nothing more. It is fast, it is not cryptographic, and it is explicitly allowed to collide — the collision handling comes later. Its presence in <PrivateImplementationDetails>, with exactly this body, is the single clearest signal that a method contains a hashed string switch.

The two-stage dispatch in IL

Now the body of Dispatch. The lowering uses two locals — call them num (the hash) and num2 (a dense case index) — and dispatches in two stages:

// ---- stage 0: hash the input ----
ldarg.0
call    uint32 '<PrivateImplementationDetails>'::ComputeStringHash(string)
stloc.0                         // num = ComputeStringHash(command)

// ---- stage 1: branch on the hash, confirm with Equals, set the case index ----
ldloc.0
ldc.i4  0x9B8C...               // == ComputeStringHash("init")
beq.s   TRY_INIT
ldloc.0
ldc.i4  0x4F2A...               // == ComputeStringHash("build")
beq.s   TRY_BUILD
// ... one comparison per case ...
br      DEFAULT

TRY_INIT:
ldarg.0
ldstr   "init"
call    bool [System.Runtime]System.String::op_Equality(string, string)
brtrue.s  SET_0
br      DEFAULT
SET_0:  ldc.i4.0
        stloc.1                 // num2 = 0  (the "init" arm index)
        br.s    DISPATCH

TRY_BUILD:
ldarg.0
ldstr   "build"
call    bool [System.Runtime]System.String::op_Equality(string, string)
brtrue.s  SET_1
br      DEFAULT
SET_1:  ldc.i4.1
        stloc.1                 // num2 = 1
        br.s    DISPATCH
// ... and so on ...

// ---- stage 2: a real integer switch over the dense case index ----
DISPATCH:
ldloc.1
switch  (ARM_INIT, ARM_BUILD, ARM_RESTORE, ARM_RUN, ARM_TEST, ARM_PACK, ARM_PUBLISH, ARM_CLEAN)
br      DEFAULT

Read what actually happens. Stage 0 turns the string into a 32-bit number. Stage 1 compares that number against the precomputed hash of each case literal; a hit does not jump to the arm — it jumps to a confirmation that loads the original string, compares it to the literal with op_Equality, and on success writes a small contiguous index into num2. Only stage 2 uses the genuine switch instruction — and by then it is switching over 0..7, a dense integer range, which is exactly what the switch opcode is good at.

The hash branch in stage 1 is itself emitted as a sequence of beq comparisons here because the eight hashes are scattered across the uint range. When the hashes happen to cluster, Roslyn will emit a second switch instruction over the hash too — so a large string switch can decompile to two nested jump tables, which is even more bewildering if you do not know the pattern.

Why the equality check is the whole point

The most important instruction in that listing is the one that looks redundant: the op_Equality after the hash already matched. It is not redundant, and removing it would be a bug.

ComputeStringHash is lossy — it maps arbitrarily many strings onto 2³² values, so two different strings can share a hash. The hash branch only narrows the input down to a candidate case; it is the ordinal String.Equals that proves the input is actually that case before its arm runs. Two consequences fall out of this directly:

  • A hash collision is handled, not feared. If two case literals collide, the compiler emits both op_Equality checks under the same hash branch; the first to match wins, and a near-miss that shares the hash with a real case still fails its op_Equality and falls through to default.
  • A string switch is ordinal and case-sensitive. op_Equality on string is String.Equals — a byte-for-byte comparison, not a culture-aware one. "Build" does not match case "build":, and it never did; the lowering simply makes the reason visible. If you need case-insensitive dispatch you lower-case the input first or switch on something else, because the generated code has no notion of culture.

How Glass.NET reads it back

A decompiler that walks this IL instruction by instruction, with no model of the pattern, produces something technically correct and completely unreadable: a uint local assigned from a call to a mysterious ComputeStringHash, a tree of if (num == 0x9B8C...) comparisons against magic numbers, a scattering of string equality checks, and a final switch over an integer that appears from nowhere. Nobody reading that can see that it began life as switch (command).

Glass.NET recognizes the shape as a unit. The recipe it matches is specific enough to be reliable:

  1. A local assigned from a call to a method named ComputeStringHash on <PrivateImplementationDetails> whose body is the FNV-1a loop.
  2. A dispatch on that local — a comparison ladder or an inner switch — whose targets each perform an op_Equality of the original string against an ldstr literal.
  3. Each confirmed match assigning a dense index and converging on a final switch over that index, or branching straight to an arm body.

When all three line up, Glass discards the hashes entirely — they are a compiler implementation detail, not information — and reconstructs the switch from the ldstr operands, which are the original case literals, preserved verbatim in the metadata. You get back:

switch (command)
{
    case "init":    return 1;
    case "build":   return 2;
    // ...
    default:        return 0;
}

The constants a naive tool would strand on the page — 0x9B8C..., 0x4F2A... — never appear, because Glass understands they are a function of the literals it already recovered.

Where the reconstruction can drift

Because the pattern is regular the common case round-trips cleanly, but a few shapes lower to IL that reads back as equivalent-but-different source, and it is worth knowing them so you trust the decompiler where it is faithful and read carefully where it paraphrases:

  • A null case is handled before the hash is ever computed — you cannot hash null — so it appears as a brfalse to the null arm at the very top, ahead of the ComputeStringHash call. Glass folds it back into case null:, but in the raw IL it sits oddly far from the rest of the switch.
  • A hash collision between two real case literals produces two op_Equality checks nested under one hash branch; a decompiler may render that bucket as nested ifs rather than two flat cases. Same behavior, slightly different shape.
  • goto case "x"; compiles to a branch back into the dispatch — often straight to the stage-2 arm or to the case-index assignment — which a decompiler reconstructs as the goto case, but only if it tracks that the branch target is inside the same lowered switch.
  • A switch used as an expression (the switch expression form over string constants) lowers through the same ComputeStringHash machinery; the difference is only whether the arms yield a value or execute statements, which is a separate reconstruction step.

The takeaway

A switch on a string is one of the few places where a single line of C# expands into a genuinely surprising amount of IL: a synthesized FNV-1a hash helper, a hash-keyed branch, an ordinal equality confirmation for every candidate, and a dense integer jump table — two dispatches with a correctness check wedged between them. The hash is there for speed, the op_Equality is there for correctness, and neither is information you want to read. A decompiler’s job is to recognize the whole machine, throw away the hashes, recover the literals from the ldstr operands, and give you back the switch you wrote. That is the difference between reading switch (command) { case "build": ... } and squinting at a wall of hexadecimal.

See also decompiling C# pattern matching and switch expressions for the pattern-matching switch that lowers through type tests rather than hashes, and how .NET metadata tables work for where the <PrivateImplementationDetails> type and its injected helper actually live. Try Glass.NET free on an assembly of your own and watch a string switch come back readable.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.