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:
parent
2d6333f84c
commit
c12a95b6e8
11 changed files with 401 additions and 46 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs
Normal file
59
tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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!());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue