fix(ui): restore retail vitals and window interactions
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s

This commit is contained in:
Erik 2026-08-20 13:26:35 +02:00
parent 4d84456c21
commit 1bd2b30291
36 changed files with 1462 additions and 86 deletions

View file

@ -132,9 +132,12 @@ public sealed class CursorFeedbackController
// found flag driving the Default/Combat/Use/Examine/Busy Found
// variants too.
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
uint hoverTarget = hover is null
? _worldTargetProvider?.Invoke() ?? 0u
: FindHoveredItemSlot(hover)?.ItemId ?? 0u;
UiItemSlot? hoveredItem = FindHoveredItemSlot(hover);
uint hoverTarget = hoveredItem is not null
? hoveredItem.ItemId
: FindRepresentedObject(hover) is { } represented
? represented
: _worldTargetProvider?.Invoke() ?? 0u;
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
&& hoverTarget != 0
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)
@ -377,4 +380,19 @@ public sealed class CursorFeedbackController
}
return null;
}
private static uint? FindRepresentedObject(UiElement? element)
{
while (element is not null)
{
if (element.FoundObjectGuidProvider is { } provider)
{
uint guid = provider();
if (guid != 0u)
return guid;
}
element = element.Parent;
}
return null;
}
}

View file

@ -73,14 +73,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private const uint Indicator3Id = 0x10000524u;
private const uint Indicator4Id = 0x10000525u;
// The 8 cosmetic "_Locked" border-art twins
// (gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 swaps these in for the 8
// live Resizebar/Dragbar grips above when PlayerModule::LockUI is true — see
// research doc §1.6). CH6a does not implement the lock-state art swap (register
// row AP-185 — UiRoot.UiLocked already gates INTERACTION generically, independent
// of which art is shown); default to the unlocked visual (hide these, show the
// live grips) to match UiRoot's own UiLocked=false default and avoid double
// rendering two overlapping border-art layers.
// The 8 cosmetic "_Locked" border-art twins. Retail's
// gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 swaps these with the 8
// live Resizebar/Dragbar grips. Bind seeds the ordinary unlocked visual for
// standalone/unregistered layouts; RetailWindowLockPresentationController
// takes authoritative ownership as soon as the window registers.
private static readonly uint[] LockedTwinIds =
{
0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u,
@ -259,10 +256,9 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
_windowFilters = windowFilters,
};
// The 8 cosmetic "_Locked" border-art twins default HIDDEN — CH6a does not
// implement retail's UiLocked-driven art swap (see the class doc + the
// LockedTwinIds field comment); the 8 live grip/dragbar elements (which
// occupy the SAME rects one ReadOrder layer above) are the ones shown.
// Seed the unlocked skin until the common registered-window presenter
// applies the canonical UiLocked state. The live and locked sets occupy
// identical rects, so only one set may be visible at a time.
foreach (uint id in LockedTwinIds)
if (layout.FindElement(id) is { } twin)
twin.Visible = false;

View file

