fix(ui): talk button keeps authored 46x17; white authored captions + font for talk/Send

Owner report (2026-08-24, post gate-pass): the chat channel button was
bigger than retail and both its caption and the Send caption were warm
gold instead of retail's near-white.

Size: retail never resizes the talk button — HandleSelection
@0x004cd540 only swaps the caption string; the authored 46x17 element
stands, and the authored SHORT captions ('Gen', 'Fell', ...) fit it —
that is why retail abbreviates. Our content-widening reflow (grow the
button to its label, shift the input) was a compensation for the
now-retired invented long captions, measured with the wrong font on
top. Deleted; the authored row layout stands.

Color + font: the button caption child (0x10000015) and the Send
button (0x10000019) both author pure white text with their OWN FontDid
0x40000002 (live-DAT probed) — different from the transcript font,
which is the other half of why 'Chat' fits 46px. UiMenu gains a
ButtonDatFont for the caption (popup rows keep the menu font);
the controller reads both elements' authored FontColor/FontDid instead
of the invented (1,.92,.72) constants.

The old widening pin is rewritten to the retail contract; a new
conformance test pins authored width, white captions, and the authored
font DID being requested for both buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 20:43:46 +02:00
parent c12a95b6e8
commit 67e963524b
4 changed files with 106 additions and 33 deletions

View file

@ -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<uint, (uint tex, int w, int h)> resolve,
Func<string?>? selectedTargetName = null,
Func<string, string?>? chatStrings = null)
Func<string, string?>? chatStrings = null,
Func<uint, UiDatFont?>? 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

View file

@ -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.");

View file

@ -202,6 +202,17 @@ public sealed class UiMenu : UiElement
public uint CurrentFaceSpriteForTest => _facePressed ? PressedSprite : NormalSprite;
public UiDatFont? DatFont { get; set; }
/// <summary>
/// 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 <see cref="DatFont"/>.
/// </summary>
public UiDatFont? ButtonDatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
/// <summary>Retail LayoutDesc property <c>0x21</c> (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
}

View file

@ -368,8 +368,15 @@ public class ChatLayoutConformanceTests
Assert.Equal(605f, inputBar.Width);
}
/// <summary>
/// 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).
/// </summary>
[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);
}
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void TalkButtonAndSend_KeepAuthoredSizeFontAndWhiteCaptions()
{
var infos = FixtureLoader.LoadChatInfos();
ImportedLayout layout = LayoutImporter.Build(infos, NoTex, null);
var requestedFonts = new List<uint>();
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<UiMenu>(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<UiButton>(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);
}
}