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;
}
}