diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs
new file mode 100644
index 0000000..f46bd5b
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs
@@ -0,0 +1,133 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Outbound inventory-manipulation GameActions: stack merge/split, give,
+/// drop, shortcuts. All ride in the 0xF7B1 GameAction envelope.
+///
+///
+/// References: r08 ยง3 rows 0x0054-0x0056 / 0x019B-0x019D / 0x00CD.
+///
+///
+public static class InventoryActions
+{
+ public const uint GameActionEnvelope = 0xF7B1u;
+
+ public const uint StackableMergeOpcode = 0x0054u;
+ public const uint StackableSplitToContainerOpcode = 0x0055u;
+ public const uint StackableSplitTo3DOpcode = 0x0056u;
+ public const uint StackableSplitToWieldOpcode = 0x019Bu;
+ public const uint GiveObjectRequestOpcode = 0x00CDu;
+ public const uint AddShortcutOpcode = 0x019Cu;
+ public const uint RemoveShortcutOpcode = 0x019Du;
+ public const uint TeleToPoiOpcode = 0x00B1u;
+
+ ///
+ /// Merge stack A into stack B of the same item type. Server validates
+ /// compatibility; if the merge fails it rolls back via
+ /// InventoryServerSaveFailed (0x00A0).
+ ///
+ public static byte[] BuildStackableMerge(uint seq, uint mergeFromGuid, uint mergeToGuid, uint amount)
+ {
+ byte[] body = new byte[24];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), StackableMergeOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), mergeFromGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), mergeToGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), amount);
+ return body;
+ }
+
+ /// Split N items off a stack into a container at placement.
+ public static byte[] BuildStackableSplitToContainer(
+ uint seq, uint stackGuid, uint containerGuid, uint placement, uint amount)
+ {
+ byte[] body = new byte[28];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), StackableSplitToContainerOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), stackGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), containerGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), placement);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), amount);
+ return body;
+ }
+
+ /// Split N items off a stack and drop them on the ground.
+ public static byte[] BuildStackableSplitTo3D(uint seq, uint stackGuid, uint amount)
+ {
+ byte[] body = new byte[20];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), StackableSplitTo3DOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), stackGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), amount);
+ return body;
+ }
+
+ /// Split N items off a stack into an equip slot.
+ public static byte[] BuildStackableSplitToWield(
+ uint seq, uint stackGuid, uint equipLocation, uint amount)
+ {
+ byte[] body = new byte[24];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), StackableSplitToWieldOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), stackGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipLocation);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), amount);
+ return body;
+ }
+
+ /// Give an item (or a stack of N) to a target creature/NPC.
+ public static byte[] BuildGiveObjectRequest(
+ uint seq, uint targetGuid, uint itemGuid, uint amount)
+ {
+ byte[] body = new byte[24];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), GiveObjectRequestOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), itemGuid);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), amount);
+ return body;
+ }
+
+ /// Pin an item / spell to a quickbar slot.
+ public static byte[] BuildAddShortcut(
+ uint seq, uint slotIndex, uint objectType, uint targetId)
+ {
+ byte[] body = new byte[24];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId);
+ return body;
+ }
+
+ /// Unpin a quickbar slot.
+ public static byte[] BuildRemoveShortcut(uint seq, uint slotIndex)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), RemoveShortcutOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex);
+ return body;
+ }
+
+ /// Teleport to a Point of Interest (quest-driven recall).
+ public static byte[] BuildTeleToPoi(uint seq, uint poiId)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TeleToPoiOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), poiId);
+ return body;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs
new file mode 100644
index 0000000..5bfec69
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class InventoryActionsTests
+{
+ [Fact]
+ public void BuildStackableMerge_CarriesBothGuidsAndAmount()
+ {
+ byte[] body = InventoryActions.BuildStackableMerge(
+ seq: 1, mergeFromGuid: 0xAA, mergeToGuid: 0xBB, amount: 5);
+
+ Assert.Equal(24, body.Length);
+ Assert.Equal(InventoryActions.StackableMergeOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0xAAu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ Assert.Equal(0xBBu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
+ Assert.Equal(5u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
+ }
+
+ [Fact]
+ public void BuildStackableSplitToContainer_FourFields()
+ {
+ byte[] body = InventoryActions.BuildStackableSplitToContainer(
+ seq: 1, stackGuid: 0xAA, containerGuid: 0xBB, placement: 3, amount: 10);
+
+ Assert.Equal(28, body.Length);
+ Assert.Equal(InventoryActions.StackableSplitToContainerOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(3u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
+ Assert.Equal(10u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
+ }
+
+ [Fact]
+ public void BuildStackableSplitTo3D_TwoFields()
+ {
+ byte[] body = InventoryActions.BuildStackableSplitTo3D(
+ seq: 1, stackGuid: 0xAA, amount: 3);
+ Assert.Equal(20, body.Length);
+ Assert.Equal(InventoryActions.StackableSplitTo3DOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ }
+
+ [Fact]
+ public void BuildStackableSplitToWield_HasEquipLocation()
+ {
+ byte[] body = InventoryActions.BuildStackableSplitToWield(
+ seq: 1, stackGuid: 0xAA, equipLocation: 0x00400000, amount: 1);
+ Assert.Equal(0x00400000u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
+ }
+
+ [Fact]
+ public void BuildGiveObjectRequest_TargetItemAmount()
+ {
+ byte[] body = InventoryActions.BuildGiveObjectRequest(
+ seq: 1, targetGuid: 0xAA, itemGuid: 0xBB, amount: 2);
+ Assert.Equal(InventoryActions.GiveObjectRequestOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0xAAu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ Assert.Equal(0xBBu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
+ Assert.Equal(2u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
+ }
+
+ [Fact]
+ public void BuildAddShortcut_ThreeFields()
+ {
+ byte[] body = InventoryActions.BuildAddShortcut(
+ seq: 1, slotIndex: 0, objectType: 1, targetId: 0x3E1);
+ Assert.Equal(InventoryActions.AddShortcutOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ Assert.Equal(1u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
+ Assert.Equal(0x3E1u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)));
+ }
+
+ [Fact]
+ public void BuildRemoveShortcut_HasSlotOnly()
+ {
+ byte[] body = InventoryActions.BuildRemoveShortcut(seq: 1, slotIndex: 5);
+ Assert.Equal(16, body.Length);
+ Assert.Equal(5u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+
+ [Fact]
+ public void BuildTeleToPoi_SimpleIdPayload()
+ {
+ byte[] body = InventoryActions.BuildTeleToPoi(seq: 1, poiId: 42);
+ Assert.Equal(InventoryActions.TeleToPoiOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(42u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+}