diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index c9aec8a5..fc425532 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -250,19 +250,18 @@ public static class GameEventWiring newSlot: (int)p.Value.Placement); }); - // B-Wire: ViewContents (0x0196) — the server's full contents list for a - // container you opened. Record membership for each entry so the table is - // correct before the container-open UI mounts the second item list. - // TODO(container-open): ViewContents is the AUTHORITATIVE full snapshot. When the - // container-open phase consumes it, treat it as a full REPLACE (flush the container's - // prior membership first), not the additive merge below — otherwise items removed - // server-side since the last open would linger. + // ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you + // opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and + // record the new entries in order (ContainerSlot = index — ACE writes entries + // OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController) + // repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents. dispatcher.Register(GameEventType.ViewContents, e => { var p = GameEvents.ParseViewContents(e.Payload.Span); if (p is null) return; - foreach (var entry in p.Value.Items) - items.RecordMembership(entry.Guid, containerId: p.Value.ContainerGuid); + var guids = new uint[p.Value.Items.Count]; + for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid; + items.ReplaceContents(p.Value.ContainerGuid, guids); }); // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 96ae5a54..24d868f3 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -556,6 +556,35 @@ public sealed class GameEventWiringTests 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_InventoryPutObjectIn3D_UnparentsFromContainer() {