Two MUST-FIX findings from the FA2 mechanism/blast reviews (docs/research/2026-08-12-fa2-review-mechanism.md, docs/research/2026-08-12-fa2-review-blast.md): MF-1 (mechanism) -- RuntimeAllegianceState survived a generation reset, contradicting retail (ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0 tail-calls AllegianceProfile::Clear at the same boundary Fellowship already clears at), contradicting the precedent it cited (RuntimeCharacterOptionsState.ResetSession clears-and-relatches, it does not persist), and pinned by a test asserting the wrong behavior. Fixed: RuntimeAllegianceState.ResetSession() clears the profile and drops HasServerSeed; a new RuntimeGenerationResetStage.Allegiance stage runs it on every generation reset, mirroring RuntimeFellowshipState exactly. RuntimeGenerationResetTests' FellowshipClearsAtResetButAllegianceSurvivesReconnect inverted to FellowshipAndAllegianceBothClearAtGenerationReset. MF-2 (mechanism) / blast MF-2 -- 0x027C AllegianceInfoResponse fed the Runtime allegiance owner (self-gated). Retail's own handler for 0x027C (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470) unpacks into a stack-local profile destroyed on return; the consumer (Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0) only prints AddTextToScroll lines. Retail's panel is fed exclusively by 0x0020 AllegianceUpdate. The removed seeding also fabricated RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on any client whose first allegiance message was a self @allegiance info query. Fixed: dropped ApplyInfoResponseSelf, the onAllegianceInfoResponseSelf delegate hole, and the self-gate; 0x027C is text-only again, matching retail and the pre-FA2 shape. Also covers blast SHOULD-FIX 1 in the same edit to LiveSessionEventRouter.cs: the fellowship/allegiance delegate holes are now passed conditionally on the owner being supplied, so GameEventDispatcher.GetUnhandledCount reads correctly for callers without an owner (bare-ChatLog tests, a future partial host) instead of silently reading 0 for 9 event types whose parse result was discarded. RuntimeAllegianceState.cs and the two owners' Apply* mutators also move their ObjectDisposedException.ThrowIf checks inside the lock they already take (mechanism SHOULD-FIX 2) -- the prior check-then-lock shape let an inbound event on the decode thread race Dispose on the host thread and repopulate state after _disposed = true, permanently falsifying CaptureOwnership().IsConverged at teardown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1804 lines
76 KiB
C#
1804 lines
76 KiB
C#
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.Net.Tests.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 void AppendU32(List<byte> payload, uint value)
|
||
{
|
||
byte[] u32 = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(u32, value);
|
||
payload.AddRange(u32);
|
||
}
|
||
|
||
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<uint>? 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);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(false)]
|
||
[InlineData(true)]
|
||
public void WireAll_IdentifyObjectResponse_ForwardsCompleteResultAndMergesOnlySuccess(
|
||
bool success)
|
||
{
|
||
const uint guid = 0x50000001u;
|
||
var dispatcher = new GameEventDispatcher();
|
||
var items = new ClientObjectTable();
|
||
items.UpsertProperties(guid, new PropertyBundle());
|
||
AppraiseInfoParser.Parsed? received = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher,
|
||
items,
|
||
new CombatState(),
|
||
new Spellbook(),
|
||
new ChatLog(),
|
||
onAppraisal: appraisal => received = appraisal);
|
||
|
||
byte[] payload = new byte[24];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, guid);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||
payload.AsSpan(4),
|
||
(uint)AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), success ? 1u : 0u);
|
||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(12), 1);
|
||
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(14), 16);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 25u);
|
||
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(20), 42);
|
||
|
||
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.IdentifyObjectResponse, payload));
|
||
Assert.NotNull(envelope);
|
||
dispatcher.Dispatch(envelope.Value);
|
||
|
||
Assert.NotNull(received);
|
||
Assert.Equal(success, received.Value.Success);
|
||
Assert.Equal(42, received.Value.Properties.Ints[25u]);
|
||
if (success)
|
||
Assert.Equal(42, items.Get(guid)!.Properties.Ints[25u]);
|
||
else
|
||
Assert.False(items.Get(guid)!.Properties.Ints.ContainsKey(25u));
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_LiteralAceCreatureFlag_ForwardsAndUpdatesHealth()
|
||
{
|
||
const uint guid = 0x50000001u;
|
||
var dispatcher = new GameEventDispatcher();
|
||
var combat = new CombatState();
|
||
AppraiseInfoParser.Parsed? received = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher,
|
||
new ClientObjectTable(),
|
||
combat,
|
||
new Spellbook(),
|
||
new ChatLog(),
|
||
onAppraisal: appraisal => received = appraisal);
|
||
|
||
byte[] payload = new byte[24];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, guid);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||
payload.AsSpan(4),
|
||
0x0100u); // ACE IdentifyResponseFlags.CreatureProfile
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 75u);
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 100u);
|
||
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.IdentifyObjectResponse, payload))!.Value);
|
||
|
||
Assert.NotNull(received);
|
||
Assert.NotNull(received.Value.CreatureProfile);
|
||
Assert.Equal(0.75f, combat.GetHealthPercent(guid), 3);
|
||
}
|
||
|
||
|
||
[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);
|
||
// channelId 42 (0x2A) matches no named legacy bit — generic
|
||
// admin/audit catch-all, hear branch (LegacyChannelChatType.Resolve
|
||
// → 0x08 Channel; corrected 2026-08-09, Opus review of 172c6f9a —
|
||
// was wrongly 0x0E Abuse, retail's ONLY 0x0E producer is bit 0x0001).
|
||
Assert.Equal(0x08u, entry.LogTextType);
|
||
}
|
||
|
||
[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<uint>();
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): 0x0248
|
||
/// House_UpdateRestrictions refreshes an already-known house object's
|
||
/// guest/ban list. Wire shape verified against Chorizite
|
||
/// House_UpdateRestrictions.generated.cs: byte Sequence, uint SenderId,
|
||
/// then RestrictionDB (Version u32, Flags u32, MonarchId u32, PHashTable
|
||
/// packed-u32-count + count × (guid u32 + value u32)).
|
||
/// </summary>
|
||
[Fact]
|
||
public void WireAll_HouseUpdateRestrictions_UpdatesClientObjectTable()
|
||
{
|
||
var (d, items, _, _, _) = MakeAll();
|
||
const uint houseGuid = 0x70200099u;
|
||
items.AddOrUpdate(new ClientObject { ObjectId = houseGuid, WeenieClassId = 1 });
|
||
|
||
var payload = new List<byte>();
|
||
payload.Add(5); // Sequence
|
||
AppendU32(payload, houseGuid); // SenderId
|
||
AppendU32(payload, 0x10000002u); // Version
|
||
AppendU32(payload, 0u); // Flags (private)
|
||
AppendU32(payload, 0x50000500u); // MonarchId
|
||
AppendU32(payload, 1u); // PHashTable packed size (count=1)
|
||
AppendU32(payload, 0x50000001u); // guest guid
|
||
AppendU32(payload, 1u); // guest permission (storage)
|
||
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.HouseUpdateRestrictions, payload.ToArray()));
|
||
d.Dispatch(env!.Value);
|
||
|
||
var house = items.Get(houseGuid);
|
||
Assert.NotNull(house);
|
||
Assert.NotNull(house!.Restrictions);
|
||
Assert.False(house.Restrictions!.OpenToPublic);
|
||
Assert.Equal(0x50000500u, house.Restrictions.AllegianceMonarchId);
|
||
Assert.Single(house.Restrictions.Guests);
|
||
Assert.Equal(1u, house.Restrictions.Guests[0x50000001u]);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_HouseUpdateRestrictions_UnknownHouseObject_NoOps()
|
||
{
|
||
// Retail's own client would only receive this for an object it already
|
||
// knows about; a stub half-formed ClientObject must not be invented.
|
||
var (d, items, _, _, _) = MakeAll();
|
||
const uint unknownHouseGuid = 0x70200100u;
|
||
|
||
var payload = new List<byte>();
|
||
payload.Add(1);
|
||
AppendU32(payload, unknownHouseGuid);
|
||
AppendU32(payload, 0x10000002u);
|
||
AppendU32(payload, 1u); // open
|
||
AppendU32(payload, 0u);
|
||
AppendU32(payload, 0u); // zero guests
|
||
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.HouseUpdateRestrictions, payload.ToArray()));
|
||
d.Dispatch(env!.Value);
|
||
|
||
Assert.Null(items.Get(unknownHouseGuid));
|
||
}
|
||
|
||
[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);
|
||
// Default (0x00), not a combat color — HandleKillerNotificationEvent
|
||
// @0x0056C410 calls AddTextToScroll(..., 0, 1, 0) for both
|
||
// VictimNotification and KillerNotification.
|
||
Assert.Equal(0x00u, entry.LogTextType);
|
||
}
|
||
|
||
[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<byte>()));
|
||
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<byte>()));
|
||
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_MagicUpdateEnchantment_PropagatesFullStatModMidSession()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
var book = new Spellbook(SpellTable.Create([TestSpell(42u)]));
|
||
var player = new LocalPlayerState(book);
|
||
player.OnSkillUpdate(
|
||
skillId: 7u,
|
||
ranks: 100u,
|
||
status: 2u,
|
||
xp: 0u,
|
||
init: 0u,
|
||
resistance: 0u,
|
||
lastUsed: 0d,
|
||
formulaBonus: 0u);
|
||
int changed = 0;
|
||
book.EnchantmentsChanged += () => changed++;
|
||
GameEventWiring.WireAll(
|
||
dispatcher,
|
||
new ClientObjectTable(),
|
||
new CombatState(),
|
||
book,
|
||
new ChatLog(),
|
||
localPlayer: player,
|
||
clientTime: () => 100d);
|
||
|
||
byte[] payload = BuildEnchantment(
|
||
spellId: 42,
|
||
layer: 3,
|
||
duration: 60,
|
||
caster: 0xBEEF,
|
||
startTime: 10,
|
||
lastDegraded: 2,
|
||
statModType: 0x00008010u,
|
||
statModKey: 7u,
|
||
statModValue: 25f);
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(
|
||
GameEventType.MagicUpdateEnchantment,
|
||
payload))!.Value);
|
||
|
||
ActiveEnchantmentRecord record =
|
||
Assert.Single(book.ActiveEnchantmentSnapshot);
|
||
Assert.Equal(0x00008010u, record.StatModType);
|
||
Assert.Equal(7u, record.StatModKey);
|
||
Assert.Equal(25f, record.StatModValue);
|
||
Assert.Equal(125, player.GetEffectiveSkill(7u));
|
||
Assert.Equal(1, changed);
|
||
}
|
||
|
||
[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 → WeenieErrorMessages.Resolve +
|
||
// ChatLog.OnSystemMessage (the legacy no-router fallback;
|
||
// REJECT-review rework SHOULD-FIX 3 deleted the dedicated
|
||
// ChatLog.OnWeenieError chokepoint-bypass — see
|
||
// docs/research/2026-08-09-ch2-review-findings.md).
|
||
var (d, _, _, _, chat) = MakeAll();
|
||
|
||
byte[] payload = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x04Au); // Ack! You killed yourself! (Default)
|
||
|
||
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("Ack! You killed yourself!", e.Text);
|
||
Assert.Equal(0x00u, e.LogTextType);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WeenieError_UnmappedCode_DoesNotReachChat()
|
||
{
|
||
// REJECT-review rework (SHOULD-FIX 4,
|
||
// docs/research/2026-08-09-ch2-review-findings.md): retail's
|
||
// HandleFailureEvent switch has no default case — an id it does not
|
||
// recognize produces NO text at all, silently, toward the player.
|
||
// The prior "WeenieError 0xNNNN" hex-fallback framing this test
|
||
// used to pin was acdream's own invention with no retail
|
||
// counterpart — an unregistered divergence, now retired.
|
||
var (d, _, _, _, chat) = MakeAll();
|
||
|
||
byte[] payload = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x9C); // arbitrary unmapped error code
|
||
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
Assert.Equal(0, chat.Count);
|
||
}
|
||
|
||
[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). Now wired → WeenieErrorMessages.Resolve +
|
||
// ChatLog.OnSystemMessage (the legacy no-router fallback).
|
||
var (d, _, _, _, chat) = MakeAll();
|
||
|
||
byte[] interpBytes = MakeString16L("Caith");
|
||
byte[] payload = new byte[4 + interpBytes.Length];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x0521u); // "%s has been added to the list of people you can hear."
|
||
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("Caith has been added to the list of people you can hear.", e.Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WeenieErrorWithString_UnmappedCode_DoesNotReachChat()
|
||
{
|
||
// REJECT-review rework (SHOULD-FIX 4): same retail-faithful silence
|
||
// as the plain WeenieError case above.
|
||
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(0, chat.Count);
|
||
}
|
||
|
||
// ── Campaign CH slice CH2: onInterfaceText routing ───────────────────
|
||
|
||
private static (GameEventDispatcher Dispatcher, ChatLog Chat, List<(string Text, RetailLogTextType Type)> Reported)
|
||
MakeAllWithInterfaceTextRouter()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
var chat = new ChatLog();
|
||
var reported = new List<(string, RetailLogTextType)>();
|
||
GameEventWiring.WireAll(
|
||
dispatcher,
|
||
new ClientObjectTable(),
|
||
new CombatState(),
|
||
new Spellbook(),
|
||
chat,
|
||
onInterfaceText: (text, type) => reported.Add((text, type)));
|
||
return (dispatcher, chat, reported);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WithRouter_WeenieError_ClientLocalCode_RoutesThroughRouter_NotChat()
|
||
{
|
||
// 0x024 = NotGrounded / YouCantJumpWhileInTheAir -> ClientLocal per
|
||
// the full HandleFailureEvent table. With a router wired, this must
|
||
// reach the router (and therefore, in production, the SpewBox) and
|
||
// NEVER the chat transcript — the exact CH1-era behavior this test
|
||
// replaces (chat.OnWeenieError used to type every code 0x00).
|
||
var (d, chat, reported) = MakeAllWithInterfaceTextRouter();
|
||
|
||
byte[] payload = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x024u);
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
Assert.Equal(0, chat.Count);
|
||
var (text, type) = Assert.Single(reported);
|
||
Assert.Equal(ClientTextRefusals.CantJumpInAir, text);
|
||
Assert.Equal(RetailLogTextType.ClientLocal, type);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WithRouter_WeenieError_ChatTypeCode_RoutesThroughRouter_AsChatType()
|
||
{
|
||
// 0x402 = Your spell fizzled -> Magic (0x07) per the table. Routed
|
||
// text still goes through the SAME router (production forwards
|
||
// non-ClientLocal types to the chat transcript via AddText).
|
||
var (d, _, reported) = MakeAllWithInterfaceTextRouter();
|
||
|
||
byte[] payload = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x402u);
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
var (text, type) = Assert.Single(reported);
|
||
Assert.Equal("Your spell fizzled.", text);
|
||
Assert.Equal(RetailLogTextType.Magic, type);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WithRouter_WeenieErrorWithString_SubstitutesParam_AndRoutesByType()
|
||
{
|
||
var (d, chat, reported) = MakeAllWithInterfaceTextRouter();
|
||
|
||
// 0x521 = "%s has been added to the list of people you can hear." -> Default (0x00).
|
||
byte[] interpBytes = MakeString16L("Caith");
|
||
byte[] payload = new byte[4 + interpBytes.Length];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x521u);
|
||
Array.Copy(interpBytes, 0, payload, 4, interpBytes.Length);
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieErrorWithString, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
var (text, type) = Assert.Single(reported);
|
||
Assert.Equal("Caith has been added to the list of people you can hear.", text);
|
||
Assert.Equal(RetailLogTextType.Default, type);
|
||
Assert.Equal(0, chat.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WithRouter_SilentClientControlStatus_StillDropped()
|
||
{
|
||
var (d, chat, reported) = MakeAllWithInterfaceTextRouter();
|
||
byte[] payload = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x003Bu); // ILeftTheWorld
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
Assert.Empty(reported);
|
||
Assert.Equal(0, chat.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WithRouter_CommunicationTransientString_AlwaysClientLocal()
|
||
{
|
||
// Retail hardcodes AddTextToScroll(text, 0x1A, 1, 0) for 0x02EB —
|
||
// corrects the CH1-era 0x00 routing note in GameEventWiring.cs.
|
||
var (d, chat, reported) = MakeAllWithInterfaceTextRouter();
|
||
|
||
byte[] payload = MakeString16L("You are too encumbered to carry that!");
|
||
var env = GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.CommunicationTransientString, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
var (text, type) = Assert.Single(reported);
|
||
Assert.Equal("You are too encumbered to carry that!", text);
|
||
Assert.Equal(RetailLogTextType.ClientLocal, type);
|
||
Assert.Equal(0, chat.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_NoRouter_CommunicationTransientString_FallsBackToChatWithClientLocalType()
|
||
{
|
||
// No router wired (older/test caller shape) — the fallback still
|
||
// tags the entry with the retail-correct type even though it can't
|
||
// perform the SpewBox split.
|
||
var (d, _, _, _, chat) = MakeAll();
|
||
|
||
byte[] payload = MakeString16L("fallback text");
|
||
var env = GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.CommunicationTransientString, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
Assert.Equal(1, chat.Count);
|
||
var e = chat.Snapshot()[0];
|
||
Assert.Equal("fallback text", e.Text);
|
||
Assert.Equal((uint)RetailLogTextType.ClientLocal, e.LogTextType);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WithRouter_UseDone_NonZeroError_RoutesResolvedTextAndType()
|
||
{
|
||
// 0x1D = YoureTooBusy -> ClientLocal per the table. Folds the former
|
||
// WeenieErrorText.cs 4-entry map into the full table (register AP-74).
|
||
var (d, chat, reported) = MakeAllWithInterfaceTextRouter();
|
||
|
||
byte[] payload = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x001Du);
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UseDone, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
var (text, type) = Assert.Single(reported);
|
||
Assert.Equal("You're too busy!", text);
|
||
Assert.Equal(RetailLogTextType.ClientLocal, type);
|
||
Assert.Equal(0, chat.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_WithRouter_UseDone_ZeroError_ReportsNothing()
|
||
{
|
||
var (d, _, reported) = MakeAllWithInterfaceTextRouter();
|
||
|
||
byte[] payload = new byte[4]; // err == 0
|
||
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UseDone, payload));
|
||
d.Dispatch(env!.Value);
|
||
|
||
Assert.Empty(reported);
|
||
}
|
||
|
||
[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<ClientObject> 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<ShortcutEntry>? 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);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_ExternalContainer_FiltersNestedViewsAndAppliesAuthoritativeClose()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
var items = new ClientObjectTable();
|
||
var external = new ExternalContainerState();
|
||
GameEventWiring.WireAll(
|
||
dispatcher,
|
||
items,
|
||
new CombatState(),
|
||
new Spellbook(),
|
||
new ChatLog(),
|
||
externalContainers: external);
|
||
external.RequestOpen(0x70000001u);
|
||
|
||
DispatchViewContents(
|
||
dispatcher,
|
||
0x70000001u,
|
||
new uint[] { 0x60000001u, 0x70000002u });
|
||
DispatchViewContents(dispatcher, 0x70000002u, new uint[] { 0x60000002u });
|
||
|
||
Assert.Equal(0x70000001u, external.CurrentContainerId);
|
||
Assert.Equal(
|
||
new uint[] { 0x60000001u, 0x70000002u },
|
||
items.GetContents(0x70000001u));
|
||
Assert.Equal(new uint[] { 0x60000002u }, items.GetContents(0x70000002u));
|
||
|
||
byte[] closePayload = new byte[4];
|
||
BinaryPrimitives.WriteUInt32LittleEndian(closePayload, 0x70000001u);
|
||
var close = GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.CloseGroundContainer, closePayload));
|
||
dispatcher.Dispatch(close!.Value);
|
||
|
||
Assert.Equal(0u, external.CurrentContainerId);
|
||
Assert.Empty(items.GetContents(0x70000001u));
|
||
Assert.NotNull(items.Get(0x60000001u));
|
||
Assert.Empty(items.GetContents(0x70000002u));
|
||
}
|
||
|
||
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, bool Truncated)? observed = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher,
|
||
new ClientObjectTable(),
|
||
new CombatState(),
|
||
new Spellbook(),
|
||
new ChatLog(),
|
||
onCharacterOptions: (options1, options2, trailerTruncated) =>
|
||
observed = (options1, options2, trailerTruncated));
|
||
|
||
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, false), observed);
|
||
}
|
||
|
||
// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ────────
|
||
// Wiring-plumbing tests: proves GameEventWiring.WireAll's NEW delegate
|
||
// holes register against the SAME already-golden-vector-tested FA1
|
||
// parsers (FellowshipEventsTests.cs / AllegianceSmallEventsTests.cs /
|
||
// AllegianceProfileVersionGateTests.cs) and reach their callback. Not a
|
||
// re-test of parser correctness — a proof the registration site is
|
||
// wired at all.
|
||
|
||
[Fact]
|
||
public void WireAll_FellowshipFullUpdate_ReachesTheCallback()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
GameEvents.FellowshipFullUpdate? observed = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||
onFellowshipFullUpdate: update => observed = update);
|
||
|
||
byte[] wire = new AceWireWriter()
|
||
.Write((ushort)0).Write((ushort)16)
|
||
.WriteString16L("TestFellowship")
|
||
.Write((uint)0) // leaderGuid
|
||
.Write((uint)1) // shareXp
|
||
.Write((uint)0) // evenXpSplit
|
||
.Write((uint)0) // openFellow
|
||
.Write((uint)0) // locked
|
||
.Write((ushort)0).Write((ushort)32)
|
||
.ToArray();
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.FellowshipFullUpdate, wire))!.Value);
|
||
|
||
Assert.NotNull(observed);
|
||
Assert.Equal("TestFellowship", observed.Value.Name);
|
||
Assert.True(observed.Value.ShareXp);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_FellowshipUpdateFellow_ReachesTheCallback()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
GameEvents.FellowshipUpdateFellow? observed = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||
onFellowshipUpdateFellow: update => observed = update);
|
||
|
||
byte[] wire = new AceWireWriter()
|
||
.Write(0x50000001u)
|
||
.Write((uint)0).Write((uint)0).Write((uint)1)
|
||
.Write((uint)100).Write((uint)100).Write((uint)100)
|
||
.Write((uint)42).Write((uint)100).Write((uint)100)
|
||
.Write((uint)0)
|
||
.WriteString16L("Self")
|
||
.Write((uint)3)
|
||
.ToArray();
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.FellowshipUpdateFellow, wire))!.Value);
|
||
|
||
Assert.NotNull(observed);
|
||
Assert.Equal(0x50000001u, observed.Value.MemberGuid);
|
||
Assert.Equal(42u, observed.Value.Member.CurrentHealth);
|
||
Assert.Equal(3u, observed.Value.UpdateType);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_FellowshipQuit_ReachesTheCallbackWithQuitterGuid()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
uint? observed = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||
onFellowshipQuit: guid => observed = guid);
|
||
|
||
byte[] wire = new AceWireWriter().Write(0x50000042u).ToArray();
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.FellowshipQuit, wire))!.Value);
|
||
|
||
Assert.Equal(0x50000042u, observed);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_FellowshipDismiss_ReachesTheCallbackWithDismissedGuid()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
uint? observed = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||
onFellowshipDismiss: guid => observed = guid);
|
||
|
||
byte[] wire = new AceWireWriter().Write(0x50000043u).ToArray();
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.FellowshipDismiss, wire))!.Value);
|
||
|
||
Assert.Equal(0x50000043u, observed);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_FellowshipDisband_ReachesTheCallback()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
bool fired = false;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||
onFellowshipDisband: () => fired = true);
|
||
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.FellowshipDisband, []))!.Value);
|
||
|
||
Assert.True(fired);
|
||
}
|
||
|
||
/// <summary>Minimal valid <c>AllegianceProfile</c> body at <c>oldVersion=0</c> — no gated fields, one monarch record only.</summary>
|
||
private static byte[] BuildMinimalAllegianceProfile(
|
||
uint leadingField,
|
||
uint monarchGuid,
|
||
string monarchName = "Monarch")
|
||
{
|
||
return new AceWireWriter()
|
||
.Write(leadingField)
|
||
.Write((uint)1) // totalMembers
|
||
.Write((uint)0) // totalVassals
|
||
.Write((ushort)1) // recordCount
|
||
.Write((ushort)0) // oldVersion — no gates fire
|
||
.Write(monarchGuid)
|
||
.Write((uint)0).Write((uint)0) // cpCached, cpTithed
|
||
.Write(0x4u | 0x8u) // bitfield: HasAllegianceAge | HasPackedLevel
|
||
.Write((byte)0).Write((byte)0) // gender, heritageGroup
|
||
.Write((ushort)1) // rank
|
||
.Write((uint)10) // level (HasPackedLevel)
|
||
.Write((ushort)0).Write((ushort)0) // loyalty, leadership
|
||
.Write((uint)0).Write((uint)0) // timeOnline, allegianceAge (HasAllegianceAge)
|
||
.WriteString16L(monarchName)
|
||
.ToArray();
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_AllegianceUpdate_ReachesTheCallback()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
ClientCommandResponses.AllegianceUpdate? observed = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||
onAllegianceUpdate: update => observed = update);
|
||
|
||
byte[] wire = BuildMinimalAllegianceProfile(leadingField: 5u, monarchGuid: 0x50000001u);
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.AllegianceUpdate, wire))!.Value);
|
||
|
||
Assert.NotNull(observed);
|
||
Assert.Equal(5u, observed.Value.Rank);
|
||
Assert.Equal(0x50000001u, observed.Value.Monarch!.Value.CharacterId);
|
||
Assert.Equal("Monarch", observed.Value.Monarch.Value.Name);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_AllegianceInfoResponse_IsTextOnly_ForSelfAndOtherGuidsAlike()
|
||
{
|
||
// FA2 fix-round MUST-FIX 2 (docs/research/2026-08-12-fa2-review-mechanism.md):
|
||
// 0x027C AllegianceInfoResponse no longer forwards to a Runtime
|
||
// allegiance-owner callback at all — retail's own handler for this
|
||
// response is print-only over a stack-local profile
|
||
// (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent
|
||
// @0x006a7470 / Handle_Allegiance__AllegianceInfoResponseEvent
|
||
// @0x0056a1d0). The chat-text output must fire unconditionally for
|
||
// BOTH a self query and a by-name query about another player —
|
||
// there is no self-gate left to test.
|
||
const uint self = 0x50000001u;
|
||
const uint other = 0x50000002u;
|
||
var dispatcher = new GameEventDispatcher();
|
||
int chatLines = 0;
|
||
var chat = new ChatLog();
|
||
chat.EntryAppended += _ => chatLines++;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat,
|
||
playerGuid: () => self);
|
||
|
||
byte[] otherWire = BuildMinimalAllegianceProfile(leadingField: other, monarchGuid: other, monarchName: "Other");
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.AllegianceInfoResponse, otherWire))!.Value);
|
||
Assert.True(chatLines > 0);
|
||
|
||
chatLines = 0;
|
||
byte[] selfWire = BuildMinimalAllegianceProfile(leadingField: self, monarchGuid: self, monarchName: "Self");
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.AllegianceInfoResponse, selfWire))!.Value);
|
||
Assert.True(chatLines > 0);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_AllegianceUpdateDoneAndAborted_ReachTheirCallbacks()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
uint? done = null;
|
||
uint? aborted = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||
onAllegianceUpdateDone: code => done = code,
|
||
onAllegianceUpdateAborted: code => aborted = code);
|
||
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
|
||
GameEventType.AllegianceUpdateDone,
|
||
new AceWireWriter().Write(0x0561u).ToArray()))!.Value);
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope(
|
||
GameEventType.AllegianceUpdateAborted,
|
||
new AceWireWriter().Write(0x0562u).ToArray()))!.Value);
|
||
|
||
Assert.Equal(0x0561u, done);
|
||
Assert.Equal(0x0562u, aborted);
|
||
}
|
||
|
||
[Fact]
|
||
public void WireAll_AllegianceLoginNotification_ReachesTheCallback()
|
||
{
|
||
var dispatcher = new GameEventDispatcher();
|
||
GameEvents.AllegianceLoginNotification? observed = null;
|
||
GameEventWiring.WireAll(
|
||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(),
|
||
onAllegianceLoginNotification: notice => observed = notice);
|
||
|
||
byte[] wire = new AceWireWriter().Write(0x50000042u).Write((uint)1).ToArray();
|
||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||
WrapEnvelope(GameEventType.AllegianceLoginNotification, wire))!.Value);
|
||
|
||
Assert.NotNull(observed);
|
||
Assert.Equal(0x50000042u, observed.Value.CharacterGuid);
|
||
Assert.True(observed.Value.IsLoggedIn);
|
||
}
|
||
|
||
private static byte[] BuildEnchantment(
|
||
ushort spellId,
|
||
ushort layer,
|
||
double duration,
|
||
uint caster,
|
||
double startTime,
|
||
double lastDegraded,
|
||
uint statModType,
|
||
uint statModKey = 7u,
|
||
float statModValue = 1.25f)
|
||
{
|
||
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(statModKey); WriteF32(statModValue);
|
||
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; }
|
||
}
|
||
|
||
private static SpellMetadata TestSpell(uint spellId) => new(
|
||
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
|
||
false, false, "", 0, 0, 0u, 0, false, false, true,
|
||
0f, 0u, 0u, 0u, 0);
|
||
|
||
}
|