acdream/src/AcDream.App/UI/Layout/ChatWindowController.cs

734 lines
38 KiB
C#
Raw 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.

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 (<c>0x2100006F</c>, retail's ACTUAL main chat
/// window — see <c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §2.1/§5,
/// Campaign CH slice CH6a) 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>
///
/// <para>
/// CH6a retired the wrong <c>0x21000006</c> import (a different, unrelated chat
/// layout whose root <c>0x1000000E</c> and 800px resize bar <c>0x1000000F</c>
/// appear nowhere in the EoR gameplay UI) along with every downstream compensation
/// that existed only to paper over it: the hand-cropped 490px content width, the
/// dropped resize bar, the 9px transcript-panel patch, the orphan-sibling pruning,
/// the max/min-vs-scrollbar overlap shift, and the scrollbar top-reclaim. All of
/// those elements/gaps do not exist in <c>0x2100006F</c>'s authored 410×100 tree —
/// nothing was ever missing; the wrong layout was imported. The eight authored
/// resize grips (<c>0x1000069B</c>-<c>0x100006A2</c>, Type 9
/// <c>UIElement_Resizebar</c> minus the top strip which is a Type-2
/// <c>UIElement_Dragbar</c> move handle) import generically via
/// <see cref="DatWidgetFactory"/>/<see cref="UiResizeGrip"/> and need no
/// controller-side binding; <see cref="UiRoot"/> gives a directly-hit grip's own
/// edges priority over its generic proximity heuristic.
/// </para>
/// </summary>
public sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
{
public const uint LayoutId = 0x2100006Fu;
private bool _disposed;
// Element ids from chat LayoutDesc 0x2100006F (Campaign CH slice CH6a).
private const uint RootId = 0x10000600u; // gmFloatyMainChatUI window root, 410x100
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;
// Chat-window 1-4 indicator buttons
// (gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80): the button lights
// up iff the corresponding floating chat window is visible. Campaign CH slice
// CH6b resolves these to _indicatorButtons and wires the MIRROR half via
// SetIndicatorOpen, called by RetailUiRuntime whenever a floating chat window's
// visibility changes. Round 4 (2026-08-10) adds the CLICK half — see
// SetIndicatorOpen's doc for the full reconciliation between the earlier
// decomp-only reading (no per-window code case) and the user's retail memory
// (clicking opens/closes the window) — both are correct, at different layers.
private const uint Indicator1Id = 0x10000522u;
private const uint Indicator2Id = 0x10000523u;
private const uint Indicator3Id = 0x10000524u;
private const uint Indicator4Id = 0x10000525u;
// The 8 cosmetic "_Locked" border-art twins
// (gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 swaps these in for the 8
// live Resizebar/Dragbar grips above when PlayerModule::LockUI is true — see
// research doc §1.6). CH6a does not implement the lock-state art swap (register
// row AP-185 — UiRoot.UiLocked already gates INTERACTION generically, independent
// of which art is shown); default to the unlocked visual (hide these, show the
// live grips) to match UiRoot's own UiLocked=false default and avoid double
// rendering two overlapping border-art layers.
private static readonly uint[] LockedTwinIds =
{
0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u,
0x10000697u, 0x10000698u, 0x10000699u, 0x1000069Au,
};
// 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;
// The main window's own per-window filter/open state (CH6a/b REJECT-review
// SHOULD-FIX 2) — the SAME canonical instance the floating windows read
// (RuntimeCommunicationState.ChatWindows), never a presentation-owned copy.
private ChatWindowState _windowFilters = null!;
// 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 ulong _cachedFilter;
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;
// Chat-window 1-4 indicator buttons, indexed [windowId - 1]. Resolved at
// Bind() time (see the Indicator1Id..Indicator4Id class doc above);
// CH6b wires SetIndicatorOpen as their only writer.
private readonly UiButton?[] _indicatorButtons = new UiButton?[4];
// ── 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="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="windowFilters">Runtime's canonical per-window filter/open state
/// (<see cref="AcDream.Runtime.Gameplay.RuntimeCommunicationState.ChatWindows"/>) — the
/// SAME instance the floating windows read. Read live on every transcript rebuild
/// (CH6a/b REJECT-review SHOULD-FIX 2: the main window's own filter is a real,
/// user-settable predicate now, not an inert no-op).</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,
ChatWindowState windowFilters,
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve)
{
ArgumentNullException.ThrowIfNull(windowFilters);
// 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 0x2100006F has exactly ONE top-level element — the
// gmFloatyMainChatUI window root itself (RootId 0x10000600, authored
// 410x100). No stray auxiliary siblings, no synthetic-wrapper orphaning
// needed: FindElement/layout.Root agree.
var window = layout.FindElement(RootId) ?? layout.Root;
var c = new ChatWindowController
{
Root = window,
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
_windowFilters = windowFilters,
};
// The 8 cosmetic "_Locked" border-art twins default HIDDEN — CH6a does not
// implement retail's UiLocked-driven art swap (see the class doc + the
// LockedTwinIds field comment); the 8 live grip/dragbar elements (which
// occupy the SAME rects one ReadOrder layer above) are the ones shown.
foreach (uint id in LockedTwinIds)
if (layout.FindElement(id) is { } twin)
twin.Visible = false;
// ── Chat-window 1-4 indicator buttons — resolve now; the MIRROR half
// is wired later by RetailUiRuntime via SetIndicatorOpen as each
// floating window's own visibility changes
// (gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80 — see this
// class's doc + Indicator1Id..Indicator4Id); the CLICK half is wired
// by RetailUiRuntime calling BindIndicatorClicks after this method
// returns (round 4, 2026-08-10 — see SetIndicatorOpen's doc for the
// full reconciliation). SuppressSelfToggle stays true regardless:
// these carry DAT property 0x0B (ToggleBehavior) = true, but the
// button's own blind self-flip (UiButton.OnEvent's MouseUp case)
// would race the REAL toggle's outcome — SetIndicatorOpen (called
// synchronously inside the click, through RetailUiRuntime's
// WindowVisibilityChanged plumbing) stays the ONE authoritative
// writer of Selected, so a click's visual result always matches the
// window's actual new state instead of a guessed flip. ──
uint[] indicatorIds = { Indicator1Id, Indicator2Id, Indicator3Id, Indicator4Id };
for (int i = 0; i < indicatorIds.Length; i++)
{
var indicator = layout.FindElement(indicatorIds[i]) as UiButton;
if (indicator is not null)
indicator.SuppressSelfToggle = true;
c._indicatorButtons[i] = indicator;
}
// ── 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;
// No hand-drawn tint (Campaign CH slice CH6a, item 5): the transcript's
// PARENT panel (0x10000010) carries its own authored background sprite
// (0x06004CC2) that draws automatically via the generic UiDatElement
// fallback — a flat overlay here only mismatched it under the wrong
// (0x21000006) layout, whose transcript panel lacked one.
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;
// No hand-drawn tint here either — the input ROW panel (0x10000013) carries
// its own authored background sprite (0x0600113A); same reasoning as the
// transcript above.
c.Input.SpriteResolve = resolve;
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// Campaign CH user-gate round 1 (item G): the imported field's right
// edge otherwise holds a FIXED absolute pixel position across a
// window resize — retail edge-mode 0's "frozen at current" fallback
// (UiLayoutPolicy.ApplyFar), or the AnchorEdges default (Left|Top,
// no stretch) when this field imported without a LayoutPolicy at
// all. ReflowInputRow below only repositions Left/Width at bind
// time and on channel change; nothing re-runs it on a plain window
// RESIZE, so shrinking the window below its authored width left the
// input's right edge frozen past the new, narrower client area —
// the reported overflow. Retail edge-mode 1 on a FAR edge
// ("originalEdge + parentDelta", UiLayoutPolicy.ApplyFar) keeps a
// CONSTANT MARGIN from the parent's right edge instead, so the
// field's right edge now tracks every resize, not just
// bind/channel-change moments; the compatibility AnchorEdges.Right
// stretch is the equivalent programmatic-widget fallback. Only the
// right-edge behavior changes — Left/Top/Bottom stay whatever the
// DAT authored (or the AnchorEdges default).
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 ────────
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
// directly. Find it, bind it in place — no remove/add needed. The dat authors it
// flush with the transcript panel's top (Top=0, Height=73 == the panel's own
// height) — no top-reclaim adjustment needed under the correct layout.
var track = layout.FindElement(TrackId);
if (track is UiScrollbar bar)
{
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 ──
// The dat already authors max/min (368,5,16,16) just left of the scrollbar
// column (389,5,16,73 abs) with a natural 5px gap — no overlap under the
// correct layout, so no positional shift is needed.
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
{
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);
}
/// <summary>
/// Mirror a floating chat window's (<paramref name="windowId"/> 1-4)
/// open/closed state onto its main-window indicator button — the exact
/// state transition <c>gmMainChatUI::RecvNotice_SetPanelVisibility
/// @0x004CCD80</c> performs: <c>State 6</c> (Highlight/"lit") when the
/// floating window is visible, <c>State 1</c> (Normal) when it is not.
/// This half of the mechanism is genuinely one-directional in the exact
/// sense that <c>SetIndicatorOpen</c> is the ONLY writer of
/// <see cref="UiButton.Selected"/> — see <see cref="BindIndicatorClicks"/>
/// for the click half, which triggers the real toggle THROUGH this same
/// writer rather than flipping the indicator itself.
///
/// <para>
/// <b>Round 4 reconciliation (2026-08-10) — the user's retail memory
/// ("clicking opens the window") overruled CH6b's decomp-only reading
/// ("clicking does nothing"), and a deeper grep found the mechanism that
/// makes BOTH readings correct at their own layer.</b> CH6b's citation of
/// <c>gmMainChatUI::ListenToElementMessage @0x004CDA80</c> (no case for
/// <c>0x10000522</c>-<c>0x10000525</c>) is STILL TRUE as a statement
/// about that one function — but it was the wrong place to look for a
/// button click. Retail buttons don't route clicks through their
/// PARENT WINDOW's message handler at all: every <c>Type 1</c>
/// (<c>UIElement_Button</c>) button has its own generic click path,
/// <c>UIElement_Button::HandleButtonClick @0x00471E50</c>, which reads
/// an ENUM-kind DAT property <c>0x12</c> off ITSELF; when present, it
/// builds an <c>InputEvent</c> and routes it through
/// <c>ICIDM</c>/<c>UIElementManager</c>'s action map, which (for a
/// visibility action) reaches <c>UIElementManager::DoVisibilityToggleAction
/// @0x0045B660</c> — a lookup into
/// <c>m_elementInputActionListenerTable</c> (populated by
/// <c>UIElementManager::RegisterElementForInputAction</c>, itself called
/// from ONE place: <c>UIElement::Initialize</c>'s generic property switch,
/// case for ENUM-kind property <c>0x24</c> — ANY element authoring that
/// property registers itself as a listener for that action id) — for
/// every registered listener, <c>DoVisibilityToggleAction</c> broadcasts
/// element message <c>0x31</c>, which <c>UIElement::ListenToElementMessage</c>
/// (the ultimate base-class handler every window falls through to when
/// its own override, and <c>ChatInterface</c>'s, don't claim the
/// message — confirmed for BOTH <c>gmMainChatUI</c> and
/// <c>gmFloatyChatUI</c>) handles GENERICALLY: it reads the RECEIVING
/// element's OWN enum property <c>0x58</c> and, if it equals <c>1</c>
/// ("toggle"), calls <c>SetVisible(!currentlyVisible)</c>.
/// </para>
///
/// <para>
/// This is a real, complete, working "generic UI action" system — the
/// task's own hypothesis — and the fixture confirms the indicator
/// buttons genuinely author property <c>0x12</c> as an Enum (not merely
/// a stray/mistyped property): <c>chat_2100006f.json</c>'s four
/// indicator elements each carry it, with values
/// <c>0x10000114</c>-<c>0x10000117</c> in id order (an earlier pass here
/// misread these as <c>0x10000514</c>-<c>0x10000517</c>). But the OTHER
/// half of the wiring is where retail's own data falls short: the floating
/// chat window fixture (<c>chat_floaty_2100005b.json</c>) authors NO
/// Enum-kind property <c>0x24</c> anywhere (its one hit on property
/// number 36 is Integer-kind, an unrelated attribute) and NO property
/// <c>0x58</c> at all — so nothing in the shipped LayoutDesc data ever
/// registers a floating chat window as a listener for those four action
/// ids, and <c>RegisterElementForInputAction</c> has exactly one call
/// site in the whole binary (the property-driven one above; no class
/// anywhere calls it directly in code). <c>DoVisibilityToggleAction</c>
/// would find zero listeners and silently no-op. (Two of the four action-id
/// VALUES are not chat-specific either — <c>0x10000114</c>/<c>0x10000115</c>
/// are <c>m_prevButton</c>/<c>m_nextButton</c> child ids for an unrelated
/// pagination widget elsewhere in the decomp
/// (<c>acclient_2013_pseudo_c.txt:194343-194344</c>), a coincidence of
/// Turbine's global asset-id allocator, not a cross-reference — an
/// earlier pass here misattributed this coincidence to
/// <c>gmFriendsUI::PostInit</c>'s Add/Remove/Tell child ids, which match
/// only the wrong, misread values above and are not actually involved.)
/// So even with the generic mechanism confirmed real and armed on the
/// button side, the authored DATA available to us does not wire a
/// target — which is consistent with, not a refutation of, the original
/// CH6b grep.
/// </para>
///
/// <para>
/// Per CLAUDE.md, the user's own retail memory is the axiom the code
/// must match regardless of what a specific grep or fixture shows.
/// <see cref="BindIndicatorClicks"/> makes each indicator's click drive
/// the SAME <c>ToggleFloatingChatWindow(windowId)</c> chokepoint the
/// <c>Alt+1..4</c> keybinds use — this is USER-DIRECTED retail behavior
/// (the generic action-dispatch mechanism exists and is plausibly HOW
/// retail wires it, but we cannot prove the exact target registration
/// from the data on hand), not a re-guess of the mirror-only reading.
/// See <c>docs/research/2026-08-09-chat-retail-window-shell.md</c> §1.4
/// for the full writeup.
/// </para>
/// </summary>
public void SetIndicatorOpen(int windowId, bool open)
{
if (windowId < 1 || windowId > _indicatorButtons.Length)
throw new ArgumentOutOfRangeException(nameof(windowId));
_indicatorButtons[windowId - 1]?.TrySetRetailState(
open ? UiButtonStateMachine.Highlight : UiButtonStateMachine.Normal);
}
/// <summary>
/// Wire each indicator button's click to <paramref name="toggleFloatingWindow"/>
/// (RetailUiRuntime's <c>ToggleFloatingChatWindow</c>, the SAME chokepoint the
/// <c>Alt+1..4</c> keybinds use) — round 4 (2026-08-10), see
/// <see cref="SetIndicatorOpen"/>'s doc for the full reconciliation. Called by
/// <c>RetailUiRuntime</c> after <see cref="Bind"/> returns, once the runtime's own
/// toggle method is available. <see cref="UiButton.SuppressSelfToggle"/> stays true
/// on every indicator (set in <see cref="Bind"/>) so this click-triggered toggle's
/// outcome — not a blind self-flip — is what <see cref="SetIndicatorOpen"/> mirrors
/// back onto <see cref="UiButton.Selected"/>, keeping the visual state consistent
/// through the full click round trip even if the toggle is ever refused.
/// </summary>
public void BindIndicatorClicks(Func<int, bool> toggleFloatingWindow)
{
ArgumentNullException.ThrowIfNull(toggleFloatingWindow);
for (int i = 0; i < _indicatorButtons.Length; i++)
{
int windowId = i + 1;
if (_indicatorButtons[i] is { } indicator)
indicator.OnClick = () => toggleFloatingWindow(windowId);
}
}
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's exact
/// <see cref="RetailChatColorTable"/> colors keyed by each entry's
/// <see cref="ChatEntry.LogTextType"/> (NOT <see cref="ChatKind"/>).
/// </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;
ulong filter = _windowFilters.GetFilter(ChatWindowState.MainWindowId);
if (_cachedTranscriptRevision == revision
&& _cachedFilter == filter
&& _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, filter, 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;
// Campaign CH slice CH6b: the wrap + retail color-carry-forward
// algorithm is now shared with FloatingChatWindowController via
// ChatTranscriptRenderer (approximation note on the color carry
// moved there). CH6a/b REJECT-review SHOULD-FIX 2: the main window
// now has a REAL accept predicate (retail's default 0xFBFFFFFF —
// everything except 0x1A, high dword zeroed so Society is opt-in),
// driven by the SAME ChatWindowState the floating windows read —
// no more accept:null "no user filter" placeholder.
bool Accept(uint logTextType) => _windowFilters.ShouldDisplay(
ChatWindowState.MainWindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
var result = ChatTranscriptRenderer.BuildLines(
detailed, maxW, measure, Accept, Transcript.DefaultColor);
return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont);
}
private IReadOnlyList<UiText.Line> StoreTranscriptLayout(
IReadOnlyList<UiText.Line> lines,
long revision,
ulong filter,
float wrapWidth,
UiDatFont? datFont,
BitmapFont? debugFont)
{
_cachedTranscriptRevision = revision;
_cachedFilter = filter;
_cachedTranscriptWrapWidth = wrapWidth;
_cachedTranscriptDatFont = datFont;
_cachedTranscriptDebugFont = debugFont;
_cachedTranscriptLines = lines;
TranscriptLayoutBuildCount++;
return lines;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
}