DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

Recovering Real Variable Names, Source Files and Line Numbers from PDBs and Source Link

Decompilation reconstructs C# from two things the runtime actually needs: IL and metadata. Neither carries the names you typed for your locals. The IL loads and stores locals by slot index — ldloc.0, stloc.1 — so a decompiler reading only the assembly has nothing to call them. It invents V_0, num, text. Parameter names usually survive (they live in the Param table), but the readable, intent-carrying names that make code navigable — pendingRetries, decodedHeader — are simply not in the DLL.

They are in the PDB. When Glass can pair an assembly with its debug symbols, decompiled output stops looking like machine output and starts looking like the source: real local names, real source-file annotations, and — with Source Link — a path to the actual original text. This post is about what is inside a Portable PDB, the three ways it reaches Glass, and how Glass uses each piece.

What a Portable PDB actually contains

A Portable PDB is a metadata stream in the same ECMA-335 table format as an assembly, read with System.Reflection.Metadata. The tables that matter for decompilation:

  • Document — one row per source file: the file path (C:\src\Acme\OrderService.cs), a hash and hash algorithm, and the language GUID. This is what lets Glass say which file a method came from.
  • MethodDebugInformation — one row per method body, holding its sequence points: a blob mapping IL offsets to (document, start line, start column, end line, end column). A sequence point is the join between an IL instruction and a span of source text.
  • LocalScope and LocalVariable — the names. A LocalScope covers a half-open IL offset range and references a run of LocalVariable rows; each LocalVariable carries a slot index and the name. This is the table that turns V_0 back into pendingRetries.
  • LocalConstant, ImportScope — constants visible to the debugger and the using directives in scope, useful for resolving short type names.
  • StateMachineMethodInformation and the custom debug information for async/iterator methods — these map a generated MoveNext back to the user method, so hoisted locals (now fields on a <Foo>d__3 struct) can be given their original names.

The core read looks the same regardless of where the PDB came from:

using System.Reflection.Metadata;

MetadataReader pdb = provider.GetMetadataReader();

foreach (DocumentHandle dh in pdb.Documents)
{
    Document doc = pdb.GetDocument(dh);
    string path = pdb.GetString(doc.Name);           // C:\src\Acme\OrderService.cs
}

// For one method (MethodDefinitionHandle from the assembly reader):
MethodDebugInformation dbg = pdb.GetMethodDebugInformation(methodDebugHandle);
foreach (SequencePoint sp in dbg.GetSequencePoints())
{
    if (sp.IsHidden) continue;                        // the 0xFEEFEE "hidden" marker
    // sp.Offset  -> IL offset
    // sp.StartLine / sp.StartColumn -> source position
    // sp.Document -> which Document row
}
Enter fullscreen mode Exit fullscreen mode

Local names come from the scope tree:

foreach (LocalScopeHandle sh in pdb.GetLocalScopes(methodDefHandle))
{
    LocalScope scope = pdb.GetLocalScope(sh);
    foreach (LocalVariableHandle vh in scope.GetLocalVariables())
    {
        LocalVariable v = pdb.GetLocalVariable(vh);
        int slot = v.Index;                           // matches ldloc/stloc slot
        string name = pdb.GetString(v.Name);          // "pendingRetries"
    }
}
Enter fullscreen mode Exit fullscreen mode

Glass builds a slot-to-name map per scope and feeds it into the naming pass that would otherwise synthesize V_n.

Three ways the PDB reaches Glass

Embedded PDB (debug directory entry type 17)

The PE's debug directory can carry an entry of type EmbeddedPortablePdb (value 17). Its payload is the Portable PDB stream Deflate-compressed behind a small header (MPDB magic + uncompressed size). Glass finds it through the PE reader and hands the decompressed bytes straight to a metadata provider:

using var pe = new PEReader(File.OpenRead("Acme.dll"));
foreach (DebugDirectoryEntry e in pe.ReadDebugDirectory())
{
    if (e.Type != DebugDirectoryEntryType.EmbeddedPortablePdb) continue;
    using MetadataReaderProvider provider = pe.ReadEmbeddedPortablePdbDebugDirectoryData(e);
    MetadataReader pdb = provider.GetMetadataReader();
    // ... read documents, sequence points, locals
}
Enter fullscreen mode Exit fullscreen mode

An embedded PDB travels inside the DLL, so symbols are available no matter where the assembly is copied. This is the friendliest case: nothing to locate, nothing to match.

Side-by-side .pdb file

The classic layout: Acme.dll next to Acme.pdb. Glass loads the stream directly and validates it against the assembly's CodeView / PDB checksum debug entry so it never binds a stale PDB to a rebuilt DLL:

using var fs = File.OpenRead("Acme.pdb");
using MetadataReaderProvider provider =
    MetadataReaderProvider.FromPortablePdbImage(fs.ToImmutableArray());
