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 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; } = 2; public bool Enabled { get; set; } = true; public bool DebugMode { get; set; } = false; public int TransferCooldownMs { get; set; } = 200; 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!; 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 long pendingCompanyChestNumericConfirmUntilMs; private int pendingCompanyChestNumericConfirmAttempts; private long pendingCloseContextMenuAtMs; private bool pendingCompanyChestNumericArmed; private bool pendingCompanyChestNumericValueSet; private long pendingCompanyChestNumericValueSetAtMs; private uint pendingCompanyChestNumericDesired; private enum PendingNumericKind { None, Store, Remove } private PendingNumericKind pendingNumericKind; private long lastShiftSeenMs; private long lastCtrlSeenMs; // 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 StackSizeCache = 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 enum ModifierMode { Shift, Ctrl, } private delegate void OpenForItemSlotDelegate( AgentInventoryContext* agent, FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType, int slot, int a4, uint addonId); // Inventory/armoury uses this; saddlebags often do not, so we also use IContextMenu fallback. [Signature("83 B9 ?? ?? ?? ?? ?? 7E ?? 39 91", DetourName = nameof(OpenForItemSlotDetour))] private Hook? openForItemSlotHook = null; 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, } private static readonly string[] ArmouryAddonNames = [ // Common internal names used by the Armoury Chest window across patches. "ArmouryBoard", "ArmoryBoard", "Armoury", "Armory", "ArmouryChest", "ArmoryChest", ]; private const string FreeCompanyChestAddonName = "FreeCompanyChest"; private const string InputNumericAddonName = "InputNumeric"; private const string ContextMenuAddonName = "ContextMenu"; private bool suppressContextMenu; private bool suppressInputNumeric; 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() .Where(IsCompanyChestType) .OrderBy(v => (int)v) .Take(max) .ToArray(); } public Plugin() { Configuration = PluginInterface.GetPluginConfig() as Configuration ?? new Configuration(); 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; // Pre-setup hook for InputNumeric so we can override the default quantity BEFORE the dialog is created. // Register without a name-filter so we can confirm it fires on this client build. AddonLifecycle.RegisterListener(AddonEvent.PreSetup, OnInputNumericPreSetup); AddonLifecycle.RegisterListener(AddonEvent.PreDraw, OnAddonPreDraw); Log.Information($"Loaded {PluginInterface.Manifest.Name}."); Log.Information($"[QuickTransfer] DebugMode={Configuration.DebugMode}, Enabled={Configuration.Enabled}"); if (Configuration.DebugMode) { try { var matches = Enum.GetNames() .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; AddonLifecycle.UnregisterListener(AddonEvent.PreSetup, OnInputNumericPreSetup); AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, OnAddonPreDraw); 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 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; // 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; 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 mode = GetModifierModeLatched(now); if (mode == null) return; var saddlebagOpen = IsSaddlebagOpen(); var retainerOpen = IsRetainerOpen(); var companyChestOpen = IsCompanyChestOpen(); var specialOpen = saddlebagOpen || retainerOpen || companyChestOpen; if (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}"); // Free Company Chest uses MenuType.Default (not Inventory). if (args.MenuType == ContextMenuType.Default && mode == ModifierMode.Shift && 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; // 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; // Modifier latch (helps cases where the user taps Shift/Ctrl quickly). if (KeyState[VirtualKey.SHIFT]) lastShiftSeenMs = now; if (KeyState[VirtualKey.CONTROL]) lastCtrlSeenMs = now; // Company Chest quantity prompt auto-confirm (best effort). if (Configuration.EnableCompanyChest && Configuration.AutoConfirmCompanyChestQuantity && pendingCompanyChestNumericConfirmUntilMs > 0 && now <= pendingCompanyChestNumericConfirmUntilMs) { if (TryGetVisibleAddon(InputNumericAddonName, out var inputNumeric)) { suppressInputNumeric = true; // Phase 1: set max (and wait a frame so the component commits the value internally). if (!pendingCompanyChestNumericValueSet) { if (!TrySetInputNumericToMax(inputNumeric, pendingNumericKind)) { // Prompt doesn't match expectation; stop (prevents confirming wrong dialogs). pendingCompanyChestNumericConfirmUntilMs = 0; pendingCompanyChestNumericArmed = false; pendingNumericKind = PendingNumericKind.None; } 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)) { pendingCompanyChestNumericConfirmUntilMs = 0; pendingCompanyChestNumericArmed = false; pendingNumericKind = PendingNumericKind.None; pendingCompanyChestNumericValueSet = false; pendingCompanyChestNumericValueSetAtMs = 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) toConfirm = 1; inputNumeric->FireCallbackInt((int)toConfirm); pendingCompanyChestNumericConfirmAttempts = 1; if (Configuration.DebugMode) Log.Information($"[QuickTransfer] Auto-confirmed InputNumeric (Company Chest) attempt 1 (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; suppressInputNumeric = false; } else { pendingCompanyChestNumericConfirmUntilMs = 0; pendingCompanyChestNumericArmed = false; pendingNumericKind = PendingNumericKind.None; pendingCompanyChestNumericValueSet = false; pendingCompanyChestNumericValueSetAtMs = 0; pendingCompanyChestNumericDesired = 0; suppressInputNumeric = false; } } catch (Exception ex) { pendingCompanyChestNumericConfirmUntilMs = 0; pendingCompanyChestNumericArmed = false; pendingNumericKind = PendingNumericKind.None; pendingCompanyChestNumericValueSet = false; pendingCompanyChestNumericValueSetAtMs = 0; pendingCompanyChestNumericDesired = 0; suppressInputNumeric = false; 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; suppressInputNumeric = false; } // 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); // 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.AddonName.Equals(FreeCompanyChestAddonName, StringComparison.OrdinalIgnoreCase) && Configuration.EnableCompanyChest) { suppressContextMenu = true; if (TrySelectRemoveFromCompanyChestContextMenu()) { lastActionTickMs = now; pendingCompanyChestNumericConfirmUntilMs = Configuration.AutoConfirmCompanyChestQuantity ? now + 1500 : 0; pendingCompanyChestNumericConfirmAttempts = 0; pendingCompanyChestNumericArmed = true; pendingNumericKind = PendingNumericKind.Remove; pendingCompanyChestNumericValueSet = false; pendingCompanyChestNumericValueSetAtMs = 0; pendingCompanyChestNumericDesired = 0; } // Keep suppression on while the remove dialog is being handled. } } var pending = pendingDeferredMenuClick; if (pending == null) return; // Consume (only try once). pendingDeferredMenuClick = null; if (now - pending.Value.EnqueuedAtMs > 1500) return; // 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; var addon = (AtkUnitBase*)pending.Value.AddonPtr; if (TryAutoSelectAndClose(agent, addon, pending.Value.Mode, out var chosenText, out var chosenIndex)) { lastActionTickMs = now; suppressContextMenu = true; 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; } 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."); } } private void OnAddonPreDraw(AddonEvent type, AddonArgs args) { try { var name = args.AddonName ?? string.Empty; if (suppressContextMenu && string.Equals(name, ContextMenuAddonName, StringComparison.OrdinalIgnoreCase)) { var addon = (AtkUnitBase*)args.Addon.Address; MakeAddonInvisible(addon); } if (suppressInputNumeric && string.Equals(name, InputNumericAddonName, StringComparison.OrdinalIgnoreCase)) { var addon = (AtkUnitBase*)args.Addon.Address; MakeAddonInvisible(addon); } } 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 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; } // 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; string? removeTxt = null, addTxt = null, placeTxt = null, returnTxt = null, entrustTxt = null, retrieveTxt = null, companyRemoveTxt = 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; } } 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.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); CloseContextMenuAddon(agent, contextMenuAddon); chosenText = chosen.txt!; chosenIndex = chosen.idx; return true; } 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; } 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 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); 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 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; // Set default = max (clamped). var min = minValue->UInt; var max = maxValue->UInt; var desired = max < min ? min : max; 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() .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() .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(); 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 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.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.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; private static bool IsAddonVisible(string addonName, int index = 1) { var addon = GameGui.GetAddonByName(addonName, index); return !addon.IsNull && addon.IsVisible; } private static bool IsAddonVisibleAnyIndex(string addonName, int maxIndex = 6) { for (var i = 1; i <= maxIndex; i++) { if (IsAddonVisible(addonName, i)) return true; } return false; } private static bool IsAnyAddonVisible(IEnumerable addonNames, int index = 1) { foreach (var name in addonNames) { if (IsAddonVisible(name, index)) return true; } return false; } private static bool IsAnyAddonVisibleAnyIndex(IEnumerable addonNames, int maxIndex = 6) { foreach (var name in addonNames) { if (IsAddonVisibleAnyIndex(name, maxIndex)) return true; } return false; } private static bool IsInventoryAndSaddlebagOpen() { var inventoryOpen = IsAddonVisibleAnyIndex("Inventory"); var saddlebagOpen = IsAddonVisibleAnyIndex("InventoryBuddy") || IsAddonVisibleAnyIndex("InventoryBuddy2"); return inventoryOpen && saddlebagOpen; } private static bool IsSaddlebagOpen() => IsAddonVisibleAnyIndex("InventoryBuddy") || IsAddonVisibleAnyIndex("InventoryBuddy2"); private static bool IsSaddlebagType(FFXIVClientStructs.FFXIV.Client.Game.InventoryType inventoryType) => inventoryType is FFXIVClientStructs.FFXIV.Client.Game.InventoryType.SaddleBag1 or FFXIVClientStructs.FFXIV.Client.Game.InventoryType.SaddleBag2; 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 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"))), _ => 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)); } } }