fix(ui): chat window retail parity — focus rails, authored captions, menu flick, drag-stable text

Four of the five owner-reported chat deltas (2026-08-24), each traced to
its retail mechanism:

1. Missing gold separator left of the input: the chat input authors two
   1px Type-3 rail CHILDREN (0x10000017 at X=0, 0x10000018
   right-anchored) whose only media is Normal_focussed (0x06004D67,
   live-DAT probed). UiField consumes its DAT children, so the rails
   were swallowed and never drawn. The factory now folds them into the
   field, which draws both while focused.

2. Button says "General", retail says "Gen": the talk button's short
   caption comes from per-target ID_Chat_ChatTargetMenu* strings
   (HandleSelection @0x004cd540, StringTable 0x23000001 via
   compute_str_hash — recovered from the raw binary after BN elided the
   ids into name-hash globals). Authored set: Chat/Tell/Fell/Pat/Mon/
   Vas/Alg/Gen/Trade/LFG/RP/Soc/Olt. Menu rows + squelch/tell specials
   resolve from the same table (ID_Chat_TellTo*); production resolves
   through DatStringResolver, fallbacks ARE the authored EoR English.
   ChatStringsLiveDatTests pins the whole set against the installed DAT.

3. Channel button stayed green while the popup was open: retail's
   pressed face is the momentary physical press ("flicks"); the OPEN
   state drives only the arrow-cap child's StateDesc swap
   (UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe).
   UiMenu now keys the face on the press, not on IsOpen.

4. Window-title/button text "vibrates" while dragging windows:
   DrawStringDatPass snapped glyphs with MathF.Round — banker's
   rounding. A centered label with a constant .5 fraction alternates
   round-up/round-down across successive integers, double-stepping then
   sticking while the background glides. Half-up Floor(v+0.5) snaps
   every tie one way: uniform 1px steps in lock-step with sprites.

The fifth report (input row sticking out on window resize) did not
reproduce: a controller-bound fixture resize at 220/300/600px keeps the
whole input row inside the window (test added) — awaiting the owner's
exact gesture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 20:27:04 +02:00
parent 2d6333f84c
commit c12a95b6e8
11 changed files with 401 additions and 46 deletions

View file

