Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a second (background) glyph atlas per font, dilated 2px on every side, plus two border-pixel scalars (Font.NumHorizontalBorderPixels/ NumVerticalBorderPixels) that acdream's font reader never read — so even the pre-existing outline parameter drew almost nothing once enabled. Landed together (either half alone is a no-op or a regression): - UiDatFont carries BorderX/BorderY from the DAT font resource. - UiRenderContext.DrawStringDat inflates the background blit's source and destination rect by that margin and restructures into retail's exact two-pass whole-string outline-then-fill model (UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for fonts with no background atlas. Corrects the stale "property 0xd" comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor). - LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/ OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/ ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every authored-outline element across the DAT set is correct at once. - SpewBox: RetailFontId corrected from a round-3 heuristic (0x40000025) to the actually-authored 0x40000001 (18px bold serif), Outline=true set on the controller's UiText. Fill colour stays the user-gate-round-1-pinned yellow — font atlases are alpha-only (PFID_A8), so there is no baked shading that could explain the screenshot's gold as anything other than the outline itself. - Chat transcript: default fill now seeds from its authored ARGB(255,204,204,204) instead of an unrelated color-table slot (ChatTranscriptRenderer.BuildLines takes the transcript's own DefaultColor as a parameter); the 34-entry LogTextType table is untouched, and every existing CH1 conformance test stays green unmodified. Regenerated the committed chat_2100006f.json fixture from the real installed DAT, confirming end to end (not by missing-field default) that the transcript carries no outline. Tests: font-reader border fields + inflation math pinned against the real DAT font, two-pass draw ordering/tint/inflation via a new TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22 import at both the ElementReader.Merge and StateDesc-property layers, SpewBox font/outline, and the chat default-shade seed with the color table proven untouched. Full Release suite: 12,610 passed / 4 skipped / 0 failed (AcDream.slnx, complete solution). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
298 lines
13 KiB
C#
298 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.UI;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.UI.Abstractions;
|
|
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Binds one of retail's four floating chat windows — LayoutDesc
|
|
/// <c>0x2100005B</c>, window elements <c>0x10000505</c>/<c>0x1000050E</c>/
|
|
/// <c>0x1000050F</c>/<c>0x10000510</c> — to live behavior (Campaign CH
|
|
/// slice CH6b). All four windows share the SAME LayoutDesc; only the
|
|
/// window-id attribute (<c>0x1000007E</c>, carried here as
|
|
/// <see cref="WindowId"/> rather than re-read from the dat, since acdream's
|
|
/// importer does not resolve LayoutDesc attributes into runtime state) and
|
|
/// the mounted screen position differ per instance.
|
|
///
|
|
/// <para>
|
|
/// Shares <see cref="ChatTranscriptRenderer"/> with
|
|
/// <see cref="ChatWindowController"/> (the main window) rather than
|
|
/// duplicating the word-wrap/color-carry algorithm — both are views over the
|
|
/// SAME <see cref="ChatVM"/> transcript (research doc §6.1's "one
|
|
/// ChatWindowController instance per window" recommendation, generalized to
|
|
/// two sibling classes because the main and floaty layouts diverge enough
|
|
/// in their own element sets — talk-focus menu, max/min button, four
|
|
/// indicator buttons on main; title bar and close button on floaty,
|
|
/// research doc §2.1 vs §2.2 — to make one shared Bind() unreadable).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// A floaty window has no talk-focus menu (research doc §2.2), so its chat
|
|
/// entry always sends on <see cref="ChatChannelKind.Say"/> — there is no
|
|
/// authored control to pick another channel from this window.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class FloatingChatWindowController : IRetainedPanelController
|
|
{
|
|
public const uint LayoutId = 0x2100005Bu;
|
|
|
|
// Element ids from floating chat LayoutDesc 0x2100005B (research doc §2.2).
|
|
private const uint RootId = 0x100004F7u; // window root, 250x108
|
|
private const uint TranscriptPanelId = 0x10000010u;
|
|
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
|
private const uint TrackId = 0x10000012u;
|
|
private const uint InputRowId = 0x10000509u; // NOTE: differs from the main window's 0x10000013
|
|
private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 -> UiField
|
|
private const uint SendId = 0x10000019u;
|
|
private const uint TitleBarId = 0x100004D9u; // gmFloatyChatUI::SetWindowTitle target
|
|
private const uint CloseButtonId = 0x1000052Au;
|
|
|
|
private bool _disposed;
|
|
|
|
/// <summary>The retail window id this instance is bound to (1-4).</summary>
|
|
public int WindowId { get; }
|
|
|
|
public UiElement Root { get; private set; } = null!;
|
|
public UiText Transcript { get; private set; } = null!;
|
|
public UiField Input { get; private set; } = null!;
|
|
public UiScrollbar? Scrollbar { get; private set; }
|
|
|
|
/// <summary>Resolved window-root metadata, including DAT size constraints.</summary>
|
|
public ElementInfo DatWindowInfo { get; private set; } = null!;
|
|
|
|
public RetailWindowHandle? WindowHandle { get; private set; }
|
|
|
|
// Same idle-frame caching shape as ChatWindowController — an unchanged
|
|
// transcript/filter/wrap-width does not re-wrap or re-resolve colors.
|
|
private IReadOnlyList<UiText.Line> _cachedTranscriptLines = Array.Empty<UiText.Line>();
|
|
private long _cachedTranscriptRevision = -1;
|
|
private ulong _cachedFilter;
|
|
private float _cachedTranscriptWrapWidth = float.NaN;
|
|
private UiDatFont? _cachedTranscriptDatFont;
|
|
private BitmapFont? _cachedTranscriptDebugFont;
|
|
internal int TranscriptLayoutBuildCount { get; private set; }
|
|
|
|
private FloatingChatWindowController(int windowId)
|
|
{
|
|
WindowId = windowId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bind an imported floating-chat layout to live behavior.
|
|
/// </summary>
|
|
/// <param name="windowId">Retail chat window id, 1 through 4.</param>
|
|
/// <param name="rootInfo">Full <see cref="ElementInfo"/> tree from an
|
|
/// <see cref="LayoutImporter.ImportInfos"/> call against
|
|
/// <see cref="LayoutId"/>.</param>
|
|
/// <param name="layout">Widget tree from a matching
|
|
/// <see cref="LayoutImporter.Build"/> call — a FRESH call per window
|
|
/// instance, since four independent windows need four independent
|
|
/// widget trees even though they share one imported <c>rootInfo</c>.</param>
|
|
/// <param name="vm">The SAME chat view-model the main window binds —
|
|
/// one canonical transcript, filtered per window.</param>
|
|
/// <param name="busProvider">Factory for the live command bus at submit time.</param>
|
|
/// <param name="windowFilters">
|
|
/// Runtime's canonical per-window filter/open state
|
|
/// (<see cref="RuntimeCommunicationState.ChatWindows"/>). Read live on
|
|
/// every transcript rebuild — never copied — so a filter change is
|
|
/// visible on the next frame without a separate notification.
|
|
/// </param>
|
|
public static FloatingChatWindowController? Bind(
|
|
int windowId,
|
|
ElementInfo rootInfo,
|
|
ImportedLayout layout,
|
|
ChatVM vm,
|
|
Func<ICommandBus> busProvider,
|
|
ChatWindowState windowFilters,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont,
|
|
Func<uint, (uint tex, int w, int h)> resolve)
|
|
{
|
|
if (windowId < ChatWindowState.MinFloatingWindowId || windowId > ChatWindowState.MaxFloatingWindowId)
|
|
throw new ArgumentOutOfRangeException(nameof(windowId));
|
|
ArgumentNullException.ThrowIfNull(windowFilters);
|
|
|
|
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
|
var inputRow = layout.FindElement(InputRowId);
|
|
var input = layout.FindElement(InputId) as UiField;
|
|
|
|
if (input is null || transcriptPanel is null || inputRow is null)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] FloatingChatWindowController.Bind(window {windowId}): missing required elements " +
|
|
$"(input={input is not null}, panel={transcriptPanel is not null}, row={inputRow is not null}) — " +
|
|
$"floating chat window will not be interactive.");
|
|
return null;
|
|
}
|
|
|
|
var window = layout.FindElement(RootId) ?? layout.Root;
|
|
var c = new FloatingChatWindowController(windowId)
|
|
{
|
|
Root = window,
|
|
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
|
|
};
|
|
|
|
// ── Transcript ───────────────────────────────────────────────────
|
|
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
|
?? throw new InvalidOperationException("floating chat transcript 0x10000011 not built as UiText");
|
|
c.Transcript.DatFont = datFont;
|
|
c.Transcript.Font = debugFont;
|
|
c.Transcript.Centered = false;
|
|
c.Transcript.RightAligned = false;
|
|
c.Transcript.OneLine = false;
|
|
c.Transcript.Selectable = true;
|
|
c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm, windowFilters);
|
|
|
|
// ── Input — no talk-focus menu on the floaty layout, so the
|
|
// channel is always Say (class doc). ─────────────────────────────
|
|
c.Input = input;
|
|
c.Input.DatFont = datFont;
|
|
c.Input.Font = debugFont;
|
|
c.Input.SpriteResolve = resolve;
|
|
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), ChatChannelKind.Say);
|
|
|
|
// Same right-edge-tracks-resize fix as the main window
|
|
// (ChatWindowController.Bind) — see that method's comment for the
|
|
// full retail edge-mode citation.
|
|
if (c.Input.LayoutPolicy is { } inputPolicy)
|
|
{
|
|
c.Input.LayoutPolicy = new UiLayoutPolicy(
|
|
inputPolicy.LeftMode,
|
|
inputPolicy.TopMode,
|
|
rightMode: 1u,
|
|
inputPolicy.BottomMode,
|
|
inputPolicy.OriginalChild,
|
|
inputPolicy.OriginalParent);
|
|
}
|
|
else
|
|
{
|
|
c.Input.Anchors |= AnchorEdges.Right;
|
|
}
|
|
|
|
// ── Scrollbar — bind the factory-built Type-11 track element ────
|
|
if (layout.FindElement(TrackId) is UiScrollbar bar)
|
|
{
|
|
bar.Model = c.Transcript.Scroll;
|
|
bar.SpriteResolve ??= resolve;
|
|
c.Scrollbar = bar;
|
|
}
|
|
|
|
// ── Send button ─────────────────────────────────────────────────
|
|
if (layout.FindElement(SendId) is UiButton sendEl)
|
|
{
|
|
sendEl.OnClick = () => c.Input.Submit();
|
|
sendEl.Label = "Send";
|
|
sendEl.LabelFont = datFont;
|
|
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
|
}
|
|
|
|
// ── Title bar — gmFloatyChatUI::SetWindowTitle @0x004CEAA0 sets a
|
|
// localized "Chat N" string here; acdream has no LayoutDesc string
|
|
// table wired for it yet, so this is a hardcoded English label —
|
|
// the same stopgap the channel menu's item labels already use
|
|
// (ChatWindowController.ChannelItems). ──────────────────────────
|
|
if (layout.FindElement(TitleBarId) is UiText titleText)
|
|
{
|
|
titleText.DatFont = datFont;
|
|
titleText.Font = debugFont;
|
|
titleText.OneLine = true;
|
|
string title = $"Chat {windowId}";
|
|
var titleColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
|
titleText.LinesProvider = () => new[] { new UiText.Line(title, titleColor) };
|
|
}
|
|
|
|
// ── Close button — gmFloatyChatUI::ListenToElementMessage
|
|
// @0x004CE330: idMessage==1 (clicked) on 0x1000052A -> SetVisible(false). ──
|
|
if (layout.FindElement(CloseButtonId) is UiButton closeEl)
|
|
{
|
|
closeEl.OnClick = () => c.WindowHandle?.Hide();
|
|
}
|
|
|
|
return c;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attach the typed outer-frame handle after the controller's imported
|
|
/// content has been mounted. The close button needs this to hide the
|
|
/// window (retail's own <c>SetVisible(false)</c>).
|
|
/// </summary>
|
|
public void AttachWindow(RetailWindowHandle handle)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(handle);
|
|
if (!ReferenceEquals(handle.ContentRoot, Root))
|
|
throw new ArgumentException(
|
|
"Floating chat handle content root does not match the bound layout.", nameof(handle));
|
|
if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle))
|
|
throw new InvalidOperationException("Floating chat controller is already attached to another window.");
|
|
WindowHandle = handle;
|
|
}
|
|
|
|
private static ElementInfo? FindInfo(ElementInfo node, uint id)
|
|
{
|
|
if (node.Id == id) return node;
|
|
foreach (ElementInfo child in node.Children)
|
|
{
|
|
ElementInfo? found = FindInfo(child, id);
|
|
if (found is not null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert the shared ChatVM's detailed lines to this window's filtered,
|
|
/// wrapped, colored transcript — <see cref="ChatWindowState.TypeIsActive"/>
|
|
/// is retail's broadcast half of the display rule
|
|
/// (<c>ChatInterface::RecvNotice_DisplayFinalStringInfo</c>); the
|
|
/// explicit-address half is inert today because no production
|
|
/// <c>ChatEntry</c> carries a target window id yet (register row
|
|
/// AP-180 — see <see cref="ChatWindowState"/>'s class doc).
|
|
/// </summary>
|
|
private IReadOnlyList<UiText.Line> GetTranscriptLines(ChatVM vm, ChatWindowState windowFilters)
|
|
{
|
|
float maxW = Transcript.Width - 2f * Transcript.Padding;
|
|
UiDatFont? datFont = Transcript.DatFont;
|
|
BitmapFont? debugFont = Transcript.Font;
|
|
long revision = vm.Revision;
|
|
ulong filter = windowFilters.GetFilter(WindowId);
|
|
|
|
if (_cachedTranscriptRevision == revision
|
|
&& _cachedFilter == filter
|
|
&& _cachedTranscriptWrapWidth.Equals(maxW)
|
|
&& ReferenceEquals(_cachedTranscriptDatFont, datFont)
|
|
&& ReferenceEquals(_cachedTranscriptDebugFont, debugFont))
|
|
{
|
|
return _cachedTranscriptLines;
|
|
}
|
|
|
|
var detailed = vm.RecentLinesDetailed();
|
|
Func<string, float> measure =
|
|
datFont is { } df ? s => df.MeasureWidth(s)
|
|
: debugFont is { } bf ? s => bf.MeasureWidth(s)
|
|
: static s => s.Length * 7f;
|
|
|
|
bool Accept(uint logTextType) => windowFilters.ShouldDisplay(
|
|
WindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
|
|
var result = ChatTranscriptRenderer.BuildLines(
|
|
detailed, maxW, measure, Accept, Transcript.DefaultColor);
|
|
|
|
_cachedTranscriptRevision = revision;
|
|
_cachedFilter = filter;
|
|
_cachedTranscriptWrapWidth = maxW;
|
|
_cachedTranscriptDatFont = datFont;
|
|
_cachedTranscriptDebugFont = debugFont;
|
|
_cachedTranscriptLines = result;
|
|
TranscriptLayoutBuildCount++;
|
|
return result;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
}
|
|
}
|