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) <noreply@anthropic.com> @
58 KiB
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 (portsUIElement_Scrollablemath). Pure, no GL.src/AcDream.App/UI/UiChatInput.cs— editable one-line text widget (portsUIElement_Textedit path).src/AcDream.App/UI/UiChatScrollbar.cs— right-side scrollbar widget (track + thumb + up/down) driving aUiScrollable.src/AcDream.App/UI/UiChannelMenu.cs— channel-selector dropdown (portsUIElement_Menu).src/AcDream.App/UI/Layout/ChatWindowController.cs— import + bind-by-id + route (theChatInterface/gmMainChatUIanalogue).- 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— addUiDatFont? DatFont; dat-font measure/advance/draw; wheel = 1 line/notch;UiScrollableintegration.src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs— callChatCommandRouterinstead of the inline submit block.src/AcDream.App/Rendering/GameWindow.cs— replace the hand-authored chat block (~line 1836) withChatWindowController.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:
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>(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/ChatVMAPIs used above match the real types before running (ChatLog.OnSystemMessage(string, int),ChatLog.Snapshot(),ChatLog.Clear(),ICommandBus.Publish<T>). 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):
using System;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>What a submit did, so the caller can clear its input + give feedback.</summary>
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
/// <summary>
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @0x4f5100</c>
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
/// chat window route through here so command handling stays in one place.
///
/// Order mirrors the prior inline <see cref="ChatPanel"/> flow:
/// client-command intercept → unknown-slash-verb guard → <see cref="ChatInputParser.Parse"/>
/// → <c>Publish(SendChatCmd)</c>.
/// </summary>
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 <name>, /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
ChatPanelat 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):
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
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) <noreply@anthropic.com>"
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<char,float> advance lookup — assert the
dat-font advance (UiDatFont.GlyphAdvance) drives caret hit-testing:
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:
- Add a property next to
Font:
/// <summary>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.</summary>
public UiDatFont? DatFont { get; set; }
- Change the wheel quantum to one line per notch (retail
HandleMouseWheel):
private const float WheelLines = 1f; // retail: 1 line per wheel notch (was 3)
- In
OnDraw, branch onDatFont: useDatFont.LineHeightforlh, draw each line withctx.DrawStringDat(DatFont, text, Padding, y, color), and measure the selection-highlight span withDatFont.MeasureWidth(...). Keep theBitmapFontbranch unchanged as the fallback. Cache_lastDatFontalongside_lastFontsoHitCharuses the same advance source it drew with. - In
HitChar, when_lastDatFontis set, build the advance lookup from it:
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);
- In the
Scrollevent, use the dat-font line height when present:
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
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) <noreply@anthropic.com>"
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:
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):
using System;
namespace AcDream.App.UI;
/// <summary>
/// Pixel-based vertical scroll model. Port of retail <c>UIElement_Scrollable</c>:
/// the scroll offset is an integer pixel value (<c>m_iScrollableY</c>) 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).
/// </summary>
public sealed class UiScrollable
{
/// <summary>Total wrapped content height in px (UIElement_Scrollable m_iScrollableHeight).</summary>
public int ContentHeight { get; set; }
/// <summary>Visible viewport height in px.</summary>
public int ViewHeight { get; set; }
/// <summary>Pixels per text line (the scroll quantum). UIElement_Text::InqScrollDelta line case.</summary>
public int LineHeight { get; set; } = 16;
private int _scrollY;
/// <summary>Current scroll offset in px from the top of the content.</summary>
public int ScrollY => _scrollY;
/// <summary>Max scroll = max(0, content - view).</summary>
public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight);
/// <summary>True when content exceeds the view (a scrollbar is warranted).</summary>
public bool HasOverflow => ContentHeight > ViewHeight;
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
public bool AtEnd => _scrollY >= MaxScroll;
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
/// <summary>Pin to the bottom (newest content visible).</summary>
public void ScrollToEnd() => _scrollY = MaxScroll;
/// <summary>Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_).</summary>
public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight);
/// <summary>Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_).</summary>
public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll;
/// <summary>Inverse of PositionRatio — used when the user drags the thumb.</summary>
public void SetPositionRatio(float ratio)
=> SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll));
/// <summary>Scroll by whole lines (sign: +down/newer, -up/older).</summary>
public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight);
/// <summary>Scroll by a page = one view height (InqScrollDelta page case).</summary>
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
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) <noreply@anthropic.com>"
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:
/// <summary>The scroll model — also read by the linked UiChatScrollbar.</summary>
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:
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
ClampScrollstatic +_scrollfield are superseded byUiScrollable. KeepClampScrollif other tests reference it; otherwise remove it and updateUiChatView's scroll-offset reads toScroll.ScrollY.
- Step 3: Route the wheel through the model
In the Scroll event handler:
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
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) <noreply@anthropic.com>"
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 "<datdir>" 0x21000006and inspect the children of track0x10000012(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:
using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the
/// content/view ratio, and up/down step buttons. Drives a linked
/// <see cref="UiScrollable"/>. Ports retail <c>UIElement_Scrollbar::UpdateLayout
/// @0x4710d0</c> (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from
/// PositionRatio) and <c>HandleButtonClick @0x470e90</c> (step ±1 line).
/// </summary>
public sealed class UiChatScrollbar : UiElement
{
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
public UiScrollable? Model { get; set; }
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
public Func<uint, (uint tex, int w, int h)>? 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; }
/// <summary>Thumb rect in local space (between the two end buttons).</summary>
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<uint,(uint,int,int)> 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
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) <noreply@anthropic.com>"
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:
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):
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Editable one-line chat input. Port of retail <c>UIElement_Text</c> in editable
/// one-line mode + <c>ChatInterface</c>'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 <see cref="OnSubmit"/>, clears, and pushes history.
/// </summary>
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
/// <summary>Called on Enter/Send with the (non-empty) text. The widget clears after.</summary>
public Action<string>? OnSubmit { get; set; }
private string _text = "";
private int _caret;
public string Text => _text;
public int CaretPos => _caret;
private readonly List<string> _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;
}
/// <summary>Caret pixel-X from the text start (FindPixelsFromPos): Σ advances to caret.</summary>
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
UiRootto readKeyboardFocus. IfUiRoot.KeyboardFocusis not reachable that way at runtime, add abool Focusedflag set fromUiEventType.FocusGained/FocusLostinOnEventinstead (theUiElementevent model delivers both — seeUiRoot.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
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) <noreply@anthropic.com>"
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.
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.UI.Abstractions;
namespace AcDream.App.UI;
/// <summary>
/// Chat channel selector (the "Chat ▸" button). Port of retail
/// <c>UIElement_Menu</c> as used by <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c>:
/// a button whose label is the active channel; clicking opens a popup of channels;
/// selecting one calls <c>SetTalkFocus</c> (here: <see cref="OnChannelChanged"/>).
/// </summary>
public sealed class UiChannelMenu : UiElement
{
public readonly record struct Item(string Label, ChatChannelKind Channel);
/// <summary>Retail talk-focus channels (subset acdream's ChatInputParser routes).</summary>
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<ChatChannelKind>? OnChannelChanged { get; set; }
public UiDatFont? DatFont { get; set; }
public BitmapFont? Font { get; set; }
public Func<uint, (uint tex, int w, int h)>? 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
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) <noreply@anthropic.com>"
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:
using System;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit</c>. 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.
/// </summary>
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!;
/// <summary>Bind an imported chat layout. Returns the controller, or null if the
/// required role elements are missing.</summary>
public static ChatWindowController? Bind(
ImportedLayout layout, ChatVM vm, ICommandBus bus,
UiDatFont? datFont, BitmapFont? debugFont,
Func<uint, (uint, int, int)> 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<UiChatView.Line> 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:
LayoutImporterbuilds those asUiDatElementsprite nodes. IfUiDatElementhas no click hook, add anAction? OnClickinvoked fromOnEvent(UiEventType.Click)(small change, generic
- reusable). Wire
send.OnClick = () => Input.Submit();andmaxmin.OnClick = ToggleMaximize;. The max/min toggle portsgmMainChatUI::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
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) <noreply@anthropic.com>"
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:
// 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.");
_commandBusmust be the liveICommandBusthe chatSendChatCmdhandler is registered on. Confirm the field name inGameWindow(grepICommandBus/LiveCommandBus— it is the same bus the ImGuiChatPanelpublishes to). If the chat window root needsvitalsDatFontloaded first, this block already runs after the vitals block wherevitalsDatFontis 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:
$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:
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) <noreply@anthropic.com>"
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:
UiScrollableAPI (ScrollY,ThumbRatio,PositionRatio,SetPositionRatio,ScrollByLines/Page) used consistently in C, C2, D.UiChatView.Scrollexposed 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. ✓