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>
337 lines
15 KiB
C#
337 lines
15 KiB
C#
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]);
|
||
}
|
||
}
|