Files
HSUI/Interface/GeneralElements/ActionBarsHud.cs
T
KnackAtNite 3b5ddbe2f9 Alliance frames, crafting tooltips, visibility: fix display and tooltips
- Alliance Frames: Populate other alliances (A/B) via GetAllianceMemberByIndex flat indices (0-7, 8-15) instead of GetAllianceMemberByGroupAndIndex which returns empty in-instance; keep own party from GetPartyMemberByIndex.
- Alliance Frames: Visibility: do not apply HideInDuty to Alliance Frames so they show in alliance raids when visibility rules are enabled.
- Hotbars: Crafting action tooltips: fallback Action sheet lookup with +100000 offset when hotbar stores CraftAction row ID.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 22:34:10 -05:00

1531 lines
77 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Game.Gui;
using HSUI.Config;
using HSUI.Enums;
using HSUI.Helpers;
using HSUI.Interface.Bars;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility;
using Dalamud.Utility;
using Lumina.Excel;
using Lumina.Excel.Sheets;
using Lumina.Text.ReadOnly;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LuminaAction = Lumina.Excel.Sheets.Action;
namespace HSUI.Interface.GeneralElements
{
public class ActionBarsHud : DraggableHudElement, IHudElementWithActor, IHudElementWithVisibilityConfig
{
private HotbarBarConfig Config => (HotbarBarConfig)_config;
private bool _wasDragging;
private uint _dragId;
private RaptureHotbarModule.HotbarSlotType _dragSlotType;
private int _pendingSlotIconIndex = -1;
private uint _pendingSlotIconId;
private int _pendingSlotIconFramesLeft;
/// <summary>When we process a game drop on a slot, we must suppress the InvisibleButton "click" that would trigger ExecuteSlot.</summary>
private int _lastFrameDroppedOnSlot = -1;
/// <summary>When we had a game drag and the user released, suppress all slot clicks this frame (release is not a click).</summary>
private bool _suppressSlotClicksAfterDragRelease;
private const string SlotPayloadType = "HSUI_HOTBAR_SLOT";
private const int SlotsPerBar = 12;
private static int _imGuiDragSourceSlotId = -1;
private static bool _anyHotbarAcceptedDrop;
private static int _lastOverlayFrame = -1;
/// <summary>Deferred clear when release-outside: set when no overlay accepted, executed next frame.</summary>
private static int _pendingReleaseOutsideSlotId = -1;
/// <summary>To avoid PICKUP log spam: only log when we first start dragging a new slot.</summary>
private static int _lastLoggedPickupSlotId = -1;
/// <summary>Encode hotbar (1-10) and slot (0-11) into internal slot id (0-119).</summary>
private static int ToSlotId(int hotbarIndex, int slotIndex)
{
int bar = Math.Clamp(hotbarIndex, 1, 10) - 1;
int slot = Math.Clamp(slotIndex, 0, 11);
return bar * SlotsPerBar + slot;
}
/// <summary>Decode internal slot id to hotbar (1-10) and slot (0-11). Returns (-1,-1) if invalid.</summary>
private static (int hotbarIndex, int slotIndex) FromSlotId(int slotId)
{
if (slotId < 0 || slotId >= 10 * SlotsPerBar) return (-1, -1);
return (slotId / SlotsPerBar + 1, slotId % SlotsPerBar);
}
[StructLayout(LayoutKind.Sequential)]
private struct SlotDragPayload
{
public int SlotId;
}
/// <summary>
/// Visibility for HSUI Action Bars is driven by Visibility → Hotbars (Hotbar 110), not the per-element config.
/// </summary>
public VisibilityConfig VisibilityConfig => GetHotbarVisibilityConfig();
private VisibilityConfig GetHotbarVisibilityConfig()
{
var hotbars = ConfigurationManager.Instance?.GetConfigObject<HotbarsVisibilityConfig>();
if (hotbars == null) return Config.VisibilityConfig;
var list = hotbars.GetHotbarConfigs();
int idx = Config.HotbarIndex - 1;
if (idx < 0 || idx >= list.Count) return Config.VisibilityConfig;
return list[idx];
}
public IGameObject? Actor { get; set; }
public ActionBarsHud(HotbarBarConfig config, string displayName) : base(config, displayName) { }
protected override (List<Vector2>, List<Vector2>) ChildrenPositionsAndSizes()
{
var size = BarSize();
return (new List<Vector2> { Config.Position }, new List<Vector2> { size });
}
private Vector2 BarSize()
{
var (cols, rows) = Config.GetLayoutGrid();
int effectiveCols = Math.Min(cols, Config.SlotCount);
int effectiveRows = (Config.SlotCount + effectiveCols - 1) / effectiveCols;
float w = effectiveCols * Config.SlotSize.X + (effectiveCols - 1) * Config.SlotPadding;
float h = effectiveRows * Config.SlotSize.Y + (effectiveRows - 1) * Config.SlotPadding;
return new Vector2(w, h);
}
private Vector2 GetSlotPosition(int slotIndex, Vector2 slotSize, int pad)
{
var (cols, _) = Config.GetLayoutGrid();
int col = slotIndex % cols;
int row = slotIndex / cols;
return new Vector2(col * (slotSize.X + pad), row * (slotSize.Y + pad));
}
public override void DrawChildren(Vector2 origin)
{
if (!Config.Enabled || Actor == null)
return;
if (ActionBarsManager.Instance == null)
return;
_lastFrameDroppedOnSlot = -1;
_suppressSlotClicksAfterDragRelease = false;
var barSize = BarSize();
var topLeft = Utils.GetAnchoredPosition(origin + Config.Position, barSize, Config.Anchor);
var slotSize = Config.SlotSize;
int pad = Config.SlotPadding;
bool showCd = Config.ShowCooldownOverlay;
bool showCdNumbers = Config.ShowCooldownNumbers;
bool showBorder = Config.ShowBorder;
AddDrawAction(Config.StrataLevel, () =>
{
HandleDragDrop(topLeft, barSize, slotSize, pad);
// Refresh slot data after placement so overwrite shows correctly
var slots = ActionBarsManager.Instance.GetSlotData(Config.HotbarIndex, Config.SlotCount);
void DrawBarContent(ImDrawListPtr drawList)
{
using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey))
{
for (int i = 0; i < slots.Count; i++)
{
var slot = slots[i];
uint displayIconId = slot.IconId;
bool isEmpty = slot.IsEmpty;
bool usePending = _pendingSlotIconIndex == i && _pendingSlotIconId != 0;
if (usePending)
{
displayIconId = _pendingSlotIconId;
isEmpty = false;
}
var slotOffset = GetSlotPosition(i, slotSize, pad);
var pos = topLeft + slotOffset;
var size = new Vector2(slotSize.X, slotSize.Y);
bool hovered = !isEmpty && ImGui.IsMouseHoveringRect(pos, pos + size);
if (isEmpty)
{
drawList.AddRectFilled(pos, pos + size, 0x22000000);
if (showBorder)
drawList.AddRect(pos, pos + size, 0xFF444444);
}
else
{
uint color = hovered ? 0xFFFFFFFF : (slot.IsUsable ? 0xFFFFFFFF : 0xAA888888);
bool useHighlightBorder = hovered && showBorder;
bool isComboNext = Config.ShowComboHighlight && IsComboNextAction(slot);
DrawHelper.DrawIcon(displayIconId, pos, size, showBorder && !useHighlightBorder && !isComboNext, color, drawList);
if (useHighlightBorder)
drawList.AddRect(pos, pos + size, 0xFF88CCFF, 0, ImDrawFlags.None, 2);
else if (isComboNext)
{
var ch = Config.ComboHighlightConfig;
DrawHelper.DrawComboHighlightRect(drawList, pos, pos + size,
ch.Color.Base, ch.Thickness, ch.ShowGlow, ch.LineStyle);
}
if (showCd && slot.CooldownPercent > 0 && _pendingSlotIconIndex != i)
{
float total = 100f;
float elapsed = total - slot.CooldownPercent;
DrawHelper.DrawIconCooldown(pos, size, elapsed, total, drawList);
if (showCdNumbers && slot.CooldownSecondsLeft > 0)
{
string cdText = slot.CooldownSecondsLeft.ToString();
Vector2 textSize = ImGui.CalcTextSize(cdText);
Vector2 textPos = pos + (size - textSize) * 0.5f;
DrawHelper.DrawOutlinedText(cdText, textPos, 0xFFFFFFFF, 0xFF000000, drawList, 1);
}
}
if (Config.ShowKeypressFlash)
{
float flashAlpha = InputsHelper.GetKeypressFlashAlpha(Config.HotbarIndex, i, 0.2);
if (flashAlpha > 0.001f)
{
byte a = (byte)(flashAlpha * 0.5f * 255);
drawList.AddRectFilled(pos, pos + size, (uint)(a << 24 | 0xFFFFFF));
}
}
}
if (Config.ShowSlotNumbers && !slot.IsEmpty)
{
string label = !string.IsNullOrWhiteSpace(slot.KeybindHint)
? slot.KeybindHint
: ActionBarsManager.GetDefaultKeybindLabel(Config.HotbarIndex, i);
Vector2 labelSize = ImGui.CalcTextSize(label);
Vector2 labelPos = new Vector2(pos.X + size.X - labelSize.X - 2, pos.Y + size.Y - labelSize.Y - 2);
DrawHelper.DrawOutlinedText(label, labelPos, 0xFFFFFFFF, 0xFF000000, drawList, 1);
}
if (Config.ShowChargeCount && !slot.IsEmpty && slot.MaxCharges > 1)
{
string chargeText = slot.CurrentCharges.ToString();
Vector2 chargeSize = ImGui.CalcTextSize(chargeText);
Vector2 chargePos = new Vector2(pos.X + 2, pos.Y + 2);
DrawHelper.DrawOutlinedText(chargeText, chargePos, 0xFFFFFFFF, 0xFF000000, drawList, 1);
}
}
}
}
if (IsGameDragging())
{
var mp = ImGui.GetMousePos();
float hole = 32f;
var cursorRect = new ClipRect(mp - new Vector2(hole), mp + new Vector2(hole));
var barRect = new ClipRect(topLeft, topLeft + barSize);
var inverted = ClipRectsHelper.GetInvertedClipRects(cursorRect);
var drawList = ImGui.GetWindowDrawList();
for (int i = 0; i < inverted.Length; i++)
{
var inter = barRect.Intersect(inverted[i]);
if (!inter.HasValue) continue;
var r = inter.Value;
ImGui.PushClipRect(r.Min, r.Max, false);
DrawBarContent(drawList);
ImGui.PopClipRect();
}
}
else
{
DrawHelper.DrawInWindow(ID + "_actionbars", topLeft, barSize, false, DrawBarContent);
}
});
// Overlay at HIGHEST so it captures input above the bar; required for icon swap.
// Use unique ID per hotbar to avoid any cross-bar conflicts (bar 210 had input capture issues).
AddDrawAction(StrataLevel.HIGHEST, () =>
{
var slots = ActionBarsManager.Instance.GetSlotData(Config.HotbarIndex, Config.SlotCount);
var flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground
| ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings
| ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoBringToFrontOnFocus
| ImGuiWindowFlags.NoNav;
ImGuiHelpers.ForceNextWindowMainViewport();
ImGui.SetNextWindowPos(topLeft);
ImGui.SetNextWindowSize(barSize);
string overlayId = $"HSUI_Hotbar{Config.HotbarIndex}_input";
if (!ImGui.Begin(overlayId, flags))
{
ImGui.End();
return;
}
int frame = ImGui.GetFrameCount();
if (frame != _lastOverlayFrame)
{
_lastOverlayFrame = frame;
_anyHotbarAcceptedDrop = false;
// Process deferred release-outside clear from previous frame (must run before overlays)
if (_pendingReleaseOutsideSlotId >= 0 && GetHotbarsConfig()?.GeneralOptions?.EnableReleaseOutsideToClear != false)
{
var (bar, slot) = FromSlotId(_pendingReleaseOutsideSlotId);
if (bar >= 1 && slot >= 0)
ActionBarsManager.Instance?.ClearSlot(bar, slot);
_pendingReleaseOutsideSlotId = -1;
}
}
bool acceptedDrop = false;
for (int i = 0; i < Config.SlotCount; i++)
{
var slotOffset = GetSlotPosition(i, slotSize, pad);
var slotPos = topLeft + slotOffset;
ImGui.SetCursorScreenPos(slotPos);
bool clicked = ImGui.InvisibleButton($"##{overlayId}_slot_{i}", slotSize);
bool isHovered = ImGui.IsItemHovered();
bool isActive = ImGui.IsItemActive();
bool shiftHeld = ImGui.GetIO().KeyShift;
int targetSlotId = ToSlotId(Config.HotbarIndex, i);
if (GetHotbarsConfig()?.GeneralOptions?.EnableShiftDragToRearrange != false && ImGui.BeginDragDropTarget())
{
var payload = ImGui.AcceptDragDropPayload(SlotPayloadType);
if (payload != ImGuiPayloadPtr.Null && payload.IsDataType(SlotPayloadType) && TryGetSlotPayload(payload, out var src))
{
acceptedDrop = true;
_anyHotbarAcceptedDrop = true;
_imGuiDragSourceSlotId = -1;
_pendingReleaseOutsideSlotId = -1;
_lastLoggedPickupSlotId = -1;
if (src.SlotId != targetSlotId)
{
var (srcBar, srcSlot) = FromSlotId(src.SlotId);
if (srcBar >= 1 && srcSlot >= 0)
{
ActionBarsManager.Instance?.SwapSlots(srcBar, srcSlot, Config.HotbarIndex, i,
Config.DebugDragDrop ? s => Plugin.Logger.Information($"[HSUI DragDrop DBG] {s}") : null);
}
}
}
ImGui.EndDragDropTarget();
}
if (GetHotbarsConfig()?.GeneralOptions?.EnableShiftDragToRearrange != false && !IsGameDragging() && shiftHeld && i < slots.Count && !slots[i].IsEmpty && isActive && ImGui.IsMouseDragging(ImGuiMouseButton.Left))
{
if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.None))
{
_imGuiDragSourceSlotId = targetSlotId;
if (Config.DebugDragDrop && _lastLoggedPickupSlotId != targetSlotId)
{
_lastLoggedPickupSlotId = targetSlotId;
Plugin.Logger.Information($"[HSUI DragDrop DBG] Shift+drag PICKUP: bar={Config.HotbarIndex} slot={i} slotId={targetSlotId}");
}
SetSlotPayload(new SlotDragPayload { SlotId = targetSlotId });
var icon = TexturesHelper.GetTextureFromIconId(slots[i].IconId);
if (icon != null)
ImGui.Image(icon.Handle, new Vector2(32, 32));
ImGui.EndDragDropSource();
}
}
if (clicked && !acceptedDrop && !ImGui.IsMouseDragging(ImGuiMouseButton.Left) && i < slots.Count && !slots[i].IsEmpty)
{
if (_suppressSlotClicksAfterDragRelease || i == _lastFrameDroppedOnSlot)
{
if (Config.DebugDragDrop)
Plugin.Logger.Information($"[HSUI DragDrop DBG] Suppressed ExecuteSlot slot={i} suppressAfterDrag={_suppressSlotClicksAfterDragRelease} lastDroppedOn={_lastFrameDroppedOnSlot}");
_suppressSlotClicksAfterDragRelease = false;
_lastFrameDroppedOnSlot = -1;
}
else if (shiftHeld)
{
ActionBarsManager.Instance?.ClearSlot(Config.HotbarIndex, i);
}
else
{
if (Config.DebugDragDrop)
Plugin.Logger.Information($"[HSUI DragDrop DBG] ExecuteSlot hotbar={Config.HotbarIndex} slot={i}");
ActionBarsManager.Instance?.ExecuteSlot(Config.HotbarIndex, i);
}
}
if (Config.ShowTooltips && isHovered && i < slots.Count)
{
var slot = slots[i];
if (!slot.IsEmpty)
{
var (title, text) = GetSlotTooltip(slot);
if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text))
{
string body = string.IsNullOrEmpty(text) ? title : text;
TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null);
if (IsTooltipDebugEnabled())
Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (main overlay): slot={i} title='{title}'");
}
}
}
}
// Release-outside clear: defer to next frame so we run after all overlays have had a chance to accept
if (GetHotbarsConfig()?.GeneralOptions?.EnableReleaseOutsideToClear != false && ImGui.GetIO().KeyShift && _imGuiDragSourceSlotId >= 0 &&
ImGui.IsMouseReleased(ImGuiMouseButton.Left) && !_anyHotbarAcceptedDrop)
{
if (Config.DebugDragDrop)
{
var (bar, slot) = FromSlotId(_imGuiDragSourceSlotId);
Plugin.Logger.Information($"[HSUI DragDrop DBG] Release outside: defer clear bar={bar} slot={slot}");
}
_pendingReleaseOutsideSlotId = _imGuiDragSourceSlotId;
_imGuiDragSourceSlotId = -1;
_lastLoggedPickupSlotId = -1;
}
ImGui.End();
});
}
/// <summary>Tooltip-only overlay when click overlay is skipped (proxy on / HUD not locked). Uses NoInputs so it doesn't capture clicks.</summary>
private void DrawTooltipOnlyOverlay(Vector2 topLeft, Vector2 barSize, Vector2 slotSize, int pad)
{
if (!Config.ShowTooltips)
return;
var slots = ActionBarsManager.Instance?.GetSlotData(Config.HotbarIndex, Config.SlotCount);
if (slots == null)
return;
var flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground
| ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings
| ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoBringToFrontOnFocus
| ImGuiWindowFlags.NoInputs;
ImGuiHelpers.ForceNextWindowMainViewport();
ImGui.SetNextWindowPos(topLeft);
ImGui.SetNextWindowSize(barSize);
if (!ImGui.Begin(ID + "_actionbars_tooltip_only", flags))
{
ImGui.End();
return;
}
for (int i = 0; i < Config.SlotCount && i < slots.Count; i++)
{
var slotOffset = GetSlotPosition(i, slotSize, pad);
var slotPos = topLeft + slotOffset;
if (ImGui.IsMouseHoveringRect(slotPos, slotPos + slotSize))
{
var slot = slots[i];
if (!slot.IsEmpty)
{
var (title, text) = GetSlotTooltip(slot);
if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text))
{
string body = string.IsNullOrEmpty(text) ? title : text;
TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null);
if (IsTooltipDebugEnabled())
Plugin.Logger.Information($"[HSUI Tooltip DBG] ActionBar tooltip (overlay): slot={i} title='{title}'");
}
}
break;
}
}
ImGui.End();
}
private static bool IsTooltipDebugEnabled()
{
try
{
return ConfigurationManager.Instance?.GetConfigObject<TooltipsConfig>()?.DebugTooltips == true;
}
catch { return false; }
}
private static (uint LastActionId, string? LastDesc, double LastTime) _tooltipDebugLogState;
private static bool ShouldLogTooltipDebug(uint actionId, string name, string? desc)
{
double now = ImGui.GetTime();
var (lastId, lastDesc, lastTime) = _tooltipDebugLogState;
if (actionId == lastId && desc == lastDesc && now - lastTime < 1f)
return false;
_tooltipDebugLogState = (actionId, desc, now);
return true;
}
private static bool TryGetDebug()
{
try
{
var configs = ConfigurationManager.Instance?.GetObjects<HotbarBarConfig>();
return configs != null && configs.Exists(c => c.DebugDragDrop);
}
catch { return false; }
}
private static (int Int1, int Int2, double LastTime) _lastItemPayloadLog;
private static bool ShouldLogItemPayload(int int1, int int2)
{
double now = ImGui.GetTime();
var last = _lastItemPayloadLog;
if (int1 == last.Int1 && int2 == last.Int2 && now - last.LastTime < 0.5)
return false;
_lastItemPayloadLog = (int1, int2, now);
return true;
}
private static (int Int1, int Int2, double LastTime) _lastMacroPayloadLog;
private static bool ShouldLogMacroPayload(int rawInt1, int rawInt2)
{
double now = ImGui.GetTime();
var last = _lastMacroPayloadLog;
if (rawInt1 == last.Int1 && rawInt2 == last.Int2 && now - last.LastTime < 2.0)
return false;
_lastMacroPayloadLog = (rawInt1, rawInt2, now);
return true;
}
private static HotbarsConfig? GetHotbarsConfig() =>
ConfigurationManager.Instance?.GetConfigObject<HotbarsConfig>();
private static (int Type, int Int1, int Int2, double LastTime) _lastDragNoPayloadLog;
private static unsafe bool ShouldLogDragNoPayload(AtkDragDropManager* dm)
{
if (dm == null) return false;
var dd = dm->DragDrop1;
int type = dd != null ? (int)dd->DragDropType : 0;
int i1 = dm->PayloadContainer.Int1;
int i2 = dm->PayloadContainer.Int2;
double now = ImGui.GetTime();
var last = _lastDragNoPayloadLog;
if (type == last.Type && i1 == last.Int1 && i2 == last.Int2 && now - last.LastTime < 0.5)
return false;
_lastDragNoPayloadLog = (type, i1, i2, now);
return true;
}
private static unsafe bool IsGameDragging()
{
var stage = AtkStage.Instance();
if (stage == null) return false;
var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager);
return dm->IsDragging;
}
private static unsafe bool TryGetSlotPayload(ImGuiPayloadPtr payload, out SlotDragPayload src)
{
if (payload.Data == null || payload.DataSize < sizeof(SlotDragPayload))
{
src = default;
return false;
}
src = *(SlotDragPayload*)payload.Data;
return true;
}
private static void SetSlotPayload(SlotDragPayload pl)
{
var type = new ImU8String(SlotPayloadType);
var data = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref pl, 1));
ImGui.SetDragDropPayload(type, data, ImGuiCond.None);
type.Recycle();
}
private static bool IsComboNextAction(ActionBarsManager.SlotInfo slot)
{
if (slot.IsEmpty || slot.ActionId == 0) return false;
try
{
var slotType = slot.SlotType;
var actionType = slotType switch
{
RaptureHotbarModule.HotbarSlotType.Action => ActionType.Action,
RaptureHotbarModule.HotbarSlotType.GeneralAction => ActionType.GeneralAction,
RaptureHotbarModule.HotbarSlotType.CraftAction => ActionType.CraftAction,
RaptureHotbarModule.HotbarSlotType.PetAction => ActionType.PetAction,
_ => (ActionType?)null
};
if (!actionType.HasValue) return false;
return SpellHelper.Instance.IsActionHighlighted(slot.ActionId, actionType.Value);
}
catch { return false; }
}
private unsafe void HandleDragDrop(Vector2 topLeft, Vector2 barSize, Vector2 slotSize, int pad)
{
bool debug = Config.DebugDragDrop;
// NOTE: Release-outside clear is done in the overlay only. HandleDragDrop runs before overlay
// processes AcceptDragDropPayload, so we must not clear here or we'd clear the source before
// the drop is processed (breaking swap/move when dropping on a slot).
if (_pendingSlotIconIndex >= 0 && _pendingSlotIconFramesLeft > 0)
{
_pendingSlotIconFramesLeft--;
if (_pendingSlotIconFramesLeft <= 0)
_pendingSlotIconIndex = -1;
}
else if (_pendingSlotIconIndex >= 0)
{
_pendingSlotIconIndex = -1;
}
var stage = AtkStage.Instance();
if (stage == null) return;
var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager);
bool dragging = dm->IsDragging;
if (dragging)
{
if (GetHotbarsConfig()?.GeneralOptions?.EnableDragDropFromGame != false && TryGetDragPayload(out var slotType, out var id))
{
_dragSlotType = slotType;
_dragId = id;
if (!_wasDragging && debug)
Plugin.Logger.Information($"[HSUI DragDrop] Game drag detected, payload: type={slotType} id={id}");
_wasDragging = true;
var mp = ImGui.GetMousePos();
int idx = GetSlotIndexAtPosition(topLeft, barSize, slotSize, pad, Config.SlotCount, Config.GetLayoutGrid().Cols, mp, false);
if (idx >= 0)
{
InputsHelper.SuppressExecuteSlotByIdAfterDrop((uint)(Config.HotbarIndex - 1), (uint)idx, 500);
}
if (ImGui.IsMouseReleased(ImGuiMouseButton.Left))
{
int releaseIdx = GetSlotIndexAtPosition(topLeft, barSize, slotSize, pad, Config.SlotCount, Config.GetLayoutGrid().Cols, mp, debug);
if (releaseIdx >= 0 && slotType != RaptureHotbarModule.HotbarSlotType.Empty && (id != 0 || slotType == RaptureHotbarModule.HotbarSlotType.Macro))
{
if (!ImGui.GetIO().KeyShift)
{
try
{
var module = RaptureHotbarModule.Instance();
if (module != null && module->ModuleReady)
{
uint barId = (uint)(Config.HotbarIndex - 1);
ref var displayBar = ref module->StandardHotbars[(int)barId];
var displaySlot = displayBar.GetHotbarSlot((uint)releaseIdx);
if (displaySlot != null)
{
displaySlot->Set(slotType, id);
displaySlot->LoadIconId();
}
ActionBarsManager.SetAndSaveSlotInternal(module, barId, (uint)releaseIdx, slotType, id, displaySlot);
uint iconId = GetIconIdForPayload(slotType, id);
if (iconId == 0)
{
var bar = module->StandardHotbars[(int)barId];
var writtenSlot = bar.GetHotbarSlot((uint)releaseIdx);
if (writtenSlot != null)
iconId = (uint)writtenSlot->IconId;
}
if (iconId != 0)
{
_pendingSlotIconIndex = releaseIdx;
_pendingSlotIconId = iconId;
_pendingSlotIconFramesLeft = 300;
}
_lastFrameDroppedOnSlot = releaseIdx;
InputsHelper.SuppressExecuteSlotByIdAfterDrop(barId, (uint)releaseIdx, 300);
if (slotType == RaptureHotbarModule.HotbarSlotType.Action ||
slotType == RaptureHotbarModule.HotbarSlotType.GeneralAction ||
slotType == RaptureHotbarModule.HotbarSlotType.CraftAction ||
slotType == RaptureHotbarModule.HotbarSlotType.PetAction)
InputsHelper.SuppressUseActionAfterDrop(id, 300);
if (debug)
Plugin.Logger.Information($"[HSUI DragDrop] Placed {slotType} id={id} on slot {releaseIdx}");
}
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI DragDrop] Drop failed: {ex.Message}");
}
}
try { dm->CancelDragDrop(true, true); }
catch { }
}
else
{
try { dm->CancelDragDrop(true, true); }
catch { }
}
_wasDragging = false;
}
}
else
{
_dragSlotType = RaptureHotbarModule.HotbarSlotType.Empty;
_dragId = 0;
_wasDragging = true;
if (ImGui.IsMouseReleased(ImGuiMouseButton.Left))
{
try { dm->CancelDragDrop(true, true); }
catch { }
_wasDragging = false;
}
if (debug && ShouldLogDragNoPayload(dm))
{
try
{
var dd = dm->DragDrop1;
if (dd != null)
Plugin.Logger.Information($"[HSUI DragDrop DBG] Drag but no payload (clearing cache): DragDropType={dd->DragDropType} Int1={dm->PayloadContainer.Int1} Int2={dm->PayloadContainer.Int2} HoveredItem={Plugin.GameGui?.HoveredItem ?? 0}");
}
catch { }
}
}
return;
}
if (_wasDragging)
{
_suppressSlotClicksAfterDragRelease = true;
var mp = ImGui.GetMousePos();
int idx = GetSlotIndexAtPosition(topLeft, barSize, slotSize, pad, Config.SlotCount, Config.GetLayoutGrid().Cols, mp, debug);
if (idx >= 0)
{
var slotType = _dragSlotType;
var id = _dragId;
// Only use payload we captured during the drag. Do NOT fall back to TryGetDragPayload here:
// after we eat LBUTTONUP, the game drag is cancelled and HoveredAction/HoveredItem can be
// stale (e.g. from macro menu UI), causing wrong placements (macro drag -> GeneralAction placed).
// If we never got valid payload (e.g. Macro Int2=0), skip the place.
bool hasValidPayload = slotType != RaptureHotbarModule.HotbarSlotType.Empty &&
(id != 0 || slotType == RaptureHotbarModule.HotbarSlotType.Macro);
if (hasValidPayload)
{
bool shiftHeld = ImGui.GetIO().KeyShift;
if (shiftHeld)
{
try { dm->CancelDragDrop(true, true); }
catch (Exception ex) { Plugin.Logger.Warning($"[HSUI DragDrop] CancelDragDrop: {ex.Message}"); }
ActionBarsManager.Instance?.ClearSlot(Config.HotbarIndex, idx);
if (debug)
Plugin.Logger.Information($"[HSUI DragDrop] Shift+release: cleared slot {idx}");
_lastFrameDroppedOnSlot = idx;
_wasDragging = false;
return;
}
try
{
var module = RaptureHotbarModule.Instance();
if (module != null && module->ModuleReady)
{
uint barId = (uint)(Config.HotbarIndex - 1);
ref var displayBar = ref module->StandardHotbars[(int)barId];
var displaySlot = displayBar.GetHotbarSlot((uint)idx);
if (displaySlot != null)
{
displaySlot->Set(slotType, id);
displaySlot->LoadIconId();
}
ActionBarsManager.SetAndSaveSlotInternal(module, barId, (uint)idx, slotType, id, displaySlot);
try { dm->CancelDragDrop(true, true); }
catch (Exception ex2) { Plugin.Logger.Warning($"[HSUI DragDrop] CancelDragDrop: {ex2.Message}"); }
uint iconId = GetIconIdForPayload(slotType, id);
if (iconId == 0)
{
var bar = module->StandardHotbars[(int)barId];
var writtenSlot = bar.GetHotbarSlot((uint)idx);
if (writtenSlot != null)
iconId = (uint)writtenSlot->IconId;
}
if (iconId != 0)
{
_pendingSlotIconIndex = idx;
_pendingSlotIconId = iconId;
_pendingSlotIconFramesLeft = 300;
}
_lastFrameDroppedOnSlot = idx;
InputsHelper.SuppressExecuteSlotByIdAfterDrop(barId, (uint)idx, 300);
if (slotType == RaptureHotbarModule.HotbarSlotType.Action ||
slotType == RaptureHotbarModule.HotbarSlotType.GeneralAction ||
slotType == RaptureHotbarModule.HotbarSlotType.CraftAction ||
slotType == RaptureHotbarModule.HotbarSlotType.PetAction)
InputsHelper.SuppressUseActionAfterDrop(id, 300);
if (debug)
Plugin.Logger.Information($"[HSUI DragDrop] Placed {slotType} id={id} on slot {idx}");
}
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI DragDrop] Drop processing failed: {ex.Message}\n{ex.StackTrace}");
}
}
else if (debug)
{
Plugin.Logger.Information($"[HSUI DragDrop] Release over slot {idx} but invalid payload: type={slotType} id={id}");
}
}
else if (debug)
{
Plugin.Logger.Information($"[HSUI DragDrop] Release outside bar area (idx={idx})");
}
_wasDragging = false;
}
else if (!dragging)
{
_wasDragging = false;
}
}
private const float DropMargin = 24f;
private static int GetSlotIndexAtPosition(Vector2 topLeft, Vector2 barSize, Vector2 slotSize, int pad, int slotCount, int cols, Vector2 pos, bool debug = false)
{
// topLeft and InvisibleButtons use the same coordinate system as ImGui.SetCursorScreenPos/GetMousePos.
// Use strict bar bounds (no extended reach) so only one hotbar matches when bars are stacked.
var viewport = ImGui.GetMainViewport();
Vector2 adjPos = pos - viewport.Pos;
int TryHit(Vector2 p)
{
float barMinX = topLeft.X;
float barMaxX = topLeft.X + barSize.X;
float barMinY = topLeft.Y;
float barMaxY = topLeft.Y + barSize.Y;
if (p.X < barMinX || p.X >= barMaxX || p.Y < barMinY || p.Y >= barMaxY)
return -1;
int best = -1;
float bestDistSq = float.MaxValue;
for (int i = 0; i < slotCount; i++)
{
int col = i % cols;
int row = i / cols;
float cx = topLeft.X + col * (slotSize.X + pad) + slotSize.X * 0.5f;
float cy = topLeft.Y + row * (slotSize.Y + pad) + slotSize.Y * 0.5f;
float dx = p.X - cx, dy = p.Y - cy;
float distSq = dx * dx + dy * dy;
float slotHalfW = slotSize.X * 0.5f + DropMargin;
float slotHalfH = slotSize.Y * 0.5f + DropMargin;
bool inSlot = System.Math.Abs(dx) <= slotHalfW && System.Math.Abs(dy) <= slotHalfH;
if (inSlot && distSq < bestDistSq) { bestDistSq = distSq; best = i; }
}
return best;
}
int idx = TryHit(pos);
if (idx < 0 && adjPos != pos)
idx = TryHit(adjPos);
if (debug)
Plugin.Logger.Information($"[HSUI DragDrop DBG] HitTest: rawMouse=({pos.X:F1},{pos.Y:F1}) viewport.Pos=({viewport.Pos.X:F1},{viewport.Pos.Y:F1}) adjPos=({adjPos.X:F1},{adjPos.Y:F1}) slotIdx={idx}");
return idx;
}
private static unsafe uint GetActiveHotbarClassJobId()
{
var module = RaptureHotbarModule.Instance();
if (module != null)
return module->ActiveHotbarClassJobId;
var player = Plugin.ObjectTable?.LocalPlayer;
return player != null ? (uint)player.ClassJob.RowId : 0;
}
/// <summary>
/// Gets drag payload from game/Dalamud. When game reports Item drag, prioritize HoveredItem
/// (HoveredAction can be stale from previous hotbar hover). Preserves full item id including HQ flag.
/// </summary>
private static unsafe bool TryGetDragPayload(out RaptureHotbarModule.HotbarSlotType slotType, out uint id)
{
slotType = RaptureHotbarModule.HotbarSlotType.Empty;
id = 0;
DragDropType gameType = DragDropType.Nothing;
try
{
var stage = AtkStage.Instance();
if (stage != null)
{
var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager);
var dd = dm->DragDrop1;
if (dd != null)
gameType = dd->DragDropType;
}
}
catch { }
bool isItemDrag = gameType is DragDropType.Item or DragDropType.Inventory_Item or DragDropType.RemoteInventory_Item
or DragDropType.ActionBar_Item or DragDropType.LetterEditor_Item;
bool isMacroDrag = gameType is DragDropType.Macro or DragDropType.ActionBar_Macro;
if (isMacroDrag)
{
try
{
var stage = AtkStage.Instance();
if (stage != null)
{
var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager);
int rawInt1 = dm->PayloadContainer.Int1;
int rawInt2 = dm->PayloadContainer.Int2;
// Int1=1 or 49 -> Individual (set 0), Int1=48 -> Shared (set 1). Int2 = 1-based macro index (1=first).
// CommandId: 1100 Individual, 101200 Shared. RaptureMacroModule set 0=Individual, set 1=Shared.
int set = (rawInt1 == 48) ? 1 : 0; // 48=Shared, else (1,49,...)=Individual
id = (uint)((set * 100) + rawInt2); // Int2 1-based: 1->id1, 2->id2, 3->id3
slotType = RaptureHotbarModule.HotbarSlotType.Macro;
if (TryGetDebug() && ShouldLogMacroPayload(rawInt1, rawInt2))
Plugin.Logger.Information($"[HSUI DragDrop DBG] Macro payload: Int1={rawInt1} Int2={rawInt2} -> set={set} idx={rawInt2} id={id}");
return id is >= 1 and <= 200;
}
}
catch { }
return false;
}
if (isItemDrag)
{
var hi = Plugin.GameGui?.HoveredItem ?? 0;
if (hi != 0)
{
id = (uint)(hi & 0xFFFFFFFF);
slotType = RaptureHotbarModule.HotbarSlotType.Item;
return true;
}
// HoveredItem can be 0 when game clears it; fallback: resolve from PayloadContainer (Int1=container, Int2=slot)
if (TryGetItemIdFromInventoryPayload(out id, TryGetDebug()))
{
slotType = RaptureHotbarModule.HotbarSlotType.Item;
return true;
}
return false;
}
if (TryGetDragPayloadFromGame(out slotType, out id))
return true;
return TryGetDragPayloadFromDalamud(out slotType, out id);
}
/// <summary>Map game PayloadContainer Int1 (agent/UI container id) to InventoryType.</summary>
private static InventoryType ContainerIdToInventoryType(int containerId)
{
return containerId switch
{
4 => InventoryType.EquippedItems,
7 => InventoryType.KeyItems,
48 => InventoryType.Inventory1,
49 => InventoryType.Inventory2,
50 => InventoryType.Inventory3,
51 => InventoryType.Inventory4,
52 => InventoryType.RetainerPage1,
53 => InventoryType.RetainerPage2,
54 => InventoryType.RetainerPage3,
55 => InventoryType.RetainerPage4,
56 => InventoryType.RetainerPage5,
57 => InventoryType.ArmoryMainHand,
58 => InventoryType.ArmoryHead,
59 => InventoryType.ArmoryBody,
60 => InventoryType.ArmoryHands,
61 => InventoryType.ArmoryLegs,
62 => InventoryType.ArmoryFeets,
63 => InventoryType.ArmoryOffHand,
64 => InventoryType.ArmoryEar,
65 => InventoryType.ArmoryNeck,
66 => InventoryType.ArmoryWrist,
67 => InventoryType.ArmoryRings,
68 => InventoryType.ArmorySoulCrystal,
69 => InventoryType.SaddleBag1,
70 => InventoryType.SaddleBag2,
71 => InventoryType.PremiumSaddleBag1,
72 => InventoryType.PremiumSaddleBag2,
_ => InventoryType.Invalid
};
}
/// <summary>Resolve visual (containerId, slotIndex) to real (invType, slot) for main inventory via ItemOrderModule.</summary>
private static unsafe bool TryResolveToRealSlot(int containerId, int visualSlot, out InventoryType realInvType, out int realSlot)
{
realInvType = InventoryType.Invalid;
realSlot = -1;
try
{
var iom = ItemOrderModule.Instance();
if (iom == null) return false;
var sorter = iom->InventorySorter;
if (sorter == null) return false;
int itemsPerPage = sorter->ItemsPerPage > 0 ? sorter->ItemsPerPage : 35;
int startIndex = containerId switch { 48 => 0, 49 => itemsPerPage, 50 => itemsPerPage * 2, 51 => itemsPerPage * 3, _ => -1 };
if (startIndex < 0) return false;
int displayIndex = startIndex + visualSlot;
if (displayIndex < 0 || displayIndex >= sorter->Items.LongCount) return false;
var entry = sorter->Items[displayIndex].Value;
if (entry == null) return false;
realInvType = InventoryType.Inventory1 + entry->Page;
realSlot = entry->Slot;
return true;
}
catch { return false; }
}
/// <summary>Resolve item ID from inventory when HoveredItem is 0. PayloadContainer Int1=container, Int2=slot.
/// Resolves through ItemOrderModule for main inventory (sorted bags) so we get the correct item.</summary>
private static unsafe bool TryGetItemIdFromInventoryPayload(out uint itemId, bool debug = false)
{
itemId = 0;
try
{
var stage = AtkStage.Instance();
if (stage == null) return false;
var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager);
int containerId = dm->PayloadContainer.Int1;
int slotIndex = dm->PayloadContainer.Int2;
InventoryType invType;
int slot;
if (containerId is 48 or 49 or 50 or 51 && TryResolveToRealSlot(containerId, slotIndex, out invType, out slot))
{
if (debug && ShouldLogItemPayload(containerId, slotIndex))
Plugin.Logger.Information($"[HSUI DragDrop DBG] Item payload: Int1={containerId} Int2={slotIndex} -> resolved to {invType} slot={slot}");
}
else
{
invType = ContainerIdToInventoryType(containerId);
slot = slotIndex;
if (invType == InventoryType.Invalid) return false;
}
var inv = InventoryManager.Instance();
if (inv == null) return false;
var item = inv->GetInventorySlot(invType, slot);
if (item == null || item->IsEmpty()) return false;
itemId = item->GetItemId();
if (debug && itemId != 0 && ShouldLogItemPayload(containerId, slotIndex))
Plugin.Logger.Information($"[HSUI DragDrop DBG] Item from inventory: Int1={containerId} Int2={slotIndex} -> {invType} slot={slot} itemId={itemId}");
return itemId != 0;
}
catch { return false; }
}
private static unsafe bool TryGetDragPayloadFromGame(out RaptureHotbarModule.HotbarSlotType slotType, out uint id)
{
slotType = RaptureHotbarModule.HotbarSlotType.Empty;
id = 0;
try
{
var stage = AtkStage.Instance();
if (stage == null) return false;
var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager);
var dd = dm->DragDrop1;
if (dd == null) return false;
slotType = UIGlobals.GetHotbarSlotTypeFromDragDropType(dd->DragDropType);
id = (uint)dm->PayloadContainer.Int2;
return slotType != RaptureHotbarModule.HotbarSlotType.Empty;
}
catch { return false; }
}
private static bool TryGetDragPayloadFromDalamud(out RaptureHotbarModule.HotbarSlotType slotType, out uint id)
{
slotType = RaptureHotbarModule.HotbarSlotType.Empty;
id = 0;
var ha = Plugin.GameGui?.HoveredAction;
if (ha != null && ha.ActionKind != HoverActionKind.None && ha.ActionID != 0)
{
slotType = GetHotbarSlotTypeFromHoverActionKind(ha.ActionKind);
if (slotType != RaptureHotbarModule.HotbarSlotType.Empty)
{
id = ha.ActionID;
return true;
}
}
var hi = Plugin.GameGui?.HoveredItem ?? 0;
if (hi != 0)
{
id = (uint)(hi & 0xFFFFFFFF);
slotType = RaptureHotbarModule.HotbarSlotType.Item;
return true;
}
return false;
}
/// <summary>Maps HoverActionKind to HotbarSlotType for tooltip lookups. Returns Empty if not supported.</summary>
public static RaptureHotbarModule.HotbarSlotType GetHotbarSlotTypeForHover(HoverActionKind kind) =>
GetHotbarSlotTypeFromHoverActionKind(kind);
/// <summary>
/// Gets tooltip (title, text, iconId) for an action when hovered in chat or elsewhere.
/// Used by global tooltip to show action details when hovering over linked actions in chat.
/// </summary>
public static (string title, string text, uint iconId)? GetActionTooltipForHover(RaptureHotbarModule.HotbarSlotType slotType, uint actionId)
{
if (actionId == 0) return null;
var slot = new ActionBarsManager.SlotInfo(0, false, true, 0, 0, actionId, slotType, "", 0, 0);
var (title, text) = GetSlotTooltip(slot);
if (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(text)) return null;
uint iconId = GetIconIdForPayload(slotType, actionId);
return (title, string.IsNullOrEmpty(text) ? title : text, iconId);
}
private static RaptureHotbarModule.HotbarSlotType GetHotbarSlotTypeFromHoverActionKind(HoverActionKind kind)
{
return kind switch
{
HoverActionKind.Action => RaptureHotbarModule.HotbarSlotType.Action,
HoverActionKind.CraftingAction => RaptureHotbarModule.HotbarSlotType.CraftAction,
HoverActionKind.GeneralAction => RaptureHotbarModule.HotbarSlotType.GeneralAction,
HoverActionKind.MainCommand => RaptureHotbarModule.HotbarSlotType.MainCommand,
HoverActionKind.ExtraCommand => RaptureHotbarModule.HotbarSlotType.ExtraCommand,
HoverActionKind.Companion => RaptureHotbarModule.HotbarSlotType.Companion,
HoverActionKind.PetOrder => RaptureHotbarModule.HotbarSlotType.PetAction,
HoverActionKind.BuddyAction => RaptureHotbarModule.HotbarSlotType.BuddyAction,
HoverActionKind.Mount => RaptureHotbarModule.HotbarSlotType.Mount,
HoverActionKind.BgcArmyAction => RaptureHotbarModule.HotbarSlotType.BgcArmyAction,
HoverActionKind.Perform => RaptureHotbarModule.HotbarSlotType.PerformanceInstrument,
HoverActionKind.Ornament => RaptureHotbarModule.HotbarSlotType.Ornament,
HoverActionKind.Glasses => RaptureHotbarModule.HotbarSlotType.Glasses,
HoverActionKind.MYCTemporaryItem => RaptureHotbarModule.HotbarSlotType.LostFindsItem,
HoverActionKind.QuickChat => RaptureHotbarModule.HotbarSlotType.PvPQuickChat,
HoverActionKind.ActionComboRoute => RaptureHotbarModule.HotbarSlotType.PvPCombo,
_ => RaptureHotbarModule.HotbarSlotType.Empty
};
}
private static unsafe uint GetIconIdForPayload(RaptureHotbarModule.HotbarSlotType slotType, uint id)
{
if (id == 0) return 0;
if (slotType == RaptureHotbarModule.HotbarSlotType.Macro)
{
var macroModule = RaptureMacroModule.Instance();
if (macroModule != null && id is >= 1 and <= 200)
{
uint set = (id - 1) / 100;
uint index1Based = ((id - 1) % 100) + 1; // GetMacro expects 1-based index
var macro = macroModule->GetMacro(set, index1Based);
if (macro != null)
return macro->IconId;
}
return 0;
}
var module = RaptureHotbarModule.Instance();
if (module != null && module->ModuleReady)
{
var bar = module->StandardHotbars[0];
var slot = bar.GetHotbarSlot(0);
if (slot != null)
{
int gameIcon = slot->GetIconIdForSlot(slotType, id);
if (gameIcon > 0) return (uint)gameIcon;
}
}
try
{
switch (slotType)
{
case RaptureHotbarModule.HotbarSlotType.GeneralAction:
var gaRow = Plugin.DataManager.GetExcelSheet<GeneralAction>()?.GetRow(id);
return gaRow.HasValue ? (uint)gaRow.Value.Icon : 0;
case RaptureHotbarModule.HotbarSlotType.Action:
case RaptureHotbarModule.HotbarSlotType.CraftAction:
case RaptureHotbarModule.HotbarSlotType.PetAction:
var action = Plugin.DataManager.GetExcelSheet<LuminaAction>()?.GetRow(id);
return action.HasValue ? (uint)action.Value.Icon : 0;
case RaptureHotbarModule.HotbarSlotType.Item:
uint baseItemId = id >= 1000000 ? id - 1000000 : id;
var item = Plugin.DataManager.GetExcelSheet<Item>()?.GetRow(baseItemId);
return item.HasValue ? (uint)item.Value.Icon : 0;
case RaptureHotbarModule.HotbarSlotType.Mount:
var mount = Plugin.DataManager.GetExcelSheet<Mount>()?.GetRow(id);
return mount.HasValue ? (uint)mount.Value.Icon : 0;
case RaptureHotbarModule.HotbarSlotType.Companion:
var companion = Plugin.DataManager.GetExcelSheet<Companion>()?.GetRow(id);
return companion.HasValue ? (uint)companion.Value.Icon : 0;
case RaptureHotbarModule.HotbarSlotType.Emote:
var emote = Plugin.DataManager.GetExcelSheet<Emote>()?.GetRow(id);
return emote.HasValue ? (uint)emote.Value.Icon : 0;
default:
return 0;
}
}
catch { return 0; }
}
private static unsafe (string title, string text) GetSlotTooltip(ActionBarsManager.SlotInfo slot)
{
// GeneralAction uses a different ID space: GeneralAction 7 = Teleport links to Action 5. Look up via GeneralAction sheet.
if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.GeneralAction)
{
if (Plugin.DataManager.GetExcelSheet<GeneralAction>()?.TryGetRow(slot.ActionId, out var gaRow) == true)
{
string name = gaRow.Name.ToString();
string desc = "";
try
{
string descRaw = gaRow.Description.ToDalamudString().ToString();
if (!string.IsNullOrEmpty(descRaw))
{
try
{
var evaluated = Plugin.SeStringEvaluator.Evaluate(gaRow.Description.AsSpan());
desc = evaluated.ExtractText();
if (string.IsNullOrEmpty(desc)) desc = descRaw;
}
catch { desc = descRaw; }
if (!string.IsNullOrEmpty(desc))
desc = EncryptedStringsHelper.GetString(desc);
}
}
catch { /* ignore */ }
string statsLine = "";
if (gaRow.Action.RowId != 0)
{
var actionRow = Plugin.DataManager.GetExcelSheet<LuminaAction>()?.GetRow(gaRow.Action.RowId);
if (actionRow.HasValue)
statsLine = TryGetActionStatsLine(actionRow.Value, includeRangeAndRadius: false);
}
if (!string.IsNullOrEmpty(statsLine))
desc = string.IsNullOrEmpty(desc) ? statsLine : statsLine + "\n\n" + desc;
return (name, desc ?? "");
}
}
if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Action ||
slot.SlotType == RaptureHotbarModule.HotbarSlotType.CraftAction ||
slot.SlotType == RaptureHotbarModule.HotbarSlotType.PetAction)
{
uint actionIdForLookup = slot.ActionId;
LuminaAction? actionRow = null;
var actionSheet = Plugin.DataManager.GetExcelSheet<LuminaAction>();
var actionRowOpt = actionSheet?.GetRow(slot.ActionId);
if (actionRowOpt.HasValue)
{
actionRow = actionRowOpt.Value;
}
else if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.CraftAction)
{
// Crafting actions: hotbar may store CraftAction sheet row ID (1-based). Action sheet uses 100000+ range (100001 = Basic Synthesis).
const uint CraftActionToActionOffset = 100000;
uint mappedId = slot.ActionId + CraftActionToActionOffset;
var mappedRow = actionSheet?.GetRow(mappedId);
if (mappedRow.HasValue)
{
actionIdForLookup = mappedId;
actionRow = mappedRow.Value;
}
}
var row = actionRow;
if (row.HasValue)
{
string name = row.Value.Name.ToString();
string desc = "";
var descRow = Plugin.DataManager.GetExcelSheet<ActionTransient>()?.GetRow(actionIdForLookup);
string descRaw = "";
if (descRow.HasValue)
{
try
{
var descSeStr = descRow.Value.Description;
descRaw = descRow.Value.Description.ToDalamudString().ToString();
try
{
var evaluated = Plugin.SeStringEvaluator.Evaluate(descSeStr.AsSpan());
desc = evaluated.ExtractText();
if (string.IsNullOrEmpty(desc)) desc = descRaw;
}
catch
{
desc = descRaw;
}
if (!string.IsNullOrEmpty(desc))
desc = EncryptedStringsHelper.GetString(desc);
}
catch { /* ignore */ }
}
bool isCombatAction = IsCombatAction(slot.SlotType, row.Value);
int? potencyValue = isCombatAction ? TryGetPotencyValue(row.Value) : null;
string potencyLine = potencyValue.HasValue ? $"Potency: {potencyValue.Value}" : "";
string statsLine = TryGetActionStatsLine(row.Value, includeRangeAndRadius: isCombatAction);
if (!string.IsNullOrEmpty(desc) && potencyValue.HasValue)
{
string before = desc;
desc = ReplacePotencyPlaceholderInDesc(desc, potencyValue.Value);
if (IsTooltipDebugEnabled() && before != desc)
Plugin.Logger.Information($"[HSUI Tooltip DBG] Replaced potency placeholder: actionId={slot.ActionId} value={potencyValue.Value}");
}
else if (IsTooltipDebugEnabled() && !string.IsNullOrEmpty(desc) && desc.Contains("potency of", StringComparison.OrdinalIgnoreCase))
Plugin.Logger.Information($"[HSUI Tooltip DBG] Potency placeholder NOT replaced: actionId={slot.ActionId} potencyValue={potencyValue?.ToString() ?? "null"} descSnippet='{(desc.Length > 60 ? desc[..60] + "..." : desc)}'");
var statsParts = new List<string>();
if (!string.IsNullOrEmpty(potencyLine)) statsParts.Add(potencyLine);
if (!string.IsNullOrEmpty(statsLine)) statsParts.Add(statsLine);
if (statsParts.Count > 0)
desc = string.IsNullOrEmpty(desc) ? string.Join("\n", statsParts) : string.Join("\n", statsParts) + "\n\n" + desc;
if (IsTooltipDebugEnabled() && ShouldLogTooltipDebug(slot.ActionId, name, desc))
Plugin.Logger.Information($"[HSUI Tooltip DBG] GetSlotTooltip actionId={slot.ActionId} name='{name}' descRawLen={descRaw?.Length ?? 0} descLen={desc?.Length ?? 0} descRawPreview='{(descRaw?.Length > 0 ? (descRaw.Length > 50 ? descRaw[..50] + "..." : descRaw) : "(empty)")}' fullBodyPreview='{(desc != null && desc.Length > 100 ? desc[..100] + "..." : desc ?? "")}'");
return (name, desc ?? "");
}
}
if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Macro)
{
try
{
var macroModule = RaptureMacroModule.Instance();
if (macroModule != null && slot.ActionId is >= 1 and <= 200)
{
uint set = (slot.ActionId - 1) / 100;
uint index1Based = ((slot.ActionId - 1) % 100) + 1; // GetMacro expects 1-based index
var macro = macroModule->GetMacro(set, index1Based);
if (macro != null)
{
string name = macro->Name.ToString();
if (string.IsNullOrWhiteSpace(name))
name = (set == 0 ? "Individual" : "Shared") + " Macro " + index1Based;
var sb = new System.Text.StringBuilder();
var lineCount = (int)macroModule->GetLineCount(macro);
for (int i = 0; i < lineCount && i < 15; i++)
{
ref var line = ref macro->Lines[i];
if (!line.IsEmpty)
{
if (sb.Length > 0) sb.Append('\n');
sb.Append(line.ToString());
}
}
string desc = sb.ToString();
return (name, desc);
}
}
}
catch { }
return ("Macro", "");
}
if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Mount)
{
var row = Plugin.DataManager.GetExcelSheet<Mount>()?.GetRow(slot.ActionId);
if (row.HasValue)
{
string name = row.Value.Singular.ToString();
string desc = "";
try
{
var transient = Plugin.DataManager.GetExcelSheet<MountTransient>()?.GetRow(slot.ActionId);
if (transient.HasValue)
{
string descRaw = transient.Value.Description.ToDalamudString().ToString();
if (!string.IsNullOrEmpty(descRaw))
desc = EncryptedStringsHelper.GetString(descRaw);
}
}
catch { /* ignore */ }
return (name, desc);
}
}
if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Companion)
{
var row = Plugin.DataManager.GetExcelSheet<Companion>()?.GetRow(slot.ActionId);
if (row.HasValue)
{
string name = row.Value.Singular.ToString();
string desc = "";
try
{
var transient = Plugin.DataManager.GetExcelSheet<CompanionTransient>()?.GetRow(slot.ActionId);
if (transient.HasValue)
{
string descRaw = transient.Value.Description.ToDalamudString().ToString();
if (!string.IsNullOrEmpty(descRaw))
desc = EncryptedStringsHelper.GetString(descRaw);
}
}
catch { /* ignore */ }
return (name, desc);
}
}
if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Emote)
{
var row = Plugin.DataManager.GetExcelSheet<Emote>()?.GetRow(slot.ActionId);
if (row.HasValue)
return (row.Value.Name.ToString(), "");
}
if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.Item)
{
var row = Plugin.DataManager.GetExcelSheet<Item>()?.GetRow(slot.ActionId);
if (row.HasValue)
{
string name = row.Value.Name.ToString();
try
{
string desc = row.Value.Description.ToDalamudString().ToString();
if (!string.IsNullOrEmpty(desc))
desc = EncryptedStringsHelper.GetString(desc);
return (name, desc);
}
catch
{
return (name, "");
}
}
}
if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.MainCommand ||
slot.SlotType == RaptureHotbarModule.HotbarSlotType.ExtraCommand)
{
if (Plugin.DataManager.GetExcelSheet<MainCommand>()?.TryGetRow(slot.ActionId, out var mcRow) == true)
{
string name = mcRow.Name.ToString();
string desc = "";
try
{
string descRaw = mcRow.Description.ToDalamudString().ToString();
if (!string.IsNullOrEmpty(descRaw))
{
try
{
var evaluated = Plugin.SeStringEvaluator.Evaluate(mcRow.Description.AsSpan());
desc = evaluated.ExtractText();
if (string.IsNullOrEmpty(desc)) desc = descRaw;
}
catch { desc = descRaw; }
if (!string.IsNullOrEmpty(desc))
desc = EncryptedStringsHelper.GetString(desc);
}
}
catch { /* ignore */ }
return (name, desc ?? "");
}
}
return (slot.SlotType.ToString(), "");
}
private static int? TryGetPotencyValue(LuminaAction action)
{
try
{
var t = typeof(LuminaAction);
foreach (var name in new[] { "AttackPotency", "Effect1", "Effect2", "Effect3", "PrimaryEffect", "SecondaryEffect" })
{
var prop = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
if (prop == null) continue;
var v = prop.GetValue(action);
if (v is int i && i > 0 && i <= 5000) return i;
if (v is uint u && u > 0 && u <= 5000) return (int)u;
}
// Fallback: try Omen row - potency may be stored there for weaponskills
try
{
var omenRef = action.Omen;
uint omenRowId = omenRef.RowId;
if (omenRowId != 0)
{
var omenSheet = Plugin.DataManager.GetExcelSheet<Omen>();
if (omenSheet != null && omenSheet.GetRow(omenRowId) is { } omenRow)
{
var ot = typeof(Omen);
foreach (var pname in new[] { "Effect1", "Effect2", "Effect3", "AttackPotency" })
{
var op = ot.GetProperty(pname, BindingFlags.Public | BindingFlags.Instance);
if (op == null) continue;
var ov = op.GetValue(omenRow);
if (ov is int oi && oi >= 50 && oi <= 2000) return oi;
if (ov is uint ou && ou >= 50 && ou <= 2000) return (int)ou;
}
}
}
}
catch { /* ignore */ }
// Last resort: scan Action int/uint properties
foreach (var prop in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (prop.PropertyType != typeof(int) && prop.PropertyType != typeof(uint)) continue;
var name = prop.Name;
if (name.Contains("Id") || name.Contains("Index") || name.Contains("Time") ||
name.Contains("Range") || name.Contains("Radius") || name.Contains("Cost") ||
name.Contains("Level") || name.Contains("Category") || name.Contains("Cast") ||
name.Contains("Recast") || name.Contains("Omen"))
continue;
var v = prop.GetValue(action);
if (v is int i && i >= 50 && i <= 2000) return i;
if (v is uint u && u >= 50 && u <= 2000) return (int)u;
}
}
catch { /* ignore */ }
return null;
}
private static string ReplacePotencyPlaceholderInDesc(string desc, int potencyValue)
{
if (string.IsNullOrEmpty(desc)) return desc;
string replaced = desc;
// Game uses placeholder that can render as ".", "?", etc. Try common patterns
replaced = System.Text.RegularExpressions.Regex.Replace(replaced,
@"(potency of\s+)[^0-9]{1,4}",
$"$1{potencyValue}",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (replaced == desc && desc.Contains("potency of .", StringComparison.OrdinalIgnoreCase))
{
int idx = desc.IndexOf("potency of .", StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
replaced = desc[..(idx + 10)] + potencyValue + desc[(idx + 11)..];
}
return replaced;
}
/// <summary>Returns true if this action should show combat stats (potency, range, radius). False for Teleport, Return, Sprint, etc.</summary>
private static bool IsCombatAction(RaptureHotbarModule.HotbarSlotType slotType, LuminaAction action)
{
// GeneralAction: Teleport, Return, Sprint, Limit Break - never show combat stats
if (slotType == RaptureHotbarModule.HotbarSlotType.GeneralAction)
return false;
// CraftAction: crafting actions have cast/recast but no combat range/radius
if (slotType == RaptureHotbarModule.HotbarSlotType.CraftAction)
return false;
// PetAction: usually combat, but some pets have non-combat skills - keep combat stats for now
// Action: check ActionCategory - category 4 (General) and similar are non-combat
try
{
var catRef = action.ActionCategory;
uint rowId = catRef.RowId;
if (rowId is 4 or 5) return false; // General, Other
}
catch { /* ignore */ }
return true;
}
private static string TryGetActionStatsLine(LuminaAction action, bool includeRangeAndRadius = true)
{
try
{
var parts = new List<string>();
float castSec = action.Cast100ms / 10f; // Cast100ms/Recast100ms are in 100ms units
float recastSec = action.Recast100ms / 10f;
if (castSec > 0)
parts.Add($"Cast: {castSec:F1}s");
else
parts.Add("Cast: Instant");
if (recastSec > 0)
parts.Add($"Recast: {recastSec:F1}s");
if (includeRangeAndRadius)
{
int range = action.Range;
int effectRange = action.EffectRange;
if (range > 0)
parts.Add($"Range: {range}y");
if (effectRange > 0)
parts.Add($"Radius: {effectRange}y");
}
return parts.Count > 0 ? string.Join(" | ", parts) : "";
}
catch { return ""; }
}
}
}