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.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 (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(); } 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; } /// /// 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.Mount) { var row = Plugin.DataManager.GetExcelSheet()?.GetRow(slot.ActionId); if (row.HasValue) { string name = row.Value.Singular.ToString(); string desc = ""; try { var transient = Plugin.DataManager.GetExcelSheet()?.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()?.GetRow(slot.ActionId); if (row.HasValue) { string name = row.Value.Singular.ToString(); string desc = ""; try { var transient = Plugin.DataManager.GetExcelSheet()?.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()?.GetRow(slot.ActionId); if (row.HasValue) return (row.Value.Name.ToString(), ""); } 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, ""); } } } if (slot.SlotType == RaptureHotbarModule.HotbarSlotType.MainCommand || slot.SlotType == RaptureHotbarModule.HotbarSlotType.ExtraCommand) { if (Plugin.DataManager.GetExcelSheet()?.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(); 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 / 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 ""; } } } }