acdream/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md
Erik 0474feb6ca docs(D.2b): correct roadmap/plan — vitals window IS resizable (resize shipped 8aa643f)
The earlier 'not resizable / fixed-size' note was wrong (inverted edge-flag
reading). Resize shipped: dat edge-anchors reflow per UIElement::UpdateForParentSizeChange.
Noted the two number-render fixes (submission-order + glyph pixel-snap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:35:29 +02:00

34 KiB
Raw Blame History

LayoutDesc Importer — Implementation Plan (Plan 1: foundation + vitals conformance)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Read the retail vitals LayoutDesc (0x2100006C) from the dat and build a UiElement tree that reproduces the hand-built vitals window — proving a data-driven importer that needs no per-window graphics code.

Architecture: A LayoutImporter reads a layout, resolves BaseElement/BaseLayoutId inheritance, and walks the ElementDesc tree. A hybrid factory maps each element's Type to either a dedicated behavioral widget (meter → UiMeter, text → dat-font label) or a generic UiDatElement that draws any element's media by draw-mode (reusing the proven tiling primitive). A per-window VitalsController binds live data to elements by id, mirroring retail's gmVitalsUI. Everything renders through the existing UiRoot + primitives — nothing is deleted.

Tech Stack: C# .NET 10, Silk.NET, Chorizite.DatReaderWriter 2.1.7, xUnit. Spec: docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md.

Scope of Plan 1: rollout steps 16 (enumeration → importer → inheritance → generic renderer → factory → vitals controller → conformance). NOT in Plan 1: window manager, chat re-drive, the full long-tail of element types (Plan 2). The generic renderer's fallback means un-widgeted types still draw their sprites.


File structure

src/AcDream.App/UI/Layout/                 ← new namespace for the importer
  ElementReader.cs        — typed read of ElementDesc fields + inheritance merge (pure, GL-free)
  LayoutImporter.cs       — read a LayoutDesc, walk the tree, build the UiElement tree
  UiDatElement.cs         — generic element: draws its state media by DrawMode (tile/blend)
  DatWidgetFactory.cs     — Type → widget (UiMeter / dat-font label) else UiDatElement
  VitalsController.cs     — bind live data to elements by id (mirrors gmVitalsUI)
src/AcDream.App/Rendering/GameWindow.cs    ← wire importer under a flag, alongside the existing path
docs/research/2026-06-15-layoutdesc-format.md   ← Task 1 enumeration reference
tests/AcDream.App.Tests/UI/Layout/         ← new test folder
  ElementReaderTests.cs   — inheritance merge, edge-flags → anchors (pure)
  DatWidgetFactoryTests.cs— Type → widget mapping
  VitalsBindingTests.cs   — bind-by-id wiring
  LayoutConformanceTests.cs — vitals tree golden checks (uses a committed fixture)
tests/AcDream.App.Tests/UI/Layout/fixtures/
  vitals_2100006C.json    — dumped vitals layout tree (so tests need no dats)

Pure logic (inheritance merge, anchor mapping, factory decision, draw-mode UV) is GL-free and dat-free so it unit-tests without the user's dats. The dat-reading shell is exercised by the headless conformance tool + the committed fixture.


Task 1: Format enumeration reference doc (research)

Pins down the exact DatReaderWriter API and the format vocabulary the later tasks depend on. No production code.

Files:

  • Create: docs/research/2026-06-15-layoutdesc-format.md

  • Step 1: Enumerate the DatReaderWriter types

Run (PowerShell), capturing output:

dotnet run --project src\AcDream.Cli\AcDream.Cli.csproj --no-build -- dump-vitals-layout "$env:USERPROFILE\Documents\Asheron's Call" 0x2100006C

From this + the package, record the exact member names/types of ElementDesc (confirm ElementId, Type, X, Y, Width, Height, LeftEdge, TopEdge, RightEdge, BottomEdge, ZLevel, BaseElement, BaseLayoutId, StateDesc, States, Children), StateDesc (its Media collection + how properties like font 0x1A / fill 0x69 are stored), and MediaDescImage (File, DrawMode) / MediaDescCursor.

  • Step 2: Enumerate the Type + DrawMode vocabulary from the decomp

Grep docs/research/named-retail/acclient_2013_pseudo_c.txt for the UIElement_* class names + their render methods, the DrawModeType values, and the KSML keyword registrations (KW_* near 0x71b540). Record each element Type value → meaning + render method, and each DrawMode value → behavior (Normal=tile, Alphablend, Stretch, …).

  • Step 3: Cross-check against real layouts

Dump 0x21000014, 0x21000075, and 0x2100003F (the vitals number-text base layout) and confirm which Types/DrawModes/properties actually occur. Note the inheritance chain for the vitals number-text element.

  • Step 4: Write the reference doc

Write docs/research/2026-06-15-layoutdesc-format.md with sections: ElementDesc API, StateDesc/properties, MediaDesc kinds, the Type table (value → meaning → render method → generic-or-widget bucket), the DrawMode table, and the inheritance rules. Mark which types/draw-modes the vitals window uses (Plan 1 surface) vs the long tail (Plan 2).

  • Step 5: Commit
git add docs/research/2026-06-15-layoutdesc-format.md
git commit -m "docs(D.2b): LayoutDesc format enumeration (importer groundwork)"

Task 2: ElementReader — inheritance merge + edge-flags → anchors (pure)

Files:

  • Create: src/AcDream.App/UI/Layout/ElementReader.cs
  • Test: tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs

ElementReader holds the pure, GL-free, dat-free transforms the importer needs. Model the element as a small POCO ElementInfo so the pure logic is testable without constructing DatReaderWriter.ElementDesc.

  • Step 1: Write the failing tests
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;

public class ElementReaderTests
{
    [Fact]
    public void EdgeFlagsToAnchors_LeftRight_Stretches()
    {
        // Edge flag value 4 = "anchor to that side" per the format doc; left+right both anchored ⇒ width stretches.
        var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1);
        Assert.True(a.HasFlag(AnchorEdges.Left));
        Assert.True(a.HasFlag(AnchorEdges.Right));
        Assert.False(a.HasFlag(AnchorEdges.Bottom));
    }

    [Fact]
    public void Merge_BaseThenOverride_DerivedWins()
    {
        var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 };
        var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height
        var merged = ElementReader.Merge(base_, derived);
        Assert.Equal(200, merged.Width);          // override
        Assert.Equal(16, merged.Height);          // inherited
        Assert.Equal(0x40000000u, merged.FontDid);// inherited
    }
}
  • Step 2: Run to verify failure

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests" Expected: FAIL — ElementReader / ElementInfo not defined.

  • Step 3: Implement ElementReader + ElementInfo
