commit 3565bcd7f9e6caa638a1749c87313790d3d84d65 Author: Knack117 Date: Sun Jan 25 18:07:19 2026 -0500 Initial public release setup Add QuickTransfer source, pluginmaster feed, MIT license, and GitHub Actions release workflow. 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" + } +]