fix(D.2b): PickupEvent removes the 3D render, not the weenie — unwield lands in the pack

PickupEvent (0xF74A) and DeleteObject (0xF747) are semantically distinct:
- 0xF747 = weenie DESTROYED → evict from ClientObjectTable (weenie_object_table)
- 0xF74A = object LEFT THE 3D WORLD VIEW (moved into a container) → remove
  the 3D WorldEntity, but the weenie persists in ClientObjectTable

Before this fix, both paths fired EntityDeleted identically, causing
ObjectTableWiring to evict the weenie from ClientObjectTable. The follow-up
InventoryPutObjInContainer (0x0022) then tried MoveItem on an unknown guid
and no-op'd, so the unwielded item simply vanished.

Fix: add `bool FromPickup` (default false) to DeleteObject.Parsed. WorldSession
sets it true on the PickupEvent path and false on the DeleteObject path.
ObjectTableWiring.Wire's EntityDeleted handler skips table.Remove when
FromPickup is true, preserving the weenie for the container-move echo.

GameWindow.OnLiveEntityDeleted (3D entity removal) is untouched — it fires
for both pickups and destroys, as intended.

Divergence register: AP-65 added (data ghosts for other-player pickups until
teleport/relog clear; harmless — no UI queries ContainerId 0).

Tests: +5 (DeleteObject FromPickup parser regression; wiring retain/evict
semantics; Parsed default/explicit FromPickup). 343/343 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-22 23:39:27 +02:00
parent 058da60212
commit db0cac03c4
6 changed files with 151 additions and 13 deletions

View file

@ -36,4 +36,42 @@ public sealed class DeleteObjectTests
Assert.Equal(0x80000439u, parsed!.Value.Guid);
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
}
/// <summary>
/// Regression guard: TryParse (0xF747 = true destroy) must always produce
/// FromPickup = false. PickupEvent (0xF74A) is a different opcode and is
/// the ONLY path that sets FromPickup = true (in WorldSession).
/// </summary>
[Fact]
public void TryParse_AlwaysReturnsFromPickupFalse()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000001u);
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x0001);
var parsed = DeleteObject.TryParse(body);
Assert.NotNull(parsed);
Assert.False(parsed!.Value.FromPickup,
"DeleteObject 0xF747 is a true destroy — FromPickup must be false.");
}
/// <summary>
/// FromPickup = true can be constructed via the record directly (as
/// WorldSession does for PickupEvent 0xF74A). Default is false.
/// </summary>
[Fact]
public void Parsed_DefaultFromPickupIsFalse()
{
var p = new DeleteObject.Parsed(0x80000001u, 1);
Assert.False(p.FromPickup);
}
[Fact]
public void Parsed_FromPickupTrueCanBeSet()
{
var p = new DeleteObject.Parsed(0x80000001u, 1, FromPickup: true);
Assert.True(p.FromPickup);
}
}

View file

@ -7,10 +7,13 @@ namespace AcDream.Core.Net.Tests;
/// <summary>
/// D.5.4 Task 7 — ObjectTableWiring.
///
/// Integration test is omitted: WorldSession.EntitySpawned has no internal
/// test seam to fire it without a real Tick + packet bytes, so subscription
/// correctness is covered by build (type-checks) + the live run. Only the
/// pure mapping (ToWeenieData) is unit-tested here.
/// WorldSession.EntitySpawned/EntityDeleted have no in-process test seam (the
/// ctor opens a UDP socket), so the subscription wiring is tested via the guard
/// logic extracted as a local handler — this is the same lambda body that
/// ObjectTableWiring.Wire wires to session.EntityDeleted. The retail two-table
/// semantics are: PickupEvent (0xF74A) removes from the 3-D object_table but
/// KEEPS the weenie in ClientObjectTable; DeleteObject (0xF747) evicts the
/// weenie from both. FromPickup = true guards the eviction.
/// </summary>
public sealed class ObjectTableWiringTests
{
@ -94,4 +97,80 @@ public sealed class ObjectTableWiringTests
Assert.Equal(100, d.MaxStructure);
Assert.Equal(4.5f, d.Workmanship);
}
// -------------------------------------------------------------------------
// The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted.
// Testing it directly avoids needing a live WorldSession (UDP socket).
// -------------------------------------------------------------------------
private static Action<DeleteObject.Parsed> MakeEntityDeletedHandler(ClientObjectTable table)
=> d => { if (!d.FromPickup) table.Remove(d.Guid); };
private static WeenieData MinimalWeenie(uint guid) => new(
Guid: guid,
Name: "Test Item",
Type: null,
WeenieClassId: 1u,
IconId: 0x06000001u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: null,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: null,
WielderId: null,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null);
/// <summary>
/// PickupEvent path: FromPickup = true → weenie is RETAINED in ClientObjectTable.
/// The 3-D render entity is removed (EntityDeleted still fires), but the weenie
/// data persists so the follow-up InventoryPutObjInContainer can find it.
/// </summary>
[Fact]
public void EntityDeleted_FromPickupTrue_RetainsWeenieInTable()
{
var table = new ClientObjectTable();
var handler = MakeEntityDeletedHandler(table);
const uint guid = 0x80000100u;
table.Ingest(MinimalWeenie(guid));
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
// Fire EntityDeleted as WorldSession does for PickupEvent (0xF74A).
// The handler itself represents the EntityDeleted subscription — firing it IS the event.
handler(new DeleteObject.Parsed(guid, 0, FromPickup: true));
// Post-condition: weenie still in table (it moved into a container, was NOT destroyed).
// EntityDeleted still fires (the handler ran), so the 3-D render layer would remove the WorldEntity.
Assert.NotNull(table.Get(guid));
}
/// <summary>
/// DeleteObject path: FromPickup = false → weenie IS evicted from ClientObjectTable.
/// Regression guard — true destroys (0xF747) must still clean up the table.
/// </summary>
[Fact]
public void EntityDeleted_FromPickupFalse_EvictsWeenieFromTable()
{
var table = new ClientObjectTable();
var handler = MakeEntityDeletedHandler(table);
const uint guid = 0x80000200u;
table.Ingest(MinimalWeenie(guid));
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
// Fire EntityDeleted as WorldSession does for DeleteObject (0xF747).
handler(new DeleteObject.Parsed(guid, 0, FromPickup: false));
// Post-condition: weenie evicted (truly destroyed).
Assert.Null(table.Get(guid));
}
}