diff --git a/src/AcDream.Core.Net/Messages/PickupEvent.cs b/src/AcDream.Core.Net/Messages/PickupEvent.cs
new file mode 100644
index 0000000..44ff95d
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/PickupEvent.cs
@@ -0,0 +1,48 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Inbound PickupEvent GameMessage (opcode 0xF74A).
+///
+///
+/// ACE emits this from Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)
+/// when a player picks up a world item — distinguishes the despawn
+/// from a generic 0xF747 DeleteObject (timeout / death /
+/// out-of-LOS). Downstream effect on the client view is the same
+/// (remove the entity from the world), so
+/// routes both opcodes to the same EntityDeleted event.
+///
+///
+///
+/// Wire layout (ACE GameMessagePickupEvent.cs):
+///
+/// u32 0xF74A
+/// u32 guid
+/// u16 objectInstanceSequence
+/// u16 objectPositionSequence
+///
+///
+///
+public static class PickupEvent
+{
+ public const uint Opcode = 0xF74Au;
+
+ public readonly record struct Parsed(
+ uint Guid, ushort InstanceSequence, ushort PositionSequence);
+
+ public static Parsed? TryParse(ReadOnlySpan body)
+ {
+ if (body.Length < 12)
+ return null;
+
+ uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
+ if (opcode != Opcode)
+ return null;
+
+ uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
+ ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
+ ushort positionSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(10, 2));
+ return new Parsed(guid, instanceSequence, positionSequence);
+ }
+}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 85a571a..2e644c6 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -712,6 +712,19 @@ public sealed class WorldSession : IDisposable
if (parsed is not null)
EntityDeleted?.Invoke(parsed.Value);
}
+ else if (op == PickupEvent.Opcode)
+ {
+ // ACE sends PickupEvent (0xF74A) instead of DeleteObject
+ // when a player picks up a world item (Player_Tracking
+ // .RemoveTrackedObject with fromPickup=true). Downstream
+ // view-removal semantics are identical, so we adapt to
+ // DeleteObject.Parsed and reuse the existing handler.
+ var parsed = PickupEvent.TryParse(body);
+ if (parsed is not null)
+ EntityDeleted?.Invoke(
+ new DeleteObject.Parsed(
+ parsed.Value.Guid, parsed.Value.InstanceSequence));
+ }
else if (op == UpdateMotion.Opcode)
{
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
diff --git a/tests/AcDream.Core.Net.Tests/Messages/PickupEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PickupEventTests.cs
new file mode 100644
index 0000000..e6248ec
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/PickupEventTests.cs
@@ -0,0 +1,41 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class PickupEventTests
+{
+ [Fact]
+ public void RejectsWrongOpcode()
+ {
+ Span body = stackalloc byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
+
+ Assert.Null(PickupEvent.TryParse(body));
+ }
+
+ [Fact]
+ public void RejectsTruncated()
+ {
+ Assert.Null(PickupEvent.TryParse(ReadOnlySpan.Empty));
+ Assert.Null(PickupEvent.TryParse(new byte[11]));
+ }
+
+ [Fact]
+ public void ParsesGuidAndSequences()
+ {
+ Span body = stackalloc byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, PickupEvent.Opcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000727u);
+ BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x1234);
+ BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(10), 0x5678);
+
+ var parsed = PickupEvent.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0x80000727u, parsed!.Value.Guid);
+ Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
+ Assert.Equal((ushort)0x5678, parsed.Value.PositionSequence);
+ }
+}