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

1484 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Chat-window re-drive Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the hand-authored retail chat window with a data-driven one built from dat `LayoutDesc 0x21000006` (`gmMainChatUI`), with faithful behavioral widgets ported from the named retail decomp and the dat font.
**Architecture:** The existing `LayoutImporter` builds the generic frame (bg sprites, resize bar, grip chrome, tabs, send button) from the dat. A new `ChatWindowController` (the `ChatInterface`/`gmMainChatUI::PostInit` analogue) binds behavior by element id: it swaps the transcript/input placeholder nodes for new behavioral widgets, wires the scrollbar/menu/send/max-min, and routes inbound chat (from `ChatVM`) and outbound (through a shared `ChatCommandRouter`). New widgets port `UIElement_Text`/`_Scrollable`/`_Scrollbar`/`_Menu`.
**Tech Stack:** C# / .NET 10, Silk.NET (GL), the in-tree retained-mode UI toolkit (`src/AcDream.App/UI/`), `DatReaderWriter` (dat reads), xUnit (`tests/`).
**Spec:** `docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md` — read it first. It has the element→role map, decomp citations, and the divergence rows. The decomp is `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
---
## File Structure
**Create:**
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` — shared submit pipeline (client-command intercept → unknown-verb guard → `ChatInputParser.Parse``Publish(SendChatCmd)`). Pure, no GL.
- `src/AcDream.App/UI/UiScrollable.cs` — pixel-scroll coordinator (ports `UIElement_Scrollable` math). Pure, no GL.
- `src/AcDream.App/UI/UiChatInput.cs` — editable one-line text widget (ports `UIElement_Text` edit path).
- `src/AcDream.App/UI/UiChatScrollbar.cs` — right-side scrollbar widget (track + thumb + up/down) driving a `UiScrollable`.
- `src/AcDream.App/UI/UiChannelMenu.cs` — channel-selector dropdown (ports `UIElement_Menu`).
- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — import + bind-by-id + route (the `ChatInterface`/`gmMainChatUI` analogue).
- Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`,
`tests/AcDream.App.Tests/UI/UiScrollableTests.cs`,
`tests/AcDream.App.Tests/UI/UiChatInputTests.cs`,
`tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`.
**Modify:**
- `src/AcDream.App/UI/UiChatView.cs` — add `UiDatFont? DatFont`; dat-font measure/advance/draw; wheel = 1 line/notch; `UiScrollable` integration.
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` — call `ChatCommandRouter` instead of the inline submit block.
- `src/AcDream.App/Rendering/GameWindow.cs` — replace the hand-authored chat block (~line 1836) with `ChatWindowController`.
- `docs/architecture/retail-divergence-register.md` — add the 6 deferral rows.
- `docs/plans/2026-04-11-roadmap.md` — mark the chat re-drive landed.
---
## Task A: `ChatCommandRouter` (shared submit pipeline)
Extract the submit + client-command logic from `ChatPanel` so both the ImGui chat and the retail chat dispatch identically. `ChatPanel` currently hardcodes `ChatChannelKind.Say`; the router parameterizes the default channel (the retail chat passes the channel-menu selection).
**Files:**
- Create: `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`
- Test: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`
- Modify: `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` (call the router)
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`:
```csharp
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using Xunit;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
public class ChatCommandRouterTests
{
// Minimal in-memory command bus capturing the last published SendChatCmd.
private sealed class CaptureBus : ICommandBus
{
public SendChatCmd? Last;
public void Publish<T>(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`):
```csharp
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):
```csharp
if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
&& submitted is not null)
{
ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say);
_input = string.Empty;
renderer.EndChild();
renderer.End();
return;
}
```
- [ ] **Step 6: Verify the full suite still passes**
Run: `dotnet test tests/AcDream.UI.Abstractions.Tests`
Expected: PASS — including the existing `ChatPanelInputTests` (they assert the same submit behavior, now via the router). If any assert on a private `ChatPanel` member, redirect it to `ChatCommandRouter`.
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs \
src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs \
tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs
git commit -m "feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline
Co-Authored-By: Claude Opus 4.8 (1M context) <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:
```csharp
using AcDream.App.UI;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiChatViewDatFontTests
{
// Synthetic per-char advance: each glyph 10px wide (Before=2,Width=6,After=2).
private static FontCharDesc Glyph(char c) => new()
{
Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2,
OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0,
};
[Fact]
public void CharIndexAt_UsesDatGlyphAdvance()
{
// "abc" with 10px advances -> midpoints at 5,15,25. x=12 -> caret before 'b' (index 1).
float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c));
Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f));
Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f));
Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f));
}
[Fact]
public void GlyphAdvance_MatchesRetailFormula()
{
// HorizontalOffsetBefore + Width + HorizontalOffsetAfter = 2+6+2 = 10.
Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x')));
}
}
```
- [ ] **Step 2: Run to verify it fails or passes-trivially**
Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests`
Expected: PASS for `GlyphAdvance_MatchesRetailFormula` (it's existing), FAIL only if
`FontCharDesc` field names differ — fix the `Glyph(...)` initializer to match the
real `DatReaderWriter.Types.FontCharDesc` (verify via the type before running). The
first test should already pass since `CharIndexAt` is font-agnostic; this test pins
the dat-font advance as the lookup.
- [ ] **Step 3: Add the dat-font draw + scroll path to `UiChatView`**
In `src/AcDream.App/UI/UiChatView.cs`:
1. Add a property next to `Font`:
```csharp
/// <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; }
```
2. Change the wheel quantum to one line per notch (retail `HandleMouseWheel`):
```csharp
private const float WheelLines = 1f; // retail: 1 line per wheel notch (was 3)
```
3. In `OnDraw`, branch on `DatFont`: use `DatFont.LineHeight` for `lh`, draw each
line with `ctx.DrawStringDat(DatFont, text, Padding, y, color)`, and measure the
selection-highlight span with `DatFont.MeasureWidth(...)`. Keep the `BitmapFont`
branch unchanged as the fallback. Cache `_lastDatFont` alongside `_lastFont` so
`HitChar` uses the same advance source it drew with.
4. In `HitChar`, when `_lastDatFont` is set, build the advance lookup from it:
```csharp
int col = _lastDatFont is { } df
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
localX - _lastPadding)
: (_lastFont is { } bf
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
localX - _lastPadding)
: 0);
```
5. In the `Scroll` event, use the dat-font line height when present:
```csharp
float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f;
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests`
Expected: PASS.
- [ ] **Step 5: Build the App project**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: 0 errors.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/UiChatView.cs tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs
git commit -m "feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum
Co-Authored-By: Claude Opus 4.8 (1M context) <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`:
```csharp
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiScrollableTests
{
[Fact]
public void Clamp_KeepsScrollWithinContent()
{
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
s.SetScrollY(500); // over max
Assert.Equal(200, s.ScrollY); // max = 300-100
s.SetScrollY(-50);
Assert.Equal(0, s.ScrollY);
}
[Fact]
public void FitsView_PinsToZero()
{
var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 };
s.SetScrollY(40);
Assert.Equal(0, s.ScrollY); // content <= view => no scroll
Assert.False(s.HasOverflow);
}
[Fact]
public void ThumbRatio_IsViewOverContent_ClampedToOne()
{
var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
Assert.Equal(0.25f, s.ThumbRatio, 3); // 100/400
var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
Assert.Equal(1f, full.ThumbRatio, 3); // content < view => full thumb
}
[Fact]
public void PositionRatio_MapsScrollToZeroOne()
{
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
s.SetScrollY(100); // half of max(200)
Assert.Equal(0.5f, s.PositionRatio, 3);
s.SetScrollY(200);
Assert.Equal(1f, s.PositionRatio, 3);
}
[Fact]
public void SetPositionRatio_IsInverseOfPositionRatio()
{
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
s.SetPositionRatio(0.5f);
Assert.Equal(100, s.ScrollY); // 0.5 * max(200)
}
[Fact]
public void ScrollByLines_AdvancesByLineHeight()
{
var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 };
s.ScrollByLines(-2); // retail: negative = toward older/top
Assert.Equal(0, s.ScrollY); // already at top, clamped
s.SetScrollY(50);
s.ScrollByLines(2);
Assert.Equal(82, s.ScrollY); // 50 + 2*16
}
[Fact]
public void ScrollByPage_AdvancesByViewHeight()
{
var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 };
s.SetScrollY(200);
s.ScrollByPage(1);
Assert.Equal(300, s.ScrollY); // 200 + view(100)
}
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests`
Expected: FAIL — `UiScrollable` does not exist.
- [ ] **Step 3: Implement `UiScrollable`**
Create `src/AcDream.App/UI/UiScrollable.cs`. Ports `UIElement_Scrollable`
(`SetScrollableXY @0x4740c0`, `UpdateScrollbarSize_ @0x4741a0`,
`UpdateScrollbarPosition_ @0x473f20`, `InqScrollDelta @0x4689b0`):
```csharp
using System;
namespace AcDream.App.UI;
/// <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**
```bash
git add src/AcDream.App/UI/UiScrollable.cs tests/AcDream.App.Tests/UI/UiScrollableTests.cs
git commit -m "feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port)
Co-Authored-By: Claude Opus 4.8 (1M context) <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`:
```csharp
/// <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`:
```csharp
Scroll.LineHeight = (int)MathF.Round(lh);
Scroll.ContentHeight = (int)MathF.Ceiling(contentH);
Scroll.ViewHeight = (int)MathF.Floor(innerH);
// Bottom-pin: if the user was at the end before content grew, stay pinned.
if (_pinBottom) Scroll.ScrollToEnd();
float baseY = bottom - contentH + Scroll.ScrollY; // ScrollY is px from top; baseY shifts content
```
Keep a `private bool _pinBottom = true;` that is set false when the user scrolls up
(in the `Scroll` event, `_pinBottom = Scroll.AtEnd;` after applying the delta) and
true again when they return to the end.
> The existing `ClampScroll` static + `_scroll` field are superseded by
> `UiScrollable`. Keep `ClampScroll` if other tests reference it; otherwise remove it
> and update `UiChatView`'s scroll-offset reads to `Scroll.ScrollY`.
- [ ] **Step 3: Route the wheel through the model**
In the `Scroll` event handler:
```csharp
case UiEventType.Scroll:
{
// Silk wheel +Y = scroll up = reveal older. Retail: 1 line per notch.
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
_pinBottom = Scroll.AtEnd;
return true;
}
```
- [ ] **Step 4: Build + run the App tests**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug && dotnet test tests/AcDream.App.Tests --filter UiChatView`
Expected: build clean; `UiChatViewDatFontTests` still PASS. Adjust any test that
referenced the removed `_scroll`/`ClampScroll` to use `Scroll`.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/UI/UiChatView.cs
git commit -m "feat(D.2b): UiChatView drives the shared UiScrollable model
Co-Authored-By: Claude Opus 4.8 (1M context) <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`:
```csharp
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**
```bash
git add src/AcDream.App/UI/UiChatScrollbar.cs
git commit -m "feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable
Co-Authored-By: Claude Opus 4.8 (1M context) <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:
```csharp
using AcDream.App.UI;
using Xunit;
namespace AcDream.App.Tests.UI;
public class UiChatInputTests
{
[Fact]
public void InsertChar_AdvancesCaret()
{
var input = new UiChatInput();
input.InsertChar('h'); input.InsertChar('i');
Assert.Equal("hi", input.Text);
Assert.Equal(2, input.CaretPos);
}
[Fact]
public void Backspace_DeletesBeforeCaret()
{
var input = new UiChatInput();
foreach (var c in "abc") input.InsertChar(c);
input.MoveCaret(-1); // caret between 'b' and 'c'
input.Backspace(); // deletes 'b'
Assert.Equal("ac", input.Text);
Assert.Equal(1, input.CaretPos);
}
[Fact]
public void Submit_FiresCallback_ClearsText_PushesHistory()
{
string? sent = null;
var input = new UiChatInput { OnSubmit = t => sent = t };
foreach (var c in "hello") input.InsertChar(c);
input.Submit();
Assert.Equal("hello", sent);
Assert.Equal("", input.Text);
Assert.Equal(0, input.CaretPos);
}
[Fact]
public void EmptySubmit_DoesNotFire()
{
int n = 0;
var input = new UiChatInput { OnSubmit = _ => n++ };
input.Submit();
Assert.Equal(0, n);
}
[Fact]
public void History_UpDownBrowsesPreviousSubmissions()
{
var input = new UiChatInput { OnSubmit = _ => {} };
foreach (var c in "first") input.InsertChar(c); input.Submit();
foreach (var c in "second") input.InsertChar(c); input.Submit();
input.HistoryPrev(); // most recent
Assert.Equal("second", input.Text);
input.HistoryPrev();
Assert.Equal("first", input.Text);
input.HistoryNext();
Assert.Equal("second", input.Text);
input.HistoryNext(); // back to live (empty)
Assert.Equal("", input.Text);
}
[Fact]
public void History_CapsAt100()
{
var input = new UiChatInput { OnSubmit = _ => {} };
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
Assert.True(input.HistoryCount <= 100);
}
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests`
Expected: FAIL — `UiChatInput` does not exist.
- [ ] **Step 3: Implement `UiChatInput`**
Create `src/AcDream.App/UI/UiChatInput.cs`. Ports `UIElement_Text` editable mode
(`CharacterHandler`, `MoveCursor @0x468d00`, `FindPixelsFromPos @0x472b40`) +
`ChatInterface` history (`ProcessCommand @0x4f5100`, `SelectCommandFromHistory`,
sentinel `-1` = live):
```csharp
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <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**
```bash
git add src/AcDream.App/UI/UiChatInput.cs tests/AcDream.App.Tests/UI/UiChatInputTests.cs
git commit -m "feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port)
Co-Authored-By: Claude Opus 4.8 (1M context) <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`.
```csharp
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**
```bash
git add src/AcDream.App/UI/UiChannelMenu.cs
git commit -m "feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port)
Co-Authored-By: Claude Opus 4.8 (1M context) <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`:
```csharp
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**
```bash
git add src/AcDream.App/UI/Layout/ChatWindowController.cs
git commit -m "feat(D.2b): ChatWindowController — bind chat LayoutDesc, route in/outbound
Co-Authored-By: Claude Opus 4.8 (1M context) <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:
```csharp
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
// the same importer path as vitals. ChatWindowController binds the transcript,
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
AcDream.App.UI.Layout.ImportedLayout? chatLayout;
lock (_datLock)
chatLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
_dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId, ResolveChrome, vitalsDatFont);
if (chatLayout is not null)
{
var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind(
chatLayout, retailChatVm, _commandBus, vitalsDatFont, _debugFont, ResolveChrome);
if (chatController is not null)
{
var chatRoot = chatLayout.Root;
chatRoot.Left = 10; chatRoot.Top = 432; // bottom-left default; user adjusts visually
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
chatRoot.Draggable = true;
chatRoot.Resizable = true;
chatRoot.MinWidth = 200f; chatRoot.MinHeight = 80f;
_uiHost.Root.AddChild(chatRoot);
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
}
else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
}
else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
```
> `_commandBus` must be the live `ICommandBus` the chat `SendChatCmd` handler is
> registered on. Confirm the field name in `GameWindow` (grep `ICommandBus` /
> `LiveCommandBus` — it is the same bus the ImGui `ChatPanel` publishes to). If the
> chat window root needs `vitalsDatFont` loaded first, this block already runs after
> the vitals block where `vitalsDatFont` is created — keep that ordering.
- [ ] **Step 2: Build + run the full suite**
Run: `dotnet build && dotnet test`
Expected: build clean; all tests green. Remove any now-unused `using`/helpers left in
`GameWindow` (the old `BuildRetailChatLines`/`RetailChatColor` local statics).
- [ ] **Step 3: Add divergence-register rows**
In `docs/architecture/retail-divergence-register.md`, add one row each (cite
`file:line`): (1) two-class transcript/input split [Adaptation]; (2) no in-element
word-wrap [Approximation]; (3) one color per line [Approximation]; (4) chat tabs
render but don't switch/filter [Stopgap]; (5) squelch + name-tags absent [Stopgap];
(6) single default opacity, default font face/size [Approximation].
- [ ] **Step 4: Visual verification (user)**
Launch live and confirm against the retail screenshot:
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_RETAIL_UI="1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath chat-redrive.log
```
Confirm: transcript scrolls in the dat font; scrollbar thumb sizes + drags; type +
Enter/Send dispatch; channel menu switches; window moves/resizes; translucent frame.
- [ ] **Step 5: Update the roadmap + commit**
Mark the chat re-drive landed in `docs/plans/2026-04-11-roadmap.md` (D.2b importer
Plan 2 — chat). Commit:
```bash
git add src/AcDream.App/Rendering/GameWindow.cs \
docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md
git commit -m "feat(D.2b): cut GameWindow over to the data-driven chat window
Co-Authored-By: Claude Opus 4.8 (1M context) <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. ✓