fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -184,6 +184,12 @@ public sealed class UiRoot : UiElement
/// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; }
// The dispatcher is attached before retained UI. A semantic binding can
// therefore focus chat before this tree receives the same native key.
// Suppress that exact key through KeyChar/KeyUp so it cannot immediately
// submit the newly-focused field or insert a rebound printable key.
private int? _suppressedPhysicalKey;
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
public UiElement? DefaultTextInput { get; set; }
@ -497,7 +503,18 @@ public sealed class UiRoot : UiElement
internal void OnSubtreeRemoving(UiElement subtree)
{
ClearSubtreeOwnership(subtree);
// Inventory/external-container lists rebuild procedurally when an
// authoritative object update arrives. That rebuild removes each old
// UIItem before adding its replacement. Once BeginDrag has promoted
// the gesture, however, retail's UIElementManager owns a separate
// root-level drag element (StartDragandDrop @ 0x0045E040) and transfers
// mouse capture to it; the source list cell is no longer the gesture's
// lifetime owner. Our drag ghost is likewise snapshotted/root-owned,
// so preserve it when the exact source leaf is replaced mid-drag and
// transfer capture to this root. Removing a containing subtree (window
// teardown) still cancels normally.
bool replacingActiveDragSource = ReferenceEquals(subtree, DragSource);
ClearSubtreeOwnership(subtree, preserveDetachedDrag: replacingActiveDragSource);
WindowManager.OnSubtreeRemoving(subtree);
}
@ -511,13 +528,16 @@ public sealed class UiRoot : UiElement
internal void OnElementVisibilityChanged(UiElement element, bool visible)
=> ElementVisibilityChanged?.Invoke(element, visible);
internal void ClearSubtreeOwnership(UiElement subtree)
internal void ClearSubtreeOwnership(UiElement subtree, bool preserveDetachedDrag = false)
{
if (IsWithinSubtree(KeyboardFocus, subtree))
SetKeyboardFocus(null);
if (IsWithinSubtree(Captured, subtree))
{
ReleaseCapture();
if (preserveDetachedDrag && ReferenceEquals(Captured, DragSource))
SetCapture(this);
else
ReleaseCapture();
_dragCandidate = false;
}
if (IsWithinSubtree(DefaultTextInput, subtree))
@ -527,10 +547,13 @@ public sealed class UiRoot : UiElement
if (IsWithinSubtree(DragSource, subtree))
{
DragSource?.SetDragSourceActive(false, DragPayload);
DragSource = null;
DragPayload = null;
_dragGhost = null;
_dragCandidate = false;
if (!preserveDetachedDrag)
{
DragSource = null;
DragPayload = null;
_dragGhost = null;
_dragCandidate = false;
}
}
if (IsWithinSubtree(_hoverWidget, subtree))
{
@ -1090,13 +1113,15 @@ public sealed class UiRoot : UiElement
public void OnKeyDown(int vk, uint lparam = 0)
{
if (_suppressedPhysicalKey == vk)
return;
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
// also fall through to a game hotkey.
if (KeyboardFocus is null && DefaultTextInput is not null
&& (vk == (int)Silk.NET.Input.Key.Tab
|| vk == (int)Silk.NET.Input.Key.Enter
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
|| vk == (int)Silk.NET.Input.Key.Enter))
{
SetKeyboardFocus(DefaultTextInput);
return;
@ -1125,6 +1150,11 @@ public sealed class UiRoot : UiElement
public void OnKeyUp(int vk, uint lparam = 0)
{
if (_suppressedPhysicalKey == vk)
{
_suppressedPhysicalKey = null;
return;
}
if (KeyboardFocus is not null)
{
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp,
@ -1136,12 +1166,18 @@ public sealed class UiRoot : UiElement
public void OnChar(int codepoint)
{
if (_suppressedPhysicalKey is not null)
return;
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return;
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char,
Data0: codepoint);
BubbleEvent(KeyboardFocus, in e);
}
/// <summary>Suppress the raw retained-UI tail of a semantic key action.</summary>
public void SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key key)
=> _suppressedPhysicalKey = (int)key;
// ── Focus + capture ─────────────────────────────────────────────────
public void SetKeyboardFocus(UiElement? e)