namespace AcDream.App.UI.Layout;

/// <summary>GL-free, dat-free snapshot of a resolved layout element. Populated by the
/// importer from DatReaderWriter.ElementDesc (after inheritance); the pure transforms
/// below operate on it so they unit-test without the dats.</summary>
public sealed class ElementInfo
{
    public uint Id;
    public int Type;
    public float X, Y, Width, Height;
    public int Left, Top, Right, Bottom;   // edge-anchor flags
    public uint FontDid;                   // 0 = none (inherited via Merge)
    // sprite per state: state name -> (file, drawMode). "" = DirectState.
    public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
}

public static class ElementReader
{
    /// <summary>Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned
    /// to that side"; any other value = not pinned. Left+Right ⇒ width stretches.</summary>
    public static AnchorEdges ToAnchors(int left, int top, int right, int bottom)
    {
        var a = AnchorEdges.None;
        if (left == 4) a |= AnchorEdges.Left;
        if (top == 4) a |= AnchorEdges.Top;
        if (right == 4) a |= AnchorEdges.Right;
        if (bottom == 4) a |= AnchorEdges.Bottom;
        if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
        return a;
    }

    /// <summary>Merge a base element with a derived override: start from base, apply any
    /// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId.</summary>
    public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
    {
        var m = new ElementInfo
        {
            Id = derived.Id != 0 ? derived.Id : base_.Id,
            Type = derived.Type != 0 ? derived.Type : base_.Type,
            X = derived.X, Y = derived.Y,                       // position is the derived placement
            Width = derived.Width != 0 ? derived.Width : base_.Width,
            Height = derived.Height != 0 ? derived.Height : base_.Height,
            Left = derived.Left, Top = derived.Top, Right = derived.Right, Bottom = derived.Bottom,
            FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
            StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia),
        };
        foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; // derived overrides
        return m;
    }
}

