From d8c68c6648111d8e2c8140849c6e53a97668d210 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:28:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20InventoryActions=20=E2=80=94=20sta?= =?UTF-8?q?ck=20merge/split=20+=20give=20+=20shortcut=20+=20poi=20recall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outbound GameActions for the inventory/drag-drop UI and quickbar: - StackableMerge (0x0054): u32 mergeFrom, u32 mergeTo, u32 amount. Combine two same-type stacks. - StackableSplitToContainer (0x0055): u32 stack, u32 container, u32 placement, u32 amount. Drag a portion of a stack into a pack slot. - StackableSplitTo3D (0x0056): u32 stack, u32 amount. Drop N items to the ground. - StackableSplitToWield (0x019B): u32 stack, u32 equipLoc, u32 amount. Split off and immediately equip (e.g. split an arrow stack to missile-ammo slot). - GiveObjectRequest (0x00CD): u32 target, u32 item, u32 amount. Give to NPC / other player. - AddShortcut (0x019C): u32 slot, u32 objectType, u32 targetId. Pin an item / spell to a quickbar. - RemoveShortcut (0x019D): u32 slot. Unpin. - TeleToPoi (0x00B1): u32 poiId. Quest-driven recall. Tests (8 new): byte-exact encoding of each action, including size assertions so breaking changes surface immediately. Build green, 190 Core.Net tests pass (up from 182). Ref: r08 §3 inventory / shortcut rows. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Messages/InventoryActions.cs | 133 ++++++++++++++++++ .../Messages/InventoryActionsTests.cs | 109 ++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/InventoryActions.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs 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))); + } +}