734 lines
38 KiB
C#
734 lines
38 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 (<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;
|
||
}
|
||
}
|