merge: integrate main (D.2b paperdoll/inventory UI line) into the physics/collision branch

Brings the 134 D.2b UI commits onto the physics/collision development line so
main can fast-forward. Today's #149 (BSP-less static collision) + #150 (open
doors fully passable) + the full collision/streaming/dense-town-FPS arc meet
the paperdoll/inventory work.

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-06-25 12:57:46 +02:00
commit 01594b4cfd
95 changed files with 17201 additions and 167 deletions

View file

@ -120,6 +120,25 @@ public sealed class GameEventWiringTests
Assert.Equal(0x2000u, item.ContainerId);
}
[Fact]
public void WireAll_WieldObject_ConfirmsOptimisticWield()
{
var (d, items, _, _, _) = MakeAll();
const uint player = 0x2000u;
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
byte[] payload = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
d.Dispatch(env!.Value); // server confirms the wield
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
}
[Fact]
public void WireAll_PopupString_RoutesToChatLog()
{
@ -496,4 +515,150 @@ public sealed class GameEventWiringTests
Assert.Equal(0x5001u, got![0].ObjectGuid);
}
[Fact]
public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject()
{
// PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler
// must land it in the player ClientObject so the burden bar reads the wire value.
const uint playerGuid = 0x50000001u;
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(),
new ChatLog(), playerGuid: () => playerGuid);
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0x00000001u); // propertyFlags = PropertyInt32
w.Write(0x52u); // weenieType
// int table: u16 count, u16 buckets, then key/val pairs
w.Write((ushort)1); // count
w.Write((ushort)8); // buckets (ignored)
w.Write(5u); // key = EncumbranceVal
w.Write(1500u); // val
// vector + has_health (no vector blocks)
w.Write(0u); // vectorFlags = None
w.Write(0u); // has_health
// strict trailer
w.Write(0u); // option_flags = None
w.Write(0u); // options1
w.Write(0u); // legacy hotbar count
w.Write(0u); // spellbook_filters
w.Write(0u); // inventory count
w.Write(0u); // equipped count
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
var player = items.Get(playerGuid);
Assert.NotNull(player);
Assert.Equal(1500, player!.Properties.Ints[5]);
}
[Fact]
public void WireAll_ViewContents_RecordsMembershipInClientObjectTable()
{
var (d, items, _, _, _) = MakeAll();
// containerGuid 0xC9 + 2 entries
byte[] payload = new byte[8 + 2 * 8];
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
d.Dispatch(env!.Value);
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
}
[Fact]
public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems()
{
var (d, items, _, _, _) = MakeAll();
// First view of container 0xC9: two items.
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u });
Assert.Equal(2, items.GetContents(0x500000C9u).Count);
// Second view: only one item — the other was removed server-side. Additive merge would keep both.
DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u });
Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u));
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
}
private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids)
{
byte[] payload = new byte[8 + itemGuids.Length * 8];
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length);
for (int i = 0; i < itemGuids.Length; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType
}
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
d.Dispatch(env!.Value);
}
[Fact]
public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove()
{
var (d, items, _, _, _) = MakeAll();
// An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1.
items.RecordMembership(0x50000B01u, containerId: 0x500000C0u);
items.MoveItem(0x50000B01u, 0x500000C0u, 2);
items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0);
Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic)
// Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError).
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload));
d.Dispatch(env!.Value);
Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back
Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot);
}
[Fact]
public void WireAll_InventoryPutObjInContainer_ConfirmsOptimisticMove_soNoRollback()
{
var (d, items, _, _, _) = MakeAll();
items.RecordMembership(0x50000B02u, containerId: 0x500000C0u);
items.MoveItem(0x50000B02u, 0x500000C0u, 2);
items.MoveItemOptimistic(0x50000B02u, 0x500000C1u, 0); // optimistic move (pending snapshot)
// Server CONFIRMS via InventoryPutObjInContainer (item, container, placement, containerType).
byte[] payload = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); // containerType
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload));
d.Dispatch(env!.Value);
// ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck).
Assert.False(items.RollbackMove(0x50000B02u));
Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId);
}
[Fact]
public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer()
{
var (d, items, _, _, _) = MakeAll();
items.RecordMembership(0x50000A01u, containerId: 0x500000C9u);
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
d.Dispatch(env!.Value);
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
}
}

