A proof-of-concept fork of WPF with changes to support Native AOT deployment, plus a sample calculator app (wpfaot) that validates the AOT-compatible build end-to-end.
Repository: github.com/ibebbs/wpfaot | WPF fork branch: ibebbs/wpf@feature/native-aot
WPF was never designed for ahead-of-time compilation. It relies heavily on runtime reflection, dynamic assembly loading, COM interop, and Reflection.Emit -- all of which are incompatible with Native AOT. This project systematically addresses each of those blockers in a fork of WPF, resulting in an AOT-published WPF application that starts in ~136 ms (vs ~650-730 ms with JIT), uses 32% less memory, and ships as a single native executable with no .NET runtime dependency.
| Area | What was done |
|---|---|
| XAML runtime | AOT-safe ClrObjectRuntime used instead of DynamicMethodRuntime (no Reflection.Emit) |
| Assembly resolution | BAML/XAML assembly resolution uses already-loaded assemblies only (no Assembly.Load in AOT) |
| Trimming annotations | [DynamicallyAccessedMembers] and [RequiresUnreferencedCode] on key WPF APIs |
| COM interop | Critical COM sites (drag-drop, TSF, UIAutomation focus) gated behind RuntimeFeature.IsDynamicCodeSupported |
| DirectWrite fonts | Custom font loaders ported from C++/CLI to C# with [GeneratedComInterface]/[GeneratedComClass] for AOT-compatible COM |
| Feature switches | [FeatureSwitchDefinition] switches for 5 subsystems (spell check, printing, drag-drop, TSF, accessibility) enabling dead-code elimination |
| Compiled bindings | New {CompiledBinding} markup extension + Roslyn source generator for reflection-free data binding |
| UI Automation | 26 UIA provider interfaces converted to [GeneratedComInterface]; 24+ implementation classes annotated with [GeneratedComClass] (in progress) |
| Metric | Framework-dependent | Self-contained | AOT + trimmed |
|---|---|---|---|
| Startup time (median) | 652 ms | 728 ms | 136 ms |
| Working set (RSS) | 82.4 MB | 82.8 MB | 56.1 MB |
| Runtime files | 202 files (24 MB) | 413 files (118 MB) | 6 files (63 MB) |
Measured on Intel i7-14700K, Windows 10, .NET 10 Preview. See wpfaot-perf.md for full methodology.
-
.NET 10 SDK (10.0.200-preview or later)
- Download from dotnet.microsoft.com
- Verify:
dotnet --versionshould show10.0.x
-
Visual Studio 2022 (17.2 or later) with WPF build workloads
- Install via the Visual Studio Installer
- Import the workload configuration file:
wpf\Documentation\wpf.vsconfig- In the VS Installer, click More > Import configuration and select the file
- Or manually ensure these workloads/components are installed:
- .NET desktop development workload
- Desktop development with C++ workload (required for
DirectWriteForwarderC++/CLI project) - Individual components:
- MSVC C++ build tools (v143 or later)
- C++/CLI support
- Windows 10 SDK (10.0.19041.0)
- .NET Framework 4.7.2 targeting pack
- ATL and MFC libraries
- Text Template Transformation
-
Windows 10 (build 19041 or later) or Windows 11
- PerfView -- for ETW trace analysis (see
wpfaot-plan.mdsection 6.2) - Inspect.exe (from Windows SDK) -- for verifying UI Automation tree
git clone https://github.com/ibebbs/wpfaot.git
cd wpfaotThis is a mono-repo containing both the WPF fork and the sample app. The WPF fork is also independently available at ibebbs/wpf@feature/native-aot (based on dotnet/wpf release/10.0).
wpfaot/
├── wpfaot/ # Sample AOT calculator app
│ ├── wpfaot.csproj # .NET 10, PublishAot=true, feature switches
│ ├── TrimmerRoots.xml # Trimmer preservation descriptors
│ ├── MainWindow.xaml # Calculator UI with {CompiledBinding}
│ ├── CalculatorViewModel.cs # ViewModel (INPC + ICommand)
│ ├── DelegateCommand.cs # ICommand implementation
│ └── Fonts/ # Embedded font resources
├── wpf/ # WPF fork (modified dotnet/wpf)
│ ├── build.cmd # WPF build script
│ ├── Microsoft.Dotnet.Wpf.sln
│ └── src/Microsoft.DotNet.Wpf/src/
│ ├── WindowsBase/ # Feature switches, AOT-safe helpers
│ ├── PresentationCore/ # DirectWrite COM source gen, rendering
│ ├── PresentationFramework/ # CompiledBinding, XAML, themes
│ ├── System.Xaml/ # AOT-safe XAML runtime
│ ├── CompiledBinding.Generator/ # Roslyn source generator
│ └── UIAutomation*/ # UIA interfaces ([GeneratedComInterface])
├── scripts/ # Build and diagnostic scripts
│ ├── collect-perf.ps1 # Performance benchmark script
│ ├── Capture-WpfEtw.ps1 # ETW trace capture
│ └── Run-WpfaotDiagnostics.ps1 # Render diagnostics
├── wpfaot-plan.md # Detailed implementation plan and phase records
└── wpfaot-perf.md # Performance comparison results
There are three ways to build and run the sample app, depending on whether you want to use the WPF fork or the built-in framework WPF.
Uses the built-in .NET 10 WPF assemblies. No fork changes are applied, but this is the fastest way to verify the app builds and publishes with AOT.
cd wpfaot
dotnet publish -c Release -r win-x64 -p:WpfFrameworkOnly=trueThe published executable is at:
wpfaot\bin\Release\net10.0-windows\win-x64\publish\wpfaot.exe
Note: With framework WPF,
{CompiledBinding}and the source generator are not available (they live in the fork). The app will build but bindings using{CompiledBinding}will not resolve.
Build the WPF fork first, then build/publish the sample app against the fork's packaging output. This is the recommended workflow -- it uses all fork changes and does not require Visual Studio's MSBuild for the app build.
Step 1: Build the WPF fork
cd wpf
.\build.cmd -configuration Release -platform x64 -packThis takes approximately 2-4 minutes on first build. The build may report "Native tools bootstrap failed" for strawberry-perl, net-framework-48-ref-assemblies, and windows-sdk-d3d-redist -- these are optional and the build will continue without them (see wpfaot-plan.md section 2.1 for details).
Build output lands in:
wpf\artifacts\packaging\Release\Microsoft.DotNet.Wpf.GitHub\lib\net10.0\
Step 2: Build and publish the sample app
cd wpfaot
dotnet build -c Release
dotnet publish -r win-x64 -c ReleaseThe wpfaot.csproj auto-detects the fork packaging output. When it exists, the app references the fork DLLs automatically.
Step 3: Run
.\bin\Release\net10.0-windows\win-x64\publish\wpfaot.exeYou should see a dark-themed calculator window with a live clock, display, memory register (TwoWay binding), and a full button grid (ICommand binding).
Builds the WPF fork as part of the app build via ProjectReference. Requires Visual Studio's MSBuild (Developer Command Prompt) because the fork includes the C++/CLI DirectWriteForwarder project.
:: Open "Developer Command Prompt for VS 2022"
msbuild wpfaot\wpfaot.csproj /p:Configuration=Release /p:WpfUsePackagingOutput=falseThis is slower (rebuilds the fork) but useful if you are iterating on fork changes without running build.cmd separately.
The sample app can be published in three deployment modes:
| Mode | Command | Description |
|---|---|---|
| Framework-dependent | dotnet publish -c Release -r win-x64 --no-self-contained |
Smallest output; requires .NET 10 runtime installed |
| Self-contained | dotnet publish -c Release -r win-x64 --self-contained -p:PublishAot=false |
Includes full .NET runtime; no install required |
| Native AOT | dotnet publish -c Release -r win-x64 |
Single native exe; fastest startup; no runtime required |
To collect performance comparisons across all three modes:
.\scripts\collect-perf.ps1Results are written to wpfaot-perf.md.
The following features are fully functional in the Native AOT published app:
| Feature | Details |
|---|---|
| Window with themed content | Aero2 theme applied correctly via targeted trimmer roots |
| Text rendering (system fonts) | System font collection via DirectWrite works natively |
| Text rendering (embedded fonts) | Embedded .ttf fonts via pack:// URIs; custom DWrite font loaders use [GeneratedComInterface]/[GeneratedComClass] |
Compiled bindings ({CompiledBinding}) |
Reflection-free data binding via WPF fork markup extension + Roslyn source generator. Supports OneWay, TwoWay, and ICommand/DelegateCommand |
Standard bindings ({Binding}) |
Works in AOT if view model types are preserved in TrimmerRoots.xml (reflection-based; {CompiledBinding} is preferred) |
| Button / ICommand | DelegateCommand pattern with CommandParameter dispatch |
| TextBox (TwoWay binding) | Editable TextBox with TwoWay compiled binding to ViewModel |
| Keyboard input and focus | Keyboard focus events and routed events work; TSF/IME is disabled |
| Timer-driven UI updates | DispatcherTimer-based clock demonstrates dynamic INPC updates |
These subsystems are disabled in the sample app via [FeatureSwitchDefinition] feature switches in the .csproj. When disabled, the trimmer eliminates all associated code. They can be re-enabled by setting the switch to true (but some require COM source generation work to function in AOT):
| Feature | Switch | Status |
|---|---|---|
| Drag and drop | System.Windows.Features.DragDrop |
Gated; no-op in AOT. COM source generation needed for full support |
| TSF / IME (input methods) | System.Windows.Features.TextServices |
Gated; no-op in AOT. ~50 COM interfaces would need source generation |
| Spell checking | System.Windows.Features.SpellCheck |
Gated; returns null. COM source generation needed |
| Printing | System.Windows.Features.Printing |
Gated; limited. IStream COM source generation needed |
| Accessibility / UI Automation | System.Windows.Features.Accessibility |
Phase 11 in progress: UIA interfaces converted to [GeneratedComInterface], implementation classes annotated with [GeneratedComClass]. Stages 3-6 remaining (P/Invoke updates, guard removal, testing) |
To configure feature switches in your app's .csproj:
<ItemGroup>
<!-- Disable a feature: trimmer removes all associated code -->
<RuntimeHostConfigurationOption Include="System.Windows.Features.SpellCheck"
Value="false" Trim="true" />
<!-- Enable a feature (default): code is preserved -->
<RuntimeHostConfigurationOption Include="System.Windows.Features.Accessibility"
Value="true" Trim="true" />
</ItemGroup>| Limitation | Details |
|---|---|
| Partial-trust XAML | Out of scope. AOT apps must run full trust |
| Dynamic assembly loading | Assembly.Load by name, plugin assemblies, and GAC resolution are not supported. All assemblies must be statically referenced at build time |
| Taskbar / JumpList | Uses COM (Type.GetTypeFromCLSID + Activator.CreateInstance); not yet gated. Will crash if used in AOT |
| ActiveX / PeoplePicker | Uses COM; not yet gated. Will crash if used in AOT |
| Unbounded type resolution | Only XAML types that are statically referenced by the app (or listed in TrimmerRoots.xml) are supported. Types resolved purely by string at runtime may be trimmed |
- Binary size: The AOT executable is ~55 MB. This is larger than ideal because WPF's BAML known types system statically references 759 types via
typeof(), preventing the trimmer from removing them. Theme BAML resources also contribute ~10 MB of embedded data. - Trimmer roots: New WPF controls used in XAML may require additional entries in
TrimmerRoots.xmlfor their theme style triggers (e.g.,TextBoxneededBooleanConverterandFrameworkAppContextSwitches). {CompiledBinding}scope: Currently supports simple property paths,INotifyPropertyChanged,DependencyProperty(TwoWay),IValueConverter, OneWay/TwoWay/OneTime modes. Not yet supported: dotted paths (Address.City), collection bindings,MultiBinding,RelativeSource,ElementName,StringFormat, indexers.
The fork uses RuntimeFeature.IsDynamicCodeSupported to detect AOT at runtime. When false (Native AOT), code paths that use Reflection.Emit, Assembly.Load, or built-in COM interop are bypassed:
if (!RuntimeFeature.IsDynamicCodeSupported)
{
// AOT path: use statically-safe alternative
return;
}
// JIT path: original behaviorFeature switches use [FeatureSwitchDefinition] (introduced in .NET 8) so the ILC trimmer can substitute the property to false at compile time and eliminate all guarded code:
[FeatureSwitchDefinition("System.Windows.Features.SpellCheck")]
internal static bool IsSpellCheckSupported =>
AppContext.TryGetSwitch("System.Windows.Features.SpellCheck", out bool v) ? v : true;Feature switches are defined in WindowsBase\MS\Internal\WpfFeatureSwitches.cs.
The {CompiledBinding} system has two parts:
-
Runtime (in WPF fork's PresentationFramework):
CompiledBindingExtension-- XAML markup extensionCompiledBindingRegistry-- static accessor dictionaryCompiledBindingInstance-- binding lifecycle manager (DataContext tracking, INPC subscription)
-
Source generator (in
CompiledBinding.Generator):- Roslyn
IIncrementalGeneratorthat scans XAMLAdditionalFiles - Finds
{CompiledBinding Type={x:Type ...}, Path=...}expressions - Emits
[ModuleInitializer]code that registers typed getter/setter lambdas
- Roslyn
Usage in XAML (no xmlns prefix needed -- it's in the WPF default namespace):
<TextBlock Text="{CompiledBinding Type={x:Type local:MyViewModel}, Path=Title}" />
<TextBox Text="{CompiledBinding Type={x:Type local:MyViewModel}, Path=Name, Mode=TwoWay}" />
<Button Command="{CompiledBinding Type={x:Type local:MyViewModel}, Path=SaveCommand}" />The TrimmerRoots.xml file tells the ILC trimmer which types to preserve. It contains targeted entries for:
- App XAML root types (
MainWindow,App,CalculatorViewModel,DelegateCommand) - Theme BAML types resolved by string at runtime (
{x:Static}targets, stream types not in the known types table, internal converters) - Core WPF assemblies (
PresentationCore,WindowsBase,DirectWriteForwarder,System.Xaml) -- included as roots to preserve metadata - Aero2 theme assembly -- loaded by
Assembly.Loadat runtime
When adding new control types to your XAML, check if their theme styles use triggers that reference types not in WPF's known types table. If the app crashes with a type resolution error, add the missing type to TrimmerRoots.xml.
The fork includes opt-in file-based diagnostics. Set the environment variable WpfAotDiagnostics=1 before running the app:
$env:WpfAotDiagnostics = "1"
.\wpfaot.exe
# Check output: $env:TEMP\wpfaot-render.logOr use the diagnostic script:
.\scripts\Run-WpfaotDiagnostics.ps1 -RunSeconds 6Check the Windows Event Log for .NET Runtime errors:
Get-WinEvent -FilterHashtable @{
LogName = 'Application'
ProviderName = '.NET Runtime'
} -MaxEvents 5 -ErrorAction SilentlyContinue | Format-List TimeCreated, Id, MessageAlso check the startup log:
Get-Content "$env:TEMP\wpfaot-startup.log"If the app crashes with ArgumentException mentioning a type that "cannot be resolved", that type needs to be added to TrimmerRoots.xml. The fork logs missing BAML types to %TEMP%\wpfaot-trimmed-types.log in AOT builds.
- Ensure Visual Studio 2022 is installed with the workloads from
wpf\Documentation\wpf.vsconfig - The "Native tools bootstrap failed" warnings for
strawberry-perl,net-framework-48-ref-assemblies, andwindows-sdk-d3d-redistare expected and harmless -- the build continues without them - If
DirectWriteForwarderfails, ensure the C++/CLI workload and Windows 10 SDK are installed
Check binary size after a clean publish:
dotnet publish -r win-x64 -c Release
(Get-Item ".\bin\Release\net10.0-windows\win-x64\publish\wpfaot.exe").Length / 1MB- Native AOT deployment
- Introduction to AOT warnings
- Creating AOT-compatible libraries
- WPF developer guide
- Detailed implementation plan -- phase-by-phase record of all changes, decisions, and outcomes
- Performance comparison -- benchmark results across deployment modes