fix(net): Phase L.1c conform combat wire events

This commit is contained in:
Erik 2026-04-28 10:54:50 +02:00
parent 460f95cb42
commit 29afc94b94
8 changed files with 241 additions and 177 deletions

View file

@ -156,22 +156,20 @@ public static class GameEventWiring
dispatcher.Register(GameEventType.VictimNotification, e => dispatcher.Register(GameEventType.VictimNotification, e =>
{ {
var p = GameEvents.ParseVictimNotification(e.Payload.Span); var p = GameEvents.ParseVictimNotification(e.Payload.Span);
if (p is not null) combat.OnVictimNotification( if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType);
}); });
dispatcher.Register(GameEventType.DefenderNotification, e => dispatcher.Register(GameEventType.DefenderNotification, e =>
{ {
var p = GameEvents.ParseDefenderNotification(e.Payload.Span); var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
if (p is not null) combat.OnDefenderNotification( if (p is not null) combat.OnDefenderNotification(
p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType, p.Value.AttackerName, 0u, p.Value.DamageType,
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical); p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical);
}); });
dispatcher.Register(GameEventType.AttackerNotification, e => dispatcher.Register(GameEventType.AttackerNotification, e =>
{ {
var p = GameEvents.ParseAttackerNotification(e.Payload.Span); var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
if (p is not null) combat.OnAttackerNotification( if (p is not null) combat.OnAttackerNotification(
p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent); p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent);
}); });
dispatcher.Register(GameEventType.EvasionAttackerNotification, e => dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
{ {
@ -188,12 +186,15 @@ public static class GameEventWiring
var p = GameEvents.ParseAttackDone(e.Payload.Span); var p = GameEvents.ParseAttackDone(e.Payload.Span);
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError); if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
}); });
dispatcher.Register(GameEventType.CombatCommenceAttack, e =>
{
if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span))
combat.OnCombatCommenceAttack();
});
dispatcher.Register(GameEventType.KillerNotification, e => dispatcher.Register(GameEventType.KillerNotification, e =>
{ {
// ISSUES.md #10 — orphan parser, never registered before. The
// server fires this after a player lands a killing blow.
var p = GameEvents.ParseKillerNotification(e.Payload.Span); var p = GameEvents.ParseKillerNotification(e.Payload.Span);
if (p is not null) combat.OnKillerNotification(p.Value.VictimName, p.Value.VictimGuid); if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info);
}); });
// ── Spells ──────────────────────────────────────────────── // ── Spells ────────────────────────────────────────────────

View file

@ -3,60 +3,79 @@ using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages; namespace AcDream.Core.Net.Messages;
/// <summary> /// <summary>
/// Outbound <c>0x0008 AttackTargetRequest</c> GameAction. /// Outbound combat attack GameActions.
///
/// Retail/ACE use distinct payloads for melee and missile:
/// ///
/// <para>
/// Wire layout (inside the <c>0xF7B1</c> GameAction envelope):
/// <code> /// <code>
/// u32 0xF7B1 // GameAction envelope opcode /// u32 0xF7B1 // GameAction envelope opcode
/// u32 gameActionSequence // client sequence /// u32 gameActionSequence // client sequence
/// u32 0x0008 // sub-opcode /// u32 0x0008 // TargetedMeleeAttack
/// u32 targetGuid // who to attack /// u32 targetGuid
/// f32 powerLevel // [0.0, 1.0] — the power bar position
/// f32 accuracyLevel // [0.0, 1.0] — for missile weapons
/// u32 attackHeight // 1=High, 2=Medium, 3=Low /// u32 attackHeight // 1=High, 2=Medium, 3=Low
/// f32 powerLevel // [0.0, 1.0]
///
/// u32 0xF7B1
/// u32 gameActionSequence
/// u32 0x000A // TargetedMissileAttack
/// u32 targetGuid
/// u32 attackHeight
/// f32 accuracyLevel // [0.0, 1.0]
/// </code> /// </code>
/// </para>
/// ///
/// <para> /// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10,
/// The server ALREADY knows the attacker (it's the session's player), /// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE
/// so this message only carries the target + attack params. The server /// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and
/// then rolls damage, picks a body part, and broadcasts /// holtburger protocol game_action.rs.
/// <see cref="GameEventType.VictimNotification"/> / AttackerNotification
/// / DefenderNotification / EvasionAttackerNotification /
/// EvasionDefenderNotification with the result.
/// </para>
///
/// <para>
/// References: r02 §7 (wire format), r08 §3 opcode 0x0008.
/// </para>
/// </summary> /// </summary>
public static class AttackTargetRequest public static class AttackTargetRequest
{ {
public const uint GameActionEnvelope = 0xF7B1u; public const uint GameActionEnvelope = 0xF7B1u;
public const uint SubOpcode = 0x0008u; public const uint TargetedMeleeAttackOpcode = 0x0008u;
public const uint TargetedMissileAttackOpcode = 0x000Au;
public const uint CancelAttackOpcode = 0x01B7u;
/// <summary> /// <summary>Build the wire body for a targeted melee attack.</summary>
/// Build the wire body for an attack request. public static byte[] BuildMelee(
/// </summary>
/// <param name="powerLevel">[0..1] melee power bar position.</param>
/// <param name="accuracyLevel">[0..1] missile accuracy bar position; pass 0 for melee.</param>
/// <param name="attackHeight">1=High, 2=Medium, 3=Low.</param>
public static byte[] Build(
uint gameActionSequence, uint gameActionSequence,
uint targetGuid, uint targetGuid,
float powerLevel, uint attackHeight,
float accuracyLevel, float powerLevel)
uint attackHeight)
{ {
byte[] body = new byte[28]; byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMeleeAttackOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(16), powerLevel); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight);
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), powerLevel);
return body;
}
/// <summary>Build the wire body for a targeted missile attack.</summary>
public static byte[] BuildMissile(
uint gameActionSequence,
uint targetGuid,
uint attackHeight,
float accuracyLevel)
{
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TargetedMissileAttackOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), attackHeight);
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel); BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight); return body;
}
/// <summary>Build the wire body for cancelling an active attack request.</summary>
public static byte[] BuildCancel(uint gameActionSequence)
{
byte[] body = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), CancelAttackOpcode);
return body; return body;
} }
} }

