Alternative Gauntlet UI Code Generator
Note
This article is written for UIExtenderEx maintainers and core contributors.
It details the design, emission rules, and behavioral parity of the code generator in Bannerlord.UIExtenderEx.CodeGenerator.
For the high-level architecture, see Overview. For how compilation is coordinated, see Pipeline.
1. Overview & Architectural Philosophy
Bannerlord.UIExtenderEx.CodeGenerator provides an alternative implementation of the ahead-of-time Gauntlet UI code generator (TaleWorlds.GauntletUI.CodeGenerator) that TaleWorlds uses to produce the game's *.AutoGenerated*.dll assemblies.
While TaleWorlds' generator runs offline as a command-line build tool, UIExtenderEx's generator is designed specifically for in-memory, runtime execution against live, patched prefab XML. Its output is bound to the game's UI engine while maintaining strict behavioral fidelity.
The Guiding Principle: Parity with the XML Loader
The central design rule governing the generator is:
The Gauntlet runtime XML loader is the authoritative standard for UI behavior.
Every mod is developed and tested against the behavior of Gauntlet's XML loader. Furthermore, whenever runtime code generation or compilation fails, a movie transparently falls back to that same XML loader.
Where TaleWorlds' offline code generator and Gauntlet's runtime XML loader diverge, UIExtenderEx's generator intentionally aligns with the XML loader:
- Invalid or non-convertible attribute values do not crash the movie; they log an assertion and leave the property at its default, matching the XML loader.
- Unresolved properties fall back to dynamic, by-name binding instead of being silently omitted.
- Missing ViewModel mixins or data source instances fail gracefully rather than throwing null reference exceptions.
Behavioral equivalence is continuously validated by automated test suites (GameGeneratorEquivalenceTests, LoaderOracle, and BindingLoaderOracle).
2. Generator Entry Point & Output Structure
The code generator is invoked in memory during the compilation pipeline:
var generator = new PrefabCodeGenerator(
CompiledPrefabManager.GeneratedNamespace,
widgetFactory,
spriteData,
brushFactory);
generator.AddMovie(movieName, viewModelType.FullName, viewModelType);
List<KeyValuePair<string, string>> files = generator.GenerateInMemory();
Key Operational Differences from the Game's Generator
- In-Memory Generation: Emits C# source code directly to string buffers rather than reading and writing loose files on disk.
- Shared Resource Re-use: Consumes the game's already initialized
WidgetFactory,SpriteData, andBrushFactoryinstances instead of reloading them from disk. - Null ViewModel Support: Passing a
nulldata source type generates the static widget hierarchy without databinding. This mode is used by test oracles (LoaderOracle) to compare raw widget layout trees against the XML loader.
Output Files
GenerateInMemory returns a list of source file pairs:
<Movie>.gen.cs: Contains the root widget class and all inlined sub-prefab dependency classes required by the movie.PrefabCodes.gen.cs: DeclaresGeneratedUIPrefabCreator, exposing factory methods (Create<Class>) for each movie variant, andCollectGeneratedPrefabDefinitions(GeneratedPrefabContext)to register them into Gauntlet's context.IgnoresAccessChecks.gen.cs: Appended byCompiledPrefabManagerto allow generated code to accessinternalViewModel and mixin members without publicization.
Strict Dependency Hygiene (No using Directives)
Generated source files contain zero using directives. Every referenced type is fully qualified starting from global:: (via ViewModelMemberResolution.GetCodeTypeName):
// Example generated instantiation
global::TaleWorlds.GauntletUI.BaseTypes.Widget _widget_0 = new global::TaleWorlds.GauntletUI.BaseTypes.Widget(this.Context);
Rationale: If generated code imported namespaces (such as using TaleWorlds.Library;), any newly loaded assembly could introduce types or extension methods that alter binding resolution without warning. Using fully qualified names ensures generated assemblies only depend on the exact assemblies recorded in their dependency manifests.
Identifier Sanitization (GeneratedNaming)
Prefab names, ViewModel types, and property names are sanitized into valid C# identifiers using GeneratedNaming.GetUsableName:
- Dots (
.) are converted to underscores (_). - Characters invalid in C# identifiers (e.g.,
+for nested classes, or hyphens and spaces in XML filenames) are encoded as hexadecimal Unicode points enclosed by underscores (e.g.,_002B_). - Identifiers starting with a digit are prefixed with an underscore.
- ViewModel members colliding with C# keywords are escaped with an
@prefix (e.g.,@event). - Sibling name collisions in the same scope (e.g., visual definitions
Tab.LeftandTab_Left) are resolved viaGeneratedNaming.GetUniqueName, which appends an index to subsequent entries while preserving the first.
3. Namespace & Code Organization
The generator resides in the Bannerlord.UIExtenderEx.GauntletUI.CodeGenerator namespace, partitioned by responsibility:
| Subnamespace / Folder | Primary Types | Description |
|---|---|---|
| Root | PrefabCodeGeneratorCodeGeneratorEnvironmentTypeDependencies |
Main entry point; host environment hooks (runtime registrations, dotted-path flags, patched method flags); dependency tracking sessions. |
Prefabs/ |
PrefabClassPrefabClassOptionsMovieClassesPrefabValuesConstantResourceAnalysis |
Models generated widget classes (movies, base prefabs, used prefabs, list templates); handles prefab parameters, visual definitions, and resource-dependent constants. |
Widgets/ |
WidgetNodeWidgetFactoryLookupWidgetPropertyPathWidgetRaisesWidgetFastAssignmentLoaderStringConversion |
Models individual widgets in a template; resolves widget classes; walks property paths; inspects widget IL for change notifications; evaluates attribute string conversions. |
Databinding/ |
WidgetBindingDatabindingEmitterDataSourceScopeOuterPathViewModelPaths |
Emits databinding logic: property bindings, commands, list item scopes, mixin receivers, and outer scope traversals (^n\Rest). |
CSharp/ |
GeneratedLiteralGeneratedNaming |
Formatting utilities for C# literals (strings, floats, comments) and identifier sanitization. |
Extensions/ |
MethodCodeExtensionsKeyValuePairExtensions |
Helpers for emitting structured code blocks and NetStandard 2.0 utility polyfills. |
Runtime/ |
DynamicMemberIDynamicMemberHostLoaderAsserts |
Runtime helper library called by generated assemblies for by-name dynamic binding fallbacks and loader-equivalent assertions. |
4. Intended Behavioral Deviations from TaleWorlds' Generator
GameGeneratorEquivalenceTests generates C# source for the entire suite of Native prefabs using both generators and asserts text equivalence. The differences between the two represent deliberate improvements to match the Gauntlet XML loader:
Architectural Differences
- No
usingDirectives: Eliminates unintended namespace pollution and guarantees reproducible assembly dependency tracking. - Creator Class Isolation:
GeneratedUIPrefabCreatoromitsIGeneratedUIPrefabCreator. This prevents Gauntlet's eager assembly scanner from registering cached assemblies prematurely on startup before fingerprint validation has completed. - No Erroneous Conversions on Write-Back: When two-way bindings write back to a ViewModel property, TaleWorlds' generator appends
.Name(for sprites) or.ToString()(for colors), which Gauntlet's XML loader never does. UIExtenderEx writes back the exact value, matchingViewModel.SetPropertyValue. - Clearing
SpriteandBrushon Null Strings: In the XML loader,WidgetExtensions.ConvertObjectpasses null strings directly to property setters, clearing them. TaleWorlds' generator guarded assignments against null, leaving previous values intact. UIExtenderEx replicates the loader's clearing behavior. - Live Widget Search for Attribute Targets: TaleWorlds' generator resolved widget references against the compile-time template, which fails when widgets are relocated to a prefab's logical children location. UIExtenderEx emits a live
FindChildquery matchingSetWidgetAttributeFromString.
Databinding & Lifecycle Differences
- Command Listener Detach/Attach Ordering: In
GauntletView.RefreshBinding, listeners are detached prior to property assignment and reattached afterward. UIExtenderEx mirrors this order and correctly attaches command listeners even when a view's data source isnull, enabling widgets in null scopes to fire commands against parent scopes. - Deterministic List Item Construction: When binding a list, the XML loader adds items sequentially via
GauntletView.AddItemToList. Template selection depends on the item's instantaneous index rather than final list count. UIExtenderEx instantiates items at the end of the widget list, sets IDs and attributes, and repositions them to match the XML loader's exact sibling index and render seed (Widget._seed). - Ubiquitous
GeneratedWidgetData: TaleWorlds' generator only attachedGeneratedWidgetDatacomponents to drag targets and list items. UIExtenderEx attaches it to all widgets, allowing commands with widget parameters (GauntletView.ConvertCommandParameter) to inspect the data source of any widget. - Explicit Empty IDs: When a widget template specifies no ID,
WidgetTemplate.CreateWidgetsassignswidget.Id = "". UIExtenderEx explicitly emits these assignments, preventing embedded prefabs from inadvertently inheriting IDs from surrounding templates. - Selective Notification Subscriptions (
WidgetRaises): Rather than indiscriminately subscribing to all nine Gauntlet property-change notifications across every bound widget, UIExtenderEx analyzes widget class IL at generation time to discover which notifications each property actually raises. This reduces generated subscription boilerplate by over 80% (e.g., reducingLobbyUI bindings from 40,365 lines to 4,727 lines). - Guard Wrapping with
LoaderAsserts: All attribute assignments are wrapped in try/catch blocks that invokeLoaderAsserts, reporting failures viaDebug.FailedAssertwith identical messaging toSetWidgetAttributeFromStringwithout interrupting UI loading. - Round-Trip Float Formatting: Numbers and color channels are formatted using invariant culture and full round-trip precision (
GeneratedLiteral.Float), preventing precision drift (e.g.,#F3DCB8shifting to#F3DCB7on .NET Framework).
5. Data Sources & Scope Hierarchy
Data sources are evaluated according to the rules of PrefabDatabindingExtension.AfterAttributesSet:
[Movie Root] (Bound to Root ViewModel)
│
├─► [Child Widget] (Inherits parent scope or declares DataSource="{Path}")
│ │
│ └─► [Used Prefab Root]
│ ├─ If caller binds nothing: receives parent scope; applies RootDataSource.
│ └─ If caller binds attributes: caller adopts DataSource (IgnoresRootDataSource).
│
└─► [List Item Widget] (Bound to individual element instance in IMBBindingList)
│
└─► [Outer Path] (^2\Rest: accesses parent scope 2 levels above item root)
Scope Resolution Rules
- Movie Root: The root widget of a movie is always initialized at
Root, regardless of anyDataSourceattribute declared on its XML root element. - List Items: The root widget of an item template is bound directly to the item instance at its respective index.
- Embedded Prefab Integration: An embedded prefab and the widget referencing it share a single conceptual view:
- If the referencing widget binds no attributes: The generated class receives the surrounding scope and applies its declared root
DataSourceinternally (PrefabClassOptions.RootDataSource). Paths stepping above its root (e.g.,{..\Hint}) remain valid. - If the referencing widget binds attributes or commands: The referencing widget adopts the data source, and the embedded class is configured with
IgnoresRootDataSourceto prevent duplicate binding listeners.
- If the referencing widget binds no attributes: The generated class receives the surrounding scope and applies its declared root
- Outer Paths (
^n\Rest): Paths that step above the root of an embedded prefab or list item (e.g.,{..\..\ActiveService}) cannot be resolved statically. These are modeled asOuterPathinstances. The parent class resolves the path within its own scope and passes it down viaSetOuterDataSourcewhenever its scope refreshes. - Raw Binding Paths: Paths climbing above the movie root (e.g.,
..\ExecuteDoneatRoot) simplify to the root itself, matching Gauntlet'sGetViewModelAtPath.
6. Command Binding & Invocation
When a Gauntlet event fires a command (e.g., Command.Click="ExecuteAction"), execution is routed through GauntletView.OnCommand and ViewModel.ExecuteCommand.
Invocation Rules
UIExtenderEx generates typed invocation code that strictly reproduces the behavior of ViewModel.ExecuteCommand:
- Parameterless Methods: Execute regardless of how many arguments the Gauntlet UI event provided.
- Parameter Matching: If the method accepts parameters, the argument count must match the parameter count; otherwise, execution is aborted.
- Type Coercion: String arguments targeted at non-string parameters are converted using Gauntlet's internal
ConvertValueTorules (integers viaConvert.ToInt32, floats via invariantConvert.ToSingle, and unsupported conversions tonull). - Type Compatibility: Arguments must be assignable (
IsAssignableFrom) without widening; otherwise, the call is aborted. - Exception Handling: Exceptions thrown by the command method are wrapped in a
TargetInvocationException, matching reflection behavior.
Dynamic Command Fallback
If a command target cannot be resolved statically at generation time (e.g., non-public base class methods, generic methods, or paths stepping into un-annotated child ViewModels like Options\ExecuteReset), UIExtenderEx emits a dynamic call via DynamicMember.Execute:
global::Bannerlord.UIExtenderEx.GauntletUI.CodeGenerator.Runtime.DynamicMember.Execute(
_datasource_Root, "ExecuteReset", "OptionalStringParameter");
7. ViewModel Member Resolution & Mixins
ViewModelMemberResolution handles all member lookups for databinding:
Lookup Priority
- Properties (
GetProperty):- First: Queries
MixinMemberResolverfor active ViewModel mixins providing the property. - Second: Queries public instance properties on the target ViewModel class.
- Non-public properties are rejected: Gauntlet's XML loader cannot bind non-public properties; generating code for them would cause divergence between XML and compiled modes.
- First: Queries
- Methods (
GetMethod):- First: Queries
MixinMemberResolverfor mixins. - Second: Queries public and non-public instance methods on the ViewModel class (since
ViewModel.ExecuteCommandsearches private base methods).
- First: Queries
- Missing Members (
DeclaresNoMember):- If a
DataSourcepath traverses a member that exists but is not publicly readable (e.g., a private property), the generator declines the movie and falls back to XML. - If an attribute binding targets such a member, the binding is omitted (matching the XML loader, which leaves it unbound), and the movie still compiles.
- If the type declares no member with that name at all, the generator emits a dynamic by-name binding.
- If a
Mixin Receivers
To minimize allocation and reflection overhead, mixin members are accessed through cached mixin receivers emitted directly into the generated class:
private global::TaleWorlds.Library.ViewModel _mixinOwner_0;
private MyCustomMixin _mixin_0;
private MyCustomMixin GetMixin_0()
{
if (!object.ReferenceEquals(_datasource_Root, _mixinOwner_0))
{
_mixinOwner_0 = _datasource_Root;
_mixin_0 = global::Bannerlord.UIExtenderEx.ViewModels.ViewModelMixins.Get<MyCustomMixin>(_datasource_Root);
}
return _mixin_0;
}
Performance Impact
- Cached Receiver: 1.4 ns per access.
- Direct
ViewModelMixins.Get<T>: 139 ns and 30 bytes allocated per access. - Dynamic By-Name Fallback: 81 ns per access.
8. Dynamic Member & By-Name Runtime
The game's own KingdomManagement depends on this. KingdomDecision.xml binds DataSource="{CurrentDecision}", a property declared as DecisionItemBaseVM, and SettlementDecisionPanel.xml binds {NotableCharacters}, which only the subclass SettlementDecisionItemVM declares. Without by-name binding that movie does not compile (GameMovieGenerationTests).
Only bindings the generator cannot resolve take this path. Measured on net472 Release: a by-name read plus widget assignment takes about 105 ns, against about 5 ns typed. A typed notification including the writeback takes about 176 ns, against about 22 ns. Per movie the difference stays small. With 50 list items whose item bindings all go by name, a movie opened in 0.61–0.85 ms, against 0.52–0.57 ms fully resolved and 2.0–2.5 ms from XML (CompiledVersusXmlBenchmarks, 2026-09-21). Nothing on this path is cached; the DynamicMember class comment says why.
When a binding references a property that cannot be statically verified (such as a derived ViewModel injected into a list, or an untyped object property), UIExtenderEx utilizes DynamicMember:
// Emitted by-name binding fallback
object uiExtenderExValue = global::Bannerlord.UIExtenderEx.GauntletUI.CodeGenerator.Runtime.DynamicMember.Get(
_datasource_Root, "CustomDynamicProperty");
if (uiExtenderExValue is bool typedValue)
{
_widget_0.IsVisible = typedValue;
}
else
{
global::TaleWorlds.GauntletUI.PrefabSystem.WidgetExtensions.SetWidgetAttribute(
this.Context, _widget_0, "IsVisible", uiExtenderExValue);
}
The DynamicMember API
Located in Bannerlord.UIExtenderEx.GauntletUI.CodeGenerator.Runtime:
Get(target, name): CallsViewModel.GetPropertyValue.Set(target, name, value): Writes the property asViewModel.SetPropertyValuewould, delegating to the installed host'sTrySetPropertymethod when present.Execute(target, name, preparedArguments): CallsViewModel.ExecuteCommand.GetChild(target, path): Traverses one step down an untyped object graph.GetListItem(list, index): Retrieves a list element by integer index.Step(target, segment): Unified traversal over either ViewModel properties or list indices.
9. Typed Payloads & Widget Fast-Path
Gauntlet widgets notify changes via PropertyChangedWithValue events carrying typed payloads. WidgetFastAssignment bypasses reflection (WidgetExtensions.SetWidgetAttribute) whenever direct assignment is type-safe.
Direct Assignment Eligibility
A property assignment is eligible for fast-path compilation if:
- It consists of a single property path segment (non-dotted).
- The widget setter is public and accepts exactly one argument.
- The property is not an indexer or nullable type.
- The property type is directly referenceable by the generated assembly.
Specialized Payload Handlers
Generated classes emit dedicated notification handlers for each payload type (bool, int, float, uint, Color, double, Vec2, and object). This allows incoming change events to be assigned to widget fields directly without CLR object boxing.
10. Resource-Dependent Constants
Gauntlet prefab XML allows constants to query runtime sprite and brush layer dimensions:
<Constant Name="Header.Width" SpriteName="General\Header" SpriteValueType="Width" />
Because texture packs and UI reskins can modify sprite sheet dimensions at runtime without altering prefab XML, baking these values as static literals at generation time causes visual bugs.
Dynamic Resolution Mechanism
- Dependency Analysis:
ConstantResourceAnalysis.DependsOnResourcesrecursively traverses constant definitions to identify any constant that transitively references a sprite or brush dimension. - Emitted Resolver Method: The generator emits
UIExtenderEx_ResolveResourceValueinto the class body. - Runtime Context Evaluation: When widgets are instantiated, the resolver evaluates dimensions dynamically against the active
UIContext, ensuring custom textures are sized correctly without requiring assembly recompilation.
11. Generation Failure & Decline Conditions
If code generation cannot produce a correct assembly, it aborts cleanly, records the cause under Failed/<Movie>/<ViewModel>/errors.txt, and allows the movie to load via the XML loader.
A movie declines code generation under the following conditions:
- Malformed Prefabs: Patch fragments improperly placed in
GUI/Prefabslacking root templates. - Circular Prefab Inheritance: Prefab replacement patches creating infinite recursive base class chains (
PrefabClass.GetBaseTypeName). - Inaccessible Properties: Property paths referencing non-public getters.
- Type Contradictions: Data sources used simultaneously as a list item template and an object scope.
- Recursive Constants: Constants referencing themselves in cyclical loops (
ConstantResourceAnalysis.ReferencesItself), which would otherwise trigger stack overflows. - Unbounded Recursive Scope Climbing: A self-referential prefab whose instances nest progressively deeper while their outer paths climb progressively higher. Supporting this would require generating an infinite number of outer data source parameters; native Gauntlet XML also throws an unhandled exception on such prefabs.
Outer paths of arbitrary depth are fully supported. Gauntlet's XML loader simplifies paths by canceling .. segments against preceding property steps. When a path climbs above the movie root, Gauntlet evaluates it as follows:
- One level above root (e.g.,
{..}): CancelsRoot, leaving an empty path that re-roots to the movie root. - Two levels above root (e.g.,
{..\..}): Resolves directly to the movie root. - Three or more levels above root (e.g.,
{..\..\..}): Attempts to look up a property named..on the root ViewModel. Because no ViewModel declares a property named.., this always evaluates tonull.
Consequently, a generated class does not need outer data sources for paths that climb at least three levels beyond the deepest instance of that class below the movie root (OuterPath.NullFrom, calculated across all instantiation sites via DatabindingEmitter.SettleRootDepths). These scopes remain null, matching XML behavior. For paths reaching one or two levels above the movie root, the host class resolves and hands down the appropriate data source.
12. Verification & Test Oracles
UIExtenderEx enforces generator correctness through automated oracles:
Static Oracle (LoaderOracle)
Instantiates prefabs twice—once via WidgetPrefab.Instantiate (XML loader) and once via the compiled class. It constructs deep snapshots of the resulting widget graphs (WidgetSnapshot), comparing every public property, field, visual definition, and Debug.FailedAssert call to guarantee parity.
Dynamic Binding Oracle (BindingLoaderOracle)
Synthesizes ViewModels with randomized mock data matching the prefab's binding expressions (ViewModelSynthesizer). It runs the XML and compiled movies in parallel, firing property change events, list mutations, and commands in cycles to ensure dynamic behavior is identical.
For full architectural details on test runners, chunk isolation, and benchmarks, see Testing.
Related Documentation
- Overview: Architecture overview and runtime split.
- Pipeline: Lifecycle of movie loading, fingerprinting, and cache management.
- Deviations from XML: Complete list of intentional differences between compiled code and the XML loader.
- Testing: Comprehensive documentation of unit test suites, harnesses, and test execution.
This page was last modified at 10/05/2026 13:34:58 +03:00 (UTC).
Commit Message
Author: Vitalii Mikhailov
Commit: f3b725a97c3f299c20e56b5f206e27142ec15edd
* Added C# Auto-Generated XML prefab compilation for patched and dynamic movies, matching vanilla UI performance
* Added name-based property binding fallback for ViewModel subtypes not declared on the parent
* Improved unparseable prefab error logging with name and file details
* Fixed game crash with 3-segment attribute paths
* Added Bannerlord.UIExtenderE...