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 =>
{
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
if (p is not null) combat.OnVictimNotification(
p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType,
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType);
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
});
dispatcher.Register(GameEventType.DefenderNotification, e =>
{
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
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);
});
dispatcher.Register(GameEventType.AttackerNotification, e =>
{
var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
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 =>
{
@ -188,12 +186,15 @@ public static class GameEventWiring
var p = GameEvents.ParseAttackDone(e.Payload.Span);
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 =>
{
// 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);
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 ────────────────────────────────────────────────

View file

@ -3,60 +3,79 @@ using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <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>
/// 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 0x0008 // TargetedMeleeAttack
/// u32 targetGuid
/// 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>
/// </para>
///
/// <para>
/// 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
/// <see cref="GameEventType.VictimNotification"/> / AttackerNotification
/// / DefenderNotification / EvasionAttackerNotification /
/// EvasionDefenderNotification with the result.
/// </para>
///
/// <para>
/// References: r02 §7 (wire format), r08 §3 opcode 0x0008.
/// </para>
/// References: CM_Combat::Event_TargetedMeleeAttack 0x006A9C10,
/// CM_Combat::Event_TargetedMissileAttack 0x006A9D60, ACE
/// GameActionTargetedMeleeAttack/GameActionTargetedMissileAttack, and
/// holtburger protocol game_action.rs.
/// </summary>
public static class AttackTargetRequest
{
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>
/// Build the wire body for an attack request.
/// </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(
/// <summary>Build the wire body for a targeted melee attack.</summary>
public static byte[] BuildMelee(
uint gameActionSequence,
uint targetGuid,
float powerLevel,
float accuracyLevel,
uint attackHeight)
uint attackHeight,
float powerLevel)
{
byte[] body = new byte[28];
byte[] body = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
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.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.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;
}
}

View file

@ -22,9 +22,17 @@ public static class CharacterActions
public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
[Flags]
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>

View file

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

View file

@ -57,6 +57,9 @@ public sealed class CombatState
/// <summary>An attack commit completed (0x01A7). WeenieError = 0 on success.</summary>
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>
/// Fires when the server confirms the player landed a killing blow
/// (GameEvent <c>KillerNotification (0x01AD)</c>). Event payload is
@ -140,5 +143,8 @@ public sealed class CombatState
public void OnAttackDone(uint attackSequence, uint weenieError)
=> AttackDone?.Invoke(attackSequence, weenieError);
public void OnCombatCommenceAttack()
=> AttackCommenced?.Invoke();
public void Clear() => _healthByGuid.Clear();
}