2f8427b20b
Add Alt split helpers and update plugin metadata.
5366 lines
213 KiB
C#
5366 lines
213 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.ListItemHighlight 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;
|
|
|
|
// If a drag is in progress, prefer the dragging renderer.
|
|
try
|
|
{
|
|
var dragging = list->DraggingListItemRenderer;
|
|
if (dragging != null)
|
|
return &dragging->AtkDragDropInterface;
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
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 }
|
|
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,
|
|
}
|
|
|
|
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.");
|
|
}
|
|
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).
|
|
if (Configuration.AutoConfirmCompanyChestQuantity &&
|
|
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 (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;
|
|
string? removeTxt = null, addTxt = null, placeTxt = null, returnTxt = null, entrustTxt = null, retrieveTxt = null, companyRemoveTxt = null, splitTxt = 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;
|
|
}
|
|
}
|
|
|
|
if (!foundAny)
|
|
return false;
|
|
|
|
var saddlebagOpen = IsSaddlebagOpen();
|
|
var retainerOpen = IsRetainerOpen();
|
|
var companyChestOpen = IsCompanyChestOpen();
|
|
|
|
// 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 && 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) 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))
|
|
{
|
|
// Don't close immediately: on some setups this cancels Split 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;
|
|
|
|
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 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),
|
|
|
|
_ => 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));
|
|
}
|
|
}
|
|
}
|
|
|