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); + } +}