diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index fb224e99..0b9c3e89 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -188,40 +188,62 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private string? _tellTarget;
- private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
+ ///
+ /// 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.
+ ///
+ private Func? _chatStrings;
+
+ private string S(string key, string authoredFallback)
+ => _chatStrings?.Invoke(key) ?? authoredFallback;
+
+ /// 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).
+ 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
+ ///
+ /// 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).
+ ///
+ 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 resolve,
- Func? selectedTargetName = null)
+ Func? selectedTargetName = null,
+ Func? 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(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 =>
{
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 2e5f7a0b..c81113b5 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -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;
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index c259f2c3..fa3a1e8d 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -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.");
diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs
index 60636d11..b9686104 100644
--- a/src/AcDream.App/UI/UiField.cs
+++ b/src/AcDream.App/UI/UiField.cs
@@ -87,6 +87,22 @@ public sealed class UiField : UiElement
public Func? SpriteResolve { get; set; }
/// Unfocused/default state sprite imported from the DAT.
public uint BackgroundSprite { get; set; }
+ ///
+ /// 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).
+ /// 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.
+ ///
+ public uint FocusRailLeftSprite { get; set; }
+ public float FocusRailLeftWidth { get; set; } = 1f;
+ public uint FocusRailRightSprite { get; set; }
+ public float FocusRailRightWidth { get; set; } = 1f;
+
/// Gold "lit" field background drawn when focused (retail Normal_focussed
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.
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);
diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs
index 5cff6a3c..cf76ecd6 100644
--- a/src/AcDream.App/UI/UiMenu.cs
+++ b/src/AcDream.App/UI/UiMenu.cs
@@ -196,6 +196,11 @@ public sealed class UiMenu : UiElement
/// reason).
public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite;
+ /// The face sprite would pick right now —
+ /// keyed on the momentary physical press (_facePressed), NOT on
+ /// open state (see _facePressed's doc). Test seam.
+ 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;
+ ///
+ /// 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
+ /// 's doc). The face must key on the
+ /// physical press, not on .
+ ///
+ private bool _facePressed;
+
/// Whether the popup is currently open (test/inspection seam,
/// same rationale as /).
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;
diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs
index d4ed5b11..6abfbf83 100644
--- a/src/AcDream.App/UI/UiRenderContext.cs
+++ b/src/AcDream.App/UI/UiRenderContext.cs
@@ -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;
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
index a32066da..122e3b38 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
@@ -672,4 +672,66 @@ public class ChatLayoutConformanceTests
scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true);
Assert.Equal(draggedPosition, scroll.ScrollY);
}
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs
new file mode 100644
index 00000000..06218fce
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs
@@ -0,0 +1,59 @@
+using AcDream.App.UI.Layout;
+using DatReaderWriter;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// 2026-08-24 pin: the talk-focus labels resolve from StringTable
+/// 0x23000001 via compute_str_hash'd ID_Chat_* keys —
+/// the mechanism gmMainChatUI::HandleSelection @0x004cd540 (button
+/// shorts) and InitTalkFocusMenu @0x004cdc50 (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.
+///
+[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"));
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
index 93b53416..2ef6ef58 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
@@ -374,7 +374,9 @@ public class ChatWindowControllerTests
UiMenu menu = Assert.IsType(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(() => ctrl.SetIndicatorOpen(windowId, open: true));
}
+
+ ///
+ /// 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.
+ ///
+ [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(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(layout2.FindElement(0x10000014u));
+ menu2.OnSelect!.Invoke(ChatChannelKind.General);
+ Assert.Equal("LOC", menu2.ButtonLabelProvider!());
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index 7644d6af..467f8155 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -181,6 +181,41 @@ public class DatWidgetFactoryTests
Assert.Equal(80, field.MaxCharacters);
}
+ ///
+ /// 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.
+ ///
+ [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(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()
{
diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs
index 2127ada4..d5c2cd55 100644
--- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs
@@ -561,4 +561,38 @@ public class UiMenuTests
Assert.False(menu.ItemTextCentered);
Assert.False(menu.PopupSizeToContent);
}
+
+ ///
+ /// 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).
+ ///
+ [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);
+ }
}