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.
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_Equalitychecks under the same hash branch; the first to match wins, and a near-miss that shares the hash with a real case still fails itsop_Equalityand falls through todefault. - A string switch is ordinal and case-sensitive.
op_EqualityonstringisString.Equals— a byte-for-byte comparison, not a culture-aware one."Build"does not matchcase "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:
- A local assigned from a call to a method named
ComputeStringHashon<PrivateImplementationDetails>whose body is the FNV-1a loop. - A dispatch on that local — a comparison ladder or an inner
switch— whose targets each perform anop_Equalityof the original string against anldstrliteral. - Each confirmed match assigning a dense index and converging on a final
switchover 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
nullcase is handled before the hash is ever computed — you cannot hashnull— so it appears as abrfalseto the null arm at the very top, ahead of theComputeStringHashcall. Glass folds it back intocase 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_Equalitychecks nested under one hash branch; a decompiler may render that bucket as nestedifs rather than two flatcases. 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 thegoto case, but only if it tracks that the branch target is inside the same lowered switch.- A switch used as an expression (the
switchexpression form over string constants) lowers through the sameComputeStringHashmachinery; 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.