NOTE: confirm the edge-flag "pinned" value (4) and the font-property key against Task 1's doc; adjust the == 4 test if the doc says otherwise.

  • Step 4: Run to verify pass

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests" Expected: PASS (2 tests).

  • Step 5: Commit
git add src/AcDream.App/UI/Layout/ElementReader.cs tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
git commit -m "feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors"

Task 3: UiDatElement — generic element + draw-mode render

Files:

  • Create: src/AcDream.App/UI/Layout/UiDatElement.cs

Generic widget: holds an ElementInfo + the active state name, draws that state's media by draw-mode. Reuses the proven tiling render (UV-repeat at native width; UI textures are GL_REPEAT-wrapped).

  • Step 1: Write the failing test (active-state selection is pure)
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;

public class UiDatElementTests
{
    [Fact]
    public void ActiveMedia_PrefersNamedStateOverDirect()
    {
        var info = new ElementInfo();
        info.StateMedia[""] = (0x06000001, 0);          // DirectState
        info.StateMedia["ShowDetail"] = (0x06000002, 1); // named
        var e = new UiDatElement(info, (_, _) => (0, 0, 0)) { ActiveState = "ShowDetail" };
        Assert.Equal(0x06000002u, e.ActiveMedia().File);
        e.ActiveState = "";
        Assert.Equal(0x06000001u, e.ActiveMedia().File);
    }
}
  • Step 2: Run to verify failure

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests" Expected: FAIL — UiDatElement not defined.

  • Step 3: Implement UiDatElement
using System;
using System.Numerics;

namespace AcDream.App.UI.Layout;

/// <summary>Generic dat element: draws its active state's media by DrawMode (Normal=tile,
/// Alphablend=blended overlay). The fallback renderer for every element type without a
/// dedicated behavioral widget; faithful because retail's base element render is exactly
/// "stamp the media per draw-mode".</summary>
public sealed class UiDatElement : UiElement
{
    private readonly ElementInfo _info;
    private readonly Func<uint, (uint tex, int w, int h)> _resolve;
    public string ActiveState { get; set; } = "";

    public UiDatElement(ElementInfo info, Func<uint, (uint, int, int)> resolve)
    {
        _info = info; _resolve = resolve;
        ClickThrough = true; // generic decoration; behavioral widgets opt back in
    }

    public (uint File, int DrawMode) ActiveMedia()
        => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m
         : _info.StateMedia.TryGetValue("", out var d) ? d
         : (0u, 0);

    protected override void OnDraw(UiRenderContext ctx)
    {
        var (file, drawMode) = ActiveMedia();
        if (file == 0) return;
        var (tex, tw, th) = _resolve(file);
        if (tex == 0 || tw == 0 || th == 0) return;
        // DrawMode 0 = Normal → TILE at native size (UV-repeat; GL_REPEAT-wrapped UI texture),
        // matching ImgTex::TileCSI. (Alphablend/others are the same blit with a blend state;
        // the sprite shader already alpha-blends, so the quad is identical here.)
        ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
    }
}

NOTE: confirm DrawMode enum values against Task 1; if a value needs a non-tiled blit (e.g. a true Stretch), branch here. For the vitals surface (Normal + Alphablend) the tiled UV-repeat quad is correct.

  • Step 4: Run to verify pass

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests" Expected: PASS.

  • Step 5: Commit
git add src/AcDream.App/UI/Layout/UiDatElement.cs tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs
git commit -m "feat(D.2b): UiDatElement — generic per-drawmode element renderer"

Task 4: DatWidgetFactory — Type → widget (else generic)

Files:

  • Create: src/AcDream.App/UI/Layout/DatWidgetFactory.cs

  • Test: tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs

  • Step 1: Write the failing tests

using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;

public class DatWidgetFactoryTests
{
    private static (uint, int, int) NoTex(uint _) => (0, 0, 0);

    [Fact]
    public void Type7_Meter_MakesUiMeter()
    {
        var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null);
        Assert.IsType<UiMeter>(e);
    }

    [Fact]
    public void UnknownType_FallsBackToGeneric()
    {
        var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null);
        Assert.IsType<UiDatElement>(e);
    }
}
  • Step 2: Run to verify failure

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests" Expected: FAIL — DatWidgetFactory not defined.

  • Step 3: Implement DatWidgetFactory
using System;

namespace AcDream.App.UI.Layout;

