diff --git a/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs b/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs
new file mode 100644
index 0000000..a9d8f39
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Buffers.Binary;
+using System.Text;
+using AcDream.Core.Items;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Parser for the full AppraiseInfo blob carried by
+/// GameEventType.IdentifyObjectResponse (0x00C9) and also as
+/// part of GameEventType.PlayerDescription (0x0013). Format
+/// source: ACE AppraiseInfo.Write (Structure/AppraiseInfo.cs:735)
+/// + PackableHashTable.WriteHeader.
+///
+///
+/// Wire shape:
+///
+/// u32 flags // IdentifyResponseFlags bitfield
+/// u32 success // 0 or 1
+/// if (flags & IntStatsTable) packedTable<u32 key, i32 value>
+/// if (flags & Int64StatsTable) packedTable<u32 key, i64 value>
+/// if (flags & BoolStatsTable) packedTable<u32 key, u32 value>
+/// if (flags & FloatStatsTable) packedTable<u32 key, f64 value>
+/// if (flags & StringStatsTable) packedTable<u32 key, string16L value>
+/// if (flags & DidStatsTable) packedTable<u32 key, u32 value>
+/// if (flags & SpellBook) u32 count, u32[count] spellIds
+/// ...armor/creature/weapon/hook profile blobs follow (deferred)
+///
+///
+///
+///
+/// Each packed table starts with a 4-byte header:
+///
+/// u16 count // how many entries follow
+/// u16 numBuckets // hashtable bucket hint (ignored for parse)
+///
+/// Then count (key, value) pairs. No padding between pairs.
+///
+///
+///
+/// Returns a fully-populated . Profile
+/// blobs (ArmorProfile / CreatureProfile / WeaponProfile / HookProfile)
+/// are recognised via the flag bits but not yet deserialized — the
+/// parser seeks past them to keep the flag walk aligned.
+///
+///
+public static class AppraiseInfoParser
+{
+ [Flags]
+ public enum IdentifyResponseFlags : uint
+ {
+ None = 0x0000_0000,
+ IntStatsTable = 0x0000_0001,
+ Int64StatsTable = 0x0000_0002,
+ BoolStatsTable = 0x0000_0004,
+ FloatStatsTable = 0x0000_0008,
+ StringStatsTable = 0x0000_0010,
+ DidStatsTable = 0x0000_0020,
+ SpellBook = 0x0000_0040,
+ ArmorProfile = 0x0000_0080,
+ WeaponProfile = 0x0000_0100,
+ HookProfile = 0x0000_0200,
+ ArmorEnchantmentBitfield = 0x0000_0400,
+ WeaponEnchantmentBitfield= 0x0000_0800,
+ ResistEnchantmentBitfield= 0x0000_1000,
+ CreatureProfile = 0x0000_2000,
+ ArmorLevels = 0x0000_4000,
+ }
+
+ public readonly record struct Parsed(
+ uint Guid,
+ IdentifyResponseFlags Flags,
+ bool Success,
+ PropertyBundle Properties,
+ uint[] SpellBook);
+
+ ///
+ /// Parse a full IdentifyObjectResponse payload.
+ /// The envelope has already been stripped by
+ /// , so
+ /// begins with u32 guid.
+ ///
+ public static Parsed? TryParse(ReadOnlySpan payload)
+ {
+ if (payload.Length < 12) return null;
+
+ int pos = 0;
+ uint guid = ReadU32(payload, ref pos);
+ uint rawFlags= ReadU32(payload, ref pos);
+ uint success = ReadU32(payload, ref pos);
+
+ var flags = (IdentifyResponseFlags)rawFlags;
+ var bundle = new PropertyBundle();
+ uint[] spellBook = Array.Empty();
+
+ try
+ {
+ if (flags.HasFlag(IdentifyResponseFlags.IntStatsTable))
+ ReadIntTable(payload, ref pos, bundle);
+ if (flags.HasFlag(IdentifyResponseFlags.Int64StatsTable))
+ ReadInt64Table(payload, ref pos, bundle);
+ if (flags.HasFlag(IdentifyResponseFlags.BoolStatsTable))
+ ReadBoolTable(payload, ref pos, bundle);
+ if (flags.HasFlag(IdentifyResponseFlags.FloatStatsTable))
+ ReadFloatTable(payload, ref pos, bundle);
+ if (flags.HasFlag(IdentifyResponseFlags.StringStatsTable))
+ ReadStringTable(payload, ref pos, bundle);
+ if (flags.HasFlag(IdentifyResponseFlags.DidStatsTable))
+ ReadDataIdTable(payload, ref pos, bundle);
+ if (flags.HasFlag(IdentifyResponseFlags.SpellBook))
+ spellBook = ReadSpellBook(payload, ref pos);
+
+ // Profile blobs: their sizes aren't trivially predictable
+ // without porting the whole profile structures. We stop here
+ // and let the caller handle the bundle they have.
+ }
+ catch (FormatException)
+ {
+ // Malformed table — return what we got so far.
+ }
+
+ return new Parsed(guid, flags, success != 0, bundle, spellBook);
+ }
+
+ // ── Table readers ────────────────────────────────────────────────────────
+
+ private static (ushort count, ushort buckets) ReadHeader(
+ ReadOnlySpan src, ref int pos)
+ {
+ if (src.Length - pos < 4) throw new FormatException("truncated table header");
+ ushort count = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
+ ushort buckets = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos + 2));
+ pos += 4;
+ return (count, buckets);
+ }
+
+ private static void ReadIntTable(ReadOnlySpan src, ref int pos, PropertyBundle bundle)
+ {
+ var (count, _) = ReadHeader(src, ref pos);
+ for (int i = 0; i < count; i++)
+ {
+ uint key = ReadU32(src, ref pos);
+ int val = (int)ReadU32(src, ref pos);
+ bundle.Ints[key] = val;
+ }
+ }
+
+ private static void ReadInt64Table(ReadOnlySpan src, ref int pos, PropertyBundle bundle)
+ {
+ var (count, _) = ReadHeader(src, ref pos);
+ for (int i = 0; i < count; i++)
+ {
+ uint key = ReadU32(src, ref pos);
+ long val = ReadI64(src, ref pos);
+ bundle.Int64s[key] = val;
+ }
+ }
+
+ private static void ReadBoolTable(ReadOnlySpan src, ref int pos, PropertyBundle bundle)
+ {
+ var (count, _) = ReadHeader(src, ref pos);
+ for (int i = 0; i < count; i++)
+ {
+ uint key = ReadU32(src, ref pos);
+ uint val = ReadU32(src, ref pos);
+ bundle.Bools[key] = val != 0;
+ }
+ }
+
+ private static void ReadFloatTable(ReadOnlySpan src, ref int pos, PropertyBundle bundle)
+ {
+ var (count, _) = ReadHeader(src, ref pos);
+ for (int i = 0; i < count; i++)
+ {
+ uint key = ReadU32(src, ref pos);
+ double val = ReadF64(src, ref pos);
+ bundle.Floats[key] = val;
+ }
+ }
+
+ private static void ReadStringTable(ReadOnlySpan src, ref int pos, PropertyBundle bundle)
+ {
+ var (count, _) = ReadHeader(src, ref pos);
+ for (int i = 0; i < count; i++)
+ {
+ uint key = ReadU32(src, ref pos);
+ string val = ReadString16L(src, ref pos);
+ bundle.Strings[key] = val;
+ }
+ }
+
+ private static void ReadDataIdTable(ReadOnlySpan src, ref int pos, PropertyBundle bundle)
+ {
+ var (count, _) = ReadHeader(src, ref pos);
+ for (int i = 0; i < count; i++)
+ {
+ uint key = ReadU32(src, ref pos);
+ uint val = ReadU32(src, ref pos);
+ bundle.DataIds[key] = val;
+ }
+ }
+
+ private static uint[] ReadSpellBook(ReadOnlySpan src, ref int pos)
+ {
+ if (src.Length - pos < 4) throw new FormatException("truncated spellbook count");
+ uint count = ReadU32(src, ref pos);
+ if (count > 4096) throw new FormatException("unreasonable spellbook count");
+ if (src.Length - pos < count * 4) throw new FormatException("truncated spellbook body");
+ uint[] result = new uint[count];
+ for (int i = 0; i < count; i++) result[i] = ReadU32(src, ref pos);
+ return result;
+ }
+
+ // ── Primitive readers ───────────────────────────────────────────────────
+
+ private static uint ReadU32(ReadOnlySpan src, ref int pos)
+ {
+ if (src.Length - pos < 4) throw new FormatException("truncated u32");
+ uint v = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
+ pos += 4;
+ return v;
+ }
+
+ private static long ReadI64(ReadOnlySpan src, ref int pos)
+ {
+ if (src.Length - pos < 8) throw new FormatException("truncated i64");
+ long v = BinaryPrimitives.ReadInt64LittleEndian(src.Slice(pos));
+ pos += 8;
+ return v;
+ }
+
+ private static double ReadF64(ReadOnlySpan src, ref int pos)
+ {
+ if (src.Length - pos < 8) throw new FormatException("truncated f64");
+ double v = BinaryPrimitives.ReadDoubleLittleEndian(src.Slice(pos));
+ pos += 8;
+ return v;
+ }
+
+ private static string ReadString16L(ReadOnlySpan src, ref int pos)
+ {
+ if (src.Length - pos < 2) throw new FormatException("truncated string length");
+ ushort len = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos));
+ pos += 2;
+ if (src.Length - pos < len) throw new FormatException("truncated string body");
+ string v = Encoding.ASCII.GetString(src.Slice(pos, len));
+ pos += len;
+ int record = 2 + len;
+ int pad = (4 - (record & 3)) & 3;
+ pos += pad;
+ return v;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs
new file mode 100644
index 0000000..a7e87af
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Buffers.Binary;
+using System.IO;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class AppraiseInfoParserTests
+{
+ ///
+ /// Build an AppraiseInfo payload matching ACE's wire format. Starts
+ /// with (guid, flags, success) then per-flag tables.
+ ///
+ private static byte[] BuildPayload(
+ uint guid,
+ AppraiseInfoParser.IdentifyResponseFlags flags,
+ bool success,
+ (uint key, int value)[]? ints = null,
+ (uint key, bool value)[]? bools = null,
+ (uint key, string value)[]? strings = null,
+ uint[]? spellBook = null)
+ {
+ using var ms = new MemoryStream();
+ using var bw = new BinaryWriter(ms);
+
+ bw.Write(guid);
+ bw.Write((uint)flags);
+ bw.Write((uint)(success ? 1 : 0));
+
+ if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable) && ints is not null)
+ {
+ bw.Write((ushort)ints.Length);
+ bw.Write((ushort)16); // numBuckets hint
+ foreach (var (k, v) in ints)
+ {
+ bw.Write(k);
+ bw.Write(v);
+ }
+ }
+
+ if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable) && bools is not null)
+ {
+ bw.Write((ushort)bools.Length);
+ bw.Write((ushort)8);
+ foreach (var (k, v) in bools)
+ {
+ bw.Write(k);
+ bw.Write(v ? 1u : 0u);
+ }
+ }
+
+ if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable) && strings is not null)
+ {
+ bw.Write((ushort)strings.Length);
+ bw.Write((ushort)8);
+ foreach (var (k, v) in strings)
+ {
+ bw.Write(k);
+ WriteString16L(bw, v);
+ }
+ }
+
+ if (flags.HasFlag(AppraiseInfoParser.IdentifyResponseFlags.SpellBook) && spellBook is not null)
+ {
+ bw.Write((uint)spellBook.Length);
+ foreach (var sid in spellBook) bw.Write(sid);
+ }
+
+ bw.Flush();
+ return ms.ToArray();
+ }
+
+ private static void WriteString16L(BinaryWriter bw, string s)
+ {
+ byte[] bytes = System.Text.Encoding.ASCII.GetBytes(s);
+ bw.Write((ushort)bytes.Length);
+ bw.Write(bytes);
+ int record = 2 + bytes.Length;
+ int pad = (4 - (record & 3)) & 3;
+ for (int i = 0; i < pad; i++) bw.Write((byte)0);
+ }
+
+ [Fact]
+ public void TryParse_GuidAndFlags_ExtractedCorrectly()
+ {
+ byte[] payload = BuildPayload(
+ guid: 0xDEADBEEFu,
+ flags: AppraiseInfoParser.IdentifyResponseFlags.None,
+ success: true);
+
+ var parsed = AppraiseInfoParser.TryParse(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal(0xDEADBEEFu, parsed!.Value.Guid);
+ Assert.True(parsed.Value.Success);
+ }
+
+ [Fact]
+ public void TryParse_IntStatsTable_PopulatesIntProperties()
+ {
+ byte[] payload = BuildPayload(
+ guid: 1,
+ flags: AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable,
+ success: true,
+ ints: new[] { ((uint)1, 100), ((uint)5, -50), ((uint)9, 42) });
+
+ var parsed = AppraiseInfoParser.TryParse(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal(3, parsed!.Value.Properties.Ints.Count);
+ Assert.Equal(100, parsed.Value.Properties.Ints[1]);
+ Assert.Equal(-50, parsed.Value.Properties.Ints[5]);
+ Assert.Equal(42, parsed.Value.Properties.Ints[9]);
+ }
+
+ [Fact]
+ public void TryParse_BoolStatsTable_ConvertsU32ToBool()
+ {
+ byte[] payload = BuildPayload(
+ guid: 1,
+ flags: AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable,
+ success: true,
+ bools: new[] { ((uint)1, true), ((uint)2, false) });
+
+ var parsed = AppraiseInfoParser.TryParse(payload);
+ Assert.NotNull(parsed);
+ Assert.True(parsed!.Value.Properties.Bools[1]);
+ Assert.False(parsed.Value.Properties.Bools[2]);
+ }
+
+ [Fact]
+ public void TryParse_StringStatsTable_ParsesPaddedStrings()
+ {
+ byte[] payload = BuildPayload(
+ guid: 1,
+ flags: AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable,
+ success: true,
+ strings: new[] { ((uint)1, "Excalibur"), ((uint)2, "Rusty Dagger") });
+
+ var parsed = AppraiseInfoParser.TryParse(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal("Excalibur", parsed!.Value.Properties.Strings[1]);
+ Assert.Equal("Rusty Dagger", parsed.Value.Properties.Strings[2]);
+ }
+
+ [Fact]
+ public void TryParse_SpellBook_ReturnsSpellIdArray()
+ {
+ byte[] payload = BuildPayload(
+ guid: 1,
+ flags: AppraiseInfoParser.IdentifyResponseFlags.SpellBook,
+ success: true,
+ spellBook: new uint[] { 0x3E1, 0x3E2, 0x3E3 });
+
+ var parsed = AppraiseInfoParser.TryParse(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal(3, parsed!.Value.SpellBook.Length);
+ Assert.Equal(0x3E1u, parsed.Value.SpellBook[0]);
+ }
+
+ [Fact]
+ public void TryParse_MultipleTables_AllParsed()
+ {
+ var flags = AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
+ | AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable
+ | AppraiseInfoParser.IdentifyResponseFlags.SpellBook;
+
+ byte[] payload = BuildPayload(
+ guid: 1, flags, success: true,
+ ints: new[] { ((uint)1, 100) },
+ bools: new[] { ((uint)2, true) },
+ spellBook: new uint[] { 42 });
+
+ var parsed = AppraiseInfoParser.TryParse(payload);
+ Assert.NotNull(parsed);
+ Assert.Single(parsed!.Value.Properties.Ints);
+ Assert.Single(parsed.Value.Properties.Bools);
+ Assert.Single(parsed.Value.SpellBook);
+ }
+
+ [Fact]
+ public void TryParse_Truncated_ReturnsNull()
+ {
+ Assert.Null(AppraiseInfoParser.TryParse(new byte[4]));
+ }
+}