From 9c68149d74d09764f19eb79f2603680d136652dd Mon Sep 17 00:00:00 2001 From: Zeffuro Date: Sat, 27 Dec 2025 03:54:47 +0100 Subject: [PATCH] Fix moving retainers, add InventoryNotificationNode --- AetherBags/Addons/AddonInventoryWindow.cs | 8 + .../Extensions/DragDropPayloadExtensions.cs | 59 ++++++- .../Extensions/InventoryTypeExtensions.cs | 50 +----- AetherBags/Helpers/InventoryMoveHelper.cs | 17 +- AetherBags/Inventory/InventoryLocation.cs | 12 ++ .../Nodes/Inventory/InventoryCategoryNode.cs | 57 +----- .../Inventory/InventoryNotificationNode.cs | 162 ++++++++++++++++++ 7 files changed, 260 insertions(+), 105 deletions(-) create mode 100644 AetherBags/Inventory/InventoryLocation.cs create mode 100644 AetherBags/Nodes/Inventory/InventoryNotificationNode.cs diff --git a/AetherBags/Addons/AddonInventoryWindow.cs b/AetherBags/Addons/AddonInventoryWindow.cs index 2cf5597..b32f375 100644 --- a/AetherBags/Addons/AddonInventoryWindow.cs +++ b/AetherBags/Addons/AddonInventoryWindow.cs @@ -69,6 +69,14 @@ public class AddonInventoryWindow : NativeAddon float x = headerX + (headerW - size.X) * 0.5f; float y = headerY + (headerH - size.Y) * 0.5f; + InventoryNotificationNode notificationNode = new InventoryNotificationNode + { + Position = new Vector2(WindowNode!.X - 4f, WindowNode!.Y - 32f), + Size = new Vector2(headerW, 28f), + }; + notificationNode.AttachNode(this); + //notificationNode.NotificationType = InventoryNotificationType.SaddleBag; + _searchInputNode = new TextInputWithHintNode { Position = new Vector2(x, y), diff --git a/AetherBags/Extensions/DragDropPayloadExtensions.cs b/AetherBags/Extensions/DragDropPayloadExtensions.cs index 62024dd..a8cd0dd 100644 --- a/AetherBags/Extensions/DragDropPayloadExtensions.cs +++ b/AetherBags/Extensions/DragDropPayloadExtensions.cs @@ -1,4 +1,6 @@ using AetherBags.Interop; +using AetherBags.Inventory; +using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Component.GUI; using KamiToolKit.Classes; using Lumina.Text.ReadOnly; @@ -6,7 +8,7 @@ using Lumina.Text; namespace AetherBags.Extensions; -// TODO: Remove this when CS is merged into Dalamud. +// TODO: Remove FixedInterface when CS is merged into Dalamud. public static unsafe class DragDropPayloadExtensions { public static DragDropPayload FromFixedInterface(AtkDragDropInterface* dragDropInterface) @@ -54,4 +56,59 @@ public static unsafe class DragDropPayloadExtensions } } } + + extension(DragDropPayload payload) + { + public bool IsValidInventoryPayload => + payload.Type is DragDropType.Inventory_Item + or DragDropType.Inventory_Crystal + or DragDropType.RemoteInventory_Item + or DragDropType.Item; + + public InventoryLocation InventoryLocation + { + get + { + if (!payload.IsValidInventoryPayload) return default; + + if (payload.Type == DragDropType.Inventory_Item) + { + return new InventoryLocation((InventoryType)payload.Int1, (ushort)payload.Int2); + } + + int containerId = payload.Int1; + int uiSlot = payload.Int2; + + InventoryType sourceContainer = InventoryType.GetInventoryTypeFromContainerId(containerId); + + if (sourceContainer == 0) + return new InventoryLocation(0, 0); + + // Retainers have special handling: UI has 5 tabs × 35 slots, data has 7 pages × 25 slots + if (sourceContainer.IsRetainer) + { + // Container IDs 52-56 = UI tabs 0-4 + int uiTabIndex = containerId - 52; + + // Convert to global data index + int globalDataIndex = (uiTabIndex * 35) + uiSlot; + + // Calculate data page and slot + int dataPage = globalDataIndex / 25; + int dataSlot = globalDataIndex % 25; + + InventoryType dataContainer = InventoryType.RetainerPage1 + (uint)dataPage; + + // Now resolve through sorter for the actual storage location + var (realContainer, realSlot) = dataContainer.GetRealItemLocation(dataSlot); + return new InventoryLocation(realContainer, realSlot); + } + + // For non-retainers, use the standard resolution + var (container, slot) = sourceContainer.GetRealItemLocation(uiSlot); + return new InventoryLocation(container, slot); + } + } + } + } \ No newline at end of file diff --git a/AetherBags/Extensions/InventoryTypeExtensions.cs b/AetherBags/Extensions/InventoryTypeExtensions.cs index 0b8ff14..df91f0b 100644 --- a/AetherBags/Extensions/InventoryTypeExtensions.cs +++ b/AetherBags/Extensions/InventoryTypeExtensions.cs @@ -1,4 +1,5 @@ using System; +using AetherBags.Inventory; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.UI.Misc; @@ -17,6 +18,7 @@ public static unsafe class InventoryTypeExtensions InventoryType.Inventory2 => 49, InventoryType.Inventory3 => 50, InventoryType.Inventory4 => 51, + // It's possible that these are actually UI IDs InventoryType.RetainerPage1 => 52, InventoryType.RetainerPage2 => 53, InventoryType.RetainerPage3 => 54, @@ -171,21 +173,21 @@ public static unsafe class InventoryTypeExtensions /// Resolves the real container and slot for this inventory type using ItemOrderModule. /// For sorted inventories, the visual slot differs from the actual storage slot. /// - public (InventoryType Container, ushort Slot) GetRealItemLocation(int visualSlot) + public InventoryLocation GetRealItemLocation(int visualSlot) { var sorter = inventoryType.GetInventorySorter; if (sorter == null) - return (inventoryType, (ushort)visualSlot); + return new InventoryLocation(inventoryType, (ushort)visualSlot); int startIndex = inventoryType.GetInventoryStartIndex; int sorterIndex = startIndex + visualSlot; if (sorterIndex < 0 || sorterIndex >= sorter->Items.LongCount) - return (inventoryType, (ushort)visualSlot); + return new InventoryLocation(inventoryType, (ushort)visualSlot); var entry = sorter->Items[sorterIndex].Value; if (entry == null) - return (inventoryType, (ushort)visualSlot); + return new InventoryLocation(inventoryType, (ushort)visualSlot); InventoryType baseType = inventoryType switch { @@ -200,45 +202,7 @@ public static unsafe class InventoryTypeExtensions InventoryType realContainer = baseType + entry->Page; ushort realSlot = entry->Slot; - return (realContainer, realSlot); - } - - public int GetVisualSlotFromReal(int realSlot) - { - var sorter = inventoryType.GetInventorySorter; - if (sorter == null) - return realSlot; - - int startIndex = inventoryType.GetInventoryStartIndex; - long itemCount = sorter->Items.LongCount; - - // Search through the sorter to find which visual index maps to this real slot - for (int visualIdx = 0; visualIdx < itemCount; visualIdx++) - { - var entry = sorter->Items[visualIdx]. Value; - if (entry == null) continue; - - // Calculate what container this entry belongs to - InventoryType baseType = inventoryType switch - { - _ when inventoryType.IsMainInventory => InventoryType. Inventory1, - _ when inventoryType.IsSaddleBag => inventoryType is InventoryType.SaddleBag1 or InventoryType.SaddleBag2 - ? InventoryType. SaddleBag1 - : InventoryType.PremiumSaddleBag1, - _ when inventoryType.IsRetainer => InventoryType.RetainerPage1, - _ => inventoryType, - }; - - InventoryType entryContainer = baseType + entry->Page; - - if (entryContainer == inventoryType && entry->Slot == realSlot) - { - // Found it! Return visual index relative to the container's start - return visualIdx - startIndex; - } - } - - return realSlot; // Fallback + return new InventoryLocation(realContainer, realSlot); } } } \ No newline at end of file diff --git a/AetherBags/Helpers/InventoryMoveHelper.cs b/AetherBags/Helpers/InventoryMoveHelper.cs index 18ef92a..1763765 100644 --- a/AetherBags/Helpers/InventoryMoveHelper.cs +++ b/AetherBags/Helpers/InventoryMoveHelper.cs @@ -8,24 +8,16 @@ namespace AetherBags. Helpers; public static unsafe class InventoryMoveHelper { + // Requires the visual UI slots instead of actual slots. public static void MoveItem(InventoryType sourceContainer, ushort sourceSlot, InventoryType destContainer, ushort destSlot) { Services.Logger.Debug($"[MoveItem] {sourceContainer}@{sourceSlot} -> {destContainer}@{destSlot}"); InventoryManager.Instance()->MoveItemSlot(sourceContainer, sourceSlot, destContainer, destSlot, true); - System.AddonInventoryWindow.ManualInventoryRefresh(); - return; - bool isCrossContainerMove = ! sourceContainer.IsSameContainerGroup(destContainer); - - if (isCrossContainerMove) - { - MoveItemViaAgent(sourceContainer, sourceSlot, destContainer, destSlot); - } - else - { - InventoryManager.Instance()->MoveItemSlot(sourceContainer, sourceSlot, destContainer, destSlot, true); - } + Services.Framework.DelayTicks(2); + Services.Framework.RunOnFrameworkThread(System.AddonInventoryWindow.ManualInventoryRefresh); } + /* private static void MoveItemViaAgent(InventoryType sourceInventory, ushort sourceSlot, InventoryType destInventory, ushort destSlot) { uint sourceContainerId = sourceInventory.AgentItemContainerId; @@ -53,4 +45,5 @@ public static unsafe class InventoryMoveHelper RaptureAtkModule* atkModule = RaptureAtkModule.Instance(); atkModule->HandleItemMove(retVal, atkValues, 4); } + */ } \ No newline at end of file diff --git a/AetherBags/Inventory/InventoryLocation.cs b/AetherBags/Inventory/InventoryLocation.cs new file mode 100644 index 0000000..13b36d1 --- /dev/null +++ b/AetherBags/Inventory/InventoryLocation.cs @@ -0,0 +1,12 @@ +using FFXIVClientStructs.FFXIV.Client.Game; + +namespace AetherBags.Inventory; + +public readonly record struct InventoryLocation(InventoryType Container, ushort Slot) +{ + public static readonly InventoryLocation Invalid = new(0, 0); + + public bool IsValid => Container != 0; + + public override string ToString() => $"{Container}@{Slot}"; +} \ No newline at end of file diff --git a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs index af2a4f6..bd332bb 100644 --- a/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs +++ b/AetherBags/Nodes/Inventory/InventoryCategoryNode.cs @@ -243,65 +243,24 @@ public class InventoryCategoryNode : SimpleComponentNode }; } - private void OnPayloadAccepted(DragDropNode node, DragDropPayload payload, ItemInfo targetItemInfo) + private void OnPayloadAccepted(DragDropNode _, DragDropPayload payload, ItemInfo targetItemInfo) { - if (payload.Type != DragDropType.Item && payload.Type != DragDropType.Inventory_Item) + Services.Logger.Debug($"[OnPayload] Received payload of type {payload.Type}, Int1={payload.Int1}, Int2={payload.Int2}, RefIndex={payload.ReferenceIndex}, Text={payload.Text}"); + if (!payload.IsValidInventoryPayload) return; - Services.Logger.Debug($"[OnPayload] Received payload of type {payload.Type}, Int1={payload.Int1}, Int2={payload.Int2}, RefIndex={payload.ReferenceIndex}, Text={payload.Text}"); - var (sourceContainer, sourceSlot) = ResolveSourceFromPayload(payload); + InventoryLocation sourceLocation = payload.InventoryLocation; - if (sourceContainer == 0) + if (!sourceLocation.IsValid) { Services.Logger.Warning($"[OnPayload] Could not resolve source from payload"); return; } - InventoryType targetContainer = targetItemInfo.Item.Container; - ushort targetSlot = (ushort)targetItemInfo.Item.Slot; + InventoryLocation targetLocation = new InventoryLocation(targetItemInfo.Item.Container, (ushort)targetItemInfo.Item.Slot); - Services.Logger.Debug($"[OnPayload] Moving {sourceContainer}@{sourceSlot} -> {targetContainer}@{targetSlot}"); + Services.Logger.Debug($"[OnPayload] Moving {sourceLocation.ToString()} -> {targetLocation.ToString()}"); - InventoryMoveHelper.MoveItem(sourceContainer, sourceSlot, targetContainer, targetSlot); - } - - private static (InventoryType Container, ushort Slot) ResolveSourceFromPayload(DragDropPayload payload) - { - if (payload.Type == DragDropType.Inventory_Item) - { - return ((InventoryType)payload.Int1, (ushort)payload.Int2); - } - - int containerId = payload.Int1; - int uiSlot = payload.Int2; - - InventoryType sourceContainer = InventoryType.GetInventoryTypeFromContainerId(containerId); - - if (sourceContainer == 0) - return (0, 0); - - // Retainers have special handling: UI has 5 tabs × 35 slots, data has 7 pages × 25 slots - if (sourceContainer. IsRetainer) - { - // Container IDs 52-56 = UI tabs 0-4 - int uiTabIndex = containerId - 52; - - // Convert to global data index - int globalDataIndex = (uiTabIndex * 35) + uiSlot; - - // Calculate data page and slot - int dataPage = globalDataIndex / 25; - int dataSlot = globalDataIndex % 25; - - InventoryType dataContainer = InventoryType.RetainerPage1 + (uint)dataPage; - - // Now resolve through sorter for the actual storage location - var (realContainer, realSlot) = dataContainer.GetRealItemLocation(dataSlot); - return (realContainer, realSlot); - } - - // For non-retainers, use the standard resolution - var (realContainerOther, realSlotOther) = sourceContainer.GetRealItemLocation(uiSlot); - return (realContainerOther, realSlotOther); + InventoryMoveHelper.MoveItem(sourceLocation.Container, sourceLocation.Slot, targetLocation.Container, targetLocation.Slot); } } \ No newline at end of file diff --git a/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs b/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs new file mode 100644 index 0000000..739a42b --- /dev/null +++ b/AetherBags/Nodes/Inventory/InventoryNotificationNode.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using System.Numerics; +using FFXIVClientStructs.FFXIV.Component.GUI; +using KamiToolKit; +using KamiToolKit.Classes; +using KamiToolKit.Classes.Timelines; +using KamiToolKit.Nodes; +using Lumina.Excel; +using Lumina.Excel.Sheets; +using Lumina.Text; +using Lumina.Text.ReadOnly; + +namespace AetherBags.Nodes.Inventory; + +public sealed class InventoryNotificationNode : SimpleComponentNode +{ + private readonly SimpleNineGridNode glowNode; + private readonly TextNode titleTextNode; + private readonly TextNode messageTextNode; + + private Dictionary notificationCache = null!; + + public InventoryNotificationNode() + { + PopulateNotificationCache(); + + AddTimeline(ParentLabels); + + glowNode = new SimpleNineGridNode { + TexturePath = "ui/uld/Inventory.tex", + TextureSize = new Vector2(56.0f, 56.0f), + TextureCoordinates = new Vector2(88.0f, 0.0f), + TopOffset = 10, + BottomOffset = 10, + LeftOffset = 26, + RightOffset = 26, + }; + glowNode.AttachNode(this); + glowNode.AddTimeline(GlowKeyFrames); + + titleTextNode = new TextNode + { + Position = new Vector2(0, 10f), + FontType = FontType.MiedingerMed, + FontSize = 18, + TextColor = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(37), + TextFlags = TextFlags.Edge, + AlignmentType = AlignmentType.Center, + }; + titleTextNode.AttachNode(this); + titleTextNode.AddTimeline(TextKeyFrames); + + messageTextNode = new TextNode + { + Position = new Vector2(0, -10f), + FontType = FontType.Axis, + FontSize = 14, + TextColor = ColorHelper.GetColor(50), + TextOutlineColor = ColorHelper.GetColor(37), + TextFlags = TextFlags.Edge, + AlignmentType = AlignmentType.Center, + }; + messageTextNode.AttachNode(this); + messageTextNode.AddTimeline(TextKeyFrames); + + Timeline?.PlayAnimation(17); + } + + protected override void OnSizeChanged() + { + base.OnSizeChanged(); + + glowNode.Size = Size with { Y = 40 }; + titleTextNode.Size = Size with { Y = 20 }; + messageTextNode.Size = Size with { Y = 16 }; + } + + private void PopulateNotificationCache() + { + ExcelSheet addonSheet = Services.DataManager.GetExcelSheet(); + notificationCache = new Dictionary + { + { + InventoryNotificationType.SaddleBag, + new InventoryNotificationInfo(addonSheet.GetRow(891).Text, addonSheet.GetRow(892).Text) + }, + { + InventoryNotificationType.RetainerEntrust, + new InventoryNotificationInfo(addonSheet.GetRow(910).Text, addonSheet.GetRow(3573).Text) + }, + { + InventoryNotificationType.RetainerEquip, + new InventoryNotificationInfo(addonSheet.GetRow(910).Text, addonSheet.GetRow(3585).Text) + } + }; + } + + public InventoryNotificationType NotificationType + { + get; + set + { + field = value; + if (value == InventoryNotificationType.None) + { + titleTextNode.String = string.Empty; + messageTextNode.String = string.Empty; + Timeline?.PlayAnimation(17); // Hide + } + else if (notificationCache.TryGetValue(value, out var texts)) + { + titleTextNode.SeString = texts.Title; + messageTextNode.SeString = texts.Message; + Timeline?.PlayAnimation(101); // Show + } + } + } = InventoryNotificationType.None; + + // Future Zeff, this always goes on a parent + private Timeline ParentLabels => new TimelineBuilder() + .BeginFrameSet(1, 59) + .AddLabel(1, 17, AtkTimelineJumpBehavior.PlayOnce, 0) + .AddLabel(10, 101, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(25, 102, AtkTimelineJumpBehavior.Start, 0) + .AddLabel(59, 0, AtkTimelineJumpBehavior.LoopForever, 102) + .EndFrameSet() + .Build(); + + // Future Zeff, this always goes on a child + private Timeline GlowKeyFrames => new TimelineBuilder().BeginFrameSet(15, 59) + .AddFrame(10, scale: new Vector2(1.4f, 1.0f), alpha: 0, addColor: new Vector3(128, 128, 128)) + .AddFrame(15, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(128, 128, 128)) + .AddFrame(21, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0)) + .AddFrame(40, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0)) + .AddFrame(46, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(10, 10, 10)) + .AddFrame(59, scale: new Vector2(1.0f, 1.0f), alpha: 255, addColor: new Vector3(0, 0, 0)) + .EndFrameSet() + .Build(); + + // Future Zeff, this always goes on a child + private Timeline TextKeyFrames => new TimelineBuilder().BeginFrameSet(15, 59) + .AddFrame(15, alpha: 0, addColor: new Vector3(128, 128, 128)) + .AddFrame(18, alpha: 255, addColor: new Vector3(64, 64, 64)) + .AddFrame(25, alpha: 255, addColor: new Vector3(0, 0, 0)) + .AddFrame(40, alpha: 255, addColor: new Vector3(0, 0, 0)) + .AddFrame(46, alpha: 255, addColor: new Vector3(64, 64, 64)) + .AddFrame(59, alpha: 255, addColor: new Vector3(0, 0, 0)) + .EndFrameSet() + .Build(); + + + private record InventoryNotificationInfo(ReadOnlySeString Title, ReadOnlySeString Message); +} + +public enum InventoryNotificationType +{ + None, + SaddleBag, + RetainerEntrust, + RetainerEquip +}