13 Commits

Author SHA1 Message Date
KnackAtNite 77006670ae Bump version to 1.0.7.0
Release / build-release (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 19:03:02 -05:00
KnackAtNite 49722e0a0a Add Quick Use: Shift+RClick usable items when no other inventories open
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 19:00:22 -05:00
KnackAtNite 9bd14fb5e8 Bump version to 1.0.6.0 for release
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 10:53:26 -05:00
KnackAtNite a567c3293f Add Shift+Right Click Hand Over to NPCs (quest/dialogue)
Release / build-release (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 00:57:09 -05:00
KnackAtNite a9bec8daed Author: flick -> Knack117
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 21:26:28 -05:00
KnackAtNite d7df385239 Release 1.0.5: Vendor Quick Sell, auto-confirm sell dialogs, README update
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-01-27 20:05:39 -05:00
KnackAtNite 633cd487e4 Update README: Add Trade window feature documentation 2026-01-26 22:48:54 -05:00
KnackAtNite dae9ea1be0 Release v1.0.4
Add Shift+Right Click Trade window support with auto-fill max quantity.
2026-01-26 22:20:18 -05:00
KnackAtNite 57d2b8c6e2 Fix api14 build
Remove references to UI struct members not present in the api14 distrib used by CI.
2026-01-26 11:31:04 -05:00
KnackAtNite 2f8427b20b Release v1.0.3
Add Alt split helpers and update plugin metadata.
2026-01-26 11:27:20 -05:00
KnackAtNite 3177248635 Add middle-click sort
Support MMB to trigger Sort via context menu, and add Company Chest MMB organize (stack + compact). Bump version to 1.0.2.
2026-01-25 19:08:15 -05:00
KnackAtNite 54ff9a0c2b Fix context menu suppression
Restore addon alpha after suppression so normal right-click menus work. Bump version to 1.0.1.
2026-01-25 18:35:58 -05:00
KnackAtNite 3565bcd7f9 Initial public release setup
Add QuickTransfer source, pluginmaster feed, MIT license, and GitHub Actions release workflow.
2026-01-25 18:07:19 -05:00
16 changed files with 6463 additions and 2 deletions
+58
View File
@@ -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
+89
View File
@@ -0,0 +1,89 @@
# 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/
# Crash unpack artifacts (local)
crash_unpack_*/
+207
View File
@@ -0,0 +1,207 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using FFXIVClientStructs.FFXIV.Component.GUI;
using AtkValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
namespace QuickTransfer;
/// <summary>
/// Utility functions for working with AtkValue structures.
/// </summary>
internal static unsafe class AtkValueHelpers
{
private const int UnitListCount = 18;
public 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);
}
}
public 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);
}
public static void WriteUtf8InPlace(byte* dst, string value)
{
if (dst == null || string.IsNullOrEmpty(value))
return;
var bytes = Encoding.UTF8.GetBytes(value);
var max = Math.Min(bytes.Length, 255); // reasonable limit
for (var i = 0; i < max; i++)
dst[i] = bytes[i];
dst[max] = 0;
}
public static void WriteUtf8StringInPlace(FFXIVClientStructs.FFXIV.Client.System.String.Utf8String* s, string value)
{
if (s == null)
return;
WriteUtf8InPlace(s->StringPtr, value);
}
public 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;
}
public 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;
}
public 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));
}
}
public static bool TryGetAtkValueInt(AtkValue* values, int count, int idx, out int value)
{
value = 0;
try
{
if (values == null || idx < 0 || idx >= count)
return false;
var v = values + idx;
if (v->Type == AtkValueType.Int)
{
value = v->Int;
return true;
}
if (v->Type == AtkValueType.UInt)
{
value = unchecked((int)v->UInt);
return true;
}
}
catch
{
// ignore
}
return false;
}
public 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;
}
public static void MakeAddonVisible(AtkUnitBase* addon)
{
if (addon == null)
return;
var root = addon->RootNode;
if (root == null)
return;
// Restore fully visible alpha; this prevents "stuck invisible" menus after a suppression frame.
root->Color.A = 255;
root->Alpha_2 = 255;
}
}
+383
View File
@@ -0,0 +1,383 @@
using System;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Component.GUI;
using AtkValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType;
namespace QuickTransfer;
/// <summary>
/// Handles context menu selection and matching logic.
/// </summary>
internal static unsafe class ContextMenuHandler
{
public enum ModifierMode
{
Shift,
Ctrl,
Alt,
}
// Access services through Plugin's static properties
private static IGameGui GameGui => Plugin.GameGui;
public enum AutoContextAction
{
AddAllToSaddlebag,
RemoveAllFromSaddlebag,
PlaceInArmouryChest,
ReturnToInventory,
EntrustToRetainer,
RetrieveFromRetainer,
RemoveFromCompanyChest,
Split,
Sort,
Trade,
Sell,
HandOver,
Use,
}
public static bool ContextLabelMatches(AutoContextAction desiredAction, string menuText)
{
var t = menuText.Trim();
static bool Has(string s, string needle) => s.Contains(needle, StringComparison.OrdinalIgnoreCase);
return desiredAction switch
{
AutoContextAction.AddAllToSaddlebag =>
t.Equals("Add All to Saddlebag", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Add All") && Has(t, "Saddlebag")),
AutoContextAction.RemoveAllFromSaddlebag =>
t.Equals("Remove All from Saddlebag", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Remove All") && Has(t, "Saddlebag")) ||
(Has(t, "Remove") && Has(t, "Saddlebag")) ||
t.Equals("Remove All", StringComparison.OrdinalIgnoreCase) ||
((Has(t, "Retrieve") || Has(t, "Take out") || Has(t, "Take Out")) && Has(t, "Saddlebag")),
AutoContextAction.PlaceInArmouryChest =>
t.Equals("Place in Armoury Chest", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Place") && (Has(t, "Armoury") || Has(t, "Armory")) && Has(t, "Chest")),
AutoContextAction.ReturnToInventory =>
t.Equals("Return to Inventory", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Return") && Has(t, "Inventory")),
AutoContextAction.EntrustToRetainer =>
t.Equals("Entrust to Retainer", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Entrust") && Has(t, "Retainer")),
AutoContextAction.RetrieveFromRetainer =>
t.Equals("Retrieve from Retainer", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Retrieve") && Has(t, "Retainer")),
AutoContextAction.RemoveFromCompanyChest =>
t.Equals("Remove", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Remove") && (Has(t, "Company") || Has(t, "Chest"))) ||
(Has(t, "Withdraw") && (Has(t, "Company") || Has(t, "Chest"))),
AutoContextAction.Split =>
t.Equals("Split", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("Split", StringComparison.OrdinalIgnoreCase),
AutoContextAction.Sort =>
t.Equals("Sort", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("Sort", StringComparison.OrdinalIgnoreCase),
AutoContextAction.Trade =>
t.Equals("Trade", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("Trade", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Trade") && Has(t, "Item")),
AutoContextAction.Sell =>
t.Equals("Sell", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("Sell", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Sell") && Has(t, "Item")),
AutoContextAction.HandOver =>
t.Equals("Hand Over", StringComparison.OrdinalIgnoreCase) ||
(Has(t, "Hand") && Has(t, "Over")) ||
(Has(t, "Hand Over") && Has(t, "Item")),
AutoContextAction.Use =>
t.Equals("Use", StringComparison.OrdinalIgnoreCase) ||
t.StartsWith("Use", StringComparison.OrdinalIgnoreCase),
_ => false,
};
}
public static void CloseContextMenuAddon(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon)
{
try { agent->AgentInterface.Hide(); } catch { /* ignore */ }
try { contextMenuAddon->Hide(false, true, 0); } catch { /* ignore */ }
}
public static bool TryAutoSelectAndClose(
AgentInventoryContext* agent,
AtkUnitBase* contextMenuAddon,
ModifierMode mode,
Configuration configuration,
out string chosenText,
out int chosenIndex,
ref long pendingCloseContextMenuAtMs)
{
chosenText = string.Empty;
chosenIndex = -1;
// Single-pass: decode each label once, record first match per action.
var foundAny = false;
int removeIdx = -1, addIdx = -1, placeIdx = -1, returnIdx = -1, entrustIdx = -1, retrieveIdx = -1, companyRemoveIdx = -1, splitIdx = -1, tradeIdx = -1, sellIdx = -1, handOverIdx = -1, useIdx = -1;
string? removeTxt = null, addTxt = null, placeTxt = null, returnTxt = null, entrustTxt = null, retrieveTxt = null, companyRemoveTxt = null, splitTxt = null, tradeTxt = null, sellTxt = null, handOverTxt = null, useTxt = 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 = AtkValueHelpers.ReadAtkValueString(param);
if (string.IsNullOrWhiteSpace(text))
continue;
foundAny = true;
// Priority matters: we want the first matching index for each action.
if (removeIdx < 0 && ContextLabelMatches(AutoContextAction.RemoveAllFromSaddlebag, text))
{
removeIdx = i;
removeTxt = text;
continue;
}
if (companyRemoveIdx < 0 && ContextLabelMatches(AutoContextAction.RemoveFromCompanyChest, text))
{
companyRemoveIdx = i;
companyRemoveTxt = text;
continue;
}
if (addIdx < 0 && ContextLabelMatches(AutoContextAction.AddAllToSaddlebag, text))
{
addIdx = i;
addTxt = text;
continue;
}
if (placeIdx < 0 && ContextLabelMatches(AutoContextAction.PlaceInArmouryChest, text))
{
placeIdx = i;
placeTxt = text;
continue;
}
if (returnIdx < 0 && ContextLabelMatches(AutoContextAction.ReturnToInventory, text))
{
returnIdx = i;
returnTxt = text;
continue;
}
if (entrustIdx < 0 && ContextLabelMatches(AutoContextAction.EntrustToRetainer, text))
{
entrustIdx = i;
entrustTxt = text;
continue;
}
if (retrieveIdx < 0 && ContextLabelMatches(AutoContextAction.RetrieveFromRetainer, text))
{
retrieveIdx = i;
retrieveTxt = text;
continue;
}
if (splitIdx < 0 && ContextLabelMatches(AutoContextAction.Split, text))
{
splitIdx = i;
splitTxt = text;
continue;
}
if (tradeIdx < 0 && ContextLabelMatches(AutoContextAction.Trade, text))
{
tradeIdx = i;
tradeTxt = text;
}
if (sellIdx < 0 && ContextLabelMatches(AutoContextAction.Sell, text))
{
sellIdx = i;
sellTxt = text;
}
if (handOverIdx < 0 && ContextLabelMatches(AutoContextAction.HandOver, text))
{
handOverIdx = i;
handOverTxt = text;
}
if (useIdx < 0 && ContextLabelMatches(AutoContextAction.Use, text))
{
useIdx = i;
useTxt = text;
}
}
if (!foundAny)
return false;
var saddlebagOpen = InventoryHelpers.IsSaddlebagOpen();
var retainerOpen = InventoryHelpers.IsRetainerOpen();
var companyChestOpen = InventoryHelpers.IsCompanyChestOpen();
var tradeOpen = InventoryHelpers.IsTradeOpen();
var vendorOpen = InventoryHelpers.IsVendorOpen();
// Choose the best action that exists in the menu.
(int idx, string? txt) chosen;
if (mode == ModifierMode.Alt)
{
chosen = splitIdx >= 0 ? (splitIdx, splitTxt) : (-1, (string?)null);
}
else if (mode == ModifierMode.Shift && vendorOpen && configuration.EnableVendorQuickSell)
{
// Vendor shop: prioritize Sell action when vendor is open
chosen = sellIdx >= 0 ? (sellIdx, sellTxt) : (-1, (string?)null);
}
else if (mode == ModifierMode.Shift && tradeOpen)
{
// Trade window: prioritize Trade action when Trade window is open
chosen = tradeIdx >= 0 ? (tradeIdx, tradeTxt) : (-1, (string?)null);
}
else if (mode == ModifierMode.Shift && companyChestOpen && configuration.EnableCompanyChest)
{
chosen = companyRemoveIdx >= 0 ? (companyRemoveIdx, companyRemoveTxt) : (-1, (string?)null);
}
else if (mode == ModifierMode.Shift && handOverIdx >= 0)
{
// Quest/dialogue: hand over item to NPC
chosen = (handOverIdx, handOverTxt);
}
else if (mode == ModifierMode.Shift &&
!saddlebagOpen && !retainerOpen && !companyChestOpen && !tradeOpen && !vendorOpen &&
useIdx >= 0 && configuration.EnableQuickUse)
{
// No other inventories open: quick Use for usable items (potions, food, etc.)
chosen = (useIdx, useTxt);
}
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;
AtkValueHelpers.GenerateCallback(contextMenuAddon, 0, chosen.idx, 0U, 0, 0);
// Some actions (notably Split and Trade) can be cancelled if we close the menu immediately.
// Delay the close slightly to allow the follow-up UI (InputNumeric) to spawn.
if (chosen.txt != null && (ContextLabelMatches(AutoContextAction.Split, chosen.txt) || ContextLabelMatches(AutoContextAction.Trade, chosen.txt)))
{
// Don't close immediately: on some setups this cancels the action before InputNumeric opens.
// We'll keep the menu invisible (via suppression) and close it later as a cleanup.
pendingCloseContextMenuAtMs = Environment.TickCount64 + 3000;
}
else
{
CloseContextMenuAddon(agent, contextMenuAddon);
}
chosenText = chosen.txt!;
chosenIndex = chosen.idx;
return true;
}
public static bool TrySelectSortAndClose(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon, out string chosenText, out int chosenIndex)
{
chosenText = string.Empty;
chosenIndex = -1;
var undoSortIdx = -1;
string? undoSortText = null;
var max = Math.Min(agent->ContextItemCount, 64);
for (var i = 0; i < max; i++)
{
var param = agent->EventParams[agent->ContexItemStartIndex + i];
if (param.Type is not (AtkValueType.String or AtkValueType.ManagedString))
continue;
var text = AtkValueHelpers.ReadAtkValueString(param);
if (string.IsNullOrWhiteSpace(text))
continue;
// If Sort isn't present (because the container is already sorted), the menu often contains "Undo Sort" instead.
// We treat that as "already sorted" and do nothing (closing the menu).
if (undoSortIdx < 0 && text.Trim().Equals("Undo Sort", StringComparison.OrdinalIgnoreCase))
{
undoSortIdx = i;
undoSortText = text;
}
if (!ContextLabelMatches(AutoContextAction.Sort, text))
continue;
AtkValueHelpers.GenerateCallback(contextMenuAddon, 0, i, 0U, 0, 0);
CloseContextMenuAddon(agent, contextMenuAddon);
chosenText = text;
chosenIndex = i;
return true;
}
// No "Sort" entry. If "Undo Sort" exists, we're already sorted; close the menu without changing state.
if (undoSortIdx >= 0)
{
try { CloseContextMenuAddon(agent, contextMenuAddon); } catch { /* ignore */ }
chosenText = "Already sorted";
chosenIndex = -1;
return true;
}
return false;
}
}
+299
View File
@@ -0,0 +1,299 @@
using System;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
namespace QuickTransfer;
/// <summary>
/// Helper functions for parsing drag-drop interfaces from UI events.
/// </summary>
internal static unsafe class DragDropHelpers
{
// ArmouryBoard drag-drop payloads are not always (InventoryType, Slot).
// On some builds the payload's Int1 is a category index, and Int2 is the slot within that category.
// This mapping is best-effort and is only applied when we're sure the hover comes from the ArmouryBoard addon.
internal static readonly InventoryType[] ArmouryBoardIndexToType =
[
InventoryType.ArmoryMainHand,
InventoryType.ArmoryOffHand,
InventoryType.ArmoryHead,
InventoryType.ArmoryBody,
InventoryType.ArmoryHands,
InventoryType.ArmoryWaist,
InventoryType.ArmoryLegs,
InventoryType.ArmoryFeets,
InventoryType.ArmoryEar,
InventoryType.ArmoryNeck,
InventoryType.ArmoryWrist,
InventoryType.ArmoryRings,
InventoryType.ArmorySoulCrystal,
];
public static bool TryGetDragDropInterfaceFromReceiveEvent(
AddonArgs args,
AddonReceiveEventArgs recv,
AtkEventType eventType,
AtkEventData* eventData,
out uint addonId,
out AtkDragDropInterface* ddi)
{
addonId = 0;
ddi = null;
var addon = (AtkUnitBase*)args.Addon.Address;
if (addon == null)
return false;
addonId = addon->Id;
// List item events can provide a renderer directly.
if (eventData != null &&
eventType is AtkEventType.ListItemRollOver or AtkEventType.ListItemRollOut or AtkEventType.ListItemClick or
AtkEventType.ListItemDoubleClick or AtkEventType.ListItemSelect)
{
try
{
var r = eventData->ListItemData.ListItemRenderer;
if (r != null)
{
// Prefer the embedded DragDrop component if present.
if (r->DragDropComponent != null)
ddi = &r->DragDropComponent->AtkDragDropInterface;
else
{
try { ddi = &r->AtkDragDropInterface; } catch { /* ignore */ }
}
}
}
catch
{
// ignore
}
}
if (ddi != null)
return true;
static AtkDragDropInterface* TryGetDdiFromList(AtkComponentList* list)
{
if (list == null)
return null;
// The list tracks a hovered item index itself, which is much safer than trying to interpret eventParam.
// Prefer HoveredItemIndex, then fall back to other hover slots.
static AtkDragDropInterface* FromIndex(AtkComponentList* l, int idx)
{
if (idx < 0 || idx > 512)
return null;
try
{
var r = l->GetItemRenderer(idx);
return r != null ? &r->AtkDragDropInterface : null;
}
catch
{
return null;
}
}
var ddi0 = FromIndex(list, list->HoveredItemIndex);
if (ddi0 != null)
return ddi0;
var ddi1 = FromIndex(list, list->HoveredItemIndex2);
if (ddi1 != null)
return ddi1;
var ddi2 = FromIndex(list, list->HoveredItemIndex3);
if (ddi2 != null)
return ddi2;
return null;
}
static AtkDragDropInterface* TryGetDdiFromComponent(AtkComponentBase* component)
{
if (component == null)
return null;
var t = component->GetComponentType();
return t switch
{
ComponentType.DragDrop => &((AtkComponentDragDrop*)component)->AtkDragDropInterface,
ComponentType.ListItemRenderer => &((AtkComponentListItemRenderer*)component)->AtkDragDropInterface,
ComponentType.List => TryGetDdiFromList((AtkComponentList*)component),
_ => null,
};
}
// Prefer the drag-drop interface directly from event data when present.
// IMPORTANT: only trust DragDropData for actual drag-drop event types; for MouseOver it can contain garbage.
var isDragDropEvent =
eventType is AtkEventType.DragDropBegin or
AtkEventType.DragDropCanAcceptCheck or
AtkEventType.DragDropClick or
AtkEventType.DragDropDiscard or
AtkEventType.DragDropEnd or
AtkEventType.DragDropInsert or
AtkEventType.DragDropInsertAttempt or
AtkEventType.DragDropRollOut or
AtkEventType.DragDropRollOver;
ddi = (isDragDropEvent && eventData != null) ? eventData->DragDropData.DragDropInterface : null;
// Some drag-drop events (notably DragDropRollOver) provide a ComponentNode but not a DragDropInterface.
// IMPORTANT: never read DragDropData.ComponentNode for non-dragdrop events (AtkEventData is a union).
if (ddi == null && isDragDropEvent && eventData != null && eventData->DragDropData.ComponentNode != null)
{
try
{
var compNode = eventData->DragDropData.ComponentNode;
var component = compNode->Component;
ddi = TryGetDdiFromComponent(component);
}
catch
{
// ignore
}
}
// Fallback: some event types provide MouseData, but the target is still a DragDrop component.
if (ddi == null)
{
var atkEvent = (AtkEvent*)recv.AtkEvent;
if (atkEvent != null && atkEvent->Node != null)
{
var node = atkEvent->Node;
var compNode = node->GetAsAtkComponentNode();
if (compNode != null)
{
var component = compNode->Component;
ddi = TryGetDdiFromComponent(component);
}
}
}
if (ddi == null)
return false;
return true;
}
public static bool TryGetSlotFromDragDropInterface(
AtkDragDropInterface* ddi,
out InventoryType invType,
out int slot)
{
invType = default;
slot = -1;
if (ddi == null)
return false;
var payload = ddi->GetPayloadContainer();
if (payload == null)
return false;
invType = (InventoryType)payload->Int1;
slot = payload->Int2;
if (slot < 0 || slot > 500)
return false;
return true;
}
public static bool TryGetSlotFromDragDropInterfaceForAddon(
AtkDragDropInterface* ddi,
string addonName,
uint addonId,
out InventoryType invType,
out int slot,
out int rawInt1,
out int rawInt2,
out uint rawFlags)
{
invType = default;
slot = -1;
rawInt1 = 0;
rawInt2 = 0;
rawFlags = 0;
if (ddi == null)
return false;
AtkDragDropPayloadContainer* payload;
try
{
payload = ddi->GetPayloadContainer();
}
catch
{
return false;
}
if (payload == null)
return false;
rawInt1 = payload->Int1;
rawInt2 = payload->Int2;
rawFlags = payload->Flags;
// Default interpretation (most inventory add-ons): (InventoryType, Slot)
invType = (InventoryType)rawInt1;
slot = rawInt2;
// ArmouryBoard special-case: some builds use (CategoryIndex, Slot)
// and Int1 may look like Inventory1..Inventory4 (0..3), which is clearly wrong for ArmouryBoard.
if (!string.IsNullOrEmpty(addonName) &&
addonName.Equals("ArmouryBoard", StringComparison.OrdinalIgnoreCase) &&
InventoryHelpers.TryGetVisibleAddon("ArmouryBoard", out var ab) &&
ab != null &&
ab->Id == addonId)
{
if (rawInt1 >= 0 && rawInt1 < ArmouryBoardIndexToType.Length)
{
invType = ArmouryBoardIndexToType[rawInt1];
slot = rawInt2;
}
}
if (slot < 0 || slot > 500)
return false;
return true;
}
public static int PickContextMenuSlot(InventoryType type, int preferredSlot)
{
try
{
var inv = InventoryManager.Instance();
if (inv == null)
return preferredSlot;
var c = inv->GetInventoryContainer(type);
if (c == null || !c->IsLoaded || c->Size <= 0)
return preferredSlot;
// Prefer the hovered slot when in range AND it contains an item.
if (preferredSlot >= 0 && preferredSlot < c->Size)
{
var it0 = c->GetInventorySlot(preferredSlot);
if (it0 != null && it0->ItemId != 0)
return preferredSlot;
}
// Fallback: find the first slot with an item.
for (var i = 0; i < c->Size; i++)
{
var it = c->GetInventorySlot(i);
if (it != null && it->ItemId != 0)
return i;
}
}
catch
{
// ignore
}
return preferredSlot;
}
}
+306
View File
@@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Component.GUI;
using Dalamud.Plugin.Services;
namespace QuickTransfer;
/// <summary>
/// Static helper functions for inventory detection, type checking, and addon visibility.
/// </summary>
internal static unsafe class InventoryHelpers
{
// Access services through Plugin's static properties
private static IGameGui GameGui => Plugin.GameGui;
private static IDataManager DataManager => Plugin.DataManager;
private static readonly InventoryType[] PlayerInventoryTypes =
[
InventoryType.Inventory1,
InventoryType.Inventory2,
InventoryType.Inventory3,
InventoryType.Inventory4,
];
private static readonly InventoryType[] SaddlebagInventoryTypes =
[
InventoryType.SaddleBag1,
InventoryType.SaddleBag2,
InventoryType.PremiumSaddleBag1,
InventoryType.PremiumSaddleBag2,
];
private static readonly InventoryType[] RetainerInventoryTypes =
[
InventoryType.RetainerPage1,
InventoryType.RetainerPage2,
InventoryType.RetainerPage3,
InventoryType.RetainerPage4,
InventoryType.RetainerPage5,
InventoryType.RetainerPage6,
InventoryType.RetainerPage7,
];
public static bool IsPlayerInventoryType(InventoryType inventoryType)
=> inventoryType is
InventoryType.Inventory1 or
InventoryType.Inventory2 or
InventoryType.Inventory3 or
InventoryType.Inventory4;
public static bool IsArmouryType(InventoryType inventoryType)
=> inventoryType is
InventoryType.ArmoryMainHand or
InventoryType.ArmoryOffHand or
InventoryType.ArmoryHead or
InventoryType.ArmoryBody or
InventoryType.ArmoryHands or
InventoryType.ArmoryWaist or
InventoryType.ArmoryLegs or
InventoryType.ArmoryFeets or
InventoryType.ArmoryEar or
InventoryType.ArmoryNeck or
InventoryType.ArmoryWrist or
InventoryType.ArmoryRings or
InventoryType.ArmorySoulCrystal;
public static bool IsSaddlebagType(InventoryType inventoryType)
=> inventoryType is
InventoryType.SaddleBag1 or
InventoryType.SaddleBag2 or
InventoryType.PremiumSaddleBag1 or
InventoryType.PremiumSaddleBag2;
public static bool IsRetainerType(InventoryType inventoryType)
=> inventoryType is
InventoryType.RetainerPage1 or
InventoryType.RetainerPage2 or
InventoryType.RetainerPage3 or
InventoryType.RetainerPage4 or
InventoryType.RetainerPage5 or
InventoryType.RetainerPage6 or
InventoryType.RetainerPage7;
public static bool IsCompanyChestType(InventoryType inventoryType)
{
var name = Enum.GetName(typeof(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);
}
public static bool IsAddonVisible(string addonName, int index = 1)
{
var addon = GameGui.GetAddonByName(addonName, index);
return !addon.IsNull && addon.IsVisible;
}
public static bool IsAddonVisibleAnyIndex(string addonName, int maxIndex = 6)
{
for (var i = 1; i <= maxIndex; i++)
{
if (IsAddonVisible(addonName, i))
return true;
}
return false;
}
public static bool IsAnyAddonVisible(IEnumerable<string> addonNames, int index = 1)
{
foreach (var name in addonNames)
{
if (IsAddonVisible(name, index))
return true;
}
return false;
}
public static bool IsAnyAddonVisibleAnyIndex(IEnumerable<string> addonNames, int maxIndex = 6)
{
foreach (var name in addonNames)
{
if (IsAddonVisibleAnyIndex(name, maxIndex))
return true;
}
return false;
}
public static bool IsInventoryAndSaddlebagOpen()
{
var inventoryOpen = IsAddonVisibleAnyIndex("Inventory");
var saddlebagOpen = IsAddonVisibleAnyIndex("InventoryBuddy") || IsAddonVisibleAnyIndex("InventoryBuddy2");
return inventoryOpen && saddlebagOpen;
}
public static bool IsSaddlebagOpen()
=> IsAddonVisibleAnyIndex("InventoryBuddy") || IsAddonVisibleAnyIndex("InventoryBuddy2");
public static bool IsRetainerOpen()
{
// Common retainer inventory addons.
// (SimpleTweaks checks "RetainerGrid0" for retainer inventory visibility.)
return IsAddonVisibleAnyIndex("RetainerGrid0") ||
IsAddonVisibleAnyIndex("RetainerSellList") ||
IsAddonVisibleAnyIndex("RetainerGrid");
}
public static bool IsCompanyChestOpen()
=> IsAddonVisibleAnyIndex("FreeCompanyChest");
public static bool IsTradeOpen()
=> IsAddonVisibleAnyIndex("Trade") || IsAddonVisibleAnyIndex("TradeWindow");
public static bool IsVendorOpen()
=> IsAddonVisibleAnyIndex("Shop");
public 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;
}
public static bool TryGetItemInfo(
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;
}
public static bool IsContainerLoaded(InventoryManager* inv, InventoryType type)
{
try
{
if (inv == null)
return false;
var c = inv->GetInventoryContainer(type);
return c != null && c->IsLoaded && c->Size > 0;
}
catch
{
return false;
}
}
public static InventoryType[] GetPlayerInventoryTypes() => PlayerInventoryTypes;
public static InventoryType[] GetSaddlebagInventoryTypes() => SaddlebagInventoryTypes;
public static InventoryType[] GetRetainerInventoryTypes() => RetainerInventoryTypes;
private static readonly Dictionary<uint, uint> StackSizeCache = new();
private static readonly Dictionary<uint, uint> ItemUiCategoryCache = new();
public 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<Lumina.Excel.Sheets.Item>();
if (sheet == null)
return 999;
// Item row IDs are base IDs; InventoryItem.ItemId is expected to already be base.
var row = sheet.GetRow(itemId);
if (row.RowId == 0)
return 999;
// In modern Lumina sheets, Item.StackSize exists.
var s = row.StackSize;
var result = s <= 0 ? 1U : (uint)s;
lock (StackSizeCache)
StackSizeCache[itemId] = result;
return result;
}
catch
{
// Fallback: most stackables are 999, and non-stackables will hit maxStack <= 1 cases anyway.
return 999;
}
}
public static uint GetItemUiCategory(uint itemId)
{
try
{
if (itemId == 0)
return 0;
lock (ItemUiCategoryCache)
{
if (ItemUiCategoryCache.TryGetValue(itemId, out var cached))
return cached;
}
var sheet = DataManager.GetExcelSheet<Lumina.Excel.Sheets.Item>();
if (sheet == null)
return 0;
var row = sheet.GetRow(itemId);
if (row.RowId == 0)
return 0;
// Prefer UI category; this tends to match how game sorts items visually.
uint result;
try
{
// Lumina RowRef usually exposes RowId.
result = row.ItemUICategory.RowId;
}
catch
{
result = 0;
}
lock (ItemUiCategoryCache)
ItemUiCategoryCache[itemId] = result;
return result;
}
catch
{
return 0;
}
}
}
+21
View File
@@ -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.
Submodule QuickTransfer deleted from d7df385239
+4634
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Dalamud.NET.Sdk/14.0.1">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AssemblyName>QuickTransfer</AssemblyName>
<RootNamespace>QuickTransfer</RootNamespace>
<OutputType>Library</OutputType>
<IsPackable>false</IsPackable>
<Version>1.0.7</Version>
<AssemblyVersion>1.0.7.0</AssemblyVersion>
<FileVersion>1.0.7.0</FileVersion>
</PropertyGroup>
<!-- Local builds: some setups have DALAMUD_HOME pointing at the XIVLauncher root,
not the actual dev hooks folder. If the resolved DalamudLibPath doesn't contain
Dalamud.dll, fall back to the standard Roaming dev hooks path. -->
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
<_DalamudDevHooksPath>$(APPDATA)\XIVLauncher\addon\Hooks\dev\</_DalamudDevHooksPath>
</PropertyGroup>
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows')) and Exists('$(_DalamudDevHooksPath)Dalamud.dll') and !Exists('$(DalamudLibPath)Dalamud.dll')">
<DalamudLibPath>$(_DalamudDevHooksPath)</DalamudLibPath>
</PropertyGroup>
</Project>
+21
View File
@@ -0,0 +1,21 @@
{
"Author": "Knack117",
"Name": "QuickTransfer",
"InternalName": "QuickTransfer",
"AssemblyVersion": "1.0.7.0",
"Description": "Automate inventory transfers with Shift/Ctrl/Alt + Right-Click. Quick Use on usable items, trade window, vendor quick sell, FC chest, Hand Over to NPCs.",
"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 + split helpers.",
"AcceptsFeedback": true
}
+172
View File
@@ -0,0 +1,172 @@
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();
// Middle-click sort
var mmbSort = _config.EnableMiddleClickSort;
if (ImGui.Checkbox("Enable Middle-Click Sort###EnableMiddleClickSort", ref mmbSort))
{
_config.EnableMiddleClickSort = mmbSort;
_config.Save();
}
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(MMB on an item: auto-select \"Sort\" when available)");
// 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/Alt: deposit/withdraw while FC chest is open)");
var mmbCompanyOrganize = _config.EnableCompanyChestMiddleClickOrganize;
if (ImGui.Checkbox("Company Chest: Middle-Click Organize###EnableCompanyChestMiddleClickOrganize", ref mmbCompanyOrganize))
{
_config.EnableCompanyChestMiddleClickOrganize = mmbCompanyOrganize;
_config.Save();
}
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(MMB: auto-stack + compact in FC chest)");
var autoConfirmQty = _config.AutoConfirmCompanyChestQuantity;
if (ImGui.Checkbox("Auto-confirm quantity prompts (Company Chest / Split)###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)");
// Vendor Quick Sell
var enableVendorQuickSell = _config.EnableVendorQuickSell;
if (ImGui.Checkbox("Enable Vendor Quick Sell###EnableVendorQuickSell", ref enableVendorQuickSell))
{
_config.EnableVendorQuickSell = enableVendorQuickSell;
_config.Save();
}
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Shift+RClick: auto-select \"Sell\" when vendor is open)");
var autoConfirmVendorSell = _config.AutoConfirmVendorSell;
if (ImGui.Checkbox("Auto-confirm vendor sell dialogs###AutoConfirmVendorSell", ref autoConfirmVendorSell))
{
_config.AutoConfirmVendorSell = autoConfirmVendorSell;
_config.Save();
}
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Auto-fill quantity, confirm \"How many?\", and click OK on \"Are you certain?\")");
// Quick Use
var enableQuickUse = _config.EnableQuickUse;
if (ImGui.Checkbox("Enable Quick Use###EnableQuickUse", ref enableQuickUse))
{
_config.EnableQuickUse = enableQuickUse;
_config.Save();
}
ImGui.SameLine();
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Shift+RClick: auto-select \"Use\" on usable items when no other inventories are open)");
// 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("Hold ALT and RIGHT-CLICK to split a stack in half (or remove half from Company Chest)");
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("Vendor Shop open: Shift+RClick to auto-select \"Sell\"; enable \"Auto-confirm vendor sell\" to auto-fill quantity and confirm.");
ImGui.BulletText("Quest/dialogue: Shift+RClick on an item to auto-select \"Hand Over\" when handing items to an NPC.");
ImGui.BulletText("Inventory only (no other panels open): Shift+RClick on a usable item (potions, food, etc.) to auto-select \"Use\".");
ImGui.BulletText("Middle-Click: Sort the clicked container when a \"Sort\" menu entry exists. In Company Chest, MMB runs an organize pass (stack + compact).");
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;
}
}
}
+228
View File
@@ -0,0 +1,228 @@
# QuickTransfer - FFXIV Quick Transfer Plugin
A Dalamud plugin for Final Fantasy XIV that adds quick inventory actions via the game's existing context menus:
- **Shift + Right Click**: quick transfers (including Quick Use on usable items when no other inventories are open, vendor sell when shop is open, and Hand Over to NPCs in quest/dialogue)
- **Ctrl + Right Click**: armoury-mode transfers (when a special container is open)
- **Alt + Right Click**: split a stack in half
## Features
- **Quick Transfer**: Hold Shift and right-click an item to automatically trigger the matching context menu action
- **Quick Use**: When no other inventories (saddlebag, retainer, company chest, trade, vendor) are open, Shift + Right Click on a usable item (potions, food, etc.) auto-selects **Use**
- **Vendor Quick Sell**: With a vendor shop open, Shift + Right Click auto-selects **Sell**. With **Auto-confirm vendor sell** enabled, quantity dialogs and "Are you certain?" confirmations are auto-filled and confirmed
- **Trade Window Support**: Shift + Right Click items from inventory into Trade window with auto-fill max quantity
- **Company Chest**: Shift + Right Click to deposit/withdraw when Free Company Chest is open; middle-click runs organize (stack + compact)
- **Hand Over to NPCs**: During quest or dialogue that requires handing over items, Shift + Right Click on the item to auto-select **Hand Over**
- **Armoury Mode**: Hold Ctrl and right-click to prioritize armoury actions while a special container is open
- **Split Half**: Hold Alt and right-click to split a stack and auto-fill half
- **Middle-Click Sort**: Middle-click an item to auto-select **Sort** (or organize in FC chest)
- **Cooldown Protection**: Built-in cooldown to prevent accidental double-moves
- **Debug Mode**: For troubleshooting and development (disabled by default)
## 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**
- **Trade Window**
- Inventory → **Trade** (auto-fills and confirms max quantity for stackable items)
- **Vendor Shop**
- With a vendor shop open, Shift + Right Click → **Sell**. Enable **Auto-confirm vendor sell** to auto-fill quantity and click OK on "Are you certain?" dialogs.
- **Company Chest (Free Company Chest)**
- Shift + Right Click Inventory/Armoury → deposit; Shift + Right Click Company Chest → **Remove** (withdraw)
- **Hand Over to NPCs (quest/dialogue)**
- When an NPC is asking for items (e.g. quest turn-in), Shift + Right Click the item → **Hand Over**
- **Inventory only (no other panels open)**
- Shift + Right Click on a usable item (potions, food, etc.) → **Use**
If an option is not present for the clicked item, **nothing happens**.
### Armoury Mode (Ctrl + Right Click)
- While a **Saddlebag**, **Retainer**, or **Company Chest** is open, **Ctrl + Right Click** will prioritize:
- Inventory gear → **Place in Armoury Chest**
- Armoury gear → **Return to Inventory**
### Split Stack (Alt + Right Click)
- **Alt + Right Click** a **stackable** item to select the existing **Split** context menu action.
- If **Auto-confirm quantity prompts** is enabled, QuickTransfer will enter **half** and confirm automatically.
### Middle-Click Sort / Organize (MMB)
- For inventories that include a **Sort** entry in the item context menu, **middle-click an item** to auto-select **Sort** (without showing the menu).
- In the **Free Company Chest**, item context menus do not include Sort, so **middle-click** will run an **organize pass** (auto-stack + compact).
## 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 |
| Enable Middle-Click Sort | Enable MMB sort behavior | True |
| Enable Company Chest | Enable FC chest helpers | True |
| Company Chest: Middle-Click Organize | Enable MMB organize (stack+compact) in FC chest | True |
| Auto-confirm quantity prompts | Auto-fill and confirm InputNumeric prompts (Split / FC chest) | True |
| Enable Vendor Quick Sell | Shift+RClick auto-selects "Sell" when vendor is open | True |
| Enable Quick Use | Shift+RClick auto-selects "Use" on usable items when no other inventories are open | True |
| Auto-confirm vendor sell | Auto-fill quantity and click OK on sell dialogs ("How many?", "Are you certain?") | True |
## 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
```
Release build produces `bin/Release/QuickTransfer/latest.zip` for distribution.
### 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
├── ContextMenuHandler.cs # Context menu matching and selection
├── InventoryHelpers.cs # Inventory/addon detection
├── DragDropHelpers.cs # Drag-drop parsing
├── AtkValueHelpers.cs # AtkValue and addon utilities
├── 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 (or the correct container for the action)
- 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 OpenForItemSlot.
```
## 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.5
- **New**: Vendor Quick Sell — Shift + Right Click at a vendor shop auto-selects **Sell**
- **New**: Auto-confirm vendor sell dialogs — auto-fill quantity ("How many to sell?") and click OK on "Are you certain you wish to sell it?" (unique/untradable items)
- README and configuration table updated for all current options
### Version 1.0.4
- **New**: Trade window support — Shift + Right Click items from inventory into Trade window
- **New**: Auto-fill and confirm max quantity when trading stackable items
- Trade window actions work independently of Company Chest settings
### Version 1.0.3
- Fix: inventory **Alt+RightClick Split** now reliably auto-fills **half** (including InventoryExpansion / localized prompts)
- Change: **Debug Mode is disabled by default** (and migrated off on update)
### Version 1.0.0
- Initial release
- Shift+Right-Click context menu automation for Inventory / Armoury / Saddlebags
Submodule external/SimpleTweaksPlugin deleted from 8671ec8951
+19
View File
@@ -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=="
}
}
}
}
+1
View File
@@ -0,0 +1 @@
[{"Author":"Knack117","Name":"QuickTransfer","Punchline":"Quick item transfer + split helpers.","Description":"Automate inventory transfers with Shift/Ctrl/Alt + Right-Click. Quick Use on usable items, trade window, vendor quick sell, FC chest, Hand Over to NPCs.","InternalName":"QuickTransfer","AssemblyVersion":"1.0.7.0","RepoUrl":"http://brassnet.ddns.net:33983/KnackAtNite/QuickTransfer","ApplicableVersion":"any","DalamudApiLevel":14,"Tags":["inventory","utility","quality of life"],"AcceptsFeedback":true,"DownloadLinkInstall":"http://brassnet.ddns.net:33983/KnackAtNite/QuickTransfer/releases/download/v1.0.7/QuickTransfer.zip","DownloadLinkUpdate":"http://brassnet.ddns.net:33983/KnackAtNite/QuickTransfer/releases/download/v1.0.7/QuickTransfer.zip","LastUpdate":"1739020800"}]