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