This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
561 lines
27 KiB
C#
561 lines
27 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 the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
|
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit @0x4ce130</c>.
|
|
///
|
|
/// <para>
|
|
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
|
|
/// by the factory; this controller binds its live data provider in place. The input
|
|
/// (<c>0x10000016</c>) carries Editable property 0x16, so the factory builds it
|
|
/// directly as <see cref="UiField"/> and this controller binds it in place. The
|
|
/// scrollbar track (<c>0x10000012</c>) is
|
|
/// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in
|
|
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
|
|
/// and bound in place.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
|
|
{
|
|
public const uint LayoutId = 0x21000006u;
|
|
private bool _disposed;
|
|
|
|
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
|
private const uint RootId = 0x1000000Eu;
|
|
private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it)
|
|
private const uint TranscriptPanelId = 0x10000010u;
|
|
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
|
private const uint TrackId = 0x10000012u;
|
|
private const uint InputBarId = 0x10000013u;
|
|
private const uint MenuId = 0x10000014u;
|
|
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField
|
|
private const uint SendId = 0x10000019u;
|
|
private const uint MaxMinId = 0x1000046Fu;
|
|
|
|
// Channel menu sprite ids (confirmed in chat element dump).
|
|
private const uint MenuNormal = 0x06004D65u; // button face
|
|
private const uint MenuPressed = 0x06004D66u; // button pressed
|
|
private const uint MenuPopupBg = 0x0600124Cu; // popup panel fill (element 0x1000001C)
|
|
private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E)
|
|
private const uint MenuItemSelected = 0x0600124Du; // active channel row
|
|
|
|
// ── Public surface ─────────────────────────────────────────────────────
|
|
|
|
/// <summary>Root element of the imported layout (the chat window chrome).</summary>
|
|
public UiElement Root { get; private set; } = null!;
|
|
|
|
/// <summary>Live chat transcript widget. Null until <see cref="Bind"/> succeeds.</summary>
|
|
public UiText Transcript { get; private set; } = null!;
|
|
|
|
/// <summary>Editable chat input widget. Null until <see cref="Bind"/> succeeds.</summary>
|
|
public UiField Input { get; private set; } = null!;
|
|
|
|
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
|
|
public UiScrollbar Scrollbar { get; private set; } = null!;
|
|
|
|
/// <summary>Channel-selector menu widget.</summary>
|
|
public UiMenu Menu { get; private set; } = null!;
|
|
|
|
/// <summary>Resolved gmMainChatUI root metadata, including DAT size constraints.</summary>
|
|
public ElementInfo DatWindowInfo { get; private set; } = null!;
|
|
|
|
public RetailWindowHandle? WindowHandle { get; private set; }
|
|
public bool IsMaximized => _maximized;
|
|
|
|
// ── Private state ──────────────────────────────────────────────────────
|
|
|
|
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
|
|
|
// UiText polls LinesProvider while drawing and hit-testing. Keep the fully
|
|
// formatted + wrapped transcript until either its source revision or the
|
|
// metrics that determine wrapping change. This makes an idle chat window
|
|
// allocation-free instead of snapshotting/formatting/wrapping every frame.
|
|
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
|
|
private long _cachedTranscriptRevision = -1;
|
|
private float _cachedTranscriptWrapWidth = float.NaN;
|
|
private UiDatFont? _cachedTranscriptDatFont;
|
|
private BitmapFont? _cachedTranscriptDebugFont;
|
|
internal int TranscriptLayoutBuildCount { get; private set; }
|
|
|
|
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
|
|
|
|
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
|
{
|
|
("Squelch (ignore)", null),
|
|
("Tell to Selected", null),
|
|
("Chat to All", ChatChannelKind.Say),
|
|
("Tell to Fellows", ChatChannelKind.Fellowship),
|
|
("Tell to General Chat", ChatChannelKind.General),
|
|
("Tell to LFG Chat", ChatChannelKind.Lfg),
|
|
("Tell to Society Chat", ChatChannelKind.Society),
|
|
("Tell to Monarch", ChatChannelKind.Monarch),
|
|
("Tell to Patron", ChatChannelKind.Patron),
|
|
("Tell to Vassals", ChatChannelKind.Vassals),
|
|
("Tell to Allegiance", ChatChannelKind.Allegiance),
|
|
("Tell to Trade Chat", ChatChannelKind.Trade),
|
|
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
|
|
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
|
|
};
|
|
|
|
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
|
|
{
|
|
ChatChannelKind.Say => "Chat",
|
|
ChatChannelKind.General => "General",
|
|
ChatChannelKind.Trade => "Trade",
|
|
ChatChannelKind.Lfg => "LFG",
|
|
ChatChannelKind.Fellowship => "Fellow",
|
|
ChatChannelKind.Allegiance => "Alleg",
|
|
ChatChannelKind.Patron => "Patron",
|
|
ChatChannelKind.Vassals => "Vassals",
|
|
ChatChannelKind.Monarch => "Monarch",
|
|
ChatChannelKind.Roleplay => "Roleplay",
|
|
ChatChannelKind.Society => "Society",
|
|
ChatChannelKind.Olthoi => "Olthoi",
|
|
_ => "Chat",
|
|
};
|
|
|
|
private static bool ChannelAvailable(ChatChannelKind k)
|
|
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
|
|
|
|
/// <summary>Window height before maximize (stored to restore on un-maximize).</summary>
|
|
private float _normalHeight;
|
|
/// <summary>Window top before maximize.</summary>
|
|
private float _normalTop;
|
|
private bool _maximized;
|
|
private UiButton? _maxMinButton;
|
|
|
|
// ── Factory ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Bind an imported chat layout to live behavior.
|
|
///
|
|
/// <paramref name="rootInfo"/> and <paramref name="layout"/> must come from the
|
|
/// SAME <see cref="LayoutImporter"/> pass (<c>ImportInfos</c> then <c>Build</c>)
|
|
/// so rects in the info tree match the widget geometry in the layout tree.
|
|
///
|
|
/// Returns <c>null</c> if the essential transcript/input panels are missing from
|
|
/// the info tree or the widget tree (e.g. the layout dat is incomplete).
|
|
/// </summary>
|
|
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from
|
|
/// <see cref="LayoutImporter.ImportInfos"/>.</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="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="debugFont">Fallback debug bitmap font (used when
|
|
/// <paramref name="datFont"/> is null).</param>
|
|
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
|
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiMenu"/>.</param>
|
|
public static ChatWindowController? Bind(
|
|
ElementInfo rootInfo,
|
|
ImportedLayout layout,
|
|
ChatVM vm,
|
|
Func<ICommandBus> busProvider,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont,
|
|
Func<uint, (uint tex, int w, int h)> resolve)
|
|
{
|
|
// Their parent panels must exist as real widgets in the layout tree.
|
|
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
|
var inputBar = layout.FindElement(InputBarId);
|
|
var input = layout.FindElement(InputId) as UiField;
|
|
|
|
if (input is null || transcriptPanel is null || inputBar is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] ChatWindowController.Bind: missing required elements " +
|
|
$"(input={input is not null}, " +
|
|
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
|
|
$"chat window will not be interactive.");
|
|
return null;
|
|
}
|
|
|
|
// LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window
|
|
// (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked
|
|
// window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the
|
|
// talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526).
|
|
// LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root,
|
|
// so using layout.Root would render the strays overlapping the real window (the
|
|
// red-striped garbage in the first live render). Use the gmMainChatUI window itself:
|
|
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
|
|
// orphaning the strays so they never draw.
|
|
var window = layout.FindElement(RootId) ?? layout.Root;
|
|
var c = new ChatWindowController
|
|
{
|
|
Root = window,
|
|
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
|
|
};
|
|
|
|
// Drop the dat top resize bar (0x1000000F): it is authored 800px wide and
|
|
// juts out of the content-width window. The host wraps this content in the
|
|
// universal nine-slice chrome, whose grips provide the resize affordance.
|
|
if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar)
|
|
rbParent.RemoveChild(resizeBar);
|
|
|
|
// Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root):
|
|
// grow the transcript panel up to the window top so its dark bg fills the strip.
|
|
// Otherwise the root element's brown bg shows through as a sliver along the top.
|
|
transcriptPanel.Top = 0f;
|
|
transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9)
|
|
|
|
// ── Transcript ───────────────────────────────────────────────────
|
|
// The factory now builds the Type-12 transcript element (0x10000011) as a UiText.
|
|
// Find it in the widget tree and bind the live providers — no remove/add needed.
|
|
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
|
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
|
c.Transcript.DatFont = datFont;
|
|
c.Transcript.Font = debugFont;
|
|
// The imported Type-12 element may inherit HJustify=Center from its dat
|
|
// prototype, which makes UiText use the static one-line label path. The
|
|
// chat transcript must always use the scrollable multi-line path so it
|
|
// caches layout for mouse selection and Ctrl+C.
|
|
c.Transcript.Centered = false;
|
|
c.Transcript.RightAligned = false;
|
|
c.Transcript.OneLine = false;
|
|
c.Transcript.Selectable = true;
|
|
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
|
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm);
|
|
|
|
// ── Input ────────────────────────────────────────────────────────
|
|
// Editable/selectable/one-line semantics and state sprites came from the
|
|
// imported property/state bags. The controller supplies runtime services only.
|
|
c.Input = input;
|
|
c.Input.DatFont = datFont;
|
|
c.Input.Font = debugFont;
|
|
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
|
|
c.Input.SpriteResolve = resolve;
|
|
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
|
|
|
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
|
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
|
|
// directly. Find it, bind it in place — no remove/add needed.
|
|
var track = layout.FindElement(TrackId);
|
|
if (track is UiScrollbar bar)
|
|
{
|
|
float oldTop = bar.Top;
|
|
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
|
bar.Height = bar.Height + oldTop;
|
|
bar.ResetAnchorCapture();
|
|
bar.Model = c.Transcript.Scroll;
|
|
bar.SpriteResolve ??= resolve;
|
|
c.Scrollbar = bar;
|
|
}
|
|
|
|
// ── Channel menu — bind the factory-built Type-6 UiMenu ──────────
|
|
if (layout.FindElement(MenuId) is UiMenu menu)
|
|
{
|
|
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
|
|
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
|
|
menu.PopupBgSprite = MenuPopupBg;
|
|
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
|
menu.Items = System.Array.ConvertAll(ChannelItems,
|
|
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
|
|
menu.Selected = (object?)c._activeChannel;
|
|
// Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like
|
|
// retail; only the talk-CHANNEL items grey when unavailable.
|
|
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
|
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
|
// The widget reports the pick; the controller owns Selected. Only a talk-channel
|
|
// payload updates the active channel + highlight — the null-payload specials are
|
|
// deferred no-ops (see the chat re-drive deferred list) and leave selection intact.
|
|
menu.OnSelect = p =>
|
|
{
|
|
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
|
|
};
|
|
c.Menu = menu;
|
|
}
|
|
|
|
// ── Send button — Enter-alternate submit trigger ──────────────────
|
|
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
|
|
if (layout.FindElement(SendId) is UiButton sendEl)
|
|
{
|
|
sendEl.OnClick = () => c.Input.Submit();
|
|
// The Send sprite is a blank gold button — retail draws the caption as text.
|
|
sendEl.Label = "Send";
|
|
sendEl.LabelFont = datFont;
|
|
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
|
}
|
|
|
|
// ── Size the channel button to its label + reflow the input field ─
|
|
// Retail's talk-focus button autosizes to the selected channel name; the input
|
|
// field then fills the gap from the button's right edge to the Send button. The
|
|
// dat authors the button at a fixed 46px (too narrow for "Chat" once the LED +
|
|
// arrow are accounted for), so widen it to its content and shift the input.
|
|
// Recompute on every channel change (the button grows/shrinks with the label).
|
|
if (c.Menu is not null)
|
|
{
|
|
float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge
|
|
void ReflowInputRow()
|
|
{
|
|
c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth());
|
|
c.Menu.ResetAnchorCapture();
|
|
c.Input.Left = c.Menu.Left + c.Menu.Width;
|
|
c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left);
|
|
c.Input.ResetAnchorCapture();
|
|
}
|
|
var onSelect = c.Menu.OnSelect;
|
|
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
|
|
ReflowInputRow();
|
|
}
|
|
|
|
// ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
|
|
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
|
|
{
|
|
// The dat puts max/min and the scrollbar up-button at the SAME X (both
|
|
// right-anchored), so at content width they overlap. Retail shows max/min
|
|
// just LEFT of the scrollbar column — shift it one button-width left.
|
|
if (track is not null)
|
|
maxMinEl.Left = track.Left - maxMinEl.Width;
|
|
maxMinEl.ResetAnchorCapture();
|
|
c._maxMinButton = maxMinEl;
|
|
maxMinEl.OnClick = c.ToggleMaximize;
|
|
}
|
|
|
|
return c;
|
|
}
|
|
|
|
// ── Max/min implementation ─────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Attach the typed outer-frame handle after the controller's imported content
|
|
/// has been mounted. Maximize/restore must resize this frame, not the child root.
|
|
/// </summary>
|
|
public void AttachWindow(RetailWindowHandle handle)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(handle);
|
|
if (!ReferenceEquals(handle.ContentRoot, Root))
|
|
throw new ArgumentException("Chat handle content root does not match the bound layout.", nameof(handle));
|
|
if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle))
|
|
throw new InvalidOperationException("Chat controller is already attached to another window.");
|
|
WindowHandle = handle;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact control flow from <c>gmMainChatUI::HandleMaximizeButton @0x004CCE50</c>:
|
|
/// save/restore Y+height, expand by half the parent, choose up/down growth from
|
|
/// available space, clamp through the mounted frame's DAT constraints, and set
|
|
/// the imported max/min button state.
|
|
/// </summary>
|
|
private void ToggleMaximize()
|
|
{
|
|
if (WindowHandle is not { IsRegistered: true } handle)
|
|
return;
|
|
|
|
UiElement frame = handle.OuterFrame;
|
|
float parentHeight = frame.Parent?.Height ?? 0f;
|
|
if (parentHeight <= 0f)
|
|
return;
|
|
|
|
if (_maximized)
|
|
{
|
|
float restoredHeight = Math.Clamp(_normalHeight, frame.MinHeight, frame.MaxHeight);
|
|
float restoredTop = Math.Clamp(
|
|
_normalTop,
|
|
0f,
|
|
MathF.Max(0f, parentHeight - frame.MinHeight));
|
|
_maximized = false;
|
|
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Minimized);
|
|
handle.ResizeTo(frame.Width, restoredHeight);
|
|
handle.MoveTo(frame.Left, restoredTop);
|
|
return;
|
|
}
|
|
|
|
_normalTop = frame.Top;
|
|
_normalHeight = frame.Height;
|
|
|
|
float expansion = parentHeight / 2f;
|
|
float targetHeight = Math.Clamp(
|
|
MathF.Min(frame.Height + expansion, parentHeight),
|
|
frame.MinHeight,
|
|
frame.MaxHeight);
|
|
bool growUp = frame.Top + targetHeight > parentHeight
|
|
|| frame.Top >= parentHeight / 2f;
|
|
float targetTop = growUp
|
|
? MathF.Max(0f, frame.Top - (targetHeight - frame.Height))
|
|
: frame.Top;
|
|
targetHeight = MathF.Min(targetHeight, parentHeight - targetTop);
|
|
|
|
_maximized = true;
|
|
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Maximized);
|
|
handle.ResizeTo(frame.Width, targetHeight);
|
|
handle.MoveTo(frame.Left, targetTop);
|
|
}
|
|
|
|
public RetainedWindowState CaptureWindowState()
|
|
=> new(
|
|
Maximized: _maximized,
|
|
PersistedTop: _maximized ? _normalTop : null,
|
|
PersistedHeight: _maximized ? _normalHeight : null);
|
|
|
|
public void RestoreWindowState(RetainedWindowState state)
|
|
{
|
|
if (state.Maximized != _maximized)
|
|
ToggleMaximize();
|
|
else
|
|
_maxMinButton?.TrySetRetailState(
|
|
_maximized ? RetailUiStateIds.Maximized : RetailUiStateIds.Minimized);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Convert the ChatVM's detailed lines to the transcript's
|
|
/// <see cref="UiText.Line"/> record format, applying retail-faithful
|
|
/// per-<see cref="ChatKind"/> colors.
|
|
/// </summary>
|
|
private IReadOnlyList<UiText.Line> GetTranscriptLines(ChatVM vm)
|
|
{
|
|
float maxW = Transcript.Width - 2f * Transcript.Padding;
|
|
UiDatFont? datFont = Transcript.DatFont;
|
|
BitmapFont? debugFont = Transcript.Font;
|
|
long revision = vm.Revision;
|
|
|
|
if (_cachedTranscriptRevision == revision
|
|
&& _cachedTranscriptWrapWidth.Equals(maxW)
|
|
&& ReferenceEquals(_cachedTranscriptDatFont, datFont)
|
|
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
|
|
{
|
|
return _cachedTranscriptLines;
|
|
}
|
|
|
|
var detailed = vm.RecentLinesDetailed();
|
|
if (detailed.Count == 0)
|
|
{
|
|
return StoreTranscriptLayout(
|
|
Array.Empty<UiText.Line>(), revision, maxW, datFont, debugFont);
|
|
}
|
|
|
|
// Word-wrap each message to the transcript's current pixel width (ports retail
|
|
// GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
|
|
// exceed wrapWidth). The cache key re-evaluates it after window resize.
|
|
Func<string, float> measure =
|
|
datFont is { } df ? s => df.MeasureWidth(s)
|
|
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
|
: static s => s.Length * 7f;
|
|
|
|
var result = new List<UiText.Line>(detailed.Count);
|
|
foreach (var d in detailed)
|
|
{
|
|
var color = RetailChatColor(d.Kind);
|
|
foreach (var frag in WrapText(d.Text, maxW, measure))
|
|
result.Add(new UiText.Line(frag, color));
|
|
}
|
|
return StoreTranscriptLayout(result, revision, maxW, datFont, debugFont);
|
|
}
|
|
|
|
private IReadOnlyList<UiText.Line> StoreTranscriptLayout(
|
|
IReadOnlyList<UiText.Line> lines,
|
|
long revision,
|
|
float wrapWidth,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont)
|
|
{
|
|
_cachedTranscriptRevision = revision;
|
|
_cachedTranscriptWrapWidth = wrapWidth;
|
|
_cachedTranscriptDatFont = datFont;
|
|
_cachedTranscriptDebugFont = debugFont;
|
|
_cachedTranscriptLines = lines;
|
|
TranscriptLayoutBuildCount++;
|
|
return lines;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
|
|
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
|
|
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
|
|
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
|
|
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
|
|
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
|
|
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
|
|
/// </summary>
|
|
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
|
|
{
|
|
if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW)
|
|
{
|
|
yield return text ?? string.Empty;
|
|
yield break;
|
|
}
|
|
|
|
var line = new System.Text.StringBuilder();
|
|
foreach (var word in text.Split(' '))
|
|
{
|
|
string sep = line.Length > 0 ? " " : string.Empty;
|
|
if (measure(line.ToString() + sep + word) <= maxW)
|
|
{
|
|
line.Append(sep).Append(word); // fits on the current line
|
|
continue;
|
|
}
|
|
if (line.Length > 0 && measure(word) <= maxW)
|
|
{
|
|
yield return line.ToString(); // word fits alone → push to a new line
|
|
line.Clear();
|
|
line.Append(word);
|
|
continue;
|
|
}
|
|
// Word too long for any single line: char-wrap it, packing onto the current
|
|
// line's remaining space first (keeps the prefix with the message start).
|
|
if (line.Length > 0) line.Append(' ');
|
|
foreach (char ch in word)
|
|
{
|
|
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
|
|
{
|
|
yield return line.ToString();
|
|
line.Clear();
|
|
}
|
|
line.Append(ch);
|
|
}
|
|
}
|
|
if (line.Length > 0) yield return line.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-<see cref="ChatKind"/> text color — the EXACT retail RGBA values read from a
|
|
/// live retail client via cdb (the named <c>RGBAColor</c> constants at acclient
|
|
/// 0x81c4a8+, e.g. <c>colorWhite</c>/<c>colorBrightPurple</c>/<c>colorLightBlue</c>/
|
|
/// <c>colorGreen</c>, used by <c>ChatInterface::BuildChatColorLookupTable @0x4f31c0</c>).
|
|
/// The four common kinds (speech/tell/channel/system) are confirmed by the named
|
|
/// symbols + universal AC convention; the rarer kinds map to the nearest named color.
|
|
/// </summary>
|
|
private static Vector4 RetailChatColor(ChatKind kind) => kind switch
|
|
{
|
|
ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite
|
|
ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout)
|
|
ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue
|
|
ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple
|
|
ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen
|
|
ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast)
|
|
ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
|
|
ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey
|
|
ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed
|
|
_ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback)
|
|
};
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
}
|
|
}
|