View file

@ -22,9 +22,17 @@ public static class CharacterActions
public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
[Flags]
public enum CombatMode : uint public enum CombatMode : uint
{ {
Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5, Undef = 0,
NonCombat = 0x01,
Melee = 0x02,
Missile = 0x04,
Magic = 0x08,
ValidCombat = NonCombat | Melee | Missile | Magic,
CombatCombat = Melee | Missile | Magic,
} }
/// <summary>Spend XP to raise an attribute (Strength, Endurance, etc).</summary> /// <summary>Spend XP to raise an attribute (Strength, Endurance, etc).</summary>

View file

@ -147,56 +147,34 @@ public static class GameEvents
// ── Combat notifications ──────────────────────────────────────────────── // ── Combat notifications ────────────────────────────────────────────────
/// <summary>0x01AC VictimNotification — "you got hit for X".</summary> /// <summary>0x01AC VictimNotification - death message for the victim.</summary>
public readonly record struct VictimNotification( public readonly record struct VictimNotification(string DeathMessage);
string AttackerName,
uint AttackerGuid,
uint DamageType,
uint Damage,
uint HitQuadrant,
uint Critical,
uint AttackType);
public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload) public static VictimNotification? ParseVictimNotification(ReadOnlySpan<byte> payload)
{ {
int pos = 0; int pos = 0;
try try { return new VictimNotification(ReadString16L(payload, ref pos)); }
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 24) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint atkType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new VictimNotification(name, guid, damageType, damage, quad, crit, atkType);
}
catch { return null; } catch { return null; }
} }
/// <summary>0x01AD KillerNotification — "you killed X".</summary> /// <summary>0x01AD KillerNotification - death message for the killer.</summary>
public readonly record struct KillerNotification(string VictimName, uint VictimGuid); public readonly record struct KillerNotification(string DeathMessage);
public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload) public static KillerNotification? ParseKillerNotification(ReadOnlySpan<byte> payload)
{ {
int pos = 0; int pos = 0;
try try { return new KillerNotification(ReadString16L(payload, ref pos)); }
{
string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 4) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos));
return new KillerNotification(name, guid);
}
catch { return null; } catch { return null; }
} }
/// <summary>0x01B1 AttackerNotification — "you hit X for Y%".</summary> /// <summary>0x01B1 AttackerNotification - "you hit X".</summary>
public readonly record struct AttackerNotification( public readonly record struct AttackerNotification(
string DefenderName, string DefenderName,
uint DamageType, uint DamageType,
double HealthPercent,
uint Damage, uint Damage,
float DamagePercent); uint Critical,
ulong AttackConditions);
public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload) public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan<byte> payload)
{ {
@ -204,23 +182,26 @@ public static class GameEvents
try try
{ {
string name = ReadString16L(payload, ref pos); string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 12) return null; if (payload.Length - pos < 28) return null;
uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4; uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new AttackerNotification(name, damageType, damage, pct); uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
return new AttackerNotification(name, damageType, pct, damage, crit, cond);
} }
catch { return null; } catch { return null; }
} }
/// <summary>0x01B2 DefenderNotification — "X hit you for Y".</summary> /// <summary>0x01B2 DefenderNotification - "X hit you".</summary>
public readonly record struct DefenderNotification( public readonly record struct DefenderNotification(
string AttackerName, string AttackerName,
uint AttackerGuid,
uint DamageType, uint DamageType,
double HealthPercent,
uint Damage, uint Damage,
uint HitQuadrant, uint HitQuadrant,
uint Critical); uint Critical,
ulong AttackConditions);
public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload) public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan<byte> payload)
{ {
@ -228,40 +209,42 @@ public static class GameEvents
try try
{ {
string name = ReadString16L(payload, ref pos); string name = ReadString16L(payload, ref pos);
if (payload.Length - pos < 20) return null; if (payload.Length - pos < 32) return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint dtype = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; double pct = BinaryPrimitives.ReadDoubleLittleEndian(payload.Slice(pos)); pos += 8;
uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; uint dmg = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; uint quad = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; uint crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
return new DefenderNotification(name, guid, dtype, dmg, quad, crit); ulong cond = BinaryPrimitives.ReadUInt64LittleEndian(payload.Slice(pos)); pos += 8;
return new DefenderNotification(name, dtype, pct, dmg, quad, crit, cond);
} }
catch { return null; } catch { return null; }
} }
/// <summary>0x01B3 EvasionAttackerNotification "X evaded".</summary> /// <summary>0x01B3 EvasionAttackerNotification - "X evaded".</summary>
public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload) public static string? ParseEvasionAttackerNotification(ReadOnlySpan<byte> payload)
{ {
int pos = 0; int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; } try { return ReadString16L(payload, ref pos); } catch { return null; }
} }
/// <summary>0x01B4 EvasionDefenderNotification "you evaded X".</summary> /// <summary>0x01B4 EvasionDefenderNotification - "you evaded X".</summary>
public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload) public static string? ParseEvasionDefenderNotification(ReadOnlySpan<byte> payload)
{ {
int pos = 0; int pos = 0;
try { return ReadString16L(payload, ref pos); } catch { return null; } try { return ReadString16L(payload, ref pos); } catch { return null; }
} }
/// <summary>0x01A7 AttackDone — (attackSequence, weenieError).</summary> /// <summary>0x01B8 CombatCommenceAttack - empty payload.</summary>
public static bool ParseCombatCommenceAttack(ReadOnlySpan<byte> payload) => payload.Length == 0;
/// <summary>0x01A7 AttackDone - single WeenieError value.</summary>
public readonly record struct AttackDone(uint AttackSequence, uint WeenieError); public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload) public static AttackDone? ParseAttackDone(ReadOnlySpan<byte> payload)
{ {
if (payload.Length < 8) return null; if (payload.Length < 4) return null;
return new AttackDone( return new AttackDone(0u, BinaryPrimitives.ReadUInt32LittleEndian(payload));
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
} }
// ── Spell enchantments ────────────────────────────────────────────────── // ── Spell enchantments ──────────────────────────────────────────────────

View file

@ -57,6 +57,9 @@ public sealed class CombatState
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary> /// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone; public event Action<uint /*attackSeq*/, uint /*weenieError*/>? AttackDone;
/// <summary>The server accepted the attack and the power bar/animation can begin.</summary>
public event Action? AttackCommenced;
/// <summary> /// <summary>
/// Fires when the server confirms the player landed a killing blow /// Fires when the server confirms the player landed a killing blow
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is /// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
@ -140,5 +143,8 @@ public sealed class CombatState
public void OnAttackDone(uint attackSequence, uint weenieError) public void OnAttackDone(uint attackSequence, uint weenieError)
=> AttackDone?.Invoke(attackSequence, weenieError); => AttackDone?.Invoke(attackSequence, weenieError);
public void OnCombatCommenceAttack()
=> AttackCommenced?.Invoke();
public void Clear() => _healthByGuid.Clear(); public void Clear() => _healthByGuid.Clear();
} }

