8-task TDD plan: chat golden fixture + resolved-Type conformance (Task 1, empirically resolves the input's Type), then one-widget-per-commit migration — UiScrollbar(11), UiButton(1), UiMenu(6), UiText(12)+the Type-12 flip, UiField(3) — then thin the controller (Task 7, visual gate) and the gated vitals UiText rewire (Task 8). Each task: failing test, register in the factory switch, controller find-by-id binding, build+test green, commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
52 KiB
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 2–7; 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(buildsAcDream.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'sCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>trailer. - Every generic widget cites its retail class +
RegisterElementClassline 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 ofUiChatView, Task 5).src/AcDream.App/UI/UiField.cs— generic Type-3 editable one-line field (rename ofUiChatInput, Task 6).src/AcDream.App/UI/UiScrollbar.cs— generic Type-11 scrollbar (rename ofUiChatScrollbar, Task 2).src/AcDream.App/UI/UiMenu.cs— generic Type-6 dropdown menu (genericizedUiChannelMenu, 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— theswitch(Type)+BuildButton/BuildMenu/BuildText/BuildField/BuildScrollbar(Tasks 2–6).src/AcDream.App/UI/Layout/ChatWindowController.cs— construction → find-by-id binding; channel-item population (Tasks 2–7).src/AcDream.App/UI/Layout/VitalsController.cs— bindUiTextnumbers (Task 8).src/AcDream.App/Rendering/GameWindow.cs— only property-type follow-through (.Transcript/.Inputtypes change) if needed (Tasks 5–6).tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs— new per-Type asserts; flip the two Type-12 tests (Tasks 2–6).tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs— addLoadChat()(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:
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):
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:
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.
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.
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:
[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 _):
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:
// 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.Topto height before zeroing Top. Write it correctly:
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.
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:
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
UiEventconstructor signature insrc/AcDream.App/UI/UiEvent.csbefore finalizing thenew 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:
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:
[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:
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:
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.
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.
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:
/// <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:
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:
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) withEquals(Items[i].Payload, Selected). -
Replace
Items[i].Channel is not { } c || IsAvailable(c)(availability) withEnabledProvider?.Invoke(Items[i].Payload) ?? true. -
Replace the button caption
ButtonTextwithButtonLabelProvider?.Invoke() ?? ""in bothOnDraw(theDrawLabel(ctx, ButtonText, …)call) andNaturalButtonWidth()(theMeasureWidth(ButtonText)). -
In
OnEvent's pick branch, replace the channel-specific selectionif (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); }with
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;withRowsPerColumnandItems.Length→Items.Count. KeepDrawBevel,DrawButtonFace,DrawSprite,DrawLabel, the sprite-id properties, the colors, andNaturalButtonWidth()otherwise unchanged. Update the doc comment to citeUIElement_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.:
[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:
[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:
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):
// 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:
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:
var onSelect = c.Menu.OnSelect;
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
_activeChannelalready exists on the controller; the old per-menuOnChannelChanged = k => c._activeChannel = k;is now folded intoOnSelect.
- 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.
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.
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:
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:
/// <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:
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:
[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_Rendersbody to assertUiTextfor both media and no-media:
[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:
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
- Add the builder:
/// <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
Createsummary/<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:
// 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.
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
UiFielddirectly; 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 aUiFieldat 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.
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:
[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:
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):
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):
// 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.
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 2–6, 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
ChatWindowControllerTestsfor 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.
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
UiTextnumber binding inVitalsController.
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
UiTextlayout support, add a minimalpublic bool CenterHorizontallyflag toUiTextwith 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.
git add -A
git commit -m "feat(D.2b): vitals numbers as UiText (widget-generalization Task 8, gated)"
Done criteria (from spec §8)
DatWidgetFactoryregisters Types 1, 3, 6, 11, 12 (+ 7) → generic widgets;_still →UiDatElement.- The
Type==12 → nullskip is removed; no Type-12 element is double-built (fixtures green). - No
ChatChannelKind/chat-color/command-routing knowledge inside any widget;ChatWindowControlleronly finds-by-id and binds. - Chat window visually + behaviorally identical through Tasks 2–7 (user-confirmed, Task 7 Step 4).
chat_21000006.jsongolden 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_Xclass + reg. line. - Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits.
- Roadmap /
claude-memory/project_d2b_retail_ui.mdupdated when the pass lands.