Part 1 covered the core concepts behind Trident's DOM: type descriptions, nodes, properties, and adapters. This post puts those pieces to work with a real example. The keymap editor is one of the smallest asset editors in the engine, which makes it a good candidate for showing how much the DOM handles for you.

Before I get into it, one important point I forgot to mention in Part 1: the DOM is purely an authoring-time system. It is part of the editor and the asset builder tooling. It is never included in, or linked into, the game runtime. The game loads cooked binary assets produced by the asset builder; it has no concept of DOM nodes, adapters, or schemas. This keeps the runtime lean and free of editor dependencies. A downside is that the DOM relies on bubbling reactive event propagation, has an inherently boxy nature, and carries a fair bit of architectural weight. It's a little too meaty to be suitable for high-performance use at runtime.

#The schema

Here is the complete schema for the keymap asset type:

<Schema Namespace="KeymapEditor.Models" Usings="Trident.Input">

    <Type Name="KeyComboInput">
        <Property Name="Button" Type="InputButton" />
        <Property Name="Axis" Type="InputAxis" />
    </Type>

    <Type Name="AxisInput">
        <Property Name="Axis" Type="InputAxis" />
    </Type>

    <Type Name="OneButton">
        <Property Name="Button" Type="InputButton" />
        <Property Name="Value" Type="float" />
    </Type>

    <Type Name="TwoButton">
        <Property Name="LowerButton" Type="InputButton" />
        <Property Name="UpperButton" Type="InputButton" />
        <Property Name="LowerValue" Type="float" />
        <Property Name="UpperValue" Type="float" />
    </Type>

    <Type Name="ButtonInputValueButton">
        <Property Name="Button" Type="InputButton" />
    </Type>

    <Type Name="ButtonInputValueAxis">
        <Property Name="Axis" Type="InputAxis" />
        <Property Name="Operation" Type="AxisBoundaryOperation" />
        <Property Name="ReferenceValue" Type="float" />
    </Type>

    <Type Name="ButtonEntry">
        <Property Name="Key" Type="string" Default="string.Empty" />
        <Property Name="Trigger" Type="DomVariant" SubTypes="ButtonInputValueButton,ButtonInputValueAxis" />
    </Type>

    <Type Name="AxesEntry">
        <Property Name="Key" Type="string" Default="string.Empty" />
        <Property Name="Modifier" Type="InputModifier" />
        <Property Name="Value" Type="DomVariant" SubTypes="AxisInput,KeyComboInput,OneButton,TwoButton,AxesEntry" />
    </Type>

    <Type Name="KeyMap">
        <Property Name="Buttons" Type="DomNode[]" SubType="ButtonEntry" />
        <Property Name="Axes" Type="DomNode[]" SubType="AxesEntry" />
    </Type>
</Schema>

That is the entire data model for keymaps. KeyMap is the root type with two collections: Buttons (a list of ButtonEntry nodes) and Axes (a list of AxesEntry nodes). The source generator reads this at compile time and produces the type descriptions, adapters, and registry wiring. No manual boilerplate needed.

#DomVariant

The interesting bit here is DomVariant. Look at the AxesEntry type:

<Property Name="Value" Type="DomVariant" SubTypes="AxisInput,KeyComboInput,OneButton,TwoButton,AxesEntry" />

A DomVariant is a discriminated union for DOM properties. It says: this property holds exactly one node, but that node can be any of the listed types. At runtime, it stores a DomDescriptionVariant which tracks the currently selected type and its node instance.

In the keymap, an axis binding's value can be:

The property grid renders this as a dropdown. The user picks which variant they want, and the grid swaps in the matching fields below it. The ButtonEntry type uses the same mechanism for its Trigger property, which can be either ButtonInputValueButton or ButtonInputValueAxis.

Here is what that looks like in practice:

Keymap editor showing DomVariant dropdowns for button triggers and axis values

