From 3565bcd7f9e6caa638a1749c87313790d3d84d65 Mon Sep 17 00:00:00 2001 From: Knack117 Date: Sun, 25 Jan 2026 18:07:19 -0500 Subject: [PATCH] Initial public release setup Add QuickTransfer source, pluginmaster feed, MIT license, and GitHub Actions release workflow. --- .github/workflows/release.yml | 58 + .gitignore | 86 ++ LICENSE | 21 + QuickTransfer.cs | 1984 +++++++++++++++++++++++++++++++++ QuickTransfer.csproj | 22 + QuickTransfer.json | 21 + QuickTransferWindow.cs | 119 ++ README.md | 163 +++ packages.lock.json | 19 + pluginmaster.json | 21 + 10 files changed, 2514 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 QuickTransfer.cs create mode 100644 QuickTransfer.csproj create mode 100644 QuickTransfer.json create mode 100644 QuickTransferWindow.cs create mode 100644 README.md create mode 100644 packages.lock.json create mode 100644 pluginmaster.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c3025ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,58 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build-release: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Fetch Dalamud SDK libs (api14) + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $url = "https://raw.githubusercontent.com/goatcorp/dalamud-distrib/main/api14/latest.zip" + $tmp = Join-Path $env:RUNNER_TEMP "dalamud" + $zip = Join-Path $tmp "dalamud.zip" + $dst = Join-Path $tmp "extracted" + New-Item -ItemType Directory -Force $dst | Out-Null + Invoke-WebRequest -Uri $url -OutFile $zip + Expand-Archive -Path $zip -DestinationPath $dst -Force + + $dalamudDll = Get-ChildItem -Path $dst -Recurse -Filter "Dalamud.dll" | Select-Object -First 1 + if (-not $dalamudDll) { throw "Dalamud.dll not found after extracting $url" } + + $env:DALAMUD_HOME = $dalamudDll.Directory.FullName + "DALAMUD_HOME=$($env:DALAMUD_HOME)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Restore + run: dotnet restore + + - name: Build (Release) + run: dotnet build -c Release --no-restore + + - name: Prepare release zip + shell: pwsh + run: | + $zip = "bin\\Release\\QuickTransfer\\latest.zip" + if (!(Test-Path $zip)) { throw "Expected pack zip not found: $zip" } + Copy-Item $zip "QuickTransfer.zip" -Force + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + QuickTransfer.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae4f12c --- /dev/null +++ b/.gitignore @@ -0,0 +1,86 @@ +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Bb]uild/ +build/ +[Bb]in/ +[Oo]bj/ + +# Visual Studio 2015 +.vs/ + +# NuGet Packages +*.nupkg +packages/ +**/packages/* +!packages/repositories.config + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# ReSharper +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity +_TeamCity* + +# DotCover +coverage/ + +# ncRush +*.ncrush* + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Build files +*.mak + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files +*_Report.htm +*_Report.html +*.log +Thumbs.db +App.config + +# IDE +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Game data +sqpack/ +*.sqpack + +# Dalamud specific +addon/ +hooks/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..47941dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 flick + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/QuickTransfer.cs b/QuickTransfer.cs new file mode 100644 index 0000000..c44e7ea --- /dev/null +++ b/QuickTransfer.cs @@ -0,0 +1,1984 @@ +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)); + } + } +} + diff --git a/QuickTransfer.csproj b/QuickTransfer.csproj new file mode 100644 index 0000000..dd61ee4 --- /dev/null +++ b/QuickTransfer.csproj @@ -0,0 +1,22 @@ + + + net10.0-windows + enable + enable + true + QuickTransfer + QuickTransfer + Library + false + + + + + <_DalamudDevHooksPath>$(APPDATA)\XIVLauncher\addon\Hooks\dev\ + + + $(_DalamudDevHooksPath) + + diff --git a/QuickTransfer.json b/QuickTransfer.json new file mode 100644 index 0000000..5cb5342 --- /dev/null +++ b/QuickTransfer.json @@ -0,0 +1,21 @@ +{ + "Author": "flick", + "Name": "QuickTransfer", + "InternalName": "QuickTransfer", + "AssemblyVersion": "1.0.0.0", + "Description": "Automate inventory transfers with Shift/Ctrl + Right-Click.", + "ApplicableVersion": "any", + "RepoUrl": "https://github.com/Knack117/QuickTransfer", + "Tags": [ + "inventory", + "utility", + "quality of life" + ], + "DalamudApiLevel": 14, + "LoadRequiredState": 0, + "LoadSync": false, + "CanUnloadAsync": false, + "LoadPriority": 0, + "Punchline": "Quick item transfer helpers.", + "AcceptsFeedback": true +} diff --git a/QuickTransferWindow.cs b/QuickTransferWindow.cs new file mode 100644 index 0000000..9e68734 --- /dev/null +++ b/QuickTransferWindow.cs @@ -0,0 +1,119 @@ +using System; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Windowing; + +namespace QuickTransfer; + +public class QuickTransferWindow : Window, IDisposable +{ + private readonly Configuration _config; + + public QuickTransferWindow(Configuration config) + : base("QuickTransfer Settings###QuickTransferConfig") + { + _config = config; + + SizeCondition = ImGuiCond.FirstUseEver; + Size = new Vector2(500, 400); + } + + public void Dispose() + { + // no-op + } + + public override void Draw() + { + // Main settings + ImGui.TextColored(new Vector4(0.4f, 0.8f, 1f, 1f), "QuickTransfer Configuration"); + ImGui.Separator(); + + // Enable/Disable + var enabled = _config.Enabled; + if (ImGui.Checkbox("Enabled###Enabled", ref enabled)) + { + _config.Enabled = enabled; + _config.Save(); + } + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 1f), _config.Enabled ? "(Active)" : "(Disabled)"); + + ImGui.Spacing(); + + // Debug mode + var debugMode = _config.DebugMode; + if (ImGui.Checkbox("Debug Mode###DebugMode", ref debugMode)) + { + _config.DebugMode = debugMode; + _config.Save(); + } + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Logs to chat - for troubleshooting)"); + + ImGui.Spacing(); + + // Company Chest + var enableCompanyChest = _config.EnableCompanyChest; + if (ImGui.Checkbox("Enable Company Chest (Free Company Chest)###EnableCompanyChest", ref enableCompanyChest)) + { + _config.EnableCompanyChest = enableCompanyChest; + _config.Save(); + } + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Shift: deposit/withdraw while FC chest is open)"); + + var autoConfirmQty = _config.AutoConfirmCompanyChestQuantity; + if (ImGui.Checkbox("Auto-confirm Company Chest quantity prompt###AutoConfirmCompanyChestQty", ref autoConfirmQty)) + { + _config.AutoConfirmCompanyChestQuantity = autoConfirmQty; + _config.Save(); + } + ImGui.SameLine(); + ImGui.TextColored(new Vector4(0.85f, 0.75f, 0.45f, 0.9f), "(Best effort; disable if it misbehaves)"); + + // Transfer cooldown + ImGui.Spacing(); + ImGui.Text("Transfer Cooldown (ms):"); + ImGui.SameLine(); + ImGui.SetNextItemWidth(100); + var cooldown = _config.TransferCooldownMs; + if (ImGui.InputInt("###Cooldown", ref cooldown)) + { + _config.TransferCooldownMs = Math.Max(0, Math.Min(1000, cooldown)); + _config.Save(); + } + + ImGui.Spacing(); + ImGui.Separator(); + + // Instructions + ImGui.TextColored(new Vector4(0.4f, 0.8f, 1f, 1f), "How to Use:"); + ImGui.BulletText("Hold SHIFT and RIGHT-CLICK to use the open container's quick action"); + ImGui.BulletText("Hold CTRL and RIGHT-CLICK to use Armoury actions when a Saddlebag, Retainer, or Company Chest is open (Inventory ↔ Armoury)"); + ImGui.BulletText("Inventory + Saddlebags: Inventory → \"Add All to Saddlebag\", Saddlebags → \"Remove All from Saddlebag\""); + ImGui.BulletText("Armoury + Saddlebags: Armoury → \"Add All to Saddlebag\""); + ImGui.BulletText("Inventory + Retainer: Inventory → \"Entrust to Retainer\", Retainer → \"Retrieve from Retainer\""); + ImGui.BulletText("Armoury + Retainer: Armoury → \"Entrust to Retainer\", Retainer → \"Retrieve from Retainer\""); + ImGui.BulletText("Retainer + Saddlebags: Retainer → \"Add All to Saddlebag\", Saddlebags → \"Entrust to Retainer\""); + ImGui.BulletText("Inventory + Armoury (no special container): (Gear) Inventory → \"Place in Armoury Chest\", Armoury → \"Return to Inventory\""); + ImGui.BulletText("Company Chest (FreeCompanyChest) open: Shift+RClick Inventory/Armoury deposits, Shift+RClick Company Chest withdraws (\"Remove\")"); + ImGui.BulletText("Use /qt or click 'Open Config' in plugin list to reopen this window"); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.TextColored(new Vector4(0.8f, 0.8f, 0.4f, 1f), "Notes:"); + ImGui.BulletText("This uses the game's existing context menu options (no manual slot moving)."); + ImGui.BulletText("If an option isn't available for the clicked item, nothing happens."); + ImGui.BulletText("If you tap Shift briefly, the action still triggers (it is captured when the menu opens)."); + ImGui.BulletText("For Company Chest deposits, this uses the same UI move function as drag+drop would."); + ImGui.Spacing(); + + // Save button + if (ImGui.Button("Save & Close###SaveClose")) + { + _config.Save(); + IsOpen = false; + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8c8223 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# QuickTransfer - FFXIV Quick Transfer Plugin + +A Dalamud plugin for Final Fantasy XIV that enables quick item transfer between inventory containers using **Shift + Right-Click**, by automatically selecting an existing entry from the game's context menu. + +## Features + +- **Quick Transfer**: Hold Shift and right-click an item to automatically trigger the matching context menu action +- **Cooldown Protection**: Built-in cooldown to prevent accidental double-moves +- **Debug Mode**: For troubleshooting and development + +## Installation + +### Prerequisites + +1. **XIVLauncher**: Download and install from [goatcorp.github.io](https://goatcorp.github.io/) +2. **Dalamud**: Enable plugins in XIVLauncher settings +3. **Dev Plugin Loading**: Enable "Dev Plugin Locations" in Dalamud settings for development builds +4. **.NET SDK**: Install the .NET 10 SDK (this project targets `net10.0-windows`) + +### Installing the Plugin + +#### Method 1: Custom Dalamud repository (recommended) +1. In-game, open **Dalamud Settings** → **Experimental** +2. Under **Custom Plugin Repositories**, add this URL: + - `https://raw.githubusercontent.com/Knack117/QuickTransfer/main/pluginmaster.json` +3. Click **Save** +4. Type `/xlplugins` in-game, search for **QuickTransfer**, and click **Install** + +#### Method 2: Development build (local) +1. Clone or download this repository +2. Open the solution in Visual Studio 2022 +3. Build the solution (Release configuration) +4. In-game, open Dalamud Settings → Experimental → Dev Plugin Locations +5. Add the path to the compiled DLL (typically `bin/Release/QuickTransfer.dll` or `bin/Debug/QuickTransfer.dll`) +6. Type `/xlplugins` in-game and enable QuickTransfer + +## Usage + +### Quick Transfer (Shift + Right Click) + +The plugin only clicks **existing** context menu options when they are available: + +- **Inventory + Chocobo Saddlebags** + - Inventory → **Add All to Saddlebag** + - Saddlebags → **Remove All from Saddlebag** +- **Armoury Chest + Chocobo Saddlebags** + - Armoury → **Add All to Saddlebag** + - Saddlebags → **Remove All from Saddlebag** +- **Inventory + Armoury Chest** + - (Gear) Inventory → **Place in Armoury Chest** + - Armoury → **Return to Inventory** + +If an option is not present for the clicked item, **nothing happens**. + +## Configuration Options + +| Setting | Description | Default | +|---------|-------------|---------| +| Enabled | Enable/disable the plugin | True | +| Debug Mode | Log transfer attempts to chat | False | +| Transfer Cooldown | Milliseconds between transfers | 200 | + +## Development + +### Setting Up Development Environment + +1. Install Visual Studio 2022 with the .NET 10 SDK +2. Clone this repository +3. Open `QuickTransfer.csproj` +4. Build the project + +### Building + +```bash +# Build Debug +dotnet build --configuration Debug + +# Build Release +dotnet build --configuration Release +``` + +### Testing + +1. Enable "Dev Plugin Locations" in Dalamud settings +2. Add the path to your build output directory +3. In-game, the plugin will automatically reload when you rebuild + +### Project Structure + +``` +QuickTransfer/ +├── QuickTransfer.cs # Main plugin class +├── QuickTransfer.csproj # Project file +├── QuickTransferWindow.cs # Configuration UI +├── pluginmaster.json # Custom repository metadata (for Dalamud) +└── README.md # This file +``` + +### Adding New Features + +1. Fork the repository +2. Create a feature branch +3. Implement your changes +4. Test thoroughly +5. Submit a pull request + +## Troubleshooting + +### Plugin Not Loading +- Ensure Dalamud is properly installed +- Check that you're using the correct .NET version +- Verify the DLL path is correct in Dev Plugin Locations + +### Transfers Not Working +- Make sure the plugin is enabled +- Check that you have both source and target inventories open +- Ensure the target inventory has space +- Try increasing the transfer cooldown + +### Game Crashes +- Disable debug mode for normal play +- Reduce the transfer cooldown if set too low +- Report bugs with detailed steps + +### Debug Mode + +Enable Debug Mode to see transfer attempts in chat: +``` +[QuickTransfer] (Shift+RClick) Selected context action 'Remove All from Saddlebag' (idx=0) via deferred OnMenuOpened. +``` + +## Compatibility + +- **Game Version**: Tested on FFXIV 7.0+ (Dawntrail) +- **Dalamud Version**: Uses `Dalamud.NET.Sdk` (targets your installed Dalamud) +- **.NET Version**: .NET 10.0 Windows (`net10.0-windows`) + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before submitting pull requests. + +### Reporting Issues + +1. Check existing issues to avoid duplicates +2. Include steps to reproduce +3. Include plugin version and game version +4. Include any relevant logs + +## License + +This plugin is licensed under the MIT License - see the `LICENSE` file for details. + +## Credits + +- **goatcorp**: For creating XIVLauncher and Dalamud +- **Dalamud Community**: For the extensive plugin ecosystem +- **Contributors**: Thanks to everyone who has contributed to this project + +## Changelog + +### Version 1.0.0 +- Initial release +- Shift+Right-Click context menu automation for Inventory / Armoury / Saddlebags diff --git a/packages.lock.json b/packages.lock.json new file mode 100644 index 0000000..d9dfac0 --- /dev/null +++ b/packages.lock.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "DalamudPackager": { + "type": "Direct", + "requested": "[14.0.1, )", + "resolved": "14.0.1", + "contentHash": "y0WWyUE6dhpGdolK3iKgwys05/nZaVf4ZPtIjpLhJBZvHxkkiE23zYRo7K7uqAgoK/QvK5cqF6l3VG5AbgC6KA==" + }, + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" + } + } + } +} \ No newline at end of file diff --git a/pluginmaster.json b/pluginmaster.json new file mode 100644 index 0000000..aac1e20 --- /dev/null +++ b/pluginmaster.json @@ -0,0 +1,21 @@ +[ + { + "Author": "flick", + "Name": "QuickTransfer", + "InternalName": "QuickTransfer", + "AssemblyVersion": "1.0.0.0", + "Description": "Automate inventory transfers with Shift/Ctrl + Right-Click.", + "ApplicableVersion": "any", + "RepoUrl": "https://github.com/Knack117/QuickTransfer", + "DalamudApiLevel": 14, + "Punchline": "Quick item transfer helpers.", + "Tags": [ + "inventory", + "utility", + "quality of life" + ], + "AcceptsFeedback": true, + "DownloadLinkInstall": "https://github.com/Knack117/QuickTransfer/releases/latest/download/QuickTransfer.zip", + "DownloadLinkUpdate": "https://github.com/Knack117/QuickTransfer/releases/latest/download/QuickTransfer.zip" + } +]