Table of Contents

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, and BrushFactory instances instead of reloading them from disk.
  • Null ViewModel Support: Passing a null data 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:

  1. <Movie>.gen.cs: Contains the root widget class and all inlined sub-prefab dependency classes required by the movie.
  2. PrefabCodes.gen.cs: Declares GeneratedUIPrefabCreator, exposing factory methods (Create<Class>) for each movie variant, and CollectGeneratedPrefabDefinitions(GeneratedPrefabContext) to register them into Gauntlet's context.
  3. IgnoresAccessChecks.gen.cs: Appended by CompiledPrefabManager to allow generated code to access internal ViewModel 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.Left and Tab_Left) are resolved via GeneratedNaming.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 PrefabCodeGenerator
CodeGeneratorEnvironment
TypeDependencies
Main entry point; host environment hooks (runtime registrations, dotted-path flags, patched method flags); dependency tracking sessions.
Prefabs/ PrefabClass
PrefabClassOptions
MovieClasses
PrefabValues
ConstantResourceAnalysis
Models generated widget classes (movies, base prefabs, used prefabs, list templates); handles prefab parameters, visual definitions, and resource-dependent constants.
Widgets/ WidgetNode
WidgetFactoryLookup
WidgetPropertyPath
WidgetRaises
WidgetFastAssignment
LoaderStringConversion
Models individual widgets in a template; resolves widget classes; walks property paths; inspects widget IL for change notifications; evaluates attribute string conversions.
Databinding/ WidgetBinding
DatabindingEmitter
DataSourceScope
OuterPath
ViewModelPaths
Emits databinding logic: property bindings, commands, list item scopes, mixin receivers, and outer scope traversals (^n\Rest).
CSharp/ GeneratedLiteral
GeneratedNaming
Formatting utilities for C# literals (strings, floats, comments) and identifier sanitization.
Extensions/ MethodCodeExtensions
KeyValuePairExtensions
Helpers for emitting structured code blocks and NetStandard 2.0 utility polyfills.
Runtime/ DynamicMember
IDynamicMemberHost
LoaderAsserts
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

  1. No using Directives: Eliminates unintended namespace pollution and guarantees reproducible assembly dependency tracking.
  2. Creator Class Isolation: GeneratedUIPrefabCreator omits IGeneratedUIPrefabCreator. This prevents Gauntlet's eager assembly scanner from registering cached assemblies prematurely on startup before fingerprint validation has completed.
  3. 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, matching ViewModel.SetPropertyValue.
  4. Clearing Sprite and Brush on Null Strings: In the XML loader, WidgetExtensions.ConvertObject passes 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.
  5. 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 FindChild query matching SetWidgetAttributeFromString.

Databinding & Lifecycle Differences

  1. 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 is null, enabling widgets in null scopes to fire commands against parent scopes.
  2. 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).
  3. Ubiquitous GeneratedWidgetData: TaleWorlds' generator only attached GeneratedWidgetData components 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.
  4. Explicit Empty IDs: When a widget template specifies no ID, WidgetTemplate.CreateWidgets assigns widget.Id = "". UIExtenderEx explicitly emits these assignments, preventing embedded prefabs from inadvertently inheriting IDs from surrounding templates.
  5. 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., reducing Lobby UI bindings from 40,365 lines to 4,727 lines).
  6. Guard Wrapping with LoaderAsserts: All attribute assignments are wrapped in try/catch blocks that invoke LoaderAsserts, reporting failures via Debug.FailedAssert with identical messaging to SetWidgetAttributeFromString without interrupting UI loading.
  7. 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., #F3DCB8 shifting to #F3DCB7 on .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 any DataSource attribute 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 DataSource internally (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 IgnoresRootDataSource to prevent duplicate binding listeners.
  • 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 as OuterPath instances. The parent class resolves the path within its own scope and passes it down via SetOuterDataSource whenever its scope refreshes.
  • Raw Binding Paths: Paths climbing above the movie root (e.g., ..\ExecuteDone at Root) simplify to the root itself, matching Gauntlet's GetViewModelAtPath.

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:

  1. Parameterless Methods: Execute regardless of how many arguments the Gauntlet UI event provided.
  2. Parameter Matching: If the method accepts parameters, the argument count must match the parameter count; otherwise, execution is aborted.
  3. Type Coercion: String arguments targeted at non-string parameters are converted using Gauntlet's internal ConvertValueTo rules (integers via Convert.ToInt32, floats via invariant Convert.ToSingle, and unsupported conversions to null).
  4. Type Compatibility: Arguments must be assignable (IsAssignableFrom) without widening; otherwise, the call is aborted.
  5. 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

  1. Properties (GetProperty):
    • First: Queries MixinMemberResolver for 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.
  2. Methods (GetMethod):
    • First: Queries MixinMemberResolver for mixins.
    • Second: Queries public and non-public instance methods on the ViewModel class (since ViewModel.ExecuteCommand searches private base methods).
  3. Missing Members (DeclaresNoMember):
    • If a DataSource path 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.

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): Calls ViewModel.GetPropertyValue.
  • Set(target, name, value): Writes the property as ViewModel.SetPropertyValue would, delegating to the installed host's TrySetProperty method when present.
  • Execute(target, name, preparedArguments): Calls ViewModel.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:

  1. It consists of a single property path segment (non-dotted).
  2. The widget setter is public and accepts exactly one argument.
  3. The property is not an indexer or nullable type.
  4. 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

  1. Dependency Analysis: ConstantResourceAnalysis.DependsOnResources recursively traverses constant definitions to identify any constant that transitively references a sprite or brush dimension.
  2. Emitted Resolver Method: The generator emits UIExtenderEx_ResolveResourceValue into the class body.
  3. 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/Prefabs lacking 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., {..}): Cancels Root, 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 to null.

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.


  • 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...