Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ccae50150 | |||
| 77006670ae | |||
| 49722e0a0a | |||
| 9bd14fb5e8 | |||
| a567c3293f | |||
| a9bec8daed | |||
| d7df385239 | |||
| 633cd487e4 | |||
| dae9ea1be0 | |||
| 57d2b8c6e2 | |||
| 2f8427b20b | |||
| 3177248635 | |||
| 54ff9a0c2b | |||
| 3565bcd7f9 |
@@ -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
@@ -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_*/
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Exclude nested QuickTransfer folder (duplicates) and external (SimpleTweaks has different deps) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="QuickTransfer\**" />
|
||||||
|
<Compile Remove="external\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Vendored
-1
Submodule external/SimpleTweaksPlugin deleted from 8671ec8951
@@ -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=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}]
|
||||||
Reference in New Issue
Block a user