diff --git a/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs
new file mode 100644
index 0000000..f3df54e
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/AttackTargetRequest.cs
@@ -0,0 +1,62 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Outbound 0x0008 AttackTargetRequest GameAction.
+///
+///
+/// Wire layout (inside the 0xF7B1 GameAction envelope):
+///
+/// u32 0xF7B1 // GameAction envelope opcode
+/// u32 gameActionSequence // client sequence
+/// u32 0x0008 // sub-opcode
+/// u32 targetGuid // who to attack
+/// 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
+///
+///
+///
+///
+/// The server ALREADY knows the attacker (it's the session's player),
+/// so this message only carries the target + attack params. The server
+/// then rolls damage, picks a body part, and broadcasts
+/// / AttackerNotification
+/// / DefenderNotification / EvasionAttackerNotification /
+/// EvasionDefenderNotification with the result.
+///
+///
+///
+/// References: r02 §7 (wire format), r08 §3 opcode 0x0008.
+///
+///
+public static class AttackTargetRequest
+{
+ public const uint GameActionEnvelope = 0xF7B1u;
+ public const uint SubOpcode = 0x0008u;
+
+ ///
+ /// Build the wire body for an attack request.
+ ///
+ /// [0..1] melee power bar position.
+ /// [0..1] missile accuracy bar position; pass 0 for melee.
+ /// 1=High, 2=Medium, 3=Low.
+ public static byte[] Build(
+ uint gameActionSequence,
+ uint targetGuid,
+ float powerLevel,
+ float accuracyLevel,
+ uint attackHeight)
+ {
+ byte[] body = new byte[28];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SubOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(16), powerLevel);
+ BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(20), accuracyLevel);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(24), attackHeight);
+ return body;
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
index 5e962b2..8c35872 100644
--- a/src/AcDream.Core.Net/Messages/GameEvents.cs
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -145,6 +145,125 @@ public static class GameEvents
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
+ // ── Combat notifications ────────────────────────────────────────────────
+
+ /// 0x01AC VictimNotification — "you got hit for X".
+ public readonly record struct VictimNotification(
+ string AttackerName,
+ uint AttackerGuid,
+ uint DamageType,
+ uint Damage,
+ uint HitQuadrant,
+ uint Critical,
+ uint AttackType);
+
+ public static VictimNotification? ParseVictimNotification(ReadOnlySpan payload)
+ {
+ int pos = 0;
+ try
+ {
+ 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; }
+ }
+
+ /// 0x01AD KillerNotification — "you killed X".
+ public readonly record struct KillerNotification(string VictimName, uint VictimGuid);
+
+ public static KillerNotification? ParseKillerNotification(ReadOnlySpan payload)
+ {
+ int pos = 0;
+ try
+ {
+ 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; }
+ }
+
+ /// 0x01B1 AttackerNotification — "you hit X for Y%".
+ public readonly record struct AttackerNotification(
+ string DefenderName,
+ uint DamageType,
+ uint Damage,
+ float DamagePercent);
+
+ public static AttackerNotification? ParseAttackerNotification(ReadOnlySpan payload)
+ {
+ int pos = 0;
+ try
+ {
+ string name = ReadString16L(payload, ref pos);
+ if (payload.Length - pos < 12) return null;
+ uint damageType = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ uint damage = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ float pct = BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(pos)); pos += 4;
+ return new AttackerNotification(name, damageType, damage, pct);
+ }
+ catch { return null; }
+ }
+
+ /// 0x01B2 DefenderNotification — "X hit you for Y".
+ public readonly record struct DefenderNotification(
+ string AttackerName,
+ uint AttackerGuid,
+ uint DamageType,
+ uint Damage,
+ uint HitQuadrant,
+ uint Critical);
+
+ public static DefenderNotification? ParseDefenderNotification(ReadOnlySpan payload)
+ {
+ int pos = 0;
+ try
+ {
+ string name = ReadString16L(payload, ref pos);
+ if (payload.Length - pos < 20) return null;
+ uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ uint dtype = 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 crit = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4;
+ return new DefenderNotification(name, guid, dtype, dmg, quad, crit);
+ }
+ catch { return null; }
+ }
+
+ /// 0x01B3 EvasionAttackerNotification — "X evaded".
+ public static string? ParseEvasionAttackerNotification(ReadOnlySpan payload)
+ {
+ int pos = 0;
+ try { return ReadString16L(payload, ref pos); } catch { return null; }
+ }
+
+ /// 0x01B4 EvasionDefenderNotification — "you evaded X".
+ public static string? ParseEvasionDefenderNotification(ReadOnlySpan payload)
+ {
+ int pos = 0;
+ try { return ReadString16L(payload, ref pos); } catch { return null; }
+ }
+
+ /// 0x01A7 AttackDone — (attackSequence, weenieError).
+ public readonly record struct AttackDone(uint AttackSequence, uint WeenieError);
+
+ public static AttackDone? ParseAttackDone(ReadOnlySpan payload)
+ {
+ if (payload.Length < 8) return null;
+ return new AttackDone(
+ BinaryPrimitives.ReadUInt32LittleEndian(payload),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
+ }
+
// ── Appraise / identify ─────────────────────────────────────────────────
/// 0x00C9 IdentifyObjectResponse header.
diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs
new file mode 100644
index 0000000..9308a13
--- /dev/null
+++ b/src/AcDream.Core/Combat/CombatState.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Concurrent;
+
+namespace AcDream.Core.Combat;
+
+///
+/// Client-side combat state — tracks per-entity health percent and
+/// emits typed events when UpdateHealth / Victim / Attacker / Defender
+/// notifications arrive. Powers target HP bars, damage floaters, combat
+/// log panel.
+///
+///
+/// Retail client-side combat responsibilities (r02 §7):
+///
+/// -
+/// Maintain a cache of "last known health percent" per entity guid.
+/// UpdateHealth (0x01C0) is sent when the player queries or the
+/// server broadcasts a change.
+///
+/// -
+/// Convert raw damage events into UI-ready notifications (colored
+/// floating numbers, "Critical!" flashes, body-part locations).
+///
+/// -
+/// Track self-centered notifications (you hit / you got hit / you
+/// evaded / you were evaded) so the log panel can format them
+/// correctly.
+///
+///
+///
+///
+///
+/// The server is authoritative: this class does NOT simulate damage
+/// locally (exception: a predictive-ish "estimated damage" display for
+/// the attack bar UI, which can use ).
+///
+///
+public sealed class CombatState
+{
+ private readonly ConcurrentDictionary _healthByGuid = new();
+
+ /// Fires when a target's health percent changes (from UpdateHealth).
+ public event Action? HealthChanged;
+
+ /// You (the player) got hit for some damage.
+ public event Action? DamageTaken;
+
+ /// You (the player) dealt some damage.
+ public event Action? DamageDealtAccepted;
+
+ /// You (the player) evaded an incoming hit.
+ public event Action? EvadedIncoming;
+
+ /// The target evaded your hit.
+ public event Action? MissedOutgoing;
+
+ /// An attack commit completed (0x01A7). WeenieError = 0 on success.
+ public event Action? AttackDone;
+
+ public readonly record struct DamageIncoming(
+ string AttackerName,
+ uint AttackerGuid,
+ uint DamageType,
+ uint Damage,
+ uint HitQuadrant,
+ bool Critical,
+ uint AttackType);
+
+ public readonly record struct DamageDealt(
+ string DefenderName,
+ uint DamageType,
+ uint Damage,
+ float DamagePercent);
+
+ /// Retrieve last known health percent for a guid, or 1.0 if unknown.
+ public float GetHealthPercent(uint guid) =>
+ _healthByGuid.TryGetValue(guid, out var pct) ? pct : 1f;
+
+ public int TrackedTargetCount => _healthByGuid.Count;
+
+ // ── Inbound handlers (wired from WorldSession.GameEvents) ────────────────
+
+ public void OnUpdateHealth(uint targetGuid, float healthPercent)
+ {
+ _healthByGuid[targetGuid] = healthPercent;
+ HealthChanged?.Invoke(targetGuid, healthPercent);
+ }
+
+ public void OnVictimNotification(
+ string attackerName, uint attackerGuid, uint damageType, uint damage,
+ uint hitQuadrant, uint critical, uint attackType)
+ {
+ DamageTaken?.Invoke(new DamageIncoming(
+ attackerName, attackerGuid, damageType, damage, hitQuadrant,
+ critical != 0, attackType));
+ }
+
+ public void OnDefenderNotification(
+ string attackerName, uint attackerGuid, uint damageType, uint damage,
+ uint hitQuadrant, uint critical)
+ {
+ // DefenderNotification is semantically the same as VictimNotification
+ // from the defender's POV — the client log merges them.
+ DamageTaken?.Invoke(new DamageIncoming(
+ attackerName, attackerGuid, damageType, damage, hitQuadrant,
+ critical != 0, 0));
+ }
+
+ public void OnAttackerNotification(
+ string defenderName, uint damageType, uint damage, float damagePercent)
+ {
+ DamageDealtAccepted?.Invoke(new DamageDealt(
+ defenderName, damageType, damage, damagePercent));
+ }
+
+ public void OnEvasionAttackerNotification(string defenderName)
+ => MissedOutgoing?.Invoke(defenderName);
+
+ public void OnEvasionDefenderNotification(string attackerName)
+ => EvadedIncoming?.Invoke(attackerName);
+
+ public void OnAttackDone(uint attackSequence, uint weenieError)
+ => AttackDone?.Invoke(attackSequence, weenieError);
+
+ public void Clear() => _healthByGuid.Clear();
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs
new file mode 100644
index 0000000..2352cac
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/CombatEventTests.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Buffers.Binary;
+using System.Text;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+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]
+ public void AttackTargetRequest_Build_EmitsCorrectWireBytes()
+ {
+ byte[] body = AttackTargetRequest.Build(
+ gameActionSequence: 3,
+ targetGuid: 0x12345678u,
+ powerLevel: 0.75f,
+ accuracyLevel: 0.5f,
+ attackHeight: 2);
+
+ Assert.Equal(28, body.Length);
+ Assert.Equal(AttackTargetRequest.GameActionEnvelope,
+ BinaryPrimitives.ReadUInt32LittleEndian(body));
+ Assert.Equal(3u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
+ Assert.Equal(AttackTargetRequest.SubOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0x12345678u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ Assert.Equal(0.75f,
+ BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4);
+ Assert.Equal(0.5f,
+ BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
+ Assert.Equal(2u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
+ }
+
+ [Fact]
+ public void ParseVictimNotification_RoundTrip()
+ {
+ byte[] name = MakeString16L("Attacker");
+ 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];
+ Buffer.BlockCopy(name, 0, payload, 0, name.Length);
+ Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
+
+ var parsed = GameEvents.ParseVictimNotification(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal("Attacker", parsed!.Value.AttackerName);
+ Assert.Equal(0xAAu, parsed.Value.AttackerGuid);
+ Assert.Equal(42u, parsed.Value.Damage);
+ Assert.Equal(1u, parsed.Value.Critical);
+ }
+
+ [Fact]
+ public void ParseAttackerNotification_RoundTrip()
+ {
+ byte[] name = MakeString16L("Drudge");
+ 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];
+ 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.Equal("Drudge", parsed!.Value.DefenderName);
+ Assert.Equal(30u, parsed.Value.Damage);
+ Assert.Equal(0.15f, parsed.Value.DamagePercent, 4);
+ }
+
+ [Fact]
+ public void ParseEvasionAttackerNotification_RoundTrip()
+ {
+ byte[] payload = MakeString16L("Thrower");
+ Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload));
+ }
+
+ [Fact]
+ public void ParseAttackDone_RoundTrip()
+ {
+ byte[] payload = new byte[8];
+ BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
+ BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error
+
+ var parsed = GameEvents.ParseAttackDone(payload);
+ Assert.NotNull(parsed);
+ Assert.Equal(42u, parsed!.Value.AttackSequence);
+ Assert.Equal(0u, parsed.Value.WeenieError);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs
new file mode 100644
index 0000000..d9baae8
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Combat/CombatStateTests.cs
@@ -0,0 +1,97 @@
+using AcDream.Core.Combat;
+using Xunit;
+
+namespace AcDream.Core.Tests.Combat;
+
+public sealed class CombatStateTests
+{
+ [Fact]
+ public void OnUpdateHealth_CachesPercent_AndFiresEvent()
+ {
+ var state = new CombatState();
+ uint seenGuid = 0;
+ float seenPct = 0;
+ state.HealthChanged += (g, p) => { seenGuid = g; seenPct = p; };
+
+ state.OnUpdateHealth(0xBEEF, 0.33f);
+
+ Assert.Equal(0xBEEFu, seenGuid);
+ Assert.Equal(0.33f, seenPct, 4);
+ Assert.Equal(0.33f, state.GetHealthPercent(0xBEEF), 4);
+ }
+
+ [Fact]
+ public void GetHealthPercent_Unknown_ReturnsFullHealth()
+ {
+ var state = new CombatState();
+ Assert.Equal(1f, state.GetHealthPercent(0xDEAD));
+ }
+
+ [Fact]
+ public void OnVictimNotification_FiresDamageTaken()
+ {
+ var state = new CombatState();
+ CombatState.DamageIncoming seen = default;
+ state.DamageTaken += d => seen = d;
+
+ state.OnVictimNotification("Drudge", 0xAA, 1, 42, 3, 1, 8);
+
+ Assert.Equal("Drudge", seen.AttackerName);
+ Assert.Equal(42u, seen.Damage);
+ Assert.True(seen.Critical);
+ }
+
+ [Fact]
+ public void OnAttackerNotification_FiresDamageDealt()
+ {
+ var state = new CombatState();
+ CombatState.DamageDealt seen = default;
+ state.DamageDealtAccepted += d => seen = d;
+
+ state.OnAttackerNotification("Drudge", 1, 30, 0.15f);
+
+ Assert.Equal("Drudge", seen.DefenderName);
+ Assert.Equal(30u, seen.Damage);
+ Assert.Equal(0.15f, seen.DamagePercent, 4);
+ }
+
+ [Fact]
+ public void OnEvasionNotification_FiresCorrectEvent()
+ {
+ var state = new CombatState();
+ string? evaded = null, missed = null;
+ state.EvadedIncoming += a => evaded = a;
+ state.MissedOutgoing += d => missed = d;
+
+ state.OnEvasionDefenderNotification("Rat"); // you evaded rat
+ state.OnEvasionAttackerNotification("Tusker"); // tusker evaded you
+
+ Assert.Equal("Rat", evaded);
+ Assert.Equal("Tusker", missed);
+ }
+
+ [Fact]
+ public void OnAttackDone_FiresAttackDone()
+ {
+ var state = new CombatState();
+ uint seenSeq = 0, seenErr = 999;
+ state.AttackDone += (s, e) => { seenSeq = s; seenErr = e; };
+
+ state.OnAttackDone(5, 0);
+ Assert.Equal(5u, seenSeq);
+ Assert.Equal(0u, seenErr);
+ }
+
+ [Fact]
+ public void Clear_ResetsHealthCache()
+ {
+ var state = new CombatState();
+ state.OnUpdateHealth(1, 0.5f);
+ state.OnUpdateHealth(2, 0.8f);
+
+ state.Clear();
+
+ Assert.Equal(1f, state.GetHealthPercent(1)); // back to default
+ Assert.Equal(0, state.TrackedTargetCount);
+ }
+}