@ -571,8 +571,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_selection.Select(payload.ObjId, SelectionChangeSource.Inventory);
}
/// <summary>Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid
/// drops accept; a side-bag/main-pack drop rejects only when that container is known full.</summary>
/// <summary>Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral. Retail keeps
/// loose-item slots and contained-container slots as different drag classes: a pack is rejected
/// by the contents grid and accepted by the player's pack-slot list, while an ordinary item is
/// accepted by the contents grid or by a non-full occupied pack.</summary>
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// UIElement_ItemList::ItemList_DragOver @ 0x004E3400 only evaluates
@ -583,10 +585,22 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
return ItemDragAcceptance.None;
if (payload.ObjId == 0)
return ItemDragAcceptance.Reject;
bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source);
if (targetList == _contentsGrid)
return ItemDragAcceptance.Accept;
return sourceIsBag
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer)
{
// UIElement_ItemList::ItemList_DragOver @0x004E3400 checks the
// dragged object's container flag before interpreting this list.
// A container drag addresses the player's contained-container
// list itself; an empty authored slot is therefore a valid pack
// destination rather than "no target".
if (sourceIsBag)
return targetCell.ItemId == payload.ObjId
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId)
return ItemDragAcceptance.Reject;
return IsContainerFull(targetCell.ItemId)
@ -610,6 +624,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint item = payload.ObjId;
if (item == 0) return;
// DropReleased is still delivered to the list after a reject overlay;
// pin the release to the same retail policy instead of relying on the
// advisory color alone.
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
return;
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
// release while m_pendingItem exists, before merge, split, or ordinary
// placement. ItemList_DragOver has no equivalent gate, so hover may
@ -635,6 +655,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
&& TryMergeStacks(item, targetCell.ItemId))
return;
bool sourceIsBag = _objects.Get(item) is { } dragged && IsBag(dragged);
uint container; int placement;
if (targetList == _contentsGrid)
{
@ -645,10 +666,23 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
else if (targetList == _containerList || targetList == _topContainer)
{
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
container = targetCell.ItemId; // the bag / main pack
if (IsContainerFull(container)) return; // red already shown
placement = _objects.GetContents(container).Count; // append into it
if (sourceIsBag)
{
// A pack dropped on the pack selector is inserted into the
// player's contained-container list at that selector slot.
// Sending the open item container here makes ACE correctly
// reject the pickup with "can't be picked up".
container = _playerGuid();
if (container == 0u) return;
placement = Math.Max(0, targetCell.SlotIndex);
}
else
{
if (targetCell.ItemId == 0 || targetCell.ItemId == item) return;
container = targetCell.ItemId; // the bag / main pack
if (IsContainerFull(container)) return; // red already shown
placement = _objects.GetContents(container).Count; // append into it
}
}
else return;

View file

@ -612,11 +612,15 @@ public static class LayoutImporter
bool imageRead = false;
foreach (var m in sd.Media)
{
if (!imageRead && m is MediaDescImage img && img.File != 0)
if (m is MediaDescImage img)
{
info.StateMedia[name] = (img.File, (int)img.DrawMode);
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
imageRead = true;
state.ImageMediaCount++;
if (!imageRead && img.File != 0)
{
info.StateMedia[name] = (img.File, (int)img.DrawMode);
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
imageRead = true;
}
}
if (m is MediaDescCursor cursor && cursor.File != 0)

View file

@ -70,8 +70,15 @@ public sealed class RadarController : IRetainedPanelController
_lockButton = layout.FindElement(LockButtonId) as UiButton;
_dragButton = layout.FindElement(DragButtonId);
if (_lockButton is not null && _setUiLocked is not null)
_lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false));
if (_lockButton is not null)
{
// LockedUI/UnlockedUI are persistent semantic faces. The button's
// authored, media-less Normal_pressed state must not replace them
// during pointer traffic after the global lock transition.
_lockButton.ControllerOwnsVisualState = true;
if (_setUiLocked is not null)
_lockButton.OnClick = () => _setUiLocked(!(_lastUiLocked ?? false));
}
_tokens =
[

View file

@ -178,6 +178,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
if (_inventoryButton is not null)
{
// Retail's inventory button is also the player/self backpack
// target: target-mode clicks call OfferSelfPrimaryClick and drops
// resolve to the player's inventory. Expose that same represented
// object to the global cursor path so ordinary hover uses the
// DefaultFound cursor instead of the unlit Default cursor.
_inventoryButton.FoundObjectGuidProvider = () =>
_playerGuid?.Invoke() ?? _itemInteraction?.PlayerGuid ?? 0u;
// gmToolbarUI::HandleInventoryButtonDragOver @ 0x004BD180 uses the
// authored child overlay's Accept state (0x10000046 = 0x060011F7).
// UiButton owns the retained drop-target seam; the panel owns policy.

View file

@ -138,6 +138,15 @@ public sealed class UiStateInfo
/// </summary>
public int MediaCount;
/// <summary>
/// Number of image media entries in the effective retail state, including
/// an image whose file id is invalid/zero and therefore means
/// draw-nothing. This is deliberately separate from <see cref="MediaCount"/>:
/// a cursor-, sound-, or message-only state resets retail's media machine
/// but does not replace the image already installed on the element.
/// </summary>
public int ImageMediaCount;
public UiStateInfo Clone()
=> new()
{
@ -149,6 +158,7 @@ public sealed class UiStateInfo
Cursor = Cursor,
Properties = Properties.Clone(),
MediaCount = MediaCount,
ImageMediaCount = ImageMediaCount,
};
public static UiStateInfo Merge(UiStateInfo baseState, UiStateInfo derivedState)
@ -161,11 +171,9 @@ public sealed class UiStateInfo
Image = derivedState.Image ?? baseState.Image,
Cursor = derivedState.Cursor ?? baseState.Cursor,
Properties = UiPropertyBag.Merge(baseState.Properties, derivedState.Properties),
// Media arrays do not merge entry-wise in retail (a derived
// StateDesc replaces the base one); the derived count wins when
// the derived state authors ANY media, else the base's carries.
MediaCount = derivedState.MediaCount != 0
? derivedState.MediaCount
: baseState.MediaCount,
// StateDesc::Incorporate @0x0069CCA0 calls ConcatenateMedia;
// inherited media arrays append rather than replace one another.
MediaCount = baseState.MediaCount + derivedState.MediaCount,
ImageMediaCount = baseState.ImageMediaCount + derivedState.ImageMediaCount,
};
}

View file

@ -529,6 +529,8 @@ public sealed class RetailUiRuntime : IDisposable
// ChatInterface base-constructor values, 0x004F4550 — when no store is
// wired or nothing has been saved yet).
ChatSettings chatSettings = bindings.Chat.Store?.LoadChat() ?? ChatSettings.Default;
WindowLockPresentation = new RetailWindowLockPresentationController(
bindings.Host.Root.WindowManager);
WindowOpacity = new RetailWindowOpacityController(
bindings.Host.Root.WindowManager,
chatSettings.DefaultOpacity,
@ -643,6 +645,13 @@ public sealed class RetailUiRuntime : IDisposable
/// </summary>
public RetailWindowOpacityController WindowOpacity { get; }
/// <summary>
/// Global retail UI-lock chrome presenter. It is attached before the first
/// window mount so late registration observes the current lock state before
/// the window's first rendered frame.
/// </summary>
public RetailWindowLockPresentationController WindowLockPresentation { get; }
/// <summary>
/// Shared dat/sprite/font resolvers this runtime was built with.
/// Campaign CH user-gate round 3: lets a controller built OUTSIDE this
@ -903,7 +912,7 @@ public sealed class RetailUiRuntime : IDisposable
else if (_screenSizeSettling)
{
_screenSizeSettling = false;
_persistence?.RestoreAll(saveBack: false);
_persistence?.RestoreAfterDisplayChange();
}
Host.Draw(screenSize);
@ -4542,6 +4551,7 @@ public sealed class RetailUiRuntime : IDisposable
{
_characterSheetSubscription?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
WindowLockPresentation.Dispose();
WindowOpacity.Dispose();
if (SecureTradeController is { } trade)
{

View file

@ -48,6 +48,27 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
/// mid-drag reload would litter settings.json with intermediate-resolution
/// keys.</summary>
public void RestoreAll(bool saveBack = true)
=> RestoreAllCore(
saveBack,
restoreVisibility: true);
/// <summary>
/// Reloads the current resolution's saved geometry after a live display
/// change. Retail's <c>UI-&lt;char&gt;-&lt;world&gt;-&lt;W&gt;-&lt;H&gt;.txt</c>
/// records only X/Y/W/H; global message <c>0xE</c> therefore cannot hide
/// a window. Keeping live visibility is especially load-bearing for the Options window:
/// hiding Config while its Resolution row is still uncommitted invokes
/// <c>PlayerOptionPage::OnVisibilityChanged(false)</c> and restores the
/// previous resolution, producing a resize-out/resize-back blip.
/// </summary>
public void RestoreAfterDisplayChange()
=> RestoreAllCore(
saveBack: false,
restoreVisibility: false);
private void RestoreAllCore(
bool saveBack,
bool restoreVisibility)
{
ObjectDisposedException.ThrowIf(_disposed, this);
string character = _characterKey();
@ -72,7 +93,9 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
handle,
layout,
screen,
restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name));
restoreVisibility:
restoreVisibility
&& !_stateManagedVisibilityWindows.Contains(handle.Name));
// Lazily migrate legacy position-only entries into the complete schema.
if (saveBack)
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));

View file

@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
namespace AcDream.App.UI;
/// <summary>
/// Applies retail's global UI-lock presentation to every registered retained
/// window. Interaction remains owned by <see cref="UiRoot.UiLocked"/>; this
/// controller owns only the corresponding chrome swap.
/// </summary>
public sealed class RetailWindowLockPresentationController : IDisposable
{
private static readonly (uint LockedStart, uint LiveStart)[] AuthoredChromeBlocks =
[
(0x10000633u, 0x1000063Bu),
(0x10000643u, 0x1000064Bu),
(0x10000653u, 0x1000065Bu),
(0x10000663u, 0x1000066Bu),
(0x10000673u, 0x1000067Bu),
(0x10000683u, 0x1000068Bu),
(0x10000693u, 0x1000069Bu),
(0x100006A5u, 0x100006ADu),
];
private const uint SmartBoxLiveChromeStart = 0x100006CAu;
private readonly RetailWindowManager _manager;
private readonly Dictionary<RetailWindowHandle, WindowPresentation> _windows = new();
private bool _disposed;
public RetailWindowLockPresentationController(RetailWindowManager manager)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_manager.WindowRegistering += OnWindowRegistered;
_manager.WindowUnregistered += OnWindowUnregistered;
foreach (RetailWindowHandle handle in _manager.Windows)
Attach(handle);
}
private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle);
private void OnWindowUnregistered(RetailWindowHandle handle) => Detach(handle);
private void Attach(RetailWindowHandle handle)
{
if (_windows.ContainsKey(handle))
return;
var presentation = WindowPresentation.Capture(handle.OuterFrame);
_windows.Add(handle, presentation);
handle.LockChanged += OnLockChanged;
presentation.Apply(_manager.IsLocked);
}
private void Detach(RetailWindowHandle handle)
{
handle.LockChanged -= OnLockChanged;
_windows.Remove(handle);
}
private void OnLockChanged(RetailWindowHandle handle, bool locked)
{
if (_windows.TryGetValue(handle, out WindowPresentation? presentation))
presentation.Apply(locked);
}
private static bool IsAuthoredLockedChrome(uint id)
{
foreach ((uint start, _) in AuthoredChromeBlocks)
if (id >= start && id < start + 8u)
return true;
return false;
}
private static bool IsAuthoredLiveChrome(uint id)
{
foreach ((_, uint start) in AuthoredChromeBlocks)
if (id >= start && id < start + 8u)
return true;
return id >= SmartBoxLiveChromeStart && id < SmartBoxLiveChromeStart + 8u;
}
private sealed class WindowPresentation
{
private readonly List<UiElement> _authoredLockedChrome = new();
private readonly List<(UiElement Element, bool VisibleWhenUnlocked)> _liveChrome = new();
private readonly List<(UiNineSlicePanel Panel, bool VisibleWhenUnlocked)> _nineSlices = new();
public static WindowPresentation Capture(UiElement outerFrame)
{
var presentation = new WindowPresentation();
presentation.CaptureElement(outerFrame);
return presentation;
}
private void CaptureElement(UiElement element)
{
if (element is UiNineSlicePanel nineSlice)
_nineSlices.Add((nineSlice, nineSlice.DrawResizeAffordances));
if (IsAuthoredLockedChrome(element.DatElementId))
{
_authoredLockedChrome.Add(element);
}
else if (IsAuthoredLiveChrome(element.DatElementId) || element is UiResizeGrip)
{
_liveChrome.Add((element, element.Visible));
}
foreach (UiElement child in element.Children)
CaptureElement(child);
}
public void Apply(bool locked)
{
foreach (UiElement element in _authoredLockedChrome)
element.Visible = locked;
foreach ((UiElement element, bool visibleWhenUnlocked) in _liveChrome)
element.Visible = !locked && visibleWhenUnlocked;
foreach ((UiNineSlicePanel panel, bool visibleWhenUnlocked) in _nineSlices)
panel.DrawResizeAffordances = !locked && visibleWhenUnlocked;
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_manager.WindowRegistering -= OnWindowRegistered;
_manager.WindowUnregistered -= OnWindowUnregistered;
foreach (RetailWindowHandle handle in new List<RetailWindowHandle>(_windows.Keys))
Detach(handle);
}
}

View file

@ -43,6 +43,12 @@ public sealed class RetailWindowManager : IDisposable
/// </summary>
public event Action<RetailWindowHandle>? WindowRegistered;
/// <summary>
/// Internal pre-exposure hook for presentation state that must be correct
/// before a newly mounted window's controller receives <c>OnShown</c>.
/// </summary>
internal event Action<RetailWindowHandle>? WindowRegistering;
/// <summary>
/// Fires when a window is REMOVED from the registry (<see cref="Unregister"/>),
/// after teardown (<c>NotifyClosed</c>/<c>DisposeController</c>) but before the
@ -99,6 +105,7 @@ public sealed class RetailWindowManager : IDisposable
authoredGeometryRevision);
_byName.Add(name, handle);
_byFrame.Add(outerFrame, handle);
WindowRegistering?.Invoke(handle);
handle.NotifyInitialState();
WindowRegistered?.Invoke(handle);
return handle;
@ -258,6 +265,7 @@ public sealed class RetailWindowManager : IDisposable
_defaultInputs.Remove(handle);
handle.NotifyClosed();
handle.DisposeController();
WindowUnregistered?.Invoke(handle);
}
}

View file

@ -308,6 +308,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// </summary>
public bool SuppressSelfToggle { get; set; }
/// <summary>
/// Gives the owning controller exclusive responsibility for the rendered
/// state. Automatic pointer, selection, and enabled-state transitions no
/// longer replace <see cref="ActiveState"/>; input and click delivery remain
/// unchanged. This is used by buttons such as the radar UI lock whose face
/// represents durable application state rather than momentary interaction.
/// </summary>
public bool ControllerOwnsVisualState { get; set; }
public bool Selected
{
get => _selected;
@ -509,14 +518,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <summary>
/// Retail's SetState media rule (<c>UIElement::SetState @0x00464E70</c>
/// tail, the <c>m_media.m_num != 0</c> gate @0x004651c0): a committed
/// state replaces the playing media ONLY when its media array is
/// non-empty — an authored empty-media state keeps the PREVIOUS media
/// (why an empty <c>Normal_pressed</c> never blanks a Normal-art
/// button), while an authored File=0 draw-nothing image counts as media
/// and clears the face (#416: the roster-row bar children's base
/// state). An UNAUTHORED committed state runs retail's state-0 arm
/// against the base media array. Face segments model retail's
/// tail plus <c>MediaMachine::Update_Image @0x00465870</c>): a committed
/// state replaces the playing IMAGE only when its effective media array
/// contains image media. An empty state—or a cursor/sound/message-only
/// state—keeps the PREVIOUS image (why the toolbar inventory button's
/// inherited non-image <c>Normal_pressed</c> media never blanks its closed
/// backpack art), while an authored File=0 draw-nothing image clears the
/// face (#416: the roster-row bar children's base state). An UNAUTHORED
/// committed state runs retail's state-0 arm against the base media array.
/// Face segments model retail's
/// PassToChildren children, so each segment resolves the rule against
/// its OWN authored states. Synced lazily on the first draw after any
/// <see cref="ActiveState"/> write so every commit path (the visual
@ -553,7 +563,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
string current)
{
if (info.States.TryGetValue(committedId, out UiStateInfo? state))
return state.MediaCount != 0 ? committedName : current;
return HasImageMedia(info, state, committedName) ? committedName : current;
// Synthetic/test infos may carry StateMedia without States entries;
// a drawable entry for the committed name counts as authored media.
if (info.StateMedia.ContainsKey(committedName))
@ -562,10 +572,18 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// otherwise the previous media keeps playing.
if (info.States.TryGetValue(
UiStateInfo.DirectStateId, out UiStateInfo? baseState))
return baseState.MediaCount != 0 ? "" : current;
return HasImageMedia(info, baseState, "") ? "" : current;
return info.StateMedia.ContainsKey("") ? "" : current;
}
private static bool HasImageMedia(
ElementInfo info,
UiStateInfo state,
string stateName)
// StateMedia keeps committed pre-ImageMediaCount fixtures and compact
// synthetic tests faithful for ordinary non-zero image entries.
=> state.ImageMediaCount != 0 || info.StateMedia.ContainsKey(stateName);
/// <summary>
/// Returns the File id the media rule selected for this face; 0 draws
/// nothing (an authored File=0 image reaches this as a media-state whose
@ -1037,6 +1055,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private void UpdateVisualState()
{
if (ControllerOwnsVisualState)
return;
uint requested = ComputeRequestedStateId();
if (_hasCustomSelectionPair)
{

View file

@ -121,6 +121,16 @@ public abstract class UiElement
/// </summary>
public uint SourceLayoutDid { get; internal set; }
/// <summary>
/// Optional game object represented by this retained widget for retail's
/// global found-object cursor. Most widgets leave this unset. The toolbar
/// inventory/backpack button supplies the player object: retail treats that
/// button as the self/backpack target (the same target used by its click and
/// drop paths), so hovering it selects the Found cursor family even though
/// the authored element is a Button rather than a UIItem.
/// </summary>
public Func<uint>? FoundObjectGuidProvider { get; set; }
/// <summary>
/// #409: mirrors <c>ElementInfo.TooltipTextChildElementId</c> (dat
/// property <c>0x4A</c>). Meaningful only when read off a tooltip

View file

@ -29,6 +29,13 @@ public class UiNineSlicePanel : UiPanel
/// </summary>
public bool DrawCenterFill { get; set; } = true;
/// <summary>
/// Whether to paint the live gold resize edges and corner studs over the
/// ordinary retail bevel. UI lock disables only this affordance layer;
/// the frame and optional center fill remain visible.
/// </summary>
public bool DrawResizeAffordances { get; set; } = true;
public UiNineSlicePanel(System.Func<uint, (uint, int, int)> resolve)
{
_resolve = resolve;
@ -90,6 +97,9 @@ public class UiNineSlicePanel : UiPanel
DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL);
DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR);
if (!DrawResizeAffordances)
return;
// Resize-grip overlay (gold ridged edges + square corner studs) on top of the
// bevel — the second border layer the vitals LayoutDesc carries (0x1000063B0x10000642).
DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top);