View file

@ -241,28 +241,32 @@ public sealed class GameEventWiringTests
} }
[Fact] [Fact]
public void WireAll_KillerNotification_FiresKillLandedOnCombatState() public void WireAll_KillerNotification_AppendsCombatLine()
{ {
// Issue #10 — orphan parser at GameEvents.ParseKillerNotification var (d, _, _, _, chat) = MakeAll();
// existed but was never registered for dispatch until 2026-04-25. byte[] payload = MakeString16L("You killed the drudge!");
// 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)); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
d.Dispatch(env!.Value); d.Dispatch(env!.Value);
Assert.Equal("Drudge", gotVictimName); Assert.Equal(1, chat.Count);
Assert.Equal(0x80001234u, gotVictimGuid); 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);
}
[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] [Fact]

View file

@ -57,4 +57,13 @@ public sealed class CharacterActionsTests
Assert.Equal(2u, // Melee = 2 Assert.Equal(2u, // Melee = 2
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
} }
[Fact]
public void CombatMode_UsesRetailAceBitValues()
{
Assert.Equal(1u, (uint)CharacterActions.CombatMode.NonCombat);
Assert.Equal(2u, (uint)CharacterActions.CombatMode.Melee);
Assert.Equal(4u, (uint)CharacterActions.CombatMode.Missile);
Assert.Equal(8u, (uint)CharacterActions.CombatMode.Magic);
}
} }

