Rename a private helper and you have edited one string in the #Strings heap; nothing else in the assembly cares. Rename a virtual method and you have reached into the mechanism the runtime uses to decide which method a call actually lands on. That mechanism is partly by name and partly by table, and the difference between the two decides whether a rename is free, fatal, or — the worst outcome — silently wrong.
This post reads what the CLR actually does with a virtual method, an override, an implicit interface implementation and an explicit one; shows which of those bindings a renamer can touch; and explains how Nebula groups them so the whole slot moves together or not at all.
Three ways a method ends up in a slot
Start with a small hierarchy that exercises all three bindings:
public interface IJob
{
void Run();
string Describe();
}
public abstract class JobBase : IJob
{
public virtual void Run() { } // implicit interface implementation, and a base slot
string IJob.Describe() => GetType().Name; // explicit interface implementation
}
public sealed class ExportJob : JobBase
{
public override void Run() { /* ... */ } // implicit override of JobBase.Run
}
Compile it and look at the method headers ILDASM prints, because the flags and directives are the whole story.
// JobBase
.method public hidebysig newslot virtual instance void Run() cil managed
.method private hidebysig newslot virtual final instance string
'IJob.Describe'() cil managed
{
.override Acme.Jobs.IJob::Describe
...
}
// ExportJob
.method public hidebysig virtual instance void Run() cil managed // note: no newslot
Three things are visible here.
JobBase.Run is newslot virtual: it introduces a vtable slot. It also implements IJob.Run, but nothing in its metadata says so — there is no directive, no attribute, no reference to IJob on the method. The CLR figures that out at type-load time by walking the interface's methods and finding, on the class, a public virtual method with the same name and signature. That is an implicit interface implementation, and it is bound by name.
ExportJob.Run is virtual without newslot. That flag — or rather its absence, "reuse slot" — tells the loader "find a virtual method on a base type with my name and signature, and take over its slot." That is an implicit override, and it is also bound by name. If the loader finds no match it does not fail; it quietly allocates a fresh slot, exactly as if the method had said newslot.
IJob.Describe on JobBase is different in kind. It is private, newslot virtual final, named with the dotted string 'IJob.Describe' — and it carries a .override directive. That directive is a row in the MethodImpl metadata table with two columns: MethodBody (the token of this method) and MethodDeclaration (the token of IJob::Describe). The loader reads the row and wires the slot from the tokens. The method's name is never consulted; it could be called x and the override would resolve identically. That is an explicit implementation, bound by table.
What that means for a renamer
Put the three bindings side by side and the rule falls out.
Explicit implementations are free. The MethodImpl row carries tokens. Rename 'IJob.Describe' to a and the row still says "body 0x06000012 implements declaration 0x06000003"; the loader never looks at the string. A decompiler will show string IJob.a() or, if the interface method was also renamed, string b.a() — but nothing about dispatch changed. (The same holds for a C# override that the compiler chose to emit with an explicit .override, which it does for some covariant-return and generic-variance cases.)
Implicit bindings are load-bearing, in both directions. ExportJob.Run finds JobBase.Run by name. JobBase.Run is found by IJob.Run by name. Rename any one of the three and the chain breaks at that link. So they cannot be renamed individually — only together, to the same new name. And "together" is transitive: if JobBase.Run also happened to implement IRunnable.Run from a second interface, that interface's method joins the set, and so does every other class that implements IRunnable.Run implicitly, and every override of those. The unit of renaming is not a method; it is the connected component of methods linked by overrides and implementations.
If any member of the component is external, the whole component is frozen. Suppose JobBase also overrides object.ToString(). That override is bound by name to a method in System.Private.CoreLib, which you are not renaming. Rename the override and it detaches; so it must keep its name — and, by the transitivity above, so must anything linked to it. The same applies to IDisposable.Dispose, Stream.Read, an interface from a NuGet package that is not an input of the run, or a public interface in a library whose API you are preserving. The boundary of the assembly (or of the multi-assembly run) is the boundary of what can move.
The three failure modes, concretely
The reason this needs care is that the three ways to get it wrong fail very differently, and the quietest one is the most dangerous.
Renamed the interface, not the implementation → TypeLoadException. Rename IJob.Run to a but leave JobBase.Run alone. When anything first touches JobBase, the loader builds its interface map, looks for a method matching a() with the right signature, finds none, and refuses to load the type:
System.TypeLoadException: Method 'a' in type 'Acme.Jobs.JobBase' from assembly
'Acme.Jobs, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' does not have an implementation.
Loud, immediate, and easy to attribute — this is the good failure. Note that it is thrown by the loader, not by a call, so it fires even if Run is never invoked, and it takes the whole type down, not one method.
Renamed the override, not the base → silent detachment. Rename ExportJob.Run to a but leave JobBase.Run. ExportJob.a is still virtual without newslot, so the loader looks for a base virtual named a with that signature. There is none. The loader does not throw; the spec says a reuse-slot virtual with no matching base simply gets a new slot. ExportJob loads fine. And every call to job.Run() through an IJob or JobBase reference now dispatches to JobBase.Run — the empty base body — because that is what sits in the inherited slot. The override has been turned into an unrelated method nothing calls. There is no exception, no warning, no log line; the export just silently stops happening. This is the failure mode that ships.
Renamed an implementation of an external interface → TypeLoadException, possibly far from the rename. Rename Dispose on a class that implements IDisposable implicitly, and you get the loader failure above — but the "missing" method is System.IDisposable.Dispose, defined in an assembly you never touched, and the type that fails may be a tiny internal helper nowhere near the code you were thinking about. The variant that bites in practice is the interface from a package: your type implements IHostedService or IValueConverter or IEqualityComparer<T>, the package assembly is not an input of the obfuscation run, and the implicit implementation is renamed because nothing in-module pinned it.
How Nebula groups the slots
Nebula's renamer does not rename methods; it renames groups. Before any name is assigned, a method-group analysis builds a union-find over every method in the module using exactly the three relationships above:
-
Explicit overrides — each
.overriderecord links the body to the declaration it implements, if that declaration lives in this module. - Implicit base overrides — each reuse-slot virtual is linked to the base-type virtual with the same name and signature, walking up the base chain.
- Implicit interface implementations — for each interface the type implements (following interface inheritance and the base chain), each interface method is linked to the class's matching virtual.
The result is one representative per connected component. Then the boundary rule is applied: a group is marked preserved when any member of it overrides or implements something outside the module — the base chain reaching System.Object, a .override whose declaration resolves to another assembly, an interface the resolver can see but does not own. Preservation is also propagated the other way: if any single member of a group must keep its name for any reason — it is public under preservePublicApi, it is listed in exclude, auto-detection found reflection on it — the whole group keeps its name, because a half-renamed slot is the silent-detachment failure above.
Every group that survives is renamed to one name. All of IJob.Run, JobBase.Run and ExportJob.Run become, say, a; the loader's by-name matching finds a on every type exactly as it found Run, and the explicit 'IJob.Describe' is renamed in the same group as IJob.Describe since its .override row links them.
There is one more case worth knowing about because it produces a log line. If a type implements an interface that cannot be resolved at obfuscation time — the reference assembly is not next to the input, or it is a package Nebula cannot locate — Nebula cannot see that interface's members, so it cannot tell which of the type's virtuals implicitly implement it. The safe answer is to preserve every virtual method on that type, and say so:
warn: preserved 4 virtual member(s) of 'Acme.Jobs.ExportJob' (implements an unresolved interface; names may be part of that contract).
If you see that warning, the fix is usually to make the referenced assembly resolvable (keep the build output folder intact, or point Nebula at it), after which the interface resolves as external, only the methods that actually implement it are pinned, and the rest of the type renames normally.
Default interface methods, generics and variance
A few newer language features sit on top of the same machinery and inherit its rules.
A default interface method (C# 8) is simply a virtual method with a body declared on the interface. A class that does not override it gets the interface's implementation through the interface map; a class that does override it does so with the same by-name implicit binding or an explicit .override. Either way the method belongs to the interface method's group, and renaming follows the group.
A generic interface like IRepository<T> adds nothing new: implementing IRepository<Customer> links the class's Load to IRepository<T>.Load by name and signature with T substituted, and the open generic's method is the group member. Obfuscating generics covers why the type parameters themselves are free.
Covariant returns (C# 9) and some variance cases make the compiler emit a .override even for what you wrote as an ordinary override, plus a [PreserveBaseOverrides] attribute — which is just the explicit, by-token binding again. Nebula reads the MethodImpl rows, so these are handled identically.
The reflection exception
Everything above is about what the loader reads. There is a second reader of interface member names, and it is your own code. A dynamic proxy library, a DI decorator, an interception framework — anything that builds types at runtime or resolves members with GetMethod("Run") or GetInterfaceMap and then compares names — depends on the names surviving:
// A decorator that forwards by name: breaks if IJob.Run is renamed.
MethodInfo target = typeof(IJob).GetMethod("Run")!;
Nebula's auto-detection catches the common patterns (string literals that match member names on reflected types, serialization and DI conventions), and because preservation propagates across the group, pinning IJob.Run pins every implementation of it automatically. For the cases it cannot see — a name assembled at runtime, a convention in a third-party library — exclude the interface explicitly and the group follows.
The configuration
For a library whose interfaces are its public contract, preservePublicApi keeps public interfaces and public implementations by itself, and the grouping makes sure no internal override of a public virtual is renamed out from under it. For an application, you rename everything and exclude only the contract types that reflection or external plugins bind to by name:
{
"inputs": ["out/Acme.Jobs.dll", "out/Acme.Host.dll"],
"outputDirectory": "out/obf",
"preservePublicApi": false,
"crossAssemblyRename": true,
"exclude": [
"Acme.Jobs.IPlugin"
],
"controlFlowObfuscation": true,
"encryptStrings": true
}
Two notes on that. crossAssemblyRename with both assemblies as inputs is what lets a virtual slot that spans Acme.Jobs and Acme.Host — a base class in one, overrides in the other — rename as a single group instead of being frozen at the assembly boundary; with only one of them as an input, the slot is external to the run and stays. And excluding IPlugin by type name preserves its members, which by propagation preserves every class that implements them, with no need to list the implementers.
Decompile the result and the hierarchy reads the way you want: interface a { void b(); string c(); }, abstract class d : a { public virtual void b() {} string a.c() ... }, sealed class e : d { public override void b() ... } — every slot intact, every name gone, and ToString() and Dispose() still standing exactly where the runtime expects them.
The rule, in one line
A virtual method's name is shared with every override and implementation it is connected to, so it is renamed as a group or not at all — and the group is frozen the moment any member of it binds to something outside the assemblies you are renaming. Explicit implementations are bound by the MethodImpl table and cost nothing to rename; implicit ones are bound by name and cost everything if you rename them alone. The obfuscator's job is to know which is which, and never to leave a slot half-renamed.
Top comments (0)