acdream/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
Erik 77c8296e3f feat(chat): Campaign CH slice CH2 — retail SpewBox interface text
Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.

This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:

CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
  a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
  @0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
  excluded — its case body is a tangled decompiler artifact, not
  resolvable with confidence). Spot-checked ~20 rows directly against
  the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
  jump family), beyond the ~10 the brief asked for, because the first
  pass surfaced two transcription classes the research doc's markdown
  silently ate: (1) 7 ids marked "shared string global" resolved by
  reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
  refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
  (2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
  therefore their %s marker) the markdown table's cell-trimming ate —
  fixed by re-reading each case body, several requiring a SECOND
  non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
  recover text the ~33-char inline preview cut off. One retail typo is
  preserved verbatim: 0x4F4's second placeholder is literal "$s", not
  "%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
  byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
  (MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
  5 were truncated in the research doc's own transcription and all 5
  turned out to end "...combat mode"/"...this position", not the
  shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
  dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
  expiry, one-frame enqueue/drain decoupling). Placed in Core (not
  Runtime as the brief's default) because AcDream.UI.Abstractions
  references Core but not Runtime, and SpewBoxVM needs to wrap it
  directly — the same constraint ChatVM already satisfies against
  ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.

RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
  AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
  chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
  cannot reference Runtime, so this follows the file's own established
  pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
  UseDone through the full table + router; fixes 0x02EB
  CommunicationTransientString's routing type from a CH1-era 0x00
  guess to retail's hardcoded ClientLocal (Handle_Communication__
  TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
  through AddText with the wire chatType verbatim instead of always
  writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
  RuntimeLocalPlayerMovementState to every controller it installs.
  Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
  CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
  their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
  0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
  other code are retail-SILENT (DoJump's jump table has exactly 4 real
  targets), which contradicts this task's brief ("0x47 -> the
  constrained/stamina row per §4.2") — the brief's reading of §4.2
  described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
  DISPLAY for it. Implemented the decomp-verified silent behavior.

APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
  now call Communication.AddText instead of Chat.OnSystemMessage
  directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
  directly on PortalWaitNoticeController. Position/font/colour/
  MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
  exhaustively swept the installed client_portal.dat's entire LayoutDesc
  id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
  against 3 known ids) and found ZERO elements of class 0x10000016 —
  gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
  the dump cannot recover these values.

REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.

Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:04:02 +02:00

1535 lines
64 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 → ChatLog.OnWeenieError.
var (d, _, _, _, chat) = MakeAll();
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x9C); // arbitrary error code
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload));
d.Dispatch(env!.Value);
Assert.Equal(1, chat.Count);
var e = chat.Snapshot()[0];
Assert.Equal(ChatKind.System, e.Kind);
Assert.Equal(0x9Cu, e.ChannelId);
Assert.Contains("0x009C", e.Text);
}
[Theory]
[InlineData(0x003Bu)] // ILeftTheWorld
[InlineData(0x003Cu)] // ITeleported
public void WireAll_RetailSilentClientControlStatus_DoesNotReachChat(uint code)
{
var (dispatcher, _, _, _, chat) = MakeAll();
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, code);
dispatcher.Dispatch(GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.WeenieError, payload))!.Value);
Assert.Equal(0, chat.Count);
}
[Fact]
public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation()
{
// Phase I.5: 0x028B carries an interpolated substring (e.g. the
// target's name in "you can't pick up the {Mana Stone}"). Now
// wired → ChatLog.OnWeenieError with the param.
var (d, _, _, _, chat) = MakeAll();
byte[] interpBytes = MakeString16L("Mana Stone");
byte[] payload = new byte[4 + interpBytes.Length];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x42u);
Array.Copy(interpBytes, 0, payload, 4, interpBytes.Length);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieErrorWithString, payload));
d.Dispatch(env!.Value);
Assert.Equal(1, chat.Count);
var e = chat.Snapshot()[0];
Assert.Equal(ChatKind.System, e.Kind);
Assert.Equal(0x42u, e.ChannelId);
Assert.Contains("Mana Stone", e.Text);
}
// ── 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)? observed = null;
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
new Spellbook(),
new ChatLog(),
onCharacterOptions: (options1, options2) =>
observed = (options1, options2));
var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(0u); // property flags
writer.Write(0x52u); // player weenie type
writer.Write(0u); // vector flags
writer.Write(0u); // has health
writer.Write(0x40u); // option flags: CharacterOptions2
writer.Write(0x50C4A54Au); // options1
writer.Write(0u); // legacy hotbar count
writer.Write(0u); // spellbook filters
writer.Write(0x948700u); // options2
writer.Write(0u); // inventory count
writer.Write(0u); // equipped count
var envelope = GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.PlayerDescription, stream.ToArray()));
dispatcher.Dispatch(envelope!.Value);
Assert.Equal((0x50C4A54Au, 0x948700u), observed);
}
private static byte[] BuildEnchantment(
ushort spellId,
ushort layer,
double duration,
uint caster,
double startTime,
double lastDegraded,
uint statModType,
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);
}