View file

@ -56,15 +56,18 @@ public sealed class AppraiseTests
[Fact]
public void ParsePutObjInContainer_RoundTrip()
{
byte[] payload = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u);
// 4 fields (Task 7 fix: containerType added) — 16 bytes required.
byte[] payload = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType
var parsed = GameEvents.ParsePutObjInContainer(payload);
Assert.NotNull(parsed);
Assert.Equal(0x1001u, parsed!.Value.ItemGuid);
Assert.Equal(0x2001u, parsed.Value.ContainerGuid);
Assert.Equal(3u, parsed.Value.Placement);
Assert.Equal(1u, parsed.Value.ContainerType);
}
}

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

@ -0,0 +1,75 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class GameEventsInventoryTests
{
[Fact]
public void ParseViewContents_twoEntries_returnsContainerAndItems()
{
// containerGuid + count(2) + 2×{guid, containerType}
var b = new byte[4 + 4 + 2 * 8];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); // containerGuid
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 2u); // count
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 0x50000A01u); // guid 1
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 0u); // type 1 (item)
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(16), 0x50000A02u);// guid 2
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(20), 1u); // type 2 (container)
var p = GameEvents.ParseViewContents(b);
Assert.NotNull(p);
Assert.Equal(0x500000C9u, p!.Value.ContainerGuid);
Assert.Equal(2, p.Value.Items.Count);
Assert.Equal(0x50000A01u, p.Value.Items[0].Guid);
Assert.Equal(0u, p.Value.Items[0].ContainerType);
Assert.Equal(0x50000A02u, p.Value.Items[1].Guid);
Assert.Equal(1u, p.Value.Items[1].ContainerType);
}
[Fact]
public void ParseViewContents_zeroCount_returnsEmptyList()
{
var b = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u);
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0u);
var p = GameEvents.ParseViewContents(b);
Assert.NotNull(p);
Assert.Empty(p!.Value.Items);
}
[Fact]
public void ParseViewContents_truncated_returnsNull()
=> Assert.Null(GameEvents.ParseViewContents(new byte[4]));
[Fact]
public void ParsePutObjInContainer_readsAllFourFields()
{
var b = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x500000C9u); // containerGuid
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 3u); // placement
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 1u); // containerType
var p = GameEvents.ParsePutObjInContainer(b);
Assert.NotNull(p);
Assert.Equal(0x50000A01u, p!.Value.ItemGuid);
Assert.Equal(0x500000C9u, p.Value.ContainerGuid);
Assert.Equal(3u, p.Value.Placement);
Assert.Equal(1u, p.Value.ContainerType);
}
[Fact]
public void ParseInventoryServerSaveFailed_readsGuidAndError()
{
var b = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x0Au); // weenieError
var p = GameEvents.ParseInventoryServerSaveFailed(b);
Assert.NotNull(p);
Assert.Equal(0x50000A01u, p!.Value.ItemGuid);
Assert.Equal(0x0Au, p.Value.WeenieError);
}
}

View file

