# 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. ✓