Files four new issues created by the 2026-04-25 PDB-discovery sprint:
#8 (DONE 2026-04-25) — pdb-extract tool, shipped 69d884a
#9 (OPEN) — function-map address-correction sweep
(Phase E will close)
#10 (DONE 2026-04-25) — wire KillerNotification (0x01AD); orphan
parser at GameEvents.ParseKillerNotification
existed but was never registered. This commit
adds CombatState.OnKillerNotification +
KillLanded event, registers the dispatcher
handler, and adds a regression test.
#11 (OPEN) — spell metadata loader (spells.csv → SpellTable)
(Phase F will close)
Code change is minimal — three lines of dispatch + a 12-line
CombatState method with a typed event for future killfeed UI.
818 tests passing (+1 KillerNotification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
282 lines
12 KiB
C#
282 lines
12 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
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 Xunit;
|
|
|
|
namespace AcDream.Core.Net.Tests;
|
|
|
|
public sealed class GameEventWiringTests
|
|
{
|
|
private static byte[] MakeString16L(string s)
|
|
{
|
|
byte[] data = Encoding.ASCII.GetBytes(s);
|
|
int recordSize = 2 + data.Length;
|
|
int padding = (4 - (recordSize & 3)) & 3;
|
|
byte[] result = new byte[recordSize + padding];
|
|
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
|
|
Array.Copy(data, 0, result, 2, data.Length);
|
|
return result;
|
|
}
|
|
|
|
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
|
|
{
|
|
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type);
|
|
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
|
return body;
|
|
}
|
|
|
|
private static (GameEventDispatcher, ItemRepository, CombatState, Spellbook, ChatLog) MakeAll()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ItemRepository();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
|
|
return (dispatcher, items, combat, spellbook, chat);
|
|
}
|
|
|
|
|
|
[Fact]
|
|
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
|
{
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
|
|
byte[] payload = new byte[4 + MakeString16L("Alice").Length + MakeString16L("hi").Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42);
|
|
int p = 4;
|
|
var senderBytes = MakeString16L("Alice");
|
|
Array.Copy(senderBytes, 0, payload, p, senderBytes.Length); p += senderBytes.Length;
|
|
var msgBytes = MakeString16L("hi");
|
|
Array.Copy(msgBytes, 0, payload, p, msgBytes.Length);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ChannelBroadcast, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
var entry = chat.Snapshot()[0];
|
|
Assert.Equal(ChatKind.Channel, entry.Kind);
|
|
Assert.Equal("Alice", entry.Sender);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_UpdateHealth_RoutesToCombatState()
|
|
{
|
|
var (d, _, combat, _, _) = MakeAll();
|
|
|
|
byte[] payload = new byte[8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xCAFE);
|
|
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UpdateHealth, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0.42f, combat.GetHealthPercent(0xCAFE), 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_MagicUpdateSpell_RoutesToSpellbook()
|
|
{
|
|
var (d, _, _, book, _) = MakeAll();
|
|
|
|
byte[] payload = new byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicUpdateSpell, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.True(book.Knows(0x3E1));
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_WieldObject_RoutesToItemRepository()
|
|
{
|
|
var (d, items, _, _, _) = MakeAll();
|
|
items.AddOrUpdate(new ItemInstance { ObjectId = 0x1000, WeenieClassId = 1 });
|
|
|
|
byte[] payload = new byte[12];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
var item = items.GetItem(0x1000);
|
|
Assert.NotNull(item);
|
|
Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation);
|
|
Assert.Equal(0x2000u, item.ContainerId);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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 ItemRepository();
|
|
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_FeedsSpellbook()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ItemRepository();
|
|
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_FiresKillLandedOnCombatState()
|
|
{
|
|
// Issue #10 — orphan parser at GameEvents.ParseKillerNotification
|
|
// existed but was never registered for dispatch until 2026-04-25.
|
|
// Now wired: 0x01AD lands on CombatState.OnKillerNotification +
|
|
// fires the KillLanded event.
|
|
var (d, _, combat, _, _) = MakeAll();
|
|
string? gotVictimName = null;
|
|
uint gotVictimGuid = 0;
|
|
combat.KillLanded += (name, guid) => { gotVictimName = name; gotVictimGuid = guid; };
|
|
|
|
// Wire shape: string16L victimName + u32 victimGuid
|
|
byte[] nameBytes = MakeString16L("Drudge");
|
|
byte[] payload = new byte[nameBytes.Length + 4];
|
|
Array.Copy(nameBytes, payload, nameBytes.Length);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(nameBytes.Length), 0x80001234u);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal("Drudge", gotVictimName);
|
|
Assert.Equal(0x80001234u, gotVictimGuid);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
|
|
{
|
|
var (d, _, _, book, _) = MakeAll();
|
|
book.OnEnchantmentAdded(1, 1, 100f, 0);
|
|
book.OnEnchantmentAdded(2, 2, 100f, 0);
|
|
Assert.Equal(2, book.ActiveCount);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicPurgeEnchantments, Array.Empty<byte>()));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0, book.ActiveCount);
|
|
}
|
|
|
|
}
|