Release 1.0.5: Vendor Quick Sell, auto-confirm sell dialogs, README update

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-01-27 20:05:39 -05:00
parent 633cd487e4
commit d7df385239
10 changed files with 1484 additions and 1072 deletions
+207
View File
@@ -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;
/// <summary>
/// Utility functions for working with AtkValue structures.
/// </summary>
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;
}
}
+348
View File
@@ -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;
/// <summary>
/// Handles context menu selection and matching logic.
/// </summary>
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;
}
}
+299
View File
@@ -0,0 +1,299 @@
using System;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
namespace QuickTransfer;
/// <summary>
/// Helper functions for parsing drag-drop interfaces from UI events.
/// </summary>
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;
}
}
+306
View File
@@ -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;
/// <summary>
/// Static helper functions for inventory detection, type checking, and addon visibility.
/// </summary>
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<string> addonNames, int index = 1)
{
foreach (var name in addonNames)
{
if (IsAddonVisible(name, index))
return true;
}
return false;
}
public static bool IsAnyAddonVisibleAnyIndex(IEnumerable<string> 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<uint, uint> StackSizeCache = new();
private static readonly Dictionary<uint, uint> 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<Lumina.Excel.Sheets.Item>();
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<Lumina.Excel.Sheets.Item>();
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;
}
}
}
+271 -1059
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -8,9 +8,9 @@
<RootNamespace>QuickTransfer</RootNamespace> <RootNamespace>QuickTransfer</RootNamespace>
<OutputType>Library</OutputType> <OutputType>Library</OutputType>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<Version>1.0.4</Version> <Version>1.0.5</Version>
<AssemblyVersion>1.0.4.0</AssemblyVersion> <AssemblyVersion>1.0.5.0</AssemblyVersion>
<FileVersion>1.0.4.0</FileVersion> <FileVersion>1.0.5.0</FileVersion>
</PropertyGroup> </PropertyGroup>
<!-- Local builds: some setups have DALAMUD_HOME pointing at the XIVLauncher root, <!-- Local builds: some setups have DALAMUD_HOME pointing at the XIVLauncher root,
+2 -2
View File
@@ -2,8 +2,8 @@
"Author": "flick", "Author": "flick",
"Name": "QuickTransfer", "Name": "QuickTransfer",
"InternalName": "QuickTransfer", "InternalName": "QuickTransfer",
"AssemblyVersion": "1.0.4.0", "AssemblyVersion": "1.0.5.0",
"Description": "Automate inventory transfers with Shift/Ctrl/Alt + Right-Click. Includes Trade window support.", "Description": "Automate inventory transfers with Shift/Ctrl/Alt + Right-Click. Trade window, vendor quick sell, FC chest.",
"ApplicableVersion": "any", "ApplicableVersion": "any",
"RepoUrl": "https://github.com/Knack117/QuickTransfer", "RepoUrl": "https://github.com/Knack117/QuickTransfer",
"Tags": [ "Tags": [
+20
View File
@@ -91,6 +91,25 @@ public class QuickTransferWindow : Window, IDisposable
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextColored(new Vector4(0.85f, 0.75f, 0.45f, 0.9f), "(Best effort; disable if it misbehaves)"); ImGui.TextColored(new Vector4(0.85f, 0.75f, 0.45f, 0.9f), "(Best effort; disable if it misbehaves)");
// Vendor Quick Sell
var enableVendorQuickSell = _config.EnableVendorQuickSell;
if (ImGui.Checkbox("Enable Vendor Quick Sell###EnableVendorQuickSell", ref enableVendorQuickSell))
{
_config.EnableVendorQuickSell = enableVendorQuickSell;
_config.Save();
}
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Shift+RClick: auto-select \"Sell\" when vendor is open)");
var autoConfirmVendorSell = _config.AutoConfirmVendorSell;
if (ImGui.Checkbox("Auto-confirm vendor sell dialogs###AutoConfirmVendorSell", ref autoConfirmVendorSell))
{
_config.AutoConfirmVendorSell = autoConfirmVendorSell;
_config.Save();
}
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Auto-fill quantity, confirm \"How many?\", and click OK on \"Are you certain?\")");
// Transfer cooldown // Transfer cooldown
ImGui.Spacing(); ImGui.Spacing();
ImGui.Text("Transfer Cooldown (ms):"); ImGui.Text("Transfer Cooldown (ms):");
@@ -118,6 +137,7 @@ public class QuickTransferWindow : Window, IDisposable
ImGui.BulletText("Retainer + Saddlebags: Retainer → \"Add All to Saddlebag\", Saddlebags → \"Entrust to Retainer\""); ImGui.BulletText("Retainer + Saddlebags: Retainer → \"Add All to Saddlebag\", Saddlebags → \"Entrust to Retainer\"");
ImGui.BulletText("Inventory + Armoury (no special container): (Gear) Inventory → \"Place in Armoury Chest\", Armoury → \"Return to Inventory\""); ImGui.BulletText("Inventory + Armoury (no special container): (Gear) Inventory → \"Place in Armoury Chest\", Armoury → \"Return to Inventory\"");
ImGui.BulletText("Company Chest (FreeCompanyChest) open: Shift+RClick Inventory/Armoury deposits, Shift+RClick Company Chest withdraws (\"Remove\")"); ImGui.BulletText("Company Chest (FreeCompanyChest) open: Shift+RClick Inventory/Armoury deposits, Shift+RClick Company Chest withdraws (\"Remove\")");
ImGui.BulletText("Vendor Shop open: Shift+RClick to auto-select \"Sell\"; enable \"Auto-confirm vendor sell\" to auto-fill quantity and confirm.");
ImGui.BulletText("Middle-Click: Sort the clicked container when a \"Sort\" menu entry exists. In Company Chest, MMB runs an organize pass (stack + compact)."); ImGui.BulletText("Middle-Click: Sort the clicked container when a \"Sort\" menu entry exists. In Company Chest, MMB runs an organize pass (stack + compact).");
ImGui.BulletText("Use /qt or click 'Open Config' in plugin list to reopen this window"); ImGui.BulletText("Use /qt or click 'Open Config' in plugin list to reopen this window");
+26 -6
View File
@@ -2,16 +2,19 @@
A Dalamud plugin for Final Fantasy XIV that adds quick inventory actions via the game's existing context menus: A Dalamud plugin for Final Fantasy XIV that adds quick inventory actions via the game's existing context menus:
- **Shift + Right Click**: quick transfers - **Shift + Right Click**: quick transfers (including vendor sell when shop is open)
- **Ctrl + Right Click**: armoury-mode transfers (when a special container is open) - **Ctrl + Right Click**: armoury-mode transfers (when a special container is open)
- **Alt + Right Click**: split a stack in half - **Alt + Right Click**: split a stack in half
## Features ## Features
- **Quick Transfer**: Hold Shift and right-click an item to automatically trigger the matching context menu action - **Quick Transfer**: Hold Shift and right-click an item to automatically trigger the matching context menu action
- **Vendor Quick Sell**: With a vendor shop open, Shift + Right Click auto-selects **Sell**. With **Auto-confirm vendor sell** enabled, quantity dialogs and "Are you certain?" confirmations are auto-filled and confirmed
- **Trade Window Support**: Shift + Right Click items from inventory into Trade window with auto-fill max quantity - **Trade Window Support**: Shift + Right Click items from inventory into Trade window with auto-fill max quantity
- **Company Chest**: Shift + Right Click to deposit/withdraw when Free Company Chest is open; middle-click runs organize (stack + compact)
- **Armoury Mode**: Hold Ctrl and right-click to prioritize armoury actions while a special container is open - **Armoury Mode**: Hold Ctrl and right-click to prioritize armoury actions while a special container is open
- **Split Half**: Hold Alt and right-click to split a stack and auto-fill half - **Split Half**: Hold Alt and right-click to split a stack and auto-fill half
- **Middle-Click Sort**: Middle-click an item to auto-select **Sort** (or organize in FC chest)
- **Cooldown Protection**: Built-in cooldown to prevent accidental double-moves - **Cooldown Protection**: Built-in cooldown to prevent accidental double-moves
- **Debug Mode**: For troubleshooting and development (disabled by default) - **Debug Mode**: For troubleshooting and development (disabled by default)
@@ -58,6 +61,10 @@ The plugin only clicks **existing** context menu options when they are available
- Armoury → **Return to Inventory** - Armoury → **Return to Inventory**
- **Trade Window** - **Trade Window**
- Inventory → **Trade** (auto-fills and confirms max quantity for stackable items) - Inventory → **Trade** (auto-fills and confirms max quantity for stackable items)
- **Vendor Shop**
- With a vendor shop open, Shift + Right Click → **Sell**. Enable **Auto-confirm vendor sell** to auto-fill quantity and click OK on "Are you certain?" dialogs.
- **Company Chest (Free Company Chest)**
- Shift + Right Click Inventory/Armoury → deposit; Shift + Right Click Company Chest → **Remove** (withdraw)
If an option is not present for the clicked item, **nothing happens**. If an option is not present for the clicked item, **nothing happens**.
@@ -86,8 +93,10 @@ If an option is not present for the clicked item, **nothing happens**.
| Transfer Cooldown | Milliseconds between transfers | 200 | | Transfer Cooldown | Milliseconds between transfers | 200 |
| Enable Middle-Click Sort | Enable MMB sort behavior | True | | Enable Middle-Click Sort | Enable MMB sort behavior | True |
| Enable Company Chest | Enable FC chest helpers | True | | Enable Company Chest | Enable FC chest helpers | True |
| Auto-confirm quantity prompts | Auto-fill and confirm InputNumeric prompts (Split / FC chest) | True |
| Company Chest: Middle-Click Organize | Enable MMB organize (stack+compact) in FC chest | True | | Company Chest: Middle-Click Organize | Enable MMB organize (stack+compact) in FC chest | True |
| Auto-confirm quantity prompts | Auto-fill and confirm InputNumeric prompts (Split / FC chest) | True |
| Enable Vendor Quick Sell | Shift+RClick auto-selects "Sell" when vendor is open | True |
| Auto-confirm vendor sell | Auto-fill quantity and click OK on sell dialogs ("How many?", "Are you certain?") | True |
## Development ## Development
@@ -108,6 +117,8 @@ dotnet build --configuration Debug
dotnet build --configuration Release dotnet build --configuration Release
``` ```
Release build produces `bin/Release/QuickTransfer/latest.zip` for distribution.
### Testing ### Testing
1. Enable "Dev Plugin Locations" in Dalamud settings 1. Enable "Dev Plugin Locations" in Dalamud settings
@@ -121,8 +132,12 @@ QuickTransfer/
├── QuickTransfer.cs # Main plugin class ├── QuickTransfer.cs # Main plugin class
├── QuickTransfer.csproj # Project file ├── QuickTransfer.csproj # Project file
├── QuickTransferWindow.cs # Configuration UI ├── QuickTransferWindow.cs # Configuration UI
├── ContextMenuHandler.cs # Context menu matching and selection
├── InventoryHelpers.cs # Inventory/addon detection
├── DragDropHelpers.cs # Drag-drop parsing
├── AtkValueHelpers.cs # AtkValue and addon utilities
├── pluginmaster.json # Custom repository metadata (for Dalamud) ├── pluginmaster.json # Custom repository metadata (for Dalamud)
└── README.md # This file └── README.md # This file
``` ```
### Adding New Features ### Adding New Features
@@ -142,7 +157,7 @@ QuickTransfer/
### Transfers Not Working ### Transfers Not Working
- Make sure the plugin is enabled - Make sure the plugin is enabled
- Check that you have both source and target inventories open - Check that you have both source and target inventories open (or the correct container for the action)
- Ensure the target inventory has space - Ensure the target inventory has space
- Try increasing the transfer cooldown - Try increasing the transfer cooldown
@@ -155,7 +170,7 @@ QuickTransfer/
Enable Debug Mode to see transfer attempts in chat: Enable Debug Mode to see transfer attempts in chat:
``` ```
[QuickTransfer] (Shift+RClick) Selected context action 'Remove All from Saddlebag' (idx=0) via deferred OnMenuOpened. [QuickTransfer] (Shift+RClick) Selected context action 'Remove All from Saddlebag' (idx=0) via OpenForItemSlot.
``` ```
## Compatibility ## Compatibility
@@ -187,8 +202,13 @@ This plugin is licensed under the MIT License - see the `LICENSE` file for detai
## Changelog ## Changelog
### Version 1.0.5
- **New**: Vendor Quick Sell — Shift + Right Click at a vendor shop auto-selects **Sell**
- **New**: Auto-confirm vendor sell dialogs — auto-fill quantity ("How many to sell?") and click OK on "Are you certain you wish to sell it?" (unique/untradable items)
- README and configuration table updated for all current options
### Version 1.0.4 ### Version 1.0.4
- **New**: Trade window support - Shift + Right Click items from inventory into Trade window - **New**: Trade window support Shift + Right Click items from inventory into Trade window
- **New**: Auto-fill and confirm max quantity when trading stackable items - **New**: Auto-fill and confirm max quantity when trading stackable items
- Trade window actions work independently of Company Chest settings - Trade window actions work independently of Company Chest settings
+2 -2
View File
@@ -3,8 +3,8 @@
"Author": "flick", "Author": "flick",
"Name": "QuickTransfer", "Name": "QuickTransfer",
"InternalName": "QuickTransfer", "InternalName": "QuickTransfer",
"AssemblyVersion": "1.0.4.0", "AssemblyVersion": "1.0.5.0",
"Description": "Automate inventory transfers with Shift/Ctrl/Alt + Right-Click. Includes Trade window support.", "Description": "Automate inventory transfers with Shift/Ctrl/Alt + Right-Click. Trade window, vendor quick sell, FC chest.",
"ApplicableVersion": "any", "ApplicableVersion": "any",
"RepoUrl": "https://github.com/Knack117/QuickTransfer", "RepoUrl": "https://github.com/Knack117/QuickTransfer",
"DalamudApiLevel": 14, "DalamudApiLevel": 14,