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
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue