Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The dragbar port (e4c99f54) armed the press path but the combat/spell
bar still would not move in the live client, and the move cursor kept
showing with the UI locked. Two distinct causes, both reported from
the user's connected session:
1. Snap-back: the combat/spell bar mounts ANCHORED (Left|Bottom), and
ApplyAnchor runs every frame before drawing children, recomputing
Left/Top from margins captured at mount. The drag wrote Left/Top and
the very next layout pass wrote them back - the window never visibly
moved. (The unit harness runs no per-frame layout, which is why the
original tests passed; unanchored windows like inventory never hit
this.) Interactive window moves AND resizes now re-baseline the
anchor capture on every applied change, and
RetailWindowManager.MoveTo/ResizeTo get the same rebase so
programmatic moves of anchored windows cannot be silently undone
either. ResetAnchorCapture is exactly the documented tool for this
("make the current geometry the new layout baseline after an
intentional change").
2. Locked cursor: the cursor the user saw was never the window-move
feedback path (which is lock-gated) - it was the dragbar's own
authored MD_Data_Cursor, revealed the moment the element began
claiming the pointer. Authored cursor resolution now suppresses a
WindowMoveHandle element's cursor while the UI is locked, matching
the radar's existing locked behavior of hiding its authored drag
affordance; movement itself was already gated.
Two inversion-sensitive regression tests: an anchored window dragged by
its handle must hold its position ACROSS an ApplyAnchor pass, and the
authored handle cursor must disappear when UiLocked flips on. App
Release suite 3,968 / 3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
365 lines
14 KiB
C#
365 lines
14 KiB
C#
using AcDream.Core.Combat;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// Semantic cursor state for the retail-look UI. Retail stores actual cursor art
|
|
/// as MediaDescCursor on UI states; this controller owns the behavior decision so
|
|
/// the visual backend can apply dat cursors without
|
|
/// changing item/window interaction code.
|
|
/// </summary>
|
|
public enum CursorFeedbackKind
|
|
{
|
|
Default,
|
|
Combat,
|
|
Use,
|
|
Examine,
|
|
Busy,
|
|
Text,
|
|
WindowMove,
|
|
ResizeHorizontal,
|
|
ResizeVertical,
|
|
ResizeDiagonalNwse,
|
|
ResizeDiagonalNesw,
|
|
Drag,
|
|
DragAccept,
|
|
DragReject,
|
|
TargetPending,
|
|
TargetValid,
|
|
TargetInvalid,
|
|
}
|
|
|
|
/// <summary>Retail <c>Target_Mode</c> values consumed by UpdateCursorState.</summary>
|
|
public enum RetailCursorTargetMode
|
|
{
|
|
None = 0,
|
|
Use = 1,
|
|
Examine = 2,
|
|
UseTarget = 3,
|
|
}
|
|
|
|
/// <summary>
|
|
/// The global/default cursor selected by ClientUISystem. Widget-local cursor
|
|
/// media is a separate layer and may override this through CheckCursor.
|
|
/// </summary>
|
|
public enum RetailGlobalCursorKind
|
|
{
|
|
Default,
|
|
DefaultFound,
|
|
MeleeOrMissile,
|
|
MeleeOrMissileFound,
|
|
Magic,
|
|
MagicFound,
|
|
Examine,
|
|
ExamineFound,
|
|
Use,
|
|
UseFound,
|
|
Busy,
|
|
BusyFound,
|
|
TargetPending,
|
|
TargetValid,
|
|
TargetInvalid,
|
|
}
|
|
|
|
public readonly record struct CursorFeedback(
|
|
CursorFeedbackKind Kind,
|
|
UiCursorMedia Cursor = default,
|
|
RetailGlobalCursorKind GlobalKind = RetailGlobalCursorKind.Default)
|
|
{
|
|
public static CursorFeedback Default { get; } = new(CursorFeedbackKind.Default);
|
|
}
|
|
|
|
public readonly record struct CursorFeedbackSnapshot(
|
|
object? DragPayload = null,
|
|
UiItemSlot.DragAcceptState DragAccept = UiItemSlot.DragAcceptState.None,
|
|
ResizeEdges ActiveResizeEdges = ResizeEdges.None,
|
|
ResizeEdges HoverResizeEdges = ResizeEdges.None,
|
|
bool WindowMoveActive = false,
|
|
bool HoverWindowMove = false,
|
|
bool HoverUi = false,
|
|
bool HoverTextEdit = false,
|
|
uint HoverTargetGuid = 0,
|
|
bool? HoverTargetCompatible = null,
|
|
int BusyCount = 0,
|
|
RetailCursorTargetMode TargetMode = RetailCursorTargetMode.None,
|
|
CombatMode CombatMode = CombatMode.NonCombat);
|
|
|
|
public sealed class CursorFeedbackController
|
|
{
|
|
private readonly ItemInteractionController? _itemInteraction;
|
|
private readonly Func<uint>? _worldTargetProvider;
|
|
private readonly Func<CombatMode> _combatModeProvider;
|
|
|
|
public CursorFeedbackController(
|
|
ItemInteractionController? itemInteraction = null,
|
|
Func<uint>? worldTargetProvider = null,
|
|
Func<CombatMode>? combatModeProvider = null)
|
|
{
|
|
_itemInteraction = itemInteraction;
|
|
_worldTargetProvider = worldTargetProvider;
|
|
_combatModeProvider = combatModeProvider ?? (() => CombatMode.NonCombat);
|
|
}
|
|
|
|
public CursorFeedback Current { get; private set; } = CursorFeedback.Default;
|
|
|
|
public CursorFeedback Update(UiRoot root)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(root);
|
|
|
|
UiElement? hover = root.Pick(root.MouseX, root.MouseY);
|
|
|
|
// Retail UpdateCursorState (0x00564630) keys the target-mode cursor off
|
|
// the SmartBox found object — the WORLD entity under the cursor. A UI
|
|
// window occludes the world (no found object → pending). The one
|
|
// UI-side source retail-style cells contribute is an occupied item
|
|
// slot's own item.
|
|
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
|
|
uint hoverTarget = hover is null
|
|
? _worldTargetProvider?.Invoke() ?? 0u
|
|
: targetMode == RetailCursorTargetMode.UseTarget
|
|
? FindHoveredItemSlot(hover)?.ItemId ?? 0u
|
|
: 0u;
|
|
bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget
|
|
&& hoverTarget != 0
|
|
? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget)
|
|
: null;
|
|
|
|
var snapshot = new CursorFeedbackSnapshot(
|
|
DragPayload: root.DragPayload,
|
|
DragAccept: FindHoveredItemSlot(hover)?.DragAcceptVisual ?? UiItemSlot.DragAcceptState.None,
|
|
ActiveResizeEdges: root.ActiveResizeEdges,
|
|
HoverResizeEdges: root.HoverResizeEdges,
|
|
WindowMoveActive: root.IsWindowMoveActive,
|
|
HoverWindowMove: root.HoverWindowMove,
|
|
HoverUi: hover is not null,
|
|
HoverTextEdit: hover?.IsEditControl == true,
|
|
HoverTargetGuid: hoverTarget,
|
|
HoverTargetCompatible: hoverTargetCompatible,
|
|
BusyCount: _itemInteraction?.BusyCount ?? 0,
|
|
TargetMode: targetMode,
|
|
CombatMode: _combatModeProvider());
|
|
|
|
var kind = ResolveKind(snapshot);
|
|
UiCursorMedia authoredCursor = ResolveCursor(root.Captured, hover, root.UiLocked);
|
|
UiCursorMedia cursor = ResolveEffectiveCursor(kind, snapshot, authoredCursor);
|
|
Current = new CursorFeedback(kind, cursor, ResolveGlobalKind(snapshot));
|
|
return Current;
|
|
}
|
|
|
|
public CursorFeedback Update(CursorFeedbackSnapshot snapshot)
|
|
{
|
|
Current = Resolve(snapshot);
|
|
return Current;
|
|
}
|
|
|
|
public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot)
|
|
{
|
|
CursorFeedbackKind kind = ResolveKind(snapshot);
|
|
return new(
|
|
kind,
|
|
ResolveEffectiveCursor(kind, snapshot, authoredCursor: default),
|
|
ResolveGlobalKind(snapshot));
|
|
}
|
|
|
|
private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot)
|
|
{
|
|
if (snapshot.DragPayload is not null)
|
|
{
|
|
return snapshot.DragAccept switch
|
|
{
|
|
UiItemSlot.DragAcceptState.Accept => CursorFeedbackKind.DragAccept,
|
|
UiItemSlot.DragAcceptState.Reject => CursorFeedbackKind.DragReject,
|
|
_ => CursorFeedbackKind.Drag,
|
|
};
|
|
}
|
|
|
|
ResizeEdges resizeEdges = snapshot.ActiveResizeEdges != ResizeEdges.None
|
|
? snapshot.ActiveResizeEdges
|
|
: snapshot.HoverResizeEdges;
|
|
if (resizeEdges != ResizeEdges.None)
|
|
return KindForResize(resizeEdges);
|
|
|
|
if (snapshot.WindowMoveActive)
|
|
return CursorFeedbackKind.WindowMove;
|
|
|
|
RetailCursorTargetMode targetMode = EffectiveTargetMode(snapshot);
|
|
if (targetMode == RetailCursorTargetMode.UseTarget)
|
|
{
|
|
if (snapshot.HoverTargetGuid != 0)
|
|
{
|
|
bool compatible = snapshot.HoverTargetCompatible
|
|
?? _itemInteraction?.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
|
|
?? false;
|
|
return compatible
|
|
? CursorFeedbackKind.TargetValid
|
|
: CursorFeedbackKind.TargetInvalid;
|
|
}
|
|
|
|
// Retail UpdateCursorState (0x00564630), TARGET_MODE 3: no found
|
|
// object → the 0x27 four-arrows pending cursor — INCLUDING over
|
|
// UI chrome. Valid/invalid exist only with a target under the
|
|
// cursor. (The earlier HoverUi → Invalid arm was a non-retail
|
|
// invention — 2026-07-03 visual gate.)
|
|
return CursorFeedbackKind.TargetPending;
|
|
}
|
|
|
|
if (snapshot.BusyCount > 0)
|
|
return CursorFeedbackKind.Busy;
|
|
|
|
if (targetMode == RetailCursorTargetMode.Use)
|
|
return CursorFeedbackKind.Use;
|
|
|
|
if (targetMode == RetailCursorTargetMode.Examine)
|
|
return CursorFeedbackKind.Examine;
|
|
|
|
if (snapshot.HoverWindowMove)
|
|
return CursorFeedbackKind.WindowMove;
|
|
|
|
if (snapshot.HoverTextEdit)
|
|
return CursorFeedbackKind.Text;
|
|
|
|
if (snapshot.CombatMode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic)
|
|
return CursorFeedbackKind.Combat;
|
|
|
|
return CursorFeedbackKind.Default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Faithful port of ClientUISystem::UpdateCursorState @ 0x00564630.
|
|
/// The found-object pair is selected first; busy then target mode then
|
|
/// combat mode replace it in exactly retail's order.
|
|
/// </summary>
|
|
internal RetailGlobalCursorKind ResolveGlobalKind(CursorFeedbackSnapshot snapshot)
|
|
{
|
|
bool found = snapshot.HoverTargetGuid != 0;
|
|
|
|
if (snapshot.BusyCount > 0)
|
|
return found ? RetailGlobalCursorKind.BusyFound : RetailGlobalCursorKind.Busy;
|
|
|
|
switch (EffectiveTargetMode(snapshot))
|
|
{
|
|
case RetailCursorTargetMode.Use:
|
|
return found ? RetailGlobalCursorKind.UseFound : RetailGlobalCursorKind.Use;
|
|
case RetailCursorTargetMode.Examine:
|
|
return found ? RetailGlobalCursorKind.ExamineFound : RetailGlobalCursorKind.Examine;
|
|
case RetailCursorTargetMode.UseTarget:
|
|
if (!found)
|
|
return RetailGlobalCursorKind.TargetPending;
|
|
bool compatible = snapshot.HoverTargetCompatible
|
|
?? _itemInteraction?.IsCurrentTargetCompatible(snapshot.HoverTargetGuid)
|
|
?? false;
|
|
return compatible
|
|
? RetailGlobalCursorKind.TargetValid
|
|
: RetailGlobalCursorKind.TargetInvalid;
|
|
case RetailCursorTargetMode.None:
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(snapshot), snapshot.TargetMode, "Unknown retail target mode.");
|
|
}
|
|
|
|
return snapshot.CombatMode switch
|
|
{
|
|
CombatMode.Melee or CombatMode.Missile => found
|
|
? RetailGlobalCursorKind.MeleeOrMissileFound
|
|
: RetailGlobalCursorKind.MeleeOrMissile,
|
|
CombatMode.Magic => found
|
|
? RetailGlobalCursorKind.MagicFound
|
|
: RetailGlobalCursorKind.Magic,
|
|
_ => found ? RetailGlobalCursorKind.DefaultFound : RetailGlobalCursorKind.Default,
|
|
};
|
|
}
|
|
|
|
private RetailCursorTargetMode EffectiveTargetMode(CursorFeedbackSnapshot snapshot)
|
|
=> snapshot.TargetMode == RetailCursorTargetMode.None
|
|
? ModeFromInteraction(_itemInteraction)
|
|
: snapshot.TargetMode;
|
|
|
|
private static RetailCursorTargetMode ModeFromInteraction(
|
|
ItemInteractionController? interaction)
|
|
=> interaction?.InteractionState.Current.Kind switch
|
|
{
|
|
InteractionModeKind.Use => RetailCursorTargetMode.Use,
|
|
InteractionModeKind.Examine => RetailCursorTargetMode.Examine,
|
|
InteractionModeKind.UseItemOnTarget => RetailCursorTargetMode.UseTarget,
|
|
_ => RetailCursorTargetMode.None,
|
|
};
|
|
|
|
private static CursorFeedbackKind KindForResize(ResizeEdges edges)
|
|
{
|
|
bool horizontal = (edges & (ResizeEdges.Left | ResizeEdges.Right)) != 0;
|
|
bool vertical = (edges & (ResizeEdges.Top | ResizeEdges.Bottom)) != 0;
|
|
|
|
if (horizontal && vertical)
|
|
{
|
|
bool nwse =
|
|
((edges & ResizeEdges.Left) != 0 && (edges & ResizeEdges.Top) != 0)
|
|
|| ((edges & ResizeEdges.Right) != 0 && (edges & ResizeEdges.Bottom) != 0);
|
|
return nwse ? CursorFeedbackKind.ResizeDiagonalNwse : CursorFeedbackKind.ResizeDiagonalNesw;
|
|
}
|
|
|
|
if (horizontal) return CursorFeedbackKind.ResizeHorizontal;
|
|
if (vertical) return CursorFeedbackKind.ResizeVertical;
|
|
return CursorFeedbackKind.Default;
|
|
}
|
|
|
|
private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover, bool uiLocked)
|
|
{
|
|
// UIElementManager::CheckCursor @ 0x0045ABF0: captured element first,
|
|
// then the last-entered element, then the ClientUISystem default. It
|
|
// does not search ancestors or synthesize a different UI state.
|
|
// One lock-state exception: a window-move handle (retail UIElement_Dragbar)
|
|
// whose movement is disabled by the UI lock must not advertise its authored
|
|
// move cursor — the same suppression the radar applies by hiding its
|
|
// authored drag button while locked.
|
|
var capturedCursor = AuthoredCursor(captured, uiLocked);
|
|
if (capturedCursor.IsValid)
|
|
return capturedCursor;
|
|
|
|
return AuthoredCursor(hover, uiLocked);
|
|
|
|
static UiCursorMedia AuthoredCursor(UiElement? element, bool locked)
|
|
=> element is null || (locked && element.WindowMoveHandle)
|
|
? default
|
|
: element.ActiveCursor();
|
|
}
|
|
|
|
private static UiCursorMedia ResolveEffectiveCursor(
|
|
CursorFeedbackKind kind,
|
|
CursorFeedbackSnapshot snapshot,
|
|
UiCursorMedia authoredCursor)
|
|
{
|
|
bool syntheticControlOwnsPointer = snapshot.ActiveResizeEdges != ResizeEdges.None
|
|
|| snapshot.HoverResizeEdges != ResizeEdges.None
|
|
|| snapshot.WindowMoveActive;
|
|
|
|
// Retail CheckCursor gives the hovered/captured Resizebar or captured
|
|
// Dragbar first refusal. Our wrapper borders are geometry, not UIElements,
|
|
// so a hovered synthetic resize edge or active synthetic control must take
|
|
// that same precedence over the content under its five-pixel hit region.
|
|
if (syntheticControlOwnsPointer
|
|
&& RetailCursorCatalog.TryGetWindowControlCursor(kind, out UiCursorMedia capturedControl))
|
|
return capturedControl;
|
|
|
|
// Ordinary imported elements retain their authored MD_Data_Cursor exactly.
|
|
if (authoredCursor.IsValid)
|
|
return authoredCursor;
|
|
|
|
// Synthetic wrapper edges and the IA-12 whole-window drag region have no
|
|
// authored child, but use retail's exact control media and hotspot.
|
|
return RetailCursorCatalog.TryGetWindowControlCursor(kind, out UiCursorMedia fallback)
|
|
? fallback
|
|
: default;
|
|
}
|
|
|
|
private static UiItemSlot? FindHoveredItemSlot(UiElement? element)
|
|
{
|
|
while (element is not null)
|
|
{
|
|
if (element is UiItemSlot slot)
|
|
return slot;
|
|
element = element.Parent;
|
|
}
|
|
return null;
|
|
}
|
|
}
|