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>
1535 lines
64 KiB
C#
1535 lines
64 KiB
C#
using System;
|
||
using System.Buffers.Binary;
|
||
using System.IO;
|
||
using System.Text;
|
||
using AcDream.Core.Chat;
|
||
using AcDream.Core.Combat;
|
||
using AcDream.Core.Items;
|
||
using AcDream.Core.Net;
|
||
using AcDream.Core.Net.Messages;
|
||
using AcDream.Core.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);
|
||
|
||
}
|