From a7875cde225ca855b2dbafdab423d55e19047c82 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 12:46:55 +0200 Subject: [PATCH] =?UTF-8?q?docs(D.2b):=20LayoutDesc=20importer=20implement?= =?UTF-8?q?ation=20plan=20(Plan=201=20=E2=80=94=20vitals=20conformance)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-15-layoutdesc-importer.md | 756 ++++++++++++++++++ 1 file changed, 756 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-layoutdesc-importer.md diff --git a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md new file mode 100644 index 00000000..f5a6b4d6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md @@ -0,0 +1,756 @@ +# 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 1–6 (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; + +/// 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. +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 StateMedia = new(); +} + +public static class ElementReader +{ + /// Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned + /// to that side"; any other value = not pinned. Left+Right ⇒ width stretches. + 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; + } + + /// Merge a base element with a derived override: start from base, apply any + /// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId. + 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(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; + +/// 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". +public sealed class UiDatElement : UiElement +{ + private readonly ElementInfo _info; + private readonly Func _resolve; + public string ActiveState { get; set; } = ""; + + public UiDatElement(ElementInfo info, Func 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(e); + } + + [Fact] + public void UnknownType_FallsBackToGeneric() + { + var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); + Assert.IsType(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; + +/// 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). +public static class DatWidgetFactory +{ + /// RenderSurface id → (GL tex, w, h). + /// Retail UI font for text elements (may be null pre-load). + public static UiElement Create(ElementInfo info, + Func 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 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(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; + +/// Reads a retail LayoutDesc into a UiElement tree. Pure tree-building +/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell. +public sealed class ImportedLayout +{ + public required UiElement Root { get; init; } + private readonly Dictionary _byId; + public ImportedLayout(UiElement root, Dictionary byId) { Root = root; _byId = byId; } + public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null; +} + +public static class LayoutImporter +{ + /// Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving + /// inheritance), then BuildFromInfos. Returns null if the layout is missing. + public static ImportedLayout? Import(DatCollection dats, uint layoutId, + Func resolve, UiDatFont? datFont) + { + var ld = dats.Get(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(); + var nested = new Dictionary(); + 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); + } + + /// 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.) + public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable children, + Func resolve, UiDatFont? datFont) + { + var byId = new Dictionary(); + 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 resolve, UiDatFont? datFont) + { + var byId = new Dictionary(); + 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 resolve, UiDatFont? datFont, Dictionary 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); + } + + /// ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance. + private static ElementInfo Resolve(DatCollection dats, ElementDesc d) + { + var self = ToInfo(d); + if (d.BaseElement != 0 && d.BaseLayoutId != 0) + { + var baseLd = dats.Get(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; + } + + /// Read the verified ElementDesc fields into ElementInfo (no inheritance). + 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(); + 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; + +/// 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. +public static class VitalsController +{ + public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE; + + public static void Bind(ImportedLayout layout, + Func healthPct, Func staminaPct, Func manaPct, + Func healthText, Func staminaText, Func 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 pct, Func 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(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 + +Once the importer window is pixel-identical to the hand-authored vitals (Task 8 gate), a follow-up commit flips vitals to the importer as the default and the hand-authored `vitals.xml` path is retired (kept in git history). **Plan 2** then covers: the `WindowManager` (open/close/z-order/persist), 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 the phase id in `docs/plans/2026-04-11-roadmap.md` before starting Plan 2. + +## 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 7–8). +- **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.