From 3d25e8760f93af52110bd98116c30e6fc17f85f4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:04:35 +0200 Subject: [PATCH] @ docs(D.2b): chat-window re-drive implementation plan (8 tasks A-H) TDD task breakdown for the data-driven chat window: ChatCommandRouter extraction (A), UiChatView dat-font (B), UiScrollable + wire-in (C/C2), UiChatScrollbar (D), UiChatInput (E), UiChannelMenu (F), ChatWindowController bind/route (G), GameWindow cutover + divergence rows (H). Each ported widget cites its retail class::method. Plan: docs/superpowers/plans/2026-06-15-chat-window-redrive.md Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md Co-Authored-By: Claude Opus 4.8 (1M context) @ --- .../plans/2026-06-15-chat-window-redrive.md | 1484 +++++++++++++++++ 1 file changed, 1484 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-chat-window-redrive.md diff --git a/docs/superpowers/plans/2026-06-15-chat-window-redrive.md b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md new file mode 100644 index 00000000..ab96b033 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md @@ -0,0 +1,1484 @@ +# Chat-window re-drive 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:** Replace the hand-authored retail chat window with a data-driven one built from dat `LayoutDesc 0x21000006` (`gmMainChatUI`), with faithful behavioral widgets ported from the named retail decomp and the dat font. + +**Architecture:** The existing `LayoutImporter` builds the generic frame (bg sprites, resize bar, grip chrome, tabs, send button) from the dat. A new `ChatWindowController` (the `ChatInterface`/`gmMainChatUI::PostInit` analogue) binds behavior by element id: it swaps the transcript/input placeholder nodes for new behavioral widgets, wires the scrollbar/menu/send/max-min, and routes inbound chat (from `ChatVM`) and outbound (through a shared `ChatCommandRouter`). New widgets port `UIElement_Text`/`_Scrollable`/`_Scrollbar`/`_Menu`. + +**Tech Stack:** C# / .NET 10, Silk.NET (GL), the in-tree retained-mode UI toolkit (`src/AcDream.App/UI/`), `DatReaderWriter` (dat reads), xUnit (`tests/`). + +**Spec:** `docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md` — read it first. It has the element→role map, decomp citations, and the divergence rows. The decomp is `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +--- + +## File Structure + +**Create:** +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` — shared submit pipeline (client-command intercept → unknown-verb guard → `ChatInputParser.Parse` → `Publish(SendChatCmd)`). Pure, no GL. +- `src/AcDream.App/UI/UiScrollable.cs` — pixel-scroll coordinator (ports `UIElement_Scrollable` math). Pure, no GL. +- `src/AcDream.App/UI/UiChatInput.cs` — editable one-line text widget (ports `UIElement_Text` edit path). +- `src/AcDream.App/UI/UiChatScrollbar.cs` — right-side scrollbar widget (track + thumb + up/down) driving a `UiScrollable`. +- `src/AcDream.App/UI/UiChannelMenu.cs` — channel-selector dropdown (ports `UIElement_Menu`). +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — import + bind-by-id + route (the `ChatInterface`/`gmMainChatUI` analogue). +- Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`, + `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`, + `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`, + `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. + +**Modify:** +- `src/AcDream.App/UI/UiChatView.cs` — add `UiDatFont? DatFont`; dat-font measure/advance/draw; wheel = 1 line/notch; `UiScrollable` integration. +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` — call `ChatCommandRouter` instead of the inline submit block. +- `src/AcDream.App/Rendering/GameWindow.cs` — replace the hand-authored chat block (~line 1836) with `ChatWindowController`. +- `docs/architecture/retail-divergence-register.md` — add the 6 deferral rows. +- `docs/plans/2026-04-11-roadmap.md` — mark the chat re-drive landed. + +--- + +## Task A: `ChatCommandRouter` (shared submit pipeline) + +Extract the submit + client-command logic from `ChatPanel` so both the ImGui chat and the retail chat dispatch identically. `ChatPanel` currently hardcodes `ChatChannelKind.Say`; the router parameterizes the default channel (the retail chat passes the channel-menu selection). + +**Files:** +- Create: `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` +- Test: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs` +- Modify: `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` (call the router) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`: + +```csharp +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public class ChatCommandRouterTests +{ + // Minimal in-memory command bus capturing the last published SendChatCmd. + private sealed class CaptureBus : ICommandBus + { + public SendChatCmd? Last; + public void Publish(T command) where T : notnull + { + if (command is SendChatCmd c) Last = c; + } + } + + private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + return (vm, log, new CaptureBus()); + } + + [Fact] + public void PlainText_PublishesOnDefaultChannel() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("hello there", bus.Last.Text); + } + + [Fact] + public void DefaultChannel_IsHonored() + { + var (vm, _, bus) = Fixture(); + ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + } + + [Fact] + public void ClearCommand_DrainsLog_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + log.OnSystemMessage("x", 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Null(bus.Last); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + Assert.Null(bus.Last); + Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); + } + + [Fact] + public void EmptyInput_DoesNothing() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); + Assert.Null(bus.Last); + } +} +``` + +> Verify the `ChatLog` / `ICommandBus` / `ChatVM` APIs used above match the real +> types before running (`ChatLog.OnSystemMessage(string, int)`, `ChatLog.Snapshot()`, +> `ChatLog.Clear()`, `ICommandBus.Publish`). Adjust the fixture if signatures differ. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` +Expected: FAIL — `ChatCommandRouter` / `SubmitOutcome` do not exist. + +- [ ] **Step 3: Implement `ChatCommandRouter`** + +Create `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`. Move the +client-command + unknown-verb + parse + publish logic out of `ChatPanel` +(`ChatPanel.TryHandleClientCommand` + the submit block at `ChatPanel.cs:191-242`): + +```csharp +using System; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// What a submit did, so the caller can clear its input + give feedback. +public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } + +/// +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 +/// analogue). Both the ImGui devtools and the retail +/// chat window route through here so command handling stays in one place. +/// +/// Order mirrors the prior inline flow: +/// client-command intercept → unknown-slash-verb guard → +/// → Publish(SendChatCmd). +/// +public static class ChatCommandRouter +{ + public static SubmitOutcome Submit( + string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(bus); + var trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) return SubmitOutcome.Empty; + + if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + + // A '/' prefix is a command, never speech — unknown ones get local feedback + // instead of leaking to the server as chat. (@ verbs pass through to ACE.) + if (trimmed[0] == '/') + { + var verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + return SubmitOutcome.UnknownCommand; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; // e.g. "/t Name" with no message + } + + private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + { + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { vm.ShowSystemMessage(BuildHelpText()); return true; } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { vm.Clear(); return true; } + if (EqAny(trimmed, "/framerate", "@framerate")) + { vm.ShowFps(); return true; } + if (EqAny(trimmed, "/loc", "@loc")) + { vm.ShowLocation(); return true; } + return false; + } + + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` +Expected: PASS (5 tests). + +- [ ] **Step 5: Repoint `ChatPanel` at the router** + +In `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`, replace the submit body +(`ChatPanel.cs:194-241`, the `var trimmed = submitted.Trim();` block through +`_input = string.Empty;`) with a single call, and delete the now-dead +`TryHandleClientCommand` / `EqAny` / `BuildHelpText` helpers (they moved to the router): + +```csharp +if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) + && submitted is not null) +{ + ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); + _input = string.Empty; + renderer.EndChild(); + renderer.End(); + return; +} +``` + +- [ ] **Step 6: Verify the full suite still passes** + +Run: `dotnet test tests/AcDream.UI.Abstractions.Tests` +Expected: PASS — including the existing `ChatPanelInputTests` (they assert the same submit behavior, now via the router). If any assert on a private `ChatPanel` member, redirect it to `ChatCommandRouter`. + +- [ ] **Step 7: Commit** + +```bash +git add src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs \ + src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs \ + tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +git commit -m "feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task B: `UiChatView` dat-font seam + 1-line wheel + +Make the transcript render in the dat font and scroll one line per wheel notch +(retail `HandleMouseWheel @0x471450`), keeping bottom-pin, drag-select, Ctrl+C. + +**Files:** +- Modify: `src/AcDream.App/UI/UiChatView.cs` +- Test: `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. `UiChatView.CharIndexAt` +is already a pure static taking a `Func` advance lookup — assert the +dat-font advance (`UiDatFont.GlyphAdvance`) drives caret hit-testing: + +```csharp +using AcDream.App.UI; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatViewDatFontTests +{ + // Synthetic per-char advance: each glyph 10px wide (Before=2,Width=6,After=2). + private static FontCharDesc Glyph(char c) => new() + { + Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2, + OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0, + }; + + [Fact] + public void CharIndexAt_UsesDatGlyphAdvance() + { + // "abc" with 10px advances -> midpoints at 5,15,25. x=12 -> caret before 'b' (index 1). + float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); + Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f)); + Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f)); + Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f)); + } + + [Fact] + public void GlyphAdvance_MatchesRetailFormula() + { + // HorizontalOffsetBefore + Width + HorizontalOffsetAfter = 2+6+2 = 10. + Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x'))); + } +} +``` + +- [ ] **Step 2: Run to verify it fails or passes-trivially** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` +Expected: PASS for `GlyphAdvance_MatchesRetailFormula` (it's existing), FAIL only if +`FontCharDesc` field names differ — fix the `Glyph(...)` initializer to match the +real `DatReaderWriter.Types.FontCharDesc` (verify via the type before running). The +first test should already pass since `CharIndexAt` is font-agnostic; this test pins +the dat-font advance as the lookup. + +- [ ] **Step 3: Add the dat-font draw + scroll path to `UiChatView`** + +In `src/AcDream.App/UI/UiChatView.cs`: + +1. Add a property next to `Font`: +```csharp +/// Retail dat font (0x40000000) for the transcript. When set, glyphs +/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph +/// advance; when null, the debug BitmapFont path is used. Set by the controller. +public UiDatFont? DatFont { get; set; } +``` +2. Change the wheel quantum to one line per notch (retail `HandleMouseWheel`): +```csharp +private const float WheelLines = 1f; // retail: 1 line per wheel notch (was 3) +``` +3. In `OnDraw`, branch on `DatFont`: use `DatFont.LineHeight` for `lh`, draw each + line with `ctx.DrawStringDat(DatFont, text, Padding, y, color)`, and measure the + selection-highlight span with `DatFont.MeasureWidth(...)`. Keep the `BitmapFont` + branch unchanged as the fallback. Cache `_lastDatFont` alongside `_lastFont` so + `HitChar` uses the same advance source it drew with. +4. In `HitChar`, when `_lastDatFont` is set, build the advance lookup from it: +```csharp +int col = _lastDatFont is { } df + ? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f, + localX - _lastPadding) + : (_lastFont is { } bf + ? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f, + localX - _lastPadding) + : 0); +``` +5. In the `Scroll` event, use the dat-font line height when present: +```csharp +float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` +Expected: PASS. + +- [ ] **Step 5: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiChatView.cs tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs +git commit -m "feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task C: `UiScrollable` (pixel-scroll coordinator) + +Port `UIElement_Scrollable`'s pixel-scroll math: a pure, GL-free coordinator the +transcript and scrollbar both read. No `UiElement` inheritance — it is held by +`UiChatView` and queried by `UiChatScrollbar`. + +**Files:** +- Create: `src/AcDream.App/UI/UiScrollable.cs` +- Test: `tests/AcDream.App.Tests/UI/UiScrollableTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiScrollableTests +{ + [Fact] + public void Clamp_KeepsScrollWithinContent() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(500); // over max + Assert.Equal(200, s.ScrollY); // max = 300-100 + s.SetScrollY(-50); + Assert.Equal(0, s.ScrollY); + } + + [Fact] + public void FitsView_PinsToZero() + { + var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; + s.SetScrollY(40); + Assert.Equal(0, s.ScrollY); // content <= view => no scroll + Assert.False(s.HasOverflow); + } + + [Fact] + public void ThumbRatio_IsViewOverContent_ClampedToOne() + { + var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + Assert.Equal(0.25f, s.ThumbRatio, 3); // 100/400 + var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + Assert.Equal(1f, full.ThumbRatio, 3); // content < view => full thumb + } + + [Fact] + public void PositionRatio_MapsScrollToZeroOne() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(100); // half of max(200) + Assert.Equal(0.5f, s.PositionRatio, 3); + s.SetScrollY(200); + Assert.Equal(1f, s.PositionRatio, 3); + } + + [Fact] + public void SetPositionRatio_IsInverseOfPositionRatio() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetPositionRatio(0.5f); + Assert.Equal(100, s.ScrollY); // 0.5 * max(200) + } + + [Fact] + public void ScrollByLines_AdvancesByLineHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.ScrollByLines(-2); // retail: negative = toward older/top + Assert.Equal(0, s.ScrollY); // already at top, clamped + s.SetScrollY(50); + s.ScrollByLines(2); + Assert.Equal(82, s.ScrollY); // 50 + 2*16 + } + + [Fact] + public void ScrollByPage_AdvancesByViewHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.SetScrollY(200); + s.ScrollByPage(1); + Assert.Equal(300, s.ScrollY); // 200 + view(100) + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` +Expected: FAIL — `UiScrollable` does not exist. + +- [ ] **Step 3: Implement `UiScrollable`** + +Create `src/AcDream.App/UI/UiScrollable.cs`. Ports `UIElement_Scrollable` +(`SetScrollableXY @0x4740c0`, `UpdateScrollbarSize_ @0x4741a0`, +`UpdateScrollbarPosition_ @0x473f20`, `InqScrollDelta @0x4689b0`): + +```csharp +using System; + +namespace AcDream.App.UI; + +/// +/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: +/// the scroll offset is an integer pixel value (m_iScrollableY) clamped to +/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position +/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and +/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar). +/// +public sealed class UiScrollable +{ + /// Total wrapped content height in px (UIElement_Scrollable m_iScrollableHeight). + public int ContentHeight { get; set; } + /// Visible viewport height in px. + public int ViewHeight { get; set; } + /// Pixels per text line (the scroll quantum). UIElement_Text::InqScrollDelta line case. + public int LineHeight { get; set; } = 16; + + private int _scrollY; + /// Current scroll offset in px from the top of the content. + public int ScrollY => _scrollY; + + /// Max scroll = max(0, content - view). + public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); + + /// True when content exceeds the view (a scrollbar is warranted). + public bool HasOverflow => ContentHeight > ViewHeight; + + /// True when the offset is at (or past) the bottom — used for bottom-pin. + public bool AtEnd => _scrollY >= MaxScroll; + + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). + public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); + + /// Pin to the bottom (newest content visible). + public void ScrollToEnd() => _scrollY = MaxScroll; + + /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). + public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); + + /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). + public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; + + /// Inverse of PositionRatio — used when the user drags the thumb. + public void SetPositionRatio(float ratio) + => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); + + /// Scroll by whole lines (sign: +down/newer, -up/older). + public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); + + /// Scroll by a page = one view height (InqScrollDelta page case). + public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiScrollable.cs tests/AcDream.App.Tests/UI/UiScrollableTests.cs +git commit -m "feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task C2: Wire `UiScrollable` into `UiChatView` + +Replace `UiChatView`'s ad-hoc `_scroll` float with a `UiScrollable`, so the +transcript's content/view height + bottom-pin + line-scroll flow through the +shared model (and the scrollbar in Task D can read the same instance). + +**Files:** +- Modify: `src/AcDream.App/UI/UiChatView.cs` + +- [ ] **Step 1: Hold a `UiScrollable` + expose it** + +Add to `UiChatView`: +```csharp +/// The scroll model — also read by the linked UiChatScrollbar. +public UiScrollable Scroll { get; } = new(); +``` + +- [ ] **Step 2: Drive it from `OnDraw`** + +In `OnDraw`, after computing `lh`, `contentH`, `innerH`, set the model and read back +the offset instead of the local `_scroll`: +```csharp +Scroll.LineHeight = (int)MathF.Round(lh); +Scroll.ContentHeight = (int)MathF.Ceiling(contentH); +Scroll.ViewHeight = (int)MathF.Floor(innerH); +// Bottom-pin: if the user was at the end before content grew, stay pinned. +if (_pinBottom) Scroll.ScrollToEnd(); +float baseY = bottom - contentH + Scroll.ScrollY; // ScrollY is px from top; baseY shifts content +``` +Keep a `private bool _pinBottom = true;` that is set false when the user scrolls up +(in the `Scroll` event, `_pinBottom = Scroll.AtEnd;` after applying the delta) and +true again when they return to the end. + +> The existing `ClampScroll` static + `_scroll` field are superseded by +> `UiScrollable`. Keep `ClampScroll` if other tests reference it; otherwise remove it +> and update `UiChatView`'s scroll-offset reads to `Scroll.ScrollY`. + +- [ ] **Step 3: Route the wheel through the model** + +In the `Scroll` event handler: +```csharp +case UiEventType.Scroll: +{ + // Silk wheel +Y = scroll up = reveal older. Retail: 1 line per notch. + Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); + _pinBottom = Scroll.AtEnd; + return true; +} +``` + +- [ ] **Step 4: Build + run the App tests** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug && dotnet test tests/AcDream.App.Tests --filter UiChatView` +Expected: build clean; `UiChatViewDatFontTests` still PASS. Adjust any test that +referenced the removed `_scroll`/`ClampScroll` to use `Scroll`. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/UiChatView.cs +git commit -m "feat(D.2b): UiChatView drives the shared UiScrollable model + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task D: `UiChatScrollbar` (track + thumb + up/down) + +A `UiElement` that renders the right-side scrollbar and drives a `UiScrollable`. +Follows the `UiMeter` sprite pattern (`SpriteResolve` + `ctx.DrawSprite`). + +**Files:** +- Create: `src/AcDream.App/UI/UiChatScrollbar.cs` + +> **First, locate the scroll up/down button ids in the dat.** Run +> `dotnet run --project src/AcDream.Cli -- dump-vitals-layout "" 0x21000006` +> and inspect the children of track `0x10000012` (and the gold caps seen at the +> top/bottom of the scrollbar in the retail screenshot). Record the up-button and +> down-button element ids + their sprite ids in a comment. If the track has no +> button children, the up/down are part of the track sprite and clicks are handled +> by hit-region (top 16px = up, bottom 16px = down). + +- [ ] **Step 1: Implement the widget** + +Create `src/AcDream.App/UI/UiChatScrollbar.cs`: + +```csharp +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the +/// content/view ratio, and up/down step buttons. Drives a linked +/// . Ports retail UIElement_Scrollbar::UpdateLayout +/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from +/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). +/// +public sealed class UiChatScrollbar : UiElement +{ + /// The scroll model this bar reflects + drives (shared with the transcript). + public UiScrollable? Model { get; set; } + /// RenderSurface id → (GL tex, w, h). 0 id = skip. + public Func? SpriteResolve { get; set; } + + public uint TrackSprite { get; set; } // 0x10000012 face + public uint ThumbSprite { get; set; } // 0x1000048c face + public uint UpSprite { get; set; } + public uint DownSprite { get; set; } + + private const float MinThumb = 8f; // retail attribute 0x89 floor + private const float ButtonH = 16f; // up/down button square + private bool _draggingThumb; + private float _dragOffsetY; + + public UiChatScrollbar() { CapturesPointerDrag = true; } + + /// Thumb rect in local space (between the two end buttons). + public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen) + { + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = trackLen - h; + float y = trackTop + travel * m.PositionRatio; + return (y, h); + } + + protected override void OnDraw(UiRenderContext ctx) + { + if (Model is not { } m || SpriteResolve is not { } resolve) return; + // Track fills the full height; buttons cap top/bottom; thumb floats between. + DrawSprite(ctx, resolve, TrackSprite, 0, 0, Width, Height); + DrawSprite(ctx, resolve, UpSprite, 0, 0, Width, ButtonH); + DrawSprite(ctx, resolve, DownSprite, 0, Height - ButtonH, Width, ButtonH); + if (m.HasOverflow) + { + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + DrawSprite(ctx, resolve, ThumbSprite, 0, ty, Width, th); + } + } + + private void DrawSprite(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0) return; + var (tex, _, _) = resolve(id); + if (tex == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); + } + + public override bool OnEvent(in UiEvent e) + { + if (Model is not { } m) return false; + switch (e.Type) + { + case UiEventType.MouseDown: + { + float ly = e.Data2; // local Y (UiRoot delivers target-local) + if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } // up button + if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } // down button + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + if (ly >= ty && ly <= ty + th) { _draggingThumb = true; _dragOffsetY = ly - ty; } + else m.ScrollByPage(ly < ty ? -1 : 1); // click in track half = page + return true; + } + case UiEventType.MouseMove when _draggingThumb: + { + float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; + float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); + float travel = MathF.Max(1f, trackLen - h); + m.SetPositionRatio((e.Data2 - _dragOffsetY - trackTop) / travel); + return true; + } + case UiEventType.MouseUp: _draggingThumb = false; return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/UiChatScrollbar.cs +git commit -m "feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task E: `UiChatInput` (editable one-line field) + +Port the `UIElement_Text` edit path: caret, insert/delete, 100-entry history, +focus sprite, dat-font draw, submit callback. Caret math reuses `UiDatFont`. + +**Files:** +- Create: `src/AcDream.App/UI/UiChatInput.cs` +- Test: `tests/AcDream.App.Tests/UI/UiChatInputTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`. The pure, testable seams are +text editing + history navigation (no GL). The widget exposes them as instance state: + +```csharp +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +public class UiChatInputTests +{ + [Fact] + public void InsertChar_AdvancesCaret() + { + var input = new UiChatInput(); + input.InsertChar('h'); input.InsertChar('i'); + Assert.Equal("hi", input.Text); + Assert.Equal(2, input.CaretPos); + } + + [Fact] + public void Backspace_DeletesBeforeCaret() + { + var input = new UiChatInput(); + foreach (var c in "abc") input.InsertChar(c); + input.MoveCaret(-1); // caret between 'b' and 'c' + input.Backspace(); // deletes 'b' + Assert.Equal("ac", input.Text); + Assert.Equal(1, input.CaretPos); + } + + [Fact] + public void Submit_FiresCallback_ClearsText_PushesHistory() + { + string? sent = null; + var input = new UiChatInput { OnSubmit = t => sent = t }; + foreach (var c in "hello") input.InsertChar(c); + input.Submit(); + Assert.Equal("hello", sent); + Assert.Equal("", input.Text); + Assert.Equal(0, input.CaretPos); + } + + [Fact] + public void EmptySubmit_DoesNotFire() + { + int n = 0; + var input = new UiChatInput { OnSubmit = _ => n++ }; + input.Submit(); + Assert.Equal(0, n); + } + + [Fact] + public void History_UpDownBrowsesPreviousSubmissions() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + foreach (var c in "first") input.InsertChar(c); input.Submit(); + foreach (var c in "second") input.InsertChar(c); input.Submit(); + input.HistoryPrev(); // most recent + Assert.Equal("second", input.Text); + input.HistoryPrev(); + Assert.Equal("first", input.Text); + input.HistoryNext(); + Assert.Equal("second", input.Text); + input.HistoryNext(); // back to live (empty) + Assert.Equal("", input.Text); + } + + [Fact] + public void History_CapsAt100() + { + var input = new UiChatInput { OnSubmit = _ => {} }; + for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } + Assert.True(input.HistoryCount <= 100); + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` +Expected: FAIL — `UiChatInput` does not exist. + +- [ ] **Step 3: Implement `UiChatInput`** + +Create `src/AcDream.App/UI/UiChatInput.cs`. Ports `UIElement_Text` editable mode +(`CharacterHandler`, `MoveCursor @0x468d00`, `FindPixelsFromPos @0x472b40`) + +`ChatInterface` history (`ProcessCommand @0x4f5100`, `SelectCommandFromHistory`, +sentinel `-1` = live): + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Editable one-line chat input. Port of retail UIElement_Text in editable +/// one-line mode + ChatInterface's 100-entry command history. Caret is a +/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. +/// Submit (Enter / Send) fires , clears, and pushes history. +/// +public sealed class UiChatInput : UiElement +{ + public UiDatFont? DatFont { get; set; } + public BitmapFont? Font { get; set; } + public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); + public float Padding { get; set; } = 4f; + public int MaxCharacters { get; set; } = 0xFFFF; // retail m_nMaxCharacters default + + /// Called on Enter/Send with the (non-empty) text. The widget clears after. + public Action? OnSubmit { get; set; } + + private string _text = ""; + private int _caret; + public string Text => _text; + public int CaretPos => _caret; + + private readonly List _history = new(); + private int _historyIndex = -1; // -1 = live line (not browsing) + public int HistoryCount => _history.Count; + + public UiChatInput() + { + AcceptsFocus = true; + IsEditControl = true; + CapturesPointerDrag = true; + } + + // ── Pure editing seams (unit-tested) ───────────────────────────────── + public void InsertChar(char c) + { + if (c < 0x20 || c == 0x7F) return; // skip controls (retail CharacterHandler) + if (_text.Length >= MaxCharacters) return; + _text = _text.Insert(_caret, c.ToString()); + _caret++; + _historyIndex = -1; // editing returns to the live line + } + + public void Backspace() + { + if (_caret == 0) return; + _text = _text.Remove(_caret - 1, 1); + _caret--; + } + + public void DeleteForward() + { + if (_caret >= _text.Length) return; + _text = _text.Remove(_caret, 1); + } + + public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length); + public void CaretHome() => _caret = 0; + public void CaretEnd() => _caret = _text.Length; + + public void Submit() + { + var t = _text; + if (t.Trim().Length == 0) { Clear(); return; } + OnSubmit?.Invoke(t); + PushHistory(t); + Clear(); + } + + private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; } + + private void PushHistory(string t) + { + _history.Add(t); + if (_history.Count > 100) _history.RemoveAt(0); // retail cap 100, drop oldest + _historyIndex = -1; + } + + public void HistoryPrev() // Up arrow — toward older + { + if (_history.Count == 0) return; + _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1); + SetTextFromHistory(); + } + + public void HistoryNext() // Down arrow — toward newer, then live + { + if (_historyIndex < 0) return; + _historyIndex++; + if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; } + SetTextFromHistory(); + } + + private void SetTextFromHistory() + { + _text = _history[_historyIndex]; + _caret = _text.Length; + } + + /// Caret pixel-X from the text start (FindPixelsFromPos): Σ advances to caret. + public float CaretPixelX() + => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret)) + : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f; + + // ── Rendering + input ──────────────────────────────────────────────── + protected override void OnDraw(UiRenderContext ctx) + { + ctx.DrawRect(0, 0, Width, Height, BackgroundColor); + float ty = (Height - (DatFont?.LineHeight ?? Font?.LineHeight ?? 14f)) * 0.5f; + if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor); + else if (Font is not null || ctx.DefaultFont is not null) ctx.DrawString(_text, Padding, ty, TextColor, Font); + + // Caret: 1px vertical line at the caret X (blink left to a follow-up; draw solid for now). + if (HasKeyboardFocus()) + { + float cx = Padding + CaretPixelX(); + float ch = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + ctx.DrawRect(cx, ty, 1f, ch, TextColor); + } + } + + private bool HasKeyboardFocus() + => (Parent is not null) && FindRoot()?.KeyboardFocus == this; + + private UiRoot? FindRoot() + { + UiElement? e = this; + while (e is not null) { if (e is UiRoot r) return r; e = e.Parent; } + return null; + } + + public override bool OnEvent(in UiEvent e) + { + switch (e.Type) + { + case UiEventType.Char: + InsertChar((char)e.Data0); + return true; + case UiEventType.KeyDown: + { + var key = (Silk.NET.Input.Key)e.Data0; + switch (key) + { + case Silk.NET.Input.Key.Enter: + case Silk.NET.Input.Key.KeypadEnter: Submit(); return true; + case Silk.NET.Input.Key.Backspace: Backspace(); return true; + case Silk.NET.Input.Key.Delete: DeleteForward(); return true; + case Silk.NET.Input.Key.Left: MoveCaret(-1); return true; + case Silk.NET.Input.Key.Right: MoveCaret(1); return true; + case Silk.NET.Input.Key.Home: CaretHome(); return true; + case Silk.NET.Input.Key.End: CaretEnd(); return true; + case Silk.NET.Input.Key.Up: HistoryPrev(); return true; + case Silk.NET.Input.Key.Down: HistoryNext(); return true; + } + return false; + } + } + return false; + } +} +``` + +> **Note on focus access:** the snippet walks to the `UiRoot` to read `KeyboardFocus`. +> If `UiRoot.KeyboardFocus` is not reachable that way at runtime, add a +> `bool Focused` flag set from `UiEventType.FocusGained`/`FocusLost` in `OnEvent` +> instead (the `UiElement` event model delivers both — see `UiRoot.SetKeyboardFocus`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` +Expected: PASS (6 tests). + +- [ ] **Step 5: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. (If `e.Data0` for `Char` is the codepoint per `UiRoot.OnChar`, +the `(char)e.Data0` cast is correct.) + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/UI/UiChatInput.cs tests/AcDream.App.Tests/UI/UiChatInputTests.cs +git commit -m "feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task F: `UiChannelMenu` (channel selector) + +The `Chat ▸` selector: a button showing the active channel; clicking opens a popup +list of channels; selecting one fires a channel-changed callback. Ports +`UIElement_Menu` minimally (a button + a popup item list). + +**Files:** +- Create: `src/AcDream.App/UI/UiChannelMenu.cs` + +- [ ] **Step 1: Implement the widget** + +Create `src/AcDream.App/UI/UiChannelMenu.cs`. The 13 channels map to +`ChatChannelKind` (retail `InitTalkFocusMenu @0x4cdc50` enum: 1=Say, 4=Fellowship, +5=Patron, 6=Trade, 7=Allegiance, …). The popup is a vertical list drawn on click; +selection updates `Selected` + fires `OnChannelChanged`. + +```csharp +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.UI.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Chat channel selector (the "Chat ▸" button). Port of retail +/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: +/// a button whose label is the active channel; clicking opens a popup of channels; +/// selecting one calls SetTalkFocus (here: ). +/// +public sealed class UiChannelMenu : UiElement +{ + public readonly record struct Item(string Label, ChatChannelKind Channel); + + /// Retail talk-focus channels (subset acdream's ChatInputParser routes). + public static readonly Item[] Channels = + { + new("Say", ChatChannelKind.Say), + new("General", ChatChannelKind.General), + new("Trade", ChatChannelKind.Trade), + new("LFG", ChatChannelKind.Lfg), + new("Fellowship", ChatChannelKind.Fellowship), + new("Allegiance", ChatChannelKind.Allegiance), + new("Patron", ChatChannelKind.Patron), + new("Vassals", ChatChannelKind.Vassals), + new("Monarch", ChatChannelKind.Monarch), + new("Roleplay", ChatChannelKind.Roleplay), + new("Society", ChatChannelKind.Society), + new("Olthoi", ChatChannelKind.Olthoi), + }; + + public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; + public Action? OnChannelChanged { get; set; } + + public UiDatFont? DatFont { get; set; } + public BitmapFont? Font { get; set; } + public Func? SpriteResolve { get; set; } + public uint NormalSprite { get; set; } // 0x06004D65 + public uint PressedSprite { get; set; } // 0x06004D66 + public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f); + + private bool _open; + private const float ItemH = 16f; + + public UiChannelMenu() { CapturesPointerDrag = true; } + + private string Label => FindLabel(Selected); + private static string FindLabel(ChatChannelKind k) + { + foreach (var it in Channels) if (it.Channel == k) return it.Label; + return "Chat"; + } + + protected override void OnDraw(UiRenderContext ctx) + { + // Button face. + if (SpriteResolve is { } resolve) + { + var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); + if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f); + + // Popup list above the button (chat is at screen bottom). + if (_open) + { + float h = Channels.Length * ItemH; + float top = -h; + ctx.DrawRect(0, top, MathF.Max(Width, 90f), h, new(0f, 0f, 0f, 0.85f)); + for (int i = 0; i < Channels.Length; i++) + DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH); + } + } + + private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + private void DrawLabel(UiRenderContext ctx, string s, float x, float y) + { + if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); + else ctx.DrawString(s, x, y, TextColor, Font); + } + + protected override bool OnHitTest(float lx, float ly) + => _open ? (lx >= 0 && lx < MathF.Max(Width, 90f) && ly >= -Channels.Length * ItemH && ly < Height) + : base.OnHitTest(lx, ly); + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.MouseDown) + { + float ly = e.Data2; + if (_open && ly < 0) // clicked an item in the popup + { + int idx = (int)((ly + Channels.Length * ItemH) / ItemH); + if (idx >= 0 && idx < Channels.Length) + { + Selected = Channels[idx].Channel; + OnChannelChanged?.Invoke(Selected); + } + _open = false; + return true; + } + _open = !_open; // toggle on button click + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. (Verify `ChatChannelKind` has the members used; adjust the +`Channels` table to the real enum names if any differ.) + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/UiChannelMenu.cs +git commit -m "feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task G: `ChatWindowController` (import + bind + route) + +The `ChatInterface`/`gmMainChatUI::PostInit` analogue: import `0x21000006`, bind by +id, swap the transcript/input placeholders for the behavioral widgets, wire the +scrollbar/menu/send/max-min, and route inbound (`ChatVM`) + outbound +(`ChatCommandRouter`). + +**Files:** +- Create: `src/AcDream.App/UI/Layout/ChatWindowController.cs` + +- [ ] **Step 1: Implement the controller** + +Create `src/AcDream.App/UI/Layout/ChatWindowController.cs`: + +```csharp +using System; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.UI.Layout; + +/// +/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream +/// analogue of retail ChatInterface + gmMainChatUI::PostInit. It +/// FindElement(id)s each role, swaps the transcript/input placeholders for the +/// behavioral widgets, wires the scrollbar/menu/send/max-min, and routes chat. +/// +public sealed class ChatWindowController +{ + public const uint LayoutId = 0x21000006u; + public const uint TranscriptId = 0x10000011u; + public const uint InputId = 0x10000016u; + public const uint TrackId = 0x10000012u; + public const uint ThumbId = 0x1000048Cu; + public const uint MenuId = 0x10000014u; + public const uint SendId = 0x10000019u; + public const uint MaxMinId = 0x1000046Fu; + + public UiChatView Transcript { get; private set; } = null!; + public UiChatInput Input { get; private set; } = null!; + public UiChatScrollbar Scrollbar { get; private set; } = null!; + public UiChannelMenu Menu { get; private set; } = null!; + + /// Bind an imported chat layout. Returns the controller, or null if the + /// required role elements are missing. + public static ChatWindowController? Bind( + ImportedLayout layout, ChatVM vm, ICommandBus bus, + UiDatFont? datFont, BitmapFont? debugFont, + Func resolve) + { + var transcriptPh = layout.FindElement(TranscriptId); + var inputPh = layout.FindElement(InputId); + if (transcriptPh is null || inputPh is null) return null; + + var c = new ChatWindowController(); + + // Transcript — swap placeholder for UiChatView at the same rect/anchors. + c.Transcript = new UiChatView + { + Left = transcriptPh.Left, Top = transcriptPh.Top, + Width = transcriptPh.Width, Height = transcriptPh.Height, + Anchors = transcriptPh.Anchors, + DatFont = datFont, Font = debugFont, + LinesProvider = () => BuildLines(vm), + }; + ReplaceInParent(transcriptPh, c.Transcript); + + // Input — swap placeholder for UiChatInput. + c.Input = new UiChatInput + { + Left = inputPh.Left, Top = inputPh.Top, + Width = inputPh.Width, Height = inputPh.Height, + Anchors = inputPh.Anchors, + DatFont = datFont, Font = debugFont, + }; + ReplaceInParent(inputPh, c.Input); + + // Menu — swap placeholder for UiChannelMenu (label tracks the active channel). + var menuPh = layout.FindElement(MenuId); + c.Menu = new UiChannelMenu { DatFont = datFont, Font = debugFont, SpriteResolve = resolve }; + if (menuPh is not null) + { + c.Menu.Left = menuPh.Left; c.Menu.Top = menuPh.Top; + c.Menu.Width = menuPh.Width; c.Menu.Height = menuPh.Height; + c.Menu.Anchors = menuPh.Anchors; + ReplaceInParent(menuPh, c.Menu); + } + + // Scrollbar — swap the track placeholder for the scrollbar widget driving the + // transcript's UiScrollable. + var trackPh = layout.FindElement(TrackId); + c.Scrollbar = new UiChatScrollbar { Model = c.Transcript.Scroll, SpriteResolve = resolve }; + if (trackPh is not null) + { + c.Scrollbar.Left = trackPh.Left; c.Scrollbar.Top = trackPh.Top; + c.Scrollbar.Width = trackPh.Width; c.Scrollbar.Height = trackPh.Height; + c.Scrollbar.Anchors = trackPh.Anchors; + // Sprite ids: read from the imported track/thumb nodes (TrackSprite, ThumbSprite). + ReplaceInParent(trackPh, c.Scrollbar); + } + + // Routing: input submit -> ChatCommandRouter with the menu's active channel. + c.Input.OnSubmit = text => + ChatCommandRouter.Submit(text, vm, bus, c.Menu.Selected); + c.Menu.OnChannelChanged = _ => { /* active channel read live from Menu.Selected */ }; + + // Send button -> submit (alternate trigger, retail ListenToElementMessage 0x10000019). + var send = layout.FindElement(SendId); + if (send is not null) send.ClickThrough = false; // ensure it receives clicks + // (wire send click -> c.Input.Submit() in the controller's event hook or via a + // small click handler subclass; if FindElement returns a UiDatElement, attach + // an OnClick delegate — add one to UiDatElement if absent.) + + return c; + } + + private static void ReplaceInParent(UiElement placeholder, UiElement widget) + { + var parent = placeholder.Parent; + if (parent is null) return; + parent.RemoveChild(placeholder); + parent.AddChild(widget); + } + + private static System.Collections.Generic.IReadOnlyList BuildLines(ChatVM vm) + { + var detailed = vm.RecentLinesDetailed(); + var result = new UiChatView.Line[detailed.Count]; + for (int i = 0; i < detailed.Count; i++) + result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); + return result; + } + + // Per-ChatKind palette (moved from GameWindow.RetailChatColor in Task H). + private static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch + { + AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), + AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), + AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), + AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f), + AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f), + AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), + AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), + AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), + _ => new(0.9f, 0.9f, 0.9f, 1f), + }; +} +``` + +> **Send-button + max/min click wiring:** `LayoutImporter` builds those as +> `UiDatElement` sprite nodes. If `UiDatElement` has no click hook, add an +> `Action? OnClick` invoked from `OnEvent(UiEventType.Click)` (small change, generic +> + reusable). Wire `send.OnClick = () => Input.Submit();` and +> `maxmin.OnClick = ToggleMaximize;`. The max/min toggle ports +> `gmMainChatUI::HandleMaximizeButton @0x4cce50` (swap between authored height and +> full-parent height, storing old Y/height). If that grows large, file it as a +> follow-up and leave the button inert this pass (note in a divergence row). + +- [ ] **Step 2: Build the App project** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. Resolve the sprite-id reads for the scrollbar (`TrackSprite`/ +`ThumbSprite`) by pulling them from the imported track/thumb `ElementInfo.StateMedia` +(or `UiDatElement`), following the `DatWidgetFactory.SliceIds` pattern. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/UI/Layout/ChatWindowController.cs +git commit -m "feat(D.2b): ChatWindowController — bind chat LayoutDesc, route in/outbound + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task H: `GameWindow` cutover + register + roadmap + +Replace the hand-authored chat block with the controller; default placement; remove +dead code; add divergence rows; mark the work landed. + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` +- Modify: `docs/architecture/retail-divergence-register.md` +- Modify: `docs/plans/2026-04-11-roadmap.md` + +- [ ] **Step 1: Swap the chat block in `GameWindow`** + +In `src/AcDream.App/Rendering/GameWindow.cs`, in the `if (_options.RetailUi)` block, +replace the "Retail chat window" section (`GameWindow.cs:1836-1887`, the +`retailChatVm` + `UiNineSlicePanel` + `UiChatView` + `BuildRetailChatLines` + +`RetailChatColor` block) with: + +```csharp +// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI), +// the same importer path as vitals. ChatWindowController binds the transcript, +// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. +var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); +AcDream.App.UI.Layout.ImportedLayout? chatLayout; +lock (_datLock) + chatLayout = AcDream.App.UI.Layout.LayoutImporter.Import( + _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId, ResolveChrome, vitalsDatFont); +if (chatLayout is not null) +{ + var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( + chatLayout, retailChatVm, _commandBus, vitalsDatFont, _debugFont, ResolveChrome); + if (chatController is not null) + { + var chatRoot = chatLayout.Root; + chatRoot.Left = 10; chatRoot.Top = 432; // bottom-left default; user adjusts visually + chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; + chatRoot.Draggable = true; + chatRoot.Resizable = true; + chatRoot.MinWidth = 200f; chatRoot.MinHeight = 80f; + _uiHost.Root.AddChild(chatRoot); + Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); + } + else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); +} +else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found."); +``` + +> `_commandBus` must be the live `ICommandBus` the chat `SendChatCmd` handler is +> registered on. Confirm the field name in `GameWindow` (grep `ICommandBus` / +> `LiveCommandBus` — it is the same bus the ImGui `ChatPanel` publishes to). If the +> chat window root needs `vitalsDatFont` loaded first, this block already runs after +> the vitals block where `vitalsDatFont` is created — keep that ordering. + +- [ ] **Step 2: Build + run the full suite** + +Run: `dotnet build && dotnet test` +Expected: build clean; all tests green. Remove any now-unused `using`/helpers left in +`GameWindow` (the old `BuildRetailChatLines`/`RetailChatColor` local statics). + +- [ ] **Step 3: Add divergence-register rows** + +In `docs/architecture/retail-divergence-register.md`, add one row each (cite +`file:line`): (1) two-class transcript/input split [Adaptation]; (2) no in-element +word-wrap [Approximation]; (3) one color per line [Approximation]; (4) chat tabs +render but don't switch/filter [Stopgap]; (5) squelch + name-tags absent [Stopgap]; +(6) single default opacity, default font face/size [Approximation]. + +- [ ] **Step 4: Visual verification (user)** + +Launch live and confirm against the retail screenshot: +```powershell +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" +$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" +$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" +$env:ACDREAM_RETAIL_UI="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath chat-redrive.log +``` +Confirm: transcript scrolls in the dat font; scrollbar thumb sizes + drags; type + +Enter/Send dispatch; channel menu switches; window moves/resizes; translucent frame. + +- [ ] **Step 5: Update the roadmap + commit** + +Mark the chat re-drive landed in `docs/plans/2026-04-11-roadmap.md` (D.2b importer +Plan 2 — chat). Commit: +```bash +git add src/AcDream.App/Rendering/GameWindow.cs \ + docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md +git commit -m "feat(D.2b): cut GameWindow over to the data-driven chat window + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review checklist (done while writing) + +- **Spec coverage:** §4 components ↔ Tasks A–H (router→A, transcript dat-font→B, + scrollable→C/C2, scrollbar→D, input→E, menu→F, controller→G, cutover→H). Deferred + items (§2/§6) → register rows in H Step 3. ✓ +- **Placeholders:** the two forward-discoveries (scroll up/down button ids in D; send/ + max-min click hook in G) are explicit, scoped implementation tasks with a fallback, + not hand-waves. ✓ +- **Type consistency:** `UiScrollable` API (`ScrollY`, `ThumbRatio`, `PositionRatio`, + `SetPositionRatio`, `ScrollByLines/Page`) used consistently in C, C2, D. `UiChatView.Scroll` + exposed in C2, consumed in D/G. `ChatCommandRouter.Submit(raw, vm, bus, channel)` defined + in A, called in E-wiring/G. `UiChatInput.OnSubmit`/`Submit()` consistent E↔G. ✓