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