@ -188,40 +188,62 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private string? _tellTarget;
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
/// <summary>
/// Authored chat-string lookup (key name in StringTable 0x23000001 —
/// gmMainChatUI resolves every talk-focus label through
/// compute_str_hash'd ID_Chat_* keys; live-DAT probed 2026-08-24).
/// Null (tests / standalone mounts) falls back to the authored EoR
/// English transcribed below.
/// </summary>
private Func<string, string?>? _chatStrings;
private string S(string key, string authoredFallback)
=> _chatStrings?.Invoke(key) ?? authoredFallback;
/// <summary>Channel rows: authored ID_Chat_TellTo* key + the authored
/// EoR English as the no-resolver fallback (both live-DAT probed
/// 2026-08-24 — the fallbacks ARE the authored strings).</summary>
private static readonly (string Key, string Fallback, 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),
("ID_Chat_TellToAll", "Chat to All", ChatChannelKind.Say),
("ID_Chat_TellToFellows", "Tell to Fellows", ChatChannelKind.Fellowship),
("ID_Chat_TellToGeneral", "Tell to General Chat", ChatChannelKind.General),
("ID_Chat_TellToLFG", "Tell to LFG Chat", ChatChannelKind.Lfg),
("ID_Chat_TellToSociety", "Tell to Society Chat", ChatChannelKind.Society),
("ID_Chat_TellToMonarch", "Tell to Monarch", ChatChannelKind.Monarch),
("ID_Chat_TellToPatron", "Tell to Patron", ChatChannelKind.Patron),
("ID_Chat_TellToVassals", "Tell to Vassals", ChatChannelKind.Vassals),
("ID_Chat_TellToAllegiance", "Tell to Allegiance", ChatChannelKind.Allegiance),
("ID_Chat_TellToTrade", "Tell to Trade Chat", ChatChannelKind.Trade),
("ID_Chat_TellToRoleplay", "Tell to Roleplay Chat", ChatChannelKind.Roleplay),
("ID_Chat_TellToOlthoi", "Tell to Olthoi Chat", ChatChannelKind.Olthoi),
};
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
/// <summary>
/// The talk button's SHORT caption: gmMainChatUI::HandleSelection
/// @0x004cd540 sets m_pChatTargetButtonText from the per-target
/// ID_Chat_ChatTargetMenu* string (table 0x23000001) — authored values
/// 'Chat'/'Tell'/'Fell'/'Pat'/'Mon'/'Vas'/'Alg'/'Gen'/'Trade'/'LFG'/
/// 'RP'/'Soc'/'Olt' (live-DAT probed 2026-08-24; the previous
/// hand-invented longs like "General"/"Fellow"/"Alleg" were the
/// owner-reported "says General not Gen" delta).
/// </summary>
private string ChannelButtonLabel(ChatChannelKind k) => k switch
{
ChatChannelKind.Say => "Chat",
ChatChannelKind.Tell => "Tell",
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",
ChatChannelKind.Say => S("ID_Chat_ChatTargetMenu", "Chat"),
ChatChannelKind.Tell => S("ID_Chat_ChatTargetMenuSelected", "Tell"),
ChatChannelKind.General => S("ID_Chat_ChatTargetMenuGeneral", "Gen"),
ChatChannelKind.Trade => S("ID_Chat_ChatTargetMenuTrade", "Trade"),
ChatChannelKind.Lfg => S("ID_Chat_ChatTargetMenuLFG", "LFG"),
ChatChannelKind.Fellowship => S("ID_Chat_ChatTargetMenuFellows", "Fell"),
ChatChannelKind.Allegiance => S("ID_Chat_ChatTargetMenuAllegiance", "Alg"),
ChatChannelKind.Patron => S("ID_Chat_ChatTargetMenuPatron", "Pat"),
ChatChannelKind.Vassals => S("ID_Chat_ChatTargetMenuVassals", "Vas"),
ChatChannelKind.Monarch => S("ID_Chat_ChatTargetMenuMonarch", "Mon"),
ChatChannelKind.Roleplay => S("ID_Chat_ChatTargetMenuRoleplay", "RP"),
ChatChannelKind.Society => S("ID_Chat_ChatTargetMenuSociety", "Soc"),
ChatChannelKind.Olthoi => S("ID_Chat_ChatTargetMenuOlthoi", "Olt"),
_ => S("ID_Chat_ChatTargetMenu", "Chat"),
};
private static bool ChannelAvailable(ChatChannelKind k)
@ -278,7 +300,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
UiDatFont? datFont,
BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve,
Func<string?>? selectedTargetName = null)
Func<string?>? selectedTargetName = null,
Func<string, string?>? chatStrings = null)
{
ArgumentNullException.ThrowIfNull(windowFilters);
@ -307,6 +330,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
Root = window,
DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo,
_windowFilters = windowFilters,
_chatStrings = chatStrings,
};
// Seed the unlocked skin until the common registered-window presenter
@ -467,19 +491,17 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
var items = new List<UiMenu.MenuItem>(ChannelItems.Length)
{
new(target is null
? "Squelch (ignore)"
: $"Squelch (ignore) {target}",
? c.S("ID_Chat_SquelchSelectedNoSelection",
"Squelch (ignore) Selected")
: c.S("ID_Chat_SquelchSelected", "Squelch (ignore) ") + target,
TalkFocusSpecial.Squelch),
new(target is null
? "Tell to Selected"
: $"Tell to {target}",
? c.S("ID_Chat_TellToSelectedNoSelection", "Tell to Selected")
: c.S("ID_Chat_TellToSelected", "Tell to ") + target,
TalkFocusSpecial.TellToSelected),
};
foreach ((string label, ChatChannelKind? channel) in ChannelItems)
{
if (channel is { } ch)
items.Add(new UiMenu.MenuItem(label, ch));
}
foreach ((string key, string fallback, ChatChannelKind ch) in ChannelItems)
items.Add(new UiMenu.MenuItem(c.S(key, fallback), ch));
menu.Items = items.ToArray();
}
@ -497,7 +519,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
};
// The button names the FOCUS, not the target: retail shows "Tell",
// not the person's name.
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
menu.ButtonLabelProvider = () => c.ChannelButtonLabel(c._activeChannel);
menu.OnOpen = RebuildItems;
menu.OnSelect = p =>
{

View file

@ -822,6 +822,32 @@ public static class DatWidgetFactory
field.TextColor = info.FontColor.Value;
if (info.OutlineColor.HasValue)
field.OutlineColor = info.OutlineColor.Value;
// The chat input's authored focus rails (children 0x10000017/18,
// Type 3, media only for Normal_focussed — live-DAT probed
// 2026-08-24): UiField consumes its DAT children, so fold the
// rails into the field and let it draw them while focused (the
// owner-reported missing gold separator next to the channel
// button). Left/right assignment follows the authored X within
// the field, matching each rail's own edge anchoring.
foreach (ElementInfo child in info.Children)
{
if (child.Type != 3u
|| !child.StateMedia.TryGetValue("Normal_focussed", out var railMedia)
|| railMedia.File == 0u)
continue;
bool leftAnchored = child.X < info.Width * 0.5f;
if (leftAnchored)
{
field.FocusRailLeftSprite = railMedia.File;
if (child.Width > 0f) field.FocusRailLeftWidth = child.Width;
}
else
{
field.FocusRailRightSprite = railMedia.File;
if (child.Width > 0f) field.FocusRailRightWidth = child.Width;
}
}
return field;
}

