From 831392a7b2c7b5e9868270de262d647c813dca82 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 11:37:49 +0200 Subject: [PATCH] feat(anim): Phase L.1c classify combat animation commands --- .../2026-04-28-combat-animation-planner.md | 68 +++++ .../Combat/CombatAnimationPlanner.cs | 278 ++++++++++++++++++ .../Combat/CombatAnimationPlannerTests.cs | 74 +++++ 3 files changed, 420 insertions(+) create mode 100644 docs/research/2026-04-28-combat-animation-planner.md create mode 100644 src/AcDream.Core/Combat/CombatAnimationPlanner.cs create mode 100644 tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs diff --git a/docs/research/2026-04-28-combat-animation-planner.md b/docs/research/2026-04-28-combat-animation-planner.md new file mode 100644 index 0000000..cd08388 --- /dev/null +++ b/docs/research/2026-04-28-combat-animation-planner.md @@ -0,0 +1,68 @@ +# Combat Animation Planner Pseudocode + +## Sources + +- Retail `ClientCombatSystem::ExecuteAttack` (`0x0056BB70`): sends + targeted melee or missile attack intent and records pending response state. + It does not choose a local swing animation. +- Retail `ClientCombatSystem::HandleCommenceAttackEvent` (`0x0056AD20`): + starts/updates power-bar and busy UI state. The event carries no + `MotionCommand`. +- Retail command-name table around `0x00803F34`: combat commands include + `Twitch1..4`, `StaggerBackward`, `StaggerForward`, `ThrustMed`, + `SlashHigh`, `Shoot`, `AttackHigh1`, and later offhand/multistrike + commands. +- ACE `Player_Melee.DoSwingMotion` and `GetSwingAnimation`: server chooses + a swing from `CombatManeuverTable.GetMotion(...)` and broadcasts the + selected `MotionCommand` with `UpdateMotion`. +- ACE `CombatManeuverTable.GetMotion`: indexes `(stance, attack height, + attack type)` to one or more motion commands; power level chooses between + multiple entries. + +## Retail Rule + +Combat GameEvents are state/UI notifications. Motion state is the animation +authority. + +## Pseudocode + +```text +PlanForEvent(event): + return None + +PlanFromWireCommand(wireCommand, speed): + fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand) + return PlanFromFullCommand(fullCommand, speed) + +PlanFromFullCommand(fullCommand, speed): + kind = ClassifyMotionCommand(fullCommand) + if kind is None: + return None + + routeKind = AnimationCommandRouter.Classify(fullCommand) + return Plan(kind, routeKind, fullCommand, speed) + +ClassifyMotionCommand(fullCommand): + if command is a combat stance: + return CombatStance + if command is a thrust/slash/backhand/offhand/multistrike motion: + return MeleeSwing + if command is Shoot, MissileAttack*, or Reload: + return MissileAttack + if command is AttackHigh/Med/Low 1..6: + return CreatureAttack + if command is CastSpell, UseMagicStaff, or UseMagicWand: + return SpellCast + if command is Twitch*, Stagger*, FallDown, or Sanctuary: + return HitReaction + if command is Dead: + return Death + return None +``` + +## Implementation Note + +The next table-driven layer can use `DatReaderWriter.DBObjs.CombatTable` +and `DatReaderWriter.Types.CombatManeuver` directly. acdream already +references `Chorizite.DatReaderWriter`; the missing live-state piece is a +named `CombatTable` data-id on player/creature state. diff --git a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs new file mode 100644 index 0000000..80b4ea5 --- /dev/null +++ b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs @@ -0,0 +1,278 @@ +using AcDream.Core.Physics; + +namespace AcDream.Core.Combat; + +/// +/// Retail-faithful combat animation planner for server-sent motion commands. +/// +/// Retail evidence: +/// - ClientCombatSystem::ExecuteAttack (0x0056BB70) only sends the +/// targeted melee/missile GameAction and sets response state; it does not +/// locally choose or play a swing animation. +/// - ClientCombatSystem::HandleCommenceAttackEvent (0x0056AD20) +/// updates the power bar/busy state; it carries no MotionCommand. +/// - ACE Player_Melee.DoSwingMotion chooses a swing via +/// CombatManeuverTable.GetMotion and broadcasts that MotionCommand +/// in UpdateMotion. +/// +/// So acdream treats combat GameEvents as state/UI signals and treats +/// UpdateMotion command IDs as the animation authority. +/// +public static class CombatAnimationPlanner +{ + public static CombatAnimationPlan PlanForEvent(CombatAnimationEvent combatEvent) + { + _ = combatEvent; + return CombatAnimationPlan.None; + } + + public static CombatAnimationPlan PlanFromWireCommand(ushort wireCommand, float speedMod = 1f) + { + uint fullCommand = MotionCommandResolver.ReconstructFullCommand(wireCommand); + return PlanFromFullCommand(fullCommand, speedMod); + } + + public static CombatAnimationPlan PlanFromFullCommand(uint fullCommand, float speedMod = 1f) + { + var kind = ClassifyMotionCommand(fullCommand); + if (kind == CombatAnimationKind.None) + return CombatAnimationPlan.None; + + return new CombatAnimationPlan( + kind, + AnimationCommandRouter.Classify(fullCommand), + fullCommand, + speedMod); + } + + public static CombatAnimationKind ClassifyMotionCommand(uint fullCommand) + { + return fullCommand switch + { + CombatAnimationMotionCommands.HandCombat + or CombatAnimationMotionCommands.SwordCombat + or CombatAnimationMotionCommands.SwordShieldCombat + or CombatAnimationMotionCommands.TwoHandedSwordCombat + or CombatAnimationMotionCommands.BowCombat + or CombatAnimationMotionCommands.CrossbowCombat + or CombatAnimationMotionCommands.SlingCombat + or CombatAnimationMotionCommands.AtlatlCombat + or CombatAnimationMotionCommands.ThrownShieldCombat + or CombatAnimationMotionCommands.Magic => CombatAnimationKind.CombatStance, + + CombatAnimationMotionCommands.ThrustMed + or CombatAnimationMotionCommands.ThrustLow + or CombatAnimationMotionCommands.ThrustHigh + or CombatAnimationMotionCommands.SlashHigh + or CombatAnimationMotionCommands.SlashMed + or CombatAnimationMotionCommands.SlashLow + or CombatAnimationMotionCommands.BackhandHigh + or CombatAnimationMotionCommands.BackhandMed + or CombatAnimationMotionCommands.BackhandLow + or CombatAnimationMotionCommands.DoubleSlashLow + or CombatAnimationMotionCommands.DoubleSlashMed + or CombatAnimationMotionCommands.DoubleSlashHigh + or CombatAnimationMotionCommands.TripleSlashLow + or CombatAnimationMotionCommands.TripleSlashMed + or CombatAnimationMotionCommands.TripleSlashHigh + or CombatAnimationMotionCommands.DoubleThrustLow + or CombatAnimationMotionCommands.DoubleThrustMed + or CombatAnimationMotionCommands.DoubleThrustHigh + or CombatAnimationMotionCommands.TripleThrustLow + or CombatAnimationMotionCommands.TripleThrustMed + or CombatAnimationMotionCommands.TripleThrustHigh + or CombatAnimationMotionCommands.OffhandSlashHigh + or CombatAnimationMotionCommands.OffhandSlashMed + or CombatAnimationMotionCommands.OffhandSlashLow + or CombatAnimationMotionCommands.OffhandThrustHigh + or CombatAnimationMotionCommands.OffhandThrustMed + or CombatAnimationMotionCommands.OffhandThrustLow + or CombatAnimationMotionCommands.OffhandDoubleSlashLow + or CombatAnimationMotionCommands.OffhandDoubleSlashMed + or CombatAnimationMotionCommands.OffhandDoubleSlashHigh + or CombatAnimationMotionCommands.OffhandTripleSlashLow + or CombatAnimationMotionCommands.OffhandTripleSlashMed + or CombatAnimationMotionCommands.OffhandTripleSlashHigh + or CombatAnimationMotionCommands.OffhandDoubleThrustLow + or CombatAnimationMotionCommands.OffhandDoubleThrustMed + or CombatAnimationMotionCommands.OffhandDoubleThrustHigh + or CombatAnimationMotionCommands.OffhandTripleThrustLow + or CombatAnimationMotionCommands.OffhandTripleThrustMed + or CombatAnimationMotionCommands.OffhandTripleThrustHigh + or CombatAnimationMotionCommands.OffhandKick => CombatAnimationKind.MeleeSwing, + + CombatAnimationMotionCommands.Shoot + or CombatAnimationMotionCommands.MissileAttack1 + or CombatAnimationMotionCommands.MissileAttack2 + or CombatAnimationMotionCommands.MissileAttack3 + or CombatAnimationMotionCommands.Reload => CombatAnimationKind.MissileAttack, + + CombatAnimationMotionCommands.AttackHigh1 + or CombatAnimationMotionCommands.AttackMed1 + or CombatAnimationMotionCommands.AttackLow1 + or CombatAnimationMotionCommands.AttackHigh2 + or CombatAnimationMotionCommands.AttackMed2 + or CombatAnimationMotionCommands.AttackLow2 + or CombatAnimationMotionCommands.AttackHigh3 + or CombatAnimationMotionCommands.AttackMed3 + or CombatAnimationMotionCommands.AttackLow3 + or CombatAnimationMotionCommands.AttackHigh4 + or CombatAnimationMotionCommands.AttackMed4 + or CombatAnimationMotionCommands.AttackLow4 + or CombatAnimationMotionCommands.AttackHigh5 + or CombatAnimationMotionCommands.AttackMed5 + or CombatAnimationMotionCommands.AttackLow5 + or CombatAnimationMotionCommands.AttackHigh6 + or CombatAnimationMotionCommands.AttackMed6 + or CombatAnimationMotionCommands.AttackLow6 => CombatAnimationKind.CreatureAttack, + + CombatAnimationMotionCommands.CastSpell + or CombatAnimationMotionCommands.UseMagicStaff + or CombatAnimationMotionCommands.UseMagicWand => CombatAnimationKind.SpellCast, + + CombatAnimationMotionCommands.FallDown + or CombatAnimationMotionCommands.Twitch1 + or CombatAnimationMotionCommands.Twitch2 + or CombatAnimationMotionCommands.Twitch3 + or CombatAnimationMotionCommands.Twitch4 + or CombatAnimationMotionCommands.StaggerBackward + or CombatAnimationMotionCommands.StaggerForward + or CombatAnimationMotionCommands.Sanctuary => CombatAnimationKind.HitReaction, + + MotionCommand.Dead => CombatAnimationKind.Death, + + _ => CombatAnimationKind.None, + }; + } +} + +public readonly record struct CombatAnimationPlan( + CombatAnimationKind Kind, + AnimationCommandRouteKind RouteKind, + uint MotionCommand, + float SpeedMod) +{ + public static CombatAnimationPlan None { get; } = new( + CombatAnimationKind.None, + AnimationCommandRouteKind.None, + 0u, + 0f); + + public bool HasMotion => Kind != CombatAnimationKind.None && MotionCommand != 0; +} + +public enum CombatAnimationEvent +{ + CombatCommenceAttack, + AttackDone, + AttackerNotification, + DefenderNotification, + EvasionAttackerNotification, + EvasionDefenderNotification, + VictimNotification, + KillerNotification, +} + +public enum CombatAnimationKind +{ + None = 0, + CombatStance, + MeleeSwing, + MissileAttack, + CreatureAttack, + SpellCast, + HitReaction, + Death, +} + +internal static class CombatAnimationMotionCommands +{ + public const uint HandCombat = 0x8000003Cu; + public const uint SwordCombat = 0x8000003Eu; + public const uint BowCombat = 0x8000003Fu; + public const uint SwordShieldCombat = 0x80000040u; + public const uint CrossbowCombat = 0x80000041u; + public const uint TwoHandedSwordCombat = 0x80000046u; + public const uint SlingCombat = 0x80000047u; + public const uint Magic = 0x80000049u; + public const uint AtlatlCombat = 0x8000013Bu; + public const uint ThrownShieldCombat = 0x8000013Cu; + + public const uint FallDown = 0x10000050u; + public const uint Twitch1 = 0x10000051u; + public const uint Twitch2 = 0x10000052u; + public const uint Twitch3 = 0x10000053u; + public const uint Twitch4 = 0x10000054u; + public const uint StaggerBackward = 0x10000055u; + public const uint StaggerForward = 0x10000056u; + public const uint Sanctuary = 0x10000057u; + public const uint ThrustMed = 0x10000058u; + public const uint ThrustLow = 0x10000059u; + public const uint ThrustHigh = 0x1000005Au; + public const uint SlashHigh = 0x1000005Bu; + public const uint SlashMed = 0x1000005Cu; + public const uint SlashLow = 0x1000005Du; + public const uint BackhandHigh = 0x1000005Eu; + public const uint BackhandMed = 0x1000005Fu; + public const uint BackhandLow = 0x10000060u; + public const uint Shoot = 0x10000061u; + public const uint AttackHigh1 = 0x10000062u; + public const uint AttackMed1 = 0x10000063u; + public const uint AttackLow1 = 0x10000064u; + public const uint AttackHigh2 = 0x10000065u; + public const uint AttackMed2 = 0x10000066u; + public const uint AttackLow2 = 0x10000067u; + public const uint AttackHigh3 = 0x10000068u; + public const uint AttackMed3 = 0x10000069u; + public const uint AttackLow3 = 0x1000006Au; + + public const uint MissileAttack1 = 0x100000D0u; + public const uint MissileAttack2 = 0x100000D1u; + public const uint MissileAttack3 = 0x100000D2u; + public const uint CastSpell = 0x400000D3u; + public const uint Reload = 0x100000D4u; + public const uint UseMagicStaff = 0x400000E0u; + public const uint UseMagicWand = 0x400000E1u; + + public const uint DoubleSlashLow = 0x1000011Fu; + public const uint DoubleSlashMed = 0x10000120u; + public const uint DoubleSlashHigh = 0x10000121u; + public const uint TripleSlashLow = 0x10000122u; + public const uint TripleSlashMed = 0x10000123u; + public const uint TripleSlashHigh = 0x10000124u; + public const uint DoubleThrustLow = 0x10000125u; + public const uint DoubleThrustMed = 0x10000126u; + public const uint DoubleThrustHigh = 0x10000127u; + public const uint TripleThrustLow = 0x10000128u; + public const uint TripleThrustMed = 0x10000129u; + public const uint TripleThrustHigh = 0x1000012Au; + + public const uint OffhandSlashHigh = 0x10000173u; + public const uint OffhandSlashMed = 0x10000174u; + public const uint OffhandSlashLow = 0x10000175u; + public const uint OffhandThrustHigh = 0x10000176u; + public const uint OffhandThrustMed = 0x10000177u; + public const uint OffhandThrustLow = 0x10000178u; + public const uint OffhandDoubleSlashLow = 0x10000179u; + public const uint OffhandDoubleSlashMed = 0x1000017Au; + public const uint OffhandDoubleSlashHigh = 0x1000017Bu; + public const uint OffhandTripleSlashLow = 0x1000017Cu; + public const uint OffhandTripleSlashMed = 0x1000017Du; + public const uint OffhandTripleSlashHigh = 0x1000017Eu; + public const uint OffhandDoubleThrustLow = 0x1000017Fu; + public const uint OffhandDoubleThrustMed = 0x10000180u; + public const uint OffhandDoubleThrustHigh = 0x10000181u; + public const uint OffhandTripleThrustLow = 0x10000182u; + public const uint OffhandTripleThrustMed = 0x10000183u; + public const uint OffhandTripleThrustHigh = 0x10000184u; + public const uint OffhandKick = 0x10000185u; + public const uint AttackHigh4 = 0x10000186u; + public const uint AttackMed4 = 0x10000187u; + public const uint AttackLow4 = 0x10000188u; + public const uint AttackHigh5 = 0x10000189u; + public const uint AttackMed5 = 0x1000018Au; + public const uint AttackLow5 = 0x1000018Bu; + public const uint AttackHigh6 = 0x1000018Cu; + public const uint AttackMed6 = 0x1000018Du; + public const uint AttackLow6 = 0x1000018Eu; +} diff --git a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs new file mode 100644 index 0000000..6a40308 --- /dev/null +++ b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs @@ -0,0 +1,74 @@ +using AcDream.Core.Combat; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Combat; + +public sealed class CombatAnimationPlannerTests +{ + [Theory] + [InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed + [InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh + [InlineData(0x10000180u, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed + [InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot + [InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload + [InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1 + [InlineData(0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6 + [InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell + [InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff + [InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1 + [InlineData(0x10000055u, CombatAnimationKind.HitReaction)] // StaggerBackward + [InlineData(0x40000011u, CombatAnimationKind.Death)] // Dead + [InlineData(0x8000003Eu, CombatAnimationKind.CombatStance)] // SwordCombat + public void ClassifyMotionCommand_RecognisesRetailCombatCommands( + uint command, + CombatAnimationKind expected) + { + Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command)); + } + + [Fact] + public void PlanFromWireCommand_Swing_IsActionOverlay() + { + var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0058, speedMod: 1.25f); + + Assert.Equal(CombatAnimationKind.MeleeSwing, plan.Kind); + Assert.Equal(AnimationCommandRouteKind.Action, plan.RouteKind); + Assert.Equal(0x10000058u, plan.MotionCommand); + Assert.Equal(1.25f, plan.SpeedMod); + Assert.True(plan.HasMotion); + } + + [Fact] + public void PlanFromWireCommand_Dead_IsPersistentSubState() + { + var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0011); + + Assert.Equal(CombatAnimationKind.Death, plan.Kind); + Assert.Equal(AnimationCommandRouteKind.SubState, plan.RouteKind); + Assert.Equal(MotionCommand.Dead, plan.MotionCommand); + } + + [Fact] + public void PlanFromWireCommand_Unknown_IsNone() + { + var plan = CombatAnimationPlanner.PlanFromWireCommand(0xFFFF); + + Assert.Equal(CombatAnimationPlan.None, plan); + Assert.False(plan.HasMotion); + } + + [Theory] + [InlineData(CombatAnimationEvent.CombatCommenceAttack)] + [InlineData(CombatAnimationEvent.AttackDone)] + [InlineData(CombatAnimationEvent.AttackerNotification)] + [InlineData(CombatAnimationEvent.DefenderNotification)] + [InlineData(CombatAnimationEvent.EvasionAttackerNotification)] + [InlineData(CombatAnimationEvent.EvasionDefenderNotification)] + [InlineData(CombatAnimationEvent.VictimNotification)] + [InlineData(CombatAnimationEvent.KillerNotification)] + public void PlanForEvent_DoesNotInventAnimations(CombatAnimationEvent combatEvent) + { + Assert.Equal(CombatAnimationPlan.None, CombatAnimationPlanner.PlanForEvent(combatEvent)); + } +}