merge: bring main (UN-7, #140 filing, D.2b UI rows) into A7 Fix D round-2 branch

Resolves the divergence-register conflict: kept the accurate per-VERTEX AP-35
(Fix A shipped per-vertex; main's row was the stale pre-Fix-A per-pixel text),
kept main's UI rows AP-37..AP-42, and renumbered this branch's torch-gate row
AP-37 -> AP-43 (AP-37 was taken by main's LayoutDesc row). AP count 41 -> 42.
Retargeted the AP-37 references in WbDrawDispatcher + the CHECKPOINT to AP-43.
Marked ISSUES #140 RESOLVED (b7d655b) with the corrected root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 09:29:53 +02:00
commit c83fd02642
94 changed files with 16216 additions and 199 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,760 @@
# 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.

View file

@ -0,0 +1,992 @@
# D.2b Widget Generalization Implementation Plan
> **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:** Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by `DatWidgetFactory`, collapsing the controllers to a thin retail `gm*UI::PostInit`-style find-by-id binder.
**Architecture:** `DatWidgetFactory.Create` grows a faithful `switch(Type)` registering the real retail `UIElement` classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays `UiDatElement`. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a *registration* task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into `ChatWindowController`. Migrate one widget per commit; chat stays visually identical through Tasks 27; vitals is rewired last (Task 8) behind a visual gate.
**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (Chorizite), Silk.NET (GL/input). Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
**Spec:** `docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md`.
---
## Conventions
- **Repo root** = the worktree dir. All paths below are relative to it.
- **Build:** `dotnet build` (builds `AcDream.slnx`). Must be green before every commit.
- **Test (all UI):** `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
- **Test (filtered):** add `--filter "FullyQualifiedName~<ClassName>"`.
- **Commit style:** `feat(D.2b): <widget> — <what>` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer.
- **Every generic widget cites its retail class + `RegisterElementClass` line** in a doc comment (per spec §8).
- **Divergence register:** `docs/architecture/retail-divergence-register.md` — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7).
---
## File Structure
**Created:**
- `src/AcDream.App/UI/UiButton.cs` — generic Type-1 button (Task 3).
- `src/AcDream.App/UI/UiText.cs` — generic Type-12 scrollable colored-line text (rename of `UiChatView`, Task 5).
- `src/AcDream.App/UI/UiField.cs` — generic Type-3 editable one-line field (rename of `UiChatInput`, Task 6).
- `src/AcDream.App/UI/UiScrollbar.cs` — generic Type-11 scrollbar (rename of `UiChatScrollbar`, Task 2).
- `src/AcDream.App/UI/UiMenu.cs` — generic Type-6 dropdown menu (genericized `UiChannelMenu`, Task 4).
- `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` — golden resolved chat tree (Task 1).
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` — skip-by-default fixture generator (Task 1).
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` — resolved-tree + factory-class conformance (Task 1, grown per widget).
- `tests/AcDream.App.Tests/UI/UiButtonTests.cs` (Task 3).
**Renamed (git mv + class/namespace-internal rename):**
- `UiChatScrollbar.cs``UiScrollbar.cs`; `UiChatScrollbarTests.cs``UiScrollbarTests.cs` (Task 2).
- `UiChatView.cs``UiText.cs`; `UiChatViewTests.cs``UiTextTests.cs`; `UiChatViewDatFontTests.cs``UiTextDatFontTests.cs` (Task 5).
- `UiChatInput.cs``UiField.cs`; `UiChatInputTests.cs``UiFieldTests.cs` (Task 6).
- `UiChannelMenu.cs``UiMenu.cs`; `UiChannelMenuTests.cs``UiMenuTests.cs` (Task 4).
**Modified:**
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 26).
- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 27).
- `src/AcDream.App/UI/Layout/VitalsController.cs` — bind `UiText` numbers (Task 8).
- `src/AcDream.App/Rendering/GameWindow.cs` — only property-type follow-through (`.Transcript`/`.Input` types change) if needed (Tasks 56).
- `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 26).
- `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` — add `LoadChat()` (Task 1).
---
## Task 1: Chat golden fixture + conformance test (also resolves the input's Type empirically)
**Files:**
- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs`
- Create: `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` (generated, committed)
- Modify: `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs`
- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs`
The generator runs once against the live dat (it is `[Fact(Skip=…)]` so CI never runs it). The committed JSON is dat-free, like `vitals_2100006C.json`. The fixture's resolved `Type` per element **answers spec verification #1** (does input `0x10000016` resolve to 3 or 12?).
- [ ] **Step 1: Write the generator (skip-by-default).**
`ChatLayoutFixtureGenerator.cs`:
```csharp
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// One-off generator for the committed chat golden fixture. Skipped by default —
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
/// </summary>
public class ChatLayoutFixtureGenerator
{
[Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")]
public void GenerateChatFixture()
{
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
Assert.NotNull(info);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(FixturePath(), json);
}
// Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
private static string FixturePath([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
}
```
- [ ] **Step 2: Generate the fixture (manual, dats present).**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call"` after temporarily removing the `Skip` (or use an IDE run). Confirm `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` is written and non-empty, then restore the `Skip`.
Expected: a JSON tree rooted at id `0x10000006`-family with the chat elements. **Record the resolved `Type` of `0x10000016` (input) and `0x10000011` (transcript)** — these drive Task 5/6 decisions.
- [ ] **Step 3: Add `FixtureLoader.LoadChat()` + `LoadChatInfos()`.**
In `FixtureLoader.cs`, add (mirroring `LoadVitals`/`LoadVitalsInfos`):
```csharp
public static ImportedLayout LoadChat()
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
=> LoadInfos("chat_21000006.json");
// Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json").
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName);
if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}");
var bytes = File.ReadAllBytes(path);
ReadOnlySpan<byte> span = bytes;
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..];
return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(span, _opts)
?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
}
```
Then make `LoadVitalsInfos()` delegate: `public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");`
- [ ] **Step 4: Write the resolved-tree conformance test (fails until the fixture exists).**
`ChatLayoutConformanceTests.cs`:
```csharp
using System.Collections.Generic;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public class ChatLayoutConformanceTests
{
private static ElementInfo Find(ElementInfo n, uint id)
{
if (n.Id == id) return n;
foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; }
return null!;
}
[Fact]
public void ChatFixture_ResolvesKnownElements()
{
var root = FixtureLoader.LoadChatInfos();
// These ids come from ChatWindowController; the resolved Type proves the base-chain merge.
Assert.NotNull(Find(root, 0x10000011u)); // transcript
Assert.NotNull(Find(root, 0x10000016u)); // input
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
Assert.NotNull(Find(root, 0x10000014u)); // channel menu
Assert.NotNull(Find(root, 0x10000019u)); // send button
Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
}
[Fact]
public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
{
var root = FixtureLoader.LoadChatInfos();
Assert.Equal(6u, Find(root, 0x10000014u).Type); // Menu
Assert.Equal(11u, Find(root, 0x10000012u).Type); // Scrollbar
Assert.Equal(1u, Find(root, 0x10000019u).Type); // Button (Send)
Assert.Equal(1u, Find(root, 0x1000046Fu).Type); // Button (Max/Min)
// transcript + input: assert the ACTUAL resolved Type recorded in Step 2.
// From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these.
Assert.Equal(12u, Find(root, 0x10000011u).Type); // Text (transcript)
Assert.Equal(12u, Find(root, 0x10000016u).Type); // Text (input — see Task 6 wrinkle)
}
}
```
- [ ] **Step 5: Run the conformance tests.**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests"`
Expected: PASS. If `ChatFixture_ResolvedTypes_MatchRetailRegistry` shows input `0x10000016` Type ≠ 12, **update the assert to the real value and note it in Task 6 Step 1** (decides factory-built vs controller-placed `UiField`).
- [ ] **Step 6: Commit.**
```bash
git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \
tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \
tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)"
```
---
## Task 2: `UiScrollbar` (Type 11) — promote the already-generic scrollbar
`UiChatScrollbar` has zero chat-specific code; this is a rename + factory registration.
**Files:**
- Rename: `src/AcDream.App/UI/UiChatScrollbar.cs``src/AcDream.App/UI/UiScrollbar.cs`
- Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs``tests/AcDream.App.Tests/UI/UiScrollbarTests.cs`
- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
- Modify: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`, `ChatLayoutConformanceTests.cs`
- [ ] **Step 1: Rename the widget file + class.**
```bash
git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs
git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
```
In `UiScrollbar.cs`: rename `class UiChatScrollbar``class UiScrollbar`; update the doc summary to "Generic scrollbar. Ports retail `UIElement_Scrollbar` (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged.
In `UiScrollbarTests.cs`: rename the test class to `UiScrollbarTests`; replace every `UiChatScrollbar` with `UiScrollbar`. (Keep the test bodies.)
- [ ] **Step 2: Write the failing factory test.**
In `DatWidgetFactoryTests.cs` add:
```csharp
[Fact]
public void Type11_Scrollbar_MakesUiScrollbar()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
Assert.IsType<UiScrollbar>(e);
}
```
- [ ] **Step 3: Run it — verify it fails.**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar"`
Expected: FAIL (`Create` returns `UiDatElement`, not `UiScrollbar`).
- [ ] **Step 4: Register Type 11 in the factory.**
In `DatWidgetFactory.Create`, add to the switch (before `_`):
```csharp
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
```
- [ ] **Step 5: Build + run factory + scrollbar tests.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests"`
Expected: PASS.
- [ ] **Step 6: Point the controller at the factory-built scrollbar (still functional).**
The factory now builds a `UiScrollbar` for the Type-11 track element. In `ChatWindowController.cs`, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently `c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);`) with:
```csharp
// The factory built the Type-11 track element as a UiScrollbar. Find it, bind it.
if (layout.FindElement(TrackId) is UiScrollbar bar)
{
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
bar.Height = bar.Height + bar.Top; // NOTE: capture old Top before zeroing — see Step 6a
bar.Model = c.Transcript.Scroll;
bar.SpriteResolve = resolve;
bar.TrackSprite = TrackSprite;
bar.ThumbSprite = ThumbSprite;
bar.ThumbTopSprite = ThumbTopSprite;
bar.ThumbBotSprite = ThumbBotSprite;
bar.UpSprite = UpSprite;
bar.DownSprite = DownSprite;
c.Scrollbar = bar;
}
```
- [ ] **Step 6a: Fix the Top/Height order bug introduced above.** The old code added `track.Top` to height *before* zeroing Top. Write it correctly:
```csharp
if (layout.FindElement(TrackId) is UiScrollbar bar)
{
float oldTop = bar.Top;
bar.Top = 0f;
bar.Height = bar.Height + oldTop;
bar.Model = c.Transcript.Scroll;
bar.SpriteResolve = resolve;
bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite;
bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite;
bar.UpSprite = UpSprite; bar.DownSprite = DownSprite;
c.Scrollbar = bar;
}
```
Change the `Scrollbar` property type: `public UiScrollbar Scrollbar { get; private set; } = null!;`
- [ ] **Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.**
`ChatLayoutConformanceTests` already asserts Type 11 for the track. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS (whole UI suite).
- [ ] **Step 8: Re-check AP-41 in the divergence register.**
The controller passes `ThumbTopSprite`/`ThumbBotSprite` (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In `docs/architecture/retail-divergence-register.md`, update the AP-41 `file:line` from `UiChatScrollbar.cs:37` to `UiScrollbar.cs` and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only").
- [ ] **Step 9: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)"
```
---
## Task 3: `UiButton` (Type 1) — Send + Max/Min
The factory currently builds Send/Max-Min as `UiDatElement` and the controller sets `OnClick`/`Label`. Introduce a dedicated `UiButton` mirroring that behavior exactly (so clicks don't regress) and register Type 1.
**Files:**
- Create: `src/AcDream.App/UI/UiButton.cs`
- Create: `tests/AcDream.App.Tests/UI/UiButtonTests.cs`
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
- [ ] **Step 1: Write the failing button-behavior test.**
`UiButtonTests.cs`:
```csharp
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class UiButtonTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
[Fact]
public void Click_InvokesOnClick()
{
var info = new ElementInfo { Type = 1, Width = 46, Height = 18 };
var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true };
b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0));
Assert.True(Clicked);
}
private bool Clicked;
[Fact]
public void NotClickThrough_SoItReceivesClicks()
{
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
Assert.False(b.ClickThrough);
}
}
```
> Confirm the `UiEvent` constructor signature in `src/AcDream.App/UI/UiEvent.cs` before finalizing the `new UiEvent(...)` call; adjust arg order if needed.
- [ ] **Step 2: Run it — verify it fails (UiButton does not exist).**
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
Expected: FAIL (compile error: `UiButton` not found).
- [ ] **Step 3: Write `UiButton`.**
`UiButton.cs`:
```csharp
using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Generic clickable button. Ports retail UIElement_Button
/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828):
/// a per-state sprite face + an optional centered caption + a click action. Built by
/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F).
/// The controller binds OnClick and the caption. State selection mirrors UiDatElement
/// so existing Send/Max-Min behavior is preserved exactly.
/// </summary>
public sealed class UiButton : UiElement
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
public Action? OnClick { get; set; }
public string? Label { get; set; }
public UiDatFont? LabelFont { get; set; }
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized).</summary>
public string ActiveState { get; set; } = "";
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_info = info;
_resolve = resolve;
ClickThrough = false; // buttons are interactive
if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName;
else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal";
}
private uint ActiveFile()
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
protected override void OnDraw(UiRenderContext ctx)
{
uint file = ActiveFile();
if (file != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
}
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
return false;
}
}
```
- [ ] **Step 4: Run the button tests — verify they pass.**
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
Expected: PASS.
- [ ] **Step 5: Write the failing factory test + register Type 1.**
In `DatWidgetFactoryTests.cs`:
```csharp
[Fact]
public void Type1_Button_MakesUiButton()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
Assert.IsType<UiButton>(e);
}
```
In `DatWidgetFactory.Create` switch:
```csharp
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
```
- [ ] **Step 6: Update the controller to bind the factory-built buttons.**
In `ChatWindowController.cs`, the Send block currently does `if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }`. Change the cast to `UiButton`:
```csharp
if (layout.FindElement(SendId) is UiButton sendEl)
{
sendEl.OnClick = () => c.Input.Submit();
sendEl.Label = "Send";
sendEl.LabelFont = datFont;
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
}
```
And the Max/Min block: change `if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)``is UiButton maxMinEl`, drop the now-unneeded `maxMinEl.ClickThrough = false;` (UiButton is interactive by construction), keep the `maxMinEl.Left = track.Left - maxMinEl.Width;` and `maxMinEl.OnClick = c.ToggleMaximize;`.
- [ ] **Step 7: Build + run the full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 8: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)"
```
---
## Task 4: `UiMenu` (Type 6) — genericize the channel menu
`UiChannelMenu` is the one heavy genericization: move `ChatChannelKind`, the 14-item array, the button-text map, and the availability defaults into `ChatWindowController`; keep all drawing/geometry/event mechanics in a generic `UiMenu` keyed on `object? Payload`.
**Files:**
- Rename: `src/AcDream.App/UI/UiChannelMenu.cs``src/AcDream.App/UI/UiMenu.cs`
- Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs``tests/AcDream.App.Tests/UI/UiMenuTests.cs`
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
- [ ] **Step 1: Rename file + class.**
```bash
git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs
git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs
```
- [ ] **Step 2: Replace the chat-specific members with the generic surface.**
In `UiMenu.cs`, rename `class UiChannelMenu``class UiMenu`; remove `using AcDream.UI.Abstractions;`. Replace the chat-specific members — the `Item` record, the static `Items` array, `Selected` (ChatChannelKind), `OnChannelChanged`, `AvailabilityProvider`, `IsAvailable`, and `ButtonText` — with these generic members:
```csharp
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
public readonly record struct MenuItem(string Label, object? Payload);
/// <summary>The rows, populated by the controller. Laid out column-major:
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
public object? Selected { get; set; }
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
public Action<object?>? OnSelect { get; set; }
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert).
/// Null ⇒ all rows enabled.</summary>
public Func<object?, bool>? EnabledProvider { get; set; }
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
public Func<string>? ButtonLabelProvider { get; set; }
```
Make the geometry constants settable so a controller/factory can match the dat:
```csharp
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
```
Replace the `private const int Rows`/`ItemH`/`ColW` usages with `RowsPerColumn`/`RowHeight`/`ColumnWidth`, and make the derived sizes instance members:
```csharp
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
private float InteriorW => ColumnCount * ColumnWidth;
private float InteriorH => RowsPerColumn * RowHeight;
private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border;
```
- [ ] **Step 3: Genericize the draw/event logic (mechanical swaps).**
In the same file, in `OnDrawOverlay`, `OnEvent`, `OnHitTest`, and `DrawButtonFace`/label:
- Replace `Items[i].Channel is { } c && c == Selected` (selected-row test) with `Equals(Items[i].Payload, Selected)`.
- Replace `Items[i].Channel is not { } c || IsAvailable(c)` (availability) with `EnabledProvider?.Invoke(Items[i].Payload) ?? true`.
- Replace the button caption `ButtonText` with `ButtonLabelProvider?.Invoke() ?? ""` in both `OnDraw` (the `DrawLabel(ctx, ButtonText, …)` call) and `NaturalButtonWidth()` (the `MeasureWidth(ButtonText)`).
- In `OnEvent`'s pick branch, replace the channel-specific selection
```csharp
if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); }
```
with
```csharp
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
{
Selected = Items[idx].Payload;
OnSelect?.Invoke(Selected);
}
```
- Replace the column/row math `int col = i / Rows, row = i % Rows;` with `RowsPerColumn` and `Items.Length``Items.Count`.
Keep `DrawBevel`, `DrawButtonFace`, `DrawSprite`, `DrawLabel`, the sprite-id properties, the colors, and `NaturalButtonWidth()` otherwise unchanged. Update the doc comment to cite `UIElement_Menu (RegisterElementClass(6) @ :120163)` + `MakePopup @0x46d310`.
- [ ] **Step 4: Update the menu tests for the generic surface.**
In `UiMenuTests.cs`, rename the class to `UiMenuTests`, replace `UiChannelMenu``UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.:
```csharp
[Fact]
public void ClickingRow_FiresOnSelect_WithPayload()
{
object? picked = null;
var m = new UiMenu
{
Width = 46, Height = 18,
Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") },
OnSelect = p => picked = p,
};
// open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the
// existing test's click coords, which used the same 17px rows).
m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0)); // toggle open
// … click into row 0 of the open popup (reuse the prior test's local coords) …
Assert.Equal("say", picked);
}
```
> Reuse the exact open/click coordinates from the original `UiChannelMenuTests` (they map into the same popup geometry); only the payload/selection assertions change.
- [ ] **Step 5: Run the menu tests — green.**
Run: `dotnet test … --filter "FullyQualifiedName~UiMenuTests"`
Expected: PASS.
- [ ] **Step 6: Failing factory test + register Type 6.**
In `DatWidgetFactoryTests.cs`:
```csharp
[Fact]
public void Type6_Menu_MakesUiMenu()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
Assert.IsType<UiMenu>(e);
}
```
In `DatWidgetFactory.Create` switch:
```csharp
6 => new UiMenu(), // UIElement_Menu (reg :120163)
```
- [ ] **Step 7: Move the channel knowledge into `ChatWindowController`.**
In `ChatWindowController.cs`, add the channel item table + maps (ported verbatim from the old `UiChannelMenu`):
```csharp
// Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50).
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
{
("Squelch (ignore)", null),
("Tell to Selected", null),
("Chat to All", ChatChannelKind.Say),
("Tell to Fellows", ChatChannelKind.Fellowship),
("Tell to General Chat", ChatChannelKind.General),
("Tell to LFG Chat", ChatChannelKind.Lfg),
("Tell to Society Chat", ChatChannelKind.Society),
("Tell to Monarch", ChatChannelKind.Monarch),
("Tell to Patron", ChatChannelKind.Patron),
("Tell to Vassals", ChatChannelKind.Vassals),
("Tell to Allegiance", ChatChannelKind.Allegiance),
("Tell to Trade Chat", ChatChannelKind.Trade),
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
};
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
{
ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General",
ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG",
ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg",
ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals",
ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay",
ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi",
_ => "Chat",
};
private static bool ChannelAvailable(ChatChannelKind k)
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
```
Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a `UiMenu`; find it and populate it:
```csharp
if (layout.FindElement(MenuId) is UiMenu menu)
{
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
menu.PopupBgSprite = MenuPopupBg;
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
menu.Items = System.Array.ConvertAll(ChannelItems,
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
menu.Selected = (object?)c._activeChannel;
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
menu.OnSelect = p =>
{
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
};
c.Menu = menu;
}
```
Update the `Menu` property type: `public UiMenu Menu { get; private set; } = null!;` Update the reflow block (`ReflowInputRow`) — it calls `c.Menu.NaturalButtonWidth()`, `c.Menu.ResetAnchorCapture()` (both still exist on `UiMenu`), and wraps `c.Menu.OnChannelChanged`. Replace the `OnChannelChanged` wrap with the generic `OnSelect`:
```csharp
var onSelect = c.Menu.OnSelect;
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
```
> `_activeChannel` already exists on the controller; the old per-menu `OnChannelChanged = k => c._activeChannel = k;` is now folded into `OnSelect`.
- [ ] **Step 8: Build + run the full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 9: Add a divergence row if the generic menu lost fidelity.**
The generic `UiMenu` item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs `UIElement_Menu::MakePopup`'s nested popups, add a row to `docs/architecture/retail-divergence-register.md` (Adaptation) citing `src/AcDream.App/UI/UiMenu.cs` + `MakePopup @0x46d310`. (The chat menu is single-level, so this is a latent note, not a behavior change.)
- [ ] **Step 10: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)"
```
---
## Task 5: `UiText` (Type 12) — transcript + the Type-12 flip
Rename `UiChatView``UiText`, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An **unbound `UiText` must draw nothing** so vitals stays frozen.
**Files:**
- Rename: `src/AcDream.App/UI/UiChatView.cs``src/AcDream.App/UI/UiText.cs`
- Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs``UiTextTests.cs`; `UiChatViewDatFontTests.cs``UiTextDatFontTests.cs`
- Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
- [ ] **Step 1: Rename file + class + tests.**
```bash
git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs
git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs
git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs
```
In `UiText.cs`: rename `class UiChatView``class UiText`; the nested `Line`/`Pos` records, `LinesProvider`, selection, and scroll stay. Update the doc to cite `UIElement_Text (RegisterElementClass(0xc) @ :115655)`. In the test files, rename classes + replace `UiChatView``UiText`.
- [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).**
In `UiText.cs`, change:
```csharp
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); // transparent by default
```
(was `(0,0,0,0.35)`). `OnDraw`'s `ctx.DrawFill(0,0,Width,Height,BackgroundColor)` then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6).
- [ ] **Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).**
So a Type-12 element that carries its own sprite (currently rendered by `UiDatElement`) does not lose it. Add to `UiText`:
```csharp
/// <summary>Optional dat state-sprite background (the element's own media), drawn
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
public uint BackgroundSprite { get; set; }
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
```
At the very top of `OnDraw`, before `DrawFill`:
```csharp
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
{
var (tex, tw, th) = sr(BackgroundSprite);
if (tex != 0 && tw != 0 && th != 0)
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
```
- [ ] **Step 4: Write the failing factory test (and flip the two existing Type-12 tests).**
In `DatWidgetFactoryTests.cs`:
- Add:
```csharp
[Fact]
public void Type12_Text_MakesUiText()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
Assert.IsType<UiText>(e);
}
```
- Replace `Type12_StylePrototype_ReturnsNull` (delete it — Type 12 is no longer skipped).
- Replace `DatWidgetFactory_Type12WithMedia_Renders` body to assert `UiText` for both media and no-media:
```csharp
[Fact]
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
{
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
}
```
- [ ] **Step 5: Run — verify the new/flipped tests fail.**
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests"`
Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement).
- [ ] **Step 6: Register Type 12 + add `BuildText`; remove the skip.**
In `DatWidgetFactory.cs`:
- Delete the skip line `if (info.Type == 12 && info.StateMedia.Count == 0) return null;`.
- Add to the switch:
```csharp
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
```
- Add the builder:
```csharp
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The
/// element's own Direct/Normal media (if any) becomes the background sprite, drawn
/// under the text — so a Type-12 element that previously rendered via UiDatElement
/// keeps its sprite. Lines are bound later by the controller (LinesProvider).</summary>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
{
uint bg = info.StateMedia.TryGetValue(
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
? m.File : 0u;
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
}
```
> Update the `Create` summary/`<returns>` doc that referenced Type-12 returning null.
- [ ] **Step 7: Verify factory + vitals fixture still green (vitals frozen).**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests"`
Expected: PASS. The vitals number text elements are meter-children (consumed, never built — `LayoutImporter.cs:113`), and any other vitals Type-12 element now builds as an unbound, transparent `UiText` (draws only its own sprite, if it had one — same as before). **Spec verification #2:** if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via `BackgroundSprite`.
- [ ] **Step 8: Controller binds the factory-built transcript (instead of constructing it).**
In `ChatWindowController.cs`, the factory now builds the Type-12 transcript element `0x10000011` as a `UiText`. Replace the "Transcript" block (which read `tInfo` and `new UiChatView { … }; transcriptPanel.AddChild(...)`) with find-and-bind:
```csharp
// The factory built the Type-12 transcript as a UiText; find + bind it.
c.Transcript = layout.FindElement(TranscriptId) as UiText
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
c.Transcript.DatFont = datFont;
c.Transcript.Font = debugFont;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
```
Change the `Transcript` property type to `public UiText Transcript { get; private set; } = null!;`. Remove the now-unused `tInfo` lookup + the `transcriptPanel.AddChild` (the transcript is already in the tree at its dat position). Keep the `transcriptPanel.Top/Height` resize-bar reclaim.
Also in `ChatWindowController.cs`, replace **every** `UiChatView.Line` with `UiText.Line` — this hits `BuildLines` (its `UiText view` parameter, its `IReadOnlyList<UiText.Line>` return type, the `Array.Empty<UiText.Line>()`, and the `new UiText.Line(frag, color)` inside the wrap loop). `WrapText`/`RetailChatColor` are unaffected (they return `string`/`Vector4`).
Finally, repoint the `Bind` early-guard: it currently does `var tInfo = FindInfo(rootInfo, TranscriptId);` and checks `tInfo is null`. The transcript is now found via `layout.FindElement(TranscriptId)`; change the guard to null-check the factory-built widgets it needs (`layout.FindElement(TranscriptPanelId)` for the panel, plus the transcript/input found in their Steps). The `iInfo` lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.)
- [ ] **Step 9: GameWindow follow-through.**
`GameWindow.cs:1860` (`chatController.Transcript.Keyboard = …`) still compiles (`UiText.Keyboard` exists). Build to confirm.
- [ ] **Step 10: Build + full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 11: Amend AP-37 (Type-0 text skip retired).**
In `docs/architecture/retail-divergence-register.md`, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as `UiText`). Keep the meter-collapse clause and the vitals-numbers-via-`UiMeter.Label` clause (retired in Task 8).
- [ ] **Step 12: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)"
```
---
## Task 6: `UiField` (Type 3) — editable input
Rename `UiChatInput``UiField`, register Type 3, and wire the input. **Input handling depends on Task 1 Step 5's recorded resolved Type** for `0x10000016`:
- **If it resolved to Type 3:** the factory builds `UiField` directly; the controller finds + binds it.
- **If it resolved to Type 12** (per the Map trace): the factory built it as a `UiText`; the controller *replaces* it with a `UiField` at the same rect (the existing replace pattern).
**Files:**
- Rename: `src/AcDream.App/UI/UiChatInput.cs``src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs``UiFieldTests.cs`
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
- [ ] **Step 1: Confirm the input's resolved Type from Task 1, choose the path.**
Re-read `ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry` (Task 1) for `0x10000016`. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6.
- [ ] **Step 2: Rename file + class + tests.**
```bash
git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs
git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs
```
In `UiField.cs`: rename `class UiChatInput``class UiField`; body unchanged. Update doc to cite `UIElement_Field (RegisterElementClass(3) @ :126190)` + the drag-drop hooks (`CatchDroppedItem`/`MouseOverTop`) it will host for future item windows. In `UiFieldTests.cs`: rename class, replace `UiChatInput``UiField`.
- [ ] **Step 3: Default the background to transparent (consistency with UiText).**
Change `UiField.BackgroundColor` default to `new(0f, 0f, 0f, 0f)`. The controller sets the translucent value (Step 6).
- [ ] **Step 4: Failing factory test + register Type 3.**
In `DatWidgetFactoryTests.cs`:
```csharp
[Fact]
public void Type3_Field_MakesUiField()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
Assert.IsType<UiField>(e);
}
```
In `DatWidgetFactory.Create` switch:
```csharp
3 => new UiField(), // UIElement_Field (reg :126190)
```
- [ ] **Step 5: Run — verify pass.**
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests"`
Expected: PASS.
- [ ] **Step 6: Wire the input in the controller (variant per Step 1).**
Replace the "Input" block (`new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …`).
**Variant A — input resolved to Type 3 (factory-built):**
```csharp
c.Input = layout.FindElement(InputId) as UiField
?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField");
c.Input.DatFont = datFont; c.Input.Font = debugFont;
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
```
**Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):**
```csharp
// 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a
// controller-placed UiField at the dat element's rect (retail authors a separate Field).
var iInfo = FindInfo(rootInfo, InputId)
?? throw new InvalidOperationException("chat input info 0x10000016 missing");
if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder)
iparent.RemoveChild(placeholder); // drop the read-only Text placeholder
c.Input = new UiField
{
Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height,
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
DatFont = datFont, Font = debugFont,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f),
SpriteResolve = resolve, FocusFieldSprite = InputFocusField,
};
(inputBar).AddChild(c.Input);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
```
Change the `Input` property type to `public UiField Input { get; private set; } = null!;` (Keep `FindInfo` for Variant B; it may become unused in Variant A — remove it then.)
- [ ] **Step 7: GameWindow follow-through.**
`GameWindow.cs:1861` (`chatController.Input.Keyboard = …`) still compiles (`UiField.Keyboard` exists). Build to confirm.
- [ ] **Step 8: Build + full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 9: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)"
```
---
## Task 7: Thin + verify the controller; remove dead construction
After Tasks 26, `ChatWindowController.Bind` should construct no widgets (except the Variant-B input). Audit and tidy.
**Files:**
- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
- [ ] **Step 1: Remove dead helpers + confirm find-by-id shape.**
In `ChatWindowController.cs`: confirm every widget is obtained via `layout.FindElement(id) as UiX` and only data/callbacks are bound. Remove any now-unused locals (`transcriptPanel`/`inputBar` are still used for the resize-bar reclaim / Variant-B parent — keep those; remove `tInfo`/`FindInfo` if Variant A). Confirm the class doc reads as the `gmMainChatUI::PostInit @0x4ce130` analogue (find child by id → bind).
- [ ] **Step 2: Update `ChatWindowControllerTests` for the new types.**
In `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`, update any references to `UiChatView`/`UiChatInput`/`UiChatScrollbar`/`UiChannelMenu` to `UiText`/`UiField`/`UiScrollbar`/`UiMenu`, and any assertions on `.Selected`/`OnChannelChanged` to the generic `OnSelect`/payload surface. Run them to confirm the binding still wires the right elements.
- [ ] **Step 3: Build + full UI suite.**
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
Expected: PASS.
- [ ] **Step 4: Visual gate (user) — chat unchanged.**
Launch the client (`ACDREAM_RETAIL_UI=1`, per CLAUDE.md launch recipe) and confirm the chat window looks + behaves identically to before this pass: transcript scroll/select/copy, input write-mode/history/clipboard, channel dropdown, send, max/min, scrollbar drag. **Stop for user confirmation.**
- [ ] **Step 5: Commit.**
```bash
git add -A
git commit -m "refactor(D.2b): ChatWindowController is now a thin find-by-id binder (widget-generalization Task 7)"
```
---
## Task 8 (GATED): vitals numbers as `UiText`
Rewire the vitals number text from `UiMeter.Label` to factory-built `UiText` (retail-faithful: vitals numbers are `UIElement_Text`). **This is a stop-and-confirm gate** — vitals shipped pixel-identical and is fixture-locked. If it risks the pixel-identical result, **stop and keep `UiMeter.Label`** (narrow AP-37 instead).
**Files:**
- Modify: `src/AcDream.App/UI/Layout/VitalsController.cs`, `LayoutImporter.cs` (meter child handling), `GameWindow.cs` (Bind call), `tests/.../VitalsBindingTests.cs`, `fixtures/vitals_2100006C.json`
- [ ] **Step 1: Decide the number element's path.**
The vitals number text is a **meter child** (consumed; `LayoutImporter.cs:113` does not recurse meter children). To render it as a real `UiText`, either (a) have `VitalsController` construct a `UiText` at the number element's rect (read from the meter's children — mirrors the chat Variant-B pattern), or (b) stop consuming the meter's text child so the factory builds it. **Prefer (a)** — it is local to `VitalsController` and does not disturb the meter slice extraction. Read the number element's rect from `DatWidgetFactory.BuildMeter`'s skipped text child (expose it, or re-read via the layout's `ElementInfo`).
- [ ] **Step 2: Write a failing binding test.**
In `VitalsBindingTests.cs`, add a test that, after `VitalsController.Bind`, a `UiText` exists for each vital and its `LinesProvider` returns the cur/max string. (Use the vitals fixture; assert the text node is present + bound.)
- [ ] **Step 3: Implement the `UiText` number binding in `VitalsController`.**
Add a `UiText` per meter (constructed at the number rect, single centered line). Keep `UiMeter.Label` unset for vitals. Bind `LinesProvider = () => new[] { new UiText.Line(text(), color) }` (centered — add a `UiText.CenterSingleLine` option or a thin overload if needed for horizontal centering).
> If centering a single line requires new `UiText` layout support, add a minimal `public bool CenterHorizontally` flag to `UiText` with a unit test, rather than overloading the chat path.
- [ ] **Step 4: Build + run vitals tests.**
Run: `dotnet test … --filter "FullyQualifiedName~VitalsBindingTests|FullyQualifiedName~LayoutConformanceTests"`
Expected: PASS. Update `vitals_2100006C.json` only if the resolved tree legitimately changed (it should not — the change is in binding, not the tree).
- [ ] **Step 5: Visual gate (user) — vitals pixel-identical.**
Launch (`ACDREAM_RETAIL_UI=1`); confirm the vitals numbers render identically (font, position, centering, color) to the shipped `UiMeter.Label` version. **Stop for user confirmation. If not identical → revert this task and narrow AP-37 instead.**
- [ ] **Step 6: Retire/narrow AP-37 + update memory.**
If the rewire lands: in `docs/architecture/retail-divergence-register.md`, retire the AP-37 vitals-numbers clause (now real `UiText`). Update `claude-memory/project_d2b_retail_ui.md` (the generalization pass shipped) + the roadmap.
- [ ] **Step 7: Commit.**
```bash
git add -A
git commit -m "feat(D.2b): vitals numbers as UiText (widget-generalization Task 8, gated)"
```
---
## Done criteria (from spec §8)
- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; `_` still → `UiDatElement`.
- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built (fixtures green).
- [ ] No `ChatChannelKind`/chat-color/command-routing knowledge inside any widget; `ChatWindowController` only finds-by-id and binds.
- [ ] Chat window visually + behaviorally identical through Tasks 27 (user-confirmed, Task 7 Step 4).
- [ ] `chat_21000006.json` golden fixture + renamed generic-widget tests all green.
- [ ] Vitals window unchanged after Task 8 (user-confirmed), or Task 8 deferred with AP-37 narrowed.
- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line.
- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits.
- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands.