acdream/src/AcDream.App/UI/Layout/FloatingChatWindowController.cs
Erik bcc34ee301 feat(chat): retail text style — two-plane glyph outlines, authored SpewBox/chat styles
Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a
second (background) glyph atlas per font, dilated 2px on every side,
plus two border-pixel scalars (Font.NumHorizontalBorderPixels/
NumVerticalBorderPixels) that acdream's font reader never read — so
even the pre-existing outline parameter drew almost nothing once
enabled. Landed together (either half alone is a no-op or a
regression):

- UiDatFont carries BorderX/BorderY from the DAT font resource.
- UiRenderContext.DrawStringDat inflates the background blit's source
  and destination rect by that margin and restructures into retail's
  exact two-pass whole-string outline-then-fill model
  (UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for
  fonts with no background atlas. Corrects the stale "property 0xd"
  comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor).
- LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/
  OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/
  ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every
  authored-outline element across the DAT set is correct at once.
- SpewBox: RetailFontId corrected from a round-3 heuristic
  (0x40000025) to the actually-authored 0x40000001 (18px bold serif),
  Outline=true set on the controller's UiText. Fill colour stays the
  user-gate-round-1-pinned yellow — font atlases are alpha-only
  (PFID_A8), so there is no baked shading that could explain the
  screenshot's gold as anything other than the outline itself.
- Chat transcript: default fill now seeds from its authored
  ARGB(255,204,204,204) instead of an unrelated color-table slot
  (ChatTranscriptRenderer.BuildLines takes the transcript's own
  DefaultColor as a parameter); the 34-entry LogTextType table is
  untouched, and every existing CH1 conformance test stays green
  unmodified.

Regenerated the committed chat_2100006f.json fixture from the real
installed DAT, confirming end to end (not by missing-field default)
that the transcript carries no outline.

Tests: font-reader border fields + inflation math pinned against the
real DAT font, two-pass draw ordering/tint/inflation via a new
TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22
import at both the ElementReader.Merge and StateDesc-property layers,
SpewBox font/outline, and the chat default-shade seed with the color
table proven untouched.

Full Release suite: 12,610 passed / 4 skipped / 0 failed
(AcDream.slnx, complete solution).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:28:34 +02:00

298 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds one of retail's four floating chat windows — LayoutDesc
/// <c>0x2100005B</c>, window elements <c>0x10000505</c>/<c>0x1000050E</c>/
/// <c>0x1000050F</c>/<c>0x10000510</c> — to live behavior (Campaign CH
/// slice CH6b). All four windows share the SAME LayoutDesc; only the
/// window-id attribute (<c>0x1000007E</c>, carried here as
/// <see cref="WindowId"/> rather than re-read from the dat, since acdream's
/// importer does not resolve LayoutDesc attributes into runtime state) and
/// the mounted screen position differ per instance.
///
/// <para>
/// Shares <see cref="ChatTranscriptRenderer"/> with
/// <see cref="ChatWindowController"/> (the main window) rather than
/// duplicating the word-wrap/color-carry algorithm — both are views over the
/// SAME <see cref="ChatVM"/> transcript (research doc §6.1's "one
/// ChatWindowController instance per window" recommendation, generalized to
/// two sibling classes because the main and floaty layouts diverge enough
/// in their own element sets — talk-focus menu, max/min button, four
/// indicator buttons on main; title bar and close button on floaty,
/// research doc §2.1 vs §2.2 — to make one shared Bind() unreadable).
/// </para>
///
/// <para>
/// A floaty window has no talk-focus menu (research doc §2.2), so its chat
/// entry always sends on <see cref="ChatChannelKind.Say"/> — there is no
/// authored control to pick another channel from this window.
/// </para>
/// </summary>
public sealed class FloatingChatWindowController : IRetainedPanelController
{
public const uint LayoutId = 0x2100005Bu;
// Element ids from floating chat LayoutDesc 0x2100005B (research doc §2.2).
private const uint RootId = 0x100004F7u; // window root, 250x108
private const uint TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
private const uint TrackId = 0x10000012u;
private const uint InputRowId = 0x10000509u; // NOTE: differs from the main window's 0x10000013
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 -> UiField
private const uint SendId = 0x10000019u;
private const uint TitleBarId = 0x100004D9u; // gmFloatyChatUI::SetWindowTitle target
private const uint CloseButtonId = 0x1000052Au;
private bool _disposed;
/// <summary>The retail window id this instance is bound to (1-4).</summary>
public int WindowId { get; }
public UiElement Root { get; private set; } = null!;
public UiText Transcript { get; private set; } = null!;
public UiField Input { get; private set; } = null!;
public UiScrollbar? Scrollbar { get; private set; }
/// <summary>Resolved window-root metadata, including DAT size constraints.</summary>
public ElementInfo DatWindowInfo { get; private set; } = null!;
public RetailWindowHandle? WindowHandle { get; private set; }
// Same idle-frame caching shape as ChatWindowController — an unchanged
// transcript/filter/wrap-width does not re-wrap or re-resolve colors.
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
private long _cachedTranscriptRevision = -1;
private ulong _cachedFilter;
private float _cachedTranscriptWrapWidth = float.NaN;
private UiDatFont? _cachedTranscriptDatFont;
private BitmapFont? _cachedTranscriptDebugFont;
internal int TranscriptLayoutBuildCount { get; private set; }
private FloatingChatWindowController(int windowId)
{
WindowId = windowId;
}
/// <summary>
/// Bind an imported floating-chat layout to live behavior.
/// </summary>
/// <param name="windowId">Retail chat window id, 1 through 4.</param>
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from an
/// <see cref="LayoutImporter.ImportInfos"/> call against
/// <see cref="LayoutId"/>.</param>
/// <param name="layout">Widget tree from a matching
/// <see cref="LayoutImporter.Build"/> call — a FRESH call per window
/// instance, since four independent windows need four independent
/// widget trees even though they share one imported <c>rootInfo</c>.</param>
/// <param name="vm">The SAME chat view-model the main window binds —
/// one canonical transcript, filtered per window.</param>
/// <param name="busProvider">Factory for the live command bus at submit time.</param>
/// <param name="windowFilters">
/// Runtime's canonical per-window filter/open state
/// (<see cref="RuntimeCommunicationState.ChatWindows"/>). Read live on
/// every transcript rebuild — never copied — so a filter change is
/// visible on the next frame without a separate notification.
/// </param>
public static FloatingChatWindowController? Bind(
int windowId,
ElementInfo rootInfo,
ImportedLayout layout,
ChatVM vm,
Func<ICommandBus> busProvider,
ChatWindowState windowFilters,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
{
if (windowId < ChatWindowState.MinFloatingWindowId || windowId > ChatWindowState.MaxFloatingWindowId)
throw new ArgumentOutOfRangeException(nameof(windowId));
ArgumentNullException.ThrowIfNull(windowFilters);
var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputRow = layout.FindElement(InputRowId);
var input = layout.FindElement(InputId) as UiField;
if (input is null || transcriptPanel is null || inputRow is null)
{
Console.WriteLine(
$"[D.2b] FloatingChatWindowController.Bind(window {windowId}): missing required elements " +
$"(input={input is not null}, panel={transcriptPanel is not null}, row={inputRow is not null}) — " +
$"floating chat window will not be interactive.");
return null;
}
var window = layout.FindElement(RootId) ?? layout.Root;
var c = new FloatingChatWindowController(windowId)
{
Root = window,
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
};
// ── Transcript ───────────────────────────────────────────────────
c.Transcript = layout.FindElement(TranscriptId) as UiText
?? throw new InvalidOperationException("floating chat transcript 0x10000011 not built as UiText");
c.Transcript.DatFont = datFont;
c.Transcript.Font = debugFont;
c.Transcript.Centered = false;
c.Transcript.RightAligned = false;
c.Transcript.OneLine = false;
c.Transcript.Selectable = true;
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm, windowFilters);
// ── Input — no talk-focus menu on the floaty layout, so the
// channel is always Say (class doc). ─────────────────────────────
c.Input = input;
c.Input.DatFont = datFont;
c.Input.Font = debugFont;
c.Input.SpriteResolve = resolve;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), ChatChannelKind.Say);
// Same right-edge-tracks-resize fix as the main window
// (ChatWindowController.Bind) — see that method's comment for the
// full retail edge-mode citation.
if (c.Input.LayoutPolicy is { } inputPolicy)
{
c.Input.LayoutPolicy = new UiLayoutPolicy(
inputPolicy.LeftMode,
inputPolicy.TopMode,
rightMode: 1u,
inputPolicy.BottomMode,
inputPolicy.OriginalChild,
inputPolicy.OriginalParent);
}
else
{
c.Input.Anchors |= AnchorEdges.Right;
}
// ── Scrollbar — bind the factory-built Type-11 track element ────
if (layout.FindElement(TrackId) is UiScrollbar bar)
{
bar.Model = c.Transcript.Scroll;
bar.SpriteResolve ??= resolve;
c.Scrollbar = bar;
}
// ── Send button ─────────────────────────────────────────────────
if (layout.FindElement(SendId) is UiButton sendEl)
{
sendEl.OnClick = () => c.Input.Submit();
sendEl.Label = "Send";
sendEl.LabelFont = datFont;
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
}
// ── Title bar — gmFloatyChatUI::SetWindowTitle @0x004CEAA0 sets a
// localized "Chat N" string here; acdream has no LayoutDesc string
// table wired for it yet, so this is a hardcoded English label —
// the same stopgap the channel menu's item labels already use
// (ChatWindowController.ChannelItems). ──────────────────────────
if (layout.FindElement(TitleBarId) is UiText titleText)
{
titleText.DatFont = datFont;
titleText.Font = debugFont;
titleText.OneLine = true;
string title = $"Chat {windowId}";
var titleColor = new Vector4(1f, 0.92f, 0.72f, 1f);
titleText.LinesProvider = () => new[] { new UiText.Line(title, titleColor) };
}
// ── Close button — gmFloatyChatUI::ListenToElementMessage
// @0x004CE330: idMessage==1 (clicked) on 0x1000052A -> SetVisible(false). ──
if (layout.FindElement(CloseButtonId) is UiButton closeEl)
{
closeEl.OnClick = () => c.WindowHandle?.Hide();
}
return c;
}
/// <summary>
/// Attach the typed outer-frame handle after the controller's imported
/// content has been mounted. The close button needs this to hide the
/// window (retail's own <c>SetVisible(false)</c>).
/// </summary>
public void AttachWindow(RetailWindowHandle handle)
{
ArgumentNullException.ThrowIfNull(handle);
if (!ReferenceEquals(handle.ContentRoot, Root))
throw new ArgumentException(
"Floating chat handle content root does not match the bound layout.", nameof(handle));
if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle))
throw new InvalidOperationException("Floating chat controller is already attached to another window.");
WindowHandle = handle;
}
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (ElementInfo child in node.Children)
{
ElementInfo? found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
/// <summary>
/// Convert the shared ChatVM's detailed lines to this window's filtered,
/// wrapped, colored transcript — <see cref="ChatWindowState.TypeIsActive"/>
/// is retail's broadcast half of the display rule
/// (<c>ChatInterface::RecvNotice_DisplayFinalStringInfo</c>); the
/// explicit-address half is inert today because no production
/// <c>ChatEntry</c> carries a target window id yet (register row
/// AP-180 — see <see cref="ChatWindowState"/>'s class doc).
/// </summary>
private IReadOnlyList<UiText.Line> GetTranscriptLines(ChatVM vm, ChatWindowState windowFilters)
{
float maxW = Transcript.Width - 2f * Transcript.Padding;
UiDatFont? datFont = Transcript.DatFont;
BitmapFont? debugFont = Transcript.Font;
long revision = vm.Revision;
ulong filter = windowFilters.GetFilter(WindowId);
if (_cachedTranscriptRevision == revision
&& _cachedFilter == filter
&& _cachedTranscriptWrapWidth.Equals(maxW)
&& ReferenceEquals(_cachedTranscriptDatFont, datFont)
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
{
return _cachedTranscriptLines;
}
var detailed = vm.RecentLinesDetailed();
Func<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: static s => s.Length * 7f;
bool Accept(uint logTextType) => windowFilters.ShouldDisplay(
WindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
var result = ChatTranscriptRenderer.BuildLines(
detailed, maxW, measure, Accept, Transcript.DefaultColor);
_cachedTranscriptRevision = revision;
_cachedFilter = filter;
_cachedTranscriptWrapWidth = maxW;
_cachedTranscriptDatFont = datFont;
_cachedTranscriptDebugFont = debugFont;
_cachedTranscriptLines = result;
TranscriptLayoutBuildCount++;
return result;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
}