diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 0b9c3e89..44012ce1 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -62,6 +62,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private const uint TrackId = 0x10000012u; private const uint InputBarId = 0x10000013u; private const uint MenuId = 0x10000014u; + private const uint MenuLabelId = 0x10000015u; // button caption child: white, FontDid 0x40000002 private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField private const uint SendId = 0x10000019u; private const uint MaxMinId = 0x1000046Fu; @@ -301,7 +302,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta BitmapFont? debugFont, Func resolve, Func? selectedTargetName = null, - Func? chatStrings = null) + Func? chatStrings = null, + Func? resolveFont = null) { ArgumentNullException.ThrowIfNull(windowFilters); @@ -478,6 +480,19 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta if (layout.FindElement(MenuId) is UiMenu menu) { menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve; + // The authored label child (0x10000015) carries the button + // caption's OWN color and font: pure white, FontDid 0x40000002 + // (live-DAT probed 2026-08-24) — not the transcript font and not + // the invented warm gold. Falls back to the previous defaults + // when the fixture lacks the child (hermetic tests). + if (FindInfo(rootInfo, MenuLabelId) is { } labelInfo) + { + if (labelInfo.FontColor is { } authoredColor) + menu.TextColor = authoredColor; + if (labelInfo.FontDid != 0u + && resolveFont?.Invoke(labelInfo.FontDid) is { } buttonFont) + menu.ButtonDatFont = buttonFont; + } menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; menu.PopupBgSprite = MenuPopupBg; menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; @@ -559,33 +574,28 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta 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. + // The Send sprite is a blank gold button — retail draws the caption + // as text with the element's OWN authored style: pure white, + // FontDid 0x40000002 (live-DAT probed 2026-08-24; the previous + // warm gold was invented). sendEl.Label = "Send"; - sendEl.LabelFont = datFont; - sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); + ElementInfo? sendInfo = FindInfo(rootInfo, SendId); + sendEl.LabelFont = + (sendInfo?.FontDid is { } sendFontDid and not 0u + ? resolveFont?.Invoke(sendFontDid) + : null) ?? datFont; + sendEl.LabelColor = sendInfo?.FontColor ?? new Vector4(1f, 1f, 1f, 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(); - } + // 2026-08-24: the previous content-widening reflow here (grow the + // button to its label, shift the input) was a compensation for the + // invented LONG captions ("General"). Retail keeps the authored + // 46x17 button — the authored SHORT captions ('Gen', font + // 0x40000002) fit it, which is exactly why retail abbreviates + // (gmMainChatUI::HandleSelection @0x004cd540 never resizes the + // element). The authored row layout stands untouched. // ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ── // The dat already authors max/min (368,5,16,16) just left of the scrollbar diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index fa3a1e8d..e743ce94 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1599,7 +1599,8 @@ public sealed class RetailUiRuntime : IDisposable // Authored talk-focus labels (StringTable 0x23000001, ID_Chat_* // keys via compute_str_hash — the retail "Gen"/"Tell to X" set). chatStrings: key => new DatStringResolver(_bindings.Assets.Dats) - .Resolve(0x23000001u, DatStringResolver.ComputeHash(key))); + .Resolve(0x23000001u, DatStringResolver.ComputeHash(key)), + resolveFont: _bindings.Assets.ResolveFont); if (controller is null) { Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F."); diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index cf76ecd6..fdcf54b9 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -202,6 +202,17 @@ public sealed class UiMenu : UiElement public uint CurrentFaceSpriteForTest => _facePressed ? PressedSprite : NormalSprite; public UiDatFont? DatFont { get; set; } + + /// + /// Optional separate font for the BUTTON caption. The chat talk button's + /// authored label child (0x10000015) carries its own FontDid 0x40000002 — + /// different from the popup rows/transcript font — which is how retail + /// fits 'Chat'/'Gen' inside the authored 46x17 face (live-DAT probed + /// 2026-08-24, the owner-reported "button too big" delta). Null falls + /// back to . + /// + public UiDatFont? ButtonDatFont { get; set; } + public AcDream.App.Rendering.BitmapFont? Font { get; set; } /// Retail LayoutDesc property 0x21 (two-pass glyph outline, @@ -400,10 +411,17 @@ public sealed class UiMenu : UiElement // label child spans the button MINUS the arrow-cap overlay's right // socket (0x10000355 is 100 wide of the 117 button, docked; the // arrow child overlays the last 17px — menuprobe3). + UiDatFont? captionFont = ButtonDatFont ?? DatFont; + float captionW = captionFont?.MeasureWidth(caption) + ?? Font?.MeasureWidth(caption) ?? caption.Length * 7f; + float captionLineH = captionFont?.LineHeight ?? Font?.LineHeight ?? 14f; float capX = ButtonTextCentered - ? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - MeasureText(caption)) * 0.5f) + ? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - captionW) * 0.5f) : ButtonTextIndent; - DrawLabel(ctx, caption, capX, (Height - LineH()) * 0.5f, TextColor); + if (captionFont is { } cf) + ctx.DrawStringDat(cf, caption, capX, (Height - captionLineH) * 0.5f, TextColor, Outline, OutlineColor); + else + ctx.DrawString(caption, capX, (Height - captionLineH) * 0.5f, TextColor, Font); // G6: the open/closed arrow-cap overlay — see ArrowCapClosedSprite's doc comment. if (resolve is not null) DrawArrowCap(ctx, resolve); @@ -443,7 +461,8 @@ public sealed class UiMenu : UiElement public float NaturalButtonWidth() { string text = ButtonLabelProvider?.Invoke() ?? ""; - float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f; + UiDatFont? nf = ButtonDatFont ?? DatFont; + float textW = nf?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f; return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap } diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index 122e3b38..502087ca 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -368,8 +368,15 @@ public class ChatLayoutConformanceTests Assert.Equal(605f, inputBar.Width); } + /// + /// 2026-08-24 rewrite: this test used to pin the content-widening + /// reflow (button grown past 46px for the invented long captions). + /// Retail never resizes the talk button — the authored 46x17 stands + /// through selection changes AND layout passes, and the input keeps its + /// authored start (owner-reported "button too big" delta). + /// [Fact] - public void ChatFixture_ChannelCaptionWidth_SurvivesImportedLayoutPass() + public void ChatFixture_ChannelButton_KeepsAuthoredWidthThroughLayoutPass() { var infos = FixtureLoader.LoadChatInfos(); var layout = LayoutImporter.Build(infos, NoTex, null); @@ -384,13 +391,12 @@ public class ChatLayoutConformanceTests NoTex); Assert.NotNull(controller); - controller!.Menu.OnSelect!.Invoke(ChatChannelKind.General); - float fittedWidth = controller.Menu.Width; + float inputLeft = controller!.Input.Left; + controller.Menu.OnSelect!.Invoke(ChatChannelKind.General); controller.Menu.ApplyAnchor(controller.Menu.Parent!.Width, controller.Menu.Parent.Height); - Assert.True(fittedWidth > 46f); - Assert.Equal(fittedWidth, controller.Menu.Width); - Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left); + Assert.Equal(46f, controller.Menu.Width); + Assert.Equal(inputLeft, controller.Input.Left); } [Fact] @@ -734,4 +740,41 @@ public class ChatLayoutConformanceTests ApplyLayoutPassLocal(child); } } + + /// + /// 2026-08-24 owner report ("chat button too big; caption should be + /// whiter — same for Send"): retail keeps the AUTHORED 46x17 talk + /// button (HandleSelection @0x004cd540 never resizes it — the authored + /// SHORT captions fit, which is why retail abbreviates), and both the + /// button caption child (0x10000015) and the Send button (0x10000019) + /// author PURE WHITE text with their own FontDid 0x40000002. The + /// previous content-widening reflow and invented warm-gold labels are + /// retired. + /// + [Fact] + public void TalkButtonAndSend_KeepAuthoredSizeFontAndWhiteCaptions() + { + var infos = FixtureLoader.LoadChatInfos(); + ImportedLayout layout = LayoutImporter.Build(infos, NoTex, null); + var requestedFonts = new List(); + var controller = ChatWindowController.Bind( + infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, + new ChatWindowState(), null, null, NoTex, + resolveFont: did => { requestedFonts.Add(did); return null; }); + Assert.NotNull(controller); + + UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u)); + // Authored 46x17 stands — no content widening. + Assert.Equal(46f, menu.Width); + menu.OnSelect!.Invoke(ChatChannelKind.General); + Assert.Equal(46f, menu.Width); + // Authored caption color: pure white. + Assert.Equal(new System.Numerics.Vector4(1f, 1f, 1f, 1f), menu.TextColor); + + var send = Assert.IsType(layout.FindElement(0x10000019u)); + Assert.Equal(new System.Numerics.Vector4(1f, 1f, 1f, 1f), send.LabelColor); + + // Both caption fonts were requested from the authored FontDid. + Assert.Contains(0x40000002u, requestedFonts); + } }