using System.Buffers.Binary; using System.Text; using AcDream.Core.Net.Messages; namespace AcDream.Core.Net.Tests; /// /// Wire-format tests for . /// Builds synthetic payloads matching ACE /// GameEventPlayerDescription.WriteEventBody and confirms the /// walker extracts the attribute block + early sections correctly. /// public sealed class PlayerDescriptionParserTests { /// /// Build a minimal PlayerDescription payload with empty property /// flags + no-attribute vector flags. Just header bytes — useful /// for testing the most basic walk. /// 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; } /// /// 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. /// 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]); } [Fact] public void TryParse_TrailerOptionFlagsAndOptions1_AreReadAfterEnchantments() { // ATTRIBUTE | ENCHANTMENT vector flag; empty enchantment mask (0). // After mask, trailer adds u32 option_flags + u32 options1. 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(0u); // EnchantmentMask = empty // Trailer header: option_flags + options1 writer.Write(0u); // option_flags = None — no further sections writer.Write(0xDEADBEEFu); // options1 sentinel // No more bytes — spellbook_filters is optional (defaults to 0). var parsed = PlayerDescriptionParser.TryParse(sb.ToArray()); Assert.NotNull(parsed); Assert.Equal(PlayerDescriptionParser.CharacterOptionDataFlag.None, parsed!.Value.OptionFlags); Assert.Equal(0xDEADBEEFu, parsed.Value.Options1); Assert.Empty(parsed.Value.Shortcuts); Assert.Empty(parsed.Value.Inventory); // Defaults for the trailer fields not yet read (Tasks 3-9 will // populate them). Asserting them here gives those tasks a // pre-existing regression guard if they accidentally consume into // the wrong field's wire bytes. Assert.Equal(0u, parsed.Value.Options2); Assert.Equal(0u, parsed.Value.SpellbookFilters); Assert.Empty(parsed.Value.HotbarSpells); Assert.Empty(parsed.Value.DesiredComps); Assert.True(parsed.Value.GameplayOptions.IsEmpty); Assert.Empty(parsed.Value.Equipped); Assert.False(parsed.Value.TrailerTruncated); } [Fact] public void TryParse_TrailerAbsent_LessThan8BytesAfterEnchantments_PreservesUpstreamAndDoesNotFlagTruncation() { // Fewer than 8 bytes remain after the enchantment block, so the // trailer header is treated as absent (no read attempted). Upstream // attribute data must survive; TrailerTruncated stays false because // the parser never *started* the trailer — it correctly skipped it. // (Tasks 3-9 will introduce truncation-mid-section cases that flip // TrailerTruncated to true.) 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 // Attribute block: only Strength (bit 0). writer.Write(0x01u); writer.Write(50u); writer.Write(10u); writer.Write(0u); // Empty enchantment mask. writer.Write(0u); // Truncated trailer: only 4 bytes (would-be option_flags) instead of 8. writer.Write(0xCAFEu); var parsed = PlayerDescriptionParser.TryParse(sb.ToArray()); Assert.NotNull(parsed); // Upstream attribute survived. Assert.Single(parsed!.Value.Attributes); Assert.Equal(1u, parsed.Value.Attributes[0].AtType); // Trailer was absent (< 8 bytes), so no truncation flag and all // trailer fields stay at their initial defaults. Assert.False(parsed.Value.TrailerTruncated); Assert.Equal(PlayerDescriptionParser.CharacterOptionDataFlag.None, parsed.Value.OptionFlags); Assert.Equal(0u, parsed.Value.Options1); } }