feat(net): AppraiseInfoParser — ArmorProfile/CreatureProfile/WeaponProfile + enchantment bitfields

Completes the deferred-in-previous-commit profile blob deserializers.
The AppraiseInfo wire format has 10 flags; previous commit handled the
6 property tables + SpellBook; this adds the 7 remaining structured
blobs:

- ArmorProfile: 8× f32 per-damage-type protection values
  (Slashing / Piercing / Bludgeoning / Cold / Fire / Acid / Nether /
  Lightning).
- ArmorLevel: 9× i32 per-body-part AL
  (Head / Chest / Abdomen / UpperArm / LowerArm / Hand / UpperLeg /
  LowerLeg / Foot).
- WeaponProfile: 10 mixed fields — u32 DamageType / WeaponTime /
  WeaponSkill / Damage, f64 DamageVariance / DamageMod / WeaponLength /
  MaxVelocity / WeaponOffense, u32 MaxVelocityEstimated.
- CreatureProfile: flag-gated — always u32 Flags + Health + HealthMax,
  optional 10× u32 attributes + vitals (flag 0x08 = ShowAttributes),
  optional 2× u16 highlight/color (flag 0x01 = HasBuffsDebuffs).
- Enchantment bitfields (ArmorEnchantmentBitfield /
  WeaponEnchantmentBitfield / ResistEnchantmentBitfield): each 2× u16
  (highlight, color).

HookProfile (flag 0x200) still deferred — needs its own structure port.

Parsed record expanded to carry all these; callers that previously
consumed PropertyBundle + SpellBook keep working, new fields are
nullable record-struct payloads.

Tests (+6): ArmorProfile round-trip, ArmorLevels, WeaponProfile with
mixed primitives, CreatureProfile with + without attributes flag,
ArmorEnchantment bitfield.

Build green, 172 Core.Net tests pass (up from 166).

Ref: ACE AppraiseInfo.cs:735-778 (writer), ArmorProfile.cs / ArmorLevel.cs /
WeaponProfile.cs / CreatureProfile.cs (structure writers).
Ref: r08 §4 opcode 0x00C9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:24:35 +02:00
parent 63b6922fc2
commit a9f366718d
2 changed files with 321 additions and 5 deletions

View file

@ -67,12 +67,67 @@ public static class AppraiseInfoParser
ArmorLevels = 0x0000_4000,
}
/// <summary>Per-damage-type protections from an ArmorProfile blob.</summary>
public readonly record struct ArmorProfile(
float SlashingProtection,
float PiercingProtection,
float BludgeoningProtection,
float ColdProtection,
float FireProtection,
float AcidProtection,
float NetherProtection,
float LightningProtection);
/// <summary>Per-slot AL (armor level) from an ArmorLevels blob.</summary>
public readonly record struct ArmorLevel(
int Head, int Chest, int Abdomen,
int UpperArm, int LowerArm, int Hand,
int UpperLeg, int LowerLeg, int Foot);
/// <summary>Melee/missile weapon statistics from a WeaponProfile blob.</summary>
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);
/// <summary>Creature vitals + (optionally) attributes from a CreatureProfile blob.</summary>
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);
/// <summary>
/// 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<uint>();
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<byte> 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<byte> 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<byte> 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<byte> 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<byte> 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<byte> 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 ────────────────────────────────────────────────────────

View file

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