acdream/tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs
Erik 196f883c10 fix(player): EnchantmentMask bit fix + Vitae key=0 + absolute Vitals overlay
Three fixes to the Vitals HUD path:

1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
   ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
   has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
   the Vitae singleton with mask bit 0x4 set, my parser read it as
   "Cooldown" and tried to consume a count-prefixed list (no count
   present), blowing up with FormatException, returning null from
   TryParse. PlayerDescription consequently failed to parse on every
   live login. Fix: swap the bit values + bucket constants to match ACE.

2. Vitae applies regardless of StatModKey. Live trace showed:
     vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
   ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
   per retail. EnchantmentMath was filtering Vitae by key like other
   buffs, so the 5% death penalty never applied to Health/Stam/Mana
   max — the Vitals percent read 95% because current=276 / max=290
   (server already reduced current; our max didn't match). Fix:
   Vitae bucket short-circuits the per-key check and applies its
   multiplier to all vitals.

3. Absolute current/max in HUD overlay. VitalsVM exposes
   HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
   LocalPlayerState. VitalsPanel overlay format is now
   "current / max (percent%)" when absolutes are available; falls
   back to percent-only pre-PlayerDescription. Matches the retail
   look the user requested ("HP 400/400" style).

Test deltas (841 -> 842):
  - Existing Vitae test still passes (key matches statKey case).
  - New Vitae key=0 test pins the "any vital" semantics.
  - Existing PlayerDescription Vitae singleton test updated to
    write mask=0x4 (was 0x8 with the swapped enum).

Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
  HP   : current=138, max=145×0.95≈138 → bar 100% (was 95%)
  Stam : current=276, max=290×0.95≈276 → bar 100%
  Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:15:20 +02:00

337 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Wire-format tests for <see cref="PlayerDescriptionParser"/>.
/// Builds synthetic payloads matching ACE
/// <c>GameEventPlayerDescription.WriteEventBody</c> and confirms the
/// walker extracts the attribute block + early sections correctly.
/// </summary>
public sealed class PlayerDescriptionParserTests
{
/// <summary>
/// Build a minimal PlayerDescription payload with empty property
/// flags + no-attribute vector flags. Just header bytes — useful
/// for testing the most basic walk.
/// </summary>
private static byte[] BuildEmpty(uint weenieType = 1u)
{
// u32 propertyFlags + u32 weenieType + u32 vectorFlags + u32 has_health
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0u); // no property flags
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), weenieType);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u); // no vector flags
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), 0u); // has_health=false
return body;
}
/// <summary>
/// Build a payload with an Attribute-block-only body
/// (vector_flags = ATTRIBUTE) populating all 9 entries with known
/// ranks/start/xp values — primary attrs 1..6 and vitals 7..9.
/// </summary>
private static byte[] BuildWithFullAttributeBlock(
uint healthCurrent, uint stamCurrent, uint manaCurrent)
{
// No property tables, no positions.
// Header (8) + vectorFlags+has_health (8) + attribFlags (4)
// + 6 primary entries × 12 + 3 vital entries × 16 = 8+8+4+72+48 = 140.
byte[] body = new byte[140];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // propertyFlags = 0
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // weenieType
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4; // vectorFlags = ATTRIBUTE
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // has_health = true
// attributeFlags = AttributeCache.Full = 0x1FF (bits 0..8)
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1FFu); p += 4;
// Primary attrs 1..6 — ranks/start/xp triplets.
for (uint i = 1; i <= 6; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 10u * i); p += 4; // ranks
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u + i); p += 4; // start
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1000u); p += 4; // xp
}
// Vitals 7..9 — Health (id=7), Stamina (id=8), Mana (id=9).
// ranks=20, start=80 → MaxApprox = 100. Currents = test args.
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: healthCurrent);
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: stamCurrent);
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: manaCurrent);
return body;
}
private static void WriteVital(byte[] body, ref int p, uint ranks, uint start, uint xp, uint current)
{
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), current); p += 4;
}
[Fact]
public void TryParse_ReturnsNull_OnTruncatedHeader()
{
byte[] tooShort = new byte[4];
Assert.Null(PlayerDescriptionParser.TryParse(tooShort));
}
[Fact]
public void TryParse_EmptyBody_ParsesHeaderOnly()
{
var p = PlayerDescriptionParser.TryParse(BuildEmpty(weenieType: 0x52u));
Assert.NotNull(p);
Assert.Equal(0x52u, p!.Value.WeenieType);
Assert.Equal(PlayerDescriptionParser.DescriptionPropertyFlag.None, p.Value.PropertyFlags);
Assert.Equal(PlayerDescriptionParser.DescriptionVectorFlag.None, p.Value.VectorFlags);
Assert.False(p.Value.HasHealth);
Assert.Empty(p.Value.Attributes);
}
[Fact]
public void TryParse_AttributeBlock_PopulatesAllNineEntries()
{
var p = PlayerDescriptionParser.TryParse(
BuildWithFullAttributeBlock(healthCurrent: 90, stamCurrent: 75, manaCurrent: 60));
Assert.NotNull(p);
Assert.True(p!.Value.HasHealth);
Assert.Equal(9, p.Value.Attributes.Count);
// Primary attrs 1..6 have null Current.
for (uint i = 1; i <= 6; i++)
{
var attr = p.Value.Attributes.First(a => a.AtType == i);
Assert.Equal(10u * i, attr.Ranks);
Assert.Equal(50u + i, attr.Start);
Assert.Equal(1000u, attr.Xp);
Assert.Null(attr.Current);
}
// Vital 7 = Health (current = 90).
var health = p.Value.Attributes.First(a => a.AtType == 7);
Assert.Equal(20u, health.Ranks);
Assert.Equal(80u, health.Start);
Assert.Equal(90u, health.Current);
// Vital 8 = Stamina.
var stam = p.Value.Attributes.First(a => a.AtType == 8);
Assert.Equal(75u, stam.Current);
// Vital 9 = Mana.
var mana = p.Value.Attributes.First(a => a.AtType == 9);
Assert.Equal(60u, mana.Current);
}
[Fact]
public void TryParse_SkipsPrimaryAttribute_WhenItsAttributeFlagBitIsClear()
{
// attribute_flags only sets bits for Strength (id=1) and Health (id=7).
// Body shape: 8 header + 8 vector header + 4 attr_flags + 12 (str) + 16 (health) = 48
byte[] body = new byte[48];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4;
// bit 0 (Strength = id 1) + bit 6 (Health = id 7) = 0x41
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x41u); p += 4;
// Strength entry (12 B):
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 100u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 60u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4;
// Health entry (16 B):
WriteVital(body, ref p, ranks: 25u, start: 75u, xp: 0u, current: 99u);
var parsed = PlayerDescriptionParser.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Attributes.Count);
Assert.Equal(1u, parsed.Value.Attributes[0].AtType); // Strength
Assert.Equal(7u, parsed.Value.Attributes[1].AtType); // Health
Assert.Equal(99u, parsed.Value.Attributes[1].Current);
}
[Fact]
public void TryParse_PropertyTablesWalked_OffsetReachesAttributeBlock()
{
// PROPERTY_INT32 + PROPERTY_STRING tables present, then ATTRIBUTE
// block. If the walker can't skip past the property tables it'll
// read garbage from the table bytes as if it were the attribute
// block — this test fails on any walking error.
// PROPERTY_INT32 = 0x0001, PROPERTY_STRING = 0x0010 → flags = 0x0011
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0x0011u); // propertyFlags = INT32 | STRING
writer.Write(0x52u); // weenieType
// INT32 table: 2 entries.
writer.Write((ushort)2);
writer.Write((ushort)32); // buckets (ignored)
writer.Write(101u); writer.Write(42);
writer.Write(102u); writer.Write(-7);
// STRING table: 1 entry "Acdream" (7 chars + 2 length + 3 padding = 12).
writer.Write((ushort)1);
writer.Write((ushort)32); // buckets
writer.Write(1u); // PropertyString.Name = 1
byte[] name = Encoding.ASCII.GetBytes("Acdream");
writer.Write((ushort)name.Length);
writer.Write(name);
writer.Write(new byte[(4 - ((2 + name.Length) & 3)) & 3]); // pad to 4
// vectorFlags = ATTRIBUTE, has_health = 1.
writer.Write(0x01u);
writer.Write(1u);
// Attribute block: only Health (bit 6 = 0x40), current=88.
writer.Write(0x40u);
WriteVitalToWriter(writer, ranks: 30u, start: 70u, xp: 0u, current: 88u);
byte[] body = sb.ToArray();
var parsed = PlayerDescriptionParser.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Properties.Ints.Count);
Assert.Equal(42, parsed.Value.Properties.Ints[101]);
Assert.Equal(-7, parsed.Value.Properties.Ints[102]);
Assert.Equal("Acdream", parsed.Value.Properties.Strings[1]);
Assert.Single(parsed.Value.Attributes);
Assert.Equal(7u, parsed.Value.Attributes[0].AtType);
Assert.Equal(88u, parsed.Value.Attributes[0].Current);
}
private static void WriteVitalToWriter(BinaryWriter w, uint ranks, uint start, uint xp, uint current)
{
w.Write(ranks); w.Write(start); w.Write(xp); w.Write(current);
}
[Fact]
public void TryParse_EnchantmentBlock_PopulatesEnchantments_WithStatModAndBucket()
{
// ATTRIBUTE | SPELL | ENCHANTMENT vector flag (= 0x301 minus
// SKILL = 0x301 incl. ATTRIBUTE+SPELL+ENCHANTMENT). Empty
// attribute block + empty spell table + 1 multiplicative
// enchantment + 1 additive enchantment. Verifies end-to-end
// that the enchantment record schema lands intact.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
// vectorFlags = ATTRIBUTE (0x01) | SPELL (0x100) | ENCHANTMENT (0x200) = 0x301
writer.Write(0x301u);
writer.Write(1u); // has_health
writer.Write(0u); // attribute_flags = 0 -> no entries
// Spell table: empty (count=0).
writer.Write((ushort)0);
writer.Write((ushort)0);
// EnchantmentMask = MULTIPLICATIVE (0x01) | ADDITIVE (0x02) = 0x03
writer.Write(0x03u);
// Multiplicative list: 1 entry
writer.Write(1u);
WriteEnchantment(writer,
spellId: 1234, layer: 5, spellCategory: 100, hasSpellSetId: 0,
powerLevel: 999, startTime: 12.5, duration: 1800.0,
casterGuid: 0xCAFE0001u, degradeMod: 1.0f, degradeLimit: 0.5f,
lastDegraded: 0.0, statModType: 0x00010000u, statModKey: 3u /* MaxStamina */,
statModValue: 1.5f);
// Additive list: 1 entry
writer.Write(1u);
WriteEnchantment(writer,
spellId: 5678, layer: 6, spellCategory: 101, hasSpellSetId: 0,
powerLevel: 100, startTime: 13.0, duration: 1500.0,
casterGuid: 0xCAFE0002u, degradeMod: 1.0f, degradeLimit: 0.5f,
lastDegraded: 0.0, statModType: 0x00020000u, statModKey: 5u /* MaxMana */,
statModValue: 25.0f);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Enchantments.Count);
var mult = parsed.Value.Enchantments[0];
Assert.Equal((ushort)1234, mult.SpellId);
Assert.Equal((ushort)5, mult.Layer);
Assert.Equal(3u, mult.StatModKey);
Assert.Equal(1.5f, mult.StatModValue);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Multiplicative, mult.Bucket);
var add = parsed.Value.Enchantments[1];
Assert.Equal((ushort)5678, add.SpellId);
Assert.Equal(5u, add.StatModKey);
Assert.Equal(25.0f, add.StatModValue);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Additive, add.Bucket);
}
[Fact]
public void TryParse_VitaeSingleton_AppearsInEnchantments()
{
// EnchantmentMask = VITAE only (0x08). Single Enchantment, no
// count prefix.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0x04u); // EnchantmentMask = VITAE (ACE bit 2)
WriteEnchantment(writer,
spellId: 7777, layer: 0, spellCategory: 0, hasSpellSetId: 0,
powerLevel: 0, startTime: 0.0, duration: -1.0,
casterGuid: 0u, degradeMod: 0f, degradeLimit: 0f,
lastDegraded: 0.0, statModType: 0u, statModKey: 1u /* MaxHealth */,
statModValue: 0.95f);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Single(parsed!.Value.Enchantments);
Assert.Equal(PlayerDescriptionParser.EnchantmentBucket.Vitae, parsed.Value.Enchantments[0].Bucket);
Assert.Equal(0.95f, parsed.Value.Enchantments[0].StatModValue);
}
private static void WriteEnchantment(BinaryWriter w,
ushort spellId, ushort layer, ushort spellCategory, ushort hasSpellSetId,
uint powerLevel, double startTime, double duration, uint casterGuid,
float degradeMod, float degradeLimit, double lastDegraded,
uint statModType, uint statModKey, float statModValue)
{
w.Write(spellId); w.Write(layer); w.Write(spellCategory); w.Write(hasSpellSetId);
w.Write(powerLevel); w.Write(startTime); w.Write(duration);
w.Write(casterGuid);
w.Write(degradeMod); w.Write(degradeLimit); w.Write(lastDegraded);
w.Write(statModType); w.Write(statModKey); w.Write(statModValue);
// Skip optional spell_set_id (only present if hasSpellSetId != 0).
}
[Fact]
public void TryParse_SpellTable_PopulatesSpellsDictionary()
{
// ATTRIBUTE | SPELL = 0x101. Empty attribute block (flags=0). Spell
// table with two entries.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x101u); // vectorFlags = ATTRIBUTE | SPELL
writer.Write(1u); // has_health
writer.Write(0u); // attribute_flags = 0 → no entries
writer.Write((ushort)2); // spell count
writer.Write((ushort)64); // spell buckets
writer.Write(1234u); writer.Write(2.0f);
writer.Write(5678u); writer.Write(2.0f);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Spells.Count);
Assert.Equal(2.0f, parsed.Value.Spells[1234u]);
Assert.Equal(2.0f, parsed.Value.Spells[5678u]);
}
}