View file

@ -1,6 +1,5 @@
using System; using System;
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using Xunit; using Xunit;
@ -8,105 +7,140 @@ namespace AcDream.Core.Net.Tests.Messages;
public sealed class CombatEventTests public sealed class CombatEventTests
{ {
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;
}
[Fact] [Fact]
public void AttackTargetRequest_Build_EmitsCorrectWireBytes() public void AttackTargetRequest_BuildMelee_EmitsRetailWireBytes()
{ {
byte[] body = AttackTargetRequest.Build( byte[] body = AttackTargetRequest.BuildMelee(
gameActionSequence: 3, gameActionSequence: 3,
targetGuid: 0x12345678u, targetGuid: 0x12345678u,
powerLevel: 0.75f, attackHeight: 2,
accuracyLevel: 0.5f, powerLevel: 0.75f);
attackHeight: 2);
Assert.Equal(28, body.Length); Assert.Equal(24, body.Length);
Assert.Equal(AttackTargetRequest.GameActionEnvelope, Assert.Equal(AttackTargetRequest.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body)); BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(3u, Assert.Equal(3u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(AttackTargetRequest.SubOpcode, Assert.Equal(AttackTargetRequest.TargetedMeleeAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x12345678u, Assert.Equal(0x12345678u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0.75f, Assert.Equal(0.75f,
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4); BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
}
[Fact]
public void AttackTargetRequest_BuildMissile_EmitsRetailWireBytes()
{
byte[] body = AttackTargetRequest.BuildMissile(
gameActionSequence: 4,
targetGuid: 0x87654321u,
attackHeight: 1,
accuracyLevel: 0.5f);
Assert.Equal(24, body.Length);
Assert.Equal(AttackTargetRequest.TargetedMissileAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x87654321u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0.5f, Assert.Equal(0.5f,
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4); BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
} }
[Fact] [Fact]
public void ParseVictimNotification_RoundTrip() public void AttackTargetRequest_BuildCancel_HasNoPayload()
{ {
byte[] name = MakeString16L("Attacker"); byte[] body = AttackTargetRequest.BuildCancel(gameActionSequence: 5);
byte[] tail = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu); // guid
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 1u); // damageType
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 42u); // damage
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(12), 3u); // quadrant
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(16), 1u); // crit
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(20), 8u); // attackType
byte[] payload = new byte[name.Length + tail.Length]; Assert.Equal(12, body.Length);
Buffer.BlockCopy(name, 0, payload, 0, name.Length); Assert.Equal(AttackTargetRequest.CancelAttackOpcode,
Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length); BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
}
[Fact]
public void ParseAttackDone_HoltburgerFixture()
{
var env = ParseFixture("B0F700000000000000000000A701000036000000");
Assert.Equal(GameEventType.AttackDone, env.EventType);
var parsed = GameEvents.ParseAttackDone(env.Payload.Span);
var parsed = GameEvents.ParseVictimNotification(payload);
Assert.NotNull(parsed); Assert.NotNull(parsed);
Assert.Equal("Attacker", parsed!.Value.AttackerName); Assert.Equal(0u, parsed!.Value.AttackSequence);
Assert.Equal(0xAAu, parsed.Value.AttackerGuid); Assert.Equal(0x36u, parsed.Value.WeenieError);
Assert.Equal(42u, parsed.Value.Damage); }
[Fact]
public void ParseAttackerNotification_HoltburgerFixture()
{
var env = ParseFixture("B0F700000000000001000000B10100000E0044727564676520526176656E657201000000000000000000D03F25000000010000000600000000000000");
var parsed = GameEvents.ParseAttackerNotification(env.Payload.Span);
Assert.NotNull(parsed);
Assert.Equal("Drudge Ravener", parsed!.Value.DefenderName);
Assert.Equal(1u, parsed.Value.DamageType);
Assert.Equal(0.25, parsed.Value.HealthPercent, 6);
Assert.Equal(37u, parsed.Value.Damage);
Assert.Equal(1u, parsed.Value.Critical); Assert.Equal(1u, parsed.Value.Critical);
Assert.Equal(6ul, parsed.Value.AttackConditions);
} }
[Fact] [Fact]
public void ParseAttackerNotification_RoundTrip() public void ParseDefenderNotification_HoltburgerFixture()
{ {
byte[] name = MakeString16L("Drudge"); var env = ParseFixture("B0F700000000000002000000B20100000A0042616E6465726C696E6710000000000000000000C03F1200000001000000000000000800000000000000");
byte[] tail = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(tail, 1u); // damageType
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 30u); // damage
BinaryPrimitives.WriteSingleLittleEndian(tail.AsSpan(8), 0.15f); // percent
byte[] payload = new byte[name.Length + tail.Length]; var parsed = GameEvents.ParseDefenderNotification(env.Payload.Span);
Buffer.BlockCopy(name, 0, payload, 0, name.Length);
Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
var parsed = GameEvents.ParseAttackerNotification(payload);
Assert.NotNull(parsed); Assert.NotNull(parsed);
Assert.Equal("Drudge", parsed!.Value.DefenderName); Assert.Equal("Banderling", parsed!.Value.AttackerName);
Assert.Equal(30u, parsed.Value.Damage); Assert.Equal(0x10u, parsed.Value.DamageType);
Assert.Equal(0.15f, parsed.Value.DamagePercent, 4); Assert.Equal(0.125, parsed.Value.HealthPercent, 6);
Assert.Equal(18u, parsed.Value.Damage);
Assert.Equal(1u, parsed.Value.HitQuadrant);
Assert.Equal(0u, parsed.Value.Critical);
Assert.Equal(8ul, parsed.Value.AttackConditions);
} }
[Fact] [Fact]
public void ParseEvasionAttackerNotification_RoundTrip() public void ParseEvasionNotifications_HoltburgerFixtures()
{ {
byte[] payload = MakeString16L("Thrower"); var attacker = ParseFixture("B0F700000000000003000000B301000008004D6F7373776172740000");
Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload)); var defender = ParseFixture("B0F700000000000004000000B401000004004D6974650000");
Assert.Equal("Mosswart", GameEvents.ParseEvasionAttackerNotification(attacker.Payload.Span));
Assert.Equal("Mite", GameEvents.ParseEvasionDefenderNotification(defender.Payload.Span));
} }
[Fact] [Fact]
public void ParseAttackDone_RoundTrip() public void ParseCombatCommenceAttack_HoltburgerFixture()
{ {
byte[] payload = new byte[8]; var env = ParseFixture("B0F700000000000005000000B8010000");
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error
var parsed = GameEvents.ParseAttackDone(payload); Assert.Equal(GameEventType.CombatCommenceAttack, env.EventType);
Assert.NotNull(parsed); Assert.True(GameEvents.ParseCombatCommenceAttack(env.Payload.Span));
Assert.Equal(42u, parsed!.Value.AttackSequence); }
Assert.Equal(0u, parsed.Value.WeenieError);
[Fact]
public void ParseDeathNotifications_HoltburgerFixtures()
{
var victim = ParseFixture("B0F700000000000006000000AC0100000E00596F752068617665206469656421");
var killer = ParseFixture("B0F700000000000007000000AD0100001600596F75206B696C6C6564207468652064727564676521");
Assert.Equal("You have died!", GameEvents.ParseVictimNotification(victim.Payload.Span)?.DeathMessage);
Assert.Equal("You killed the drudge!", GameEvents.ParseKillerNotification(killer.Payload.Span)?.DeathMessage);
}
private static GameEventEnvelope ParseFixture(string hex)
{
byte[] body = Convert.FromHexString(hex);
var env = GameEventEnvelope.TryParse(body);
Assert.NotNull(env);
return env.Value;
} }
} }