/// <summary>Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
/// algorithm ports); everything else (and unknown Types) falls back to UiDatElement.
/// The Type→bucket assignment comes from the format enumeration (Task 1).</summary>
public static class DatWidgetFactory
{
    /// <param name="resolve">RenderSurface id → (GL tex, w, h).</param>
    /// <param name="datFont">Retail UI font for text elements (may be null pre-load).</param>
    public static UiElement Create(ElementInfo info,
        Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
    {
        var e = info.Type switch
        {
            7 => BuildMeter(info, resolve),   // UIElement_Meter
            _ => new UiDatElement(info, resolve),
        };
        e.Left = info.X; e.Top = info.Y; e.Width = info.Width; e.Height = info.Height;
        e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
        return e;
    }

    private static UiElement BuildMeter(ElementInfo info, Func<uint, (uint, int, int)> resolve)
        => new UiMeter { SpriteResolve = resolve };  // back/front slice ids + binding set by the controller
}

NOTE: text (Type 0) keeps using the generic element for now; the dat-font label binding happens in the controller via UiDatFont. Add a dedicated text widget in Plan 2 if the enumeration shows behavior beyond "draw a bound string".

  • Step 4: Run to verify pass

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests" Expected: PASS.

  • Step 5: Commit
git add src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
git commit -m "feat(D.2b): DatWidgetFactory — Type→widget hybrid mapping"

Task 5: LayoutImporter — read layout, resolve inheritance, build tree

Files:

  • Create: src/AcDream.App/UI/Layout/LayoutImporter.cs

Reads a LayoutDesc via DatCollection, converts each ElementDesc to ElementInfo (resolving BaseElement/BaseLayoutId via ElementReader.Merge), builds the widget tree via the factory, and recurses into children. Exposes FindElement(uint id).

  • Step 1: Write the failing test (uses the committed fixture, no dats)

Create tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json by serializing the dumped tree (a list of ElementInfo-shaped records). Test that the importer's pure BuildFromInfos produces the right tree:

using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;

public class LayoutImporterTests
{
    [Fact]
    public void BuildFromInfos_HealthMeter_IsUiMeterAtRect()
    {
        // health meter element 0x100000E6: X=5,Y=5,150x16,Type=7
        var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 };
        var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 };
        var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, (_, _) => (0, 0, 0), null);
        var found = tree.FindElement(0x100000E6);
        Assert.IsType<UiMeter>(found);
        Assert.Equal(5f, found!.Left); Assert.Equal(150f, found.Width);
    }
}
  • Step 2: Run to verify failure

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests" Expected: FAIL — LayoutImporter not defined.

  • Step 3: Implement LayoutImporter
using System;
using System.Collections.Generic;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;

namespace AcDream.App.UI.Layout;

/// <summary>Reads a retail LayoutDesc into a UiElement tree. Pure tree-building
/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell.</summary>
public sealed class ImportedLayout
{
    public required UiElement Root { get; init; }
    private readonly Dictionary<uint, UiElement> _byId;
    public ImportedLayout(UiElement root, Dictionary<uint, UiElement> byId) { Root = root; _byId = byId; }
    public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null;
}