MetadataReader pdb = provider.GetMetadataReader();
Enter fullscreen mode Exit fullscreen mode

(If you already hold the bytes — say, a symbol server returned them — FromMetadataImage reads an in-memory image the same way.) The PDB's ID must match the assembly's debug directory GUID + age; a mismatch means the symbols are from a different build and Glass declines them rather than misname everything.

Fetched via Source Link / a symbol server

When no local PDB exists, Glass can fetch one from a symbol server using the assembly's PDB signature, exactly as Visual Studio does. More interestingly, a Portable PDB can carry a Source Link record — a custom debug information blob whose content is a tiny JSON document:

{
  "documents": {
    "C:\\src\\Acme\\*": "https://raw.githubusercontent.com/acme/orders/3f9a1c2/*"
  }
}
Enter fullscreen mode Exit fullscreen mode

Each key is a source path prefix with a * wildcard; the value is a URL with a matching *. To resolve a document, Glass takes its Document.Name, finds the longest matching prefix, and substitutes the remainder into the URL — turning C:\src\Acme\OrderService.cs into https://raw.githubusercontent.com/acme/orders/3f9a1c2/OrderService.cs, pinned to the exact commit that produced the binary. With that, Glass can show the genuine original source for a method, not a reconstruction — for dependencies you are entitled to read.

How the pieces feed the decompiler

The flow from PE to annotated C# is a small pipeline. Glass locates the symbols, reads the three kinds of information, and routes each into a different stage of decompilation.



PE image
Acme.dll
IL + metadata
debug directory

Embedded PDB (type 17)
Deflate-compressed, in the PE

Side-by-side .pdb
validated by PDB id

Source Link fetch
documents JSON to raw URL




LocalScope / LocalVariable
slot → real name

Sequence points
IL offset → file : line



Naming + line annotation
readable, annotated C#




original source text (when entitled)

Seeing the difference

Decompile a method without symbols and you get correct but anonymous code:

// glass decompile Acme.dll --type Acme.Orders.OrderService --member Retry
public bool Retry(Order V_0, int V_1)
{
    int num = 0;
    while (num < V_1)
    {
        if (TrySubmit(V_0))
            return true;
        num++;
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

Now point Glass at the symbols. The --pdb flag takes an explicit path, --use-embedded reads a type-17 entry, and --source-link enables URL resolution:

glass decompile Acme.dll --type Acme.Orders.OrderService --member Retry \
    --pdb Acme.pdb --source-link --annotate-lines
Enter fullscreen mode Exit fullscreen mode
// Acme.Orders.OrderService  —  C:\src\Acme\OrderService.cs
public bool Retry(Order order, int maxAttempts)
{
    int attempt = 0;                       // line 42
    while (attempt < maxAttempts)          // line 43
    {
        if (TrySubmit(order))              // line 45
            return true;
        attempt++;                         // line 47
    }
    return false;                          // line 49
}
Enter fullscreen mode Exit fullscreen mode

The parameter order, the locals attempt and maxAttempts, the file path and the per-line annotations all come from the PDB — the LocalVariable names for the identifiers, the sequence points for the // line N comments and the Document row for the header. With --source-link, Glass resolves OrderService.cs against the documents map and can show the genuine original body alongside the reconstruction.

When the symbols are thin — degrading gracefully

PDBs are an optimization, not a guarantee, and Glass is honest about the gap:

  • No PDB at all (stripped assembly). Nothing to read. Glass synthesizes readable names (order, attempt, count) from types and usage, and omits line annotations entirely rather than printing fake line numbers.
  • Optimized release build with a PDB. The JIT-oriented compiler hoists, reuses and elides locals, so a single slot may back several source variables and sequence points can be sparse — whole expressions collapse to one point, or appear as the hidden 0xFEEFEE marker (SequencePoint.IsHidden). Glass annotates only the offsets it can prove map to real source spans and leaves optimized regions unannotated instead of guessing.
  • Mismatched PDB. If the PDB id does not match the assembly's debug directory GUID and age, Glass refuses it. A wrong-build PDB would attach plausible-but-false names, which is worse than V_0.
  • Async and iterators. Names for hoisted locals live in the state-machine's debug information; Glass maps <Retry>d__4's fields back to their source names when that information is present, and falls back to field-name heuristics when it is not.

The honest limit

Symbols recover names and positions, never semantics the compiler discarded. Optimizations are one-way: an inlined method leaves no call site to re-label, a folded constant has no original expression, a reused slot cannot be split back into two variables with certainty. A PDB makes decompiled code readable; it does not reverse the compiler. Use it where it belongs — debugging code you own, inspecting a dependency you are licensed to read, reconstructing a stack trace during an incident — and Glass will give you real names and real line numbers wherever the symbols can prove them, and clean synthesized code wherever they cannot.

Glass is free. Download it, point it at an assembly and its PDB, and read your own code the way you wrote it.

Top comments (0)