feat(chat): Campaign CH slice CH6b — floating chat windows 1-4
Mounts retail's four floating chat windows as always-resident, born-hidden children per gmGamePlayUI::SetupChildren @0x004E9EC0, all sharing LayoutDesc 0x2100005B (window ids 0x10000505/0x1000050E/0x1000050F/0x10000510). New FloatingChatWindowController (AcDream.App/UI/Layout) binds each window's own widget tree — built fresh per instance from one shared imported ElementInfo — reusing ChatWindowController's word-wrap + retail color-carry algorithm via the extracted ChatTranscriptRenderer instead of duplicating it. A floaty window has no talk-focus menu (research doc §2.2), so its entry field always sends on Say; the mismatch against retail's possible shared-channel behavior is UNVERIFIED and filed as #369/AP-188. Runtime owns the per-window filter/open state: ChatWindowState (new, AcDream.Core.Chat) seeds retail's exact PostInit defaults per window (window 1 0x0000101C Speech/Tell/DirectSend/Emote, window 2 0x00040C00 Social/SocialSend/Allegiance, window 3 0x00080000 Fellowship, window 4 0x78000000 Turbine General/Trade/LFG/Roleplay) and implements the full ShouldDisplay(windowId, targetWindowId, logTextType) display predicate from ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640. It lives on RuntimeCommunicationState.ChatWindows so every host borrows the same instance. The main window's filter (0xFBFFFFFF, "no user filter") never actually gates anything because its own explicit-address branch already covers every broadcast line — that's why UpdateFromPlayerModule early-returns for window 0 in retail, ported here by construction rather than a special case. Keybind wiring: InputAction.ToggleFloatingChatWindow1..4 and their KeyBindings.RetailDefaults() chords already existed since Phase K.1c (unwired until now). The MetaKeys table confirms retail's default is Alt+1 through Alt+4 (index 3 = bit 0x00000004, cross-checked against the same file's Alt+A/D strafe and Alt+Enter/Tab/F4 rows). Routes through GameplayInputCommandController -> RetainedGameplayWindowCommands -> RetailUiRuntime.ToggleFloatingChatWindow -> the generic UiHost.ToggleWindow, whose visibility-change event is the single chokepoint that syncs ChatWindowState.SetOpen and mirrors the main window's 1-4 indicator button regardless of what changed a window's visibility (keybind, close button, or a restored layout). A direct decomp read of gmMainChatUI::ListenToElementMessage @0x004CDA80 — the only function in the whole binary that branches on a click message — settles what the research doc had left as a hedge: it handles exactly 0x1000046f (max/min) and the talk-focus menu's selection message, with NO case for 0x10000522-0x10000525. The four indicator buttons are PURE one-directional mirrors in retail; clicking them does nothing. ChatWindowController.SetIndicatorOpen ports this with no OnClick at all. Corrected research doc §1.4 accordingly. Persistence is local-only (register row AP-187; the retail 0x1000008C GameplayOptions wire remains deferred to CH6f): window geometry and open/visible state ride the existing generic RetailWindowLayoutPersistence path for free once each window registers under its own WindowNames entry; the four filter masks get a dedicated ChatSettings round-trip (ChatWindow1Filter..ChatWindow4Filter, defaulting to the retail PostInit constants) loaded at mount and saved alongside SaveLayout(). Tests: ChatWindowStateTests (defaults, TypeIsActive, the full display-rule matrix, toggle/reset, revision counter), FloatingChatWindowControllerTests (bind smoke tests against a synthetic 0x2100005B tree, per-window filter routing, filter-change cache invalidation, fixed-Say submit), new ChatWindowController.SetIndicatorOpen tests (Highlight/Normal state, cross-window isolation, range validation), GameplayInputCommandController routing for the four toggle actions, and a SettingsStore filter round-trip. Full Release suite: 12,392 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
41b408f3e6
commit
22020ef2c4
21 changed files with 1699 additions and 46 deletions
296
src/AcDream.App/UI/Layout/FloatingChatWindowController.cs
Normal file
296
src/AcDream.App/UI/Layout/FloatingChatWindowController.cs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Binds one of retail's four floating chat windows — LayoutDesc
|
||||
/// <c>0x2100005B</c>, window elements <c>0x10000505</c>/<c>0x1000050E</c>/
|
||||
/// <c>0x1000050F</c>/<c>0x10000510</c> — to live behavior (Campaign CH
|
||||
/// slice CH6b). All four windows share the SAME LayoutDesc; only the
|
||||
/// window-id attribute (<c>0x1000007E</c>, carried here as
|
||||
/// <see cref="WindowId"/> rather than re-read from the dat, since acdream's
|
||||
/// importer does not resolve LayoutDesc attributes into runtime state) and
|
||||
/// the mounted screen position differ per instance.
|
||||
///
|
||||
/// <para>
|
||||
/// Shares <see cref="ChatTranscriptRenderer"/> with
|
||||
/// <see cref="ChatWindowController"/> (the main window) rather than
|
||||
/// duplicating the word-wrap/color-carry algorithm — both are views over the
|
||||
/// SAME <see cref="ChatVM"/> transcript (research doc §6.1's "one
|
||||
/// ChatWindowController instance per window" recommendation, generalized to
|
||||
/// two sibling classes because the main and floaty layouts diverge enough
|
||||
/// in their own element sets — talk-focus menu, max/min button, four
|
||||
/// indicator buttons on main; title bar and close button on floaty,
|
||||
/// research doc §2.1 vs §2.2 — to make one shared Bind() unreadable).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A floaty window has no talk-focus menu (research doc §2.2), so its chat
|
||||
/// entry always sends on <see cref="ChatChannelKind.Say"/> — there is no
|
||||
/// authored control to pick another channel from this window.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class FloatingChatWindowController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x2100005Bu;
|
||||
|
||||
// Element ids from floating chat LayoutDesc 0x2100005B (research doc §2.2).
|
||||
private const uint RootId = 0x100004F7u; // window root, 250x108
|
||||
private const uint TranscriptPanelId = 0x10000010u;
|
||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||
private const uint TrackId = 0x10000012u;
|
||||
private const uint InputRowId = 0x10000509u; // NOTE: differs from the main window's 0x10000013
|
||||
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 -> UiField
|
||||
private const uint SendId = 0x10000019u;
|
||||
private const uint TitleBarId = 0x100004D9u; // gmFloatyChatUI::SetWindowTitle target
|
||||
private const uint CloseButtonId = 0x1000052Au;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>The retail window id this instance is bound to (1-4).</summary>
|
||||
public int WindowId { get; }
|
||||
|
||||
public UiElement Root { get; private set; } = null!;
|
||||
public UiText Transcript { get; private set; } = null!;
|
||||
public UiField Input { get; private set; } = null!;
|
||||
public UiScrollbar? Scrollbar { get; private set; }
|
||||
|
||||
/// <summary>Resolved window-root metadata, including DAT size constraints.</summary>
|
||||
public ElementInfo DatWindowInfo { get; private set; } = null!;
|
||||
|
||||
public RetailWindowHandle? WindowHandle { get; private set; }
|
||||
|
||||
// Same idle-frame caching shape as ChatWindowController — an unchanged
|
||||
// transcript/filter/wrap-width does not re-wrap or re-resolve colors.
|
||||
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
|
||||
private long _cachedTranscriptRevision = -1;
|
||||
private ulong _cachedFilter;
|
||||
private float _cachedTranscriptWrapWidth = float.NaN;
|
||||
private UiDatFont? _cachedTranscriptDatFont;
|
||||
private BitmapFont? _cachedTranscriptDebugFont;
|
||||
internal int TranscriptLayoutBuildCount { get; private set; }
|
||||
|
||||
private FloatingChatWindowController(int windowId)
|
||||
{
|
||||
WindowId = windowId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind an imported floating-chat layout to live behavior.
|
||||
/// </summary>
|
||||
/// <param name="windowId">Retail chat window id, 1 through 4.</param>
|
||||
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from an
|
||||
/// <see cref="LayoutImporter.ImportInfos"/> call against
|
||||
/// <see cref="LayoutId"/>.</param>
|
||||
/// <param name="layout">Widget tree from a matching
|
||||
/// <see cref="LayoutImporter.Build"/> call — a FRESH call per window
|
||||
/// instance, since four independent windows need four independent
|
||||
/// widget trees even though they share one imported <c>rootInfo</c>.</param>
|
||||
/// <param name="vm">The SAME chat view-model the main window binds —
|
||||
/// one canonical transcript, filtered per window.</param>
|
||||
/// <param name="busProvider">Factory for the live command bus at submit time.</param>
|
||||
/// <param name="windowFilters">
|
||||
/// Runtime's canonical per-window filter/open state
|
||||
/// (<see cref="RuntimeCommunicationState.ChatWindows"/>). Read live on
|
||||
/// every transcript rebuild — never copied — so a filter change is
|
||||
/// visible on the next frame without a separate notification.
|
||||
/// </param>
|
||||
public static FloatingChatWindowController? Bind(
|
||||
int windowId,
|
||||
ElementInfo rootInfo,
|
||||
ImportedLayout layout,
|
||||
ChatVM vm,
|
||||
Func<ICommandBus> busProvider,
|
||||
ChatWindowState windowFilters,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
if (windowId < ChatWindowState.MinFloatingWindowId || windowId > ChatWindowState.MaxFloatingWindowId)
|
||||
throw new ArgumentOutOfRangeException(nameof(windowId));
|
||||
ArgumentNullException.ThrowIfNull(windowFilters);
|
||||
|
||||
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
||||
var inputRow = layout.FindElement(InputRowId);
|
||||
var input = layout.FindElement(InputId) as UiField;
|
||||
|
||||
if (input is null || transcriptPanel is null || inputRow is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] FloatingChatWindowController.Bind(window {windowId}): missing required elements " +
|
||||
$"(input={input is not null}, panel={transcriptPanel is not null}, row={inputRow is not null}) — " +
|
||||
$"floating chat window will not be interactive.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var window = layout.FindElement(RootId) ?? layout.Root;
|
||||
var c = new FloatingChatWindowController(windowId)
|
||||
{
|
||||
Root = window,
|
||||
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
|
||||
};
|
||||
|
||||
// ── Transcript ───────────────────────────────────────────────────
|
||||
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
||||
?? throw new InvalidOperationException("floating chat transcript 0x10000011 not built as UiText");
|
||||
c.Transcript.DatFont = datFont;
|
||||
c.Transcript.Font = debugFont;
|
||||
c.Transcript.Centered = false;
|
||||
c.Transcript.RightAligned = false;
|
||||
c.Transcript.OneLine = false;
|
||||
c.Transcript.Selectable = true;
|
||||
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm, windowFilters);
|
||||
|
||||
// ── Input — no talk-focus menu on the floaty layout, so the
|
||||
// channel is always Say (class doc). ─────────────────────────────
|
||||
c.Input = input;
|
||||
c.Input.DatFont = datFont;
|
||||
c.Input.Font = debugFont;
|
||||
c.Input.SpriteResolve = resolve;
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), ChatChannelKind.Say);
|
||||
|
||||
// Same right-edge-tracks-resize fix as the main window
|
||||
// (ChatWindowController.Bind) — see that method's comment for the
|
||||
// full retail edge-mode citation.
|
||||
if (c.Input.LayoutPolicy is { } inputPolicy)
|
||||
{
|
||||
c.Input.LayoutPolicy = new UiLayoutPolicy(
|
||||
inputPolicy.LeftMode,
|
||||
inputPolicy.TopMode,
|
||||
rightMode: 1u,
|
||||
inputPolicy.BottomMode,
|
||||
inputPolicy.OriginalChild,
|
||||
inputPolicy.OriginalParent);
|
||||
}
|
||||
else
|
||||
{
|
||||
c.Input.Anchors |= AnchorEdges.Right;
|
||||
}
|
||||
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────
|
||||
if (layout.FindElement(TrackId) is UiScrollbar bar)
|
||||
{
|
||||
bar.Model = c.Transcript.Scroll;
|
||||
bar.SpriteResolve ??= resolve;
|
||||
c.Scrollbar = bar;
|
||||
}
|
||||
|
||||
// ── Send button ─────────────────────────────────────────────────
|
||||
if (layout.FindElement(SendId) is UiButton sendEl)
|
||||
{
|
||||
sendEl.OnClick = () => c.Input.Submit();
|
||||
sendEl.Label = "Send";
|
||||
sendEl.LabelFont = datFont;
|
||||
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
||||
}
|
||||
|
||||
// ── Title bar — gmFloatyChatUI::SetWindowTitle @0x004CEAA0 sets a
|
||||
// localized "Chat N" string here; acdream has no LayoutDesc string
|
||||
// table wired for it yet, so this is a hardcoded English label —
|
||||
// the same stopgap the channel menu's item labels already use
|
||||
// (ChatWindowController.ChannelItems). ──────────────────────────
|
||||
if (layout.FindElement(TitleBarId) is UiText titleText)
|
||||
{
|
||||
titleText.DatFont = datFont;
|
||||
titleText.Font = debugFont;
|
||||
titleText.OneLine = true;
|
||||
string title = $"Chat {windowId}";
|
||||
var titleColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
||||
titleText.LinesProvider = () => new[] { new UiText.Line(title, titleColor) };
|
||||
}
|
||||
|
||||
// ── Close button — gmFloatyChatUI::ListenToElementMessage
|
||||
// @0x004CE330: idMessage==1 (clicked) on 0x1000052A -> SetVisible(false). ──
|
||||
if (layout.FindElement(CloseButtonId) is UiButton closeEl)
|
||||
{
|
||||
closeEl.OnClick = () => c.WindowHandle?.Hide();
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attach the typed outer-frame handle after the controller's imported
|
||||
/// content has been mounted. The close button needs this to hide the
|
||||
/// window (retail's own <c>SetVisible(false)</c>).
|
||||
/// </summary>
|
||||
public void AttachWindow(RetailWindowHandle handle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handle);
|
||||
if (!ReferenceEquals(handle.ContentRoot, Root))
|
||||
throw new ArgumentException(
|
||||
"Floating chat handle content root does not match the bound layout.", nameof(handle));
|
||||
if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle))
|
||||
throw new InvalidOperationException("Floating chat controller is already attached to another window.");
|
||||
WindowHandle = handle;
|
||||
}
|
||||
|
||||
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
||||
{
|
||||
if (node.Id == id) return node;
|
||||
foreach (ElementInfo child in node.Children)
|
||||
{
|
||||
ElementInfo? found = FindInfo(child, id);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert the shared ChatVM's detailed lines to this window's filtered,
|
||||
/// wrapped, colored transcript — <see cref="ChatWindowState.TypeIsActive"/>
|
||||
/// is retail's broadcast half of the display rule
|
||||
/// (<c>ChatInterface::RecvNotice_DisplayFinalStringInfo</c>); the
|
||||
/// explicit-address half is inert today because no production
|
||||
/// <c>ChatEntry</c> carries a target window id yet (register row
|
||||
/// AP-180 — see <see cref="ChatWindowState"/>'s class doc).
|
||||
/// </summary>
|
||||
private IReadOnlyList<UiText.Line> GetTranscriptLines(ChatVM vm, ChatWindowState windowFilters)
|
||||
{
|
||||
float maxW = Transcript.Width - 2f * Transcript.Padding;
|
||||
UiDatFont? datFont = Transcript.DatFont;
|
||||
BitmapFont? debugFont = Transcript.Font;
|
||||
long revision = vm.Revision;
|
||||
ulong filter = windowFilters.GetFilter(WindowId);
|
||||
|
||||
if (_cachedTranscriptRevision == revision
|
||||
&& _cachedFilter == filter
|
||||
&& _cachedTranscriptWrapWidth.Equals(maxW)
|
||||
&& ReferenceEquals(_cachedTranscriptDatFont, datFont)
|
||||
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
|
||||
{
|
||||
return _cachedTranscriptLines;
|
||||
}
|
||||
|
||||
var detailed = vm.RecentLinesDetailed();
|
||||
Func<string, float> measure =
|
||||
datFont is { } df ? s => df.MeasureWidth(s)
|
||||
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
||||
: static s => s.Length * 7f;
|
||||
|
||||
bool Accept(uint logTextType) => windowFilters.ShouldDisplay(WindowId, targetWindowId: 0u, logTextType);
|
||||
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept);
|
||||
|
||||
_cachedTranscriptRevision = revision;
|
||||
_cachedFilter = filter;
|
||||
_cachedTranscriptWrapWidth = maxW;
|
||||
_cachedTranscriptDatFont = datFont;
|
||||
_cachedTranscriptDebugFont = debugFont;
|
||||
_cachedTranscriptLines = result;
|
||||
TranscriptLayoutBuildCount++;
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue