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

760 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**
```csharp
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**
```csharp
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)**
```csharp
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**
```csharp
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**
```csharp
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**
```csharp
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:
```csharp
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**
```csharp
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**
```csharp
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**
```csharp
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:
```csharp
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)**
```csharp
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 `ElementInfo`s 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; `NOTE`s 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.