Files
QuickTransfer/QuickTransfer.cs
T
KnackAtNite dae9ea1be0 Release v1.0.4
Add Shift+Right Click Trade window support with auto-fill max quantity.
2026-01-26 22:20:18 -05:00

5422 lines
216 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Configuration;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Command;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Hooking;
using Dalamud.Interface.Windowing;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Lumina.Excel.Sheets;
using AtkValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
namespace QuickTransfer;
[Serializable]
public sealed class Configuration : IPluginConfiguration
{
public int Version { get; set; } = 3;
public bool Enabled { get; set; } = true;
// Default OFF (explicitly requested).
public bool DebugMode { get; set; } = false;
public int TransferCooldownMs { get; set; } = 200;
public bool EnableMiddleClickSort { get; set; } = true;
public bool EnableCompanyChestMiddleClickOrganize { get; set; } = true;
public bool EnableCompanyChest { get; set; } = true;
public bool AutoConfirmCompanyChestQuantity { get; set; } = true;
public int CompanyChestCompartments { get; set; } = 3; // 3..5 (default game starts at 3)
public void Save() => Plugin.PluginInterface.SavePluginConfig(this);
}
public sealed unsafe class Plugin : IDalamudPlugin
{
[PluginService] internal static IDalamudPluginInterface PluginInterface { get; private set; } = null!;
[PluginService] internal static ICommandManager CommandManager { get; private set; } = null!;
[PluginService] internal static IPluginLog Log { get; private set; } = null!;
[PluginService] internal static IGameGui GameGui { get; private set; } = null!;
[PluginService] internal static IFramework Framework { get; private set; } = null!;
[PluginService] internal static IKeyState KeyState { get; private set; } = null!;
[PluginService] internal static IDataManager DataManager { get; private set; } = null!;
[PluginService] internal static IGameInteropProvider InteropProvider { get; private set; } = null!;
[PluginService] internal static IContextMenu ContextMenu { get; private set; } = null!;
[PluginService] internal static IAddonLifecycle AddonLifecycle { get; private set; } = null!;
[PluginService] internal static IChatGui ChatGui { get; private set; } = null!;
private const string CommandName = "/qt";
public Configuration Configuration { get; }
private readonly WindowSystem windowSystem = new("QuickTransfer");
private readonly QuickTransferWindow configWindow;
private long lastActionTickMs;
private (nint AgentPtr, nint AddonPtr, long EnqueuedAtMs, ModifierMode Mode)? pendingDeferredMenuClick;
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 long lastMiddleClickSortMs;
private long lastReceiveEventDebugLogMs;
private long lastFcChestTabUnmappedLogMs;
private bool debugPrintedReceiveEventHook;
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 int companyChestSelectedTabAtkValueIndex = -1;
private readonly Dictionary<int, Dictionary<int, FFXIVClientStructs.FFXIV.Client.Game.InventoryType>> companyChestSelectedTabCandidates = new();
private long companyChestBusyUntilMs;
private int companyChestBusyHits;
private long lastCompanyChestOrganizeSkipLogMs;
private string lastCompanyChestOrganizeSkipReason = string.Empty;
private bool lastVkLButtonDown;
private bool lastVkRButtonDown;
private bool lastVkMButtonDown;
private bool lastVkX1ButtonDown;
private bool lastVkX2ButtonDown;
private long lastCursorHitTestLogMs;
private const int WideAddonSearchMaxIndex = 50;
// Cache the "a4" parameter observed when the game opens inventory context menus.
// Some UIs (notably ArmouryBoard on some builds) appear to require a non-zero a4 to actually populate items.
private readonly Dictionary<(uint OwnerAddonId, uint InventoryType), int> observedContextA4 = new();
private long lastObservedA4LogMs;
// 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<uint, (FFXIVClientStructs.FFXIV.Client.Game.InventoryType Type, int Slot, int A4)> lastGoodContextTargetByAddonId = new();
// Win32: reliable mouse button state (works even when Dalamud KeyState doesn't report mouse buttons).
[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll")]
private static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll")]
private static extern bool ScreenToClient(IntPtr hWnd, ref POINT lpPoint);
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
// Heuristic: ignore "pointers" that look like 32-bit values.
// 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 =
[
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,
];
private static readonly FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] PlayerInventoryTypes =
[
FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory1,
FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory2,
FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory3,
FFXIVClientStructs.FFXIV.Client.Game.InventoryType.Inventory4,
];
private static readonly FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] SaddlebagInventoryTypes =
[
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,
];
private static bool IsVkDown(int vKey)
{
try
{
return (GetAsyncKeyState(vKey) & 0x8000) != 0;
}
catch
{
return false;
}
}
private static bool TryGetClientCursorPos(out short x, out short y)
{
x = 0;
y = 0;
try
{
if (!GetCursorPos(out var p))
return false;
var hwnd = System.Diagnostics.Process.GetCurrentProcess().MainWindowHandle;
if (hwnd == IntPtr.Zero)
return false;
if (!ScreenToClient(hwnd, ref p))
return false;
if (p.X < short.MinValue || p.X > short.MaxValue || p.Y < short.MinValue || p.Y > short.MaxValue)
return false;
x = (short)p.X;
y = (short)p.Y;
return true;
}
catch
{
return false;
}
}
private bool TryUpdateLastHoverAddonFromCollisionManager(long now)
{
try
{
var stage = AtkStage.Instance();
if (stage == null || stage->AtkCollisionManager == null)
return false;
var hit = stage->AtkCollisionManager->IntersectingAddon;
if (hit == null || hit->Id == 0)
return false;
// We can't reliably compare addon pointers here:
// - The collision manager can report child addons/overlays
// - Some users have addon indices > 6
//
// Instead, map via Id/HostId/ParentId to a known *owner* addon window.
bool TryGetVisibleAddonId(string name, out uint id)
{
id = 0;
try
{
if (TryGetVisibleAddon(name, out var a, WideAddonSearchMaxIndex) && a != null && a->Id != 0)
{
id = a->Id;
return true;
}
}
catch
{
// ignore
}
return false;
}
var visibleById = new Dictionary<uint, string>(capacity: 16);
void AddVisible(string name)
{
if (TryGetVisibleAddonId(name, out var id) && id != 0)
{
// Don't overwrite an existing mapping. This prevents rare mis-labeling if an alias query
// accidentally returns an unexpected addon that reuses an id already mapped earlier.
if (!visibleById.ContainsKey(id))
visibleById[id] = name;
}
}
AddVisible("Inventory");
AddVisible("InventoryBuddy");
AddVisible("InventoryBuddy2");
AddVisible("RetainerGrid0");
AddVisible("RetainerGrid");
AddVisible("RetainerSellList");
AddVisible(FreeCompanyChestAddonName);
foreach (var n in ArmouryAddonNames)
AddVisible(n);
var hitId = (uint)hit->Id;
var hostId = (uint)hit->HostId;
var parentId = (uint)hit->ParentId;
uint ownerId = 0;
string ownerName = string.Empty;
string ownerSource = string.Empty;
bool Pick(uint id)
{
if (id == 0)
return false;
if (!visibleById.TryGetValue(id, out var n))
return false;
ownerId = id;
ownerName = n;
ownerSource = "visible";
return true;
}
// Prefer direct hit, then host, then parent.
if (!Pick(hitId) && !Pick(hostId) && !Pick(parentId))
{
static string InferOwnerNameFromInvType(FFXIVClientStructs.FFXIV.Client.Game.InventoryType t)
{
if (IsPlayerInventoryType(t))
return "Inventory";
if (IsSaddlebagType(t))
return "InventoryBuddy";
if (IsArmouryType(t))
return "ArmouryBoard";
if (IsCompanyChestType(t))
return FreeCompanyChestAddonName;
if (IsRetainerType(t))
return "RetainerGrid0";
return string.Empty;
}
bool PickFromLastGood(uint id)
{
if (id == 0)
return false;
if (!lastGoodContextTargetByAddonId.TryGetValue(id, out var good))
return false;
var inferred = InferOwnerNameFromInvType(good.Type);
if (string.IsNullOrEmpty(inferred))
return false;
ownerId = id;
ownerName = inferred;
ownerSource = "lastGood";
return true;
}
// If GameGui can't see the owner window (common for Inventory), fall back to previously observed "good" targets.
if (!PickFromLastGood(hitId) && !PickFromLastGood(hostId) && !PickFromLastGood(parentId))
{
// Heuristic: Inventory's owner addon id is commonly 17, while collision hits are child ids (e.g. 108/110)
// with HostId/ParentId pointing at 17. Prefer that to make MMB Inventory sort work even without a prior RClick.
const uint inventoryOwnerId = 17;
if (hostId == inventoryOwnerId || parentId == inventoryOwnerId)
{
ownerId = inventoryOwnerId;
ownerName = "Inventory";
ownerSource = "heuristic17";
}
else
{
// If we couldn't map to a known owner addon, still log what we saw to help diagnose.
if (Configuration.DebugMode && now - lastCursorHitTestLogMs >= 1000)
{
lastCursorHitTestLogMs = now;
Log.Information($"[QuickTransfer] (MMB) CollisionManager hit addonId={hitId} hostId={hostId} parentId={parentId} (unmapped). Visible owners=[{string.Join(", ", visibleById.Select(kv => $"{kv.Value}:{kv.Key}"))}] lastGoodOwnerIds=[{string.Join(", ", lastGoodContextTargetByAddonId.Keys.Take(24))}]");
}
return false;
}
}
}
lastHoverAddon = (ownerName, ownerId, now);
if (Configuration.DebugMode && now - lastCursorHitTestLogMs >= 1000)
{
lastCursorHitTestLogMs = now;
Log.Information($"[QuickTransfer] (MMB) CollisionManager picked addon '{ownerName}' (ownerAddonId={ownerId}, hitAddonId={hitId}, source={ownerSource}).");
}
return true;
}
catch
{
return false;
}
}
private bool TryUpdateLastHoverAddonFromCursorHitTest(long now)
{
try
{
// Prefer the game's own collision manager; it already knows what addon is under the cursor.
if (TryUpdateLastHoverAddonFromCollisionManager(now))
return true;
if (!TryGetClientCursorPos(out var x, out var y))
return false;
AtkUnitBase* best = null;
string bestName = string.Empty;
uint bestId = 0;
uint bestDepth = 0;
ushort bestDraw = 0;
void Consider(string name, AtkUnitBase* a)
{
if (a == null)
return;
try
{
if (!a->IsVisible || !a->IsReady)
return;
if (!a->CheckWindowCollisionAtCoords(x, y))
return;
var depth = a->DepthLayer;
var draw = a->DrawOrderIndex;
if (best == null || depth > bestDepth || (depth == bestDepth && draw > bestDraw))
{
best = a;
bestName = name ?? string.Empty;
bestId = a->Id;
bestDepth = depth;
bestDraw = draw;
}
}
catch
{
// ignore
}
}
if (TryGetVisibleAddon("Inventory", out var inv) && inv != null)
Consider("Inventory", inv);
if (TryGetVisibleAddon("InventoryBuddy", out var sb) && sb != null)
Consider("InventoryBuddy", sb);
if (TryGetVisibleAddon("InventoryBuddy2", out var sb2) && sb2 != null)
Consider("InventoryBuddy2", sb2);
if (TryGetVisibleAddon("RetainerGrid0", out var rg0, WideAddonSearchMaxIndex) && rg0 != null)
Consider("RetainerGrid0", rg0);
if (TryGetVisibleAddon("RetainerGrid", out var rg, WideAddonSearchMaxIndex) && rg != null)
Consider("RetainerGrid", rg);
if (TryGetVisibleAddon("RetainerSellList", out var rsl, WideAddonSearchMaxIndex) && rsl != null)
Consider("RetainerSellList", rsl);
if (TryGetVisibleAddon(FreeCompanyChestAddonName, out var fcc, WideAddonSearchMaxIndex) && fcc != null)
Consider(FreeCompanyChestAddonName, fcc);
foreach (var n in ArmouryAddonNames)
{
if (TryGetVisibleAddon(n, out var ab) && ab != null)
Consider(n, ab);
}
if (best == null || bestId == 0 || string.IsNullOrEmpty(bestName))
return false;
lastHoverAddon = (bestName, bestId, now);
if (Configuration.DebugMode && now - lastCursorHitTestLogMs >= 1000)
{
lastCursorHitTestLogMs = now;
Log.Information($"[QuickTransfer] (MMB) Cursor hit-test picked addon '{bestName}' (addonId={bestId}) at ({x},{y}).");
}
return true;
}
catch
{
return false;
}
}
private 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;
}
private static bool TryGetSlotFromDragDropInterface(
AtkDragDropInterface* ddi,
out FFXIVClientStructs.FFXIV.Client.Game.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;
}
private static bool TryGetSlotFromDragDropInterfaceForAddon(
AtkDragDropInterface* ddi,
string addonName,
uint addonId,
out FFXIVClientStructs.FFXIV.Client.Game.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 = (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 bool TryResolveTargetFromWeirdPayload(
ReadOnlySpan<FFXIVClientStructs.FFXIV.Client.Game.InventoryType> containers,
int rawInt1,
int rawInt2,
short refIdx,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType type,
out int slot)
{
type = default;
slot = -1;
try
{
if (containers.Length == 0)
return false;
var inv = InventoryManager.Instance();
if (inv == null)
return false;
// Try a few plausible slot candidates first (fast path).
// Observed weird payloads often still include a real slot index in one of these fields.
var candidates = new List<int>(capacity: 4) { rawInt2, rawInt1, refIdx };
foreach (var s in candidates.Distinct())
{
if (s < 0 || s > 500)
continue;
foreach (var t in containers)
{
var it = inv->GetInventorySlot(t, s);
if (it != null && it->ItemId != 0)
{
type = t;
slot = s;
return true;
}
}
}
// Last resort: pick the first container that has any items,
// and then pick its first non-empty slot.
foreach (var t in containers)
{
var c = inv->GetInventoryContainer(t);
if (c == null || !c->IsLoaded || c->Size <= 0)
continue;
for (var i = 0; i < c->Size; i++)
{
var it = c->GetInventorySlot(i);
if (it != null && it->ItemId != 0)
{
type = t;
slot = i;
return true;
}
}
}
}
catch
{
// ignore
}
return false;
}
private bool TryQueueMiddleClickSortFromVisibleWindows(long now)
{
try
{
// If multiple inventory windows are open, we can't know which one the cursor is over without a hover DDI.
// In that case, refuse and require hover capture.
var visibleCount = 0;
FFXIVClientStructs.FFXIV.Client.Game.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))
{
visibleCount++;
chosenType = t;
chosenSlot = s;
chosenAddonId = ab->Id;
}
}
// Saddlebags
if (TryGetVisibleAddon("InventoryBuddy", out var sb, WideAddonSearchMaxIndex) && sb != null)
{
if (TryResolveTargetFromWeirdPayload(SaddlebagInventoryTypes, -1, -1, -1, out var t, out var s))
{
visibleCount++;
chosenType = t;
chosenSlot = s;
chosenAddonId = sb->Id;
}
}
else if (TryGetVisibleAddon("InventoryBuddy2", out var sb2, WideAddonSearchMaxIndex) && sb2 != null)
{
if (TryResolveTargetFromWeirdPayload(SaddlebagInventoryTypes, -1, -1, -1, out var t, out var s))
{
visibleCount++;
chosenType = t;
chosenSlot = s;
chosenAddonId = sb2->Id;
}
}
// Player inventory
if (TryGetVisibleAddon("Inventory", out var inv, WideAddonSearchMaxIndex) && inv != null)
{
if (TryResolveTargetFromWeirdPayload(PlayerInventoryTypes, -1, -1, -1, out var t, out var s))
{
visibleCount++;
chosenType = t;
chosenSlot = s;
chosenAddonId = inv->Id;
}
}
// Retainer inventory
if (TryGetVisibleAddon("RetainerGrid0", out var rg0, WideAddonSearchMaxIndex) && rg0 != null)
{
if (TryResolveTargetFromWeirdPayload(RetainerInventoryTypes, -1, -1, -1, out var t, out var s))
{
visibleCount++;
chosenType = t;
chosenSlot = s;
chosenAddonId = rg0->Id;
}
}
else if (TryGetVisibleAddon("RetainerGrid", out var rg, WideAddonSearchMaxIndex) && rg != null)
{
if (TryResolveTargetFromWeirdPayload(RetainerInventoryTypes, -1, -1, -1, out var t, out var s))
{
visibleCount++;
chosenType = t;
chosenSlot = s;
chosenAddonId = rg->Id;
}
}
else if (TryGetVisibleAddon("RetainerSellList", out var rsl, WideAddonSearchMaxIndex) && rsl != null)
{
if (TryResolveTargetFromWeirdPayload(RetainerInventoryTypes, -1, -1, -1, out var t, out var s))
{
visibleCount++;
chosenType = t;
chosenSlot = s;
chosenAddonId = rsl->Id;
}
}
// Free Company Chest (no native Sort context menu; MMB triggers our organize pass)
if (TryGetVisibleAddon(FreeCompanyChestAddonName, out var fcc, WideAddonSearchMaxIndex) && fcc != null)
{
var lp = lastHoverCompanyChestPage;
if (lp != null && lp.Value.AddonId == fcc->Id && now - lp.Value.SeenAtMs <= 20000 && IsCompanyChestType(lp.Value.Page))
{
visibleCount++;
chosenType = lp.Value.Page;
chosenSlot = 0;
chosenAddonId = fcc->Id;
}
else
{
var sp = lastSelectedCompanyChestPage;
if (sp != null && sp.Value.AddonId == fcc->Id && now - sp.Value.SeenAtMs <= 20000 && IsCompanyChestType(sp.Value.Page))
{
visibleCount++;
chosenType = sp.Value.Page;
chosenSlot = 0;
chosenAddonId = fcc->Id;
}
else
{
var pages = GetCompanyChestInventoryTypes();
if (pages.Length > 0)
{
visibleCount++;
chosenType = pages[0];
chosenSlot = 0;
chosenAddonId = fcc->Id;
}
}
}
}
if (visibleCount != 1 || chosenAddonId == 0 || chosenSlot < 0)
return false;
var openSlot = PickContextMenuSlot(chosenType, chosenSlot);
pendingMiddleClickSortRequest = (chosenType, openSlot, chosenAddonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) No hover DDI; bootstrapped from visible window: {chosenType} slot={openSlot} addonId={chosenAddonId}");
return true;
}
catch
{
return false;
}
}
private bool TryQueueMiddleClickSortFromLastHoverAddon(long now)
{
try
{
var h = lastHoverAddon;
if (h == null || now - h.Value.SeenAtMs > 20000)
return false;
var addonName = h.Value.AddonName ?? string.Empty;
var addonId = h.Value.AddonId;
ReadOnlySpan<FFXIVClientStructs.FFXIV.Client.Game.InventoryType> containers = default;
if (addonName.Equals("Inventory", StringComparison.OrdinalIgnoreCase))
containers = PlayerInventoryTypes;
else if (addonName.Equals("InventoryBuddy", StringComparison.OrdinalIgnoreCase) || addonName.Equals("InventoryBuddy2", StringComparison.OrdinalIgnoreCase))
containers = SaddlebagInventoryTypes;
else if (addonName.Equals("RetainerGrid0", StringComparison.OrdinalIgnoreCase) ||
addonName.Equals("RetainerGrid", StringComparison.OrdinalIgnoreCase) ||
addonName.Equals("RetainerSellList", StringComparison.OrdinalIgnoreCase))
containers = RetainerInventoryTypes;
else if (addonName.Equals(FreeCompanyChestAddonName, StringComparison.OrdinalIgnoreCase))
{
const long companyChestTabMaxAgeMs = 180000; // 3 minutes
// FC Chest has no native "Sort"; MMB triggers our organize pass.
// Run only on the currently selected tab, approximated as the most recently hovered/clicked FreeCompanyPage payload.
// First preference: read the currently displayed page directly from the addon via a payload probe.
// This avoids relying on tab ButtonClick params, which vary across clients/builds.
if (TryGetVisibleAddon(FreeCompanyChestAddonName, out var fcc, WideAddonSearchMaxIndex) &&
fcc != null &&
fcc->Id == addonId &&
TryResolveCompanyChestPageFromAddon(fcc, out var curPage) &&
IsCompanyChestType(curPage))
{
pendingMiddleClickSortRequest = (curPage, 0, addonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Resolved active Company Chest tab from payload: {curPage} (addonId={addonId})");
return true;
}
else if (Configuration.DebugMode && TryGetVisibleAddon(FreeCompanyChestAddonName, out var fccDbg, WideAddonSearchMaxIndex) && fccDbg != null && fccDbg->Id == addonId)
{
// Diagnostic: we expected to be able to infer the active page from visible payloads, but couldn't.
// This helps identify whether the probe is failing entirely or just returning a non-page payload.
Log.Information("[QuickTransfer] (MMB) Company Chest payload tab probe failed; falling back to hover/selected tab.");
}
var lp = lastHoverCompanyChestPage;
if (lp != null && lp.Value.AddonId == addonId && now - lp.Value.SeenAtMs <= companyChestTabMaxAgeMs && IsCompanyChestType(lp.Value.Page))
{
pendingMiddleClickSortRequest = (lp.Value.Page, 0, addonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Using last-hovered Company Chest tab: {lp.Value.Page} slot=0 addonId={addonId}");
return true;
}
var sp = lastSelectedCompanyChestPage;
if (sp != null && sp.Value.AddonId == addonId && now - sp.Value.SeenAtMs <= companyChestTabMaxAgeMs && IsCompanyChestType(sp.Value.Page))
{
pendingMiddleClickSortRequest = (sp.Value.Page, 0, addonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Using selected Company Chest tab: {sp.Value.Page} slot=0 addonId={addonId}");
return true;
}
if (TryResolveCompanyChestSelectedPageFromAtkValues(addonId, out var atkPage))
{
pendingMiddleClickSortRequest = (atkPage, 0, addonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Using Company Chest tab from AtkValues: {atkPage} slot=0 addonId={addonId}");
return true;
}
// If we couldn't infer the selected tab, do NOT guess (guessing Page1 is what causes "I clicked tab 2 but it sorted tab 1").
if (Configuration.DebugMode)
Log.Information("[QuickTransfer] (MMB) Company Chest tab unknown; no action taken (waiting for a tab click or hover).");
return false;
}
else if (ArmouryAddonNames.Any(n => addonName.Equals(n, StringComparison.OrdinalIgnoreCase)))
containers = ArmouryBoardIndexToType;
if (containers.Length == 0 || addonId == 0)
return false;
// Prefer last known-good context target when available (more likely to produce a menu).
if (lastGoodContextTargetByAddonId.TryGetValue(addonId, out var good) &&
(IsPlayerInventoryType(good.Type) || IsArmouryType(good.Type) || IsSaddlebagType(good.Type) || IsRetainerType(good.Type) || IsCompanyChestType(good.Type)))
{
var openSlot = PickContextMenuSlot(good.Type, good.Slot);
pendingMiddleClickSortRequest = (good.Type, openSlot, addonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Using last-good target for hovered addon '{addonName}': {good.Type} slot={openSlot} addonId={addonId}");
return true;
}
if (!TryResolveTargetFromWeirdPayload(containers, -1, -1, -1, out var type, out var slot))
return false;
var openSlot2 = PickContextMenuSlot(type, slot);
pendingMiddleClickSortRequest = (type, openSlot2, addonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Bootstrapped from hovered addon '{addonName}': {type} slot={openSlot2} addonId={addonId}");
return true;
}
catch
{
return false;
}
}
private void TryQueueMiddleClickSortFromHover(long now)
{
if (!Configuration.Enabled || !Configuration.EnableMiddleClickSort)
return;
if (now - lastMiddleClickSortMs < 250)
return;
var hDdi = lastHoverDdi;
// Rollover events only fire when moving the cursor; keep a generous window so MMB works while stationary.
if (hDdi == null || now - hDdi.Value.SeenAtMs > 20000)
{
// Inventory sometimes does not emit hover events; fall back to a window hit-test at the cursor.
// This also lets us disambiguate which window is being targeted when multiple are open.
if (TryUpdateLastHoverAddonFromCursorHitTest(now) && TryQueueMiddleClickSortFromLastHoverAddon(now))
return;
if (TryQueueMiddleClickSortFromLastHoverAddon(now))
return;
if (TryQueueMiddleClickSortFromVisibleWindows(now))
return;
if (Configuration.DebugMode)
Log.Information("[QuickTransfer] (MMB) No recent hover slot/dragdrop captured; cannot queue sort.");
return;
}
try
{
var ddiAddonId = hDdi.Value.AddonId;
// Key rule for stability across windows:
// - A stored hover DDI can be stale if the UI doesn't emit MouseOut/RollOut events (common for Inventory/Saddlebags).
// - Therefore, if the DDI wasn't updated very recently, prefer a live hit-test (collision manager) to determine
// which window is actually under the cursor right now.
//
// Armoury remains stable because the collision manager typically also reports it correctly, and we no longer
// let stale "lastHoverAddon" from other windows override a fresh cursor hit-test.
var ddiFresh = now - hDdi.Value.SeenAtMs <= 250;
if (!ddiFresh)
{
if (TryUpdateLastHoverAddonFromCursorHitTest(now) && TryQueueMiddleClickSortFromLastHoverAddon(now))
return;
}
// Otherwise, use the DDI's addon id and cached addon name as the target.
if (!string.IsNullOrWhiteSpace(lastHoverAddonName))
{
lastHoverAddon = (lastHoverAddonName, ddiAddonId, now);
if (TryQueueMiddleClickSortFromLastHoverAddon(now))
return;
}
// As a fallback, still allow using the last-good target for this addon id.
if (lastGoodContextTargetByAddonId.TryGetValue(ddiAddonId, out var good2))
{
var openSlot = PickContextMenuSlot(good2.Type, good2.Slot);
pendingMiddleClickSortRequest = (good2.Type, openSlot, ddiAddonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Used last-good target by addonId (no hover metadata): {good2.Type} slot={openSlot} addonId={ddiAddonId}");
return;
}
// If we can't decide safely, do nothing.
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
}
catch (Exception ex)
{
// Best-effort only; avoid crashing the client if the hovered pointer becomes invalid.
Log.Warning(ex, "[QuickTransfer] (MMB) Failed to queue sort from hover dragdrop.");
}
}
private long pendingCompanyChestNumericConfirmUntilMs;
private int pendingCompanyChestNumericConfirmAttempts;
private long pendingCloseContextMenuAtMs;
private bool pendingCompanyChestNumericArmed;
private bool pendingCompanyChestNumericValueSet;
private long pendingCompanyChestNumericValueSetAtMs;
private uint pendingCompanyChestNumericDesired;
private bool pendingCompanyChestNumericHalf;
// Extra safety for inventory Split dialogs (InventoryExpansion / non-English prompts):
// When we arm a Split, record the expected "max" value (usually qty-1).
// 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 PendingNumericKind pendingNumericKind;
private long lastShiftSeenMs;
private long lastCtrlSeenMs;
private long lastAltSeenMs;
// For stack moves that open InputNumeric, the native operation state must stay alive.
// If it's stack-allocated, the resulting InputNumeric buttons can become "dead".
private nint pendingMoveOutValuePtr;
private long pendingMoveOutValueFreeAtMs;
private nint pendingMoveAtkValuesPtr;
private long pendingMoveCreatedAtMs;
private bool pendingMoveSawInputNumeric;
private static readonly Dictionary<uint, uint> StackSizeCache = new();
private static readonly Dictionary<uint, uint> ItemUiCategoryCache = new();
private struct CompanyChestDepositState
{
public bool Active;
public FFXIVClientStructs.FFXIV.Client.Game.InventoryType SourceType;
public uint SourceSlot;
public uint ItemId;
public bool IsHq;
public long NextAttemptAtMs;
public long ExpiresAtMs;
public int Steps;
public uint LastQty;
public long WaitForQtyChangeUntilMs;
}
private CompanyChestDepositState companyChestDeposit;
private struct CompanyChestOrganizeState
{
public bool Active;
public uint OwnerAddonId;
public long NextAttemptAtMs;
public long ExpiresAtMs;
public int Steps;
public int Phase; // 0=stack, 1=compact
public FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] Pages;
// Throttle: wait for the last move to apply before issuing another.
public bool WaitingForApply;
public FFXIVClientStructs.FFXIV.Client.Game.InventoryType WaitSrcType;
public uint WaitSrcSlot;
public uint WaitSrcItemId;
public int WaitSrcQty;
public FFXIVClientStructs.FFXIV.Client.Game.InventoryType WaitDstType;
public uint WaitDstSlot;
public uint WaitDstItemId;
public int WaitDstQty;
public long WaitUntilMs;
public int WaitStuckCount;
public long WaitObservedChangeAtMs;
}
private static bool TryGetSlotSnapshot(
InventoryManager* inv,
FFXIVClientStructs.FFXIV.Client.Game.InventoryType type,
uint slot,
out uint itemId,
out int qty)
{
itemId = 0;
qty = 0;
try
{
if (inv == null)
return false;
var it = inv->GetInventorySlot(type, (int)slot);
if (it == null)
return false;
itemId = it->ItemId;
qty = it->Quantity;
return true;
}
catch
{
return false;
}
}
private static bool IsContainerLoaded(InventoryManager* inv, FFXIVClientStructs.FFXIV.Client.Game.InventoryType type)
{
try
{
if (inv == null)
return false;
var c = inv->GetInventoryContainer(type);
return c != null && c->IsLoaded && c->Size > 0;
}
catch
{
return false;
}
}
private CompanyChestOrganizeState companyChestOrganize;
private enum ModifierMode
{
Shift,
Ctrl,
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<OpenForItemSlotDelegate>? 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;
// 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.
private enum AutoContextAction
{
AddAllToSaddlebag,
RemoveAllFromSaddlebag,
PlaceInArmouryChest,
ReturnToInventory,
EntrustToRetainer,
RetrieveFromRetainer,
RemoveFromCompanyChest,
Split,
Sort,
Trade,
}
private static readonly string[] ArmouryAddonNames =
[
// Common internal names used by the Armoury Chest window across patches.
"ArmouryBoard",
"ArmoryBoard",
"Armoury",
"Armory",
"ArmouryChest",
"ArmoryChest",
];
private static readonly string[] ReceiveEventAddonNames =
[
// Player inventory
"Inventory",
// Saddlebags
"InventoryBuddy",
"InventoryBuddy2",
// Armoury chest (aliases vary by patch)
..ArmouryAddonNames,
// Retainer inventory
"RetainerGrid0",
"RetainerSellList",
"RetainerGrid",
// Company chest
FreeCompanyChestAddonName,
];
private const string FreeCompanyChestAddonName = "FreeCompanyChest";
private const string InputNumericAddonName = "InputNumeric";
private const string ContextMenuAddonName = "ContextMenu";
// IMPORTANT:
// We suppress by forcing alpha to 0 in PreDraw, which can "stick" because the same addon instance is reused.
// Therefore we track suppression windows and also restore alpha when not suppressing.
private long suppressContextMenuUntilMs;
private long suppressInputNumericUntilMs;
private void ArmSuppressContextMenu(long now, int durationMs = 250)
=> suppressContextMenuUntilMs = Math.Max(suppressContextMenuUntilMs, now + durationMs);
private void ArmSuppressInputNumeric(long now, int durationMs = 1500)
=> suppressInputNumericUntilMs = Math.Max(suppressInputNumericUntilMs, now + durationMs);
private FFXIVClientStructs.FFXIV.Client.Game.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<FFXIVClientStructs.FFXIV.Client.Game.InventoryType>()
.Where(IsCompanyChestType)
.OrderBy(v => (int)v)
.Take(max)
.ToArray();
}
private static FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] GetAllCompanyChestItemPages()
=> Enum.GetValues<FFXIVClientStructs.FFXIV.Client.Game.InventoryType>()
.Where(IsCompanyChestType)
.OrderBy(v => (int)v)
.Take(5)
.ToArray();
private static AtkDragDropInterface* TryGetDdiFromListIndex(AtkComponentList* list, int idx)
{
if (list == null)
return null;
if (idx < 0 || idx > 512)
return null;
try
{
var r = list->GetItemRenderer(idx);
return r != null ? &r->AtkDragDropInterface : null;
}
catch
{
return null;
}
}
private static AtkDragDropInterface* TryGetDdiFromComponent(AtkComponentBase* component, int preferredListIndex = 0)
{
if (component == null)
return null;
try
{
var t = component->GetComponentType();
return t switch
{
ComponentType.DragDrop => &((AtkComponentDragDrop*)component)->AtkDragDropInterface,
ComponentType.ListItemRenderer => &((AtkComponentListItemRenderer*)component)->AtkDragDropInterface,
ComponentType.List => TryGetDdiFromListIndex((AtkComponentList*)component, preferredListIndex),
_ => null,
};
}
catch
{
return null;
}
}
private bool TryResolveCompanyChestPageFromAddon(AtkUnitBase* addon, out FFXIVClientStructs.FFXIV.Client.Game.InventoryType page)
{
page = default;
try
{
if (addon == null)
return false;
// Scan component nodes for any DragDrop/List that yields a FreeCompanyPageX payload.
var nodeCount = addon->UldManager.NodeListCount;
if (nodeCount <= 0)
return false;
var maxNodes = Math.Min((int)nodeCount, 2000);
var bestPage = default(FFXIVClientStructs.FFXIV.Client.Game.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<FFXIVClientStructs.FFXIV.Client.Game.InventoryType, int>(8);
for (var i = 0; i < maxNodes; i++)
{
var n = addon->UldManager.NodeList[i];
if (n == null)
continue;
// Skip hidden nodes (inactive tabs commonly force alpha to 0).
try
{
if (n->Alpha_2 == 0 || n->Color.A == 0)
continue;
}
catch
{
// ignore; continue scanning
}
AtkComponentNode* compNode;
try { compNode = n->GetAsAtkComponentNode(); }
catch { continue; }
if (compNode == null || compNode->Component == null)
continue;
var component = compNode->Component;
var ct = component->GetComponentType();
if (ct == ComponentType.List)
{
var list = (AtkComponentList*)component;
// Try a few indices; FC chest lists usually expose items here.
var observed = 0;
for (var li = 0; li < 30; li++)
{
var ddi = TryGetDdiFromListIndex(list, li);
if (ddi == null || (nint)ddi < MinLikelyPointer)
continue;
if (TryGetSlotFromDragDropInterface(ddi, out var invType, out _))
{
if (IsCompanyChestType(invType))
{
hitsByPage.TryGetValue(invType, out var cur);
cur++;
hitsByPage[invType] = cur;
if (cur > bestHits)
{
bestHits = cur;
bestPage = invType;
}
// Don't over-scan; we just need enough evidence to pick the visible page.
observed++;
if (observed >= 6)
break;
}
}
}
}
else
{
var ddi = TryGetDdiFromComponent(component, preferredListIndex: 0);
if (ddi == null || (nint)ddi < MinLikelyPointer)
continue;
if (TryGetSlotFromDragDropInterface(ddi, out var invType, out _))
{
if (IsCompanyChestType(invType))
{
hitsByPage.TryGetValue(invType, out var cur);
cur++;
hitsByPage[invType] = cur;
if (cur > bestHits)
{
bestHits = cur;
bestPage = invType;
}
}
}
}
}
if (bestHits > 0 && IsCompanyChestType(bestPage))
{
page = bestPage;
return true;
}
}
catch
{
// ignore
}
return false;
}
private 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;
}
private void ObserveCompanyChestTabFromAtkValues(AtkUnitBase* addon, FFXIVClientStructs.FFXIV.Client.Game.InventoryType selectedPage)
{
try
{
if (addon == null || addon->AtkValues == null || addon->AtkValuesCount <= 0)
return;
var values = addon->AtkValues;
var count = (int)addon->AtkValuesCount;
var max = Math.Min(count, 80);
for (var i = 0; i < max; i++)
{
if (!TryGetAtkValueInt(values, max, i, out var n))
continue;
// Only small integers are plausible "tab indices".
if (n < 0 || n > 10)
continue;
if (!companyChestSelectedTabCandidates.TryGetValue(i, out var map))
{
map = new Dictionary<int, FFXIVClientStructs.FFXIV.Client.Game.InventoryType>(8);
companyChestSelectedTabCandidates[i] = map;
}
// If we see conflicting mappings for the same (index,value), drop this candidate index.
if (map.TryGetValue(n, out var existing) && existing != selectedPage)
{
companyChestSelectedTabCandidates.Remove(i);
continue;
}
map[n] = selectedPage;
}
// Pick the best candidate index (most distinct pages mapped).
var bestIdx = -1;
var bestDistinct = 0;
foreach (var kv in companyChestSelectedTabCandidates)
{
var distinct = kv.Value.Values.Distinct().Count();
if (distinct > bestDistinct)
{
bestDistinct = distinct;
bestIdx = kv.Key;
}
}
if (bestIdx >= 0 && bestDistinct >= 2)
{
if (companyChestSelectedTabAtkValueIndex != bestIdx)
{
companyChestSelectedTabAtkValueIndex = bestIdx;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] FC Chest AtkValues selected-tab index inferred: idx={bestIdx} (mappedPages={bestDistinct}).");
}
}
}
catch
{
// ignore
}
}
private bool TryResolveCompanyChestSelectedPageFromAtkValues(uint addonId, out FFXIVClientStructs.FFXIV.Client.Game.InventoryType page)
{
page = default;
try
{
if (companyChestSelectedTabAtkValueIndex < 0)
return false;
if (!TryGetVisibleAddon(FreeCompanyChestAddonName, out var addon, WideAddonSearchMaxIndex) || addon == null || addon->Id != addonId)
return false;
if (addon->AtkValues == null || addon->AtkValuesCount <= 0)
return false;
if (!companyChestSelectedTabCandidates.TryGetValue(companyChestSelectedTabAtkValueIndex, out var map) || map.Count == 0)
return false;
if (!TryGetAtkValueInt(addon->AtkValues, (int)addon->AtkValuesCount, companyChestSelectedTabAtkValueIndex, out var n))
return false;
if (!map.TryGetValue(n, out var p))
return false;
if (!IsCompanyChestType(p))
return false;
page = p;
return true;
}
catch
{
return false;
}
}
public Plugin()
{
Configuration = PluginInterface.GetPluginConfig() as Configuration ?? new Configuration();
// Config migration: ensure DebugMode defaults to OFF even for existing installs.
try
{
if (Configuration.Version < 3)
{
Configuration.DebugMode = false;
Configuration.Version = 3;
Configuration.Save();
}
else if (Configuration.Version > 3)
{
// If the user downgrades, don't overwrite their config; just keep their stored values.
}
else
{
// Version == 3: still ensure debug isn't accidentally on by default after updates.
// (User can re-enable it explicitly.)
// No auto-save here to avoid writing config every startup.
}
}
catch
{
// ignore
}
configWindow = new QuickTransferWindow(Configuration);
windowSystem.AddWindow(configWindow);
CommandManager.AddHandler(CommandName, new CommandInfo(OnCommand)
{
HelpMessage = "Open QuickTransfer settings",
});
PluginInterface.UiBuilder.Draw += windowSystem.Draw;
PluginInterface.UiBuilder.OpenConfigUi += OpenConfigUi;
InteropProvider.InitializeFromAttributes(this);
openForItemSlotHook?.Enable();
// Saddlebags can bypass OpenForItemSlot, so use a safe deferred click via context menu events.
ContextMenu.OnMenuOpened += OnContextMenuOpened;
Framework.Update += OnFrameworkUpdate;
// Lifecycle hooks:
// Register with explicit addon names; wildcard registration is not reliable across Dalamud versions/builds.
AddonLifecycle.RegisterListener(AddonEvent.PreSetup, InputNumericAddonName, OnInputNumericPreSetup);
AddonLifecycle.RegisterListener(AddonEvent.PreDraw, ContextMenuAddonName, OnAddonPreDraw);
AddonLifecycle.RegisterListener(AddonEvent.PreDraw, InputNumericAddonName, OnAddonPreDraw);
foreach (var name in ReceiveEventAddonNames)
AddonLifecycle.RegisterListener(AddonEvent.PreReceiveEvent, name, OnAddonReceiveEvent);
// Listen for system error messages (e.g. "Another player is using the chest") so we can stop FC chest organize/deposit
// instead of spamming actions.
ChatGui.ChatMessage += OnChatMessage;
Log.Information($"Loaded {PluginInterface.Manifest.Name}.");
Log.Information(
$"[QuickTransfer] DebugMode={Configuration.DebugMode}, Enabled={Configuration.Enabled}, " +
$"EnableMiddleClickSort={Configuration.EnableMiddleClickSort}, " +
$"EnableCompanyChest={Configuration.EnableCompanyChest}, " +
$"EnableCompanyChestMiddleClickOrganize={Configuration.EnableCompanyChestMiddleClickOrganize}");
if (Configuration.DebugMode)
{
try
{
var matches = Enum.GetNames<FFXIVClientStructs.FFXIV.Client.Game.InventoryType>()
.Where(n => n.Contains("FreeCompany", StringComparison.OrdinalIgnoreCase) ||
n.Contains("Company", StringComparison.OrdinalIgnoreCase) ||
n.Contains("Chest", StringComparison.OrdinalIgnoreCase))
.ToArray();
Log.Information($"[QuickTransfer] InventoryType names containing Company/Chest: {string.Join(", ", matches)}");
}
catch (Exception ex)
{
Log.Warning(ex, "[QuickTransfer] Failed to enumerate InventoryType names (debug).");
}
}
}
public void Dispose()
{
Framework.Update -= OnFrameworkUpdate;
ContextMenu.OnMenuOpened -= OnContextMenuOpened;
ChatGui.ChatMessage -= OnChatMessage;
AddonLifecycle.UnregisterListener(AddonEvent.PreSetup, InputNumericAddonName, OnInputNumericPreSetup);
AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, ContextMenuAddonName, OnAddonPreDraw);
AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, InputNumericAddonName, OnAddonPreDraw);
foreach (var name in ReceiveEventAddonNames)
AddonLifecycle.UnregisterListener(AddonEvent.PreReceiveEvent, name, OnAddonReceiveEvent);
openForItemSlotHook?.Disable();
openForItemSlotHook?.Dispose();
PluginInterface.UiBuilder.Draw -= windowSystem.Draw;
PluginInterface.UiBuilder.OpenConfigUi -= OpenConfigUi;
windowSystem.RemoveAllWindows();
configWindow.Dispose();
CommandManager.RemoveHandler(CommandName);
}
private void OnCommand(string command, string args) => OpenConfigUi();
private void OpenConfigUi() => configWindow.IsOpen = true;
private void OnChatMessage(XivChatType type, int timestamp, ref SeString sender, ref SeString message, ref bool isHandled)
{
try
{
if (!Configuration.EnableCompanyChest)
return;
// Only care while FC chest features are active; avoid doing extra work on every chat line.
if (!companyChestOrganize.Active && !companyChestDeposit.Active)
return;
var text = message.TextValue ?? string.Empty;
if (text.Length == 0)
return;
// These strings appear as system error toasts and (typically) also in the log/chat.
// If we see them, stop the state machine and back off for a few seconds.
if (text.Contains("Another player is using the chest", StringComparison.OrdinalIgnoreCase) ||
text.Contains("Unable to store item", StringComparison.OrdinalIgnoreCase) ||
text.Contains("Unable to complete company chest action", StringComparison.OrdinalIgnoreCase))
{
var now = Environment.TickCount64;
companyChestBusyHits = Math.Min(companyChestBusyHits + 1, 10);
var backoffMs = (long)Math.Min(60000, 5000 * (1 << Math.Min(companyChestBusyHits - 1, 4))); // 5s,10s,20s,40s,60s cap
companyChestBusyUntilMs = Math.Max(companyChestBusyUntilMs, now + backoffMs);
// If the chest is busy repeatedly, stop the run and let the user try later.
if (companyChestOrganize.Active && companyChestBusyHits >= 3)
{
companyChestOrganize.Active = false;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) FC Chest busy hit {companyChestBusyHits}; stopping organize run. msg='{text}'");
}
else if (companyChestOrganize.Active)
{
// Pause and retry later.
companyChestOrganize.WaitingForApply = false;
companyChestOrganize.WaitObservedChangeAtMs = 0;
companyChestOrganize.NextAttemptAtMs = Math.Max(companyChestOrganize.NextAttemptAtMs, companyChestBusyUntilMs + 750);
companyChestOrganize.ExpiresAtMs = Math.Max(companyChestOrganize.ExpiresAtMs, companyChestBusyUntilMs + 20000);
companyChestOrganize.WaitStuckCount = 0;
}
// Deposit is interactive; stop it outright on busy.
companyChestDeposit.Active = false;
pendingCompanyChestNumericConfirmUntilMs = 0;
pendingCompanyChestNumericArmed = false;
pendingNumericKind = PendingNumericKind.None;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) FC Chest busy detected from chat; backoff={backoffMs}ms (hit {companyChestBusyHits}). msg='{text}'");
}
}
catch
{
// ignore
}
}
private void OpenForItemSlotDetour(
AgentInventoryContext* agent,
FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType,
int slot,
int a4,
uint addonId)
{
openForItemSlotHook?.Original(agent, inventoryType, slot, a4, addonId);
if (!Configuration.Enabled)
return;
// Record observed a4 values so we can reuse them for MMB-driven opens.
try
{
observedContextA4[(addonId, (uint)inventoryType)] = a4;
// If this call actually produced a context menu, remember it as a safe fallback for MMB sorting.
if (agent != null && agent->ContextItemCount > 0)
lastGoodContextTargetByAddonId[addonId] = (inventoryType, slot, a4);
if (Configuration.DebugMode && Environment.TickCount64 - lastObservedA4LogMs >= 1000)
{
lastObservedA4LogMs = Environment.TickCount64;
Log.Information($"[QuickTransfer] Observed OpenForItemSlot: type={inventoryType} slot={slot} a4={a4} addonId={addonId} ctxCount={(agent != null ? agent->ContextItemCount : -1)}");
}
}
catch
{
// ignore
}
// Modifier: Ctrl+RClick (special) or Shift+RClick (default).
// Ctrl takes priority if both are held. Use a short "latch" so quick taps still work.
var mode = GetModifierModeLatched(Environment.TickCount64);
if (mode == null)
return;
var saddlebagOpen = IsSaddlebagOpen();
var retainerOpen = IsRetainerOpen();
var companyChestOpen = IsCompanyChestOpen();
var specialOpen = saddlebagOpen || retainerOpen || companyChestOpen;
// Ctrl is only enabled while a "special" container is open (Saddlebag or Retainer),
// so Shift/Ctrl can be used to disambiguate behaviors.
if (mode == ModifierMode.Ctrl && !specialOpen)
return;
// Never run Ctrl-mode from saddlebag slots.
if (mode == ModifierMode.Ctrl && IsSaddlebagType(inventoryType))
return;
// Never run Ctrl-mode from retainer slots.
if (mode == ModifierMode.Ctrl && IsRetainerType(inventoryType))
return;
// Never run Ctrl-mode from Company Chest slots.
if (mode == ModifierMode.Ctrl && IsCompanyChestType(inventoryType))
return;
var now = Environment.TickCount64;
if (now - lastActionTickMs < Configuration.TransferCooldownMs)
return;
// For Alt (Split), prefer the deferred OnMenuOpened path (more reliable than firing callbacks during OpenForItemSlot).
if (mode == ModifierMode.Alt)
return;
if (mode == ModifierMode.Shift && companyChestOpen && Configuration.EnableCompanyChest)
{
// If a quantity dialog is already open, don't start another move.
if (TryGetVisibleAddon(InputNumericAddonName, out _))
return;
// Deposit: Inventory -> Company Chest (UI-driven move).
// This is handled as a small state machine so stacks can top-off existing stacks and spill into new stacks.
if (IsPlayerInventoryType(inventoryType) && StartCompanyChestDeposit(inventoryType, (uint)slot))
{
lastActionTickMs = now;
TryCloseCurrentContextMenu(agent);
return;
}
}
if (TryAutoSelectAndClose(agent, mode.Value, out var chosenText, out var chosenIndex))
{
lastActionTickMs = now;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] ({mode} + RClick) Selected context action '{chosenText}' (idx={chosenIndex}) via OpenForItemSlot.");
// Set up Trade quantity auto-confirm if Trade was selected
if (mode == ModifierMode.Shift &&
chosenText.Length > 0 &&
ContextLabelMatches(AutoContextAction.Trade, chosenText) &&
IsTradeOpen())
{
pendingCompanyChestNumericConfirmUntilMs = now + 1500;
pendingCompanyChestNumericConfirmAttempts = 0;
pendingCompanyChestNumericArmed = true;
pendingNumericKind = PendingNumericKind.Trade;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
ArmSuppressInputNumeric(now, 1500);
}
}
else if (Configuration.DebugMode && mode == ModifierMode.Ctrl)
{
Log.Information("[QuickTransfer] (Ctrl + RClick) No matching armoury action found in context menu.");
DebugDumpContextMenu(agent, maxItems: 24);
}
}
private void OnContextMenuOpened(IMenuOpenedArgs args)
{
if (!Configuration.Enabled)
return;
var now = Environment.TickCount64;
var middleSortActive = pendingMiddleClickSortUntilMs > 0 && now <= pendingMiddleClickSortUntilMs;
var mode = middleSortActive ? null : GetModifierModeLatched(now);
if (!middleSortActive && mode == null)
return;
var saddlebagOpen = IsSaddlebagOpen();
var retainerOpen = IsRetainerOpen();
var companyChestOpen = IsCompanyChestOpen();
var specialOpen = saddlebagOpen || retainerOpen || companyChestOpen;
if (!middleSortActive && mode == ModifierMode.Ctrl && !specialOpen)
return;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] OnMenuOpened: AddonName='{args.AddonName}', MenuType={args.MenuType}, AgentPtr=0x{args.AgentPtr.ToInt64():X}, AddonPtr=0x{args.AddonPtr.ToInt64():X}");
// Middle-click "Sort" uses an inventory context menu, but does not require Shift/Ctrl.
if (middleSortActive && args.MenuType == ContextMenuType.Inventory)
{
if (args.AgentPtr != IntPtr.Zero && args.AddonPtr != IntPtr.Zero)
{
pendingDeferredSortMenuClick = ((nint)args.AgentPtr, (nint)args.AddonPtr, now);
return;
}
}
// Free Company Chest uses MenuType.Default (not Inventory).
if (args.MenuType == ContextMenuType.Default &&
(mode == ModifierMode.Shift || mode == ModifierMode.Alt) &&
Configuration.EnableCompanyChest &&
string.Equals(args.AddonName, FreeCompanyChestAddonName, StringComparison.OrdinalIgnoreCase))
{
pendingDeferredDefaultMenu = (args.AddonName ?? string.Empty, now, mode.Value);
return;
}
// Only deal with inventory context menus otherwise.
if (args.MenuType != ContextMenuType.Inventory)
return;
if (args.AgentPtr == IntPtr.Zero || args.AddonPtr == IntPtr.Zero)
return;
if (mode == null)
return;
// IMPORTANT: Do not click inside the open event (re-entrancy risk).
pendingDeferredMenuClick = ((nint)args.AgentPtr, (nint)args.AddonPtr, Environment.TickCount64, mode.Value);
}
private void OnFrameworkUpdate(IFramework framework)
{
if (!Configuration.Enabled)
return;
var now = Environment.TickCount64;
// Poll mouse button state from Win32 and log transitions in DebugMode.
// This helps diagnose cases where the game doesn't emit click events for MMB.
var lDown = IsVkDown(0x01); // VK_LBUTTON
var rDown = IsVkDown(0x02); // VK_RBUTTON
var mDown = IsVkDown(0x04); // VK_MBUTTON
var x1Down = IsVkDown(0x05); // VK_XBUTTON1
var x2Down = IsVkDown(0x06); // VK_XBUTTON2
var prevL = lastVkLButtonDown;
var prevR = lastVkRButtonDown;
var prevM = lastVkMButtonDown;
var prevX1 = lastVkX1ButtonDown;
var prevX2 = lastVkX2ButtonDown;
if (Configuration.DebugMode && (lDown != prevL || rDown != prevR || mDown != prevM || x1Down != prevX1 || x2Down != prevX2))
Log.Information($"[QuickTransfer] Win32 mouse state: L={(lDown ? 1 : 0)} R={(rDown ? 1 : 0)} M={(mDown ? 1 : 0)} X1={(x1Down ? 1 : 0)} X2={(x2Down ? 1 : 0)}");
lastVkLButtonDown = lDown;
lastVkRButtonDown = rDown;
lastVkMButtonDown = mDown;
lastVkX1ButtonDown = x1Down;
lastVkX2ButtonDown = x2Down;
// If a "middle-ish" button is pressed (rising edge), queue a sort using the last hovered slot.
// This works even if the client doesn't generate a distinct UI click event on this build.
var middleEdge = (mDown && !prevM) || (x1Down && !prevX1) || (x2Down && !prevX2);
if (middleEdge)
{
if (Configuration.EnableMiddleClickSort)
TryQueueMiddleClickSortFromHover(now);
else if (Configuration.DebugMode)
Log.Information("[QuickTransfer] (MMB) Press detected, but EnableMiddleClickSort is disabled.");
}
// Modifier latch (helps cases where the user taps Shift/Ctrl quickly).
if (KeyState[VirtualKey.SHIFT])
lastShiftSeenMs = now;
if (KeyState[VirtualKey.CONTROL])
lastCtrlSeenMs = now;
if (KeyState[VirtualKey.MENU])
lastAltSeenMs = now;
// Quantity prompt auto-confirm (best effort).
// Trade and Split always auto-confirm; Company Chest respects the config setting.
var shouldAutoConfirm = pendingNumericKind == PendingNumericKind.Trade ||
pendingNumericKind == PendingNumericKind.Split ||
(Configuration.AutoConfirmCompanyChestQuantity && pendingNumericKind != PendingNumericKind.None);
if (shouldAutoConfirm &&
pendingNumericKind != PendingNumericKind.None &&
pendingCompanyChestNumericConfirmUntilMs > 0 &&
now <= pendingCompanyChestNumericConfirmUntilMs)
{
if (TryGetVisibleAddon(InputNumericAddonName, out var inputNumeric))
{
ArmSuppressInputNumeric(now);
// Phase 1: set max (and wait a frame so the component commits the value internally).
if (!pendingCompanyChestNumericValueSet)
{
if (!TrySetInputNumericToMax(inputNumeric, pendingNumericKind))
{
if (Configuration.DebugMode)
{
try
{
var promptVal = inputNumeric->AtkValues + 6;
var prompt = promptVal->Type is AtkValueType.String or AtkValueType.ManagedString ? ReadAtkValueString(*promptVal) : string.Empty;
var minVal = inputNumeric->AtkValues + 2;
var maxVal = inputNumeric->AtkValues + 3;
var min = minVal->Type == AtkValueType.UInt ? minVal->UInt : 0U;
var max = maxVal->Type == AtkValueType.UInt ? maxVal->UInt : 0U;
Log.Information($"[QuickTransfer] Auto-confirm InputNumeric skipped (kind={pendingNumericKind}, prompt='{prompt}', min={min}, max={max}, expectedSplitMax={pendingSplitExpectedMax}).");
}
catch
{
Log.Information($"[QuickTransfer] Auto-confirm InputNumeric skipped (kind={pendingNumericKind}).");
}
}
// Prompt doesn't match expectation; stop (prevents confirming wrong dialogs).
pendingCompanyChestNumericConfirmUntilMs = 0;
pendingCompanyChestNumericArmed = false;
pendingNumericKind = PendingNumericKind.None;
pendingCompanyChestNumericHalf = false;
pendingSplitExpectedMax = 0;
pendingSplitExpectedUntilMs = 0;
suppressInputNumericUntilMs = 0;
}
else
{
pendingCompanyChestNumericValueSet = true;
pendingCompanyChestNumericValueSetAtMs = now;
}
return;
}
// Phase 2: confirm after a short delay (next frame / ~50ms).
if (now - pendingCompanyChestNumericValueSetAtMs < 50)
return;
// Re-check prompt + re-apply max right before confirming (cheap + safer).
if (!TrySetInputNumericToMax(inputNumeric, pendingNumericKind))
{
if (Configuration.DebugMode)
{
try
{
var promptVal = inputNumeric->AtkValues + 6;
var prompt = promptVal->Type is AtkValueType.String or AtkValueType.ManagedString ? ReadAtkValueString(*promptVal) : string.Empty;
var minVal = inputNumeric->AtkValues + 2;
var maxVal = inputNumeric->AtkValues + 3;
var min = minVal->Type == AtkValueType.UInt ? minVal->UInt : 0U;
var max = maxVal->Type == AtkValueType.UInt ? maxVal->UInt : 0U;
Log.Information($"[QuickTransfer] Auto-confirm InputNumeric aborted (kind={pendingNumericKind}, prompt='{prompt}', min={min}, max={max}, expectedSplitMax={pendingSplitExpectedMax}).");
}
catch
{
Log.Information($"[QuickTransfer] Auto-confirm InputNumeric aborted (kind={pendingNumericKind}).");
}
}
pendingCompanyChestNumericConfirmUntilMs = 0;
pendingCompanyChestNumericArmed = false;
pendingNumericKind = PendingNumericKind.None;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericHalf = false;
pendingSplitExpectedMax = 0;
pendingSplitExpectedUntilMs = 0;
return;
}
try
{
if (pendingCompanyChestNumericConfirmAttempts == 0)
{
// IMPORTANT:
// For InputNumeric, FireCallbackInt(value) is interpreted as the quantity being confirmed on this client build.
// Passing "2" causes exactly the observed behavior: moving 2 every time.
var toConfirm = pendingCompanyChestNumericDesired;
if (toConfirm == 0)
{
// Default: confirm max (we already set the numeric input to max above).
try
{
var maxVal = inputNumeric->AtkValues + 3;
if (maxVal->Type == AtkValueType.UInt)
toConfirm = maxVal->UInt;
else if (maxVal->Type == AtkValueType.Int)
toConfirm = (uint)Math.Max(0, maxVal->Int);
}
catch
{
// ignore
}
if (toConfirm == 0)
toConfirm = 1;
}
inputNumeric->FireCallbackInt((int)toConfirm);
pendingCompanyChestNumericConfirmAttempts = 1;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] Auto-confirmed InputNumeric attempt 1 (kind={pendingNumericKind}, FireCallbackInt={toConfirm}).");
// Clear state after issuing confirm; the dialog should close itself.
pendingCompanyChestNumericConfirmUntilMs = 0;
pendingCompanyChestNumericArmed = false;
pendingNumericKind = PendingNumericKind.None;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
pendingSplitExpectedMax = 0;
pendingSplitExpectedUntilMs = 0;
suppressInputNumericUntilMs = 0;
}
else
{
pendingCompanyChestNumericConfirmUntilMs = 0;
pendingCompanyChestNumericArmed = false;
pendingNumericKind = PendingNumericKind.None;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
pendingSplitExpectedMax = 0;
pendingSplitExpectedUntilMs = 0;
suppressInputNumericUntilMs = 0;
}
}
catch (Exception ex)
{
pendingCompanyChestNumericConfirmUntilMs = 0;
pendingCompanyChestNumericArmed = false;
pendingNumericKind = PendingNumericKind.None;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
pendingSplitExpectedMax = 0;
pendingSplitExpectedUntilMs = 0;
suppressInputNumericUntilMs = 0;
Log.Warning(ex, "[QuickTransfer] Failed to auto-confirm InputNumeric.");
}
}
}
else if (pendingCompanyChestNumericConfirmUntilMs > 0 && now > pendingCompanyChestNumericConfirmUntilMs)
{
pendingCompanyChestNumericConfirmUntilMs = 0;
pendingCompanyChestNumericArmed = false;
pendingNumericKind = PendingNumericKind.None;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
pendingSplitExpectedMax = 0;
pendingSplitExpectedUntilMs = 0;
suppressInputNumericUntilMs = 0;
}
// If we have a pending "move outValue" buffer for InputNumeric-driven moves, free it once the dialog is gone,
// or when the timeout expires.
if (pendingMoveOutValuePtr != 0 || pendingMoveAtkValuesPtr != 0)
{
var inputVisible = TryGetVisibleAddon(InputNumericAddonName, out _);
if (inputVisible)
pendingMoveSawInputNumeric = true;
// Important: InputNumeric often appears on a subsequent frame.
// Do NOT free the buffers immediately just because it's not visible yet.
var graceExpired = pendingMoveCreatedAtMs > 0 && now - pendingMoveCreatedAtMs >= 1500;
if ((pendingMoveSawInputNumeric && !inputVisible) || now >= pendingMoveOutValueFreeAtMs || (!inputVisible && graceExpired))
{
try { if (pendingMoveOutValuePtr != 0) Marshal.FreeHGlobal(pendingMoveOutValuePtr); } catch { /* ignore */ }
try { if (pendingMoveAtkValuesPtr != 0) Marshal.FreeHGlobal(pendingMoveAtkValuesPtr); } catch { /* ignore */ }
pendingMoveOutValuePtr = 0;
pendingMoveOutValueFreeAtMs = 0;
pendingMoveAtkValuesPtr = 0;
pendingMoveCreatedAtMs = 0;
pendingMoveSawInputNumeric = false;
}
}
// Company Chest deposit state machine (Inventory -> FC Chest).
if (Configuration.EnableCompanyChest)
ProcessCompanyChestDeposit(now);
// Company Chest organize (MMB): auto-stack + compact items within FC chest pages.
if (Configuration.EnableCompanyChest && Configuration.EnableCompanyChestMiddleClickOrganize)
ProcessCompanyChestOrganize(now);
// Middle-click sort: open the context menu on the clicked slot, then auto-select "Sort".
var mmb = pendingMiddleClickSortRequest;
if (Configuration.EnableMiddleClickSort && mmb != null && now - mmb.Value.EnqueuedAtMs <= 1500)
{
// If the request was for Company Chest, run organize instead (there is no Sort entry on the item menu).
if (IsCompanyChestType(mmb.Value.Type) && Configuration.EnableCompanyChest && Configuration.EnableCompanyChestMiddleClickOrganize)
{
// Only organize the currently selected tab (we use mmb.Value.Type as the selected FreeCompanyPage).
StartCompanyChestOrganize(now, mmb.Value.Type);
pendingMiddleClickSortRequest = null;
pendingMiddleClickSortUntilMs = 0;
}
else
{
// Safety: never call OpenForItemSlot with unknown inventory types; this can crash the game client.
if (!IsPlayerInventoryType(mmb.Value.Type) && !IsArmouryType(mmb.Value.Type) && !IsSaddlebagType(mmb.Value.Type) &&
!IsRetainerType(mmb.Value.Type) && !IsCompanyChestType(mmb.Value.Type))
{
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Refusing to call OpenForItemSlot for unrecognized inventory type={mmb.Value.Type} slot={mmb.Value.Slot} addonId={mmb.Value.AddonId} (crash-prevention).");
pendingMiddleClickSortRequest = null;
pendingMiddleClickSortUntilMs = 0;
return;
}
// Open context menu for that slot. Our OnMenuOpened handler will enqueue the deferred sort selection.
var agentModule = AgentModule.Instance();
if (agentModule != null)
{
var agent = agentModule->GetAgentByInternalId(AgentId.InventoryContext);
var invCtx = (AgentInventoryContext*)agent;
if (invCtx != null)
{
try
{
ArmSuppressContextMenu(now, 250);
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Calling OpenForItemSlot: type={mmb.Value.Type} slot={mmb.Value.Slot} addonId={mmb.Value.AddonId}");
// Try to open the inventory context menu using the same mysterious "a4" value the game uses.
// If we don't have a recorded value yet, try a small set of common candidates.
int[] candidates;
if (!observedContextA4.TryGetValue((mmb.Value.AddonId, (uint)mmb.Value.Type), out var observedA4))
{
// Heuristic: armoury boards often need a non-zero a4; try 1 first.
candidates = IsArmouryType(mmb.Value.Type) ? [1, 0, 2] : [0, 1, 2];
}
else
{
candidates = [observedA4, 0, 1, 2];
}
var opened = false;
var usedA4 = 0;
foreach (var a4 in candidates.Distinct())
{
invCtx->OpenForItemSlot(mmb.Value.Type, mmb.Value.Slot, a4, mmb.Value.AddonId);
usedA4 = a4;
if (invCtx->ContextItemCount > 0)
{
opened = true;
observedContextA4[(mmb.Value.AddonId, (uint)mmb.Value.Type)] = a4;
break;
}
}
// Fallback: don't rely solely on OnMenuOpened firing.
try
{
var cm = GameGui.GetAddonByName("ContextMenu", 1);
pendingDeferredSortMenuClick = ((nint)invCtx, cm.IsNull ? 0 : (nint)cm.Address, now);
}
catch
{
pendingDeferredSortMenuClick = ((nint)invCtx, 0, now);
}
if (Configuration.DebugMode)
{
try
{
Log.Information(
$"[QuickTransfer] (MMB) Post OpenForItemSlot: opened={(opened ? 1 : 0)} usedA4={usedA4} ContextItemCount={invCtx->ContextItemCount}, " +
$"OwnerAddonId={invCtx->OwnerAddonId}, BlockingAddonId={invCtx->BlockingAddonId}, " +
$"TargetInv={invCtx->TargetInventoryId}, TargetSlot={invCtx->TargetInventorySlotId}");
}
catch
{
// ignore
}
}
}
catch
{
// ignore
}
}
}
// Only try once; selection happens via deferred menu click.
pendingMiddleClickSortRequest = null;
}
}
// Delay-close the context menu slightly; closing immediately can cancel some default-menu actions.
if (pendingCloseContextMenuAtMs > 0 && now >= pendingCloseContextMenuAtMs)
{
pendingCloseContextMenuAtMs = 0;
try
{
var cm = (AtkUnitBase*)GameGui.GetAddonByName("ContextMenu", 1).Address;
if (cm != null)
{
try { cm->Hide(false, true, 0); } catch { /* ignore */ }
try { atkUnitBaseClose?.Invoke(cm, 0); } catch { /* ignore */ }
}
}
catch
{
// ignore
}
}
// Handle deferred "default" context menus (e.g., FreeCompanyChest).
var pendingDefault = pendingDeferredDefaultMenu;
if (pendingDefault != null)
{
pendingDeferredDefaultMenu = null;
if (now - pendingDefault.Value.EnqueuedAtMs <= 1500 &&
(pendingDefault.Value.Mode == ModifierMode.Shift || pendingDefault.Value.Mode == ModifierMode.Alt) &&
pendingDefault.Value.AddonName.Equals(FreeCompanyChestAddonName, StringComparison.OrdinalIgnoreCase) &&
Configuration.EnableCompanyChest)
{
ArmSuppressContextMenu(now, 1500);
if (TrySelectRemoveFromCompanyChestContextMenu())
{
lastActionTickMs = now;
pendingCompanyChestNumericConfirmUntilMs = Configuration.AutoConfirmCompanyChestQuantity ? now + 1500 : 0;
pendingCompanyChestNumericConfirmAttempts = 0;
pendingCompanyChestNumericArmed = true;
pendingNumericKind = PendingNumericKind.Remove;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = pendingDefault.Value.Mode == ModifierMode.Alt;
ArmSuppressInputNumeric(now, 1500);
}
// Keep suppression on while the remove dialog is being handled.
}
}
var pending = pendingDeferredMenuClick;
if (pending == null)
{
// Process deferred middle-click sort selection even if no normal deferred click.
ProcessDeferredSortMenuClick(now);
return;
}
if (now - pending.Value.EnqueuedAtMs > 1500)
{
// Consume (timed out).
pendingDeferredMenuClick = null;
return;
}
// Give the context menu a moment to populate after OnMenuOpened (InventoryExpansion often needs a frame).
if (now - pending.Value.EnqueuedAtMs < 50)
return;
// Consume (only try once after the short delay).
pendingDeferredMenuClick = null;
// If we already acted this tick/window via OpenForItemSlot, don't deref pointers.
if (now - lastActionTickMs < Configuration.TransferCooldownMs)
return;
try
{
var agent = (AgentInventoryContext*)pending.Value.AgentPtr;
// NOTE:
// IMenuOpenedArgs.AddonPtr/AddOnName refers to the addon that *opened* the menu (e.g. Inventory/InventoryExpansion),
// not the context menu addon itself. We must fire callbacks on the actual ContextMenu addon.
AtkUnitBase* addon = null;
try
{
var cm = GameGui.GetAddonByName(ContextMenuAddonName, 1);
if (!cm.IsNull)
addon = (AtkUnitBase*)cm.Address;
}
catch
{
// ignore
}
// Fallback: keep whatever we were given (older Dalamud builds may have provided the context menu pointer).
if (addon == null)
addon = (AtkUnitBase*)pending.Value.AddonPtr;
if (TryAutoSelectAndClose(agent, addon, pending.Value.Mode, out var chosenText, out var chosenIndex))
{
lastActionTickMs = now;
// Split is finicky: keep the menu suppressed longer so it can't be cancelled by an early close/visibility change.
var suppressMs = (pending.Value.Mode == ModifierMode.Alt && chosenText.Length > 0 && ContextLabelMatches(AutoContextAction.Split, chosenText))
? 3000
: 1500;
ArmSuppressContextMenu(now, suppressMs);
if (pending.Value.Mode == ModifierMode.Shift &&
chosenText.Length > 0 &&
ContextLabelMatches(AutoContextAction.Trade, chosenText))
{
// Trade: auto-confirm max quantity when InputNumeric appears
pendingCompanyChestNumericConfirmUntilMs = now + 1500;
pendingCompanyChestNumericConfirmAttempts = 0;
pendingCompanyChestNumericArmed = true;
pendingNumericKind = PendingNumericKind.Trade;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
ArmSuppressInputNumeric(now, 1500);
}
if (Configuration.EnableCompanyChest &&
pending.Value.Mode == ModifierMode.Shift &&
chosenText.Length > 0 &&
ContextLabelMatches(AutoContextAction.RemoveFromCompanyChest, chosenText))
{
pendingCompanyChestNumericConfirmUntilMs = Configuration.AutoConfirmCompanyChestQuantity ? now + 1500 : 0;
pendingCompanyChestNumericConfirmAttempts = 0;
pendingCompanyChestNumericArmed = true;
pendingNumericKind = PendingNumericKind.Remove;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
ArmSuppressInputNumeric(now, 1500);
}
if (pending.Value.Mode == ModifierMode.Alt &&
chosenText.Length > 0 &&
ContextLabelMatches(AutoContextAction.Split, chosenText))
{
// InventoryExpansion can delay InputNumeric slightly; allow a longer window.
pendingCompanyChestNumericConfirmUntilMs = Configuration.AutoConfirmCompanyChestQuantity ? now + 5000 : 0;
pendingCompanyChestNumericConfirmAttempts = 0;
pendingCompanyChestNumericArmed = true;
pendingNumericKind = PendingNumericKind.Split;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = true;
ArmSuppressInputNumeric(now, 5000);
// 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 srcSlot = (int)agent->TargetInventorySlotId;
if (TryGetItemInfo(srcType, srcSlot, out _, out _, out var qty) && qty > 1)
{
pendingSplitExpectedMax = qty - 1;
pendingSplitExpectedUntilMs = now + 5000;
}
else
{
pendingSplitExpectedMax = 0;
pendingSplitExpectedUntilMs = 0;
}
}
catch
{
pendingSplitExpectedMax = 0;
pendingSplitExpectedUntilMs = 0;
}
}
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] ({pending.Value.Mode} + RClick) Selected context action '{chosenText}' (idx={chosenIndex}) via deferred OnMenuOpened.");
}
else if (Configuration.DebugMode && pending.Value.Mode == ModifierMode.Ctrl)
{
Log.Information("[QuickTransfer] (Ctrl + RClick) Deferred menu opened but no matching 'Place in Armoury Chest' action was found.");
DebugDumpContextMenu(agent, maxItems: 24);
}
}
catch (Exception ex)
{
Log.Warning(ex, "[QuickTransfer] Deferred menu select failed.");
}
// Also process a pending sort click (if any) after normal transfers.
ProcessDeferredSortMenuClick(now);
}
private void ProcessDeferredSortMenuClick(long now)
{
var pendingSort = pendingDeferredSortMenuClick;
if (pendingSort == null)
return;
// Give the context menu a moment to populate after OpenForItemSlot.
if (now - pendingSort.Value.EnqueuedAtMs < 50)
return;
if (now - pendingSort.Value.EnqueuedAtMs > 1500)
{
if (Configuration.DebugMode)
{
try
{
var agent = (AgentInventoryContext*)pendingSort.Value.AgentPtr;
var count = agent != null ? agent->ContextItemCount : -1;
Log.Information($"[QuickTransfer] (MMB) Deferred sort timed out (ContextItemCount={count}).");
}
catch
{
Log.Information("[QuickTransfer] (MMB) Deferred sort timed out.");
}
}
pendingDeferredSortMenuClick = null;
pendingMiddleClickSortUntilMs = 0;
return;
}
try
{
var agent = (AgentInventoryContext*)pendingSort.Value.AgentPtr;
var addon = (AtkUnitBase*)pendingSort.Value.AddonPtr;
// If we didn't have the ContextMenu addon pointer yet, try to resolve it now.
if (addon == null)
{
try
{
var cm = GameGui.GetAddonByName("ContextMenu", 1);
if (!cm.IsNull)
{
addon = (AtkUnitBase*)cm.Address;
pendingDeferredSortMenuClick = (pendingSort.Value.AgentPtr, (nint)addon, pendingSort.Value.EnqueuedAtMs);
pendingSort = pendingDeferredSortMenuClick;
}
}
catch
{
// ignore
}
}
// If the menu hasn't populated yet, keep waiting.
// AgentInventoryContext::ContextItemCount tends to remain 0 for a frame or two after OpenForItemSlot.
if (agent == null || agent->ContextItemCount <= 0)
return;
if (addon == null)
return;
if (TrySelectSortAndClose(agent, addon, out var chosenText, out var chosenIndex))
{
pendingDeferredSortMenuClick = null;
pendingMiddleClickSortUntilMs = 0;
lastActionTickMs = now;
ArmSuppressContextMenu(now, 500);
if (Configuration.DebugMode)
{
if (chosenIndex >= 0)
Log.Information($"[QuickTransfer] (MMB) Selected context action '{chosenText}' (idx={chosenIndex}) via deferred OnMenuOpened.");
else
Log.Information("[QuickTransfer] (MMB) Already sorted (Undo Sort present); no action taken.");
}
}
else
{
// If we opened a menu but didn't find Sort, wait briefly in case the menu is still updating.
// After ~300ms, give up and close it to avoid leaving a hidden menu behind.
if (now - pendingSort.Value.EnqueuedAtMs < 300)
return;
if (Configuration.DebugMode)
{
Log.Information($"[QuickTransfer] (MMB) Context menu opened but no 'Sort' entry was found (count={agent->ContextItemCount}).");
DebugDumpContextMenu(agent, maxItems: 32);
}
pendingDeferredSortMenuClick = null;
pendingMiddleClickSortUntilMs = 0;
try { if (addon != null) CloseContextMenuAddon(agent, addon); } catch { /* ignore */ }
}
}
catch (Exception ex)
{
pendingDeferredSortMenuClick = null;
pendingMiddleClickSortUntilMs = 0;
Log.Warning(ex, "[QuickTransfer] Deferred sort select failed.");
}
}
private void OnAddonPreDraw(AddonEvent type, AddonArgs args)
{
try
{
var name = args.AddonName ?? string.Empty;
var now = Environment.TickCount64;
if (string.Equals(name, ContextMenuAddonName, StringComparison.OrdinalIgnoreCase))
{
var addon = (AtkUnitBase*)args.Addon.Address;
if (now <= suppressContextMenuUntilMs)
MakeAddonInvisible(addon);
else
MakeAddonVisible(addon);
}
if (string.Equals(name, InputNumericAddonName, StringComparison.OrdinalIgnoreCase))
{
var addon = (AtkUnitBase*)args.Addon.Address;
if (now <= suppressInputNumericUntilMs)
MakeAddonInvisible(addon);
else
MakeAddonVisible(addon);
}
}
catch
{
// ignore
}
}
private void OnAddonReceiveEvent(AddonEvent type, AddonArgs args)
{
try
{
if (!Configuration.Enabled || !Configuration.EnableMiddleClickSort)
return;
if (args is not AddonReceiveEventArgs recv)
return;
var now = Environment.TickCount64;
if (Configuration.DebugMode && !debugPrintedReceiveEventHook)
{
debugPrintedReceiveEventHook = true;
try { ChatGui.Print("[QuickTransfer] ReceiveEvent hook active (MMB debug)."); } catch { /* ignore */ }
Log.Information("[QuickTransfer] ReceiveEvent hook active (MMB debug).");
}
var eventType = (AtkEventType)recv.AtkEventType;
var eventData = (AtkEventData*)recv.AtkEventData;
var mouseButtonId = eventData != null ? eventData->MouseData.ButtonId : (byte)255;
var dragDropMouseButtonId = eventData != null ? eventData->DragDropData.MouseButtonId : (byte)255;
// Track last-hovered dragdrop (for polling-based triggers).
// IMPORTANT:
// - For ArmouryBoard, only capture from drag-drop rollover/click (avoids bad union reads on some builds).
// - For Inventory/Saddlebags, we also allow MouseOver by resolving the DDI from atkEvent->Node (safe path).
var addonName = args.AddonName ?? string.Empty;
var allowMouseOverCapture =
addonName.Equals("Inventory", StringComparison.OrdinalIgnoreCase) ||
addonName.Equals("InventoryBuddy", StringComparison.OrdinalIgnoreCase) ||
addonName.Equals("InventoryBuddy2", StringComparison.OrdinalIgnoreCase) ||
addonName.Equals("RetainerGrid0", StringComparison.OrdinalIgnoreCase) ||
addonName.Equals("RetainerGrid", StringComparison.OrdinalIgnoreCase) ||
addonName.Equals("RetainerSellList", StringComparison.OrdinalIgnoreCase) ||
addonName.Equals(FreeCompanyChestAddonName, StringComparison.OrdinalIgnoreCase);
// Always track which addon the cursor is currently interacting with, even if we can't resolve a DDI.
// This enables a safe MMB "Sort" path that doesn't dereference drag/drop pointers.
if (eventType is AtkEventType.MouseOver or AtkEventType.MouseOut or AtkEventType.DragDropRollOver or AtkEventType.DragDropRollOut ||
eventType is AtkEventType.ListItemRollOver or AtkEventType.ListItemRollOut)
{
try
{
var ab = (AtkUnitBase*)args.Addon.Address;
var id = ab != null ? ab->Id : 0u;
if (eventType is AtkEventType.MouseOut or AtkEventType.DragDropRollOut or AtkEventType.ListItemRollOut)
{
lastHoverAddon = null;
}
else if (id != 0)
{
lastHoverAddon = (addonName, id, now);
}
}
catch
{
// ignore
}
}
// FreeCompanyChest: remember which compartment tab is selected based on its button click param.
// This allows MMB organize to operate ONLY on the active tab, even if you don't hover an item slot first.
if (addonName.Equals(FreeCompanyChestAddonName, StringComparison.OrdinalIgnoreCase) &&
eventType == AtkEventType.ButtonClick)
{
try
{
var ab = (AtkUnitBase*)args.Addon.Address;
var id = ab != null ? ab->Id : 0u;
if (id != 0 && TryMapCompanyChestTabParamToPage(recv.EventParam, out var selectedPage))
{
lastSelectedCompanyChestPage = (selectedPage, id, now);
ObserveCompanyChestTabFromAtkValues(ab, selectedPage);
if (Configuration.DebugMode && now - lastReceiveEventDebugLogMs >= 250)
Log.Information($"[QuickTransfer] FC Chest selected tab: param={recv.EventParam} -> {selectedPage} (addonId={id})");
// If we're currently organizing a different tab, stop immediately.
if (companyChestOrganize.Active &&
(companyChestOrganize.OwnerAddonId == 0 || companyChestOrganize.OwnerAddonId == id) &&
companyChestOrganize.Pages is { Length: 1 } &&
companyChestOrganize.Pages[0] != selectedPage)
{
companyChestOrganize.Active = false;
companyChestOrganize.WaitingForApply = false;
companyChestOrganize.WaitObservedChangeAtMs = 0;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Company Chest tab changed to {selectedPage}; stopping previous organize run.");
}
}
else if (Configuration.DebugMode && id != 0 && now - lastFcChestTabUnmappedLogMs >= 250)
{
lastFcChestTabUnmappedLogMs = now;
Log.Information($"[QuickTransfer] FC Chest tab param unmapped: param={recv.EventParam} (addonId={id})");
}
}
catch
{
// ignore
}
}
if (eventType is AtkEventType.DragDropRollOut || (allowMouseOverCapture && eventType is AtkEventType.MouseOut))
{
lastHoverDdi = null;
lastHoverAddonName = string.Empty;
}
else if (eventType is AtkEventType.DragDropRollOver or AtkEventType.DragDropClick ||
(allowMouseOverCapture && eventType is AtkEventType.MouseOver))
{
if (TryGetDragDropInterfaceFromReceiveEvent(args, recv, eventType, eventData, out var hAddonId, out var hDdi) && hDdi != null)
{
var ptr = (nint)hDdi;
if (ptr >= MinLikelyPointer)
{
lastHoverDdi = (ptr, hAddonId, now);
lastHoverAddonName = addonName;
}
// For FC Chest, decode the hovered page while the payload is fresh.
if (addonName.Equals(FreeCompanyChestAddonName, StringComparison.OrdinalIgnoreCase))
{
try
{
if (TryGetSlotFromDragDropInterface(hDdi, out var hoverInvType, out _))
{
if (IsCompanyChestType(hoverInvType))
lastHoverCompanyChestPage = (hoverInvType, hAddonId, now);
}
}
catch
{
// ignore
}
}
// Debug: confirm hover capture occasionally.
if (Configuration.DebugMode && now - lastReceiveEventDebugLogMs >= 250)
{
Log.Information($"[QuickTransfer] HoverCapture: Addon='{args.AddonName}', EventType={eventType}, Param={recv.EventParam}, DDI=0x{((nint)hDdi):X}");
}
}
}
// Determine middle-click reliably:
// - Prefer Win32 VK_MBUTTON (works regardless of client button id mapping)
// - Fall back to the event's button flags (some builds use bitmask: middle=0x04)
// - As a final fallback, try Dalamud KeyState (may not include mouse buttons on some builds)
bool? middleDown = null;
try
{
const VirtualKey vkMButton = (VirtualKey)0x04; // VK_MBUTTON
middleDown = KeyState[vkMButton];
}
catch
{
// ignore
}
var asyncMiddleDown = IsVkDown(0x04); // VK_MBUTTON
var isMiddleByMask = ((mouseButtonId & 0x04) != 0) || ((dragDropMouseButtonId & 0x04) != 0);
var isMiddle = asyncMiddleDown || isMiddleByMask || middleDown == true;
// Always log (rate-limited) in DebugMode so we can see which event types fire on MMB for this client.
if (Configuration.DebugMode && now - lastReceiveEventDebugLogMs >= 250)
{
lastReceiveEventDebugLogMs = now;
Log.Information(
$"[QuickTransfer] PreReceiveEvent: Addon='{args.AddonName}', Type={eventType}, Param={recv.EventParam}, " +
$"MouseBtn={mouseButtonId} (0x{mouseButtonId:X2}), DragBtn={dragDropMouseButtonId} (0x{dragDropMouseButtonId:X2}), " +
$"MaskMiddle={(isMiddleByMask ? "1" : "0")}, AsyncMiddle={(asyncMiddleDown ? "1" : "0")}, KeyStateMiddle={(middleDown?.ToString() ?? "n/a")}");
}
if (now - lastMiddleClickSortMs < 250)
return;
// Only proceed on click events; other events can be noisy and don't carry slot payloads.
if (eventType != AtkEventType.DragDropClick &&
eventType != AtkEventType.MouseClick &&
eventType != AtkEventType.MouseDown)
return;
if (!isMiddle)
return;
if (!TryGetDragDropInterfaceFromReceiveEvent(args, recv, eventType, eventData, out var addonId, out var ddi))
return;
if (!TryGetSlotFromDragDropInterface(ddi, out var invType, out var slot))
return;
// Do not require a non-empty slot; "Sort" can be invoked from empty slots/spaces.
pendingMiddleClickSortRequest = (invType, slot, addonId, now);
pendingMiddleClickSortUntilMs = now + 1500;
lastMiddleClickSortMs = now;
// Prevent the underlying UI from processing the click further.
var atkEvent2 = (AtkEvent*)recv.AtkEvent;
if (atkEvent2 != null)
atkEvent2->SetEventIsHandled();
}
catch
{
// ignore
}
}
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;
}
private void OnInputNumericPreSetup(AddonEvent type, AddonArgs args)
{
try
{
if (!Configuration.EnableCompanyChest)
return;
if (!pendingCompanyChestNumericArmed)
return;
if (!string.Equals(args.AddonName, InputNumericAddonName, StringComparison.OrdinalIgnoreCase))
return;
// Only touch this dialog if the Company Chest is open (avoid affecting unrelated InputNumeric uses).
if (!IsCompanyChestOpen())
return;
if (args is not AddonSetupArgs setup)
return;
var values = (AtkValue*)setup.AtkValues;
var count = (int)setup.AtkValueCount;
if (values == null || count < 7)
return;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] InputNumeric PreSetup (armed): AtkValueCount={count}");
// Guard against cross-confirmation: only touch the prompt we intended (store/remove).
if (pendingNumericKind != PendingNumericKind.None)
{
var prompt = values[6].Type is AtkValueType.String or AtkValueType.ManagedString ? ReadAtkValueString(values[6]) : string.Empty;
if (pendingNumericKind == PendingNumericKind.Store && !prompt.Contains("store", StringComparison.OrdinalIgnoreCase))
return;
if (pendingNumericKind == PendingNumericKind.Remove && !prompt.Contains("remove", StringComparison.OrdinalIgnoreCase))
return;
// For "Move" we accept any prompt while the Company Chest is open (used for internal stack/organize moves).
}
// Standard InputNumeric layout (also used by SimpleTweaks):
// [2]=min (UInt), [3]=max (UInt), [4]=default (UInt), [6]=prompt text (String)
if (values[2].Type != AtkValueType.UInt || values[3].Type != AtkValueType.UInt || values[4].Type != AtkValueType.UInt)
{
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] InputNumeric PreSetup: unexpected types: [2]={values[2].Type}, [3]={values[3].Type}, [4]={values[4].Type}");
return;
}
var min = values[2].UInt;
var max = values[3].UInt;
var desired = max < min ? min : max;
// Log current/default if present.
if (Configuration.DebugMode)
{
var curStr = (count > 5 && values[5].Type == AtkValueType.UInt) ? values[5].UInt.ToString() : "n/a";
Log.Information($"[QuickTransfer] InputNumeric PreSetup: min={min}, max={max}, default={values[4].UInt}, current={curStr}");
}
values[4].UInt = desired; // default
if (count > 5)
{
if (values[5].Type == AtkValueType.UInt)
values[5].UInt = desired; // some layouts have current (UInt)
else if (values[5].Type is AtkValueType.String or AtkValueType.ManagedString or AtkValueType.String8)
WriteUtf8InPlace(values[5].String, desired.ToString()); // some builds use String current
}
if (Configuration.DebugMode)
{
var prompt = values[6].Type is AtkValueType.String or AtkValueType.ManagedString ? ReadAtkValueString(values[6]) : string.Empty;
Log.Information($"[QuickTransfer] InputNumeric PreSetup: prompt='{prompt}', min={min}, max={max}, setDefault={desired}");
}
}
catch (Exception ex)
{
Log.Warning(ex, "[QuickTransfer] InputNumeric PreSetup failed.");
}
}
private bool TryAutoSelectAndClose(AgentInventoryContext* agent, ModifierMode mode, out string chosenText, out int chosenIndex)
{
chosenText = string.Empty;
chosenIndex = -1;
var agentAddonId = agent->AgentInterface.GetAddonId();
if (agentAddonId == 0)
return false;
var addon = GetAddonById(agentAddonId);
if (addon == null)
{
var cm = GameGui.GetAddonByName("ContextMenu", 1);
addon = (AtkUnitBase*)cm.Address;
}
if (addon == null)
return false;
return TryAutoSelectAndClose(agent, addon, mode, out chosenText, out chosenIndex);
}
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;
}
private 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 = 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)
{
try
{
if (!Configuration.EnableCompanyChest)
return false;
if (RaptureAtkModule.Instance() == null)
return false;
if (!IsCompanyChestOpen())
return false;
if (!IsPlayerInventoryType(sourceType))
return false;
if (!TryGetItemInfo(sourceType, (int)sourceSlot, out var itemId, out var isHq, out var qty))
return false;
var now = Environment.TickCount64;
companyChestDeposit = new CompanyChestDepositState
{
Active = true,
SourceType = sourceType,
SourceSlot = sourceSlot,
ItemId = itemId,
IsHq = isHq,
NextAttemptAtMs = now,
ExpiresAtMs = now + 12000,
Steps = 0,
LastQty = qty,
WaitForQtyChangeUntilMs = 0,
};
return true;
}
catch
{
return false;
}
}
private void ProcessCompanyChestDeposit(long now)
{
if (!companyChestDeposit.Active)
return;
// Stop if conditions no longer apply.
if (!Configuration.EnableCompanyChest || RaptureAtkModule.Instance() == null || !IsCompanyChestOpen())
{
companyChestDeposit.Active = false;
return;
}
if (now >= companyChestDeposit.ExpiresAtMs || companyChestDeposit.Steps >= 40)
{
companyChestDeposit.Active = false;
return;
}
// If we just issued a move, wait for the source stack quantity to change (or for the dialog to appear).
// This prevents spamming the same move over and over when the game hasn't applied it yet.
if (companyChestDeposit.WaitForQtyChangeUntilMs > 0 && now <= companyChestDeposit.WaitForQtyChangeUntilMs)
{
if (TryGetVisibleAddon(InputNumericAddonName, out _))
return;
if (TryGetItemInfo(companyChestDeposit.SourceType, (int)companyChestDeposit.SourceSlot, out var _, out var _, out var qNow) &&
qNow != companyChestDeposit.LastQty)
{
companyChestDeposit.LastQty = qNow;
companyChestDeposit.WaitForQtyChangeUntilMs = 0;
}
else
{
return;
}
}
// Don't issue a new move while the quantity dialog is open.
if (TryGetVisibleAddon(InputNumericAddonName, out _))
return;
if (now < companyChestDeposit.NextAttemptAtMs)
return;
if (!TryGetItemInfo(companyChestDeposit.SourceType, (int)companyChestDeposit.SourceSlot, out var itemId, out var isHq, out var qty) ||
itemId == 0 ||
qty == 0)
{
companyChestDeposit.Active = false;
return;
}
// If the slot changed (user moved/split), stop to avoid moving the wrong thing.
if (itemId != companyChestDeposit.ItemId || isHq != companyChestDeposit.IsHq)
{
companyChestDeposit.Active = false;
return;
}
var pages = GetCompanyChestInventoryTypes();
if (pages.Length == 0)
{
companyChestDeposit.Active = false;
return;
}
var maxStack = GetItemStackSize(itemId);
var needsQuantityConfirm = qty > 1 && maxStack > 1;
// Prefer stacking into an existing stack; otherwise use the first empty slot.
if (!TryFindCompanyChestBestStackSlot(pages, itemId, isHq, maxStack, out var destType, out var destSlot) &&
!TryFindCompanyChestFirstEmptySlot(pages, out destType, out destSlot))
{
companyChestDeposit.Active = false;
return;
}
if (!TryCompanyChestMoveItem(companyChestDeposit.SourceType, companyChestDeposit.SourceSlot, destType, destSlot, needsQuantityConfirm))
{
companyChestDeposit.Active = false;
return;
}
companyChestDeposit.Steps++;
companyChestDeposit.NextAttemptAtMs = now + (needsQuantityConfirm ? 600 : 350);
companyChestDeposit.LastQty = qty;
companyChestDeposit.WaitForQtyChangeUntilMs = now + (needsQuantityConfirm ? 2000 : 1200);
if (Configuration.AutoConfirmCompanyChestQuantity && needsQuantityConfirm)
{
pendingCompanyChestNumericConfirmUntilMs = now + 1500;
pendingCompanyChestNumericConfirmAttempts = 0;
pendingCompanyChestNumericArmed = true;
pendingNumericKind = PendingNumericKind.Store;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
}
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (Shift+RClick) Company Chest deposit step {companyChestDeposit.Steps}: {companyChestDeposit.SourceType} slot={companyChestDeposit.SourceSlot} -> {destType} slot={destSlot} (qty={qty}, stackMax={maxStack}).");
}
private void StartCompanyChestOrganize(long now)
{
if (!Configuration.EnableCompanyChest || !IsCompanyChestOpen() || RaptureAtkModule.Instance() == null)
return;
if (now <= companyChestBusyUntilMs)
return;
if (companyChestOrganize.Active && now < companyChestOrganize.ExpiresAtMs)
{
// Already running; don't reset progress on repeated MMB presses.
companyChestOrganize.ExpiresAtMs = Math.Max(companyChestOrganize.ExpiresAtMs, now + 20000);
if (Configuration.DebugMode)
Log.Information("[QuickTransfer] (MMB) Company Chest organize already running; ignoring restart.");
return;
}
companyChestBusyHits = 0;
var ownerAddonId = 0u;
try
{
if (TryGetVisibleAddon(FreeCompanyChestAddonName, out var fcc, WideAddonSearchMaxIndex) && fcc != null)
ownerAddonId = fcc->Id;
}
catch
{
// ignore
}
var pages = GetCompanyChestInventoryTypes();
if (pages.Length == 0)
return;
companyChestOrganize = new CompanyChestOrganizeState
{
Active = true,
OwnerAddonId = ownerAddonId,
NextAttemptAtMs = now,
ExpiresAtMs = now + 60000,
Steps = 0,
Phase = 0, // Stack merge -> compact -> sort
Pages = pages,
WaitingForApply = false,
WaitUntilMs = 0,
WaitStuckCount = 0,
WaitObservedChangeAtMs = 0,
};
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Company Chest organize started (pages=[{string.Join(", ", pages)}]).");
}
private void StartCompanyChestOrganize(long now, FFXIVClientStructs.FFXIV.Client.Game.InventoryType selectedPage)
{
if (!IsCompanyChestType(selectedPage))
{
StartCompanyChestOrganize(now);
return;
}
if (!Configuration.EnableCompanyChest || !IsCompanyChestOpen() || RaptureAtkModule.Instance() == null)
return;
if (now <= companyChestBusyUntilMs)
return;
if (companyChestOrganize.Active && now < companyChestOrganize.ExpiresAtMs)
{
// If a different tab is requested, stop the old run and restart on the new tab.
if (companyChestOrganize.Pages is { Length: 1 } && companyChestOrganize.Pages[0] != selectedPage)
{
companyChestOrganize.Active = false;
}
else
{
// Same tab: extend expiry but don't reset progress.
companyChestOrganize.ExpiresAtMs = Math.Max(companyChestOrganize.ExpiresAtMs, now + 20000);
if (Configuration.DebugMode)
Log.Information("[QuickTransfer] (MMB) Company Chest organize already running; ignoring restart.");
return;
}
}
companyChestBusyHits = 0;
var ownerAddonId = 0u;
try
{
if (TryGetVisibleAddon(FreeCompanyChestAddonName, out var fcc, WideAddonSearchMaxIndex) && fcc != null)
ownerAddonId = fcc->Id;
}
catch
{
// ignore
}
companyChestOrganize = new CompanyChestOrganizeState
{
Active = true,
OwnerAddonId = ownerAddonId,
NextAttemptAtMs = now,
ExpiresAtMs = now + 60000,
Steps = 0,
Phase = 0, // Stack merge -> compact -> sort
Pages = new[] { selectedPage },
WaitingForApply = false,
WaitUntilMs = 0,
WaitStuckCount = 0,
WaitObservedChangeAtMs = 0,
};
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Company Chest organize started (selectedPage={selectedPage}).");
}
private void ProcessCompanyChestOrganize(long now)
{
void LogSkip(string reason)
{
if (!Configuration.DebugMode)
return;
// Rate-limit skip logs; only log when the reason changes or every 2s.
if (!string.Equals(lastCompanyChestOrganizeSkipReason, reason, StringComparison.Ordinal) ||
now - lastCompanyChestOrganizeSkipLogMs >= 2000)
{
lastCompanyChestOrganizeSkipReason = reason;
lastCompanyChestOrganizeSkipLogMs = now;
Log.Information($"[QuickTransfer] (MMB) Company Chest organize waiting: {reason}");
}
}
(int stepDelayMs, int stabilizeMs, int applyTimeoutMs, int noApplyBackoffMs, int pageRetryMs, int numericStepDelayMs, int numericApplyTimeoutMs) GetTimings()
{
// Start fast, but if the server begins rejecting actions (busyHits>0), automatically slow down.
var tier = Math.Clamp(companyChestBusyHits, 0, 2);
return tier switch
{
0 => (750, 300, 1300, 650, 350, 1500, 3200),
1 => (1000, 450, 1800, 900, 500, 2200, 4500),
_ => (1300, 650, 2500, 1200, 750, 3000, 6000),
};
}
if (!companyChestOrganize.Active)
return;
if (now <= companyChestBusyUntilMs)
{
LogSkip("busy backoff");
return;
}
if (!Configuration.EnableCompanyChest || RaptureAtkModule.Instance() == null || !IsCompanyChestOpen())
{
companyChestOrganize.Active = false;
return;
}
if (now >= companyChestOrganize.ExpiresAtMs || companyChestOrganize.Steps >= 140)
{
companyChestOrganize.Active = false;
return;
}
if (TryGetVisibleAddon(InputNumericAddonName, out _))
{
LogSkip("InputNumeric visible");
return;
}
// If the selected page isn't loaded yet (loading spinner), wait.
try
{
var pages0 = companyChestOrganize.Pages ?? Array.Empty<FFXIVClientStructs.FFXIV.Client.Game.InventoryType>();
var inv0 = InventoryManager.Instance();
if (inv0 != null && pages0.Length > 0)
{
var allLoaded = true;
foreach (var p in pages0)
{
if (!IsContainerLoaded(inv0, p))
{
allLoaded = false;
break;
}
// Extra readiness guard: even if the container reports loaded, slot pointers can be null for a bit.
// If we treat that as "no moves", the organizer will instantly finish without doing anything.
if (inv0->GetInventorySlot(p, 0) == null)
{
allLoaded = false;
break;
}
}
if (!allLoaded)
{
var t = GetTimings();
companyChestOrganize.NextAttemptAtMs = now + t.pageRetryMs;
LogSkip($"pages not ready yet; waiting. pages=[{string.Join(", ", pages0)}]");
return;
}
}
}
catch
{
// ignore
}
// Wait for the previous move to apply (Company Chest actions can lag and will fail/spam errors if we spam moves).
if (companyChestOrganize.WaitingForApply)
{
try
{
var inv = InventoryManager.Instance();
if (inv != null)
{
var s = inv->GetInventorySlot(companyChestOrganize.WaitSrcType, (int)companyChestOrganize.WaitSrcSlot);
var d = inv->GetInventorySlot(companyChestOrganize.WaitDstType, (int)companyChestOrganize.WaitDstSlot);
var sId = s != null ? s->ItemId : 0u;
var sQty = s != null ? s->Quantity : 0;
var dId = d != null ? d->ItemId : 0u;
var dQty = d != null ? d->Quantity : 0;
var applied =
sId != companyChestOrganize.WaitSrcItemId ||
sQty != companyChestOrganize.WaitSrcQty ||
dId != companyChestOrganize.WaitDstItemId ||
dQty != companyChestOrganize.WaitDstQty;
if (applied)
{
var t = GetTimings();
// We saw a change; wait a short stabilization window in case the server rejects and rolls back.
if (companyChestOrganize.WaitObservedChangeAtMs == 0)
{
companyChestOrganize.WaitObservedChangeAtMs = now;
LogSkip("waiting for apply (stabilize)");
return;
}
if (now - companyChestOrganize.WaitObservedChangeAtMs < t.stabilizeMs)
{
LogSkip("waiting for apply (stabilize)");
return;
}
// Stable: allow next move.
companyChestOrganize.WaitingForApply = false;
companyChestOrganize.WaitUntilMs = 0;
companyChestOrganize.WaitStuckCount = 0;
companyChestOrganize.WaitObservedChangeAtMs = 0;
}
else if (companyChestOrganize.WaitObservedChangeAtMs != 0)
{
// We previously saw a change, but now we're back to the pre-snapshot: likely a server rejection rollback.
companyChestBusyHits = Math.Min(companyChestBusyHits + 1, 10);
var backoffMs = (long)Math.Min(60000, 5000 * (1 << Math.Min(companyChestBusyHits - 1, 4)));
companyChestBusyUntilMs = Math.Max(companyChestBusyUntilMs, now + backoffMs);
companyChestOrganize.WaitingForApply = false;
companyChestOrganize.WaitObservedChangeAtMs = 0;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Company Chest move rolled back; treating as busy. backoff={backoffMs}ms (hit {companyChestBusyHits}).");
if (companyChestBusyHits >= 3)
companyChestOrganize.Active = false;
return;
}
else if (now <= companyChestOrganize.WaitUntilMs)
{
LogSkip("waiting for apply");
return;
}
else
{
companyChestOrganize.WaitStuckCount++;
companyChestOrganize.WaitingForApply = false;
companyChestOrganize.WaitObservedChangeAtMs = 0;
if (companyChestOrganize.WaitStuckCount >= 3)
{
companyChestOrganize.Active = false;
if (Configuration.DebugMode)
{
Log.Information("[QuickTransfer] (MMB) Company Chest organize stalled (no inventory change observed); stopping to avoid spam.");
Log.Information(
$"[QuickTransfer] (MMB) Stall snapshot: src={companyChestOrganize.WaitSrcType} slot={companyChestOrganize.WaitSrcSlot} " +
$"was(id={companyChestOrganize.WaitSrcItemId},qty={companyChestOrganize.WaitSrcQty}) now(id={sId},qty={sQty}); " +
$"dst={companyChestOrganize.WaitDstType} slot={companyChestOrganize.WaitDstSlot} " +
$"was(id={companyChestOrganize.WaitDstItemId},qty={companyChestOrganize.WaitDstQty}) now(id={dId},qty={dQty});");
}
return;
}
// Back off a bit and retry.
var t = GetTimings();
companyChestOrganize.NextAttemptAtMs = now + t.noApplyBackoffMs;
LogSkip("no apply observed; backoff");
return;
}
}
}
catch
{
// ignore; fall through
}
}
if (now < companyChestOrganize.NextAttemptAtMs)
{
LogSkip("cooldown");
return;
}
var pages = companyChestOrganize.Pages ?? Array.Empty<FFXIVClientStructs.FFXIV.Client.Game.InventoryType>();
if (pages.Length == 0)
{
companyChestOrganize.Active = false;
return;
}
// Phase 0: merge stacks where possible. (Disabled for FC chest by starting at Phase=1.)
if (companyChestOrganize.Phase == 0)
{
if (TryFindCompanyChestMergeMove(pages, out var srcType, out var srcSlot, out var dstType, out var dstSlot, out var needsNumeric))
{
// Snapshot BEFORE issuing the move (so we can detect when it applies).
var preSrcId = 0u;
var preDstId = 0u;
var preSrcQty = 0;
var preDstQty = 0;
try
{
var inv = InventoryManager.Instance();
if (inv != null)
{
TryGetSlotSnapshot(inv, srcType, srcSlot, out preSrcId, out preSrcQty);
TryGetSlotSnapshot(inv, dstType, dstSlot, out preDstId, out preDstQty);
}
}
catch
{
// ignore
}
if (!TryCompanyChestMoveItem(srcType, srcSlot, dstType, dstSlot, needsNumeric))
{
companyChestOrganize.Active = false;
return;
}
companyChestOrganize.WaitingForApply = true;
companyChestOrganize.WaitSrcType = srcType;
companyChestOrganize.WaitSrcSlot = srcSlot;
companyChestOrganize.WaitSrcItemId = preSrcId;
companyChestOrganize.WaitSrcQty = preSrcQty;
companyChestOrganize.WaitDstType = dstType;
companyChestOrganize.WaitDstSlot = dstSlot;
companyChestOrganize.WaitDstItemId = preDstId;
companyChestOrganize.WaitDstQty = preDstQty;
var t = GetTimings();
companyChestOrganize.WaitUntilMs = now + (needsNumeric ? t.numericApplyTimeoutMs : t.applyTimeoutMs);
companyChestOrganize.WaitObservedChangeAtMs = 0;
companyChestOrganize.Steps++;
// Even after a move applies, add a small delay; Company Chest actions are more latency-sensitive.
companyChestOrganize.NextAttemptAtMs = now + (needsNumeric ? t.numericStepDelayMs : t.stepDelayMs);
if (Configuration.AutoConfirmCompanyChestQuantity && needsNumeric)
{
pendingCompanyChestNumericConfirmUntilMs = now + 1500;
pendingCompanyChestNumericConfirmAttempts = 0;
pendingCompanyChestNumericArmed = true;
pendingNumericKind = PendingNumericKind.Move;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = false;
ArmSuppressInputNumeric(now, 1500);
}
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Company Chest organize step {companyChestOrganize.Steps}: {srcType} slot={srcSlot} -> {dstType} slot={dstSlot} (phase=stack, numeric={needsNumeric}).");
return;
}
// No more merges; move on to compaction.
companyChestOrganize.Phase = 1;
}
// Phase 1: compact items to fill empty slots from the start.
if (TryFindCompanyChestCompactionMove(pages, out var cSrcType, out var cSrcSlot, out var cDstType, out var cDstSlot))
{
// Snapshot BEFORE issuing the move (so we can detect when it applies).
var preSrcId = 0u;
var preDstId = 0u;
var preSrcQty = 0;
var preDstQty = 0;
try
{
var inv = InventoryManager.Instance();
if (inv != null)
{
TryGetSlotSnapshot(inv, cSrcType, cSrcSlot, out preSrcId, out preSrcQty);
TryGetSlotSnapshot(inv, cDstType, cDstSlot, out preDstId, out preDstQty);
}
}
catch
{
// ignore
}
if (!TryCompanyChestMoveItem(cSrcType, cSrcSlot, cDstType, cDstSlot, keepAliveForInputNumeric: false))
{
companyChestOrganize.Active = false;
return;
}
companyChestOrganize.WaitingForApply = true;
companyChestOrganize.WaitSrcType = cSrcType;
companyChestOrganize.WaitSrcSlot = cSrcSlot;
companyChestOrganize.WaitSrcItemId = preSrcId;
companyChestOrganize.WaitSrcQty = preSrcQty;
companyChestOrganize.WaitDstType = cDstType;
companyChestOrganize.WaitDstSlot = cDstSlot;
companyChestOrganize.WaitDstItemId = preDstId;
companyChestOrganize.WaitDstQty = preDstQty;
var t = GetTimings();
companyChestOrganize.WaitUntilMs = now + t.applyTimeoutMs;
companyChestOrganize.WaitObservedChangeAtMs = 0;
companyChestOrganize.Steps++;
companyChestOrganize.NextAttemptAtMs = now + t.stepDelayMs;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Company Chest organize step {companyChestOrganize.Steps}: {cSrcType} slot={cSrcSlot} -> {cDstType} slot={cDstSlot} (phase=compact).");
return;
}
// No more compaction moves; proceed to sorting.
if (companyChestOrganize.Phase == 1)
companyChestOrganize.Phase = 2;
// Phase 2: reorder stacks by (UI category, itemId, HQ), mimicking the feel of Sort/itemsort.
if (companyChestOrganize.Phase == 2)
{
if (TryFindCompanyChestSortMove(pages, out var sSrcType, out var sSrcSlot, out var sDstType, out var sDstSlot))
{
// Snapshot BEFORE issuing the move (so we can detect when it applies).
var preSrcId = 0u;
var preDstId = 0u;
var preSrcQty = 0;
var preDstQty = 0;
try
{
var inv = InventoryManager.Instance();
if (inv != null)
{
TryGetSlotSnapshot(inv, sSrcType, sSrcSlot, out preSrcId, out preSrcQty);
TryGetSlotSnapshot(inv, sDstType, sDstSlot, out preDstId, out preDstQty);
}
}
catch
{
// ignore
}
if (!TryCompanyChestMoveItem(sSrcType, sSrcSlot, sDstType, sDstSlot, keepAliveForInputNumeric: false))
{
companyChestOrganize.Active = false;
return;
}
companyChestOrganize.WaitingForApply = true;
companyChestOrganize.WaitSrcType = sSrcType;
companyChestOrganize.WaitSrcSlot = sSrcSlot;
companyChestOrganize.WaitSrcItemId = preSrcId;
companyChestOrganize.WaitSrcQty = preSrcQty;
companyChestOrganize.WaitDstType = sDstType;
companyChestOrganize.WaitDstSlot = sDstSlot;
companyChestOrganize.WaitDstItemId = preDstId;
companyChestOrganize.WaitDstQty = preDstQty;
var t = GetTimings();
companyChestOrganize.WaitUntilMs = now + t.applyTimeoutMs;
companyChestOrganize.WaitObservedChangeAtMs = 0;
companyChestOrganize.Steps++;
companyChestOrganize.NextAttemptAtMs = now + t.stepDelayMs;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Company Chest organize step {companyChestOrganize.Steps}: {sSrcType} slot={sSrcSlot} -> {sDstType} slot={sDstSlot} (phase=sort).");
return;
}
}
// Done (no more moves).
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] (MMB) Company Chest organize done; no moves found. pages=[{string.Join(", ", pages)}]");
companyChestOrganize.Active = false;
}
private bool TryFindCompanyChestMergeMove(
FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType srcType,
out uint srcSlot,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType dstType,
out uint dstSlot,
out bool needsNumeric)
{
srcType = default;
srcSlot = 0;
dstType = default;
dstSlot = 0;
needsNumeric = false;
var inv = InventoryManager.Instance();
if (inv == null)
return false;
const int slotCap = 80;
// Find a destination stack with free space, then a later source stack of same item to merge.
foreach (var dt in pages)
{
for (var di = 0; di < slotCap; di++)
{
var d = inv->GetInventorySlot(dt, di);
if (d == null)
break;
if (d->ItemId == 0 || d->Quantity <= 0)
continue;
var itemId = d->ItemId;
var isHq = d->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality);
var maxStack = GetItemStackSize(itemId);
if (maxStack <= 1)
continue;
var free = (int)maxStack - d->Quantity;
if (free <= 0)
continue;
// Find a later stack to merge into this one.
var foundDest = false;
var destGlobalIndex = 0;
for (var pi = 0; pi < pages.Length; pi++)
{
if (pages[pi] != dt) continue;
destGlobalIndex = pi * slotCap + di;
foundDest = true;
break;
}
if (!foundDest)
continue;
for (var p = 0; p < pages.Length; p++)
{
var st = pages[p];
for (var si = 0; si < slotCap; si++)
{
var s = inv->GetInventorySlot(st, si);
if (s == null)
break;
if (s->ItemId == 0 || s->Quantity <= 0)
continue;
if (s->ItemId != itemId)
continue;
var sHq = s->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality);
if (sHq != isHq)
continue;
var srcGlobalIndex = p * slotCap + si;
if (srcGlobalIndex <= destGlobalIndex)
continue;
if (st == dt && si == di)
continue;
// Merging stacks usually prompts for quantity.
srcType = st;
srcSlot = (uint)si;
dstType = dt;
dstSlot = (uint)di;
// Be conservative: if the client shows InputNumeric for this move, we must keep the move state alive.
// We auto-confirm max, so this will stack as much as possible.
needsNumeric = true;
return true;
}
}
}
}
return false;
}
private static bool TryFindCompanyChestCompactionMove(
FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType srcType,
out uint srcSlot,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType dstType,
out uint dstSlot)
{
srcType = default;
srcSlot = 0;
dstType = default;
dstSlot = 0;
var inv = InventoryManager.Instance();
if (inv == null)
return false;
const int slotCap = 80;
// Find first empty, then next non-empty after it.
for (var dp = 0; dp < pages.Length; dp++)
{
var dt = pages[dp];
for (var di = 0; di < slotCap; di++)
{
var d = inv->GetInventorySlot(dt, di);
if (d == null)
break;
if (d->ItemId != 0)
continue;
// Found empty destination.
for (var sp = dp; sp < pages.Length; sp++)
{
var st = pages[sp];
var start = sp == dp ? di + 1 : 0;
for (var si = start; si < slotCap; si++)
{
var s = inv->GetInventorySlot(st, si);
if (s == null)
break;
if (s->ItemId == 0 || s->Quantity <= 0)
continue;
srcType = st;
srcSlot = (uint)si;
dstType = dt;
dstSlot = (uint)di;
return true;
}
}
return false;
}
}
return false;
}
private readonly struct ChestSortKey : IComparable<ChestSortKey>
{
public readonly uint Category;
public readonly uint ItemId;
public readonly byte Hq;
public ChestSortKey(uint category, uint itemId, bool isHq)
{
Category = category;
ItemId = itemId;
Hq = (byte)(isHq ? 1 : 0);
}
public int CompareTo(ChestSortKey other)
{
var c = Category.CompareTo(other.Category);
if (c != 0) return c;
c = ItemId.CompareTo(other.ItemId);
if (c != 0) return c;
return Hq.CompareTo(other.Hq); // NQ first, HQ after
}
}
private static bool TryFindCompanyChestSortMove(
FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType srcType,
out uint srcSlot,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType dstType,
out uint dstSlot)
{
srcType = default;
srcSlot = 0;
dstType = default;
dstSlot = 0;
if (pages.Length != 1)
return false;
var page = pages[0];
var inv = InventoryManager.Instance();
if (inv == null)
return false;
InventoryContainer* c;
try { c = inv->GetInventoryContainer(page); }
catch { return false; }
if (c == null || !c->IsLoaded || c->Size <= 0)
return false;
var size = (int)c->Size;
if (size <= 1)
return false;
// Build keys for current slots.
var keys = new ChestSortKey[size];
var empty = new bool[size];
for (var i = 0; i < size; i++)
{
var it = c->GetInventorySlot(i);
if (it == null || it->ItemId == 0 || it->Quantity <= 0)
{
empty[i] = true;
keys[i] = default;
continue;
}
var id = it->ItemId;
var hq = it->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality);
var cat = GetItemUiCategory(id);
keys[i] = new ChestSortKey(cat, id, hq);
}
// Ensure empties are at the end (safety; compaction phase should mostly handle this).
for (var i = 0; i < size; i++)
{
if (!empty[i]) continue;
for (var j = i + 1; j < size; j++)
{
if (!empty[j])
{
srcType = page;
srcSlot = (uint)j;
dstType = page;
dstSlot = (uint)i;
return true;
}
}
break;
}
// Selection-sort step: for first index i, if there is a smaller key later, swap/move it into i.
// This uses HandleItemMove's swap behavior for occupied destinations.
for (var i = 0; i < size; i++)
{
if (empty[i])
break;
var best = i;
for (var j = i + 1; j < size; j++)
{
if (empty[j]) break; // empties at end
if (keys[j].CompareTo(keys[best]) < 0)
best = j;
}
if (best != i && keys[best].CompareTo(keys[i]) < 0)
{
srcType = page;
srcSlot = (uint)best;
dstType = page;
dstSlot = (uint)i;
return true;
}
}
return false;
}
private bool TryCompanyChestMoveItem(
FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType,
uint sourceSlot,
FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType,
uint destSlot,
bool keepAliveForInputNumeric)
{
var module = RaptureAtkModule.Instance();
if (module == null)
return false;
// IMPORTANT:
// HandleItemMove expects InventoryType values (e.g. Inventory1=0, FreeCompanyPage1=20000),
// not "container ids" like 48/57.
var srcInvType = (uint)sourceType;
var dstInvType = (uint)destType;
nint localValuesAlloc = 0;
nint localRetAlloc = 0;
try
{
AtkValue* values;
AtkValue* ret;
if (keepAliveForInputNumeric)
{
// Keep alive across the InputNumeric dialog.
if (pendingMoveOutValuePtr != 0)
{
try { Marshal.FreeHGlobal(pendingMoveOutValuePtr); } catch { /* ignore */ }
pendingMoveOutValuePtr = 0;
}
if (pendingMoveAtkValuesPtr != 0)
{
try { Marshal.FreeHGlobal(pendingMoveAtkValuesPtr); } catch { /* ignore */ }
pendingMoveAtkValuesPtr = 0;
}
pendingMoveOutValuePtr = Marshal.AllocHGlobal(sizeof(AtkValue));
pendingMoveAtkValuesPtr = Marshal.AllocHGlobal(sizeof(AtkValue) * 4);
pendingMoveCreatedAtMs = Environment.TickCount64;
pendingMoveSawInputNumeric = false;
pendingMoveOutValueFreeAtMs = pendingMoveCreatedAtMs + 8000;
ret = (AtkValue*)pendingMoveOutValuePtr;
values = (AtkValue*)pendingMoveAtkValuesPtr;
}
else
{
localRetAlloc = Marshal.AllocHGlobal(sizeof(AtkValue));
localValuesAlloc = Marshal.AllocHGlobal(sizeof(AtkValue) * 4);
ret = (AtkValue*)localRetAlloc;
values = (AtkValue*)localValuesAlloc;
}
ret->Type = AtkValueType.Int;
ret->Int = 0;
for (var i = 0; i < 4; i++) values[i].Type = AtkValueType.UInt;
values[0].UInt = srcInvType;
values[1].UInt = sourceSlot;
values[2].UInt = dstInvType;
values[3].UInt = destSlot;
module->HandleItemMove(ret, values, 4);
if (Configuration.DebugMode)
{
var inv = InventoryManager.Instance();
TryGetSlotSnapshot(inv, sourceType, sourceSlot, out var sId, out var sQty);
TryGetSlotSnapshot(inv, destType, destSlot, out var dId, out var dQty);
Log.Information(
$"[QuickTransfer] (MMB) CompanyChest HandleItemMove: retInt={ret->Int}, " +
$"src={sourceType} slot={sourceSlot} (id={sId},qty={sQty}) -> dst={destType} slot={destSlot} (id={dId},qty={dQty}), keepAlive={keepAliveForInputNumeric}");
}
return true;
}
catch (Exception ex)
{
Log.Warning(ex, "[QuickTransfer] Company Chest HandleItemMove failed.");
return false;
}
finally
{
if (localRetAlloc != 0) { try { Marshal.FreeHGlobal(localRetAlloc); } catch { /* ignore */ } }
if (localValuesAlloc != 0) { try { Marshal.FreeHGlobal(localValuesAlloc); } catch { /* ignore */ } }
}
}
private static FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] GetSplitCandidateTypes(FFXIVClientStructs.FFXIV.Client.Game.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<FFXIVClientStructs.FFXIV.Client.Game.InventoryType>(capacity: 8);
void Add(FFXIVClientStructs.FFXIV.Client.Game.InventoryType t)
{
for (var i = 0; i < tmp.Count; i++)
if (tmp[i] == t)
return;
tmp.Add(t);
}
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);
}
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);
}
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);
}
return tmp.ToArray();
}
private static bool TryFindFirstEmptySlotForSplit(
InventoryManager* inv,
FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] candidates,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType,
out uint destSlot)
{
destType = default;
destSlot = 0;
if (inv == null || candidates.Length == 0)
return false;
const int slotCap = 80;
foreach (var t in candidates)
{
if (!IsContainerLoaded(inv, t))
continue;
for (var i = 0; i < slotCap; i++)
{
var it = inv->GetInventorySlot(t, i);
if (it == null)
break;
if (it->ItemId == 0)
{
destType = t;
destSlot = (uint)i;
return true;
}
}
}
return false;
}
private bool TryStartSplitHalfMove(
FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType,
uint sourceSlot,
long now,
out string reason)
{
reason = string.Empty;
try
{
// Only supported for inventory-like containers.
if (!IsPlayerInventoryType(sourceType) && !IsSaddlebagType(sourceType) && !IsRetainerType(sourceType))
{
reason = "unsupported container";
return false;
}
if (TryGetVisibleAddon(InputNumericAddonName, out _))
{
reason = "InputNumeric visible";
return false;
}
if (!TryGetItemInfo(sourceType, (int)sourceSlot, out var itemId, out _, out var qty) || qty <= 1)
{
reason = "no stack";
return false;
}
var maxStack = GetItemStackSize(itemId);
if (maxStack <= 1)
{
reason = "not stackable";
return false;
}
var inv = InventoryManager.Instance();
if (inv == null)
{
reason = "InventoryManager null";
return false;
}
var candidates = GetSplitCandidateTypes(sourceType);
if (!TryFindFirstEmptySlotForSplit(inv, candidates, out var destType, out var destSlot))
{
reason = "no empty slot";
return false;
}
// Trigger a move to an empty slot; the game will prompt for quantity.
if (!TryCompanyChestMoveItem(sourceType, sourceSlot, destType, destSlot, keepAliveForInputNumeric: true))
{
reason = "HandleItemMove failed";
return false;
}
// Auto-confirm half (best-effort). We reuse the existing InputNumeric handler.
if (Configuration.AutoConfirmCompanyChestQuantity)
{
pendingCompanyChestNumericConfirmUntilMs = now + 1500;
pendingCompanyChestNumericConfirmAttempts = 0;
pendingCompanyChestNumericArmed = true;
pendingNumericKind = PendingNumericKind.Move;
pendingCompanyChestNumericValueSet = false;
pendingCompanyChestNumericValueSetAtMs = 0;
pendingCompanyChestNumericDesired = 0;
pendingCompanyChestNumericHalf = true;
}
reason = $"dst={destType} slot={destSlot}";
return true;
}
catch
{
reason = "exception";
return false;
}
}
private static bool TryFindCompanyChestFirstEmptySlot(
FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType,
out uint destSlot)
{
destType = default;
destSlot = 0;
if (pages.Length == 0)
return false;
var inv = InventoryManager.Instance();
if (inv == null)
return false;
const int slotCap = 80;
foreach (var t in pages)
{
for (var i = 0; i < slotCap; i++)
{
var item = inv->GetInventorySlot(t, i);
if (item == null)
break;
if (item->ItemId == 0)
{
destType = t;
destSlot = (uint)i;
return true;
}
}
}
return false;
}
private static bool TryFindCompanyChestBestStackSlot(
FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages,
uint itemId,
bool isHq,
uint maxStack,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType,
out uint destSlot)
{
destType = default;
destSlot = 0;
if (pages.Length == 0 || itemId == 0 || maxStack <= 1)
return false;
var inv = InventoryManager.Instance();
if (inv == null)
return false;
const int slotCap = 80;
var bestFree = 0;
foreach (var t in pages)
{
for (var i = 0; i < slotCap; i++)
{
var it = inv->GetInventorySlot(t, i);
if (it == null)
break;
if (it->ItemId != itemId)
continue;
var hq = it->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality);
if (hq != isHq)
continue;
var qty = it->Quantity;
if (qty <= 0)
continue;
var free = (int)maxStack - qty;
if (free <= 0)
continue;
if (free > bestFree)
{
bestFree = free;
destType = t;
destSlot = (uint)i;
}
}
}
return bestFree > 0;
}
private bool TrySetInputNumericToMax(AtkUnitBase* inputNumeric, PendingNumericKind kind)
{
try
{
if (inputNumeric == null)
return false;
if (inputNumeric->AtkValues == null || inputNumeric->AtkValuesCount < 7)
return false;
var minValue = inputNumeric->AtkValues + 2;
var maxValue = inputNumeric->AtkValues + 3;
var defaultValue = inputNumeric->AtkValues + 4;
var currentValue = inputNumeric->AtkValuesCount > 5 ? (inputNumeric->AtkValues + 5) : null;
var promptVal = inputNumeric->AtkValues + 6;
var prompt = promptVal->Type is AtkValueType.String or AtkValueType.ManagedString ? ReadAtkValueString(*promptVal) : string.Empty;
// Guard: only confirm prompts we expect.
if (kind == PendingNumericKind.Store && !prompt.Contains("store", StringComparison.OrdinalIgnoreCase))
return false;
if (kind == PendingNumericKind.Remove && !prompt.Contains("remove", StringComparison.OrdinalIgnoreCase))
return false;
// Trade dialogs may be localized; if we're in Trade mode and Trade window is open, accept it
// (similar to how Split works - we trust the context rather than requiring exact prompt text)
if (kind == PendingNumericKind.Trade && !prompt.Contains("trade", StringComparison.OrdinalIgnoreCase))
{
// Fallback: if Trade window is open and we're expecting Trade, accept it anyway
// (prompt might be localized or say "How many would you like to trade?" etc.)
if (!IsTradeOpen())
return false;
}
if (minValue->Type != AtkValueType.UInt || maxValue->Type != AtkValueType.UInt || defaultValue->Type != AtkValueType.UInt)
return false;
var min = minValue->UInt;
var max = maxValue->UInt;
// Split dialogs are localized and can also be emitted by InventoryExpansion without "split" in the prompt.
// Accept if either:
// - prompt contains "split" (English), OR
// - max matches the expected qty-1 we recorded when arming the Split.
if (kind == PendingNumericKind.Split && !prompt.Contains("split", StringComparison.OrdinalIgnoreCase))
{
var nowMs = Environment.TickCount64;
var expectedMax = pendingSplitExpectedMax;
var okByExpected = expectedMax != 0 && nowMs <= pendingSplitExpectedUntilMs && max == expectedMax;
if (!okByExpected)
return false;
}
uint desired;
if (pendingCompanyChestNumericHalf)
{
// Split/remove half as evenly as possible.
// - Split: max is usually (qty-1), so use (max+1)/2.
// - Remove: max is usually qty, so use max/2.
if (kind == PendingNumericKind.Remove && max <= 1)
return false;
if (kind == PendingNumericKind.Split && max == 0)
return false;
desired = kind == PendingNumericKind.Remove ? (max / 2) : ((max + 1) / 2);
pendingCompanyChestNumericHalf = false;
}
else if (pendingCompanyChestNumericDesired != 0)
{
desired = pendingCompanyChestNumericDesired;
}
else
{
// Default: max (clamped).
desired = max < min ? min : max;
}
if (desired < min)
desired = min;
if (desired > max)
desired = max;
if (desired == 0 && min > 0)
desired = min;
pendingCompanyChestNumericDesired = desired;
var beforeDefault = defaultValue->UInt;
var beforeCurrentUInt = (currentValue != null && currentValue->Type == AtkValueType.UInt) ? currentValue->UInt : 0U;
var beforeCurrentStr = (currentValue != null && currentValue->Type is (AtkValueType.String or AtkValueType.ManagedString or AtkValueType.String8))
? ReadAtkValueString(*currentValue)
: string.Empty;
// Many InputNumeric uses have both "default" and "current" values; set both so OK uses max.
defaultValue->UInt = desired;
if (currentValue != null)
{
if (currentValue->Type == AtkValueType.UInt)
{
currentValue->UInt = desired;
}
else if (currentValue->Type is (AtkValueType.String or AtkValueType.ManagedString or AtkValueType.String8))
{
// This dialog uses a String "current quantity" slot on your client build.
// Overwrite the existing buffer in-place (max is <= 999 so this is safe).
var s = desired.ToString();
WriteUtf8InPlace(currentValue->String, s);
}
}
// Critical: Some builds don't actually use AtkValues for the editable quantity; they use the NumericInput component's Raw/Evaluated strings.
// Set that too, if present, so the OK action applies "desired" instead of a stale value (e.g. 2).
TrySetInputNumericComponentValue(inputNumeric, desired);
if (Configuration.DebugMode)
{
var curType = currentValue != null ? currentValue->Type.ToString() : "n/a";
var afterCurrentUInt = (currentValue != null && currentValue->Type == AtkValueType.UInt) ? currentValue->UInt : 0U;
var afterCurrentStr = (currentValue != null && currentValue->Type is (AtkValueType.String or AtkValueType.ManagedString or AtkValueType.String8))
? ReadAtkValueString(*currentValue)
: string.Empty;
Log.Information($"[QuickTransfer] InputNumeric(Update): prompt='{prompt}', min={min}, max={max}, default {beforeDefault}->{defaultValue->UInt}, currentUInt {beforeCurrentUInt}->{afterCurrentUInt}, currentStr '{beforeCurrentStr}'->'{afterCurrentStr}' (idx5 type {curType})");
}
return true;
}
catch
{
// ignore
return false;
}
}
private static void TrySetInputNumericComponentValue(AtkUnitBase* inputNumeric, uint desired)
{
try
{
if (inputNumeric == null)
return;
if (inputNumeric->UldManager.NodeList == null)
return;
var desiredStr = desired.ToString();
for (var i = 0; i < inputNumeric->UldManager.NodeListCount; i++)
{
var node = inputNumeric->UldManager.NodeList[i];
if (node == null)
continue;
if ((int)node->Type < 1000)
continue;
var compNode = (AtkComponentNode*)node;
var comp = compNode->Component;
if (comp == null)
continue;
if (comp->GetComponentType() != ComponentType.NumericInput)
continue;
var ni = (AtkComponentNumericInput*)comp;
// RawString / EvaluatedString are Utf8String.
WriteUtf8StringInPlace(&ni->RawString, desiredStr);
WriteUtf8StringInPlace(&ni->EvaluatedString, desiredStr);
// The authoritative value used by OK is the numeric input's internal Value.
// Setting strings alone can leave the internal Value at its old value (commonly 2).
ni->SetValue((int)desired);
// Update cursor to end.
ni->CursorPos = (ushort)desiredStr.Length;
ni->SelectionStart = ni->CursorPos;
ni->SelectionEnd = ni->CursorPos;
// Only need first numeric input.
return;
}
}
catch
{
// ignore
}
}
private static void WriteUtf8StringInPlace(FFXIVClientStructs.FFXIV.Client.System.String.Utf8String* s, string value)
{
if (s == null)
return;
WriteUtf8InPlace(s->StringPtr, value);
s->StringLength = value.Length;
s->BufUsed = value.Length + 1;
}
private static void WriteUtf8InPlace(byte* dst, string value)
{
if (dst == null)
return;
var bytes = Encoding.UTF8.GetBytes(value);
// write bytes + null terminator
for (var i = 0; i < bytes.Length; i++)
dst[i] = bytes[i];
dst[bytes.Length] = 0;
}
private bool TrySelectRemoveFromCompanyChestContextMenu()
{
try
{
var ctxAddr = GameGui.GetAddonByName("ContextMenu", 1).Address;
if (ctxAddr == nint.Zero)
return false;
var ctxMenu = (AddonContextMenu*)ctxAddr;
if (ctxMenu == null)
return false;
// Find the list component and pick the row whose label is "Remove".
// FreeCompanyChest uses a Default context menu, so the AgentInventoryContext index-based selection does not apply.
for (uint listId = 1; listId <= 6; listId++)
{
var list = ctxMenu->GetComponentListById(listId);
if (list == null)
continue;
var itemCount = list->GetItemCount();
if (itemCount <= 0 || itemCount > 64)
continue;
for (var i = 0; i < itemCount; i++)
{
var labelPtr = list->GetItemLabel(i);
if (labelPtr == null)
continue;
var label = Marshal.PtrToStringUTF8(new IntPtr(labelPtr))?.TrimEnd('\0') ?? string.Empty;
if (string.IsNullOrWhiteSpace(label))
continue;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] ContextMenu listId={listId} row={i} label='{label}'");
if (!label.Equals("Remove", StringComparison.OrdinalIgnoreCase))
continue;
// Trigger via callback payload (matches the inventory context menu pattern).
GenerateCallback((AtkUnitBase*)ctxMenu, 0, i, 0U, 0, 0);
// Close slightly later (immediate close can cancel the action).
pendingCloseContextMenuAtMs = Environment.TickCount64 + 50;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] Triggered Company Chest 'Remove' (listId={listId}, row={i}).");
return true;
}
}
// Fallback: keep old string-scan (helpful for debugging), but don't attempt a blind click.
return ContextMenuContainsString((AtkUnitBase*)ctxMenu, "Remove");
}
catch (Exception ex)
{
Log.Warning(ex, "[QuickTransfer] Failed to select Remove from Company Chest context menu.");
return false;
}
}
private bool ContextMenuContainsString(AtkUnitBase* ctxAddon, string needle)
{
try
{
if (ctxAddon == null || ctxAddon->AtkValues == null || ctxAddon->AtkValuesCount <= 0)
return false;
var count = Math.Min((int)ctxAddon->AtkValuesCount, 128);
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] ContextMenu AtkValuesCount={ctxAddon->AtkValuesCount} (scanning {count}).");
for (var i = 0; i < count; i++)
{
var v = ctxAddon->AtkValues[i];
if (v.Type is not (AtkValueType.String or AtkValueType.ManagedString or AtkValueType.String8))
continue;
var s = ReadAtkValueString(v);
if (string.IsNullOrWhiteSpace(s))
continue;
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] ContextMenu AtkValue[{i}] = '{s}'");
if (s.Contains(needle, StringComparison.OrdinalIgnoreCase))
{
if (Configuration.DebugMode)
Log.Information($"[QuickTransfer] ContextMenu contains '{needle}' (found '{s}' at AtkValue[{i}]).");
return true;
}
}
}
catch
{
// ignore
}
return false;
}
// (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 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<FFXIVClientStructs.FFXIV.Client.Game.InventoryType>()
.Where(IsCompanyChestType)
.OrderBy(v => (int)v)
.ToArray();
if (invTypes.Length == 0)
return false;
var inv = InventoryManager.Instance();
if (inv == null)
return false;
// Most FC chest tabs are 50 slots; we keep a conservative cap.
const int slotCap = 80;
foreach (var t in invTypes)
{
for (var i = 0; i < slotCap; i++)
{
var item = inv->GetInventorySlot(t, i);
if (item == null)
break;
if (item->ItemId == 0)
{
destType = t;
destSlot = (uint)i;
return true;
}
}
}
return false;
}
private static bool TryFindCompanyChestExistingStackSlot(
uint itemId,
bool isHq,
uint maxStack,
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType destType,
out uint destSlot)
{
destType = default;
destSlot = 0;
var invTypes = Enum.GetValues<FFXIVClientStructs.FFXIV.Client.Game.InventoryType>()
.Where(IsCompanyChestType)
.OrderBy(v => (int)v)
.ToArray();
if (invTypes.Length == 0)
return false;
var inv = InventoryManager.Instance();
if (inv == null)
return false;
const int slotCap = 80;
var bestFree = 0;
foreach (var t in invTypes)
{
for (var i = 0; i < slotCap; i++)
{
var it = inv->GetInventorySlot(t, i);
if (it == null)
break;
if (it->ItemId != itemId)
continue;
var hq = it->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality);
if (hq != isHq)
continue;
if (maxStack <= 1)
continue;
var qty = it->Quantity;
if (qty <= 0)
continue;
var free = (int)maxStack - qty;
if (free <= 0)
continue;
// Pick the stack with the most free space, to reduce "moves 1 then repeats" behavior.
if (free > bestFree)
{
bestFree = free;
destType = t;
destSlot = (uint)i;
}
}
}
return bestFree > 0;
}
private 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<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;
}
}
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<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;
}
}
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;
}
private ModifierMode? GetModifierModeLatched(long nowMs)
{
const int latchWindowMs = 180;
if (KeyState[VirtualKey.MENU] || nowMs - lastAltSeenMs <= latchWindowMs)
return ModifierMode.Alt;
if (KeyState[VirtualKey.CONTROL] || nowMs - lastCtrlSeenMs <= latchWindowMs)
return ModifierMode.Ctrl;
if (KeyState[VirtualKey.SHIFT] || nowMs - lastShiftSeenMs <= latchWindowMs)
return ModifierMode.Shift;
return null;
}
private void TryCloseCurrentContextMenu(AgentInventoryContext* agent)
{
try
{
var agentAddonId = agent->AgentInterface.GetAddonId();
if (agentAddonId != 0)
{
var addon = GetAddonById(agentAddonId);
if (addon != null)
{
CloseContextMenuAddon(agent, addon);
return;
}
}
}
catch
{
// ignore
}
// Fallback attempt.
try
{
var cm = GameGui.GetAddonByName("ContextMenu", 1);
if (!cm.IsNull)
CloseContextMenuAddon(agent, (AtkUnitBase*)cm.Address);
}
catch
{
// ignore
}
}
private void DebugDumpContextMenu(AgentInventoryContext* agent, int maxItems)
{
try
{
var max = Math.Min(Math.Min(agent->ContextItemCount, 64), maxItems);
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;
Log.Information($"[QuickTransfer] Menu idx={i}: '{text}'");
}
}
catch (Exception ex)
{
Log.Warning(ex, "[QuickTransfer] Failed to dump context menu.");
}
}
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;
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<string> addonNames, int index = 1)
{
foreach (var name in addonNames)
{
if (IsAddonVisible(name, index))
return true;
}
return false;
}
private static bool IsAnyAddonVisibleAnyIndex(IEnumerable<string> 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)
{
page = default;
try
{
// IMPORTANT: this mapping is about tab clicks, not how many compartments we *want* to operate on.
// So we always consider all possible item pages (up to 5), even if the user configured fewer.
var pages = GetAllCompanyChestItemPages();
if (pages.Length == 0)
return false;
// Free Company Chest (your UI):
// param=1 -> Items tab 1 (FreeCompanyPage1)
// param=2 -> Items tab 2 (FreeCompanyPage2)
// param=3 -> Items tab 3 (FreeCompanyPage3)
// param=4 -> Items tab 4 (FreeCompanyPage4) [FC rank unlock]
// param=5 -> Items tab 5 (FreeCompanyPage5) [FC rank unlock]
// param=6 -> Crystals (NOT an item page)
if (eventParam < 1 || eventParam > pages.Length)
return false;
page = pages[eventParam - 1];
return true;
}
catch
{
// ignore
}
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;
}
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 */ }
}
private 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")),
_ => 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));
}
}
}