using System; using System.Buffers.Binary; using System.IO; using System.Text; using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Player; using AcDream.Core.Spells; using AcDream.Core.Social; using Xunit; namespace AcDream.Core.Net.Tests; public sealed class GameEventWiringTests { private static byte[] MakeString16L(string s) { byte[] data = Encoding.ASCII.GetBytes(s); int recordSize = 2 + data.Length; int padding = (4 - (recordSize & 3)) & 3; byte[] result = new byte[recordSize + padding]; BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); Array.Copy(data, 0, result, 2, data.Length); return result; } private static byte[] WrapEnvelope(GameEventType type, byte[] payload) { byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type); Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length); return body; } private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll(Func? playerGuid = null) { var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, playerGuid: playerGuid); return (dispatcher, items, combat, spellbook, chat); } [Fact] public void WireAll_ChannelBroadcast_RoutesToChatLog() { var (d, _, _, _, chat) = MakeAll(); byte[] payload = new byte[4 + MakeString16L("Alice").Length + MakeString16L("hi").Length]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 42); int p = 4; var senderBytes = MakeString16L("Alice"); Array.Copy(senderBytes, 0, payload, p, senderBytes.Length); p += senderBytes.Length; var msgBytes = MakeString16L("hi"); Array.Copy(msgBytes, 0, payload, p, msgBytes.Length); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ChannelBroadcast, payload)); d.Dispatch(env!.Value); Assert.Equal(1, chat.Count); var entry = chat.Snapshot()[0]; Assert.Equal(ChatKind.Channel, entry.Kind); Assert.Equal("Alice", entry.Sender); } [Fact] public void WireAll_UpdateHealth_RoutesToCombatState() { var (d, _, combat, _, _) = MakeAll(); byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xCAFE); BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UpdateHealth, payload)); d.Dispatch(env!.Value); Assert.Equal(0.42f, combat.GetHealthPercent(0xCAFE), 4); } [Fact] public void WireAll_MagicUpdateSpell_RoutesToSpellbook() { var (d, _, _, book, _) = MakeAll(); byte[] payload = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicUpdateSpell, payload)); d.Dispatch(env!.Value); Assert.True(book.Knows(0x3E1)); } [Fact] public void WireAll_WieldObject_RoutesToClientObjectTable() { const uint player = 0x2000u; var (d, items, _, _, _) = MakeAll(() => player); items.AddOrUpdate(new ClientObject { ObjectId = 0x1000, WeenieClassId = 1 }); byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); d.Dispatch(env!.Value); var item = items.Get(0x1000); Assert.NotNull(item); Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation); Assert.Equal(0u, item.ContainerId); Assert.Equal(player, item.WielderId); } [Fact] public void WireAll_WieldObject_ConfirmsOptimisticWield() { const uint player = 0x2000u; var (d, items, _, _, _) = MakeAll(() => player); items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 }); items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending var wieldConfirmed = new List(); items.WieldConfirmed += itemId => wieldConfirmed.Add(itemId); byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); d.Dispatch(env!.Value); // server confirms the wield Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove Assert.Equal(0u, items.Get(0x1500)!.ContainerId); Assert.Equal(player, items.Get(0x1500)!.WielderId); Assert.Equal(new[] { 0x1500u }, wieldConfirmed); } [Fact] public void WireAll_ConfirmationRequest_UsesRetailTwoIntegerHeader() { var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); GameEvents.CharacterConfirmationRequest? received = null; GameEventWiring.WireAll( dispatcher, items, combat, spellbook, chat, onConfirmationRequest: request => received = request); byte[] text = MakeString16L("Proceed?"); byte[] payload = new byte[8 + text.Length]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u); text.CopyTo(payload.AsSpan(8)); var env = GameEventEnvelope.TryParse(WrapEnvelope( GameEventType.CharacterConfirmationRequest, payload)); dispatcher.Dispatch(env!.Value); Assert.Equal(new GameEvents.CharacterConfirmationRequest(7u, 42u, "Proceed?"), received); } [Fact] public void WireAll_ConfirmationDone_UsesRetailTypeAndContextTuple() { var dispatcher = new GameEventDispatcher(); GameEvents.CharacterConfirmationDone? received = null; GameEventWiring.WireAll( dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), onConfirmationDone: done => received = done); byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 6u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 314u); var env = GameEventEnvelope.TryParse(WrapEnvelope( GameEventType.CharacterConfirmationDone, payload)); dispatcher.Dispatch(env!.Value); Assert.Equal(new GameEvents.CharacterConfirmationDone(6u, 314u), received); } [Fact] public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState() { var dispatcher = new GameEventDispatcher(); var friends = new FriendsState(); GameEventWiring.WireAll( dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), friends: friends); byte[] name = MakeString16L("Alice"); byte[] payload = new byte[4 + 12 + name.Length + 4 + 4 + 4]; int p = 0; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 1u); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0x50000001u); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 1u); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4; name.CopyTo(payload.AsSpan(p)); p += name.Length; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4; BinaryPrimitives.WriteUInt32LittleEndian( payload.AsSpan(p), (uint)FriendsUpdateType.Full); var env = GameEventEnvelope.TryParse(WrapEnvelope( GameEventType.FriendsListUpdate, payload)); dispatcher.Dispatch(env!.Value); FriendEntry friend = Assert.Single(friends.Snapshot()); Assert.Equal("Alice", friend.Name); Assert.True(friend.Online); } [Fact] public void WireAll_PopupString_RoutesToChatLog() { var (d, _, _, _, chat) = MakeAll(); byte[] payload = MakeString16L("A modal message"); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PopupString, payload)); d.Dispatch(env!.Value); Assert.Equal(1, chat.Count); Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind); } [Theory] [InlineData("", "2d 4h", "You have played for 2d 4h.")] [InlineData("Alice", "1y 2mo", "Alice has played for 1y 2mo.")] public void WireAll_QueryAgeResponse_UsesRetailWording( string name, string age, string expected) { var (d, _, _, _, chat) = MakeAll(); byte[] nameBytes = MakeString16L(name); byte[] ageBytes = MakeString16L(age); byte[] payload = [.. nameBytes, .. ageBytes]; var env = GameEventEnvelope.TryParse( WrapEnvelope(GameEventType.QueryAgeResponse, payload)); d.Dispatch(env!.Value); Assert.Equal(expected, chat.Snapshot().Single().Text); } [Fact] public void WireAll_PlayerDescription_PopulatesLocalPlayerStateVitals() { // Issue #5 — the full pipeline: synthetic 0xF7B0 envelope wrapping // a PlayerDescription body with Health/Stam/Mana entries, dispatched // through WireAll, lands in LocalPlayerState with the right // ranks/start/current values. var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); var local = new LocalPlayerState(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local); // Body: empty property tables, ATTRIBUTE vector flag, all 9 attrs // present. Primary attrs: // Endurance (id=2): ranks=50 + start=150 → current=200 // Self (id=6): ranks=50 + start=50 → current=100 // Vitals (ranks+start = 0 — typical retail values): // Health (id=7) cur=90 → MaxApprox = 0 + 200/2 = 100 → percent 0.9 // Stamina (id=8) cur=140 → MaxApprox = 0 + 200 = 200 → percent 0.7 // Mana (id=9) cur=50 → MaxApprox = 0 + 100 = 100 → percent 0.5 byte[] body = new byte[140]; int p = 0; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // propertyFlags BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x52u); p += 4; // weenieType BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4; // vectorFlags = ATTRIBUTE BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // has_health BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1FFu); p += 4; // attribute_flags = Full // Primary attrs in order 1..6. WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 1 Strength WritePrimaryAttr(body, ref p, ranks: 50, start: 150, xp: 0); // 2 Endurance — current=200 WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 3 Quickness WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 4 Coordination WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 5 Focus WritePrimaryAttr(body, ref p, ranks: 50, start: 50, xp: 0); // 6 Self — current=100 // Vitals 7/8/9. WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 90); // Health WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 140); // Stamina WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 50); // Mana var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, body)); dispatcher.Dispatch(env!.Value); var health = local.Get(LocalPlayerState.VitalKind.Health); var stam = local.Get(LocalPlayerState.VitalKind.Stamina); var mana = local.Get(LocalPlayerState.VitalKind.Mana); Assert.NotNull(health); Assert.NotNull(stam); Assert.NotNull(mana); Assert.Equal(90u, health!.Value.Current); Assert.Equal(140u, stam!.Value.Current); Assert.Equal(50u, mana!.Value.Current); // Primary attrs landed too — formula contributions feed the max. Assert.Equal(200u, local.GetAttribute(LocalPlayerState.AttributeKind.Endurance)!.Value.Current); Assert.Equal(100u, local.GetAttribute(LocalPlayerState.AttributeKind.Self)!.Value.Current); Assert.Equal(0.9f, local.HealthPercent!.Value, precision: 3); Assert.Equal(0.7f, local.StaminaPercent!.Value, precision: 3); Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3); } [Fact] public void WireAll_PlayerDescription_PopulatesLocalPlayerStateSkills() { var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); var local = new LocalPlayerState(); int callbackRun = -1; GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local, onSkillsUpdated: (run, _) => callbackRun = run, resolveSkillFormulaBonus: (skillId, attrs) => { Assert.Equal(24u, skillId); Assert.Equal(50u, attrs[1u]); return 80u; }); var sb = new MemoryStream(); using var w = new BinaryWriter(sb); w.Write(0u); // propertyFlags w.Write(0x52u); // weenieType w.Write(0x03u); // vectorFlags = Attribute | Skill w.Write(0u); // has_health = false w.Write(0x01u); // attribute_flags = Strength only w.Write(0u); // Strength ranks w.Write(50u); // Strength start w.Write(0u); // Strength xp w.Write((ushort)1); // skill count w.Write((ushort)8); // buckets w.Write(24u); // Run w.Write((ushort)12); w.Write((ushort)1); // const_one w.Write(2u); // trained w.Write(3456u); // xp w.Write(30u); // init w.Write(0u); // resistance w.Write(1.5); // last used var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); var run = local.GetSkill(24u); Assert.NotNull(run); Assert.Equal(122u, run!.Value.CurrentLevel); Assert.Equal(2u, run.Value.Status); Assert.Equal(3456u, run.Value.Xp); Assert.Equal(122, callbackRun); } [Fact] public void WireAll_PlayerDescription_FeedsSpellbook() { var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); var local = new LocalPlayerState(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local); // Body: SPELL vector flag + a spell table with 2 entries. var sb = new MemoryStream(); using var w = new BinaryWriter(sb); w.Write(0u); // propertyFlags w.Write(0x52u); // weenieType w.Write(0x100u); // vectorFlags = SPELL only w.Write(0u); // has_health = false w.Write((ushort)2); // spell count w.Write((ushort)64); // buckets w.Write(0x3E1u); w.Write(2.0f); w.Write(0x3E2u); w.Write(2.0f); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); Assert.True(spellbook.Knows(0x3E1u)); Assert.True(spellbook.Knows(0x3E2u)); } private static void WriteVitalBlock(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; } private static void WritePrimaryAttr(byte[] body, ref int p, uint ranks, uint start, uint xp) { BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4; BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4; } [Fact] public void WireAll_KillerNotification_AppendsCombatLine() { var (d, _, _, _, chat) = MakeAll(); byte[] payload = MakeString16L("You killed the drudge!"); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload)); d.Dispatch(env!.Value); Assert.Equal(1, chat.Count); var entry = chat.Snapshot()[0]; Assert.Equal(ChatKind.Combat, entry.Kind); Assert.Equal(CombatLineKind.Info, entry.CombatKind); Assert.Equal("You killed the drudge!", entry.Text); } [Fact] public void WireAll_CombatCommenceAttack_FiresCombatStateEvent() { var (d, _, combat, _, _) = MakeAll(); bool commenced = false; combat.AttackCommenced += () => commenced = true; var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.CombatCommenceAttack, Array.Empty())); d.Dispatch(env!.Value); Assert.True(commenced); } [Fact] public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll() { var (d, _, _, book, _) = MakeAll(); book.OnEnchantmentAdded(new(1, 1, 100, 0, Bucket: 1)); book.OnEnchantmentAdded(new(2, 2, 100, 0, Bucket: 2)); Assert.Equal(2, book.ActiveCount); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicPurgeEnchantments, Array.Empty())); d.Dispatch(env!.Value); Assert.Equal(0, book.ActiveCount); } [Fact] public void WireAll_MagicUpdateEnchantment_ConvertsRelativeTimesAndClassifiesBucket() { var dispatcher = new GameEventDispatcher(); var book = new Spellbook(); GameEventWiring.WireAll( dispatcher, new ClientObjectTable(), new CombatState(), book, new ChatLog(), clientTime: () => 100d); byte[] payload = BuildEnchantment( spellId: 42, layer: 3, duration: 60, caster: 0xBEEF, startTime: 10, lastDegraded: 2, statModType: 0x00008000u); dispatcher.Dispatch(GameEventEnvelope.TryParse( WrapEnvelope(GameEventType.MagicUpdateEnchantment, payload))!.Value); ActiveEnchantmentRecord record = Assert.Single(book.ActiveEnchantmentSnapshot); Assert.Equal(110d, record.StartTime); Assert.Equal(102d, record.LastTimeDegraded); Assert.Equal(2u, record.Bucket); Assert.Equal(60d, record.Duration); } [Fact] public void WireAll_WeenieError_RoutesToChatLog() { // Phase I.5: 0x028A previously had a parser // (GameEvents.ParseWeenieError) but no dispatcher registration. The // server fires this for plain game-logic failures (e.g. "you can't // pick that up"). Now wired → ChatLog.OnWeenieError. var (d, _, _, _, chat) = MakeAll(); byte[] payload = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x9C); // arbitrary error code var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload)); d.Dispatch(env!.Value); Assert.Equal(1, chat.Count); var e = chat.Snapshot()[0]; Assert.Equal(ChatKind.System, e.Kind); Assert.Equal(0x9Cu, e.ChannelId); Assert.Contains("0x009C", e.Text); } [Theory] [InlineData(0x003Bu)] // ILeftTheWorld [InlineData(0x003Cu)] // ITeleported public void WireAll_RetailSilentClientControlStatus_DoesNotReachChat(uint code) { var (dispatcher, _, _, _, chat) = MakeAll(); byte[] payload = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(payload, code); dispatcher.Dispatch(GameEventEnvelope.TryParse( WrapEnvelope(GameEventType.WeenieError, payload))!.Value); Assert.Equal(0, chat.Count); } [Fact] public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation() { // Phase I.5: 0x028B carries an interpolated substring (e.g. the // target's name in "you can't pick up the {Mana Stone}"). Now // wired → ChatLog.OnWeenieError with the param. var (d, _, _, _, chat) = MakeAll(); byte[] interpBytes = MakeString16L("Mana Stone"); byte[] payload = new byte[4 + interpBytes.Length]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x42u); Array.Copy(interpBytes, 0, payload, 4, interpBytes.Length); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieErrorWithString, payload)); d.Dispatch(env!.Value); Assert.Equal(1, chat.Count); var e = chat.Snapshot()[0]; Assert.Equal(ChatKind.System, e.Kind); Assert.Equal(0x42u, e.ChannelId); Assert.Contains("Mana Stone", e.Text); } [Fact] public void PlayerDescription_RegistersInventoryEntries_InClientObjectTable() { // Issue #13 acceptance test: after a PlayerDescription with non-empty // Inventory is dispatched through WireAll, ClientObjectTable.ObjectCount > 0. // Wire format: strict path (no GAMEPLAY_OPTIONS bit) so inventory + // equipped follow directly after spellbook_filters. var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat); Assert.Equal(0, items.ObjectCount); // pre-condition var sb = new MemoryStream(); using var w = new BinaryWriter(sb); w.Write(0u); // propertyFlags = 0 w.Write(0x52u); // weenieType w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT w.Write(1u); // has_health w.Write(0u); // attribute_flags = 0 (no attrs) w.Write(0u); // enchantment_mask = 0 w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path) w.Write(0u); // options1 w.Write(0u); // legacy hotbar list count = 0 w.Write(0u); // spellbook_filters // Inventory: 2 entries w.Write(2u); w.Write(0x50000A01u); w.Write(0u); // guid, ContainerType=NonContainer w.Write(0x50000A02u); w.Write(1u); // guid, ContainerType=Container // Equipped: 0 entries w.Write(0u); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); Assert.Equal(2, items.ObjectCount); Assert.NotNull(items.Get(0x50000A01u)); Assert.NotNull(items.Get(0x50000A02u)); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint); Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); } [Fact] public void PlayerDescription_IndexesLoginEquipmentForDefaultCombatMode() { const uint playerGuid = 0x50000001u; const uint packItemGuid = 0x50000A01u; const uint crossbowGuid = 0x50000B01u; const uint ammoGuid = 0x50000B02u; var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); GameEventWiring.WireAll( dispatcher, items, new CombatState(), new Spellbook(), new ChatLog(), playerGuid: () => playerGuid); var sb = new MemoryStream(); using var w = new BinaryWriter(sb); w.Write(0u); // propertyFlags w.Write(0x52u); // weenieType w.Write(0x201u); // ATTRIBUTE | ENCHANTMENT w.Write(1u); // has_health w.Write(0u); // attribute_flags w.Write(0u); // enchantment_mask w.Write(0u); // option_flags w.Write(0u); // options1 w.Write(0u); // legacy hotbar count w.Write(0u); // spellbook_filters w.Write(1u); // inventory count w.Write(packItemGuid); w.Write(0u); w.Write(2u); // equipped count w.Write(crossbowGuid); w.Write((uint)EquipMask.MissileWeapon); w.Write(7u); // layering priority w.Write(ammoGuid); w.Write((uint)EquipMask.MissileAmmo); w.Write(8u); var envelope = GameEventEnvelope.TryParse( WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(envelope!.Value); ClientObject crossbow = items.Get(crossbowGuid)!; crossbow.Type = ItemType.MissileWeapon; crossbow.CombatUse = 2; IReadOnlyList orderedEquipment = items.GetEquippedBy(playerGuid); Assert.Equal(new[] { packItemGuid }, items.GetContents(playerGuid)); Assert.Equal( new[] { crossbowGuid, ammoGuid }, orderedEquipment.Select(item => item.ObjectId)); Assert.Equal(0u, crossbow.ContainerId); Assert.Equal(playerGuid, crossbow.WielderId); Assert.Equal(EquipMask.MissileWeapon, crossbow.CurrentlyEquippedLocation); Assert.Equal(7u, crossbow.Priority); Assert.Equal( CombatMode.Missile, CombatInputPlanner.GetDefaultCombatMode(orderedEquipment)); } [Fact] public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState() { var dispatcher = new GameEventDispatcher(); var itemMana = new ItemManaState(); (uint Guid, float Percent, bool Valid)? observed = null; itemMana.ItemManaChanged += (guid, percent, valid) => observed = (guid, percent, valid); GameEventWiring.WireAll( dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), itemMana: itemMana); byte[] payload = new byte[12]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.375f); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u); var envelope = GameEventEnvelope.TryParse( WrapEnvelope(GameEventType.QueryItemManaResponse, payload)); dispatcher.Dispatch(envelope!.Value); Assert.Equal((0x50000A01u, 0.375f, true), observed); Assert.True(itemMana.HasMana(0x50000A01u)); } [Fact] public void UseDone_releasesAppBusyOwnerThroughCallback() { var dispatcher = new GameEventDispatcher(); uint? completedWith = null; GameEventWiring.WireAll( dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), onUseDone: error => completedWith = error); byte[] payload = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1Du); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UseDone, payload)); dispatcher.Dispatch(env!.Value); Assert.Equal(0x1Du, completedWith); } [Fact] public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder() { const uint playerGuid = 0x50000001u; var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, playerGuid: () => playerGuid); var sb = new MemoryStream(); using var w = new BinaryWriter(sb); w.Write(0u); // propertyFlags = 0 w.Write(0x52u); // weenieType w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT w.Write(1u); // has_health w.Write(0u); // attribute_flags = 0 (no attrs) w.Write(0u); // enchantment_mask = 0 w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS -> strict inv path) w.Write(0u); // options1 w.Write(0u); // legacy hotbar list count = 0 w.Write(0u); // spellbook_filters w.Write(2u); w.Write(0x50000A02u); w.Write(1u); w.Write(0x50000A01u); w.Write(0u); w.Write(0u); // equipped count var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); Assert.Equal(new[] { 0x50000A02u, 0x50000A01u }, items.GetContents(playerGuid)); Assert.Equal(playerGuid, items.Get(0x50000A02u)!.ContainerId); Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint); } [Fact] public void PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse() { // D.5.4: PlayerDescription is a membership MANIFEST, not the data // source. The old code set WeenieClassId = inv.ContainerType (a // 0/1/2 discriminator), which is a misuse. After the fix, the // registered stub has WeenieClassId == 0 and the equipped item's // CurrentlyEquippedLocation is set to MeleeWeapon (0x1). // Uses the SAME wire fixture as PlayerDescription_RegistersInventoryEntries_InClientObjectTable. var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat); var sb = new MemoryStream(); using var w = new BinaryWriter(sb); w.Write(0u); // propertyFlags = 0 w.Write(0x52u); // weenieType w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT w.Write(1u); // has_health w.Write(0u); // attribute_flags = 0 (no attrs) w.Write(0u); // enchantment_mask = 0 w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path) w.Write(0u); // options1 w.Write(0u); // legacy hotbar list count = 0 w.Write(0u); // spellbook_filters // Inventory: 1 entry with ContainerType=1 (the OLD code would have // set WeenieClassId=1; the new code must leave WeenieClassId==0). w.Write(1u); w.Write(0x700u); w.Write(1u); // guid=0x700, ContainerType=1 // Equipped: 1 entry with EquipLocation = MeleeWeapon (0x1). // Wire format: guid(4) + loc(4) + priority(4) = 12 bytes per entry. w.Write(1u); w.Write(0x701u); w.Write((uint)EquipMask.MeleeWeapon); w.Write(0u); // guid=0x701, slot=MeleeWeapon, prio=0 var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); // (a) inventory guid is registered Assert.NotNull(items.Get(0x700u)); // (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId); Assert.Equal(1u, items.Get(0x700u)!.ContainerTypeHint); // (c) equipped guid has its equip slot set Assert.NotNull(items.Get(0x701u)); Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation); } [Fact] public void WireAll_PlayerDescription_invokesOnShortcuts() { // D.5.1 Task 4: WireAll must forward parsed.Shortcuts to the onShortcuts // callback so the toolbar can read them without keeping a parser reference. // Mirrors PlayerDescription_RegistersInventoryEntries_InClientObjectTable // for the harness pattern; adds the Shortcut flag (0x1) + one 12-byte // entry, followed by the legacy-hotbar count (0) + spellbook_filters (0) // then empty inventory and equipped. IReadOnlyList? got = null; var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, onShortcuts: list => got = list); // PlayerDescription body — minimal: no property flags, ATTRIBUTE|ENCHANTMENT // vectorFlags (so the parser sees has_health=1, attribute_flags=0, // enchantment_mask=0 and advances past both vector blocks), then the trailer // with option_flags=Shortcut (0x1). // // Trailer layout when option_flags=0x1 (Shortcut only, no SpellLists8): // u32 option_flags = 0x1 // u32 options1 = 0 // u32 count = 1 ← shortcut block (Shortcut flag set) // u32 idx = 0 // u32 guid = 0x5001 // u16 spellId = 0 // u16 layer = 0 // u32 legacyHotbar count = 0 ← SpellLists8 NOT set → legacy fallback // u32 spellbook_filters = 0 // u32 inventory count = 0 // u32 equipped count = 0 var sb = new MemoryStream(); using var w = new BinaryWriter(sb); w.Write(0u); // propertyFlags = 0 w.Write(0x52u); // weenieType w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT w.Write(1u); // has_health w.Write(0u); // attribute_flags = 0 (no attrs) w.Write(0u); // enchantment_mask = 0 // Trailer w.Write(0x00000001u); // option_flags = Shortcut w.Write(0u); // options1 // Shortcut block (option_flags & 0x1 set): w.Write(1u); // count = 1 w.Write(0u); // idx = 0 w.Write(0x5001u); // guid = 0x5001 w.Write((ushort)0); // spellId = 0 w.Write((ushort)0); // layer = 0 // SpellLists8 NOT set → legacy single-list fallback: w.Write(0u); // legacy hotbar list count = 0 // No DesiredComps, no CharacterOptions2, no GameplayOptions → strict path: w.Write(0u); // spellbook_filters = 0 w.Write(0u); // inventory count = 0 w.Write(0u); // equipped count = 0 var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); Assert.NotNull(got); Assert.Single(got!); Assert.Equal(0x5001u, got![0].ObjectId); } [Fact] public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject() { // PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler // must land it in the player ClientObject so the burden bar reads the wire value. const uint playerGuid = 0x50000001u; var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(), new ChatLog(), playerGuid: () => playerGuid); var sb = new MemoryStream(); using var w = new BinaryWriter(sb); w.Write(0x00000001u); // propertyFlags = PropertyInt32 w.Write(0x52u); // weenieType // int table: u16 count, u16 buckets, then key/val pairs w.Write((ushort)1); // count w.Write((ushort)8); // buckets (ignored) w.Write(5u); // key = EncumbranceVal w.Write(1500u); // val // vector + has_health (no vector blocks) w.Write(0u); // vectorFlags = None w.Write(0u); // has_health // strict trailer w.Write(0u); // option_flags = None w.Write(0u); // options1 w.Write(0u); // legacy hotbar count w.Write(0u); // spellbook_filters w.Write(0u); // inventory count w.Write(0u); // equipped count var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); var player = items.Get(playerGuid); Assert.NotNull(player); Assert.Equal(1500, player!.Properties.Ints[5]); } [Fact] public void WireAll_ViewContents_RecordsMembershipInClientObjectTable() { var (d, items, _, _, _) = MakeAll(); // containerGuid 0xC9 + 2 entries byte[] payload = new byte[8 + 2 * 8]; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); d.Dispatch(env!.Value); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); Assert.Equal(0u, items.Get(0x50000A02u)!.ContainerId); Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u)); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint); Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); } [Fact] public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems() { var (d, items, _, _, _) = MakeAll(); // First view of container 0xC9: two items. DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u }); Assert.Equal(2, items.GetContents(0x500000C9u).Count); // Second view: only one item — the other was removed server-side. Additive merge would keep both. DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u }); Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u)); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); } private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids) { byte[] payload = new byte[8 + itemGuids.Length * 8]; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length); for (int i = 0; i < itemGuids.Length; i++) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType } var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); d.Dispatch(env!.Value); } [Fact] public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove() { var (d, items, _, _, _) = MakeAll(); // An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1. items.RecordMembership(0x50000B01u, containerId: 0x500000C0u); items.MoveItem(0x50000B01u, 0x500000C0u, 2); items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0); Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic) // Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError). byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload)); d.Dispatch(env!.Value); Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot); } [Fact] public void WireAll_InventoryPutObjInContainer_ConfirmsOptimisticMove_soNoRollback() { var (d, items, _, _, _) = MakeAll(); items.RecordMembership(0x50000B02u, containerId: 0x500000C0u); items.MoveItem(0x50000B02u, 0x500000C0u, 2); items.MoveItemOptimistic(0x50000B02u, 0x500000C1u, 0); // optimistic move (pending snapshot) // Server CONFIRMS via InventoryPutObjInContainer (item, container, placement, containerType). byte[] payload = new byte[16]; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload)); d.Dispatch(env!.Value); // ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck). Assert.False(items.RollbackMove(0x50000B02u)); Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId); Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint); } [Fact] public void WireAll_InventoryPutObjInContainer_ClearsPriorWielder() { const uint player = 0x50000001u; const uint pack = 0x500000C1u; const uint bow = 0x50000B03u; var (d, items, _, _, _) = MakeAll(() => player); items.AddOrUpdate(new ClientObject { ObjectId = bow, ContainerId = player, WielderId = player, CurrentlyEquippedLocation = EquipMask.MissileWeapon, }); byte[] payload = new byte[16]; BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), bow); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), pack); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); var env = GameEventEnvelope.TryParse(WrapEnvelope( GameEventType.InventoryPutObjInContainer, payload)); d.Dispatch(env!.Value); Assert.Equal(pack, items.Get(bow)!.ContainerId); Assert.Equal(0u, items.Get(bow)!.WielderId); Assert.Equal(EquipMask.None, items.Get(bow)!.CurrentlyEquippedLocation); } [Fact] public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() { var (d, items, _, _, _) = MakeAll(); items.RecordMembership(0x50000A01u, containerId: 0x500000C9u); byte[] payload = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload)); d.Dispatch(env!.Value); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); } [Fact] public void WireAll_InventoryPutObjectIn3D_ConfirmsOptimisticDrop() { var (d, items, _, _, _) = MakeAll(); items.RecordMembership(0x50000A03u, containerId: 0x500000C9u); items.MoveItem(0x50000A03u, 0x500000C9u, 4); items.MoveItemOptimistic(0x50000A03u, newContainerId: 0u, newSlot: -1); byte[] payload = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A03u); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload)); d.Dispatch(env!.Value); Assert.Equal(0u, items.Get(0x50000A03u)!.ContainerId); Assert.False(items.RollbackMove(0x50000A03u)); } [Fact] public void WireAll_PlayerDescription_PublishesCharacterOptions() { var dispatcher = new GameEventDispatcher(); (uint Options1, uint Options2)? observed = null; GameEventWiring.WireAll( dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), onCharacterOptions: (options1, options2) => observed = (options1, options2)); var stream = new MemoryStream(); using var writer = new BinaryWriter(stream); writer.Write(0u); // property flags writer.Write(0x52u); // player weenie type writer.Write(0u); // vector flags writer.Write(0u); // has health writer.Write(0x40u); // option flags: CharacterOptions2 writer.Write(0x50C4A54Au); // options1 writer.Write(0u); // legacy hotbar count writer.Write(0u); // spellbook filters writer.Write(0x948700u); // options2 writer.Write(0u); // inventory count writer.Write(0u); // equipped count var envelope = GameEventEnvelope.TryParse( WrapEnvelope(GameEventType.PlayerDescription, stream.ToArray())); dispatcher.Dispatch(envelope!.Value); Assert.Equal((0x50C4A54Au, 0x948700u), observed); } private static byte[] BuildEnchantment( ushort spellId, ushort layer, double duration, uint caster, double startTime, double lastDegraded, uint statModType) { byte[] payload = new byte[60]; int offset = 0; WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0); WriteU32(8); WriteF64(startTime); WriteF64(duration); WriteU32(caster); WriteF32(0.1f); WriteF32(-1f); WriteF64(lastDegraded); WriteU32(statModType); WriteU32(7); WriteF32(1.25f); return payload; void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; } void WriteU32(uint value) { BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(offset), value); offset += 4; } void WriteF32(float value) { BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(offset), value); offset += 4; } void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; } } }