diff --git a/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs b/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs index a9d8f39..0240301 100644 --- a/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs +++ b/src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs @@ -67,12 +67,67 @@ public static class AppraiseInfoParser ArmorLevels = 0x0000_4000, } + /// Per-damage-type protections from an ArmorProfile blob. + public readonly record struct ArmorProfile( + float SlashingProtection, + float PiercingProtection, + float BludgeoningProtection, + float ColdProtection, + float FireProtection, + float AcidProtection, + float NetherProtection, + float LightningProtection); + + /// Per-slot AL (armor level) from an ArmorLevels blob. + public readonly record struct ArmorLevel( + int Head, int Chest, int Abdomen, + int UpperArm, int LowerArm, int Hand, + int UpperLeg, int LowerLeg, int Foot); + + /// Melee/missile weapon statistics from a WeaponProfile blob. + public readonly record struct WeaponProfile( + uint DamageType, + uint WeaponTime, + uint WeaponSkill, + uint Damage, + double DamageVariance, + double DamageMod, + double WeaponLength, + double MaxVelocity, + double WeaponOffense, + uint MaxVelocityEstimated); + + /// Creature vitals + (optionally) attributes from a CreatureProfile blob. + public readonly record struct CreatureProfile( + uint Flags, + uint Health, + uint HealthMax, + uint? Strength, + uint? Endurance, + uint? Quickness, + uint? Coordination, + uint? Focus, + uint? Self, + uint? Stamina, + uint? Mana, + uint? StaminaMax, + uint? ManaMax, + ushort? AttributeHighlights, + ushort? AttributeColors); + public readonly record struct Parsed( uint Guid, IdentifyResponseFlags Flags, bool Success, PropertyBundle Properties, - uint[] SpellBook); + uint[] SpellBook, + ArmorProfile? ArmorProfile, + CreatureProfile? CreatureProfile, + WeaponProfile? WeaponProfile, + ArmorLevel? ArmorLevels, + (ushort Highlight, ushort Color)? ArmorEnchantments, + (ushort Highlight, ushort Color)? WeaponEnchantments, + (ushort Highlight, ushort Color)? ResistEnchantments); /// /// Parse a full IdentifyObjectResponse payload. @@ -92,6 +147,11 @@ public static class AppraiseInfoParser var flags = (IdentifyResponseFlags)rawFlags; var bundle = new PropertyBundle(); uint[] spellBook = Array.Empty(); + ArmorProfile? armor = null; + CreatureProfile? creature = null; + WeaponProfile? weapon = null; + ArmorLevel? levels = null; + (ushort H, ushort C)? armorEnc = null, weaponEnc = null, resistEnc = null; try { @@ -110,16 +170,132 @@ public static class AppraiseInfoParser 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. + // Profile blobs (AppraiseInfo.Write line 751 onward, in flag order). + if (flags.HasFlag(IdentifyResponseFlags.ArmorProfile)) + armor = ReadArmorProfile(payload, ref pos); + if (flags.HasFlag(IdentifyResponseFlags.CreatureProfile)) + creature = ReadCreatureProfile(payload, ref pos); + if (flags.HasFlag(IdentifyResponseFlags.WeaponProfile)) + weapon = ReadWeaponProfile(payload, ref pos); + // HookProfile (0x200) — not deserialized; would need HookProfile struct port. + if (flags.HasFlag(IdentifyResponseFlags.ArmorEnchantmentBitfield)) + armorEnc = ReadEnchantmentBitfield(payload, ref pos); + if (flags.HasFlag(IdentifyResponseFlags.WeaponEnchantmentBitfield)) + weaponEnc = ReadEnchantmentBitfield(payload, ref pos); + if (flags.HasFlag(IdentifyResponseFlags.ResistEnchantmentBitfield)) + resistEnc = ReadEnchantmentBitfield(payload, ref pos); + if (flags.HasFlag(IdentifyResponseFlags.ArmorLevels)) + levels = ReadArmorLevel(payload, ref pos); } catch (FormatException) { // Malformed table — return what we got so far. } - return new Parsed(guid, flags, success != 0, bundle, spellBook); + return new Parsed( + guid, flags, success != 0, bundle, spellBook, + armor, creature, weapon, levels, + armorEnc, weaponEnc, resistEnc); + } + + // ── Profile readers ────────────────────────────────────────────────────── + + private static ArmorProfile ReadArmorProfile(ReadOnlySpan src, ref int pos) + { + return new ArmorProfile( + ReadF32(src, ref pos), + ReadF32(src, ref pos), + ReadF32(src, ref pos), + ReadF32(src, ref pos), + ReadF32(src, ref pos), + ReadF32(src, ref pos), + ReadF32(src, ref pos), + ReadF32(src, ref pos)); + } + + private static ArmorLevel ReadArmorLevel(ReadOnlySpan src, ref int pos) + { + return new ArmorLevel( + (int)ReadU32(src, ref pos), + (int)ReadU32(src, ref pos), + (int)ReadU32(src, ref pos), + (int)ReadU32(src, ref pos), + (int)ReadU32(src, ref pos), + (int)ReadU32(src, ref pos), + (int)ReadU32(src, ref pos), + (int)ReadU32(src, ref pos), + (int)ReadU32(src, ref pos)); + } + + private static WeaponProfile ReadWeaponProfile(ReadOnlySpan src, ref int pos) + { + return new WeaponProfile( + DamageType: ReadU32(src, ref pos), + WeaponTime: ReadU32(src, ref pos), + WeaponSkill: ReadU32(src, ref pos), + Damage: ReadU32(src, ref pos), + DamageVariance: ReadF64(src, ref pos), + DamageMod: ReadF64(src, ref pos), + WeaponLength: ReadF64(src, ref pos), + MaxVelocity: ReadF64(src, ref pos), + WeaponOffense: ReadF64(src, ref pos), + MaxVelocityEstimated: ReadU32(src, ref pos)); + } + + private static CreatureProfile ReadCreatureProfile(ReadOnlySpan src, ref int pos) + { + uint flags = ReadU32(src, ref pos); + uint health = ReadU32(src, ref pos); + uint healthMax = ReadU32(src, ref pos); + + uint? str = null, end = null, quic = null, coord = null, focus = null, self = null; + uint? sta = null, mana = null, staMax = null, manaMax = null; + ushort? attrHighlights = null, attrColors = null; + + // Flag 0x08 = ShowAttributes + if ((flags & 0x08u) != 0) + { + str = ReadU32(src, ref pos); + end = ReadU32(src, ref pos); + quic = ReadU32(src, ref pos); + coord = ReadU32(src, ref pos); + focus = ReadU32(src, ref pos); + self = ReadU32(src, ref pos); + sta = ReadU32(src, ref pos); + mana = ReadU32(src, ref pos); + staMax = ReadU32(src, ref pos); + manaMax= ReadU32(src, ref pos); + } + // Flag 0x01 = HasBuffsDebuffs. ACE writes two u16 fields, 4 bytes total. + if ((flags & 0x01u) != 0) + { + if (src.Length - pos < 4) throw new FormatException("truncated creature buffs"); + attrHighlights = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2; + attrColors = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2; + } + + return new CreatureProfile( + flags, health, healthMax, + str, end, quic, coord, focus, self, + sta, mana, staMax, manaMax, + attrHighlights, attrColors); + } + + private static (ushort Highlight, ushort Color) ReadEnchantmentBitfield( + ReadOnlySpan src, ref int pos) + { + if (src.Length - pos < 4) throw new FormatException("truncated enchantment bitfield"); + ushort highlight = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2; + ushort color = BinaryPrimitives.ReadUInt16LittleEndian(src.Slice(pos)); pos += 2; + return (highlight, color); + } + + private static float ReadF32(ReadOnlySpan src, ref int pos) + { + if (src.Length - pos < 4) throw new FormatException("truncated f32"); + float v = BinaryPrimitives.ReadSingleLittleEndian(src.Slice(pos)); + pos += 4; + return v; } // ── Table readers ──────────────────────────────────────────────────────── diff --git a/tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs index a7e87af..f53e8f2 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/AppraiseInfoParserTests.cs @@ -182,4 +182,144 @@ public sealed class AppraiseInfoParserTests { Assert.Null(AppraiseInfoParser.TryParse(new byte[4])); } + + // ── Profile blobs ──────────────────────────────────────────────────────── + + [Fact] + public void TryParse_ArmorProfile_ParsesEightF32() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(0u); // guid + bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.ArmorProfile); + bw.Write(1u); // success + // 8 float protections + bw.Write(0.1f); bw.Write(0.2f); bw.Write(0.3f); bw.Write(0.4f); + bw.Write(0.5f); bw.Write(0.6f); bw.Write(0.7f); bw.Write(0.8f); + + var parsed = AppraiseInfoParser.TryParse(ms.ToArray()); + Assert.NotNull(parsed); + Assert.NotNull(parsed!.Value.ArmorProfile); + var armor = parsed.Value.ArmorProfile!.Value; + Assert.Equal(0.1f, armor.SlashingProtection, 4); + Assert.Equal(0.8f, armor.LightningProtection, 4); + } + + [Fact] + public void TryParse_ArmorLevels_NinePerBodyPart() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(0u); + bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels); + bw.Write(1u); + bw.Write(100); // Head + bw.Write(200); // Chest + bw.Write(150); // Abdomen + bw.Write(125); bw.Write(110); bw.Write(90); + bw.Write(140); bw.Write(130); bw.Write(80); + + var parsed = AppraiseInfoParser.TryParse(ms.ToArray()); + Assert.NotNull(parsed!.Value.ArmorLevels); + Assert.Equal(100, parsed.Value.ArmorLevels!.Value.Head); + Assert.Equal(200, parsed.Value.ArmorLevels.Value.Chest); + Assert.Equal(80, parsed.Value.ArmorLevels.Value.Foot); + } + + [Fact] + public void TryParse_WeaponProfile_TenFieldsMixedPrimitives() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(0u); + bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.WeaponProfile); + bw.Write(1u); + bw.Write(4u); // DamageType (Fire?) + bw.Write(30u); // WeaponTime + bw.Write(44u); // WeaponSkill + bw.Write(25u); // Damage + bw.Write(0.25); // DamageVariance (f64) + bw.Write(1.5); // DamageMod + bw.Write(1.0); // WeaponLength + bw.Write(2.0); // MaxVelocity + bw.Write(1.1); // WeaponOffense + bw.Write(5u); // MaxVelocityEstimated + + var parsed = AppraiseInfoParser.TryParse(ms.ToArray()); + Assert.NotNull(parsed!.Value.WeaponProfile); + var w = parsed.Value.WeaponProfile!.Value; + Assert.Equal(4u, w.DamageType); + Assert.Equal(25u, w.Damage); + Assert.Equal(1.5, w.DamageMod, 4); + Assert.Equal(5u, w.MaxVelocityEstimated); + } + + [Fact] + public void TryParse_CreatureProfile_AttributesPresent_WhenShowAttributesFlag() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(0u); + bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile); + bw.Write(1u); + bw.Write(0x08u); // flags: ShowAttributes only + bw.Write(500u); // Health + bw.Write(600u); // HealthMax + bw.Write(80u); // Str + bw.Write(70u); // End + bw.Write(65u); // Quic + bw.Write(60u); // Coord + bw.Write(100u); // Focus + bw.Write(100u); // Self + bw.Write(200u); // Stamina + bw.Write(150u); // Mana + bw.Write(250u); // StaminaMax + bw.Write(200u); // ManaMax + + var parsed = AppraiseInfoParser.TryParse(ms.ToArray()); + Assert.NotNull(parsed!.Value.CreatureProfile); + var c = parsed.Value.CreatureProfile!.Value; + Assert.Equal(0x08u, c.Flags); + Assert.Equal(500u, c.Health); + Assert.Equal(600u, c.HealthMax); + Assert.Equal((uint?)80u, c.Strength); + Assert.Equal((uint?)150u, c.Mana); + } + + [Fact] + public void TryParse_CreatureProfile_NoAttributesFlag_OnlyHealth() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(0u); + bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile); + bw.Write(1u); + bw.Write(0u); // flags = 0 + bw.Write(300u); // Health + bw.Write(400u); // HealthMax + + var parsed = AppraiseInfoParser.TryParse(ms.ToArray()); + Assert.NotNull(parsed!.Value.CreatureProfile); + var c = parsed.Value.CreatureProfile!.Value; + Assert.Null(c.Strength); + Assert.Null(c.AttributeHighlights); + Assert.Equal(300u, c.Health); + } + + [Fact] + public void TryParse_ArmorEnchantmentBitfield_HighlightAndColor() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + bw.Write(0u); + bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.ArmorEnchantmentBitfield); + bw.Write(1u); + bw.Write((ushort)0x00FF); + bw.Write((ushort)0x0042); + + var parsed = AppraiseInfoParser.TryParse(ms.ToArray()); + Assert.NotNull(parsed!.Value.ArmorEnchantments); + Assert.Equal((ushort)0x00FF, parsed.Value.ArmorEnchantments!.Value.Highlight); + Assert.Equal((ushort)0x0042, parsed.Value.ArmorEnchantments.Value.Color); + } }