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:
parent
63b6922fc2
commit
a9f366718d
2 changed files with 321 additions and 5 deletions
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue