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,
+ };
+ }
+}