fix(D.2b): chat input resolves the live command bus lazily (was bound to null) + register thumb-3-slice row

The live session + its LiveCommandBus are created after the retail-UI block in
OnLoad, so binding the bus by value captured NullCommandBus and silently dropped
outbound chat. Pass a Func<ICommandBus> resolved at submit time (mirrors how the
ImGui ChatPanel re-reads the bus each frame).

AP-41: scrollbar thumb drawn as single stretched tile (0x06004C63) instead of
retail's 3-slice top-cap/middle/bottom-cap — acknowledged in UiChatScrollbar.cs:37,
registered per the divergence-register rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 23:24:44 +02:00
parent 12ab9663d2
commit 0ec36f6197
4 changed files with 16 additions and 11 deletions

View file

@ -94,7 +94,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 40 rows ## 3. Documented approximation (AP) — 41 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -138,6 +138,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiChatView.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | | AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiChatView.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | | AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` |
| AP-41 | Scrollbar thumb drawn as a single stretched sprite (`0x06004C63`, the 3-slice middle tile) instead of retail's 3-slice: top cap `0x06004C60` + tiled middle `0x06004C63` + bottom cap `0x06004C66` | `src/AcDream.App/UI/UiChatScrollbar.cs:37` | The middle tile stretches acceptably at chat-panel dimensions; the 3-slice port is a Task-H upgrade acknowledged inline in the `ThumbSprite` property comment | The thumb's top and bottom edges lack the retail end-cap sprites — slightly wrong visual shape at small thumb sizes (thumb too-short for the middle tile to cleanly scale) | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
--- ---

View file

@ -1849,7 +1849,8 @@ public sealed class GameWindow : IDisposable
if (chatRootInfo is not null && chatLayout is not null) if (chatRootInfo is not null && chatLayout is not null)
{ {
var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind(
chatRootInfo, chatLayout, retailChatVm, _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance, chatRootInfo, chatLayout, retailChatVm,
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance,
vitalsDatFont, _debugFont, ResolveChrome); vitalsDatFont, _debugFont, ResolveChrome);
if (chatController is not null) if (chatController is not null)
{ {

View file

@ -93,7 +93,10 @@ public sealed class ChatWindowController
/// <see cref="LayoutImporter.ImportInfos"/>.</param> /// <see cref="LayoutImporter.ImportInfos"/>.</param>
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param> /// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
/// <param name="vm">Chat view-model (transcript data + command routing).</param> /// <param name="vm">Chat view-model (transcript data + command routing).</param>
/// <param name="bus">Command bus for <c>SendChatCmd</c> publishes.</param> /// <param name="busProvider">Factory that returns the live command bus at submit time.
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
/// even when the live session is established AFTER <see cref="Bind"/> runs
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
/// <param name="datFont">Retail dat font for transcript + input rendering.</param> /// <param name="datFont">Retail dat font for transcript + input rendering.</param>
/// <param name="debugFont">Fallback debug bitmap font (used when /// <param name="debugFont">Fallback debug bitmap font (used when
/// <paramref name="datFont"/> is null).</param> /// <paramref name="datFont"/> is null).</param>
@ -103,7 +106,7 @@ public sealed class ChatWindowController
ElementInfo rootInfo, ElementInfo rootInfo,
ImportedLayout layout, ImportedLayout layout,
ChatVM vm, ChatVM vm,
ICommandBus bus, Func<ICommandBus> busProvider,
UiDatFont? datFont, UiDatFont? datFont,
BitmapFont? debugFont, BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve) Func<uint, (uint tex, int w, int h)> resolve)
@ -158,7 +161,7 @@ public sealed class ChatWindowController
Font = debugFont, Font = debugFont,
}; };
inputBar.AddChild(c.Input); inputBar.AddChild(c.Input);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, bus, c._activeChannel); c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// ── Scrollbar — replace the imported track placeholder ──────────── // ── Scrollbar — replace the imported track placeholder ────────────
// The factory created a UiDatElement for the track. Remove it and place a // The factory created a UiDatElement for the track. Remove it and place a

View file

@ -112,7 +112,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
} }
@ -125,7 +125,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
var panel = layout.FindElement(0x10000010u); var panel = layout.FindElement(0x10000010u);
@ -142,7 +142,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
var bar = layout.FindElement(0x10000013u); var bar = layout.FindElement(0x10000013u);
@ -158,7 +158,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
ctrl!.Input.OnSubmit!.Invoke("hello world"); ctrl!.Input.OnSubmit!.Invoke("hello world");
@ -177,7 +177,7 @@ public class ChatWindowControllerTests
var (rootInfo, layout, vm) = BuildTestTree(); var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl); Assert.NotNull(ctrl);
// Switch channel to General. // Switch channel to General.
@ -202,7 +202,7 @@ public class ChatWindowControllerTests
var vm = new ChatVM(new ChatLog()); var vm = new ChatVM(new ChatLog());
var bus = new CaptureBus(); var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(root, layout, vm, bus, null, null, NoTex); var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, null, null, NoTex);
Assert.Null(ctrl); Assert.Null(ctrl);
} }