diff --git a/Dalamud/Game/Inventory/GameInventory.cs b/Dalamud/Game/Inventory/GameInventory.cs
new file mode 100644
index 000000000..1c7f3e3bf
--- /dev/null
+++ b/Dalamud/Game/Inventory/GameInventory.cs
@@ -0,0 +1,547 @@
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+
+using Dalamud.Game.Inventory.InventoryEventArgTypes;
+using Dalamud.Hooking;
+using Dalamud.IoC;
+using Dalamud.IoC.Internal;
+using Dalamud.Logging.Internal;
+using Dalamud.Plugin.Internal;
+using Dalamud.Plugin.Services;
+
+using FFXIVClientStructs.FFXIV.Client.UI;
+
+namespace Dalamud.Game.Inventory;
+
+///
+/// This class provides events for the players in-game inventory.
+///
+[InterfaceVersion("1.0")]
+[ServiceManager.BlockingEarlyLoadedService]
+internal class GameInventory : IDisposable, IServiceType
+{
+ private readonly List subscribersPendingChange = new();
+ private readonly List subscribers = new();
+
+ private readonly List addedEvents = new();
+ private readonly List removedEvents = new();
+ private readonly List changedEvents = new();
+ private readonly List movedEvents = new();
+ private readonly List splitEvents = new();
+ private readonly List mergedEvents = new();
+
+ [ServiceManager.ServiceDependency]
+ private readonly Framework framework = Service.Get();
+
+ private readonly Hook raptureAtkModuleUpdateHook;
+
+ private readonly GameInventoryType[] inventoryTypes;
+ private readonly GameInventoryItem[]?[] inventoryItems;
+
+ private bool subscribersChanged;
+ private bool inventoriesMightBeChanged;
+
+ [ServiceManager.ServiceConstructor]
+ private GameInventory()
+ {
+ this.inventoryTypes = Enum.GetValues();
+ this.inventoryItems = new GameInventoryItem[this.inventoryTypes.Length][];
+
+ unsafe
+ {
+ this.raptureAtkModuleUpdateHook = Hook.FromFunctionPointerVariable(
+ new(&((RaptureAtkModule.RaptureAtkModuleVTable*)RaptureAtkModule.StaticAddressPointers.VTable)->Update),
+ this.RaptureAtkModuleUpdateDetour);
+ }
+
+ this.raptureAtkModuleUpdateHook.Enable();
+ }
+
+ private unsafe delegate void RaptureAtkModuleUpdateDelegate(RaptureAtkModule* ram, float f1);
+
+ ///
+ public void Dispose()
+ {
+ lock (this.subscribersPendingChange)
+ {
+ this.subscribers.Clear();
+ this.subscribersPendingChange.Clear();
+ this.subscribersChanged = false;
+ this.framework.Update -= this.OnFrameworkUpdate;
+ this.raptureAtkModuleUpdateHook.Dispose();
+ }
+ }
+
+ ///
+ /// Subscribe to events.
+ ///
+ /// The event target.
+ public void Subscribe(GameInventoryPluginScoped s)
+ {
+ lock (this.subscribersPendingChange)
+ {
+ this.subscribersPendingChange.Add(s);
+ this.subscribersChanged = true;
+ if (this.subscribersPendingChange.Count == 1)
+ {
+ this.inventoriesMightBeChanged = true;
+ this.framework.Update += this.OnFrameworkUpdate;
+ }
+ }
+ }
+
+ ///
+ /// Unsubscribe from events.
+ ///
+ /// The event target.
+ public void Unsubscribe(GameInventoryPluginScoped s)
+ {
+ lock (this.subscribersPendingChange)
+ {
+ if (!this.subscribersPendingChange.Remove(s))
+ return;
+ this.subscribersChanged = true;
+ if (this.subscribersPendingChange.Count == 0)
+ this.framework.Update -= this.OnFrameworkUpdate;
+ }
+ }
+
+ private void OnFrameworkUpdate(IFramework framework1)
+ {
+ if (!this.inventoriesMightBeChanged)
+ return;
+
+ this.inventoriesMightBeChanged = false;
+
+ for (var i = 0; i < this.inventoryTypes.Length; i++)
+ {
+ var newItems = GameInventoryItem.GetReadOnlySpanOfInventory(this.inventoryTypes[i]);
+ if (newItems.IsEmpty)
+ continue;
+
+ // Assumption: newItems is sorted by slots, and the last item has the highest slot number.
+ var oldItems = this.inventoryItems[i] ??= new GameInventoryItem[newItems[^1].InternalItem.Slot + 1];
+
+ foreach (ref readonly var newItem in newItems)
+ {
+ ref var oldItem = ref oldItems[newItem.InternalItem.Slot];
+
+ if (oldItem.IsEmpty)
+ {
+ if (!newItem.IsEmpty)
+ {
+ this.addedEvents.Add(new(newItem));
+ oldItem = newItem;
+ }
+ }
+ else
+ {
+ if (newItem.IsEmpty)
+ {
+ this.removedEvents.Add(new(oldItem));
+ oldItem = newItem;
+ }
+ else if (!oldItem.Equals(newItem))
+ {
+ this.changedEvents.Add(new(oldItem, newItem));
+ oldItem = newItem;
+ }
+ }
+ }
+ }
+
+ // Was there any change? If not, stop further processing.
+ // Note that only these three are checked; the rest will be populated after this check.
+ if (this.addedEvents.Count == 0 && this.removedEvents.Count == 0 && this.changedEvents.Count == 0)
+ return;
+
+ // Make a copy of subscribers, to accommodate self removal during the loop.
+ if (this.subscribersChanged)
+ {
+ bool isNew;
+ lock (this.subscribersPendingChange)
+ {
+ isNew = this.subscribersPendingChange.Any() && !this.subscribers.Any();
+ this.subscribers.Clear();
+ this.subscribers.AddRange(this.subscribersPendingChange);
+ this.subscribersChanged = false;
+ }
+
+ // Is this the first time (resuming) scanning for changes? Then discard the "changes".
+ if (isNew)
+ {
+ this.addedEvents.Clear();
+ this.removedEvents.Clear();
+ this.changedEvents.Clear();
+ return;
+ }
+ }
+
+ // Broadcast InventoryChangedRaw.
+ // Same reason with the above on why are there 3 lists of events involved.
+ var allRawEventsCollection = new DeferredReadOnlyCollection(
+ this.addedEvents.Count +
+ this.removedEvents.Count +
+ this.changedEvents.Count,
+ () => Array.Empty()
+ .Concat(this.addedEvents)
+ .Concat(this.removedEvents)
+ .Concat(this.changedEvents));
+ foreach (var s in this.subscribers)
+ s.InvokeChangedRaw(allRawEventsCollection);
+
+ // Resolve moved items, from 1 added + 1 removed event.
+ for (var iAdded = this.addedEvents.Count - 1; iAdded >= 0; --iAdded)
+ {
+ var added = this.addedEvents[iAdded];
+ for (var iRemoved = this.removedEvents.Count - 1; iRemoved >= 0; --iRemoved)
+ {
+ var removed = this.removedEvents[iRemoved];
+ if (added.Item.ItemId != removed.Item.ItemId)
+ continue;
+
+ this.movedEvents.Add(new(removed, added));
+
+ // Remove the reinterpreted entries.
+ this.addedEvents.RemoveAt(iAdded);
+ this.removedEvents.RemoveAt(iRemoved);
+ break;
+ }
+ }
+
+ // Resolve moved items, from 2 changed events.
+ for (var i = this.changedEvents.Count - 1; i >= 0; --i)
+ {
+ var e1 = this.changedEvents[i];
+ for (var j = i - 1; j >= 0; --j)
+ {
+ var e2 = this.changedEvents[j];
+ if (e1.Item.ItemId != e2.OldItemState.ItemId || e1.OldItemState.ItemId != e2.Item.ItemId)
+ continue;
+
+ // Move happened, and e2 has an item.
+ if (!e2.Item.IsEmpty)
+ this.movedEvents.Add(new(e1, e2));
+
+ // Move happened, and e1 has an item.
+ if (!e1.Item.IsEmpty)
+ this.movedEvents.Add(new(e2, e1));
+
+ // Remove the reinterpreted entries. Note that i > j.
+ this.changedEvents.RemoveAt(i);
+ this.changedEvents.RemoveAt(j);
+
+ // We've removed two. Adjust the outer counter.
+ --i;
+ break;
+ }
+ }
+
+ // Resolve split items, from 1 added + 1 changed event.
+ for (var iAdded = this.addedEvents.Count - 1; iAdded >= 0; --iAdded)
+ {
+ var added = this.addedEvents[iAdded];
+ for (var iChanged = this.changedEvents.Count - 1; iChanged >= 0; --iChanged)
+ {
+ var changed = this.changedEvents[iChanged];
+ if (added.Item.ItemId != changed.Item.ItemId || added.Item.ItemId != changed.OldItemState.ItemId)
+ continue;
+
+ this.splitEvents.Add(new(changed, added));
+
+ // Remove the reinterpreted entries.
+ this.addedEvents.RemoveAt(iAdded);
+ this.changedEvents.RemoveAt(iChanged);
+ break;
+ }
+ }
+
+ // Resolve merged items, from 1 removed + 1 changed event.
+ for (var iRemoved = this.removedEvents.Count - 1; iRemoved >= 0; --iRemoved)
+ {
+ var removed = this.removedEvents[iRemoved];
+ for (var iChanged = this.changedEvents.Count - 1; iChanged >= 0; --iChanged)
+ {
+ var changed = this.changedEvents[iChanged];
+ if (removed.Item.ItemId != changed.Item.ItemId || removed.Item.ItemId != changed.OldItemState.ItemId)
+ continue;
+
+ this.mergedEvents.Add(new(removed, changed));
+
+ // Remove the reinterpreted entries.
+ this.removedEvents.RemoveAt(iRemoved);
+ this.changedEvents.RemoveAt(iChanged);
+ break;
+ }
+ }
+
+ // Create a collection view of all events.
+ var allEventsCollection = new DeferredReadOnlyCollection(
+ this.addedEvents.Count +
+ this.removedEvents.Count +
+ this.changedEvents.Count +
+ this.movedEvents.Count +
+ this.splitEvents.Count +
+ this.mergedEvents.Count,
+ () => Array.Empty()
+ .Concat(this.addedEvents)
+ .Concat(this.removedEvents)
+ .Concat(this.changedEvents)
+ .Concat(this.movedEvents)
+ .Concat(this.splitEvents)
+ .Concat(this.mergedEvents));
+
+ // Broadcast the rest.
+ foreach (var s in this.subscribers)
+ {
+ s.InvokeChanged(allEventsCollection);
+ s.Invoke(this.addedEvents);
+ s.Invoke(this.removedEvents);
+ s.Invoke(this.changedEvents);
+ s.Invoke(this.movedEvents);
+ s.Invoke(this.splitEvents);
+ s.Invoke(this.mergedEvents);
+ }
+
+ // We're done using the lists. Clean them up.
+ this.addedEvents.Clear();
+ this.removedEvents.Clear();
+ this.changedEvents.Clear();
+ this.movedEvents.Clear();
+ this.splitEvents.Clear();
+ this.mergedEvents.Clear();
+ }
+
+ private unsafe void RaptureAtkModuleUpdateDetour(RaptureAtkModule* ram, float f1)
+ {
+ this.inventoriesMightBeChanged |= ram->AgentUpdateFlag != 0;
+ this.raptureAtkModuleUpdateHook.Original(ram, f1);
+ }
+
+ ///
+ /// A view of , so that the number of items
+ /// contained within can be known in advance, and it can be enumerated multiple times.
+ ///
+ /// The type of elements being enumerated.
+ private class DeferredReadOnlyCollection : IReadOnlyCollection
+ {
+ private readonly Func> enumerableGenerator;
+
+ public DeferredReadOnlyCollection(int count, Func> enumerableGenerator)
+ {
+ this.enumerableGenerator = enumerableGenerator;
+ this.Count = count;
+ }
+
+ public int Count { get; }
+
+ public IEnumerator GetEnumerator() => this.enumerableGenerator().GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => this.enumerableGenerator().GetEnumerator();
+ }
+}
+
+///
+/// Plugin-scoped version of a GameInventory service.
+///
+[PluginInterface]
+[InterfaceVersion("1.0")]
+[ServiceManager.ScopedService]
+#pragma warning disable SA1015
+[ResolveVia]
+#pragma warning restore SA1015
+internal class GameInventoryPluginScoped : IDisposable, IServiceType, IGameInventory
+{
+ private static readonly ModuleLog Log = new(nameof(GameInventoryPluginScoped));
+
+ [ServiceManager.ServiceDependency]
+ private readonly GameInventory gameInventoryService = Service.Get();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public GameInventoryPluginScoped() => this.gameInventoryService.Subscribe(this);
+
+ ///
+ public event IGameInventory.InventoryChangelogDelegate? InventoryChanged;
+
+ ///
+ public event IGameInventory.InventoryChangelogDelegate? InventoryChangedRaw;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemAdded;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemRemoved;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemChanged;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemMoved;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemSplit;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemMerged;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemAddedExplicit;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemRemovedExplicit;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemChangedExplicit;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemMovedExplicit;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemSplitExplicit;
+
+ ///
+ public event IGameInventory.InventoryChangedDelegate? ItemMergedExplicit;
+
+ ///
+ public void Dispose()
+ {
+ this.gameInventoryService.Unsubscribe(this);
+
+ this.InventoryChanged = null;
+ this.InventoryChangedRaw = null;
+ this.ItemAdded = null;
+ this.ItemRemoved = null;
+ this.ItemChanged = null;
+ this.ItemMoved = null;
+ this.ItemSplit = null;
+ this.ItemMerged = null;
+ this.ItemAddedExplicit = null;
+ this.ItemRemovedExplicit = null;
+ this.ItemChangedExplicit = null;
+ this.ItemMovedExplicit = null;
+ this.ItemSplitExplicit = null;
+ this.ItemMergedExplicit = null;
+ }
+
+ ///
+ /// Invoke .
+ ///
+ /// The data.
+ internal void InvokeChanged(IReadOnlyCollection data)
+ {
+ try
+ {
+ this.InventoryChanged?.Invoke(data);
+ }
+ catch (Exception e)
+ {
+ Log.Error(
+ e,
+ "[{plugin}] Exception during {argType} callback",
+ Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)",
+ nameof(this.InventoryChanged));
+ }
+ }
+
+ ///
+ /// Invoke .
+ ///
+ /// The data.
+ internal void InvokeChangedRaw(IReadOnlyCollection data)
+ {
+ try
+ {
+ this.InventoryChangedRaw?.Invoke(data);
+ }
+ catch (Exception e)
+ {
+ Log.Error(
+ e,
+ "[{plugin}] Exception during {argType} callback",
+ Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)",
+ nameof(this.InventoryChangedRaw));
+ }
+ }
+
+ // Note below: using List instead of IEnumerable, since List has a specialized lightweight enumerator.
+
+ ///
+ /// Invoke the appropriate event handler.
+ ///
+ /// The data.
+ internal void Invoke(List events) =>
+ Invoke(this.ItemAdded, this.ItemAddedExplicit, events);
+
+ ///
+ /// Invoke the appropriate event handler.
+ ///
+ /// The data.
+ internal void Invoke(List events) =>
+ Invoke(this.ItemRemoved, this.ItemRemovedExplicit, events);
+
+ ///
+ /// Invoke the appropriate event handler.
+ ///
+ /// The data.
+ internal void Invoke(List events) =>
+ Invoke(this.ItemChanged, this.ItemChangedExplicit, events);
+
+ ///
+ /// Invoke the appropriate event handler.
+ ///
+ /// The data.
+ internal void Invoke(List events) =>
+ Invoke(this.ItemMoved, this.ItemMovedExplicit, events);
+
+ ///
+ /// Invoke the appropriate event handler.
+ ///
+ /// The data.
+ internal void Invoke(List events) =>
+ Invoke(this.ItemSplit, this.ItemSplitExplicit, events);
+
+ ///
+ /// Invoke the appropriate event handler.
+ ///
+ /// The data.
+ internal void Invoke(List events) =>
+ Invoke(this.ItemMerged, this.ItemMergedExplicit, events);
+
+ private static void Invoke(
+ IGameInventory.InventoryChangedDelegate? cb,
+ IGameInventory.InventoryChangedDelegate? cbt,
+ List events) where T : InventoryEventArgs
+ {
+ foreach (var evt in events)
+ {
+ try
+ {
+ cb?.Invoke(evt.Type, evt);
+ }
+ catch (Exception e)
+ {
+ Log.Error(
+ e,
+ "[{plugin}] Exception during untyped callback for {evt}",
+ Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)",
+ evt);
+ }
+
+ try
+ {
+ cbt?.Invoke(evt);
+ }
+ catch (Exception e)
+ {
+ Log.Error(
+ e,
+ "[{plugin}] Exception during typed callback for {evt}",
+ Service.GetNullable()?.FindCallingPlugin(new(e))?.Name ?? "(unknown plugin)",
+ evt);
+ }
+ }
+ }
+}
diff --git a/Dalamud/Game/Inventory/GameInventoryEvent.cs b/Dalamud/Game/Inventory/GameInventoryEvent.cs
new file mode 100644
index 000000000..16efab648
--- /dev/null
+++ b/Dalamud/Game/Inventory/GameInventoryEvent.cs
@@ -0,0 +1,43 @@
+namespace Dalamud.Game.Inventory;
+
+///
+/// Class representing a item's changelog state.
+///
+public enum GameInventoryEvent
+{
+ ///
+ /// A value indicating that there was no event.
+ /// You should not see this value, unless you explicitly used it yourself, or APIs using this enum say otherwise.
+ ///
+ Empty = 0,
+
+ ///
+ /// Item was added to an inventory.
+ ///
+ Added = 1,
+
+ ///
+ /// Item was removed from an inventory.
+ ///
+ Removed = 2,
+
+ ///
+ /// Properties are changed for an item in an inventory.
+ ///
+ Changed = 3,
+
+ ///
+ /// Item has been moved, possibly across different inventories.
+ ///
+ Moved = 4,
+
+ ///
+ /// Item has been split into two stacks from one, possibly across different inventories.
+ ///
+ Split = 5,
+
+ ///
+ /// Item has been merged into one stack from two, possibly across different inventories.
+ ///
+ Merged = 6,
+}
diff --git a/Dalamud/Game/Inventory/GameInventoryItem.cs b/Dalamud/Game/Inventory/GameInventoryItem.cs
new file mode 100644
index 000000000..912b91f53
--- /dev/null
+++ b/Dalamud/Game/Inventory/GameInventoryItem.cs
@@ -0,0 +1,203 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+using FFXIVClientStructs.FFXIV.Client.Game;
+
+namespace Dalamud.Game.Inventory;
+
+///
+/// Dalamud wrapper around a ClientStructs InventoryItem.
+///
+[StructLayout(LayoutKind.Explicit, Size = StructSizeInBytes)]
+public unsafe struct GameInventoryItem : IEquatable
+{
+ ///
+ /// The actual data.
+ ///
+ [FieldOffset(0)]
+ internal readonly InventoryItem InternalItem;
+
+ private const int StructSizeInBytes = 0x38;
+
+ ///
+ /// The view of the backing data, in .
+ ///
+ [FieldOffset(0)]
+ private fixed ulong dataUInt64[StructSizeInBytes / 0x8];
+
+ static GameInventoryItem()
+ {
+ Debug.Assert(
+ sizeof(InventoryItem) == StructSizeInBytes,
+ $"Definition of {nameof(InventoryItem)} has been changed. " +
+ $"Update {nameof(StructSizeInBytes)} to {sizeof(InventoryItem)} to accommodate for the size change.");
+ }
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// Inventory item to wrap.
+ internal GameInventoryItem(InventoryItem item) => this.InternalItem = item;
+
+ ///
+ /// Gets a value indicating whether the this is empty.
+ ///
+ public bool IsEmpty => this.InternalItem.ItemID == 0;
+
+ ///
+ /// Gets the container inventory type.
+ ///
+ public GameInventoryType ContainerType => (GameInventoryType)this.InternalItem.Container;
+
+ ///
+ /// Gets the inventory slot index this item is in.
+ ///
+ public uint InventorySlot => (uint)this.InternalItem.Slot;
+
+ ///
+ /// Gets the item id.
+ ///
+ public uint ItemId => this.InternalItem.ItemID;
+
+ ///
+ /// Gets the quantity of items in this item stack.
+ ///
+ public uint Quantity => this.InternalItem.Quantity;
+
+ ///
+ /// Gets the spiritbond of this item.
+ ///
+ public uint Spiritbond => this.InternalItem.Spiritbond;
+
+ ///
+ /// Gets the repair condition of this item.
+ ///
+ public uint Condition => this.InternalItem.Condition;
+
+ ///
+ /// Gets a value indicating whether the item is High Quality.
+ ///
+ public bool IsHq => (this.InternalItem.Flags & InventoryItem.ItemFlags.HQ) != 0;
+
+ ///
+ /// Gets a value indicating whether the item has a company crest applied.
+ ///
+ public bool IsCompanyCrestApplied => (this.InternalItem.Flags & InventoryItem.ItemFlags.CompanyCrestApplied) != 0;
+
+ ///
+ /// Gets a value indicating whether the item is a relic.
+ ///
+ public bool IsRelic => (this.InternalItem.Flags & InventoryItem.ItemFlags.Relic) != 0;
+
+ ///
+ /// Gets a value indicating whether the is a collectable.
+ ///
+ public bool IsCollectable => (this.InternalItem.Flags & InventoryItem.ItemFlags.Collectable) != 0;
+
+ ///
+ /// Gets the array of materia types.
+ ///
+ public ReadOnlySpan Materia => new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.Materia[0])), 5);
+
+ ///
+ /// Gets the array of materia grades.
+ ///
+ public ReadOnlySpan MateriaGrade =>
+ new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.MateriaGrade[0])), 5);
+
+ ///
+ /// Gets the address of native inventory item in the game.
+ /// Can be 0 if this instance of does not point to a valid set of container type and slot.
+ /// Note that this instance of can be a snapshot; it may not necessarily match the
+ /// data you can query from the game using this address value.
+ ///
+ public nint Address
+ {
+ get
+ {
+ var s = GetReadOnlySpanOfInventory(this.ContainerType);
+ if (s.IsEmpty)
+ return 0;
+
+ foreach (ref readonly var i in s)
+ {
+ if (i.InventorySlot == this.InventorySlot)
+ return (nint)Unsafe.AsPointer(ref Unsafe.AsRef(in i));
+ }
+
+ return 0;
+ }
+ }
+
+ ///
+ /// Gets the color used for this item.
+ ///
+ public byte Stain => this.InternalItem.Stain;
+
+ ///
+ /// Gets the glamour id for this item.
+ ///
+ public uint GlamourId => this.InternalItem.GlamourID;
+
+ ///
+ /// Gets the items crafter's content id.
+ /// NOTE: I'm not sure if this is a good idea to include or not in the dalamud api. Marked internal for now.
+ ///
+ internal ulong CrafterContentId => this.InternalItem.CrafterContentID;
+
+ public static bool operator ==(in GameInventoryItem l, in GameInventoryItem r) => l.Equals(r);
+
+ public static bool operator !=(in GameInventoryItem l, in GameInventoryItem r) => !l.Equals(r);
+
+ ///
+ readonly bool IEquatable.Equals(GameInventoryItem other) => this.Equals(other);
+
+ /// Indicates whether the current object is equal to another object of the same type.
+ /// An object to compare with this object.
+ /// true if the current object is equal to the parameter; otherwise, false.
+ public readonly bool Equals(in GameInventoryItem other)
+ {
+ for (var i = 0; i < StructSizeInBytes / 8; i++)
+ {
+ if (this.dataUInt64[i] != other.dataUInt64[i])
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ public override bool Equals(object obj) => obj is GameInventoryItem gii && this.Equals(gii);
+
+ ///
+ public override int GetHashCode()
+ {
+ var k = 0x5a8447b91aff51b4UL;
+ for (var i = 0; i < StructSizeInBytes / 8; i++)
+ k ^= this.dataUInt64[i];
+ return unchecked((int)(k ^ (k >> 32)));
+ }
+
+ ///
+ public override string ToString() =>
+ this.IsEmpty
+ ? "empty"
+ : $"item({this.ItemId}@{this.ContainerType}#{this.InventorySlot})";
+
+ ///
+ /// Gets a view of s, wrapped as .
+ ///
+ /// The inventory type.
+ /// The span.
+ internal static ReadOnlySpan GetReadOnlySpanOfInventory(GameInventoryType type)
+ {
+ var inventoryManager = InventoryManager.Instance();
+ if (inventoryManager is null) return default;
+
+ var inventory = inventoryManager->GetInventoryContainer((InventoryType)type);
+ if (inventory is null) return default;
+
+ return new ReadOnlySpan(inventory->Items, (int)inventory->Size);
+ }
+}
diff --git a/Dalamud/Game/Inventory/GameInventoryType.cs b/Dalamud/Game/Inventory/GameInventoryType.cs
new file mode 100644
index 000000000..00c65046f
--- /dev/null
+++ b/Dalamud/Game/Inventory/GameInventoryType.cs
@@ -0,0 +1,356 @@
+namespace Dalamud.Game.Inventory;
+
+///
+/// Enum representing various player inventories.
+///
+public enum GameInventoryType : ushort
+{
+ ///
+ /// First panel of main player inventory.
+ ///
+ Inventory1 = 0,
+
+ ///
+ /// Second panel of main player inventory.
+ ///
+ Inventory2 = 1,
+
+ ///
+ /// Third panel of main player inventory.
+ ///
+ Inventory3 = 2,
+
+ ///
+ /// Fourth panel of main player inventory.
+ ///
+ Inventory4 = 3,
+
+ ///
+ /// Items that are currently equipped by the player.
+ ///
+ EquippedItems = 1000,
+
+ ///
+ /// Player currency container.
+ /// ie, gil, serpent seals, sacks of nuts.
+ ///
+ Currency = 2000,
+
+ ///
+ /// Crystal container.
+ ///
+ Crystals = 2001,
+
+ ///
+ /// Mail container.
+ ///
+ Mail = 2003,
+
+ ///
+ /// Key item container.
+ ///
+ KeyItems = 2004,
+
+ ///
+ /// Quest item hand-in inventory.
+ ///
+ HandIn = 2005,
+
+ ///
+ /// DamagedGear container.
+ ///
+ DamagedGear = 2007,
+
+ ///
+ /// Examine window container.
+ ///
+ Examine = 2009,
+
+ ///
+ /// Doman Enclave Reconstruction Reclamation Box.
+ ///
+ ReconstructionBuyback = 2013,
+
+ ///
+ /// Armory off-hand weapon container.
+ ///
+ ArmoryOffHand = 3200,
+
+ ///
+ /// Armory head container.
+ ///
+ ArmoryHead = 3201,
+
+ ///
+ /// Armory body container.
+ ///
+ ArmoryBody = 3202,
+
+ ///
+ /// Armory hand/gloves container.
+ ///
+ ArmoryHands = 3203,
+
+ ///
+ /// Armory waist container.
+ ///
+ /// This container should be unused as belt items were removed from the game in Shadowbringers.
+ ///
+ ///
+ ArmoryWaist = 3204,
+
+ ///
+ /// Armory legs/pants/skirt container.
+ ///
+ ArmoryLegs = 3205,
+
+ ///
+ /// Armory feet/boots/shoes container.
+ ///
+ ArmoryFeets = 3206,
+
+ ///
+ /// Armory earring container.
+ ///
+ ArmoryEar = 3207,
+
+ ///
+ /// Armory necklace container.
+ ///
+ ArmoryNeck = 3208,
+
+ ///
+ /// Armory bracelet container.
+ ///
+ ArmoryWrist = 3209,
+
+ ///
+ /// Armory ring container.
+ ///
+ ArmoryRings = 3300,
+
+ ///
+ /// Armory soul crystal container.
+ ///
+ ArmorySoulCrystal = 3400,
+
+ ///
+ /// Armory main-hand weapon container.
+ ///
+ ArmoryMainHand = 3500,
+
+ ///
+ /// First panel of saddelbag inventory.
+ ///
+ SaddleBag1 = 4000,
+
+ ///
+ /// Second panel of Saddlebag inventory.
+ ///
+ SaddleBag2 = 4001,
+
+ ///
+ /// First panel of premium saddlebag inventory.
+ ///
+ PremiumSaddleBag1 = 4100,
+
+ ///
+ /// Second panel of premium saddlebag inventory.
+ ///
+ PremiumSaddleBag2 = 4101,
+
+ ///
+ /// First panel of retainer inventory.
+ ///
+ RetainerPage1 = 10000,
+
+ ///
+ /// Second panel of retainer inventory.
+ ///
+ RetainerPage2 = 10001,
+
+ ///
+ /// Third panel of retainer inventory.
+ ///
+ RetainerPage3 = 10002,
+
+ ///
+ /// Fourth panel of retainer inventory.
+ ///
+ RetainerPage4 = 10003,
+
+ ///
+ /// Fifth panel of retainer inventory.
+ ///
+ RetainerPage5 = 10004,
+
+ ///
+ /// Sixth panel of retainer inventory.
+ ///
+ RetainerPage6 = 10005,
+
+ ///
+ /// Seventh panel of retainer inventory.
+ ///
+ RetainerPage7 = 10006,
+
+ ///
+ /// Retainer equipment container.
+ ///
+ RetainerEquippedItems = 11000,
+
+ ///
+ /// Retainer currency container.
+ ///
+ RetainerGil = 12000,
+
+ ///
+ /// Retainer crystal container.
+ ///
+ RetainerCrystals = 12001,
+
+ ///
+ /// Retainer market item container.
+ ///
+ RetainerMarket = 12002,
+
+ ///
+ /// First panel of Free Company inventory.
+ ///
+ FreeCompanyPage1 = 20000,
+
+ ///
+ /// Second panel of Free Company inventory.
+ ///
+ FreeCompanyPage2 = 20001,
+
+ ///
+ /// Third panel of Free Company inventory.
+ ///
+ FreeCompanyPage3 = 20002,
+
+ ///
+ /// Fourth panel of Free Company inventory.
+ ///
+ FreeCompanyPage4 = 20003,
+
+ ///
+ /// Fifth panel of Free Company inventory.
+ ///
+ FreeCompanyPage5 = 20004,
+
+ ///
+ /// Free Company currency container.
+ ///
+ FreeCompanyGil = 22000,
+
+ ///
+ /// Free Company crystal container.
+ ///
+ FreeCompanyCrystals = 22001,
+
+ ///
+ /// Housing exterior appearance container.
+ ///
+ HousingExteriorAppearance = 25000,
+
+ ///
+ /// Housing exterior placed items container.
+ ///
+ HousingExteriorPlacedItems = 25001,
+
+ ///
+ /// Housing interior appearance container.
+ ///
+ HousingInteriorAppearance = 25002,
+
+ ///
+ /// First panel of housing interior inventory.
+ ///
+ HousingInteriorPlacedItems1 = 25003,
+
+ ///
+ /// Second panel of housing interior inventory.
+ ///
+ HousingInteriorPlacedItems2 = 25004,
+
+ ///
+ /// Third panel of housing interior inventory.
+ ///
+ HousingInteriorPlacedItems3 = 25005,
+
+ ///
+ /// Fourth panel of housing interior inventory.
+ ///
+ HousingInteriorPlacedItems4 = 25006,
+
+ ///
+ /// Fifth panel of housing interior inventory.
+ ///
+ HousingInteriorPlacedItems5 = 25007,
+
+ ///
+ /// Sixth panel of housing interior inventory.
+ ///
+ HousingInteriorPlacedItems6 = 25008,
+
+ ///
+ /// Seventh panel of housing interior inventory.
+ ///
+ HousingInteriorPlacedItems7 = 25009,
+
+ ///
+ /// Eighth panel of housing interior inventory.
+ ///
+ HousingInteriorPlacedItems8 = 25010,
+
+ ///
+ /// Housing exterior storeroom inventory.
+ ///
+ HousingExteriorStoreroom = 27000,
+
+ ///
+ /// First panel of housing interior storeroom inventory.
+ ///
+ HousingInteriorStoreroom1 = 27001,
+
+ ///
+ /// Second panel of housing interior storeroom inventory.
+ ///
+ HousingInteriorStoreroom2 = 27002,
+
+ ///
+ /// Third panel of housing interior storeroom inventory.
+ ///
+ HousingInteriorStoreroom3 = 27003,
+
+ ///
+ /// Fourth panel of housing interior storeroom inventory.
+ ///
+ HousingInteriorStoreroom4 = 27004,
+
+ ///
+ /// Fifth panel of housing interior storeroom inventory.
+ ///
+ HousingInteriorStoreroom5 = 27005,
+
+ ///
+ /// Sixth panel of housing interior storeroom inventory.
+ ///
+ HousingInteriorStoreroom6 = 27006,
+
+ ///
+ /// Seventh panel of housing interior storeroom inventory.
+ ///
+ HousingInteriorStoreroom7 = 27007,
+
+ ///
+ /// Eighth panel of housing interior storeroom inventory.
+ ///
+ HousingInteriorStoreroom8 = 27008,
+
+ ///
+ /// An invalid value.
+ ///
+ Invalid = ushort.MaxValue,
+}
diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs
new file mode 100644
index 000000000..95d7e8238
--- /dev/null
+++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryComplexEventArgs.cs
@@ -0,0 +1,54 @@
+namespace Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+///
+/// Represents the data associated with an item being affected across different slots, possibly in different containers.
+///
+public abstract class InventoryComplexEventArgs : InventoryEventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Type of the event.
+ /// The item at before slot.
+ /// The item at after slot.
+ internal InventoryComplexEventArgs(
+ GameInventoryEvent type, InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent)
+ : base(type, targetEvent.Item)
+ {
+ this.SourceEvent = sourceEvent;
+ this.TargetEvent = targetEvent;
+ }
+
+ ///
+ /// Gets the inventory this item was at.
+ ///
+ public GameInventoryType SourceInventory => this.SourceEvent.Item.ContainerType;
+
+ ///
+ /// Gets the inventory this item now is.
+ ///
+ public GameInventoryType TargetInventory => this.Item.ContainerType;
+
+ ///
+ /// Gets the slot this item was at.
+ ///
+ public uint SourceSlot => this.SourceEvent.Item.InventorySlot;
+
+ ///
+ /// Gets the slot this item now is.
+ ///
+ public uint TargetSlot => this.Item.InventorySlot;
+
+ ///
+ /// Gets the associated source event.
+ ///
+ public InventoryEventArgs SourceEvent { get; }
+
+ ///
+ /// Gets the associated target event.
+ ///
+ public InventoryEventArgs TargetEvent { get; }
+
+ ///
+ public override string ToString() => $"{this.Type}({this.SourceEvent}, {this.TargetEvent})";
+}
diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs
new file mode 100644
index 000000000..198e0395b
--- /dev/null
+++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryEventArgs.cs
@@ -0,0 +1,37 @@
+namespace Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+///
+/// Abstract base class representing inventory changed events.
+///
+public abstract class InventoryEventArgs
+{
+ private readonly GameInventoryItem item;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Type of the event.
+ /// Item about the event.
+ protected InventoryEventArgs(GameInventoryEvent type, in GameInventoryItem item)
+ {
+ this.Type = type;
+ this.item = item;
+ }
+
+ ///
+ /// Gets the type of event for these args.
+ ///
+ public GameInventoryEvent Type { get; }
+
+ ///
+ /// Gets the item associated with this event.
+ /// This is a copy of the item data.
+ ///
+ // impl note: we return a ref readonly view, to avoid making copies every time this property is accessed.
+ // see: https://devblogs.microsoft.com/premier-developer/avoiding-struct-and-readonly-reference-performance-pitfalls-with-errorprone-net/
+ // "Consider using ref readonly locals and ref return for library code"
+ public ref readonly GameInventoryItem Item => ref this.item;
+
+ ///
+ public override string ToString() => $"{this.Type}({this.Item})";
+}
diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs
new file mode 100644
index 000000000..ceb64c6f9
--- /dev/null
+++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemAddedArgs.cs
@@ -0,0 +1,26 @@
+namespace Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+///
+/// Represents the data associated with an item being added to an inventory.
+///
+public sealed class InventoryItemAddedArgs : InventoryEventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The item.
+ internal InventoryItemAddedArgs(in GameInventoryItem item)
+ : base(GameInventoryEvent.Added, item)
+ {
+ }
+
+ ///
+ /// Gets the inventory this item was added to.
+ ///
+ public GameInventoryType Inventory => this.Item.ContainerType;
+
+ ///
+ /// Gets the slot this item was added to.
+ ///
+ public uint Slot => this.Item.InventorySlot;
+}
diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs
new file mode 100644
index 000000000..372418793
--- /dev/null
+++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemChangedArgs.cs
@@ -0,0 +1,38 @@
+namespace Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+///
+/// Represents the data associated with an items properties being changed.
+/// This also includes an items stack count changing.
+///
+public sealed class InventoryItemChangedArgs : InventoryEventArgs
+{
+ private readonly GameInventoryItem oldItemState;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The item before change.
+ /// The item after change.
+ internal InventoryItemChangedArgs(in GameInventoryItem oldItem, in GameInventoryItem newItem)
+ : base(GameInventoryEvent.Changed, newItem)
+ {
+ this.oldItemState = oldItem;
+ }
+
+ ///
+ /// Gets the inventory this item is in.
+ ///
+ public GameInventoryType Inventory => this.Item.ContainerType;
+
+ ///
+ /// Gets the inventory slot this item is in.
+ ///
+ public uint Slot => this.Item.InventorySlot;
+
+ ///
+ /// Gets the state of the item from before it was changed.
+ /// This is a copy of the item data.
+ ///
+ // impl note: see InventoryEventArgs.Item.
+ public ref readonly GameInventoryItem OldItemState => ref this.oldItemState;
+}
diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs
new file mode 100644
index 000000000..d7056356e
--- /dev/null
+++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMergedArgs.cs
@@ -0,0 +1,26 @@
+namespace Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+///
+/// Represents the data associated with an item being merged from two stacks into one.
+///
+public sealed class InventoryItemMergedArgs : InventoryComplexEventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The item at before slot.
+ /// The item at after slot.
+ internal InventoryItemMergedArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent)
+ : base(GameInventoryEvent.Merged, sourceEvent, targetEvent)
+ {
+ }
+
+ ///
+ public override string ToString() =>
+ this.TargetEvent is InventoryItemChangedArgs iica
+ ? $"{this.Type}(" +
+ $"item({this.Item.ItemId}), " +
+ $"{this.SourceInventory}#{this.SourceSlot}({this.SourceEvent.Item.Quantity} to 0), " +
+ $"{this.TargetInventory}#{this.TargetSlot}({iica.OldItemState.Quantity} to {iica.Item.Quantity}))"
+ : base.ToString();
+}
diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs
new file mode 100644
index 000000000..8d0bbca17
--- /dev/null
+++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemMovedArgs.cs
@@ -0,0 +1,21 @@
+namespace Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+///
+/// Represents the data associated with an item being moved from one inventory and added to another.
+///
+public sealed class InventoryItemMovedArgs : InventoryComplexEventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The item at before slot.
+ /// The item at after slot.
+ internal InventoryItemMovedArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent)
+ : base(GameInventoryEvent.Moved, sourceEvent, targetEvent)
+ {
+ }
+
+ ///
+ public override string ToString() =>
+ $"{this.Type}(item({this.Item.ItemId}) from {this.SourceInventory}#{this.SourceSlot} to {this.TargetInventory}#{this.TargetSlot})";
+}
diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs
new file mode 100644
index 000000000..5677e3cc4
--- /dev/null
+++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemRemovedArgs.cs
@@ -0,0 +1,26 @@
+namespace Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+///
+/// Represents the data associated with an item being removed from an inventory.
+///
+public sealed class InventoryItemRemovedArgs : InventoryEventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The item.
+ internal InventoryItemRemovedArgs(in GameInventoryItem item)
+ : base(GameInventoryEvent.Removed, item)
+ {
+ }
+
+ ///
+ /// Gets the inventory this item was removed from.
+ ///
+ public GameInventoryType Inventory => this.Item.ContainerType;
+
+ ///
+ /// Gets the slot this item was removed from.
+ ///
+ public uint Slot => this.Item.InventorySlot;
+}
diff --git a/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs
new file mode 100644
index 000000000..5f717cf60
--- /dev/null
+++ b/Dalamud/Game/Inventory/InventoryEventArgTypes/InventoryItemSplitArgs.cs
@@ -0,0 +1,26 @@
+namespace Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+///
+/// Represents the data associated with an item being split from one stack into two.
+///
+public sealed class InventoryItemSplitArgs : InventoryComplexEventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The item at before slot.
+ /// The item at after slot.
+ internal InventoryItemSplitArgs(InventoryEventArgs sourceEvent, InventoryEventArgs targetEvent)
+ : base(GameInventoryEvent.Split, sourceEvent, targetEvent)
+ {
+ }
+
+ ///
+ public override string ToString() =>
+ this.SourceEvent is InventoryItemChangedArgs iica
+ ? $"{this.Type}(" +
+ $"item({this.Item.ItemId}), " +
+ $"{this.SourceInventory}#{this.SourceSlot}({iica.OldItemState.Quantity} to {iica.Item.Quantity}), " +
+ $"{this.TargetInventory}#{this.TargetSlot}(0 to {this.Item.Quantity}))"
+ : base.ToString();
+}
diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs
index b285520d4..89dd153cc 100644
--- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs
+++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs
@@ -679,6 +679,9 @@ internal class ConsoleWindow : Window, IDisposable
private bool IsFilterApplicable(LogEntry entry)
{
+ if (this.regexError)
+ return false;
+
try
{
// If this entry is below a newly set minimum level, fail it
diff --git a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs
index e9d4152a5..20c3d6d01 100644
--- a/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs
+++ b/Dalamud/Interface/Internal/Windows/Data/DataWindow.cs
@@ -33,6 +33,7 @@ internal class DataWindow : Window
new FateTableWidget(),
new FlyTextWidget(),
new FontAwesomeTestWidget(),
+ new GameInventoryTestWidget(),
new GamepadWidget(),
new GaugeWidget(),
new HookWidget(),
diff --git a/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs
new file mode 100644
index 000000000..c19f56654
--- /dev/null
+++ b/Dalamud/Interface/Internal/Windows/Data/GameInventoryTestWidget.cs
@@ -0,0 +1,163 @@
+using System.Collections.Generic;
+
+using Dalamud.Configuration.Internal;
+using Dalamud.Game.Inventory;
+using Dalamud.Game.Inventory.InventoryEventArgTypes;
+using Dalamud.Interface.Colors;
+using Dalamud.Interface.Utility;
+using Dalamud.Interface.Utility.Raii;
+using Dalamud.Logging.Internal;
+
+using ImGuiNET;
+
+using Serilog.Events;
+
+namespace Dalamud.Interface.Internal.Windows.Data;
+
+///
+/// Tester for .
+///
+internal class GameInventoryTestWidget : IDataWindowWidget
+{
+ private static readonly ModuleLog Log = new(nameof(GameInventoryTestWidget));
+
+ private GameInventoryPluginScoped? scoped;
+ private bool standardEnabled;
+ private bool rawEnabled;
+
+ ///
+ public string[]? CommandShortcuts { get; init; } = { "gameinventorytest" };
+
+ ///
+ public string DisplayName { get; init; } = "GameInventory Test";
+
+ ///
+ public bool Ready { get; set; }
+
+ ///
+ public void Load() => this.Ready = true;
+
+ ///
+ public void Draw()
+ {
+ if (Service.Get().LogLevel > LogEventLevel.Information)
+ {
+ ImGuiHelpers.SafeTextColoredWrapped(
+ ImGuiColors.DalamudRed,
+ "Enable LogLevel=Information display to see the logs.");
+ }
+
+ using var table = ImRaii.Table(this.DisplayName, 3, ImGuiTableFlags.SizingFixedFit);
+ if (!table.Success)
+ return;
+
+ ImGui.TableNextColumn();
+ ImGui.TextUnformatted("Standard Logging");
+
+ ImGui.TableNextColumn();
+ using (ImRaii.Disabled(this.standardEnabled))
+ {
+ if (ImGui.Button("Enable##standard-enable") && !this.standardEnabled)
+ {
+ this.scoped ??= new();
+ this.scoped.InventoryChanged += ScopedOnInventoryChanged;
+ this.standardEnabled = true;
+ }
+ }
+
+ ImGui.TableNextColumn();
+ using (ImRaii.Disabled(!this.standardEnabled))
+ {
+ if (ImGui.Button("Disable##standard-disable") && this.scoped is not null && this.standardEnabled)
+ {
+ this.scoped.InventoryChanged -= ScopedOnInventoryChanged;
+ this.standardEnabled = false;
+ if (!this.rawEnabled)
+ {
+ this.scoped.Dispose();
+ this.scoped = null;
+ }
+ }
+ }
+
+ ImGui.TableNextRow();
+
+ ImGui.TableNextColumn();
+ ImGui.TextUnformatted("Raw Logging");
+
+ ImGui.TableNextColumn();
+ using (ImRaii.Disabled(this.rawEnabled))
+ {
+ if (ImGui.Button("Enable##raw-enable") && !this.rawEnabled)
+ {
+ this.scoped ??= new();
+ this.scoped.InventoryChangedRaw += ScopedOnInventoryChangedRaw;
+ this.rawEnabled = true;
+ }
+ }
+
+ ImGui.TableNextColumn();
+ using (ImRaii.Disabled(!this.rawEnabled))
+ {
+ if (ImGui.Button("Disable##raw-disable") && this.scoped is not null && this.rawEnabled)
+ {
+ this.scoped.InventoryChangedRaw -= ScopedOnInventoryChangedRaw;
+ this.rawEnabled = false;
+ if (!this.standardEnabled)
+ {
+ this.scoped.Dispose();
+ this.scoped = null;
+ }
+ }
+ }
+
+ ImGui.TableNextRow();
+
+ ImGui.TableNextColumn();
+ ImGui.TextUnformatted("All");
+
+ ImGui.TableNextColumn();
+ using (ImRaii.Disabled(this.standardEnabled && this.rawEnabled))
+ {
+ if (ImGui.Button("Enable##all-enable"))
+ {
+ this.scoped ??= new();
+ if (!this.standardEnabled)
+ this.scoped.InventoryChanged += ScopedOnInventoryChanged;
+ if (!this.rawEnabled)
+ this.scoped.InventoryChangedRaw += ScopedOnInventoryChangedRaw;
+ this.standardEnabled = this.rawEnabled = true;
+ }
+ }
+
+ ImGui.TableNextColumn();
+ using (ImRaii.Disabled(this.scoped is null))
+ {
+ if (ImGui.Button("Disable##all-disable"))
+ {
+ this.scoped?.Dispose();
+ this.scoped = null;
+ this.standardEnabled = this.rawEnabled = false;
+ }
+ }
+ }
+
+ private static void ScopedOnInventoryChangedRaw(IReadOnlyCollection events)
+ {
+ var i = 0;
+ foreach (var e in events)
+ Log.Information($"[{++i}/{events.Count}] Raw: {e}");
+ }
+
+ private static void ScopedOnInventoryChanged(IReadOnlyCollection events)
+ {
+ var i = 0;
+ foreach (var e in events)
+ {
+ if (e is InventoryComplexEventArgs icea)
+ Log.Information($"[{++i}/{events.Count}] {icea}\n\t├ {icea.SourceEvent}\n\t└ {icea.TargetEvent}");
+ else
+ Log.Information($"[{++i}/{events.Count}] {e}");
+ }
+ }
+}
diff --git a/Dalamud/Plugin/Services/IGameInventory.cs b/Dalamud/Plugin/Services/IGameInventory.cs
new file mode 100644
index 000000000..a1b1114d7
--- /dev/null
+++ b/Dalamud/Plugin/Services/IGameInventory.cs
@@ -0,0 +1,106 @@
+using System.Collections.Generic;
+
+using Dalamud.Game.Inventory;
+using Dalamud.Game.Inventory.InventoryEventArgTypes;
+
+namespace Dalamud.Plugin.Services;
+
+///
+/// This class provides events for the in-game inventory.
+///
+public interface IGameInventory
+{
+ ///
+ /// Delegate function to be called when inventories have been changed.
+ /// This delegate sends the entire set of changes recorded.
+ ///
+ /// The events.
+ public delegate void InventoryChangelogDelegate(IReadOnlyCollection events);
+
+ ///
+ /// Delegate function to be called for each change to inventories.
+ /// This delegate sends individual events for changes.
+ ///
+ /// The event try that triggered this message.
+ /// Data for the triggered event.
+ public delegate void InventoryChangedDelegate(GameInventoryEvent type, InventoryEventArgs data);
+
+ ///
+ /// Delegate function to be called for each change to inventories.
+ /// This delegate sends individual events for changes.
+ ///
+ /// The event arg type.
+ /// Data for the triggered event.
+ public delegate void InventoryChangedDelegate(T data) where T : InventoryEventArgs;
+
+ ///
+ /// Event that is fired when the inventory has been changed.
+ /// Note that some events, such as , , and
+ /// currently is subject to reinterpretation as , , and
+ /// .
+ /// Use if you do not want such reinterpretation.
+ ///
+ public event InventoryChangelogDelegate InventoryChanged;
+
+ ///
+ /// Event that is fired when the inventory has been changed, without trying to interpret two inventory slot changes
+ /// as a move event as appropriate.
+ /// In other words, , , and
+ /// currently do not fire in this event.
+ ///
+ public event InventoryChangelogDelegate InventoryChangedRaw;
+
+ ///
+ /// Event that is fired when an item is added to an inventory.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation.
+ ///
+ public event InventoryChangedDelegate ItemAdded;
+
+ ///
+ /// Event that is fired when an item is removed from an inventory.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation.
+ ///
+ public event InventoryChangedDelegate ItemRemoved;
+
+ ///
+ /// Event that is fired when an items properties are changed.
+ /// If this event is a part of multi-step event, then this event will not be called.
+ /// Use if you do not want such reinterpretation.
+ ///
+ public event InventoryChangedDelegate ItemChanged;
+
+ ///
+ /// Event that is fired when an item is moved from one inventory into another.
+ ///
+ public event InventoryChangedDelegate ItemMoved;
+
+ ///
+ /// Event that is fired when an item is split from one stack into two.
+ ///
+ public event InventoryChangedDelegate ItemSplit;
+
+ ///
+ /// Event that is fired when an item is merged from two stacks into one.
+ ///
+ public event InventoryChangedDelegate ItemMerged;
+
+ ///
+ public event InventoryChangedDelegate ItemAddedExplicit;
+
+ ///
+ public event InventoryChangedDelegate ItemRemovedExplicit;
+
+ ///
+ public event InventoryChangedDelegate ItemChangedExplicit;
+
+ ///
+ public event InventoryChangedDelegate ItemMovedExplicit;
+
+ ///
+ public event InventoryChangedDelegate ItemSplitExplicit;
+
+ ///
+ public event InventoryChangedDelegate ItemMergedExplicit;
+}