public static class LayoutImporter
{
    /// <summary>Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving
    /// inheritance), then BuildFromInfos. Returns null if the layout is missing.</summary>
    public static ImportedLayout? Import(DatCollection dats, uint layoutId,
        Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
    {
        var ld = dats.Get<LayoutDesc>(layoutId);
        if (ld is null) return null;
        // Convert top-level + nested ElementDescs to resolved ElementInfo.
        ElementInfo Convert(ElementDesc d) => Resolve(dats, d);
        // Build a synthetic root that holds the top-level elements as children.
        var rootInfo = new ElementInfo { Id = 0, Type = 3 };
        var children = new List<ElementInfo>();
        var nested = new Dictionary<ElementInfo, ElementDesc>();
        foreach (var kv in ld.Elements) { var info = Convert(kv.Value); children.Add(info); nested[info] = kv.Value; }
        return BuildFromInfosRecursive(rootInfo, ld, dats, resolve, datFont);
    }

    /// <summary>Pure builder used by tests + the shell: build a tree from a root info + its
    /// direct children infos. (The recursive dat variant handles real nested trees.)</summary>
    public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable<ElementInfo> children,
        Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
    {
        var byId = new Dictionary<uint, UiElement>();
        var root = DatWidgetFactory.Create(rootInfo, resolve, datFont);
        if (rootInfo.Id != 0) byId[rootInfo.Id] = root;
        foreach (var c in children)
        {
            var w = DatWidgetFactory.Create(c, resolve, datFont);
            root.AddChild(w);
            if (c.Id != 0) byId[c.Id] = w;
        }
        return new ImportedLayout(root, byId);
    }

    // ---- dat-side helpers ----

    private static ImportedLayout BuildFromInfosRecursive(ElementInfo rootInfo, LayoutDesc ld,
        DatCollection dats, Func<uint, (uint, int, int)> resolve, UiDatFont? datFont)
    {
        var byId = new Dictionary<uint, UiElement>();
        var root = DatWidgetFactory.Create(rootInfo, resolve, datFont);
        foreach (var kv in ld.Elements)
            AddElement(root, kv.Value, dats, resolve, datFont, byId);
        return new ImportedLayout(root, byId);
    }

    private static void AddElement(UiElement parent, ElementDesc d, DatCollection dats,
        Func<uint, (uint, int, int)> resolve, UiDatFont? datFont, Dictionary<uint, UiElement> byId)
    {
        var info = Resolve(dats, d);
        var w = DatWidgetFactory.Create(info, resolve, datFont);
        parent.AddChild(w);
        if (info.Id != 0) byId[info.Id] = w;
        foreach (var kv in d.Children)
            AddElement(w, kv.Value, dats, resolve, datFont, byId);
    }

    /// <summary>ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance.</summary>
    private static ElementInfo Resolve(DatCollection dats, ElementDesc d)
    {
        var self = ToInfo(d);
        if (d.BaseElement != 0 && d.BaseLayoutId != 0)
        {
            var baseLd = dats.Get<LayoutDesc>(d.BaseLayoutId);
            var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement);
            if (baseDesc is not null) return ElementReader.Merge(Resolve(dats, baseDesc), self); // recursive base chain
        }
        return self;
    }

    private static ElementDesc? FindDesc(LayoutDesc ld, uint id)
    {
        foreach (var kv in ld.Elements) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; }
        return null;
    }
    private static ElementDesc? FindDescIn(ElementDesc d, uint id)
    {
        if (d.ElementId == id) return d;
        foreach (var kv in d.Children) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; }
        return null;
    }

    /// <summary>Read the verified ElementDesc fields into ElementInfo (no inheritance).</summary>
    private static ElementInfo ToInfo(ElementDesc d)
    {
        var info = new ElementInfo
        {
            Id = d.ElementId, Type = (int)d.Type,
            X = d.X, Y = d.Y, Width = d.Width, Height = d.Height,
            Left = (int)d.LeftEdge, Top = (int)d.TopEdge, Right = (int)d.RightEdge, Bottom = (int)d.BottomEdge,
        };
        if (d.StateDesc is not null) ReadState(d.StateDesc, "", info);
        foreach (var s in d.States) ReadState(s.Value, s.Key, info);
        return info;
    }

    private static void ReadState(StateDesc sd, string name, ElementInfo info)
    {
        foreach (var m in sd.Media)
            if (m is MediaDescImage img && img.File != 0)
                info.StateMedia[name] = (img.File, (int)img.DrawMode);
        // font DID (property 0x1A) read here once the format doc confirms the property API.
    }
}

NOTE: the exact ElementDesc/StateDesc member access (d.X, d.Type, d.States, sd.Media, img.DrawMode, the font property) must match Task 1's verified API; dump-vitals-layout confirms these members exist. Adjust casts/names to the real API.

  • Step 4: Run to verify pass

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests" Expected: PASS.

  • Step 5: Commit
git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json
git commit -m "feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree"

Task 6: VitalsController — bind live data by id

Files:

  • Create: src/AcDream.App/UI/Layout/VitalsController.cs
  • Test: tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs

