596 lines
26 KiB
C#
596 lines
26 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]);
|
||
}
|
||
|
||
[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_TrailerShortcuts_PopulatesList()
|
||
{
|
||
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); // empty enchantment mask
|
||
|
||
writer.Write(0x01u); // option_flags = SHORTCUT
|
||
writer.Write(0xCAFEu); // options1 sentinel
|
||
|
||
// Shortcut count + 2 entries (16 B each).
|
||
writer.Write(2u);
|
||
writer.Write(0u); writer.Write(0xAABBCCDDu); writer.Write((ushort)0); writer.Write((ushort)0);
|
||
writer.Write(7u); writer.Write(0u); writer.Write((ushort)1234); writer.Write((ushort)5);
|
||
|
||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(2, parsed!.Value.Shortcuts.Count);
|
||
Assert.Equal(0u, parsed.Value.Shortcuts[0].Index);
|
||
Assert.Equal(0xAABBCCDDu, parsed.Value.Shortcuts[0].ObjectGuid);
|
||
Assert.Equal((ushort)0, parsed.Value.Shortcuts[0].SpellId);
|
||
Assert.Equal(7u, parsed.Value.Shortcuts[1].Index);
|
||
Assert.Equal((ushort)1234, parsed.Value.Shortcuts[1].SpellId);
|
||
Assert.Equal((ushort)5, parsed.Value.Shortcuts[1].Layer);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_TrailerShortcuts_TruncatedMidList_FlagsTrailerTruncatedAndPreservesPriorEntries()
|
||
{
|
||
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); // empty enchantment mask
|
||
|
||
writer.Write(0x01u); // option_flags = SHORTCUT
|
||
writer.Write(0u); // options1
|
||
writer.Write(3u); // claimed shortcut count = 3
|
||
// First entry complete (16 B).
|
||
writer.Write(1u); writer.Write(0xAAAAu); writer.Write((ushort)10); writer.Write((ushort)1);
|
||
// Second entry truncated to 8 bytes — ReadU16 will throw FormatException.
|
||
writer.Write(2u); writer.Write(0xBBBBu);
|
||
// (no SpellId/Layer — payload ends here)
|
||
|
||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||
|
||
Assert.NotNull(parsed);
|
||
// Inner catch fired — flag set.
|
||
Assert.True(parsed!.Value.TrailerTruncated);
|
||
// First entry survives in the partial list.
|
||
Assert.Single(parsed.Value.Shortcuts);
|
||
Assert.Equal(1u, parsed.Value.Shortcuts[0].Index);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_TrailerHotbarSpells_SpellLists8_Reads8Lists()
|
||
{
|
||
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); // empty enchantment mask
|
||
|
||
writer.Write(0x400u); // option_flags = SPELL_LISTS8
|
||
writer.Write(0u); // options1
|
||
|
||
// 8 hotbars: counts {2,1,0,0,0,0,0,3}
|
||
writer.Write(2u); writer.Write(11u); writer.Write(12u);
|
||
writer.Write(1u); writer.Write(21u);
|
||
writer.Write(0u);
|
||
writer.Write(0u);
|
||
writer.Write(0u);
|
||
writer.Write(0u);
|
||
writer.Write(0u);
|
||
writer.Write(3u); writer.Write(81u); writer.Write(82u); writer.Write(83u);
|
||
|
||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(8, parsed!.Value.HotbarSpells.Count);
|
||
Assert.Equal(new uint[] { 11u, 12u }, parsed.Value.HotbarSpells[0]);
|
||
Assert.Equal(new uint[] { 21u }, parsed.Value.HotbarSpells[1]);
|
||
Assert.Empty(parsed.Value.HotbarSpells[2]);
|
||
Assert.Equal(new uint[] { 81u, 82u, 83u }, parsed.Value.HotbarSpells[7]);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_TrailerHotbarSpells_NoSpellLists8_ReadsSingleLegacyList()
|
||
{
|
||
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); // empty enchantment mask
|
||
|
||
writer.Write(0u); // option_flags = None (no SPELL_LISTS8)
|
||
writer.Write(0u); // options1
|
||
|
||
// Legacy single hotbar list: count=2, two spells.
|
||
writer.Write(2u); writer.Write(101u); writer.Write(102u);
|
||
|
||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Single(parsed!.Value.HotbarSpells);
|
||
Assert.Equal(new uint[] { 101u, 102u }, parsed.Value.HotbarSpells[0]);
|
||
}
|
||
|
||
[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);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_TrailerDesiredComps_ReadsIdAmtPairs()
|
||
{
|
||
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); // empty enchantment mask
|
||
|
||
// option_flags = DESIRED_COMPS (0x08); no SPELL_LISTS8 so legacy hotbar list (count=0).
|
||
writer.Write(0x08u);
|
||
writer.Write(0u); // options1
|
||
|
||
// Legacy hotbar list: count=0
|
||
writer.Write(0u);
|
||
|
||
// DESIRED_COMPS: u16 count=2, u16 padding, then 2 (id,amt) pairs of 8 bytes each.
|
||
writer.Write((ushort)2);
|
||
writer.Write((ushort)0);
|
||
writer.Write(0xAAu); writer.Write(50u);
|
||
writer.Write(0xBBu); writer.Write(75u);
|
||
|
||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(2, parsed!.Value.DesiredComps.Count);
|
||
Assert.Equal((0xAAu, 50u), parsed.Value.DesiredComps[0]);
|
||
Assert.Equal((0xBBu, 75u), parsed.Value.DesiredComps[1]);
|
||
}
|
||
|
||
[Fact]
|
||
public void TryParse_TrailerSpellbookFilters_ReadOptionalU32()
|
||
{
|
||
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); // empty enchantment mask
|
||
|
||
writer.Write(0u); // option_flags = None
|
||
writer.Write(0u); // options1
|
||
|
||
// Legacy hotbar list: count=0
|
||
writer.Write(0u);
|
||
|
||
// spellbook_filters sentinel.
|
||
writer.Write(0xF00DBA42u);
|
||
|
||
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
|
||
|
||
Assert.NotNull(parsed);
|
||
Assert.Equal(0xF00DBA42u, parsed!.Value.SpellbookFilters);
|
||
}
|
||
}
|