fix(B.5): handle PickupEvent 0xF74A so picked-up items despawn locally
ACE sends GameMessagePickupEvent (opcode 0xF74A) instead of GameMessageDeleteObject (0xF747) for items removed via player pickup (Player_Tracking.RemoveTrackedObject with fromPickup=true). Without this handler, BuildPickUp succeeded server-side (item moved into the player's container, retail observers saw it disappear), but our local client kept rendering it on the ground because the despawn message went to the unhandled-opcode bucket. PickupEvent's wire body adds an objectPositionSequence field on top of DeleteObject's layout, so the parser is its own type. The downstream view-removal semantics are identical to DeleteObject, so the dispatcher routes both opcodes into the same EntityDeleted event via a small adapter.
This commit is contained in:
parent
5c24f6cafe
commit
f7636a9e78
3 changed files with 102 additions and 0 deletions
48
src/AcDream.Core.Net/Messages/PickupEvent.cs
Normal file
48
src/AcDream.Core.Net/Messages/PickupEvent.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inbound <c>PickupEvent</c> GameMessage (opcode <c>0xF74A</c>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// ACE emits this from <c>Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)</c>
|
||||||
|
/// when a player picks up a world item — distinguishes the despawn
|
||||||
|
/// from a generic <c>0xF747 DeleteObject</c> (timeout / death /
|
||||||
|
/// out-of-LOS). Downstream effect on the client view is the same
|
||||||
|
/// (remove the entity from the world), so <see cref="WorldSession"/>
|
||||||
|
/// routes both opcodes to the same <c>EntityDeleted</c> event.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Wire layout (ACE <c>GameMessagePickupEvent.cs</c>):
|
||||||
|
/// <code>
|
||||||
|
/// u32 0xF74A
|
||||||
|
/// u32 guid
|
||||||
|
/// u16 objectInstanceSequence
|
||||||
|
/// u16 objectPositionSequence
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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<byte> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -712,6 +712,19 @@ public sealed class WorldSession : IDisposable
|
||||||
if (parsed is not null)
|
if (parsed is not null)
|
||||||
EntityDeleted?.Invoke(parsed.Value);
|
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)
|
else if (op == UpdateMotion.Opcode)
|
||||||
{
|
{
|
||||||
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
|
||||||
|
|
|
||||||
41
tests/AcDream.Core.Net.Tests/Messages/PickupEventTests.cs
Normal file
41
tests/AcDream.Core.Net.Tests/Messages/PickupEventTests.cs
Normal file
|
|
@ -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<byte> body = stackalloc byte[12];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
|
||||||
|
|
||||||
|
Assert.Null(PickupEvent.TryParse(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RejectsTruncated()
|
||||||
|
{
|
||||||
|
Assert.Null(PickupEvent.TryParse(ReadOnlySpan<byte>.Empty));
|
||||||
|
Assert.Null(PickupEvent.TryParse(new byte[11]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesGuidAndSequences()
|
||||||
|
{
|
||||||
|
Span<byte> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue