From 646246ba84f1dee7ebd4a195bca25e9f49ed7676 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 11:44:17 +0200 Subject: [PATCH] feat(anim): Phase L.1c select combat maneuvers --- .../2026-04-28-combat-animation-planner.md | 45 +++++ .../Combat/CombatAnimationPlanner.cs | 92 +++++++---- .../Combat/CombatManeuverSelector.cs | 89 ++++++++++ .../Physics/MotionCommandResolver.cs | 18 ++ .../Combat/CombatAnimationPlannerTests.cs | 19 ++- .../Combat/CombatManeuverSelectorTests.cs | 155 ++++++++++++++++++ 6 files changed, 385 insertions(+), 33 deletions(-) create mode 100644 src/AcDream.Core/Combat/CombatManeuverSelector.cs create mode 100644 tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs diff --git a/docs/research/2026-04-28-combat-animation-planner.md b/docs/research/2026-04-28-combat-animation-planner.md index cd08388..f557659 100644 --- a/docs/research/2026-04-28-combat-animation-planner.md +++ b/docs/research/2026-04-28-combat-animation-planner.md @@ -60,6 +60,51 @@ ClassifyMotionCommand(fullCommand): return None ``` +## Maneuver Selection Pseudocode + +```text +SelectMotion(table, stance, attackHeight, attackType, powerLevel, + isThrustSlashWeapon): + candidates = [] + for maneuver in table.CombatManeuvers: + if maneuver.Style == stance + and maneuver.AttackHeight == attackHeight + and maneuver.AttackType == attackType: + candidates.append(maneuver.Motion) + + if candidates is empty: + return None + + subdivision = isThrustSlashWeapon ? 0.66 : 0.33 + + if candidates.Count > 1 and powerLevel < subdivision: + motion = candidates[1] + else: + motion = candidates[0] + + return motion +``` + +This matches ACE `CombatManeuverTable.GetMotion` plus +`Player_Melee.GetSwingAnimation`. The `prevMotion` parameter is present in +ACE's table API but the current ACE implementation does not use it; the +power threshold chooses between multiple entries. + +## Named Retail Motion IDs + +`DatReaderWriter.Enums.MotionCommand` is shifted by three entries starting +at `AllegianceHometownRecall`. Named retail command tables are: + +- `command_ids` table lines 1017626-1017658: + `0x016E..0x0197 -> 0x1000016E..0x10000197`. +- command-name table lines 1068272-1068313: + `OffhandSlashHigh = 0x10000170`, `AttackLow6 = 0x1000018B`, + `PunchFastLow = 0x1000018E`, etc. + +`MotionCommandResolver` therefore overrides that range after building the +DRW reflection table, otherwise offhand and late unarmed attack actions +resolve as UI/mappable commands and never reach `PlayAction`. + ## Implementation Note The next table-driven layer can use `DatReaderWriter.DBObjs.CombatTable` diff --git a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs index 80b4ea5..bf67a85 100644 --- a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs +++ b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs @@ -53,9 +53,12 @@ public static class CombatAnimationPlanner or CombatAnimationMotionCommands.SwordCombat or CombatAnimationMotionCommands.SwordShieldCombat or CombatAnimationMotionCommands.TwoHandedSwordCombat + or CombatAnimationMotionCommands.TwoHandedStaffCombat or CombatAnimationMotionCommands.BowCombat or CombatAnimationMotionCommands.CrossbowCombat or CombatAnimationMotionCommands.SlingCombat + or CombatAnimationMotionCommands.DualWieldCombat + or CombatAnimationMotionCommands.ThrownWeaponCombat or CombatAnimationMotionCommands.AtlatlCombat or CombatAnimationMotionCommands.ThrownShieldCombat or CombatAnimationMotionCommands.Magic => CombatAnimationKind.CombatStance, @@ -99,7 +102,19 @@ public static class CombatAnimationPlanner or CombatAnimationMotionCommands.OffhandTripleThrustLow or CombatAnimationMotionCommands.OffhandTripleThrustMed or CombatAnimationMotionCommands.OffhandTripleThrustHigh - or CombatAnimationMotionCommands.OffhandKick => CombatAnimationKind.MeleeSwing, + or CombatAnimationMotionCommands.OffhandKick + or CombatAnimationMotionCommands.PunchFastHigh + or CombatAnimationMotionCommands.PunchFastMed + or CombatAnimationMotionCommands.PunchFastLow + or CombatAnimationMotionCommands.PunchSlowHigh + or CombatAnimationMotionCommands.PunchSlowMed + or CombatAnimationMotionCommands.PunchSlowLow + or CombatAnimationMotionCommands.OffhandPunchFastHigh + or CombatAnimationMotionCommands.OffhandPunchFastMed + or CombatAnimationMotionCommands.OffhandPunchFastLow + or CombatAnimationMotionCommands.OffhandPunchSlowHigh + or CombatAnimationMotionCommands.OffhandPunchSlowMed + or CombatAnimationMotionCommands.OffhandPunchSlowLow => CombatAnimationKind.MeleeSwing, CombatAnimationMotionCommands.Shoot or CombatAnimationMotionCommands.MissileAttack1 @@ -192,8 +207,11 @@ internal static class CombatAnimationMotionCommands 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 SlingCombat = 0x80000043u; + public const uint TwoHandedSwordCombat = 0x80000044u; + public const uint TwoHandedStaffCombat = 0x80000045u; + public const uint DualWieldCombat = 0x80000046u; + public const uint ThrownWeaponCombat = 0x80000047u; public const uint Magic = 0x80000049u; public const uint AtlatlCombat = 0x8000013Bu; public const uint ThrownShieldCombat = 0x8000013Cu; @@ -247,32 +265,44 @@ internal static class CombatAnimationMotionCommands 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; + public const uint OffhandSlashHigh = 0x10000170u; + public const uint OffhandSlashMed = 0x10000171u; + public const uint OffhandSlashLow = 0x10000172u; + public const uint OffhandThrustHigh = 0x10000173u; + public const uint OffhandThrustMed = 0x10000174u; + public const uint OffhandThrustLow = 0x10000175u; + public const uint OffhandDoubleSlashLow = 0x10000176u; + public const uint OffhandDoubleSlashMed = 0x10000177u; + public const uint OffhandDoubleSlashHigh = 0x10000178u; + public const uint OffhandTripleSlashLow = 0x10000179u; + public const uint OffhandTripleSlashMed = 0x1000017Au; + public const uint OffhandTripleSlashHigh = 0x1000017Bu; + public const uint OffhandDoubleThrustLow = 0x1000017Cu; + public const uint OffhandDoubleThrustMed = 0x1000017Du; + public const uint OffhandDoubleThrustHigh = 0x1000017Eu; + public const uint OffhandTripleThrustLow = 0x1000017Fu; + public const uint OffhandTripleThrustMed = 0x10000180u; + public const uint OffhandTripleThrustHigh = 0x10000181u; + public const uint OffhandKick = 0x10000182u; + public const uint AttackHigh4 = 0x10000183u; + public const uint AttackMed4 = 0x10000184u; + public const uint AttackLow4 = 0x10000185u; + public const uint AttackHigh5 = 0x10000186u; + public const uint AttackMed5 = 0x10000187u; + public const uint AttackLow5 = 0x10000188u; + public const uint AttackHigh6 = 0x10000189u; + public const uint AttackMed6 = 0x1000018Au; + public const uint AttackLow6 = 0x1000018Bu; + public const uint PunchFastHigh = 0x1000018Cu; + public const uint PunchFastMed = 0x1000018Du; + public const uint PunchFastLow = 0x1000018Eu; + public const uint PunchSlowHigh = 0x1000018Fu; + public const uint PunchSlowMed = 0x10000190u; + public const uint PunchSlowLow = 0x10000191u; + public const uint OffhandPunchFastHigh = 0x10000192u; + public const uint OffhandPunchFastMed = 0x10000193u; + public const uint OffhandPunchFastLow = 0x10000194u; + public const uint OffhandPunchSlowHigh = 0x10000195u; + public const uint OffhandPunchSlowMed = 0x10000196u; + public const uint OffhandPunchSlowLow = 0x10000197u; } diff --git a/src/AcDream.Core/Combat/CombatManeuverSelector.cs b/src/AcDream.Core/Combat/CombatManeuverSelector.cs new file mode 100644 index 0000000..8d0d07f --- /dev/null +++ b/src/AcDream.Core/Combat/CombatManeuverSelector.cs @@ -0,0 +1,89 @@ +using DatReaderWriter.DBObjs; +using DatMotionCommand = DatReaderWriter.Enums.MotionCommand; +using DatMotionStance = DatReaderWriter.Enums.MotionStance; +using DatAttackHeight = DatReaderWriter.Enums.AttackHeight; +using DatAttackType = DatReaderWriter.Enums.AttackType; + +namespace AcDream.Core.Combat; + +/// +/// Selects combat swing motions from the retail CombatTable DBObj. +/// +/// Retail evidence: +/// - CombatManeuverTable::Get (0x0056AB60) loads DB type +/// 0x1000000D for a 0x30xxxxxx combat table id. +/// - ACE CombatManeuverTable.GetMotion indexes maneuvers by +/// stance, attack height, and attack type, returning all matching motions. +/// - ACE Player_Melee.GetSwingAnimation then chooses +/// motions[1] when more than one motion exists and power is below +/// the subdivision threshold; otherwise it uses motions[0]. +/// +public static class CombatManeuverSelector +{ + public const float DefaultSubdivision = 0.33f; + public const float ThrustSlashSubdivision = 0.66f; + + public static CombatManeuverSelection SelectMotion( + CombatTable table, + DatMotionStance stance, + DatAttackHeight attackHeight, + DatAttackType attackType, + float powerLevel, + bool isThrustSlashWeapon = false) + { + var motions = FindMotions(table, stance, attackHeight, attackType); + if (motions.Count == 0) + return CombatManeuverSelection.None; + + float subdivision = isThrustSlashWeapon + ? ThrustSlashSubdivision + : DefaultSubdivision; + + var motion = motions.Count > 1 && powerLevel < subdivision + ? motions[1] + : motions[0]; + + return new CombatManeuverSelection( + Found: true, + Motion: motion, + Candidates: motions, + EffectiveAttackType: attackType, + Subdivision: subdivision); + } + + public static IReadOnlyList FindMotions( + CombatTable table, + DatMotionStance stance, + DatAttackHeight attackHeight, + DatAttackType attackType) + { + var result = new List(); + + foreach (var maneuver in table.CombatManeuvers) + { + if (maneuver.Style == stance + && maneuver.AttackHeight == attackHeight + && maneuver.AttackType == attackType) + { + result.Add(maneuver.Motion); + } + } + + return result; + } +} + +public readonly record struct CombatManeuverSelection( + bool Found, + DatMotionCommand Motion, + IReadOnlyList Candidates, + DatAttackType EffectiveAttackType, + float Subdivision) +{ + public static CombatManeuverSelection None { get; } = new( + Found: false, + Motion: DatMotionCommand.Invalid, + Candidates: Array.Empty(), + EffectiveAttackType: DatAttackType.Undef, + Subdivision: 0f); +} diff --git a/src/AcDream.Core/Physics/MotionCommandResolver.cs b/src/AcDream.Core/Physics/MotionCommandResolver.cs index 1a0a3e2..016d8e1 100644 --- a/src/AcDream.Core/Physics/MotionCommandResolver.cs +++ b/src/AcDream.Core/Physics/MotionCommandResolver.cs @@ -84,6 +84,24 @@ public static class MotionCommandResolver result[lo] = full; } } + + ApplyNamedRetailOverrides(result); return result; } + + private static void ApplyNamedRetailOverrides(Dictionary result) + { + // The generated DRW enum is shifted by three entries starting at + // AllegianceHometownRecall. The named Sept 2013 retail command_ids + // table is authoritative here: + // named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658 + // and command-name table lines 1068272-1068313. + // + // These values cover recall, offhand, attack 4-6, and fast/slow punch + // actions. Without the override, wire command 0x0170 reconstructs to + // IssueSlashCommand instead of OffhandSlashHigh, so offhand swing + // animations route as UI commands and never play. + for (ushort lo = 0x016E; lo <= 0x0197; lo++) + result[lo] = 0x10000000u | lo; + } } diff --git a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs index 6a40308..7a4a9a1 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs @@ -9,17 +9,20 @@ public sealed class CombatAnimationPlannerTests [Theory] [InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed [InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh - [InlineData(0x10000180u, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed + [InlineData(0x1000017Du, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed + [InlineData(0x1000018Eu, CombatAnimationKind.MeleeSwing)] // PunchFastLow [InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot [InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload [InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1 - [InlineData(0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6 + [InlineData(0x1000018Bu, 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 + [InlineData(0x80000043u, CombatAnimationKind.CombatStance)] // SlingCombat + [InlineData(0x80000044u, CombatAnimationKind.CombatStance)] // 2HandedSwordCombat public void ClassifyMotionCommand_RecognisesRetailCombatCommands( uint command, CombatAnimationKind expected) @@ -27,6 +30,18 @@ public sealed class CombatAnimationPlannerTests Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command)); } + [Theory] + [InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh + [InlineData(0x017D, 0x1000017Du)] // OffhandDoubleThrustMed + [InlineData(0x018B, 0x1000018Bu)] // AttackLow6 + [InlineData(0x018E, 0x1000018Eu)] // PunchFastLow + public void MotionCommandResolver_UsesNamedRetailLateCombatCommands( + ushort wireCommand, + uint expectedFullCommand) + { + Assert.Equal(expectedFullCommand, MotionCommandResolver.ReconstructFullCommand(wireCommand)); + } + [Fact] public void PlanFromWireCommand_Swing_IsActionOverlay() { diff --git a/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs b/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs new file mode 100644 index 0000000..72e32a7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Combat/CombatManeuverSelectorTests.cs @@ -0,0 +1,155 @@ +using AcDream.Core.Combat; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using DatAttackHeight = DatReaderWriter.Enums.AttackHeight; +using DatAttackType = DatReaderWriter.Enums.AttackType; +using DatMotionCommand = DatReaderWriter.Enums.MotionCommand; +using DatMotionStance = DatReaderWriter.Enums.MotionStance; +using Xunit; + +namespace AcDream.Core.Tests.Combat; + +public sealed class CombatManeuverSelectorTests +{ + [Fact] + public void SelectMotion_UsesFirstEntryAtOrAboveSubdivision() + { + var table = MakeTable( + Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium, + DatAttackType.Slash, DatMotionCommand.SlashMed), + Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium, + DatAttackType.Slash, DatMotionCommand.BackhandMed)); + + var atThreshold = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.Medium, + DatAttackType.Slash, + powerLevel: CombatManeuverSelector.DefaultSubdivision); + + var highPower = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.Medium, + DatAttackType.Slash, + powerLevel: 1f); + + Assert.Equal(DatMotionCommand.SlashMed, atThreshold.Motion); + Assert.Equal(DatMotionCommand.SlashMed, highPower.Motion); + } + + [Fact] + public void SelectMotion_UsesSecondEntryBelowSubdivision() + { + var table = MakeTable( + Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium, + DatAttackType.Slash, DatMotionCommand.SlashMed), + Entry(DatMotionStance.SwordCombat, DatAttackHeight.Medium, + DatAttackType.Slash, DatMotionCommand.BackhandMed)); + + var selection = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.Medium, + DatAttackType.Slash, + powerLevel: 0.2f); + + Assert.True(selection.Found); + Assert.Equal(DatMotionCommand.BackhandMed, selection.Motion); + Assert.Equal(DatAttackType.Slash, selection.EffectiveAttackType); + Assert.Equal(2, selection.Candidates.Count); + } + + [Fact] + public void SelectMotion_ThrustSlashWeaponUsesTwoThirdsSubdivision() + { + var table = MakeTable( + Entry(DatMotionStance.SwordCombat, DatAttackHeight.High, + DatAttackType.Slash, DatMotionCommand.SlashHigh), + Entry(DatMotionStance.SwordCombat, DatAttackHeight.High, + DatAttackType.Slash, DatMotionCommand.BackhandHigh)); + + var normal = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.High, + DatAttackType.Slash, + powerLevel: 0.5f); + + var thrustSlash = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.High, + DatAttackType.Slash, + powerLevel: 0.5f, + isThrustSlashWeapon: true); + + Assert.Equal(DatMotionCommand.SlashHigh, normal.Motion); + Assert.Equal(DatMotionCommand.BackhandHigh, thrustSlash.Motion); + Assert.Equal(CombatManeuverSelector.ThrustSlashSubdivision, thrustSlash.Subdivision); + } + + [Fact] + public void SelectMotion_MissingLookupReturnsNone() + { + var table = MakeTable( + Entry(DatMotionStance.BowCombat, DatAttackHeight.High, + DatAttackType.Punch, DatMotionCommand.Shoot)); + + var selection = CombatManeuverSelector.SelectMotion( + table, + DatMotionStance.SwordCombat, + DatAttackHeight.High, + DatAttackType.Punch, + powerLevel: 0.5f); + + Assert.Equal(CombatManeuverSelection.None, selection); + } + + [Fact] + public void FindMotions_PreservesRetailTableOrder() + { + var table = MakeTable( + Entry(DatMotionStance.HandCombat, DatAttackHeight.Low, + DatAttackType.Kick, DatMotionCommand.AttackLow1), + Entry(DatMotionStance.HandCombat, DatAttackHeight.Low, + DatAttackType.Kick, (DatMotionCommand)0x1000018Eu), + Entry(DatMotionStance.HandCombat, DatAttackHeight.Low, + DatAttackType.Punch, DatMotionCommand.AttackLow2)); + + var motions = CombatManeuverSelector.FindMotions( + table, + DatMotionStance.HandCombat, + DatAttackHeight.Low, + DatAttackType.Kick); + + Assert.Equal(new[] + { + DatMotionCommand.AttackLow1, + (DatMotionCommand)0x1000018Eu, + }, motions); + } + + private static CombatTable MakeTable(params CombatManeuver[] maneuvers) + { + var table = new CombatTable(); + table.CombatManeuvers.AddRange(maneuvers); + return table; + } + + private static CombatManeuver Entry( + DatMotionStance stance, + DatAttackHeight height, + DatAttackType type, + DatMotionCommand motion) + { + return new CombatManeuver + { + Style = stance, + AttackHeight = height, + AttackType = type, + MinSkillLevel = 0, + Motion = motion, + }; + } +}