A WPF obfuscation guide does not transfer to WinUI 3, and a UWP one only half-transfers, because the thing that decides what survives a rename is the XAML compiler, and WinUI 3's is closer to Avalonia's than to WPF's. WPF compiles XAML to BAML — a serialized object graph the runtime instantiates by name, with binding paths walked by reflection. WinUI 3 compiles {x:Bind} to C# in your own assembly: a generated binding class whose job is to call your getters directly and push the values into your controls. By the time Nebula sees the assembly, most of what an obfuscator would break in WPF has already been turned into metadata tokens — and tokens are exactly what a renamer rewrites correctly.
So the failures that remain are the places WinUI, or your own code, still goes through a string. There are three that matter, and it is worth ranking them by how fast they bite: a classic {Binding} path comes back empty with a log line and a blank control; a page located by Type.GetType on a name you built up breaks every navigation that uses it; and a type your Package.appxmanifest activates by its literal name fails to launch with a class-not-registered error that the OS raises, not your code.
What {x:Bind} compiles to
Read the generated code first, because the whole safety argument depends on exactly what is in it. A page with a typed binding:
<!-- MainPage.xaml --> <Page x:Class="MyApp.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <TextBlock Text="{x:Bind ViewModel.Greeting, Mode=OneWay}" /> </Page> The XAML compiler writes a MainPage.xaml.g.cs containing a nested binding class. Decompiled and cleaned up for reading, the part that pushes the value into the TextBlock looks like this:
// Generated into MyApp.dll: MainPage.xaml.g.cs, nested class MainPage_obj1_Bindings private global::MyApp.MainViewModel dataRoot; // the x:Bind source (this.ViewModel) private void Update_(global::MyApp.MainViewModel obj, int phase) { if (obj != null) this.Update_ViewModel_Greeting(obj.Greeting, phase); // callvirt get_Greeting() — a token } private void Update_ViewModel_Greeting(string value, int phase) { if ((phase & (NOT_PHASED | DATA_CHANGED | (1 << 0))) != 0) this.obj2.Text = value ?? string.Empty; // obj2 is the TextBlock } // change tracking for the OneWay bind: private void PropertyChanged_ViewModel(object sender, PropertyChangedEventArgs e) { switch (e.PropertyName) { case "Greeting": this.Update_ViewModel_Greeting(this.dataRoot.Greeting, DATA_CHANGED); break; // "" or null -> update every tracked property } } Two things reference Greeting, and they are different kinds of reference. obj.Greeting compiles to callvirt instance string MyApp.MainViewModel::get_Greeting() — a metadata token. When Nebula renames get_Greeting to a, it rewrites every token that points at it, this one included, so the generated code calls the renamed getter and returns the right value. The reference to MainViewModel itself, and the field access on obj2, are tokens too, rewritten the same way.
The second reference is the string "Greeting" in the switch. It is not used to find the property — the update method already holds the getter. It decides, when a PropertyChanged event arrives, whether this binding is the one that changed. So the question is what the view model puts in that event:
public sealed partial class MainViewModel : ObservableObject // CommunityToolkit.Mvvm { [ObservableProperty] private string _greeting = "Ready"; // the generator emits: set { ...; OnPropertyChanged("Greeting"); } — a literal // or by hand, with the same result: private int _count; public int Count { get => _count; set { _count = value; OnPropertyChanged(nameof(Count)); } } } nameof(Count), [CallerMemberName], and the MVVM Toolkit generator all resolve to a string literal in IL — ldstr "Count" — before the obfuscator runs. Renaming the property changes the metadata name and leaves the literal untouched. The generated switch compares its literal "Greeting" with the event's literal "Greeting", both unchanged, and the match holds. The binding works, the change notification works, and the property can be renamed to a single letter.
The one way to break it is a base class that raises PropertyChanged by enumerating GetType().GetProperties() and passing prop.Name — a runtime name that does change. That is a reflection dependency like any other, and it needs a rewrite or a preserve rule. The MVVM Toolkit does not do this; hand-rolled bases sometimes do.
The classic {Binding}: a string the runtime walks
Write {Binding Greeting} instead of {x:Bind} and nothing is resolved at compile time. The binding stores the path "Greeting" and, when the data context is assigned, asks the source object for a member of that name. In a managed WinUI 3 app that resolution goes through reflection (or, under Native AOT, a generated [GeneratedBindableCustomProperty] provider). After obfuscation there is no member called Greeting; the lookup returns nothing, the binding engine logs a binding failure, and the control stays empty. No exception. The window opens, your startup test passes, and the screen is quietly blank.
You can preserve your way out of this — keep the public properties of every view model — but it is the wrong fix, because it leaves the dependency in place and the most descriptive names in the app in plain text. The right fix is to remove the dependency by migrating to {x:Bind}, which also moves the failure to the build: the XAML compiler errors on any x:Bind path it cannot resolve, so a binding that would have broken under renaming cannot compile. Where you genuinely need a late-bound path — binding to a dynamic row from a plugin, say — keep exactly that type's public properties with a targeted rule, not the whole view-model layer.
Navigation and activation by name
The pattern that takes out the whole app at once is navigation built from a string:
// page key -> Type by name; common in settings and shell navigation var pageType = Type.GetType($"MyApp.Views.{key}Page"); // "MyApp.Views.SettingsPage" rootFrame.Navigate(pageType, argument); Rename the pages and Type.GetType("MyApp.Views.SettingsPage") returns null — the type is now a.b — so Navigate(null) throws and every navigation through this code path dies. The fix is the same shape as everywhere else: turn the string into a token. rootFrame.Navigate(typeof(SettingsPage)) is a ldtoken, which the renamer follows, and a Dictionary<string, Type> keyed by a stable external key (a URL fragment, a settings value) mapped to typeof(SettingsPage) keeps the stable key while renaming the type.
The irreducible case is activation declared in Package.appxmanifest. A background task, an app-extension class, a protocol handler, an out-of-process COM server — each is named in the manifest as a fully-qualified string, and the platform constructs it through the WinRT activation factory from that literal:
<!-- Package.appxmanifest --> <Extensions> <Extension Category="windows.backgroundTasks" EntryPoint="MyApp.Tasks.SyncTask"> <BackgroundTasks><Task Type="timer" /></BackgroundTasks> </Extension> </Extensions> Nothing in your IL references MyApp.Tasks.SyncTask; the OS does, by name, when the trigger fires. Rename it and activation fails with a class-not-registered error that surfaces far from the build. These types must keep their names and stay WinRT-visible — they are the one keep-list no amount of x:Bind discipline removes.
A note on dependency-property names
It is tempting to preserve every custom dependency property, and it is usually unnecessary. DependencyProperty.Register("Title", typeof(string), typeof(MyControl), ...) stores "Title" as a literal, and the markup Title="..." that sets it is a literal too; the generated XAML type information maps that name to a setter delegate referenced by token. On rename the token is rewritten and the two literals stay equal, so the property keeps working from markup, from {x:Bind}, and from a Storyboard that targets it by name — none of which consults the CLR wrapper's metadata name. The registration string does not even need to equal the renamed wrapper for XAML to work. The wrapper name is load-bearing only where something reaches it by reflection: a classic {Binding Title}, a manual GetProperty("Title"), a serializer. Keep the wrapper there; otherwise let it rename.
The Nebula configuration
With {x:Bind} in place of classic bindings and navigation routed through typeof, almost nothing in the app is resolved by name, so the configuration is short:
<Nebula> <Assembly file="MyApp.dll"> <Rename enabled="true"> <!-- Types the Package.appxmanifest activates by string, through the WinRT factory. Keep their names and WinRT visibility; nothing references them in IL. --> <Preserve names="true" members="false" type="MyApp.Tasks.SyncTask" /> </Rename> <ControlFlow enabled="true" /> <StringEncryption enabled="true" /> </Assembly> </Nebula> String encryption is safe to leave on for the whole assembly. The "Greeting" literal in the generated binding switch and the "Greeting" literal in the view model's OnPropertyChanged are each decrypted at the point of use, and the comparison runs on the decrypted values — the binding sees the same two strings it always did.
If you are not yet free of classic bindings — a large app with years of {Binding} — this is the interim rule, scoped to exactly the surface reflection touches and nothing more:
<Rename enabled="true"> <Preserve names="true" members="false" type="MyApp.Tasks.SyncTask" /> <!-- INTERIM ONLY: classic {Binding} walks these public property names by reflection. Keep the properties; rename the fields, the commands' backing members, and the types. --> <Preserve names="true" members="public" baseType="MyApp.ViewModels.ViewModelBase" /> </Rename> members="public" keeps the property names the binding engine reads while still renaming private fields, generated command backing members, and everything the engine never sees. Treat the block as a to-do list: each line is a dependency to delete, and when the last {Binding} becomes an {x:Bind}, the line goes with it.
The practical takeaway
A modern WinUI 3 app is mostly rename-safe before you configure anything, because {x:Bind} compiled the member references into tokens and the obfuscator rewrites tokens correctly. Your job is to find the three remaining strings — a classic {Binding} path, a Type.GetType built from a name, a manifest-declared activation type — and handle each at its source: migrate the binding so the compiler catches it, route navigation through typeof, and preserve only the manifest-named types. Do that and you can flatten the pages, the view models, and their members to single letters while every screen still binds, updates, and navigates. As always, obfuscate only software you have the right to protect, and let the build — not a user's blank screen — be the thing that tells you a binding broke.