View file

@ -1595,7 +1595,11 @@ public sealed class RetailUiRuntime : IDisposable
return null;
string? name = _bindings.Toolbar.ResolveName(selected);
return string.IsNullOrWhiteSpace(name) ? null : name;
});
},
// 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)));
if (controller is null)
{
Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F.");

View file

@ -87,6 +87,22 @@ public sealed class UiField : UiElement
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Unfocused/default state sprite imported from the DAT.</summary>
public uint BackgroundSprite { get; set; }
/// <summary>
/// The 1px vertical end-cap rails the chat input authors as consumed
/// Type-3 CHILDREN (0x10000017 left-anchored at X=0, 0x10000018
/// right-anchored — both media 0x06004D67, authored ONLY for the
/// Normal_focussed state, live-DAT probed 2026-08-24). Retail lights
/// them with the field: the left one is the gold separator between the
/// channel button and the text (the owner-reported missing bar).
/// <see cref="UiElement.ConsumesDatChildren"/> is true for fields, so
/// the factory folds the rails into these properties and the field
/// draws them itself while focused. Zero ids draw nothing.
/// </summary>
public uint FocusRailLeftSprite { get; set; }
public float FocusRailLeftWidth { get; set; } = 1f;
public uint FocusRailRightSprite { get; set; }
public float FocusRailRightWidth { get; set; } = 1f;
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
public uint FocusFieldSprite { get; set; }
@ -419,6 +435,26 @@ public sealed class UiField : UiElement
if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
else lit = false;
}
if (_focused && SpriteResolve is not null)
{
// The authored focus rails (see FocusRailLeftSprite's doc): 1px
// vertical gold end caps, lit only while focused, exactly the
// rails' own Normal_focussed authoring.
if (FocusRailLeftSprite != 0)
{
var (tex, tw, _) = SpriteResolve(FocusRailLeftSprite);
if (tex != 0 && tw > 0)
ctx.DrawSprite(tex, 0, 0, FocusRailLeftWidth, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
if (FocusRailRightSprite != 0)
{
var (tex, tw, _) = SpriteResolve(FocusRailRightSprite);
if (tex != 0 && tw > 0)
ctx.DrawSprite(
tex, Width - FocusRailRightWidth, 0,
FocusRailRightWidth, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
if (!lit && SpriteResolve is not null && BackgroundSprite != 0)
{
var (tex, tw, th) = SpriteResolve(BackgroundSprite);

View file

@ -196,6 +196,11 @@ public sealed class UiMenu : UiElement
/// reason).</summary>
public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite;
/// <summary>The face sprite <see cref="OnDraw"/> would pick right now —
/// keyed on the momentary physical press (<c>_facePressed</c>), NOT on
/// open state (see <c>_facePressed</c>'s doc). Test seam.</summary>
public uint CurrentFaceSpriteForTest => _facePressed ? PressedSprite : NormalSprite;
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
@ -272,6 +277,19 @@ public sealed class UiMenu : UiElement
private bool _open;
/// <summary>
/// True only while the pointer is physically pressed on the button FACE.
/// 2026-08-24 owner report: the chat channel button stayed green (pressed
/// art) the whole time the popup was open — retail's pressed face
/// (0x06004D66) is the ordinary momentary button press ("flicks green"),
/// while the OPEN state drives only the arrow-cap child's state swap
/// (UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe, which
/// the cap element's own StateDesc consumes — see
/// <see cref="ArrowCapClosedSprite"/>'s doc). The face must key on the
/// physical press, not on <see cref="_open"/>.
/// </summary>
private bool _facePressed;
/// <summary>Whether the popup is currently open (test/inspection seam,
/// same rationale as <see cref="PopupScroll"/>/<see cref="CurrentArrowCapSprite"/>).</summary>
public bool IsOpen => _open;
@ -374,7 +392,7 @@ public sealed class UiMenu : UiElement
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
if (resolve is not null)
{
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
var (tex, tw, _) = resolve(_facePressed ? PressedSprite : NormalSprite);
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
}
string caption = ButtonLabelProvider?.Invoke() ?? "";
@ -660,6 +678,14 @@ public sealed class UiMenu : UiElement
}
}
if (e.Type is UiEventType.MouseUp
or UiEventType.HoverLeave
or UiEventType.CaptureChanged)
{
_facePressed = false; // the momentary face flick ends here
return false;
}
if (e.Type != UiEventType.MouseDown) return false;
float lx = e.Data1, ly = e.Data2;
@ -700,6 +726,7 @@ public sealed class UiMenu : UiElement
// Retail Open @0x0046cc42 refuses an empty list (gates on
// m_listBox->m_listItems.m_num != 0) — a bare click on an itemless
// menu is a no-op rather than an empty popup.
_facePressed = true; // momentary press flick
if (!_open && Items.Count == 0) return true;
SetOpen(!_open); // toggle on button click
return true;

View file

@ -338,7 +338,16 @@ public sealed class UiRenderContext
// digits never showed it because their bar baseline lands on an integer; chat text
// does. Snapping the baseline once, then adding the integer offset, keeps the whole
// line on one row and pixel-aligned.
float baseY = System.MathF.Round(originY);
//
// HALF-UP, not MathF.Round (2026-08-24 owner report: window-title and
// button labels "vibrate" while dragging a window): MathF.Round is
// banker's rounding — a centered label whose origin carries a constant
// .5 fraction (odd text width over /2) alternates round-up/round-down
// as the dragged window crosses successive integers, so the text
// double-steps then sticks while the background glides 1px per frame.
// Floor(v + 0.5) snaps every tie the same direction: constant fraction
// → uniform 1px steps in lock-step with the sprites.
float baseY = System.MathF.Floor(originY + 0.5f);
float pen = originX;
for (int i = 0; i < text.Length; i++)
@ -349,7 +358,8 @@ public sealed class UiRenderContext
// Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
// true fractional advance). Vertical: integer baseline + integer per-glyph
// offset — never an independent per-glyph round (see baseY's note above).
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
// Half-up for the same anti-vibration reason as baseY.
float gx = System.MathF.Floor(pen + g.HorizontalOffsetBefore + 0.5f);
float gy = baseY + g.VerticalOffsetBefore;
float gw = g.Width;
float gh = g.Height;

View file

@ -672,4 +672,66 @@ public class ChatLayoutConformanceTests
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(draggedPosition, scroll.ScrollY);
}
/// <summary>
/// 2026-08-24 owner report: resizing the chat window let the text input
/// stick out past the window edge. The authored edge modes (input row
/// 0x10000013 L1/R1 = stretch, field 0x10000016 L1/R1 = stretch, Send
/// 0x10000019 L2/R1 = right-docked, menu button 0x10000014 L1/R2 =
/// left-docked) must keep the whole input row inside the window at every
/// size, narrower AND wider than the authored 410.
/// </summary>
[Theory]
[InlineData(300f, 100f)]
[InlineData(600f, 160f)]
[InlineData(220f, 80f)]
public void ResizingTheWindow_KeepsTheInputRowInsideIt(float width, float height)
{
var infos = FixtureLoader.LoadChatInfos();
ImportedLayout layout = LayoutImporter.Build(infos, NoTex, null);
// Bind the REAL controller — production geometry overrides included.
var controller = ChatWindowController.Bind(
infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance,
new ChatWindowState(), null, null, NoTex);
Assert.NotNull(controller);
UiElement window = layout.FindElement(0x10000600u)!;
var root = new UiRoot { Width = 800f, Height = 600f };
root.AddChild(window);
ApplyLayoutPassLocal(window);
window.Width = width;
window.Height = height;
window.ResetAnchorCapture();
ApplyLayoutPassLocal(window);
ApplyLayoutPassLocal(window); // second frame — policies settle
UiElement inputBar = layout.FindElement(0x10000013u)!;
UiElement input = layout.FindElement(0x10000016u)!;
UiElement send = layout.FindElement(0x10000019u)!;
UiElement menuButton = layout.FindElement(0x10000014u)!;
Assert.True(inputBar.Left >= 0f && inputBar.Left + inputBar.Width <= width + 0.5f,
$"input bar [{inputBar.Left},{inputBar.Left + inputBar.Width}] escapes window width {width}");
float inputRight = inputBar.Left + input.Left + input.Width;
Assert.True(inputRight <= width + 0.5f,
$"input field right {inputRight} escapes window width {width}");
float sendRight = inputBar.Left + send.Left + send.Width;
Assert.True(sendRight <= width + 0.5f,
$"send right {sendRight} escapes window width {width}");
Assert.True(menuButton.Left >= 0f, "menu button escaped left");
// The field must stay BETWEEN the menu button and the send button.
Assert.True(input.Left >= menuButton.Left + menuButton.Width - 0.5f,
$"input {input.Left} overlaps menu button ending {menuButton.Left + menuButton.Width}");
Assert.True(input.Left + input.Width <= send.Left + 0.5f,
$"input ends {input.Left + input.Width} past send start {send.Left}");
}
private static void ApplyLayoutPassLocal(UiElement parent)
{
foreach (var child in parent.Children)
{
child.ApplyAnchor(parent.Width, parent.Height);
ApplyLayoutPassLocal(child);
}
}
}

View file

@ -0,0 +1,59 @@
using AcDream.App.UI.Layout;
using DatReaderWriter;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// 2026-08-24 pin: the talk-focus labels resolve from StringTable
/// <c>0x23000001</c> via <c>compute_str_hash</c>'d <c>ID_Chat_*</c> keys —
/// the mechanism <c>gmMainChatUI::HandleSelection @0x004cd540</c> (button
/// shorts) and <c>InitTalkFocusMenu @0x004cdc50</c> (menu rows) use. The
/// authored shorts are ABBREVIATIONS ('Gen', not "General" — the
/// owner-reported delta); guard both families so a DAT revision or resolver
/// regression fails loudly instead of silently reverting to fallbacks.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class ChatStringsLiveDatTests
{
private static string DatDirectory =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
[InstalledDatFact]
public void TalkFocusStrings_ResolveToTheAuthoredRetailSet()
{
using var dats = new DatCollection(
DatDirectory, DatReaderWriter.Options.DatAccessType.Read);
var strings = new DatStringResolver(dats);
const uint table = 0x23000001u;
string? Resolve(string key)
=> strings.Resolve(table, DatStringResolver.ComputeHash(key));
// Button shorts (m_pChatTargetButtonText).
Assert.Equal("Chat", Resolve("ID_Chat_ChatTargetMenu"));
Assert.Equal("Tell", Resolve("ID_Chat_ChatTargetMenuSelected"));
Assert.Equal("Fell", Resolve("ID_Chat_ChatTargetMenuFellows"));
Assert.Equal("Pat", Resolve("ID_Chat_ChatTargetMenuPatron"));
Assert.Equal("Mon", Resolve("ID_Chat_ChatTargetMenuMonarch"));
Assert.Equal("Vas", Resolve("ID_Chat_ChatTargetMenuVassals"));
Assert.Equal("Alg", Resolve("ID_Chat_ChatTargetMenuAllegiance"));
Assert.Equal("Gen", Resolve("ID_Chat_ChatTargetMenuGeneral"));
Assert.Equal("Trade", Resolve("ID_Chat_ChatTargetMenuTrade"));
Assert.Equal("LFG", Resolve("ID_Chat_ChatTargetMenuLFG"));
Assert.Equal("RP", Resolve("ID_Chat_ChatTargetMenuRoleplay"));
Assert.Equal("Soc", Resolve("ID_Chat_ChatTargetMenuSociety"));
Assert.Equal("Olt", Resolve("ID_Chat_ChatTargetMenuOlthoi"));
// Menu rows + specials composition sources.
Assert.Equal("Chat to All", Resolve("ID_Chat_TellToAll"));
Assert.Equal("Tell to General Chat", Resolve("ID_Chat_TellToGeneral"));
Assert.Equal("Tell to ", Resolve("ID_Chat_TellToSelected"));
Assert.Equal("Tell to Selected", Resolve("ID_Chat_TellToSelectedNoSelection"));
Assert.Equal("Squelch (ignore) ", Resolve("ID_Chat_SquelchSelected"));
Assert.Equal(
"Squelch (ignore) Selected",
Resolve("ID_Chat_SquelchSelectedNoSelection"));
}
}

View file

@ -374,7 +374,9 @@ public class ChatWindowControllerTests
UiMenu menu = Assert.IsType<UiMenu>(layout.FindElement(0x10000014u));
menu.OnOpen!.Invoke();
Assert.Equal("Squelch (ignore)", menu.Items[0].Label);
// Authored no-selection labels (live-DAT probed 2026-08-24:
// ID_Chat_SquelchSelectedNoSelection / ID_Chat_TellToSelectedNoSelection).
Assert.Equal("Squelch (ignore) Selected", menu.Items[0].Label);
Assert.Equal("Tell to Selected", menu.Items[1].Label);
// Retail arms the tell slot only once a talkable object is selected
@ -738,4 +740,42 @@ public class ChatWindowControllerTests
Assert.Throws<ArgumentOutOfRangeException>(() => ctrl.SetIndicatorOpen(windowId, open: true));
}
/// <summary>
/// 2026-08-24 owner report ("says General not Gen"): the talk button's
/// SHORT caption comes from the per-target ID_Chat_ChatTargetMenu*
/// strings (gmMainChatUI::HandleSelection @0x004cd540, table
/// 0x23000001) — authored 'Gen', not the invented "General". The
/// no-resolver fallbacks ARE the authored EoR strings; a resolver
/// (production) overrides them for localization.
/// </summary>
[Fact]
public void TalkButton_UsesAuthoredShortCaptions()
{
var (rootInfo, layout, vm) = BuildTestTree();
ChatWindowController? ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => NullCommandBus.Instance,
new ChatWindowState(), null, null, NoTex);
Assert.NotNull(ctrl);
UiMenu menu = Assert.IsType<UiMenu>(layout.FindElement(0x10000014u));
Assert.Equal("Chat", menu.ButtonLabelProvider!());
menu.OnSelect!.Invoke(ChatChannelKind.General);
Assert.Equal("Gen", menu.ButtonLabelProvider());
menu.OnSelect.Invoke(ChatChannelKind.Lfg);
Assert.Equal("LFG", menu.ButtonLabelProvider());
menu.OnSelect.Invoke(ChatChannelKind.Trade);
Assert.Equal("Trade", menu.ButtonLabelProvider());
// A DAT resolver (production) wins over the fallback.
var (rootInfo2, layout2, vm2) = BuildTestTree();
ChatWindowController? ctrl2 = ChatWindowController.Bind(
rootInfo2, layout2, vm2, () => NullCommandBus.Instance,
new ChatWindowState(), null, null, NoTex,
chatStrings: key => key == "ID_Chat_ChatTargetMenuGeneral" ? "LOC" : null);
Assert.NotNull(ctrl2);
UiMenu menu2 = Assert.IsType<UiMenu>(layout2.FindElement(0x10000014u));
menu2.OnSelect!.Invoke(ChatChannelKind.General);
Assert.Equal("LOC", menu2.ButtonLabelProvider!());
}
}