(Excuse my theming, it's a WIP!)

The coloured dropdown in each entry is the DomVariant selector. Switching it replaces the child properties underneath. In the screenshot, the first CameraYaw binding uses KeyComboInput (Button + Axis), the second uses TwoButton (LowerButton + UpperButton with float values), and CameraPitch uses KeyComboInput. The Trigger field on the button entry shows ButtonInputValueButton selected. All of this UI is generated automatically from the schema; none of it is hand-built.

The generated adapter exposes this through a typed Switch method:

axisEntry.Value.Switch<AxisInput, KeyComboInput, OneButton, TwoButton, AxesEntry>(
    axisInput   => { /* handle AxisInput */ },
    keyCombo    => { /* handle KeyComboInput */ },
    oneButton   => { /* handle OneButton */ },
    twoButton   => { /* handle TwoButton */ },
    nestedEntry => { /* handle recursive AxesEntry */ });

This gives you exhaustive matching at compile time. If I add a variant to the schema and forget to handle it somewhere, the code won't compile. It can be a bit annoying having to trudge through the fixes if you've made an early structural mistake, but the validation is worth it.

#The editor

Here's where the DOM system becomes useful. The keymap editor is tiny. Here is every source file in the project.

KeymapDocumentType.cs declares the document type with its display name, colour, file extension, and factory method:

using Avalonia.Media;
using EditorCore.Documents;
using KeymapEditor.Models;

namespace KeymapEditor.Documents;

public sealed class KeymapDocumentType() : StandardDomDocumentType<KeymapDocument, KeyMap>(
    "Key Map",
    Color.Parse("#550f97"),
    [
        new SerializeFormat("Key Map", "*.keymap"),
    ],
    KeyMap.Create);

KeymapDocument.cs wraps the DOM node in a document:

using EditorCore.Documents;
using KeymapEditor.Models;

namespace KeymapEditor.Documents;

public sealed class KeymapDocument : DomDocumentBase, IDocumentFactoryMethod<KeyMap>
{
    public KeymapDocument(DomDocumentType documentType, KeyMap keymap) : base(documentType, keymap.Adaptee)
    {
        this.Keymap = keymap;
    }

    public KeyMap Keymap { get; }

    public static IDocument Create(DomDocumentType type, KeyMap domNode)
    {
        return new KeymapDocument(type, domNode);
    }
}

KeymapAssetTypeHandler.cs wires the document to its view:

using EditorCore;
using EditorCore.Documents;
using KeymapEditor.Documents;
using KeymapEditor.Parts.KeymapDocumentHost;
using Microsoft.Extensions.DependencyInjection;
using Trident.Core.Errors;
using Trident.SolarLib.Avalonia;

namespace KeymapEditor;

public sealed class KeymapAssetTypeHandler(KeymapDocumentType domDocumentType) : IAssetTypeHandler
{
    public bool SpawnInNewWindow { get; } = false;

    public DomDocumentType DocumentType { get; } = domDocumentType;

    public Result<(ViewModelBase ViewModel, IViewFor)> CreateTopLevelView(
        IServiceScope scope, IDocument document)
    {
        if (document is not KeymapDocument keymapDocument)
        {
            return new Error("Document is not a KeymapDocument");
        }

        scope.ServiceProvider.GetRequiredService<ScopedDocument>().Document = keymapDocument;
        scope.ServiceProvider.GetRequiredService<ScopedDocument<KeymapDocument>>().Document
            = keymapDocument;

        KeymapDocumentHostViewModel vm =
            scope.ServiceProvider.GetRequiredService<KeymapDocumentHostViewModel>();
        KeymapDocumentHost view =
            scope.ServiceProvider.GetRequiredService<KeymapDocumentHost>();
        view.DataTemplates.Add(scope.ServiceProvider.GetRequiredService<ViewLocator>());
        view.ViewModel = vm;

        return (vm, view);
    }
}

BootstrapperExtensions.cs registers everything with the DI container:

using EditorCore.Extensions;
using KeymapEditor.Documents;
using KeymapEditor.Parts.KeymapDocumentHost;
using Microsoft.Extensions.DependencyInjection;
using KeyMap = Trident.Input.KeyMapping.KeyMap;

namespace KeymapEditor;

public static class BootstrapperExtensions
{
    public static IServiceCollection AddKeyMapEditor(this IServiceCollection serviceCollection)
    {
        serviceCollection.AddAssetType<KeymapDocumentType, KeyMap,
            KeymapAssetTypeHandler, KeymapDocument>(".keymap");
        serviceCollection.AddViewAndViewModel<KeymapDocumentHost, KeymapDocumentHostViewModel>();

        return serviceCollection;
    }
}

KeymapDocumentHost.axaml is the entire UI. One DomPropertyEditor control, bound to the root DOM node:

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:propertyEditor="clr-namespace:EditorCore.Parts.PropertyEditor;assembly=EditorCore"
             xmlns:keymapDocumentHost="clr-namespace:KeymapEditor.Parts.KeymapDocumentHost"
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
             x:Class="KeymapEditor.Parts.KeymapDocumentHost.KeymapDocumentHost"
             x:DataType="keymapDocumentHost:KeymapDocumentHostViewModel">
    <ScrollViewer VerticalScrollBarVisibility="Visible" VerticalSnapPointsAlignment="Far">
        <propertyEditor:DomPropertyEditor
            DomNode="{Binding KeymapData}"
            ValueEditorFactory="{Binding ValueEditorFactory}"
            NamedValueEditorFactory="{Binding NamedValueEditorFactory}"
            Grid.IsSharedSizeScope="True" />
    </ScrollViewer>
</UserControl>

KeymapDocumentHost.axaml.cs has nothing beyond the constructor:

using Trident.SolarLib.Avalonia;

namespace KeymapEditor.Parts.KeymapDocumentHost;

public partial class KeymapDocumentHost : ReactiveUserControl<KeymapDocumentHostViewModel>
{
    public KeymapDocumentHost()
    {
        InitializeComponent();
    }
}

KeymapDocumentHostViewModel.cs passes the root DOM node and the value editor factories to the view:

using EditorCore.Parts.PropertyEditor;
using JetBrains.Annotations;
using KeymapEditor.Documents;
using Tools.Core.Dom;
using Trident.Core.Extensions.DependencyInjection;
using Trident.SolarLib.Avalonia;

namespace KeymapEditor.Parts.KeymapDocumentHost;

[UsedImplicitly]
public sealed class KeymapDocumentHostViewModel(
    KeymapDocument document,
    Keyed<IPropertyValueEditor, Type> valueEditorFactory,
    Keyed<IPropertyValueEditor, string> namedValueEditorFactory
)
    : ViewModelBase
{
    public DomNode KeymapData { get; } = document.Keymap.Adaptee;

    public Keyed<IPropertyValueEditor, Type> ValueEditorFactory { get; } = valueEditorFactory;

    public Keyed<IPropertyValueEditor, string> NamedValueEditorFactory { get; } = namedValueEditorFactory;
}

That is the entire editor. Seven files, most under 20 lines, and nearly all are just for wiring up broader editor stuff. There is no code for rendering property fields, no code for handling collection add/remove, no code for serialisation or dirty tracking. The DomPropertyEditor control walks the DOM node tree and builds the UI from the type descriptions. Collections get "Add Element" and delete buttons automatically. Variant properties get their dropdown. Change notifications flow through the DOM's reactive pipeline to the document's dirty flag. All of it comes from the schema and the shared DOM infrastructure.

#Cooking

The last piece of the puzzle. At build time, the asset builder cooks keymap files into a binary format the game can load directly. Here is the complete processor:

using KeymapEditor.Models;
using MemoryPack;
using Tools.Core;
using Tools.Core.Dom.Serializer;
using Trident.Core.Errors;
using Trident.Input.KeyMapping;
using Trident.Input.Loaders;

namespace AssetBuilder.AssetProcessors.KeyMap;

public class KeyMapProcessor : IAssetProcessor
{
    public static int StaticVersion => 3;

    public int Version => StaticVersion;

    public ValueTask<ProcessResult> Process(ProcessRequest request)
    {
        string source = request.Data.ReadString(request.Encoding).Trim('\ufeff');
        Result<KeymapEditor.Models.KeyMap> asset =
            JsonDomSerializer.Deserialize<KeymapEditor.Models.KeyMap>(source);
        if (asset.IsError)
        {
            return ValueTask.FromResult(
                ProcessResult.Error("Failed to deserialize KeyMap file."));
        }

        List<ButtonBinding> buttonBindings = [];
        List<AxisBinding> axisBindings = [];

        KeymapEditor.Models.KeyMap keyMapFile = asset.Value;
        if (keyMapFile.Buttons != null)
        {
            foreach (ButtonEntry? buttonEntry in keyMapFile.Buttons)
            {
                if (buttonEntry is null)
                {
                    return ValueTask.FromResult(
                        ProcessResult.Error("Invalid button entry: null"));
                }

                buttonEntry.Trigger.Switch<ButtonInputValueButton, ButtonInputValueAxis>(
                    button =>
                    {
                        buttonBindings.Add(new ButtonBinding(buttonEntry.Key,
                            new ButtonBindingInput(button.Button)));
                    },
                    axis =>
                    {
                        buttonBindings.Add(new ButtonBinding(
                            buttonEntry.Key,
                            new ButtonBindingInput(new AxisButtonBinding(
                                axis.Axis,
                                axis.Operation,
                                axis.ReferenceValue))));
                    });
            }
        }

        if (keyMapFile.Axes != null)
        {
            foreach (AxesEntry? axis in keyMapFile.Axes)
            {
                if (axis is null)
                {
                    return ValueTask.FromResult(
                        ProcessResult.Error("Invalid axis entry: null"));
                }

                axisBindings.Add(new AxisBinding(
                    axis.Key,
                    axis.Modifier,
                    CreateAxisBinding(axis)));
            }
        }

        KeyMap_Data dataModel = new(
            request.AssetUri.ToString(),
            buttonBindings.ToArray(),
            axisBindings.ToArray());
        ReadOnlyMemory<byte> data = MemoryPackSerializer.Serialize(dataModel);

        return ValueTask.FromResult(ProcessResult.Success(data));

        AxisBindingInput CreateAxisBinding(AxesEntry axisEntry)
        {
            return axisEntry.Value
                .Switch<AxisInput, KeyComboInput, OneButton, TwoButton,
                    AxesEntry, AxisBindingInput>(
                axis => new AxisBindingInput(axis.Axis),
                keyCombo => new AxisBindingInput(
                    new KeyCombinationAxisButton(
                        keyCombo.Button,
                        new AxisBindingInput(keyCombo.Axis))),
                oneButton => new AxisBindingInput(
                    new OneDAxisButton(oneButton.Button, oneButton.Value)),
                twoButton => new AxisBindingInput(
                    new TwoDAxisButton(
                        twoButton.LowerButton,
                        twoButton.UpperButton,
                        twoButton.LowerValue,
                        twoButton.UpperValue)),
                _ => throw new Exception("Invalid axis entry"));
        }
    }
}

The processor deserialises the JSON keymap file back into the generated DOM adapter types using JsonDomSerializer, walks the structure, and maps each entry to the runtime ButtonBinding and AxisBinding types. You can see DomVariant.Switch used again here, matching over each variant. The final result is serialised with MemoryPack into a compact binary blob.

This is where the DOM's responsibility ends. KeyMap_Data, ButtonBinding, and AxisBinding belong to the Trident.Input module. They know nothing about DOM nodes, adapters, or schemas. The asset builder reads the DOM representation the editor produced, converts it to the runtime data model, and serialises that. The game loads the binary output directly. The DOM never reaches the runtime.

#What this adds up to

Adding a new asset editor in Trident comes down to three things:

  1. Write a .schema file defining the data model.
  2. Write a thin editor shell (document type, document, handler, bootstrapper, a view that hosts DomPropertyEditor).
  3. Write a processor that maps the DOM types to the runtime data model.

The editor shell is largely identical boilerplate across asset types. The processor is the only part with real domain logic, and even there the generated adapters and DomVariant.Switch keep things straightforward. For the amount of functionality you get, I think it is a pretty good trade.

#Why not automate the conversion from DOM to the runtime binary structure?

I could, but I would like the game to remain its own authority without depending on editor based structures. I do have editor-side DOM nodes being created based on game runtime components, but inverting that relationship so that the game data would rely on editor definitions feels icky to me. Matter of preference I think.