From d7df3852394f220239b419f5f1a3519215d450bb Mon Sep 17 00:00:00 2001 From: Knack117 Date: Tue, 27 Jan 2026 20:05:39 -0500 Subject: [PATCH] Release 1.0.5: Vendor Quick Sell, auto-confirm sell dialogs, README update Co-authored-by: Cursor --- AtkValueHelpers.cs | 207 +++++++ ContextMenuHandler.cs | 348 +++++++++++ DragDropHelpers.cs | 299 +++++++++ InventoryHelpers.cs | 306 +++++++++ QuickTransfer.cs | 1330 ++++++++-------------------------------- QuickTransfer.csproj | 6 +- QuickTransfer.json | 4 +- QuickTransferWindow.cs | 20 + README.md | 32 +- pluginmaster.json | 4 +- 10 files changed, 1484 insertions(+), 1072 deletions(-) create mode 100644 AtkValueHelpers.cs create mode 100644 ContextMenuHandler.cs create mode 100644 DragDropHelpers.cs create mode 100644 InventoryHelpers.cs diff --git a/AtkValueHelpers.cs b/AtkValueHelpers.cs new file mode 100644 index 0000000..6652578 --- /dev/null +++ b/AtkValueHelpers.cs @@ -0,0 +1,207 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; +using FFXIVClientStructs.FFXIV.Component.GUI; +using AtkValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; + +namespace QuickTransfer; + +/// +/// Utility functions for working with AtkValue structures. +/// +internal static unsafe class AtkValueHelpers +{ + private const int UnitListCount = 18; + + public static string ReadAtkValueString(AtkValue v) + { + if (v.String == null) + return string.Empty; + + try + { + // SimpleTweaks-style decoding. + return Marshal.PtrToStringUTF8(new IntPtr(v.String))?.TrimEnd('\0') ?? string.Empty; + } + catch + { + return ReadUtf8(v.String); + } + } + + public static string ReadUtf8(byte* ptr) + { + if (ptr == null) + return string.Empty; + + var len = 0; + while (ptr[len] != 0) + len++; + + return len <= 0 ? string.Empty : Encoding.UTF8.GetString(ptr, len); + } + + public static void WriteUtf8InPlace(byte* dst, string value) + { + if (dst == null || string.IsNullOrEmpty(value)) + return; + + var bytes = Encoding.UTF8.GetBytes(value); + var max = Math.Min(bytes.Length, 255); // reasonable limit + for (var i = 0; i < max; i++) + dst[i] = bytes[i]; + dst[max] = 0; + } + + public static void WriteUtf8StringInPlace(FFXIVClientStructs.FFXIV.Client.System.String.Utf8String* s, string value) + { + if (s == null) + return; + + WriteUtf8InPlace(s->StringPtr, value); + } + + public static AtkUnitBase* GetAddonById(uint id) + { + var unitManagers = &AtkStage.Instance()->RaptureAtkUnitManager->AtkUnitManager.DepthLayerOneList; + for (var i = 0; i < UnitListCount; i++) + { + var unitManager = &unitManagers[i]; + for (var j = 0; j < Math.Min(unitManager->Count, unitManager->Entries.Length); j++) + { + var unitBase = unitManager->Entries[j].Value; + if (unitBase != null && unitBase->Id == id) + return unitBase; + } + } + + return null; + } + + public static AtkValue* CreateAtkValueArray(params object[] values) + { + var atkValues = (AtkValue*)Marshal.AllocHGlobal(values.Length * sizeof(AtkValue)); + if (atkValues == null) + return null; + + try + { + for (var i = 0; i < values.Length; i++) + { + var v = values[i]; + switch (v) + { + case uint u: + atkValues[i].Type = AtkValueType.UInt; + atkValues[i].UInt = u; + break; + case int n: + atkValues[i].Type = AtkValueType.Int; + atkValues[i].Int = n; + break; + case float f: + atkValues[i].Type = AtkValueType.Float; + atkValues[i].Float = f; + break; + case bool b: + atkValues[i].Type = AtkValueType.Bool; + atkValues[i].Byte = (byte)(b ? 1 : 0); + break; + case string s: + { + atkValues[i].Type = AtkValueType.String; + var bytes = Encoding.UTF8.GetBytes(s); + var alloc = Marshal.AllocHGlobal(bytes.Length + 1); + Marshal.Copy(bytes, 0, alloc, bytes.Length); + Marshal.WriteByte(alloc, bytes.Length, 0); + atkValues[i].String = (byte*)alloc; + break; + } + default: + throw new ArgumentException($"Unsupported AtkValue type {v.GetType()}"); + } + } + } + catch + { + Marshal.FreeHGlobal(new IntPtr(atkValues)); + return null; + } + + return atkValues; + } + + public static void GenerateCallback(AtkUnitBase* unitBase, params object[] values) + { + var atkValues = CreateAtkValueArray(values); + if (atkValues == null) + return; + + try + { + unitBase->FireCallback((uint)values.Length, atkValues); + } + finally + { + for (var i = 0; i < values.Length; i++) + { + if (atkValues[i].Type == AtkValueType.String) + Marshal.FreeHGlobal(new IntPtr(atkValues[i].String)); + } + + Marshal.FreeHGlobal(new IntPtr(atkValues)); + } + } + + public static bool TryGetAtkValueInt(AtkValue* values, int count, int idx, out int value) + { + value = 0; + try + { + if (values == null || idx < 0 || idx >= count) + return false; + var v = values + idx; + if (v->Type == AtkValueType.Int) + { + value = v->Int; + return true; + } + if (v->Type == AtkValueType.UInt) + { + value = unchecked((int)v->UInt); + return true; + } + } + catch + { + // ignore + } + return false; + } + + public static void MakeAddonInvisible(AtkUnitBase* addon) + { + if (addon == null) + return; + var root = addon->RootNode; + if (root == null) + return; + + // Keep it logically visible/interactive, but force it fully transparent before it draws. + root->Color.A = 0; + root->Alpha_2 = 0; + } + + public static void MakeAddonVisible(AtkUnitBase* addon) + { + if (addon == null) + return; + var root = addon->RootNode; + if (root == null) + return; + + // Restore fully visible alpha; this prevents "stuck invisible" menus after a suppression frame. + root->Color.A = 255; + root->Alpha_2 = 255; + } +} diff --git a/ContextMenuHandler.cs b/ContextMenuHandler.cs new file mode 100644 index 0000000..b4d568e --- /dev/null +++ b/ContextMenuHandler.cs @@ -0,0 +1,348 @@ +using System; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using AtkValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; + +namespace QuickTransfer; + +/// +/// Handles context menu selection and matching logic. +/// +internal static unsafe class ContextMenuHandler +{ + public enum ModifierMode + { + Shift, + Ctrl, + Alt, + } + // Access services through Plugin's static properties + private static IGameGui GameGui => Plugin.GameGui; + + public enum AutoContextAction + { + AddAllToSaddlebag, + RemoveAllFromSaddlebag, + PlaceInArmouryChest, + ReturnToInventory, + EntrustToRetainer, + RetrieveFromRetainer, + RemoveFromCompanyChest, + Split, + Sort, + Trade, + Sell, + } + + public static bool ContextLabelMatches(AutoContextAction desiredAction, string menuText) + { + var t = menuText.Trim(); + static bool Has(string s, string needle) => s.Contains(needle, StringComparison.OrdinalIgnoreCase); + + return desiredAction switch + { + AutoContextAction.AddAllToSaddlebag => + t.Equals("Add All to Saddlebag", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Add All") && Has(t, "Saddlebag")), + + AutoContextAction.RemoveAllFromSaddlebag => + t.Equals("Remove All from Saddlebag", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Remove All") && Has(t, "Saddlebag")) || + (Has(t, "Remove") && Has(t, "Saddlebag")) || + t.Equals("Remove All", StringComparison.OrdinalIgnoreCase) || + ((Has(t, "Retrieve") || Has(t, "Take out") || Has(t, "Take Out")) && Has(t, "Saddlebag")), + + AutoContextAction.PlaceInArmouryChest => + t.Equals("Place in Armoury Chest", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Place") && (Has(t, "Armoury") || Has(t, "Armory")) && Has(t, "Chest")), + + AutoContextAction.ReturnToInventory => + t.Equals("Return to Inventory", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Return") && Has(t, "Inventory")), + + AutoContextAction.EntrustToRetainer => + t.Equals("Entrust to Retainer", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Entrust") && Has(t, "Retainer")), + + AutoContextAction.RetrieveFromRetainer => + t.Equals("Retrieve from Retainer", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Retrieve") && Has(t, "Retainer")), + + AutoContextAction.RemoveFromCompanyChest => + t.Equals("Remove", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Remove") && (Has(t, "Company") || Has(t, "Chest"))) || + (Has(t, "Withdraw") && (Has(t, "Company") || Has(t, "Chest"))), + + AutoContextAction.Split => + t.Equals("Split", StringComparison.OrdinalIgnoreCase) || + t.StartsWith("Split", StringComparison.OrdinalIgnoreCase), + + AutoContextAction.Sort => + t.Equals("Sort", StringComparison.OrdinalIgnoreCase) || + t.StartsWith("Sort", StringComparison.OrdinalIgnoreCase), + + AutoContextAction.Trade => + t.Equals("Trade", StringComparison.OrdinalIgnoreCase) || + t.StartsWith("Trade", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Trade") && Has(t, "Item")), + + AutoContextAction.Sell => + t.Equals("Sell", StringComparison.OrdinalIgnoreCase) || + t.StartsWith("Sell", StringComparison.OrdinalIgnoreCase) || + (Has(t, "Sell") && Has(t, "Item")), + + _ => false, + }; + } + + public static void CloseContextMenuAddon(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon) + { + try { agent->AgentInterface.Hide(); } catch { /* ignore */ } + try { contextMenuAddon->Hide(false, true, 0); } catch { /* ignore */ } + } + + public static bool TryAutoSelectAndClose( + AgentInventoryContext* agent, + AtkUnitBase* contextMenuAddon, + ModifierMode mode, + Configuration configuration, + out string chosenText, + out int chosenIndex, + ref long pendingCloseContextMenuAtMs) + { + chosenText = string.Empty; + chosenIndex = -1; + + // Single-pass: decode each label once, record first match per action. + var foundAny = false; + + int removeIdx = -1, addIdx = -1, placeIdx = -1, returnIdx = -1, entrustIdx = -1, retrieveIdx = -1, companyRemoveIdx = -1, splitIdx = -1, tradeIdx = -1, sellIdx = -1; + string? removeTxt = null, addTxt = null, placeTxt = null, returnTxt = null, entrustTxt = null, retrieveTxt = null, companyRemoveTxt = null, splitTxt = null, tradeTxt = null, sellTxt = null; + + var max = Math.Min(agent->ContextItemCount, 64); + for (var i = 0; i < max; i++) + { + var param = agent->EventParams[agent->ContexItemStartIndex + i]; + if (param.Type is not (AtkValueType.String or AtkValueType.ManagedString)) + continue; + + var text = AtkValueHelpers.ReadAtkValueString(param); + if (string.IsNullOrWhiteSpace(text)) + continue; + + foundAny = true; + + // Priority matters: we want the first matching index for each action. + if (removeIdx < 0 && ContextLabelMatches(AutoContextAction.RemoveAllFromSaddlebag, text)) + { + removeIdx = i; + removeTxt = text; + continue; + } + + if (companyRemoveIdx < 0 && ContextLabelMatches(AutoContextAction.RemoveFromCompanyChest, text)) + { + companyRemoveIdx = i; + companyRemoveTxt = text; + continue; + } + + if (addIdx < 0 && ContextLabelMatches(AutoContextAction.AddAllToSaddlebag, text)) + { + addIdx = i; + addTxt = text; + continue; + } + + if (placeIdx < 0 && ContextLabelMatches(AutoContextAction.PlaceInArmouryChest, text)) + { + placeIdx = i; + placeTxt = text; + continue; + } + + if (returnIdx < 0 && ContextLabelMatches(AutoContextAction.ReturnToInventory, text)) + { + returnIdx = i; + returnTxt = text; + continue; + } + + if (entrustIdx < 0 && ContextLabelMatches(AutoContextAction.EntrustToRetainer, text)) + { + entrustIdx = i; + entrustTxt = text; + continue; + } + + if (retrieveIdx < 0 && ContextLabelMatches(AutoContextAction.RetrieveFromRetainer, text)) + { + retrieveIdx = i; + retrieveTxt = text; + continue; + } + + if (splitIdx < 0 && ContextLabelMatches(AutoContextAction.Split, text)) + { + splitIdx = i; + splitTxt = text; + continue; + } + + if (tradeIdx < 0 && ContextLabelMatches(AutoContextAction.Trade, text)) + { + tradeIdx = i; + tradeTxt = text; + } + + if (sellIdx < 0 && ContextLabelMatches(AutoContextAction.Sell, text)) + { + sellIdx = i; + sellTxt = text; + } + } + + if (!foundAny) + return false; + + var saddlebagOpen = InventoryHelpers.IsSaddlebagOpen(); + var retainerOpen = InventoryHelpers.IsRetainerOpen(); + var companyChestOpen = InventoryHelpers.IsCompanyChestOpen(); + var tradeOpen = InventoryHelpers.IsTradeOpen(); + var vendorOpen = InventoryHelpers.IsVendorOpen(); + + // Choose the best action that exists in the menu. + (int idx, string? txt) chosen; + if (mode == ModifierMode.Alt) + { + chosen = splitIdx >= 0 ? (splitIdx, splitTxt) : (-1, (string?)null); + } + else if (mode == ModifierMode.Shift && vendorOpen && configuration.EnableVendorQuickSell) + { + // Vendor shop: prioritize Sell action when vendor is open + chosen = sellIdx >= 0 ? (sellIdx, sellTxt) : (-1, (string?)null); + } + else if (mode == ModifierMode.Shift && tradeOpen) + { + // Trade window: prioritize Trade action when Trade window is open + chosen = tradeIdx >= 0 ? (tradeIdx, tradeTxt) : (-1, (string?)null); + } + else if (mode == ModifierMode.Shift && companyChestOpen && configuration.EnableCompanyChest) + { + chosen = companyRemoveIdx >= 0 ? (companyRemoveIdx, companyRemoveTxt) : (-1, (string?)null); + } + else if (mode == ModifierMode.Ctrl) + { + chosen = returnIdx >= 0 ? (returnIdx, returnTxt) : + placeIdx >= 0 ? (placeIdx, placeTxt) : + (-1, (string?)null); + } + else if (retainerOpen) + { + if (saddlebagOpen) + { + // Retainer <-> Saddlebag: + // - Retainer item: Add All to Saddlebag + // - Saddlebag item: Entrust to Retainer + chosen = addIdx >= 0 ? (addIdx, addTxt) : + entrustIdx >= 0 ? (entrustIdx, entrustTxt) : + // last-resort fallback + removeIdx >= 0 ? (removeIdx, removeTxt) : + (-1, (string?)null); + } + else + { + // Retainer <-> Player (Inventory/Armoury): + // - Retainer item: Retrieve from Retainer + // - Player item: Entrust to Retainer + chosen = retrieveIdx >= 0 ? (retrieveIdx, retrieveTxt) : + entrustIdx >= 0 ? (entrustIdx, entrustTxt) : + (-1, (string?)null); + } + } + else if (saddlebagOpen) + { + chosen = removeIdx >= 0 ? (removeIdx, removeTxt) : + addIdx >= 0 ? (addIdx, addTxt) : + (-1, (string?)null); + } + else + { + chosen = placeIdx >= 0 ? (placeIdx, placeTxt) : + returnIdx >= 0 ? (returnIdx, returnTxt) : + (-1, (string?)null); + } + + if (chosen.idx < 0 || string.IsNullOrWhiteSpace(chosen.txt)) + return false; + + AtkValueHelpers.GenerateCallback(contextMenuAddon, 0, chosen.idx, 0U, 0, 0); + + // Some actions (notably Split and Trade) can be cancelled if we close the menu immediately. + // Delay the close slightly to allow the follow-up UI (InputNumeric) to spawn. + if (chosen.txt != null && (ContextLabelMatches(AutoContextAction.Split, chosen.txt) || ContextLabelMatches(AutoContextAction.Trade, chosen.txt))) + { + // Don't close immediately: on some setups this cancels the action before InputNumeric opens. + // We'll keep the menu invisible (via suppression) and close it later as a cleanup. + pendingCloseContextMenuAtMs = Environment.TickCount64 + 3000; + } + else + { + CloseContextMenuAddon(agent, contextMenuAddon); + } + + chosenText = chosen.txt!; + chosenIndex = chosen.idx; + return true; + } + + public static bool TrySelectSortAndClose(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon, out string chosenText, out int chosenIndex) + { + chosenText = string.Empty; + chosenIndex = -1; + + var undoSortIdx = -1; + string? undoSortText = null; + + var max = Math.Min(agent->ContextItemCount, 64); + for (var i = 0; i < max; i++) + { + var param = agent->EventParams[agent->ContexItemStartIndex + i]; + if (param.Type is not (AtkValueType.String or AtkValueType.ManagedString)) + continue; + + var text = AtkValueHelpers.ReadAtkValueString(param); + if (string.IsNullOrWhiteSpace(text)) + continue; + + // If Sort isn't present (because the container is already sorted), the menu often contains "Undo Sort" instead. + // We treat that as "already sorted" and do nothing (closing the menu). + if (undoSortIdx < 0 && text.Trim().Equals("Undo Sort", StringComparison.OrdinalIgnoreCase)) + { + undoSortIdx = i; + undoSortText = text; + } + + if (!ContextLabelMatches(AutoContextAction.Sort, text)) + continue; + + AtkValueHelpers.GenerateCallback(contextMenuAddon, 0, i, 0U, 0, 0); + CloseContextMenuAddon(agent, contextMenuAddon); + chosenText = text; + chosenIndex = i; + return true; + } + + // No "Sort" entry. If "Undo Sort" exists, we're already sorted; close the menu without changing state. + if (undoSortIdx >= 0) + { + try { CloseContextMenuAddon(agent, contextMenuAddon); } catch { /* ignore */ } + chosenText = "Already sorted"; + chosenIndex = -1; + return true; + } + + return false; + } +} diff --git a/DragDropHelpers.cs b/DragDropHelpers.cs new file mode 100644 index 0000000..5b30f8a --- /dev/null +++ b/DragDropHelpers.cs @@ -0,0 +1,299 @@ +using System; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +namespace QuickTransfer; + +/// +/// Helper functions for parsing drag-drop interfaces from UI events. +/// +internal static unsafe class DragDropHelpers +{ + // ArmouryBoard drag-drop payloads are not always (InventoryType, Slot). + // On some builds the payload's Int1 is a category index, and Int2 is the slot within that category. + // This mapping is best-effort and is only applied when we're sure the hover comes from the ArmouryBoard addon. + internal static readonly InventoryType[] ArmouryBoardIndexToType = + [ + InventoryType.ArmoryMainHand, + InventoryType.ArmoryOffHand, + InventoryType.ArmoryHead, + InventoryType.ArmoryBody, + InventoryType.ArmoryHands, + InventoryType.ArmoryWaist, + InventoryType.ArmoryLegs, + InventoryType.ArmoryFeets, + InventoryType.ArmoryEar, + InventoryType.ArmoryNeck, + InventoryType.ArmoryWrist, + InventoryType.ArmoryRings, + InventoryType.ArmorySoulCrystal, + ]; + + public static bool TryGetDragDropInterfaceFromReceiveEvent( + AddonArgs args, + AddonReceiveEventArgs recv, + AtkEventType eventType, + AtkEventData* eventData, + out uint addonId, + out AtkDragDropInterface* ddi) + { + addonId = 0; + ddi = null; + + var addon = (AtkUnitBase*)args.Addon.Address; + if (addon == null) + return false; + addonId = addon->Id; + + // List item events can provide a renderer directly. + if (eventData != null && + eventType is AtkEventType.ListItemRollOver or AtkEventType.ListItemRollOut or AtkEventType.ListItemClick or + AtkEventType.ListItemDoubleClick or AtkEventType.ListItemSelect) + { + try + { + var r = eventData->ListItemData.ListItemRenderer; + if (r != null) + { + // Prefer the embedded DragDrop component if present. + if (r->DragDropComponent != null) + ddi = &r->DragDropComponent->AtkDragDropInterface; + else + { + try { ddi = &r->AtkDragDropInterface; } catch { /* ignore */ } + } + } + } + catch + { + // ignore + } + } + + if (ddi != null) + return true; + + static AtkDragDropInterface* TryGetDdiFromList(AtkComponentList* list) + { + if (list == null) + return null; + + // The list tracks a hovered item index itself, which is much safer than trying to interpret eventParam. + // Prefer HoveredItemIndex, then fall back to other hover slots. + static AtkDragDropInterface* FromIndex(AtkComponentList* l, int idx) + { + if (idx < 0 || idx > 512) + return null; + try + { + var r = l->GetItemRenderer(idx); + return r != null ? &r->AtkDragDropInterface : null; + } + catch + { + return null; + } + } + + var ddi0 = FromIndex(list, list->HoveredItemIndex); + if (ddi0 != null) + return ddi0; + + var ddi1 = FromIndex(list, list->HoveredItemIndex2); + if (ddi1 != null) + return ddi1; + + var ddi2 = FromIndex(list, list->HoveredItemIndex3); + if (ddi2 != null) + return ddi2; + + return null; + } + + static AtkDragDropInterface* TryGetDdiFromComponent(AtkComponentBase* component) + { + if (component == null) + return null; + + var t = component->GetComponentType(); + return t switch + { + ComponentType.DragDrop => &((AtkComponentDragDrop*)component)->AtkDragDropInterface, + ComponentType.ListItemRenderer => &((AtkComponentListItemRenderer*)component)->AtkDragDropInterface, + ComponentType.List => TryGetDdiFromList((AtkComponentList*)component), + _ => null, + }; + } + + // Prefer the drag-drop interface directly from event data when present. + // IMPORTANT: only trust DragDropData for actual drag-drop event types; for MouseOver it can contain garbage. + var isDragDropEvent = + eventType is AtkEventType.DragDropBegin or + AtkEventType.DragDropCanAcceptCheck or + AtkEventType.DragDropClick or + AtkEventType.DragDropDiscard or + AtkEventType.DragDropEnd or + AtkEventType.DragDropInsert or + AtkEventType.DragDropInsertAttempt or + AtkEventType.DragDropRollOut or + AtkEventType.DragDropRollOver; + + ddi = (isDragDropEvent && eventData != null) ? eventData->DragDropData.DragDropInterface : null; + + // Some drag-drop events (notably DragDropRollOver) provide a ComponentNode but not a DragDropInterface. + // IMPORTANT: never read DragDropData.ComponentNode for non-dragdrop events (AtkEventData is a union). + if (ddi == null && isDragDropEvent && eventData != null && eventData->DragDropData.ComponentNode != null) + { + try + { + var compNode = eventData->DragDropData.ComponentNode; + var component = compNode->Component; + ddi = TryGetDdiFromComponent(component); + } + catch + { + // ignore + } + } + + // Fallback: some event types provide MouseData, but the target is still a DragDrop component. + if (ddi == null) + { + var atkEvent = (AtkEvent*)recv.AtkEvent; + if (atkEvent != null && atkEvent->Node != null) + { + var node = atkEvent->Node; + var compNode = node->GetAsAtkComponentNode(); + if (compNode != null) + { + var component = compNode->Component; + ddi = TryGetDdiFromComponent(component); + } + } + } + + if (ddi == null) + return false; + + return true; + } + + public static bool TryGetSlotFromDragDropInterface( + AtkDragDropInterface* ddi, + out InventoryType invType, + out int slot) + { + invType = default; + slot = -1; + if (ddi == null) + return false; + + var payload = ddi->GetPayloadContainer(); + if (payload == null) + return false; + + invType = (InventoryType)payload->Int1; + slot = payload->Int2; + if (slot < 0 || slot > 500) + return false; + + return true; + } + + public static bool TryGetSlotFromDragDropInterfaceForAddon( + AtkDragDropInterface* ddi, + string addonName, + uint addonId, + out InventoryType invType, + out int slot, + out int rawInt1, + out int rawInt2, + out uint rawFlags) + { + invType = default; + slot = -1; + rawInt1 = 0; + rawInt2 = 0; + rawFlags = 0; + + if (ddi == null) + return false; + + AtkDragDropPayloadContainer* payload; + try + { + payload = ddi->GetPayloadContainer(); + } + catch + { + return false; + } + if (payload == null) + return false; + + rawInt1 = payload->Int1; + rawInt2 = payload->Int2; + rawFlags = payload->Flags; + + // Default interpretation (most inventory add-ons): (InventoryType, Slot) + invType = (InventoryType)rawInt1; + slot = rawInt2; + + // ArmouryBoard special-case: some builds use (CategoryIndex, Slot) + // and Int1 may look like Inventory1..Inventory4 (0..3), which is clearly wrong for ArmouryBoard. + if (!string.IsNullOrEmpty(addonName) && + addonName.Equals("ArmouryBoard", StringComparison.OrdinalIgnoreCase) && + InventoryHelpers.TryGetVisibleAddon("ArmouryBoard", out var ab) && + ab != null && + ab->Id == addonId) + { + if (rawInt1 >= 0 && rawInt1 < ArmouryBoardIndexToType.Length) + { + invType = ArmouryBoardIndexToType[rawInt1]; + slot = rawInt2; + } + } + + if (slot < 0 || slot > 500) + return false; + + return true; + } + + public static int PickContextMenuSlot(InventoryType type, int preferredSlot) + { + try + { + var inv = InventoryManager.Instance(); + if (inv == null) + return preferredSlot; + + var c = inv->GetInventoryContainer(type); + if (c == null || !c->IsLoaded || c->Size <= 0) + return preferredSlot; + + // Prefer the hovered slot when in range AND it contains an item. + if (preferredSlot >= 0 && preferredSlot < c->Size) + { + var it0 = c->GetInventorySlot(preferredSlot); + if (it0 != null && it0->ItemId != 0) + return preferredSlot; + } + + // Fallback: find the first slot with an item. + for (var i = 0; i < c->Size; i++) + { + var it = c->GetInventorySlot(i); + if (it != null && it->ItemId != 0) + return i; + } + } + catch + { + // ignore + } + + return preferredSlot; + } +} diff --git a/InventoryHelpers.cs b/InventoryHelpers.cs new file mode 100644 index 0000000..6917069 --- /dev/null +++ b/InventoryHelpers.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Component.GUI; +using Dalamud.Plugin.Services; + +namespace QuickTransfer; + +/// +/// Static helper functions for inventory detection, type checking, and addon visibility. +/// +internal static unsafe class InventoryHelpers +{ + // Access services through Plugin's static properties + private static IGameGui GameGui => Plugin.GameGui; + private static IDataManager DataManager => Plugin.DataManager; + + private static readonly InventoryType[] PlayerInventoryTypes = + [ + InventoryType.Inventory1, + InventoryType.Inventory2, + InventoryType.Inventory3, + InventoryType.Inventory4, + ]; + + private static readonly InventoryType[] SaddlebagInventoryTypes = + [ + InventoryType.SaddleBag1, + InventoryType.SaddleBag2, + InventoryType.PremiumSaddleBag1, + InventoryType.PremiumSaddleBag2, + ]; + + private static readonly InventoryType[] RetainerInventoryTypes = + [ + InventoryType.RetainerPage1, + InventoryType.RetainerPage2, + InventoryType.RetainerPage3, + InventoryType.RetainerPage4, + InventoryType.RetainerPage5, + InventoryType.RetainerPage6, + InventoryType.RetainerPage7, + ]; + + public static bool IsPlayerInventoryType(InventoryType inventoryType) + => inventoryType is + InventoryType.Inventory1 or + InventoryType.Inventory2 or + InventoryType.Inventory3 or + InventoryType.Inventory4; + + public static bool IsArmouryType(InventoryType inventoryType) + => inventoryType is + InventoryType.ArmoryMainHand or + InventoryType.ArmoryOffHand or + InventoryType.ArmoryHead or + InventoryType.ArmoryBody or + InventoryType.ArmoryHands or + InventoryType.ArmoryWaist or + InventoryType.ArmoryLegs or + InventoryType.ArmoryFeets or + InventoryType.ArmoryEar or + InventoryType.ArmoryNeck or + InventoryType.ArmoryWrist or + InventoryType.ArmoryRings or + InventoryType.ArmorySoulCrystal; + + public static bool IsSaddlebagType(InventoryType inventoryType) + => inventoryType is + InventoryType.SaddleBag1 or + InventoryType.SaddleBag2 or + InventoryType.PremiumSaddleBag1 or + InventoryType.PremiumSaddleBag2; + + public static bool IsRetainerType(InventoryType inventoryType) + => inventoryType is + InventoryType.RetainerPage1 or + InventoryType.RetainerPage2 or + InventoryType.RetainerPage3 or + InventoryType.RetainerPage4 or + InventoryType.RetainerPage5 or + InventoryType.RetainerPage6 or + InventoryType.RetainerPage7; + + public static bool IsCompanyChestType(InventoryType inventoryType) + { + var name = Enum.GetName(typeof(InventoryType), inventoryType); + if (string.IsNullOrEmpty(name)) + return false; + + // We only want the *item compartments*, not crystals/gil/etc. + // Observed names: FreeCompanyPage1..FreeCompanyPage5 + return name.StartsWith("FreeCompanyPage", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsAddonVisible(string addonName, int index = 1) + { + var addon = GameGui.GetAddonByName(addonName, index); + return !addon.IsNull && addon.IsVisible; + } + + public static bool IsAddonVisibleAnyIndex(string addonName, int maxIndex = 6) + { + for (var i = 1; i <= maxIndex; i++) + { + if (IsAddonVisible(addonName, i)) + return true; + } + + return false; + } + + public static bool IsAnyAddonVisible(IEnumerable addonNames, int index = 1) + { + foreach (var name in addonNames) + { + if (IsAddonVisible(name, index)) + return true; + } + + return false; + } + + public static bool IsAnyAddonVisibleAnyIndex(IEnumerable addonNames, int maxIndex = 6) + { + foreach (var name in addonNames) + { + if (IsAddonVisibleAnyIndex(name, maxIndex)) + return true; + } + + return false; + } + + public static bool IsInventoryAndSaddlebagOpen() + { + var inventoryOpen = IsAddonVisibleAnyIndex("Inventory"); + var saddlebagOpen = IsAddonVisibleAnyIndex("InventoryBuddy") || IsAddonVisibleAnyIndex("InventoryBuddy2"); + return inventoryOpen && saddlebagOpen; + } + + public static bool IsSaddlebagOpen() + => IsAddonVisibleAnyIndex("InventoryBuddy") || IsAddonVisibleAnyIndex("InventoryBuddy2"); + + public static bool IsRetainerOpen() + { + // Common retainer inventory addons. + // (SimpleTweaks checks "RetainerGrid0" for retainer inventory visibility.) + return IsAddonVisibleAnyIndex("RetainerGrid0") || + IsAddonVisibleAnyIndex("RetainerSellList") || + IsAddonVisibleAnyIndex("RetainerGrid"); + } + + public static bool IsCompanyChestOpen() + => IsAddonVisibleAnyIndex("FreeCompanyChest"); + + public static bool IsTradeOpen() + => IsAddonVisibleAnyIndex("Trade") || IsAddonVisibleAnyIndex("TradeWindow"); + + public static bool IsVendorOpen() + => IsAddonVisibleAnyIndex("Shop"); + + public static bool TryGetVisibleAddon(string addonName, out AtkUnitBase* addon, int maxIndex = 6) + { + addon = null; + for (var i = 1; i <= maxIndex; i++) + { + var a = GameGui.GetAddonByName(addonName, i); + if (!a.IsNull && a.IsVisible) + { + addon = (AtkUnitBase*)a.Address; + return true; + } + } + + return false; + } + + public static bool TryGetItemInfo( + InventoryType type, + int slot, + out uint itemId, + out bool isHq, + out uint quantity) + { + itemId = 0; + isHq = false; + quantity = 0; + + var inv = InventoryManager.Instance(); + if (inv == null) + return false; + + var it = inv->GetInventorySlot(type, slot); + if (it == null) + return false; + + itemId = it->ItemId; + isHq = it->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality); + quantity = (uint)it->Quantity; + return itemId != 0; + } + + public static bool IsContainerLoaded(InventoryManager* inv, InventoryType type) + { + try + { + if (inv == null) + return false; + var c = inv->GetInventoryContainer(type); + return c != null && c->IsLoaded && c->Size > 0; + } + catch + { + return false; + } + } + + public static InventoryType[] GetPlayerInventoryTypes() => PlayerInventoryTypes; + public static InventoryType[] GetSaddlebagInventoryTypes() => SaddlebagInventoryTypes; + public static InventoryType[] GetRetainerInventoryTypes() => RetainerInventoryTypes; + + private static readonly Dictionary StackSizeCache = new(); + private static readonly Dictionary ItemUiCategoryCache = new(); + + public static uint GetItemStackSize(uint itemId) + { + try + { + // If item isn't known/stackable, return 1. + if (itemId == 0) + return 1; + + lock (StackSizeCache) + { + if (StackSizeCache.TryGetValue(itemId, out var cached)) + return cached; + } + + var sheet = DataManager.GetExcelSheet(); + if (sheet == null) + return 999; + + // Item row IDs are base IDs; InventoryItem.ItemId is expected to already be base. + var row = sheet.GetRow(itemId); + if (row.RowId == 0) + return 999; + + // In modern Lumina sheets, Item.StackSize exists. + var s = row.StackSize; + var result = s <= 0 ? 1U : (uint)s; + lock (StackSizeCache) + StackSizeCache[itemId] = result; + return result; + } + catch + { + // Fallback: most stackables are 999, and non-stackables will hit maxStack <= 1 cases anyway. + return 999; + } + } + + public static uint GetItemUiCategory(uint itemId) + { + try + { + if (itemId == 0) + return 0; + + lock (ItemUiCategoryCache) + { + if (ItemUiCategoryCache.TryGetValue(itemId, out var cached)) + return cached; + } + + var sheet = DataManager.GetExcelSheet(); + if (sheet == null) + return 0; + + var row = sheet.GetRow(itemId); + if (row.RowId == 0) + return 0; + + // Prefer UI category; this tends to match how game sorts items visually. + uint result; + try + { + // Lumina RowRef usually exposes RowId. + result = row.ItemUICategory.RowId; + } + catch + { + result = 0; + } + + lock (ItemUiCategoryCache) + ItemUiCategoryCache[itemId] = result; + return result; + } + catch + { + return 0; + } + } +} diff --git a/QuickTransfer.cs b/QuickTransfer.cs index 347beef..231fa35 100644 --- a/QuickTransfer.cs +++ b/QuickTransfer.cs @@ -43,6 +43,9 @@ public sealed class Configuration : IPluginConfiguration public bool AutoConfirmCompanyChestQuantity { get; set; } = true; public int CompanyChestCompartments { get; set; } = 3; // 3..5 (default game starts at 3) + public bool EnableVendorQuickSell { get; set; } = true; + public bool AutoConfirmVendorSell { get; set; } = true; + public void Save() => Plugin.PluginInterface.SavePluginConfig(this); } @@ -72,7 +75,7 @@ public sealed unsafe class Plugin : IDalamudPlugin private (string AddonName, long EnqueuedAtMs, ModifierMode Mode)? pendingDeferredDefaultMenu; private (nint AgentPtr, nint AddonPtr, long EnqueuedAtMs)? pendingDeferredSortMenuClick; private long pendingMiddleClickSortUntilMs; - private (FFXIVClientStructs.FFXIV.Client.Game.InventoryType Type, int Slot, uint AddonId, long EnqueuedAtMs)? pendingMiddleClickSortRequest; + private (InventoryType Type, int Slot, uint AddonId, long EnqueuedAtMs)? pendingMiddleClickSortRequest; private long lastMiddleClickSortMs; private long lastReceiveEventDebugLogMs; private long lastFcChestTabUnmappedLogMs; @@ -80,10 +83,10 @@ public sealed unsafe class Plugin : IDalamudPlugin private (nint DdiPtr, uint AddonId, long SeenAtMs)? lastHoverDdi; private string lastHoverAddonName = string.Empty; private (string AddonName, uint AddonId, long SeenAtMs)? lastHoverAddon; - private (FFXIVClientStructs.FFXIV.Client.Game.InventoryType Page, uint AddonId, long SeenAtMs)? lastHoverCompanyChestPage; - private (FFXIVClientStructs.FFXIV.Client.Game.InventoryType Page, uint AddonId, long SeenAtMs)? lastSelectedCompanyChestPage; + private (InventoryType Page, uint AddonId, long SeenAtMs)? lastHoverCompanyChestPage; + private (InventoryType Page, uint AddonId, long SeenAtMs)? lastSelectedCompanyChestPage; private int companyChestSelectedTabAtkValueIndex = -1; - private readonly Dictionary> companyChestSelectedTabCandidates = new(); + private readonly Dictionary> companyChestSelectedTabCandidates = new(); private long companyChestBusyUntilMs; private int companyChestBusyHits; private long lastCompanyChestOrganizeSkipLogMs; @@ -103,7 +106,7 @@ public sealed unsafe class Plugin : IDalamudPlugin // Cache a known-good (type, slot, a4) that successfully produced a populated inventory context menu for a given addon. // This allows MMB to "Sort" even when hover payloads are weird/un-decodable, because Sort applies to the container. - private readonly Dictionary lastGoodContextTargetByAddonId = new(); + private readonly Dictionary lastGoodContextTargetByAddonId = new(); // Win32: reliable mouse button state (works even when Dalamud KeyState doesn't report mouse buttons). [DllImport("user32.dll")] @@ -126,51 +129,33 @@ public sealed unsafe class Plugin : IDalamudPlugin // Real UI heap pointers in a 64-bit process are typically well above 4GB. private const long MinLikelyPointer = 0x1_0000_0000; // 4GB - // ArmouryBoard drag-drop payloads are not always (InventoryType, Slot). - // On some builds the payload's Int1 is a category index, and Int2 is the slot within that category. - // This mapping is best-effort and is only applied when we're sure the hover comes from the ArmouryBoard addon. - private static readonly FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] ArmouryBoardIndexToType = + // ArmouryBoardIndexToType moved to DragDropHelpers.cs + + private static readonly InventoryType[] PlayerInventoryTypes = [ - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryMainHand, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryOffHand, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryHead, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryBody, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryHands, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryWaist, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryLegs, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryFeets, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryEar, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryNeck, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryWrist, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryRings, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmorySoulCrystal, + InventoryType.Inventory1, + InventoryType.Inventory2, + InventoryType.Inventory3, + InventoryType.Inventory4, ]; - private static readonly FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] PlayerInventoryTypes = + private static readonly InventoryType[] SaddlebagInventoryTypes = [ - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory1, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory2, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory3, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory4, + InventoryType.SaddleBag1, + InventoryType.SaddleBag2, + InventoryType.PremiumSaddleBag1, + InventoryType.PremiumSaddleBag2, ]; - private static readonly FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] SaddlebagInventoryTypes = + private static readonly InventoryType[] RetainerInventoryTypes = [ - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.SaddleBag1, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.SaddleBag2, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.PremiumSaddleBag1, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.PremiumSaddleBag2, - ]; - - private static readonly FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] RetainerInventoryTypes = - [ - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage1, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage2, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage3, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage4, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage5, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage6, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage7, + InventoryType.RetainerPage1, + InventoryType.RetainerPage2, + InventoryType.RetainerPage3, + InventoryType.RetainerPage4, + InventoryType.RetainerPage5, + InventoryType.RetainerPage6, + InventoryType.RetainerPage7, ]; private static bool IsVkDown(int vKey) @@ -295,7 +280,7 @@ public sealed unsafe class Plugin : IDalamudPlugin // Prefer direct hit, then host, then parent. if (!Pick(hitId) && !Pick(hostId) && !Pick(parentId)) { - static string InferOwnerNameFromInvType(FFXIVClientStructs.FFXIV.Client.Game.InventoryType t) + static string InferOwnerNameFromInvType(InventoryType t) { if (IsPlayerInventoryType(t)) return "Inventory"; @@ -458,6 +443,7 @@ public sealed unsafe class Plugin : IDalamudPlugin } } + // Use DragDropHelpers private static bool TryGetDragDropInterfaceFromReceiveEvent( AddonArgs args, AddonReceiveEventArgs recv, @@ -465,272 +451,34 @@ public sealed unsafe class Plugin : IDalamudPlugin AtkEventData* eventData, out uint addonId, out AtkDragDropInterface* ddi) - { - addonId = 0; - ddi = null; - - var addon = (AtkUnitBase*)args.Addon.Address; - if (addon == null) - return false; - addonId = addon->Id; - - // List item events can provide a renderer directly. - if (eventData != null && - eventType is AtkEventType.ListItemRollOver or AtkEventType.ListItemRollOut or AtkEventType.ListItemClick or - AtkEventType.ListItemDoubleClick or AtkEventType.ListItemSelect) - { - try - { - var r = eventData->ListItemData.ListItemRenderer; - if (r != null) - { - // Prefer the embedded DragDrop component if present. - if (r->DragDropComponent != null) - ddi = &r->DragDropComponent->AtkDragDropInterface; - else - { - try { ddi = &r->AtkDragDropInterface; } catch { /* ignore */ } - } - } - } - catch - { - // ignore - } - } - - if (ddi != null) - return true; - - static AtkDragDropInterface* TryGetDdiFromList(AtkComponentList* list) - { - if (list == null) - return null; - - // The list tracks a hovered item index itself, which is much safer than trying to interpret eventParam. - // Prefer HoveredItemIndex, then fall back to other hover slots. - static AtkDragDropInterface* FromIndex(AtkComponentList* l, int idx) - { - if (idx < 0 || idx > 512) - return null; - try - { - var r = l->GetItemRenderer(idx); - return r != null ? &r->AtkDragDropInterface : null; - } - catch - { - return null; - } - } - - var ddi0 = FromIndex(list, list->HoveredItemIndex); - if (ddi0 != null) - return ddi0; - - var ddi1 = FromIndex(list, list->HoveredItemIndex2); - if (ddi1 != null) - return ddi1; - - var ddi2 = FromIndex(list, list->HoveredItemIndex3); - if (ddi2 != null) - return ddi2; - - return null; - } - - static AtkDragDropInterface* TryGetDdiFromComponent(AtkComponentBase* component) - { - if (component == null) - return null; - - var t = component->GetComponentType(); - return t switch - { - ComponentType.DragDrop => &((AtkComponentDragDrop*)component)->AtkDragDropInterface, - ComponentType.ListItemRenderer => &((AtkComponentListItemRenderer*)component)->AtkDragDropInterface, - ComponentType.List => TryGetDdiFromList((AtkComponentList*)component), - _ => null, - }; - } - - // Prefer the drag-drop interface directly from event data when present. - // IMPORTANT: only trust DragDropData for actual drag-drop event types; for MouseOver it can contain garbage. - var isDragDropEvent = - eventType is AtkEventType.DragDropBegin or - AtkEventType.DragDropCanAcceptCheck or - AtkEventType.DragDropClick or - AtkEventType.DragDropDiscard or - AtkEventType.DragDropEnd or - AtkEventType.DragDropInsert or - AtkEventType.DragDropInsertAttempt or - AtkEventType.DragDropRollOut or - AtkEventType.DragDropRollOver; - - ddi = (isDragDropEvent && eventData != null) ? eventData->DragDropData.DragDropInterface : null; - - // Some drag-drop events (notably DragDropRollOver) provide a ComponentNode but not a DragDropInterface. - // IMPORTANT: never read DragDropData.ComponentNode for non-dragdrop events (AtkEventData is a union). - if (ddi == null && isDragDropEvent && eventData != null && eventData->DragDropData.ComponentNode != null) - { - try - { - var compNode = eventData->DragDropData.ComponentNode; - var component = compNode->Component; - ddi = TryGetDdiFromComponent(component); - } - catch - { - // ignore - } - } - - // Fallback: some event types provide MouseData, but the target is still a DragDrop component. - if (ddi == null) - { - var atkEvent = (AtkEvent*)recv.AtkEvent; - if (atkEvent != null && atkEvent->Node != null) - { - var node = atkEvent->Node; - var compNode = node->GetAsAtkComponentNode(); - if (compNode != null) - { - var component = compNode->Component; - ddi = TryGetDdiFromComponent(component); - } - } - } - - if (ddi == null) - return false; - - return true; - } + => DragDropHelpers.TryGetDragDropInterfaceFromReceiveEvent(args, recv, eventType, eventData, out addonId, out ddi); private static bool TryGetSlotFromDragDropInterface( AtkDragDropInterface* ddi, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType invType, + out InventoryType invType, out int slot) - { - invType = default; - slot = -1; - if (ddi == null) - return false; - - var payload = ddi->GetPayloadContainer(); - if (payload == null) - return false; - - invType = (FFXIVClientStructs.FFXIV.Client.Game.InventoryType)payload->Int1; - slot = payload->Int2; - if (slot < 0 || slot > 500) - return false; - - return true; - } + => DragDropHelpers.TryGetSlotFromDragDropInterface(ddi, out invType, out slot); private static bool TryGetSlotFromDragDropInterfaceForAddon( AtkDragDropInterface* ddi, string addonName, uint addonId, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType invType, + out InventoryType invType, out int slot, out int rawInt1, out int rawInt2, out uint rawFlags) - { - invType = default; - slot = -1; - rawInt1 = 0; - rawInt2 = 0; - rawFlags = 0; + => DragDropHelpers.TryGetSlotFromDragDropInterfaceForAddon(ddi, addonName, addonId, out invType, out slot, out rawInt1, out rawInt2, out rawFlags); - if (ddi == null) - return false; - - AtkDragDropPayloadContainer* payload; - try - { - payload = ddi->GetPayloadContainer(); - } - catch - { - return false; - } - if (payload == null) - return false; - - rawInt1 = payload->Int1; - rawInt2 = payload->Int2; - rawFlags = payload->Flags; - - // Default interpretation (most inventory add-ons): (InventoryType, Slot) - invType = (FFXIVClientStructs.FFXIV.Client.Game.InventoryType)rawInt1; - slot = rawInt2; - - // ArmouryBoard special-case: some builds use (CategoryIndex, Slot) - // and Int1 may look like Inventory1..Inventory4 (0..3), which is clearly wrong for ArmouryBoard. - if (!string.IsNullOrEmpty(addonName) && - addonName.Equals("ArmouryBoard", StringComparison.OrdinalIgnoreCase) && - TryGetVisibleAddon("ArmouryBoard", out var ab) && - ab != null && - ab->Id == addonId) - { - if (rawInt1 >= 0 && rawInt1 < ArmouryBoardIndexToType.Length) - { - invType = ArmouryBoardIndexToType[rawInt1]; - slot = rawInt2; - } - } - - if (slot < 0 || slot > 500) - return false; - - return true; - } - - private static int PickContextMenuSlot(FFXIVClientStructs.FFXIV.Client.Game.InventoryType type, int preferredSlot) - { - try - { - var inv = InventoryManager.Instance(); - if (inv == null) - return preferredSlot; - - var c = inv->GetInventoryContainer(type); - if (c == null || !c->IsLoaded || c->Size <= 0) - return preferredSlot; - - // Prefer the hovered slot when in range AND it contains an item. - if (preferredSlot >= 0 && preferredSlot < c->Size) - { - var it0 = c->GetInventorySlot(preferredSlot); - if (it0 != null && it0->ItemId != 0) - return preferredSlot; - } - - // Otherwise open on the first non-empty slot (more likely to produce an inventory context menu). - for (var i = 0; i < c->Size; i++) - { - var it = c->GetInventorySlot(i); - if (it != null && it->ItemId != 0) - return i; - } - - return 0; - } - catch - { - return preferredSlot; - } - } + private static int PickContextMenuSlot(InventoryType type, int preferredSlot) + => DragDropHelpers.PickContextMenuSlot(type, preferredSlot); private static bool TryResolveTargetFromWeirdPayload( - ReadOnlySpan containers, + ReadOnlySpan containers, int rawInt1, int rawInt2, short refIdx, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType type, + out InventoryType type, out int slot) { type = default; @@ -801,14 +549,14 @@ public sealed unsafe class Plugin : IDalamudPlugin // In that case, refuse and require hover capture. var visibleCount = 0; - FFXIVClientStructs.FFXIV.Client.Game.InventoryType chosenType = default; + InventoryType chosenType = default; var chosenSlot = -1; uint chosenAddonId = 0; // ArmouryBoard if (TryGetVisibleAddon("ArmouryBoard", out var ab, WideAddonSearchMaxIndex) && ab != null) { - if (TryResolveTargetFromWeirdPayload(ArmouryBoardIndexToType, -1, -1, -1, out var t, out var s)) + if (TryResolveTargetFromWeirdPayload(DragDropHelpers.ArmouryBoardIndexToType, -1, -1, -1, out var t, out var s)) { visibleCount++; chosenType = t; @@ -948,7 +696,7 @@ public sealed unsafe class Plugin : IDalamudPlugin var addonName = h.Value.AddonName ?? string.Empty; var addonId = h.Value.AddonId; - ReadOnlySpan containers = default; + ReadOnlySpan containers = default; if (addonName.Equals("Inventory", StringComparison.OrdinalIgnoreCase)) containers = PlayerInventoryTypes; else if (addonName.Equals("InventoryBuddy", StringComparison.OrdinalIgnoreCase) || addonName.Equals("InventoryBuddy2", StringComparison.OrdinalIgnoreCase)) @@ -1024,7 +772,7 @@ public sealed unsafe class Plugin : IDalamudPlugin return false; } else if (ArmouryAddonNames.Any(n => addonName.Equals(n, StringComparison.OrdinalIgnoreCase))) - containers = ArmouryBoardIndexToType; + containers = DragDropHelpers.ArmouryBoardIndexToType; if (containers.Length == 0 || addonId == 0) return false; @@ -1148,7 +896,7 @@ public sealed unsafe class Plugin : IDalamudPlugin // Then we can recognize the correct InputNumeric without relying on prompt text. private uint pendingSplitExpectedMax; private long pendingSplitExpectedUntilMs; - private enum PendingNumericKind { None, Store, Remove, Move, Split, Trade } + private enum PendingNumericKind { None, Store, Remove, Move, Split, Trade, Sell } private PendingNumericKind pendingNumericKind; private long lastShiftSeenMs; @@ -1168,7 +916,7 @@ public sealed unsafe class Plugin : IDalamudPlugin private struct CompanyChestDepositState { public bool Active; - public FFXIVClientStructs.FFXIV.Client.Game.InventoryType SourceType; + public InventoryType SourceType; public uint SourceSlot; public uint ItemId; public bool IsHq; @@ -1189,15 +937,15 @@ public sealed unsafe class Plugin : IDalamudPlugin public long ExpiresAtMs; public int Steps; public int Phase; // 0=stack, 1=compact - public FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] Pages; + public InventoryType[] Pages; // Throttle: wait for the last move to apply before issuing another. public bool WaitingForApply; - public FFXIVClientStructs.FFXIV.Client.Game.InventoryType WaitSrcType; + public InventoryType WaitSrcType; public uint WaitSrcSlot; public uint WaitSrcItemId; public int WaitSrcQty; - public FFXIVClientStructs.FFXIV.Client.Game.InventoryType WaitDstType; + public InventoryType WaitDstType; public uint WaitDstSlot; public uint WaitDstItemId; public int WaitDstQty; @@ -1208,7 +956,7 @@ public sealed unsafe class Plugin : IDalamudPlugin private static bool TryGetSlotSnapshot( InventoryManager* inv, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType type, + InventoryType type, uint slot, out uint itemId, out int qty) @@ -1232,7 +980,7 @@ public sealed unsafe class Plugin : IDalamudPlugin } } - private static bool IsContainerLoaded(InventoryManager* inv, FFXIVClientStructs.FFXIV.Client.Game.InventoryType type) + private static bool IsContainerLoaded(InventoryManager* inv, InventoryType type) { try { @@ -1256,38 +1004,31 @@ public sealed unsafe class Plugin : IDalamudPlugin Alt, } - private delegate void OpenForItemSlotDelegate( - AgentInventoryContext* agent, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType, - int slot, - int a4, - uint addonId); - // Inventory/armoury uses this; saddlebags often do not, so we also use IContextMenu fallback. - [Signature("83 B9 ?? ?? ?? ?? ?? 7E ?? 39 91", DetourName = nameof(OpenForItemSlotDetour))] - private Hook? openForItemSlotHook = null; + // Use ClientStructs delegate for better compatibility (per Discord feedback). + private Hook? openForItemSlotHook = null; - private delegate byte AtkUnitBaseCloseDelegate(AtkUnitBase* unitBase, byte a2); - - [Signature("40 53 48 83 EC 50 81 A1", Fallibility = Fallibility.Fallible)] - private AtkUnitBaseCloseDelegate? atkUnitBaseClose = null; + // AtkUnitBase.Close: Use IAddonLifecycle service instead of manual signature (recommended by Dalamud team). + // We use Hide() calls directly where needed, and IAddonLifecycle for addon lifecycle events. // NOTE: For inventory transfers (including Free Company Chest), use the client callback handler: // RaptureAtkModule::HandleItemMove(AtkValue* returnValue, AtkValue* values, uint valueCount) // This is exposed directly by FFXIVClientStructs as a member function, so we do not signature-scan it ourselves. + // Use ContextMenuHandler.AutoContextAction private enum AutoContextAction { - AddAllToSaddlebag, - RemoveAllFromSaddlebag, - PlaceInArmouryChest, - ReturnToInventory, - EntrustToRetainer, - RetrieveFromRetainer, - RemoveFromCompanyChest, - Split, - Sort, - Trade, + AddAllToSaddlebag = ContextMenuHandler.AutoContextAction.AddAllToSaddlebag, + RemoveAllFromSaddlebag = ContextMenuHandler.AutoContextAction.RemoveAllFromSaddlebag, + PlaceInArmouryChest = ContextMenuHandler.AutoContextAction.PlaceInArmouryChest, + ReturnToInventory = ContextMenuHandler.AutoContextAction.ReturnToInventory, + EntrustToRetainer = ContextMenuHandler.AutoContextAction.EntrustToRetainer, + RetrieveFromRetainer = ContextMenuHandler.AutoContextAction.RetrieveFromRetainer, + RemoveFromCompanyChest = ContextMenuHandler.AutoContextAction.RemoveFromCompanyChest, + Split = ContextMenuHandler.AutoContextAction.Split, + Sort = ContextMenuHandler.AutoContextAction.Sort, + Trade = ContextMenuHandler.AutoContextAction.Trade, + Sell = ContextMenuHandler.AutoContextAction.Sell, } private static readonly string[] ArmouryAddonNames = @@ -1325,6 +1066,7 @@ public sealed unsafe class Plugin : IDalamudPlugin private const string FreeCompanyChestAddonName = "FreeCompanyChest"; private const string InputNumericAddonName = "InputNumeric"; private const string ContextMenuAddonName = "ContextMenu"; + private const string SelectYesnoAddonName = "SelectYesno"; // IMPORTANT: // We suppress by forcing alpha to 0 in PreDraw, which can "stick" because the same addon instance is reused. @@ -1338,20 +1080,20 @@ public sealed unsafe class Plugin : IDalamudPlugin private void ArmSuppressInputNumeric(long now, int durationMs = 1500) => suppressInputNumericUntilMs = Math.Max(suppressInputNumericUntilMs, now + durationMs); - private FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] GetCompanyChestInventoryTypes() + private InventoryType[] GetCompanyChestInventoryTypes() { // Don't hardcode enum names; discover them by name at runtime so we don't break across patches/structs. // Limit to the configured number of item compartments (default 3; can be upgraded to 5). var max = Math.Clamp(Configuration.CompanyChestCompartments, 3, 5); - return Enum.GetValues() + return Enum.GetValues() .Where(IsCompanyChestType) .OrderBy(v => (int)v) .Take(max) .ToArray(); } - private static FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] GetAllCompanyChestItemPages() - => Enum.GetValues() + private static InventoryType[] GetAllCompanyChestItemPages() + => Enum.GetValues() .Where(IsCompanyChestType) .OrderBy(v => (int)v) .Take(5) @@ -1396,7 +1138,7 @@ public sealed unsafe class Plugin : IDalamudPlugin } } - private bool TryResolveCompanyChestPageFromAddon(AtkUnitBase* addon, out FFXIVClientStructs.FFXIV.Client.Game.InventoryType page) + private bool TryResolveCompanyChestPageFromAddon(AtkUnitBase* addon, out InventoryType page) { page = default; try @@ -1410,13 +1152,13 @@ public sealed unsafe class Plugin : IDalamudPlugin return false; var maxNodes = Math.Min((int)nodeCount, 2000); - var bestPage = default(FFXIVClientStructs.FFXIV.Client.Game.InventoryType); + var bestPage = default(InventoryType); var bestHits = 0; // Track the most frequently observed FreeCompanyPageX among *visible* nodes. // Rationale: the FC chest addon often keeps nodes for other tabs alive but hidden; a "first match wins" // scan can return the wrong tab (observed off-by-one behavior). - var hitsByPage = new Dictionary(8); + var hitsByPage = new Dictionary(8); for (var i = 0; i < maxNodes; i++) { var n = addon->UldManager.NodeList[i]; @@ -1537,7 +1279,7 @@ public sealed unsafe class Plugin : IDalamudPlugin return false; } - private void ObserveCompanyChestTabFromAtkValues(AtkUnitBase* addon, FFXIVClientStructs.FFXIV.Client.Game.InventoryType selectedPage) + private void ObserveCompanyChestTabFromAtkValues(AtkUnitBase* addon, InventoryType selectedPage) { try { @@ -1559,7 +1301,7 @@ public sealed unsafe class Plugin : IDalamudPlugin if (!companyChestSelectedTabCandidates.TryGetValue(i, out var map)) { - map = new Dictionary(8); + map = new Dictionary(8); companyChestSelectedTabCandidates[i] = map; } @@ -1602,7 +1344,7 @@ public sealed unsafe class Plugin : IDalamudPlugin } } - private bool TryResolveCompanyChestSelectedPageFromAtkValues(uint addonId, out FFXIVClientStructs.FFXIV.Client.Game.InventoryType page) + private bool TryResolveCompanyChestSelectedPageFromAtkValues(uint addonId, out InventoryType page) { page = default; try @@ -1678,7 +1420,41 @@ public sealed unsafe class Plugin : IDalamudPlugin PluginInterface.UiBuilder.OpenConfigUi += OpenConfigUi; InteropProvider.InitializeFromAttributes(this); - openForItemSlotHook?.Enable(); + + // Hook using ClientStructs delegate (per Discord feedback for better compatibility) + try + { + // AgentInventoryContext.Delegates.OpenForItemSlot is the delegate type + // We need to get the function pointer from MemberFunctionPointers to hook it + var funcPtr = AgentInventoryContext.MemberFunctionPointers.OpenForItemSlot; + if (funcPtr != null) + { + openForItemSlotHook = InteropProvider.HookFromAddress( + (nint)funcPtr, + OpenForItemSlotDetour); + openForItemSlotHook?.Enable(); + } + else + { + Log.Warning("[QuickTransfer] AgentInventoryContext.MemberFunctionPointers.OpenForItemSlot is null - signature may not be resolved"); + } + } + catch (Exception ex) + { + Log.Warning(ex, "[QuickTransfer] Failed to hook OpenForItemSlot using ClientStructs delegate - falling back to manual signature"); + // Fallback: try manual signature hook + try + { + openForItemSlotHook = InteropProvider.HookFromSignature( + "83 B9 ?? ?? ?? ?? ?? 7E ?? 39 91", + OpenForItemSlotDetour); + openForItemSlotHook?.Enable(); + } + catch (Exception ex2) + { + Log.Error(ex2, "[QuickTransfer] Failed to hook OpenForItemSlot with fallback signature"); + } + } // Saddlebags can bypass OpenForItemSlot, so use a safe deferred click via context menu events. ContextMenu.OnMenuOpened += OnContextMenuOpened; @@ -1706,7 +1482,7 @@ public sealed unsafe class Plugin : IDalamudPlugin { try { - var matches = Enum.GetNames() + var matches = Enum.GetNames() .Where(n => n.Contains("FreeCompany", StringComparison.OrdinalIgnoreCase) || n.Contains("Company", StringComparison.OrdinalIgnoreCase) || n.Contains("Chest", StringComparison.OrdinalIgnoreCase)) @@ -1812,7 +1588,7 @@ public sealed unsafe class Plugin : IDalamudPlugin private void OpenForItemSlotDetour( AgentInventoryContext* agent, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType, + InventoryType inventoryType, int slot, int a4, uint addonId) @@ -1917,6 +1693,23 @@ public sealed unsafe class Plugin : IDalamudPlugin pendingCompanyChestNumericHalf = false; ArmSuppressInputNumeric(now, 1500); } + // Set up vendor Sell quantity auto-confirm if Sell was selected + if (Configuration.AutoConfirmVendorSell && + mode == ModifierMode.Shift && + chosenText.Length > 0 && + ContextLabelMatches(AutoContextAction.Sell, chosenText) && + IsVendorOpen()) + { + pendingCompanyChestNumericConfirmUntilMs = now + 1500; + pendingCompanyChestNumericConfirmAttempts = 0; + pendingCompanyChestNumericArmed = true; + pendingNumericKind = PendingNumericKind.Sell; + pendingCompanyChestNumericValueSet = false; + pendingCompanyChestNumericValueSetAtMs = 0; + pendingCompanyChestNumericDesired = 0; + pendingCompanyChestNumericHalf = false; + ArmSuppressInputNumeric(now, 1500); + } } else if (Configuration.DebugMode && mode == ModifierMode.Ctrl) { @@ -2032,9 +1825,10 @@ public sealed unsafe class Plugin : IDalamudPlugin lastAltSeenMs = now; // Quantity prompt auto-confirm (best effort). - // Trade and Split always auto-confirm; Company Chest respects the config setting. + // Trade and Split always auto-confirm; Company Chest and Vendor Sell respect their config settings. var shouldAutoConfirm = pendingNumericKind == PendingNumericKind.Trade || pendingNumericKind == PendingNumericKind.Split || + (Configuration.AutoConfirmVendorSell && pendingNumericKind == PendingNumericKind.Sell) || (Configuration.AutoConfirmCompanyChestQuantity && pendingNumericKind != PendingNumericKind.None); if (shouldAutoConfirm && @@ -2191,6 +1985,29 @@ public sealed unsafe class Plugin : IDalamudPlugin Log.Warning(ex, "[QuickTransfer] Failed to auto-confirm InputNumeric."); } } + else if (Configuration.AutoConfirmVendorSell && pendingNumericKind == PendingNumericKind.Sell && IsVendorOpen() && + TryGetVisibleAddon(SelectYesnoAddonName, out var selectYesno) && selectYesno != null) + { + // "Are you certain you wish to sell it?" (unique/untradable) — click OK/Yes. + try + { + selectYesno->FireCallbackInt(0); // 0 = OK/Yes + if (Configuration.DebugMode) + Log.Information("[QuickTransfer] Auto-confirmed vendor sell Yes/No dialog (SelectYesno)."); + } + catch (Exception ex) + { + if (Configuration.DebugMode) + Log.Warning(ex, "[QuickTransfer] Failed to auto-confirm SelectYesno."); + } + pendingCompanyChestNumericConfirmUntilMs = 0; + pendingCompanyChestNumericArmed = false; + pendingNumericKind = PendingNumericKind.None; + pendingCompanyChestNumericValueSet = false; + pendingCompanyChestNumericValueSetAtMs = 0; + pendingCompanyChestNumericDesired = 0; + pendingCompanyChestNumericHalf = false; + } } else if (pendingCompanyChestNumericConfirmUntilMs > 0 && now > pendingCompanyChestNumericConfirmUntilMs) { @@ -2351,7 +2168,6 @@ public sealed unsafe class Plugin : IDalamudPlugin if (cm != null) { try { cm->Hide(false, true, 0); } catch { /* ignore */ } - try { atkUnitBaseClose?.Invoke(cm, 0); } catch { /* ignore */ } } } catch @@ -2460,6 +2276,23 @@ public sealed unsafe class Plugin : IDalamudPlugin pendingCompanyChestNumericHalf = false; ArmSuppressInputNumeric(now, 1500); } + if (Configuration.AutoConfirmVendorSell && + pending.Value.Mode == ModifierMode.Shift && + chosenText.Length > 0 && + ContextLabelMatches(AutoContextAction.Sell, chosenText) && + IsVendorOpen()) + { + // Vendor Sell: auto-confirm max quantity when InputNumeric appears + pendingCompanyChestNumericConfirmUntilMs = now + 1500; + pendingCompanyChestNumericConfirmAttempts = 0; + pendingCompanyChestNumericArmed = true; + pendingNumericKind = PendingNumericKind.Sell; + pendingCompanyChestNumericValueSet = false; + pendingCompanyChestNumericValueSetAtMs = 0; + pendingCompanyChestNumericDesired = 0; + pendingCompanyChestNumericHalf = false; + ArmSuppressInputNumeric(now, 1500); + } if (Configuration.EnableCompanyChest && pending.Value.Mode == ModifierMode.Shift && chosenText.Length > 0 && @@ -2493,7 +2326,7 @@ public sealed unsafe class Plugin : IDalamudPlugin // Record expected split max (qty-1) to recognize the right dialog even if the prompt isn't English. try { - var srcType = (FFXIVClientStructs.FFXIV.Client.Game.InventoryType)agent->TargetInventoryId; + var srcType = (InventoryType)agent->TargetInventoryId; var srcSlot = (int)agent->TargetInventorySlotId; if (TryGetItemInfo(srcType, srcSlot, out _, out _, out var qty) && qty > 1) { @@ -2870,31 +2703,7 @@ public sealed unsafe class Plugin : IDalamudPlugin } } - private static void MakeAddonInvisible(AtkUnitBase* addon) - { - if (addon == null) - return; - var root = addon->RootNode; - if (root == null) - return; - - // Keep it logically visible/interactive, but force it fully transparent before it draws. - root->Color.A = 0; - root->Alpha_2 = 0; - } - - private static void MakeAddonVisible(AtkUnitBase* addon) - { - if (addon == null) - return; - var root = addon->RootNode; - if (root == null) - return; - - // Restore fully visible alpha; this prevents "stuck invisible" menus after a suppression frame. - root->Color.A = 255; - root->Alpha_2 = 255; - } + // Use AtkValueHelpers (implementation moved to AtkValueHelpers.cs) private void OnInputNumericPreSetup(AddonEvent type, AddonArgs args) { @@ -2924,7 +2733,7 @@ public sealed unsafe class Plugin : IDalamudPlugin if (Configuration.DebugMode) Log.Information($"[QuickTransfer] InputNumeric PreSetup (armed): AtkValueCount={count}"); - // Guard against cross-confirmation: only touch the prompt we intended (store/remove). + // Guard against cross-confirmation: only touch the prompt we intended (store/remove/sell). if (pendingNumericKind != PendingNumericKind.None) { var prompt = values[6].Type is AtkValueType.String or AtkValueType.ManagedString ? ReadAtkValueString(values[6]) : string.Empty; @@ -2932,6 +2741,8 @@ public sealed unsafe class Plugin : IDalamudPlugin return; if (pendingNumericKind == PendingNumericKind.Remove && !prompt.Contains("remove", StringComparison.OrdinalIgnoreCase)) return; + if (pendingNumericKind == PendingNumericKind.Sell && !prompt.Contains("sell", StringComparison.OrdinalIgnoreCase)) + return; // For "Move" we accept any prompt while the Company Chest is open (used for internal stack/organize moves). } @@ -2977,6 +2788,7 @@ public sealed unsafe class Plugin : IDalamudPlugin } } + // Use ContextMenuHandler private bool TryAutoSelectAndClose(AgentInventoryContext* agent, ModifierMode mode, out string chosenText, out int chosenIndex) { chosenText = string.Empty; @@ -3001,246 +2813,21 @@ public sealed unsafe class Plugin : IDalamudPlugin private bool TryAutoSelectAndClose(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon, ModifierMode mode, out string chosenText, out int chosenIndex) { - chosenText = string.Empty; - chosenIndex = -1; - - // Single-pass: decode each label once, record first match per action. - var foundAny = false; - - int removeIdx = -1, addIdx = -1, placeIdx = -1, returnIdx = -1, entrustIdx = -1, retrieveIdx = -1, companyRemoveIdx = -1, splitIdx = -1, tradeIdx = -1; - string? removeTxt = null, addTxt = null, placeTxt = null, returnTxt = null, entrustTxt = null, retrieveTxt = null, companyRemoveTxt = null, splitTxt = null, tradeTxt = null; - - var max = Math.Min(agent->ContextItemCount, 64); - for (var i = 0; i < max; i++) - { - var param = agent->EventParams[agent->ContexItemStartIndex + i]; - if (param.Type is not (AtkValueType.String or AtkValueType.ManagedString)) - continue; - - var text = ReadAtkValueString(param); - if (string.IsNullOrWhiteSpace(text)) - continue; - - foundAny = true; - - // Priority matters: we want the first matching index for each action. - if (removeIdx < 0 && ContextLabelMatches(AutoContextAction.RemoveAllFromSaddlebag, text)) - { - removeIdx = i; - removeTxt = text; - continue; - } - - if (companyRemoveIdx < 0 && ContextLabelMatches(AutoContextAction.RemoveFromCompanyChest, text)) - { - companyRemoveIdx = i; - companyRemoveTxt = text; - continue; - } - - if (addIdx < 0 && ContextLabelMatches(AutoContextAction.AddAllToSaddlebag, text)) - { - addIdx = i; - addTxt = text; - continue; - } - - if (placeIdx < 0 && ContextLabelMatches(AutoContextAction.PlaceInArmouryChest, text)) - { - placeIdx = i; - placeTxt = text; - continue; - } - - if (returnIdx < 0 && ContextLabelMatches(AutoContextAction.ReturnToInventory, text)) - { - returnIdx = i; - returnTxt = text; - continue; - } - - if (entrustIdx < 0 && ContextLabelMatches(AutoContextAction.EntrustToRetainer, text)) - { - entrustIdx = i; - entrustTxt = text; - continue; - } - - if (retrieveIdx < 0 && ContextLabelMatches(AutoContextAction.RetrieveFromRetainer, text)) - { - retrieveIdx = i; - retrieveTxt = text; - continue; - } - - if (splitIdx < 0 && ContextLabelMatches(AutoContextAction.Split, text)) - { - splitIdx = i; - splitTxt = text; - continue; - } - - if (tradeIdx < 0 && ContextLabelMatches(AutoContextAction.Trade, text)) - { - tradeIdx = i; - tradeTxt = text; - } - } - - if (!foundAny) - return false; - - var saddlebagOpen = IsSaddlebagOpen(); - var retainerOpen = IsRetainerOpen(); - var companyChestOpen = IsCompanyChestOpen(); - var tradeOpen = IsTradeOpen(); - - // Choose the best action that exists in the menu. - // - // When Company Chest is open: - // - Shift mode: remove from chest (withdraw) - // - Ctrl mode: armoury actions (Inventory <-> Armoury) are allowed (like other "special" containers) - // - // When Retainer is open: - // - Shift mode: retainer actions (Entrust/Retrieve), and if Saddlebags are also open, retainer<->saddlebag. - // - // When Saddlebags are open (no retainer): - // - Shift mode: saddlebag actions (Add/Remove) - // - // Ctrl mode (only enabled when Retainer OR Saddlebags are open): - // - Armoury actions (Inventory <-> Armoury): Return/Place - // - // No Retainer/Saddlebags: - // - Shift mode: allow armoury transfers (Place/Return). - (int idx, string? txt) chosen; - if (mode == ModifierMode.Alt) - { - chosen = splitIdx >= 0 ? (splitIdx, splitTxt) : (-1, (string?)null); - } - else if (mode == ModifierMode.Shift && tradeOpen) - { - // Trade window: prioritize Trade action when Trade window is open - chosen = tradeIdx >= 0 ? (tradeIdx, tradeTxt) : (-1, (string?)null); - } - else if (mode == ModifierMode.Shift && companyChestOpen && Configuration.EnableCompanyChest) - { - chosen = companyRemoveIdx >= 0 ? (companyRemoveIdx, companyRemoveTxt) : (-1, (string?)null); - } - else if (mode == ModifierMode.Ctrl) - { - chosen = returnIdx >= 0 ? (returnIdx, returnTxt) : - placeIdx >= 0 ? (placeIdx, placeTxt) : - (-1, (string?)null); - } - else if (retainerOpen) - { - if (saddlebagOpen) - { - // Retainer <-> Saddlebag: - // - Retainer item: Add All to Saddlebag - // - Saddlebag item: Entrust to Retainer - chosen = addIdx >= 0 ? (addIdx, addTxt) : - entrustIdx >= 0 ? (entrustIdx, entrustTxt) : - // last-resort fallback - removeIdx >= 0 ? (removeIdx, removeTxt) : - (-1, (string?)null); - } - else - { - // Retainer <-> Player (Inventory/Armoury): - // - Retainer item: Retrieve from Retainer - // - Player item: Entrust to Retainer - chosen = retrieveIdx >= 0 ? (retrieveIdx, retrieveTxt) : - entrustIdx >= 0 ? (entrustIdx, entrustTxt) : - (-1, (string?)null); - } - } - else if (saddlebagOpen) - { - chosen = removeIdx >= 0 ? (removeIdx, removeTxt) : - addIdx >= 0 ? (addIdx, addTxt) : - (-1, (string?)null); - } - else - { - chosen = placeIdx >= 0 ? (placeIdx, placeTxt) : - returnIdx >= 0 ? (returnIdx, returnTxt) : - (-1, (string?)null); - } - - if (chosen.idx < 0 || string.IsNullOrWhiteSpace(chosen.txt)) - return false; - - GenerateCallback(contextMenuAddon, 0, chosen.idx, 0U, 0, 0); - - // Some actions (notably Split and Trade) can be cancelled if we close the menu immediately. - // Delay the close slightly to allow the follow-up UI (InputNumeric) to spawn. - if (chosen.txt != null && (ContextLabelMatches(AutoContextAction.Split, chosen.txt) || ContextLabelMatches(AutoContextAction.Trade, chosen.txt))) - { - // Don't close immediately: on some setups this cancels the action before InputNumeric opens. - // We'll keep the menu invisible (via suppression) and close it later as a cleanup. - pendingCloseContextMenuAtMs = Environment.TickCount64 + 3000; - } - else - { - CloseContextMenuAddon(agent, contextMenuAddon); - } - - chosenText = chosen.txt!; - chosenIndex = chosen.idx; - return true; + return ContextMenuHandler.TryAutoSelectAndClose( + agent, + contextMenuAddon, + (ContextMenuHandler.ModifierMode)mode, + Configuration, + out chosenText, + out chosenIndex, + ref pendingCloseContextMenuAtMs); } + // Use ContextMenuHandler private bool TrySelectSortAndClose(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon, out string chosenText, out int chosenIndex) - { - chosenText = string.Empty; - chosenIndex = -1; + => ContextMenuHandler.TrySelectSortAndClose(agent, contextMenuAddon, out chosenText, out chosenIndex); - var undoSortIdx = -1; - string? undoSortText = null; - - var max = Math.Min(agent->ContextItemCount, 64); - for (var i = 0; i < max; i++) - { - var param = agent->EventParams[agent->ContexItemStartIndex + i]; - if (param.Type is not (AtkValueType.String or AtkValueType.ManagedString)) - continue; - - var text = ReadAtkValueString(param); - if (string.IsNullOrWhiteSpace(text)) - continue; - - // If Sort isn't present (because the container is already sorted), the menu often contains "Undo Sort" instead. - // We treat that as "already sorted" and do nothing (closing the menu). - if (undoSortIdx < 0 && text.Trim().Equals("Undo Sort", StringComparison.OrdinalIgnoreCase)) - { - undoSortIdx = i; - undoSortText = text; - } - - if (!ContextLabelMatches(AutoContextAction.Sort, text)) - continue; - - GenerateCallback(contextMenuAddon, 0, i, 0U, 0, 0); - CloseContextMenuAddon(agent, contextMenuAddon); - chosenText = text; - chosenIndex = i; - return true; - } - - // No "Sort" entry. If "Undo Sort" exists, we're already sorted; close the menu without changing state. - if (undoSortIdx >= 0) - { - try { CloseContextMenuAddon(agent, contextMenuAddon); } catch { /* ignore */ } - chosenText = "Already sorted"; - chosenIndex = -1; - return true; - } - - return false; - } - - private bool StartCompanyChestDeposit(FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType, uint sourceSlot) + private bool StartCompanyChestDeposit(InventoryType sourceType, uint sourceSlot) { try { @@ -3435,7 +3022,7 @@ public sealed unsafe class Plugin : IDalamudPlugin Log.Information($"[QuickTransfer] (MMB) Company Chest organize started (pages=[{string.Join(", ", pages)}])."); } - private void StartCompanyChestOrganize(long now, FFXIVClientStructs.FFXIV.Client.Game.InventoryType selectedPage) + private void StartCompanyChestOrganize(long now, InventoryType selectedPage) { if (!IsCompanyChestType(selectedPage)) { @@ -3557,7 +3144,7 @@ public sealed unsafe class Plugin : IDalamudPlugin // If the selected page isn't loaded yet (loading spinner), wait. try { - var pages0 = companyChestOrganize.Pages ?? Array.Empty(); + var pages0 = companyChestOrganize.Pages ?? Array.Empty(); var inv0 = InventoryManager.Instance(); if (inv0 != null && pages0.Length > 0) { @@ -3699,7 +3286,7 @@ public sealed unsafe class Plugin : IDalamudPlugin return; } - var pages = companyChestOrganize.Pages ?? Array.Empty(); + var pages = companyChestOrganize.Pages ?? Array.Empty(); if (pages.Length == 0) { companyChestOrganize.Active = false; @@ -3887,10 +3474,10 @@ public sealed unsafe class Plugin : IDalamudPlugin } private bool TryFindCompanyChestMergeMove( - FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType srcType, + InventoryType[] pages, + out InventoryType srcType, out uint srcSlot, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType dstType, + out InventoryType dstType, out uint dstSlot, out bool needsNumeric) { @@ -3980,10 +3567,10 @@ public sealed unsafe class Plugin : IDalamudPlugin } private static bool TryFindCompanyChestCompactionMove( - FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType srcType, + InventoryType[] pages, + out InventoryType srcType, out uint srcSlot, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType dstType, + out InventoryType dstType, out uint dstSlot) { srcType = default; @@ -4060,10 +3647,10 @@ public sealed unsafe class Plugin : IDalamudPlugin } private static bool TryFindCompanyChestSortMove( - FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType srcType, + InventoryType[] pages, + out InventoryType srcType, out uint srcSlot, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType dstType, + out InventoryType dstType, out uint dstSlot) { srcType = default; @@ -4155,9 +3742,9 @@ public sealed unsafe class Plugin : IDalamudPlugin } private bool TryCompanyChestMoveItem( - FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType, + InventoryType sourceType, uint sourceSlot, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType, + InventoryType destType, uint destSlot, bool keepAliveForInputNumeric) { @@ -4242,12 +3829,12 @@ public sealed unsafe class Plugin : IDalamudPlugin } } - private static FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] GetSplitCandidateTypes(FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType) + private static InventoryType[] GetSplitCandidateTypes(InventoryType sourceType) { // Prefer the same container first, then fall back to other pages of the same "kind". // This mirrors the game's "Split" behavior which places the new stack into an empty slot in the same inventory group. - var tmp = new List(capacity: 8); - void Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType t) + var tmp = new List(capacity: 8); + void Add(InventoryType t) { for (var i = 0; i < tmp.Count; i++) if (tmp[i] == t) @@ -4258,27 +3845,27 @@ public sealed unsafe class Plugin : IDalamudPlugin Add(sourceType); if (IsPlayerInventoryType(sourceType)) { - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory1); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory2); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory3); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory4); + Add(InventoryType.Inventory1); + Add(InventoryType.Inventory2); + Add(InventoryType.Inventory3); + Add(InventoryType.Inventory4); } else if (IsSaddlebagType(sourceType)) { - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.SaddleBag1); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.SaddleBag2); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.PremiumSaddleBag1); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.PremiumSaddleBag2); + Add(InventoryType.SaddleBag1); + Add(InventoryType.SaddleBag2); + Add(InventoryType.PremiumSaddleBag1); + Add(InventoryType.PremiumSaddleBag2); } else if (IsRetainerType(sourceType)) { - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage1); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage2); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage3); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage4); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage5); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage6); - Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage7); + Add(InventoryType.RetainerPage1); + Add(InventoryType.RetainerPage2); + Add(InventoryType.RetainerPage3); + Add(InventoryType.RetainerPage4); + Add(InventoryType.RetainerPage5); + Add(InventoryType.RetainerPage6); + Add(InventoryType.RetainerPage7); } return tmp.ToArray(); @@ -4286,8 +3873,8 @@ public sealed unsafe class Plugin : IDalamudPlugin private static bool TryFindFirstEmptySlotForSplit( InventoryManager* inv, - FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] candidates, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType, + InventoryType[] candidates, + out InventoryType destType, out uint destSlot) { destType = default; @@ -4319,7 +3906,7 @@ public sealed unsafe class Plugin : IDalamudPlugin } private bool TryStartSplitHalfMove( - FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType, + InventoryType sourceType, uint sourceSlot, long now, out string reason) @@ -4398,8 +3985,8 @@ public sealed unsafe class Plugin : IDalamudPlugin } private static bool TryFindCompanyChestFirstEmptySlot( - FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType, + InventoryType[] pages, + out InventoryType destType, out uint destSlot) { destType = default; @@ -4433,11 +4020,11 @@ public sealed unsafe class Plugin : IDalamudPlugin } private static bool TryFindCompanyChestBestStackSlot( - FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages, + InventoryType[] pages, uint itemId, bool isHq, uint maxStack, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType, + out InventoryType destType, out uint destSlot) { destType = default; @@ -4517,6 +4104,12 @@ public sealed unsafe class Plugin : IDalamudPlugin if (!IsTradeOpen()) return false; } + // Vendor sell dialogs may be localized; accept if prompt contains "sell" or vendor is open. + if (kind == PendingNumericKind.Sell && !prompt.Contains("sell", StringComparison.OrdinalIgnoreCase)) + { + if (!IsVendorOpen()) + return false; + } if (minValue->Type != AtkValueType.UInt || maxValue->Type != AtkValueType.UInt || defaultValue->Type != AtkValueType.UInt) return false; @@ -4793,14 +4386,14 @@ public sealed unsafe class Plugin : IDalamudPlugin // (removed) GetMoveContainerId: // Company Chest transfers must use InventoryType values directly with RaptureAtkModule.HandleItemMove. private static bool TryFindFirstCompanyChestEmptySlot( - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType, + out InventoryType destType, out uint destSlot) { destType = default; destSlot = 0; // This method is static; use all known pages as a fallback, callers should prefer GetCompanyChestInventoryTypes(). - var invTypes = Enum.GetValues() + var invTypes = Enum.GetValues() .Where(IsCompanyChestType) .OrderBy(v => (int)v) .ToArray(); @@ -4838,13 +4431,13 @@ public sealed unsafe class Plugin : IDalamudPlugin uint itemId, bool isHq, uint maxStack, - out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType, + out InventoryType destType, out uint destSlot) { destType = default; destSlot = 0; - var invTypes = Enum.GetValues() + var invTypes = Enum.GetValues() .Where(IsCompanyChestType) .OrderBy(v => (int)v) .ToArray(); @@ -4896,110 +4489,13 @@ public sealed unsafe class Plugin : IDalamudPlugin return bestFree > 0; } - private static uint GetItemStackSize(uint itemId) - { - try - { - // If item isn't known/stackable, return 1. - if (itemId == 0) - return 1; + // Use InventoryHelpers + private static uint GetItemStackSize(uint itemId) => InventoryHelpers.GetItemStackSize(itemId); + private static uint GetItemUiCategory(uint itemId) => InventoryHelpers.GetItemUiCategory(itemId); - lock (StackSizeCache) - { - if (StackSizeCache.TryGetValue(itemId, out var cached)) - return cached; - } - - var sheet = DataManager.GetExcelSheet(); - if (sheet == null) - return 999; - - // Item row IDs are base IDs; InventoryItem.ItemId is expected to already be base. - var row = sheet.GetRow(itemId); - if (row.RowId == 0) - return 999; - - // In modern Lumina sheets, Item.StackSize exists. - var s = row.StackSize; - var result = s <= 0 ? 1U : (uint)s; - lock (StackSizeCache) - StackSizeCache[itemId] = result; - return result; - } - catch - { - // Fallback: most stackables are 999, and non-stackables will hit maxStack <= 1 cases anyway. - return 999; - } - } - - private static uint GetItemUiCategory(uint itemId) - { - try - { - if (itemId == 0) - return 0; - - lock (ItemUiCategoryCache) - { - if (ItemUiCategoryCache.TryGetValue(itemId, out var cached)) - return cached; - } - - var sheet = DataManager.GetExcelSheet(); - if (sheet == null) - return 0; - - var row = sheet.GetRow(itemId); - if (row.RowId == 0) - return 0; - - // Prefer UI category; this tends to match how game sorts items visually. - uint result; - try - { - // Lumina RowRef usually exposes RowId. - result = row.ItemUICategory.RowId; - } - catch - { - result = 0; - } - - lock (ItemUiCategoryCache) - ItemUiCategoryCache[itemId] = result; - return result; - } - catch - { - return 0; - } - } - - private static bool TryGetItemInfo( - FFXIVClientStructs.FFXIV.Client.Game.InventoryType type, - int slot, - out uint itemId, - out bool isHq, - out uint quantity) - { - itemId = 0; - isHq = false; - quantity = 0; - - var inv = InventoryManager.Instance(); - if (inv == null) - return false; - - var it = inv->GetInventorySlot(type, slot); - if (it == null) - return false; - - itemId = it->ItemId; - isHq = it->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality); - quantity = (uint)it->Quantity; - return itemId != 0; - } + // Use InventoryHelpers + private static bool TryGetItemInfo(InventoryType type, int slot, out uint itemId, out bool isHq, out uint quantity) + => InventoryHelpers.TryGetItemInfo(type, slot, out itemId, out isHq, out quantity); private ModifierMode? GetModifierModeLatched(long nowMs) { @@ -5070,122 +4566,21 @@ public sealed unsafe class Plugin : IDalamudPlugin } } - private static bool IsPlayerInventoryType(FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType) - => inventoryType is - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory1 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory2 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory3 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory4; + // Use InventoryHelpers for these functions + private static bool IsPlayerInventoryType(InventoryType inventoryType) => InventoryHelpers.IsPlayerInventoryType(inventoryType); + private static bool IsArmouryType(InventoryType inventoryType) => InventoryHelpers.IsArmouryType(inventoryType); + private static bool IsSaddlebagOpen() => InventoryHelpers.IsSaddlebagOpen(); + private static bool IsSaddlebagType(InventoryType inventoryType) => InventoryHelpers.IsSaddlebagType(inventoryType); + private static bool IsRetainerOpen() => InventoryHelpers.IsRetainerOpen(); + private static bool IsRetainerType(InventoryType inventoryType) => InventoryHelpers.IsRetainerType(inventoryType); + private static bool IsCompanyChestOpen() => InventoryHelpers.IsCompanyChestOpen(); + private static bool IsTradeOpen() => InventoryHelpers.IsTradeOpen(); + private static bool IsVendorOpen() => InventoryHelpers.IsVendorOpen(); + private static bool IsCompanyChestType(InventoryType inventoryType) => InventoryHelpers.IsCompanyChestType(inventoryType); + private static bool IsAddonVisibleAnyIndex(string addonName, int maxIndex = 6) => InventoryHelpers.IsAddonVisibleAnyIndex(addonName, maxIndex); + private static bool TryGetVisibleAddon(string addonName, out AtkUnitBase* addon, int maxIndex = 6) => InventoryHelpers.TryGetVisibleAddon(addonName, out addon, maxIndex); - private static bool IsArmouryType(FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType) - => inventoryType is - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryMainHand or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryOffHand or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryHead or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryBody or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryHands or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryWaist or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryLegs or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryFeets or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryEar or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryNeck or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryWrist or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmoryRings or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.ArmorySoulCrystal; - - private static bool IsAddonVisible(string addonName, int index = 1) - { - var addon = GameGui.GetAddonByName(addonName, index); - return !addon.IsNull && addon.IsVisible; - } - - private static bool IsAddonVisibleAnyIndex(string addonName, int maxIndex = 6) - { - for (var i = 1; i <= maxIndex; i++) - { - if (IsAddonVisible(addonName, i)) - return true; - } - - return false; - } - - private static bool IsAnyAddonVisible(IEnumerable addonNames, int index = 1) - { - foreach (var name in addonNames) - { - if (IsAddonVisible(name, index)) - return true; - } - - return false; - } - - private static bool IsAnyAddonVisibleAnyIndex(IEnumerable addonNames, int maxIndex = 6) - { - foreach (var name in addonNames) - { - if (IsAddonVisibleAnyIndex(name, maxIndex)) - return true; - } - - return false; - } - - private static bool IsInventoryAndSaddlebagOpen() - { - var inventoryOpen = IsAddonVisibleAnyIndex("Inventory"); - var saddlebagOpen = IsAddonVisibleAnyIndex("InventoryBuddy") || IsAddonVisibleAnyIndex("InventoryBuddy2"); - return inventoryOpen && saddlebagOpen; - } - - private static bool IsSaddlebagOpen() - => IsAddonVisibleAnyIndex("InventoryBuddy") || IsAddonVisibleAnyIndex("InventoryBuddy2"); - - private static bool IsSaddlebagType(FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType) - => inventoryType is - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.SaddleBag1 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.SaddleBag2 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.PremiumSaddleBag1 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.PremiumSaddleBag2; - - private static bool IsRetainerOpen() - { - // Common retainer inventory addons. - // (SimpleTweaks checks "RetainerGrid0" for retainer inventory visibility.) - return IsAddonVisibleAnyIndex("RetainerGrid0") || - IsAddonVisibleAnyIndex("RetainerSellList") || - IsAddonVisibleAnyIndex("RetainerGrid"); - } - - private static bool IsRetainerType(FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType) - => inventoryType is - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage1 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage2 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage3 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage4 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage5 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage6 or - FFXIVClientStructs.FFXIV.Client.Game.InventoryType.RetainerPage7; - - private static bool IsCompanyChestOpen() - => IsAddonVisibleAnyIndex(FreeCompanyChestAddonName); - - private static bool IsTradeOpen() - => IsAddonVisibleAnyIndex("Trade") || IsAddonVisibleAnyIndex("TradeWindow"); - - private static bool IsCompanyChestType(FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType) - { - var name = Enum.GetName(typeof(FFXIVClientStructs.FFXIV.Client.Game.InventoryType), inventoryType); - if (string.IsNullOrEmpty(name)) - return false; - - // We only want the *item compartments*, not crystals/gil/etc. - // Observed names: FreeCompanyPage1..FreeCompanyPage5 - return name.StartsWith("FreeCompanyPage", StringComparison.OrdinalIgnoreCase); - } - - private bool TryMapCompanyChestTabParamToPage(int eventParam, out FFXIVClientStructs.FFXIV.Client.Game.InventoryType page) + private bool TryMapCompanyChestTabParamToPage(int eventParam, out InventoryType page) { page = default; try @@ -5217,205 +4612,22 @@ public sealed unsafe class Plugin : IDalamudPlugin return false; } - private static bool TryGetVisibleAddon(string addonName, out AtkUnitBase* addon, int maxIndex = 6) - { - addon = null; - for (var i = 1; i <= maxIndex; i++) - { - var a = GameGui.GetAddonByName(addonName, i); - if (!a.IsNull && a.IsVisible) - { - addon = (AtkUnitBase*)a.Address; - return true; - } - } - - return false; - } + // Use InventoryHelpers (implementation moved to InventoryHelpers.cs) + // Use ContextMenuHandler private void CloseContextMenuAddon(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon) - { - try { agent->AgentInterface.Hide(); } catch { /* ignore */ } - try { contextMenuAddon->Hide(false, true, 0); } catch { /* ignore */ } - try { atkUnitBaseClose?.Invoke(contextMenuAddon, 0); } catch { /* ignore */ } - } + => ContextMenuHandler.CloseContextMenuAddon(agent, contextMenuAddon); + // Use ContextMenuHandler private static bool ContextLabelMatches(AutoContextAction desiredAction, string menuText) - { - var t = menuText.Trim(); - static bool Has(string s, string needle) => s.Contains(needle, StringComparison.OrdinalIgnoreCase); + => ContextMenuHandler.ContextLabelMatches((ContextMenuHandler.AutoContextAction)desiredAction, menuText); - return desiredAction switch - { - AutoContextAction.AddAllToSaddlebag => - t.Equals("Add All to Saddlebag", StringComparison.OrdinalIgnoreCase) || - (Has(t, "Add All") && Has(t, "Saddlebag")), - - AutoContextAction.RemoveAllFromSaddlebag => - t.Equals("Remove All from Saddlebag", StringComparison.OrdinalIgnoreCase) || - (Has(t, "Remove All") && Has(t, "Saddlebag")) || - (Has(t, "Remove") && Has(t, "Saddlebag")) || - t.Equals("Remove All", StringComparison.OrdinalIgnoreCase) || - ((Has(t, "Retrieve") || Has(t, "Take out") || Has(t, "Take Out")) && Has(t, "Saddlebag")), - - AutoContextAction.PlaceInArmouryChest => - t.Equals("Place in Armoury Chest", StringComparison.OrdinalIgnoreCase) || - (Has(t, "Place") && (Has(t, "Armoury") || Has(t, "Armory")) && Has(t, "Chest")), - - AutoContextAction.ReturnToInventory => - t.Equals("Return to Inventory", StringComparison.OrdinalIgnoreCase) || - (Has(t, "Return") && Has(t, "Inventory")), - - AutoContextAction.EntrustToRetainer => - t.Equals("Entrust to Retainer", StringComparison.OrdinalIgnoreCase) || - (Has(t, "Entrust") && Has(t, "Retainer")), - - AutoContextAction.RetrieveFromRetainer => - t.Equals("Retrieve from Retainer", StringComparison.OrdinalIgnoreCase) || - (Has(t, "Retrieve") && Has(t, "Retainer")), - - AutoContextAction.RemoveFromCompanyChest => - t.Equals("Remove", StringComparison.OrdinalIgnoreCase) || - (Has(t, "Remove") && (Has(t, "Company") || Has(t, "Chest"))) || - (Has(t, "Withdraw") && (Has(t, "Company") || Has(t, "Chest"))), - - AutoContextAction.Split => - t.Equals("Split", StringComparison.OrdinalIgnoreCase) || - t.StartsWith("Split", StringComparison.OrdinalIgnoreCase), - - AutoContextAction.Sort => - t.Equals("Sort", StringComparison.OrdinalIgnoreCase) || - t.StartsWith("Sort", StringComparison.OrdinalIgnoreCase), - - AutoContextAction.Trade => - t.Equals("Trade", StringComparison.OrdinalIgnoreCase) || - t.StartsWith("Trade", StringComparison.OrdinalIgnoreCase) || - (Has(t, "Trade") && Has(t, "Item")), - - _ => false, - }; - } - - private static string ReadAtkValueString(AtkValue v) - { - if (v.String == null) - return string.Empty; - - try - { - // SimpleTweaks-style decoding. - return Marshal.PtrToStringUTF8(new IntPtr(v.String))?.TrimEnd('\0') ?? string.Empty; - } - catch - { - return ReadUtf8(v.String); - } - } - - private static string ReadUtf8(byte* ptr) - { - if (ptr == null) - return string.Empty; - - var len = 0; - while (ptr[len] != 0) - len++; - - return len <= 0 ? string.Empty : Encoding.UTF8.GetString(ptr, len); - } - - private const int UnitListCount = 18; - - private static AtkUnitBase* GetAddonById(uint id) - { - var unitManagers = &AtkStage.Instance()->RaptureAtkUnitManager->AtkUnitManager.DepthLayerOneList; - for (var i = 0; i < UnitListCount; i++) - { - var unitManager = &unitManagers[i]; - for (var j = 0; j < Math.Min(unitManager->Count, unitManager->Entries.Length); j++) - { - var unitBase = unitManager->Entries[j].Value; - if (unitBase != null && unitBase->Id == id) - return unitBase; - } - } - - return null; - } - - private static AtkValue* CreateAtkValueArray(params object[] values) - { - var atkValues = (AtkValue*)Marshal.AllocHGlobal(values.Length * sizeof(AtkValue)); - if (atkValues == null) - return null; - - try - { - for (var i = 0; i < values.Length; i++) - { - var v = values[i]; - switch (v) - { - case uint u: - atkValues[i].Type = AtkValueType.UInt; - atkValues[i].UInt = u; - break; - case int n: - atkValues[i].Type = AtkValueType.Int; - atkValues[i].Int = n; - break; - case float f: - atkValues[i].Type = AtkValueType.Float; - atkValues[i].Float = f; - break; - case bool b: - atkValues[i].Type = AtkValueType.Bool; - atkValues[i].Byte = (byte)(b ? 1 : 0); - break; - case string s: - { - atkValues[i].Type = AtkValueType.String; - var bytes = Encoding.UTF8.GetBytes(s); - var alloc = Marshal.AllocHGlobal(bytes.Length + 1); - Marshal.Copy(bytes, 0, alloc, bytes.Length); - Marshal.WriteByte(alloc, bytes.Length, 0); - atkValues[i].String = (byte*)alloc; - break; - } - default: - throw new ArgumentException($"Unsupported AtkValue type {v.GetType()}"); - } - } - } - catch - { - Marshal.FreeHGlobal(new IntPtr(atkValues)); - return null; - } - - return atkValues; - } - - private static void GenerateCallback(AtkUnitBase* unitBase, params object[] values) - { - var atkValues = CreateAtkValueArray(values); - if (atkValues == null) - return; - - try - { - unitBase->FireCallback((uint)values.Length, atkValues); - } - finally - { - for (var i = 0; i < values.Length; i++) - { - if (atkValues[i].Type == AtkValueType.String) - Marshal.FreeHGlobal(new IntPtr(atkValues[i].String)); - } - - Marshal.FreeHGlobal(new IntPtr(atkValues)); - } - } + // Use AtkValueHelpers + private static string ReadAtkValueString(AtkValue v) => AtkValueHelpers.ReadAtkValueString(v); + private static string ReadUtf8(byte* ptr) => AtkValueHelpers.ReadUtf8(ptr); + private static AtkUnitBase* GetAddonById(uint id) => AtkValueHelpers.GetAddonById(id); + private static void GenerateCallback(AtkUnitBase* unitBase, params object[] values) => AtkValueHelpers.GenerateCallback(unitBase, values); + private static void MakeAddonInvisible(AtkUnitBase* addon) => AtkValueHelpers.MakeAddonInvisible(addon); + private static void MakeAddonVisible(AtkUnitBase* addon) => AtkValueHelpers.MakeAddonVisible(addon); } diff --git a/QuickTransfer.csproj b/QuickTransfer.csproj index 38b3b71..019d340 100644 --- a/QuickTransfer.csproj +++ b/QuickTransfer.csproj @@ -8,9 +8,9 @@ QuickTransfer Library false - 1.0.4 - 1.0.4.0 - 1.0.4.0 + 1.0.5 + 1.0.5.0 + 1.0.5.0