@ -74,18 +74,25 @@ public sealed class InventoryActionsTests
}
[Fact]
public void BuildAddShortcut_ThreeFields()
public void BuildAddShortcut_ItemShortcut_FieldLayout()
{
byte[] body = InventoryActions.BuildAddShortcut(
seq: 1, slotIndex: 0, objectType: 1, targetId: 0x3E1);
// ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0.
byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0);
Assert.Equal(24, body.Length);
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)));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index
Assert.Equal(0x3E1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid
Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId
Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer
}
[Fact]
public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s()
{
byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3);
Assert.Equal(0x1234, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20)));
Assert.Equal(3, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22)));
}
[Fact]
@ -106,4 +113,38 @@ public sealed class InventoryActionsTests
Assert.Equal(42u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
}
[Fact]
public void BuildDropItem_layout()
{
byte[] b = InventoryActions.BuildDropItem(seq: 7, itemGuid: 0x50000A01u);
Assert.Equal(16, b.Length);
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
Assert.Equal(0x001Bu, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
}
[Fact]
public void BuildGetAndWieldItem_layout()
{
byte[] b = InventoryActions.BuildGetAndWieldItem(seq: 7, itemGuid: 0x50000A01u, equipMask: 0x02u);
Assert.Equal(20, b.Length);
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
Assert.Equal(0x001Au, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
Assert.Equal(0x02u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16)));
}
[Fact]
public void BuildNoLongerViewingContents_layout()
{
byte[] b = InventoryActions.BuildNoLongerViewingContents(seq: 7, containerGuid: 0x500000C9u);
Assert.Equal(16, b.Length);
Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0)));
Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4)));
Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
}
}

View file

@ -0,0 +1,32 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class InventoryRemoveObjectTests
{
private static byte[] Build(uint guid, uint opcode = 0x0024u)
{
var b = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), guid);
return b;
}
[Fact]
public void TryParse_valid_returnsGuid()
{
var p = InventoryRemoveObject.TryParse(Build(0x50000A07u));
Assert.NotNull(p);
Assert.Equal(0x50000A07u, p!.Value.Guid);
}
[Fact]
public void TryParse_wrongOpcode_returnsNull()
=> Assert.Null(InventoryRemoveObject.TryParse(Build(1, opcode: 0x0023u)));
[Fact]
public void TryParse_truncated_returnsNull()
=> Assert.Null(InventoryRemoveObject.TryParse(new byte[7]));
}

View file

@ -0,0 +1,36 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class PrivateUpdatePropertyIntTests
{
// 0x02CD body: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. NO guid.
private static byte[] Build(uint property, int value, byte seq = 1, uint opcode = 0x02CDu)
{
var b = new byte[13];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
b[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), property);
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), value);
return b;
}
[Fact]
public void TryParse_encumbranceVal_returnsPropValue()
{
var p = PrivateUpdatePropertyInt.TryParse(Build(property: 5u, value: 1500));
Assert.NotNull(p);
Assert.Equal(5u, p!.Value.Property);
Assert.Equal(1500, p.Value.Value);
}
[Fact]
public void TryParse_wrongOpcode_returnsNull()
=> Assert.Null(PrivateUpdatePropertyInt.TryParse(Build(5, 1, opcode: 0x02CEu)));
[Fact]
public void TryParse_truncated_returnsNull()
=> Assert.Null(PrivateUpdatePropertyInt.TryParse(new byte[12]));
}

View file

@ -0,0 +1,37 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class SetStackSizeTests
{
private static byte[] Build(uint guid, int stackSize, int value, byte seq = 1, uint opcode = 0x0197u)
{
var b = new byte[17];
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode);
b[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid);
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), stackSize);
BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value);
return b;
}
[Fact]
public void TryParse_validStack_returnsGuidStackValue()
{
var p = SetStackSize.TryParse(Build(0x50000A01u, stackSize: 25, value: 125));
Assert.NotNull(p);
Assert.Equal(0x50000A01u, p!.Value.Guid);
Assert.Equal(25, p.Value.StackSize);
Assert.Equal(125, p.Value.Value);
}
[Fact]
public void TryParse_wrongOpcode_returnsNull()
=> Assert.Null(SetStackSize.TryParse(Build(1, 1, 1, opcode: 0x0196u)));
[Fact]
public void TryParse_truncated_returnsNull()
=> Assert.Null(SetStackSize.TryParse(new byte[16]));
}

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