View file

@ -181,6 +181,41 @@ public class DatWidgetFactoryTests
Assert.Equal(80, field.MaxCharacters);
}
/// <summary>
/// 2026-08-24 owner report (missing gold separator left of the chat
/// input): the chat input authors two 1px Type-3 rail CHILDREN
/// (0x10000017 at X=0, 0x10000018 right-anchored) whose only media is
/// Normal_focussed (0x06004D67, live-DAT probed). UiField consumes its
/// DAT children, so the factory must fold the rails into the field for
/// its own focused draw.
/// </summary>
[Fact]
public void Type12_EditableField_FoldsAuthoredFocusRailsIntoTheWidget()
{
var info = TextInfo((0x16u, Bool(true)));
info.Width = 306f;
info.Height = 17f;
var leftRail = new ElementInfo
{
Id = 0x10000017u, Type = 3u, X = 0f, Width = 1f, Height = 17f,
};
leftRail.StateMedia["Normal_focussed"] = (0x06004D67u, 1);
var rightRail = new ElementInfo
{
Id = 0x10000018u, Type = 3u, X = 305f, Width = 1f, Height = 17f,
};
rightRail.StateMedia["Normal_focussed"] = (0x06004D67u, 1);
info.Children.Add(leftRail);
info.Children.Add(rightRail);
var field = Assert.IsType<UiField>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(0x06004D67u, field.FocusRailLeftSprite);
Assert.Equal(1f, field.FocusRailLeftWidth);
Assert.Equal(0x06004D67u, field.FocusRailRightSprite);
Assert.Equal(1f, field.FocusRailRightWidth);
}
[Fact]
public void Type12_SelectableProperty_MakesSelectableUiText()
{

View file

@ -561,4 +561,38 @@ public class UiMenuTests
Assert.False(menu.ItemTextCentered);
Assert.False(menu.PopupSizeToContent);
}
/// <summary>
/// 2026-08-24 owner report: the chat channel button stayed green
/// (pressed art) the whole time the popup was open — retail's pressed
/// face is the momentary physical press ("flicks green"); the OPEN
/// state drives only the arrow-cap child (UIElement_Menu::UpdateState
/// @0x0046cad0 writes attribute 0xe for the cap's own StateDesc).
/// </summary>
[Fact]
public void ButtonFace_FlicksPressedOnClick_NotLatchedWhileOpen()
{
UiMenu menu = MakeMenu();
menu.NormalSprite = 10u;
menu.PressedSprite = 20u;
Assert.Equal(10u, menu.CurrentFaceSpriteForTest);
// Press on the face: pressed art while the button is held.
menu.OnEvent(new UiEvent(0u, menu, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.True(menu.IsOpen);
Assert.Equal(20u, menu.CurrentFaceSpriteForTest);
// Release: the flick ends — face returns to normal WHILE open.
menu.OnEvent(new UiEvent(0u, menu, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.True(menu.IsOpen);
Assert.Equal(10u, menu.CurrentFaceSpriteForTest);
// Second face press closes and flicks again; release ends the flick.
menu.OnEvent(new UiEvent(0u, menu, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.False(menu.IsOpen);
Assert.Equal(20u, menu.CurrentFaceSpriteForTest);
menu.OnEvent(new UiEvent(0u, menu, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.Equal(10u, menu.CurrentFaceSpriteForTest);
}
}