acdream/src/AcDream.App/UI/CursorFeedbackController.cs
Erik 1bd2b30291
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s
fix(ui): restore retail vitals and window interactions
2026-08-20 13:26:35 +02:00

398 lines
15 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 EVERY mode's cursor off
// the SAME SmartBox found-object flag, computed once at the top of
// the function (ebx = SmartBox::get_found_object_id() != 0,
// @0x00564642) and read verbatim by every later branch — target mode
// and combat mode only pick WHICH cursor variant (Default vs.
// DefaultFound, Use vs. UseFound, ...) to show for that SAME found
// state, never whether it is set.
//
// #411 correction (2026-08-16): the found object itself is not
// world-only. UIElement_SmartBoxWrapper::FindObject @0x004E5430 runs
// every frame regardless of input focus (Global_Loop @0x004E5620)
// and, when the currently-hovered UI element (m_pElementLastOver)
// casts to UIElement_UIItem (0x10000032), calls
// SmartBox::set_found_object(itemID) directly — UNCONDITIONALLY, not
// gated on target mode — and returns WITHOUT running the 3D raycast.
// So hovering an occupied item cell sets the SAME found flag in
// EVERY mode (peace, melee/missile, magic, busy, examine, use,
// use-target), which is why retail's cursor visibly changes there —
// the earlier reading here (item slots only contribute in
// UseTarget) covered only the Valid/Invalid sub-branch, not the
// found flag driving the Default/Combat/Use/Examine/Busy Found
// variants too.
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
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)
: 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;
}
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;
}
}