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); + } }