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;
/// When we process a game drop on a slot, we must suppress the InvisibleButton "click" that would trigger ExecuteSlot.
private int _lastFrameDroppedOnSlot = -1;
/// When we had a game drag and the user released, suppress all slot clicks this frame (release is not a click).
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;
/// Deferred clear when release-outside: set when no overlay accepted, executed next frame.
private static int _pendingReleaseOutsideSlotId = -1;
/// To avoid PICKUP log spam: only log when we first start dragging a new slot.
private static int _lastLoggedPickupSlotId = -1;
/// Encode hotbar (1-10) and slot (0-11) into internal slot id (0-119).
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;
}
/// Decode internal slot id to hotbar (1-10) and slot (0-11). Returns (-1,-1) if invalid.
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;
}
///
/// Visibility for HSUI Action Bars is driven by Visibility → Hotbars (Hotbar 1–10), not the per-element config.
///
public VisibilityConfig VisibilityConfig => GetHotbarVisibilityConfig();
private VisibilityConfig GetHotbarVisibilityConfig()
{
var hotbars = ConfigurationManager.Instance?.GetConfigObject();
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, List) ChildrenPositionsAndSizes()
{
var size = BarSize();
return (new List { Config.Position }, new List { 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.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 (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 2–10 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, "");
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();
});
}
/// Tooltip-only overlay when click overlay is skipped (proxy on / HUD not locked). Uses NoInputs so it doesn't capture clicks.
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, "");
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()?.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();
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();
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();
}
module->SetAndSaveSlot(barId, (uint)releaseIdx, slotType, id);
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();
}
module->SetAndSaveSlot(barId, (uint)idx, slotType, id);
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;
}
///
/// 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.
///
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: 1–100 Individual, 101–200 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);
}
/// Map game PayloadContainer Int1 (agent/UI container id) to InventoryType.
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
};
}
/// Resolve visual (containerId, slotIndex) to real (invType, slot) for main inventory via ItemOrderModule.
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; }
}
/// 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.
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;
}
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()?.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()?.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- ()?.GetRow(baseItemId);
return item.HasValue ? (uint)item.Value.Icon : 0;
case RaptureHotbarModule.HotbarSlotType.Mount:
var mount = Plugin.DataManager.GetExcelSheet()?.GetRow(id);
return mount.HasValue ? (uint)mount.Value.Icon : 0;
case RaptureHotbarModule.HotbarSlotType.Companion:
var companion = Plugin.DataManager.GetExcelSheet()?.GetRow(id);
return companion.HasValue ? (uint)companion.Value.Icon : 0;
case RaptureHotbarModule.HotbarSlotType.Emote:
var emote = Plugin.DataManager.GetExcelSheet()?.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()?.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()?.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)
{
var row = Plugin.DataManager.GetExcelSheet()?.GetRow(slot.ActionId);
if (row.HasValue)
{
string name = row.Value.Name.ToString();
string desc = "";
var descRow = Plugin.DataManager.GetExcelSheet()?.GetRow(slot.ActionId);
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();
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.Item)
{
var row = Plugin.DataManager.GetExcelSheet
- ()?.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, "");
}
}
}
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();
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;
}
/// Returns true if this action should show combat stats (potency, range, radius). False for Teleport, Return, Sprint, etc.
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();
float castSec = action.Cast100ms / 100f;
float recastSec = action.Recast100ms / 100f;
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 ""; }
}
}
}