diff --git a/src/AcDream.Core.Net/Messages/AppraiseRequest.cs b/src/AcDream.Core.Net/Messages/AppraiseRequest.cs
new file mode 100644
index 0000000..7327f8f
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/AppraiseRequest.cs
@@ -0,0 +1,42 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Outbound 0x00C8 IdentifyObject / Appraise GameAction.
+///
+///
+/// Wire shape (inside the 0xF7B1 envelope):
+///
+/// u32 0xF7B1 // GameMessage opcode (GameAction envelope)
+/// u32 gameActionSequence // client-tracked sequence
+/// u32 0x00C8 // GameAction sub-opcode
+/// u32 targetGuid // whose appraisal we're requesting
+///
+///
+///
+///
+/// Server replies with a 0xF7B0 / 0x00C9 IdentifyObjectResponse
+/// containing the full AppraiseInfo property bundle. See
+/// GameEvents.cs for the matching parser (once wired).
+///
+///
+public static class AppraiseRequest
+{
+ public const uint GameActionEnvelope = 0xF7B1u;
+ public const uint SubOpcode = 0x00C8u;
+
+ ///
+ /// Pack an AppraiseRequest body (with envelope) ready to be handed
+ /// to WorldSession.SendGameAction.
+ ///
+ public static byte[] Build(uint gameActionSequence, uint targetGuid)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
+ return body;
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
index 0102f7c..5e962b2 100644
--- a/src/AcDream.Core.Net/Messages/GameEvents.cs
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -145,6 +145,60 @@ public static class GameEvents
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
+ // ── Appraise / identify ─────────────────────────────────────────────────
+
+ /// 0x00C9 IdentifyObjectResponse header.
+ public readonly record struct IdentifyResponseHeader(
+ uint Guid,
+ uint AppraiseFlags,
+ bool Success);
+
+ ///
+ /// Parse the header of an IdentifyObjectResponse (0x00C9).
+ /// Full property-bundle deserialization (int / bool / float / string
+ /// tables per the AppraiseFlags bitfield) is a future pass; this
+ /// header alone is enough for the UI to display "Appraise complete
+ /// on target X" and to route into the repository.
+ ///
+ public static IdentifyResponseHeader? ParseIdentifyResponseHeader(ReadOnlySpan payload)
+ {
+ if (payload.Length < 12) return null;
+ uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ uint flags = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4));
+ uint success = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8));
+ return new IdentifyResponseHeader(guid, flags, success != 0);
+ }
+
+ /// 0x0023 WieldObject: server-driven equip.
+ public readonly record struct WieldObject(
+ uint ItemGuid,
+ uint EquipLoc,
+ uint WielderGuid);
+
+ public static WieldObject? ParseWieldObject(ReadOnlySpan payload)
+ {
+ if (payload.Length < 12) return null;
+ return new WieldObject(
+ BinaryPrimitives.ReadUInt32LittleEndian(payload),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
+ }
+
+ /// 0x0022 InventoryPutObjInContainer: server puts item into container slot.
+ public readonly record struct InventoryPutObjInContainer(
+ uint ItemGuid,
+ uint ContainerGuid,
+ uint Placement);
+
+ public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan payload)
+ {
+ if (payload.Length < 12) return null;
+ return new InventoryPutObjInContainer(
+ BinaryPrimitives.ReadUInt32LittleEndian(payload),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)));
+ }
+
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan source, ref int pos)
diff --git a/src/AcDream.Core/Items/ItemRepository.cs b/src/AcDream.Core/Items/ItemRepository.cs
new file mode 100644
index 0000000..02c864a
--- /dev/null
+++ b/src/AcDream.Core/Items/ItemRepository.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+
+namespace AcDream.Core.Items;
+
+///
+/// Live item-state mirror — the client-side view of every item the
+/// server has spawned for this session. Owns
+/// records, tracks which container holds each item, and fires events so
+/// UI panels (inventory, paperdoll, hotbars) can redraw on change.
+///
+///
+/// Retail semantics (r06):
+///
+/// -
+/// Every item is a with a unique
+/// ObjectId. CreateObject seeds it when the server tells us
+/// the item exists (in our inventory, on the ground, in a
+/// vendor's list, etc).
+///
+/// -
+/// Moves happen via -carrying messages:
+/// WieldObject, InventoryPutObjInContainer,
+/// InventoryPutObjectIn3D, ViewContents,
+/// CloseGroundContainer.
+///
+/// -
+/// InventoryServerSaveFailed reverts a speculative local
+/// state change (e.g. when a drag-drop was rejected server-side).
+///
+///
+///
+///
+///
+/// Thread safety: designed for single-threaded use from the render
+/// thread; the event delegates run synchronously on the caller's
+/// thread. A backs the
+/// map so plugin code can look up items from any thread without
+/// corrupting state.
+///
+///
+public sealed class ItemRepository
+{
+ private readonly ConcurrentDictionary _items = new();
+ private readonly ConcurrentDictionary _containers = new();
+
+ /// Fires when an item is first added to the session.
+ public event Action? ItemAdded;
+
+ ///
+ /// Fires when an item's container / slot changes (moved between
+ /// packs, equipped, unequipped, dropped on ground). Old and new
+ /// container ids are 0 if origin or destination is "world" / "nowhere".
+ ///
+ public event Action? ItemMoved;
+
+ /// Fires when an item is removed from the session.
+ public event Action? ItemRemoved;
+
+ /// Fires when an item's properties are updated (typically after Appraise).
+ public event Action? ItemPropertiesUpdated;
+
+ public int ItemCount => _items.Count;
+ public int ContainerCount => _containers.Count;
+
+ public IEnumerable Items => _items.Values;
+ public IEnumerable Containers => _containers.Values;
+
+ ///
+ /// Look up an item by its server-assigned ObjectId.
+ ///
+ public ItemInstance? GetItem(uint objectId) =>
+ _items.TryGetValue(objectId, out var item) ? item : null;
+
+ ///
+ /// Look up a container by object id, creating a lightweight stub if
+ /// the id doesn't match any known container (defensive — avoids losing
+ /// references when the server announces a move into a container it
+ /// hasn't described yet).
+ ///
+ public Container? GetContainer(uint objectId) =>
+ _containers.TryGetValue(objectId, out var c) ? c : null;
+
+ ///
+ /// Register / refresh an item in the repository. Called on
+ /// CreateObject for item-typed weenies and on IdentifyObjectResponse
+ /// to fill in detail properties.
+ ///
+ public void AddOrUpdate(ItemInstance item)
+ {
+ ArgumentNullException.ThrowIfNull(item);
+ bool existed = _items.ContainsKey(item.ObjectId);
+ _items[item.ObjectId] = item;
+ if (!existed) ItemAdded?.Invoke(item);
+ else ItemPropertiesUpdated?.Invoke(item);
+ }
+
+ ///
+ /// Register a container. Idempotent.
+ ///
+ public void AddContainer(Container container)
+ {
+ ArgumentNullException.ThrowIfNull(container);
+ _containers[container.ObjectId] = container;
+ }
+
+ ///
+ /// Handle a server-driven move — called from
+ /// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
+ /// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
+ /// and fires ItemMoved.
+ ///
+ public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
+ EquipMask newEquipLocation = EquipMask.None)
+ {
+ if (!_items.TryGetValue(itemId, out var item)) return false;
+
+ uint oldContainer = item.ContainerId;
+ item.ContainerId = newContainerId;
+ item.ContainerSlot = newSlot;
+ item.CurrentlyEquippedLocation = newEquipLocation;
+
+ ItemMoved?.Invoke(item, oldContainer, newContainerId);
+ return true;
+ }
+
+ ///
+ /// Handle a server-driven remove (destroyed item, dropped into 3D
+ /// space, stolen, etc).
+ ///
+ public bool Remove(uint itemId)
+ {
+ if (!_items.TryRemove(itemId, out var item)) return false;
+ ItemRemoved?.Invoke(item);
+ return true;
+ }
+
+ ///
+ /// Apply a patch (e.g. from an
+ /// IdentifyObjectResponse) to an existing item. Individual
+ /// keys in the incoming bundle overwrite existing values; keys not
+ /// present are left untouched.
+ ///
+ public bool UpdateProperties(uint itemId, PropertyBundle incoming)
+ {
+ if (!_items.TryGetValue(itemId, out var item)) return false;
+ foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
+ foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
+ foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
+ foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
+ foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
+ foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
+ foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
+ ItemPropertiesUpdated?.Invoke(item);
+ return true;
+ }
+
+ ///
+ /// Flush the repository — typically called on logoff or teleport
+ /// that drops the session's item state.
+ ///
+ public void Clear()
+ {
+ _items.Clear();
+ _containers.Clear();
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs
new file mode 100644
index 0000000..79dcd1d
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class AppraiseTests
+{
+ [Fact]
+ public void AppraiseRequest_Build_EmitsCorrectWireBytes()
+ {
+ byte[] body = AppraiseRequest.Build(gameActionSequence: 7, targetGuid: 0x12345678u);
+
+ Assert.Equal(16, body.Length);
+ Assert.Equal(AppraiseRequest.GameActionEnvelope,
+ BinaryPrimitives.ReadUInt32LittleEndian(body));
+ Assert.Equal(7u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
+ Assert.Equal(AppraiseRequest.SubOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0x12345678u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+
+ [Fact]
+ public void ParseIdentifyResponseHeader_RoundTrip()
+ {
+ byte[] payload = new byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xDEADBEEFu); // guid
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x000F); // flags
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u); // success
+
+ var parsed = GameEvents.ParseIdentifyResponseHeader(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal(0xDEADBEEFu, parsed!.Value.Guid);
+ Assert.Equal(0x000Fu, parsed.Value.AppraiseFlags);
+ Assert.True(parsed.Value.Success);
+ }
+
+ [Fact]
+ public void ParseWieldObject_RoundTrip()
+ {
+ byte[] payload = new byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000u);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x00400000u); // MeleeWeapon slot
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000u); // wielder
+
+ var parsed = GameEvents.ParseWieldObject(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal(0x1000u, parsed!.Value.ItemGuid);
+ Assert.Equal(0x00400000u, parsed.Value.EquipLoc);
+ Assert.Equal(0x2000u, parsed.Value.WielderGuid);
+ }
+
+ [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);
+
+ 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);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs b/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs
new file mode 100644
index 0000000..79fe2ef
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs
@@ -0,0 +1,119 @@
+using AcDream.Core.Items;
+using Xunit;
+
+namespace AcDream.Core.Tests.Items;
+
+public sealed class ItemRepositoryTests
+{
+ private static ItemInstance MakeItem(uint id, string name = "Widget") =>
+ new ItemInstance
+ {
+ ObjectId = id,
+ WeenieClassId = 1,
+ Name = name,
+ Type = ItemType.Misc,
+ StackSize = 1,
+ Burden = 10,
+ Value = 5,
+ };
+
+ [Fact]
+ public void AddOrUpdate_FiresAddedEvent()
+ {
+ var repo = new ItemRepository();
+ ItemInstance? added = null;
+ repo.ItemAdded += i => added = i;
+
+ var item = MakeItem(100);
+ repo.AddOrUpdate(item);
+
+ Assert.Same(item, added);
+ Assert.Equal(1, repo.ItemCount);
+ Assert.Same(item, repo.GetItem(100));
+ }
+
+ [Fact]
+ public void AddOrUpdate_ExistingItem_FiresPropertiesUpdated()
+ {
+ var repo = new ItemRepository();
+ var item = MakeItem(100);
+ repo.AddOrUpdate(item);
+
+ int propUpdateCount = 0;
+ repo.ItemPropertiesUpdated += _ => propUpdateCount++;
+
+ repo.AddOrUpdate(item); // second call is an update
+ Assert.Equal(1, propUpdateCount);
+ }
+
+ [Fact]
+ public void MoveItem_UpdatesContainerAndFiresEvent()
+ {
+ var repo = new ItemRepository();
+ var item = MakeItem(100);
+ repo.AddOrUpdate(item);
+
+ uint seenOld = 999, seenNew = 999;
+ repo.ItemMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; };
+
+ repo.MoveItem(100, 42, newSlot: 3);
+
+ Assert.Equal(0u, seenOld); // was not in any container initially
+ Assert.Equal(42u, seenNew);
+ Assert.Equal(42u, item.ContainerId);
+ Assert.Equal(3, item.ContainerSlot);
+ }
+
+ [Fact]
+ public void MoveItem_Nonexistent_ReturnsFalse()
+ {
+ var repo = new ItemRepository();
+ Assert.False(repo.MoveItem(999, 42));
+ }
+
+ [Fact]
+ public void Remove_FiresEventAndRemoves()
+ {
+ var repo = new ItemRepository();
+ var item = MakeItem(100);
+ repo.AddOrUpdate(item);
+
+ ItemInstance? removed = null;
+ repo.ItemRemoved += i => removed = i;
+
+ Assert.True(repo.Remove(100));
+ Assert.Same(item, removed);
+ Assert.Null(repo.GetItem(100));
+ }
+
+ [Fact]
+ public void UpdateProperties_MergesIncomingBundle()
+ {
+ var repo = new ItemRepository();
+ var item = MakeItem(100);
+ item.Properties.Ints[1] = 10;
+ repo.AddOrUpdate(item);
+
+ var patch = new PropertyBundle();
+ patch.Ints[2] = 20; // new
+ patch.Ints[1] = 15; // overrides
+ patch.Strings[100] = "desc";
+ repo.UpdateProperties(100, patch);
+
+ Assert.Equal(15, item.Properties.Ints[1]);
+ Assert.Equal(20, item.Properties.Ints[2]);
+ Assert.Equal("desc", item.Properties.Strings[100]);
+ }
+
+ [Fact]
+ public void Clear_RemovesAllItems()
+ {
+ var repo = new ItemRepository();
+ repo.AddOrUpdate(MakeItem(1));
+ repo.AddOrUpdate(MakeItem(2));
+ repo.AddOrUpdate(MakeItem(3));
+
+ repo.Clear();
+ Assert.Equal(0, repo.ItemCount);
+ }
+}