acdream/docs/superpowers/plans/2026-06-15-chat-window-redrive.md
Erik 3d25e8760f @
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>
@
2026-06-15 22:04:35 +02:00

58 KiB
Raw Blame History

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.ParsePublish(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:

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 / ChatVM APIs 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 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):

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:

  1. 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; }
  1. Change the wheel quantum to one line per notch (retail HandleMouseWheel):
private const float WheelLines = 1f;   // retail: 1 line per wheel notch (was 3)
  1. 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.
  2. In HitChar, when _lastDatFont is 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);
  1. In the Scroll event, 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 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:

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>" 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:

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 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
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: 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
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.");

_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:

$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 AH (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. ✓