using AcDream.Core.Combat; namespace AcDream.App.UI; /// /// 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. /// public enum CursorFeedbackKind { Default, Combat, Use, Examine, Busy, Text, WindowMove, ResizeHorizontal, ResizeVertical, ResizeDiagonalNwse, ResizeDiagonalNesw, Drag, DragAccept, DragReject, TargetPending, TargetValid, TargetInvalid, } /// Retail Target_Mode values consumed by UpdateCursorState. public enum RetailCursorTargetMode { None = 0, Use = 1, Examine = 2, UseTarget = 3, } /// /// The global/default cursor selected by ClientUISystem. Widget-local cursor /// media is a separate layer and may override this through CheckCursor. /// 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? _worldTargetProvider; private readonly Func _combatModeProvider; public CursorFeedbackController( ItemInteractionController? itemInteraction = null, Func? worldTargetProvider = null, Func? 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); 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; } /// /// 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. /// 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) { // 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. var capturedCursor = captured?.ActiveCursor() ?? default; if (capturedCursor.IsValid) return capturedCursor; var hoverCursor = hover?.ActiveCursor() ?? default; if (hoverCursor.IsValid) return hoverCursor; return default; } 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; } }