Mirrors gmVitalsUI: grab the meter elements by id and wire their fill + numbers + the correct per-vital sprite slice ids (which are dat-driven, but the back/front-slice split + the live data binding are the controller's job).

  • Step 1: Write the failing test
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;

public class VitalsBindingTests
{
    [Fact]
    public void Bind_SetsHealthMeterFillFromProvider()
    {
        var health = new UiMeter();
        var layout = FakeLayout(("0x100000E6", health));
        float hp = 0.42f;
        VitalsController.Bind(layout, healthPct: () => hp, staminaPct: () => 1, manaPct: () => 1,
            healthText: () => "42/100", staminaText: () => "", manaText: () => "");
        Assert.Equal(0.42f, health.Fill());
    }

    private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items)
    {
        var dict = new System.Collections.Generic.Dictionary<uint, UiElement>();
        var root = new UiPanel();
        foreach (var (idHex, e) in items)
        { uint id = System.Convert.ToUInt32(idHex, 16); root.AddChild(e); dict[id] = e; }
        return new ImportedLayout(root, dict);
    }
}
  • Step 2: Run to verify failure

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests" Expected: FAIL — VitalsController not defined.

  • Step 3: Implement VitalsController
using System;

namespace AcDream.App.UI.Layout;

/// <summary>Per-window controller for the vitals layout (0x2100006C). Mirrors retail
/// gmVitalsUI::PostInit: grab the meter elements by id and bind live data. The ONLY
/// per-window code — data wiring, not graphics.</summary>
public static class VitalsController
{
    public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE;

    public static void Bind(ImportedLayout layout,
        Func<float> healthPct, Func<float> staminaPct, Func<float> manaPct,
        Func<string> healthText, Func<string> staminaText, Func<string> manaText)
    {
        BindMeter(layout, Health, healthPct, healthText);
        BindMeter(layout, Stamina, staminaPct, staminaText);
        BindMeter(layout, Mana, manaPct, manaText);
    }

    private static void BindMeter(ImportedLayout layout, uint id, Func<float> pct, Func<string> text)
    {
        if (layout.FindElement(id) is UiMeter m)
        {
            m.Fill = () => pct();
            m.Label = () => text();
        }
    }
}

NOTE: the per-vital back/front 3-slice sprite ids live on the meter's child image elements in the dat; the importer sets them on the UiMeter (extend DatWidgetFactory.BuildMeter to read the meter's E8/E9/EA + back/front child sprites once the tree is built). For Plan 1 conformance, the controller binds the dynamic data; the static slice ids come from the dat via the importer.

  • Step 4: Run to verify pass

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests" Expected: PASS.

  • Step 5: Commit
git add src/AcDream.App/UI/Layout/VitalsController.cs tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs
git commit -m "feat(D.2b): VitalsController — bind live vitals data by element id"

Task 7: Wire the importer into GameWindow behind a flag

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (the _options.RetailUi block where the vitals panel is built)
  • Modify: src/AcDream.App/RuntimeOptions.cs (add RetailUiImporter flag from ACDREAM_RETAIL_UI_IMPORTER)

Run the importer-built vitals window when ACDREAM_RETAIL_UI_IMPORTER=1, ALONGSIDE the existing hand-authored path (which stays the default). This is the conformance harness + the eventual switch-over.

  • Step 1: Add the RuntimeOptions flag

In RuntimeOptions.cs, add public bool RetailUiImporter { get; init; } and read it in Program.cs from ACDREAM_RETAIL_UI_IMPORTER == "1" (follow the existing RetailUi pattern).

  • Step 2: Wire the importer in the RetailUi block

In GameWindow.cs, in the if (_options.RetailUi) block, after the existing vitals panel is built, add:

if (_options.RetailUiImporter)
{
    var imported = AcDream.App.UI.Layout.LayoutImporter.Import(
        _dats, 0x2100006Cu, ResolveChrome, _datFont);
    if (imported is not null)
    {
        AcDream.App.UI.Layout.VitalsController.Bind(imported,
            healthPct: () => _vitalsVm!.HealthPercent ?? 0f,
            staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f,
            manaPct: () => _vitalsVm!.ManaPercent ?? 0f,
            healthText: () => $"{_vitalsVm!.HealthCurrent}/{_vitalsVm.HealthMax}",
            staminaText: () => $"{_vitalsVm!.StaminaCurrent}/{_vitalsVm.StaminaMax}",
            manaText: () => $"{_vitalsVm!.ManaCurrent}/{_vitalsVm.ManaMax}");
        imported.Root.Left = 240; imported.Root.Top = 30; // offset so it sits beside the hand-built one for A/B
        _uiHost.Root.AddChild(imported.Root);
        Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored).");
    }
}

