acdream/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
Erik 6f48e34152 fix(runtime,net): OP1 re-review residuals R1/R2/R3 (coordinator pass)
R1: the K4-load-bearing IsDirty pre-check moves into GameRuntime's two
once-allocated hook lambdas — SendBlob's closure environment is allocated
in FlushCharacterOptions's PROLOGUE, ahead of any guard inside the body,
so the clean-tick fast path must never enter the method at all. The body
keeps its check as idempotent defense only; the doc comment now describes
the real mechanism instead of overclaiming.

R2: RuntimeCharacterOptionsState gains a dirty-generation token. MarkDirty
bumps it on EVERY call (including while already dirty); TryFlush /
TryFlushIfAutoSaveDue capture it before invoking the callback and only
clear IsDirty when it is unchanged after — a dirtying change landing
DURING a flush (cross-thread, or re-entrant from the callback itself,
the re-review's NOTE-6 case) now stays dirty and flushes on its own later
trigger instead of being silently erased by the trailing clear. The S2
interleaving test now asserts the retained dirty state it previously
ignored; a deterministic re-entrancy test pins the same-thread shape.

R3: a trailer-truncated PlayerDescription parse carries zero placeholder
option words, not server truth — GameEventWiring now forwards
TrailerTruncated, LiveSessionEventRouter passes armServerSeed:
!trailerTruncated, and Replace withholds the 0x01A1 flush authorization
for truncated seeds while still installing the words (pre-existing local
behavior unchanged). Newly wire-reaching via the R1/MF-1 timer, hence
closed now rather than left a NOTE.

Full Release suite: 12,870 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 01:13:57 +02:00

1577 lines
66 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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);
}
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);
}