NOTE: confirm _dats (the DatCollection) + _datFont (the UiDatFont) field names in GameWindow; both already exist (the chrome resolve + the dat-font load use them).

  • Step 3: Build

Run: dotnet build src\AcDream.App\AcDream.App.csproj -c Debug Expected: 0 errors.

  • Step 4: Commit
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Program.cs
git commit -m "feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)"

Task 8: Vitals conformance — golden tree checks + headless render diff

Files:

  • Create: tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs

  • Modify: src/AcDream.Cli/VitalsMockup.cs (add an importer-render mode if needed for the visual diff)

  • Step 1: Write the golden tree conformance test (against the fixture)

using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;

public class LayoutConformanceTests
{
    [Fact]
    public void VitalsTree_HasThreeMetersAtExpectedRects()
    {
        var layout = FixtureLoader.LoadVitals(); // deserializes vitals_2100006C.json → ImportedLayout via BuildFromInfos
        (uint id, float y)[] expected = { (0x100000E6, 5), (0x100000EC, 21), (0x100000EE, 37) };
        foreach (var (id, y) in expected)
        {
            var m = layout.FindElement(id);
            Assert.IsType<UiMeter>(m);
            Assert.Equal(5f, m!.Left);
            Assert.Equal(150f, m.Width);
            Assert.Equal(16f, m.Height);
            Assert.Equal(y, m.Top);
        }
    }
}

Add a tiny FixtureLoader that reads the committed JSON into ElementInfos and calls LayoutImporter.BuildFromInfos.

  • Step 2: Run to verify failure, then implement FixtureLoader, then pass

Run: dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutConformanceTests" Expected: FAIL → implement FixtureLoader → PASS.

  • Step 3: Headless visual diff

Launch the client with both windows (ACDREAM_RETAIL_UI=1 ACDREAM_RETAIL_UI_IMPORTER=1, testaccount2) and confirm the importer window (offset) is pixel-identical to the hand-authored one. (Manual visual gate — the user confirms. No assertion.)

  • Step 4: Full test sweep

Run: dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~UI" Expected: PASS (all prior UI tests + the new Layout tests).

  • Step 5: Commit
git add tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render gate)"

After Plan 1

Plan 1 status: SHIPPED 2026-06-15, pixel-identical.

Default flip DONE 2026-06-15 (bf77a23): the importer is now the default vitals window at ACDREAM_RETAIL_UI=1. The hand-authored vitals.xml and the ACDREAM_RETAIL_UI_IMPORTER flag were retired (vitals.xml is recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, 8aa643f): on a width change the dat edge-anchors reflow the pieces (top/bottom edges + bars stretch, corners fixed 5px, right side tracks) per retail UIElement::UpdateForParentSizeChange @0x00462640. (The earlier "fixed-size" note was wrong — it came from an inverted edge-flag reading, now corrected; stretch is RightEdge==1.) Faithful grip/dragbar-driven drag/resize INPUT for the whole toolkit is Plan 2. Post-flip number-render fixes (43064ba, 34243f2): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bar sprites) + glyph pixel-snap (numbers stay sharp at all resize widths). MarkupDocument/UiNineSlicePanel remain for the chat window + plugin panels.

Plan 2 covers: the WindowManager (open/close/z-order/persist, drag via Type-2 drag bars, resize via Type-9 resize grips for the whole toolkit), re-driving the chat window (ChatController), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register Plan 2 in the roadmap before starting it.

Self-review

  • Spec coverage: enumeration (Task 1) ✓, importer + inheritance (Tasks 2,5) ✓, generic renderer (Task 3) ✓, hybrid factory (Task 4) ✓, controller/binding (Task 6) ✓, coexistence/flag (Task 7) ✓, conformance (Task 8) ✓. Window manager + chat + full long-tail = explicitly deferred to Plan 2 (spec rollout 78).
  • Placeholder scan: every code step has concrete code; NOTEs flag where Task 1's verified API must confirm a member name/value — that's a real dependency, not a vague requirement.
  • Type consistency: ElementInfo, ImportedLayout, LayoutImporter.BuildFromInfos/Import, DatWidgetFactory.Create, UiDatElement.ActiveMedia, VitalsController.Bind are used consistently across tasks; UiMeter.Fill/Label/SpriteResolve match the existing widget.