feat(anim): Phase L.1c select combat maneuvers

This commit is contained in:
Erik 2026-04-28 11:44:17 +02:00
parent 831392a7b2
commit 646246ba84
6 changed files with 385 additions and 33 deletions

View file

@ -60,6 +60,51 @@ ClassifyMotionCommand(fullCommand):
return None 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 ## Implementation Note
The next table-driven layer can use `DatReaderWriter.DBObjs.CombatTable` The next table-driven layer can use `DatReaderWriter.DBObjs.CombatTable`

View file

@ -53,9 +53,12 @@ public static class CombatAnimationPlanner
or CombatAnimationMotionCommands.SwordCombat or CombatAnimationMotionCommands.SwordCombat
or CombatAnimationMotionCommands.SwordShieldCombat or CombatAnimationMotionCommands.SwordShieldCombat
or CombatAnimationMotionCommands.TwoHandedSwordCombat or CombatAnimationMotionCommands.TwoHandedSwordCombat
or CombatAnimationMotionCommands.TwoHandedStaffCombat
or CombatAnimationMotionCommands.BowCombat or CombatAnimationMotionCommands.BowCombat
or CombatAnimationMotionCommands.CrossbowCombat or CombatAnimationMotionCommands.CrossbowCombat
or CombatAnimationMotionCommands.SlingCombat or CombatAnimationMotionCommands.SlingCombat
or CombatAnimationMotionCommands.DualWieldCombat
or CombatAnimationMotionCommands.ThrownWeaponCombat
or CombatAnimationMotionCommands.AtlatlCombat or CombatAnimationMotionCommands.AtlatlCombat
or CombatAnimationMotionCommands.ThrownShieldCombat or CombatAnimationMotionCommands.ThrownShieldCombat
or CombatAnimationMotionCommands.Magic => CombatAnimationKind.CombatStance, or CombatAnimationMotionCommands.Magic => CombatAnimationKind.CombatStance,
@ -99,7 +102,19 @@ public static class CombatAnimationPlanner
or CombatAnimationMotionCommands.OffhandTripleThrustLow or CombatAnimationMotionCommands.OffhandTripleThrustLow
or CombatAnimationMotionCommands.OffhandTripleThrustMed or CombatAnimationMotionCommands.OffhandTripleThrustMed
or CombatAnimationMotionCommands.OffhandTripleThrustHigh 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 CombatAnimationMotionCommands.Shoot
or CombatAnimationMotionCommands.MissileAttack1 or CombatAnimationMotionCommands.MissileAttack1
@ -192,8 +207,11 @@ internal static class CombatAnimationMotionCommands
public const uint BowCombat = 0x8000003Fu; public const uint BowCombat = 0x8000003Fu;
public const uint SwordShieldCombat = 0x80000040u; public const uint SwordShieldCombat = 0x80000040u;
public const uint CrossbowCombat = 0x80000041u; public const uint CrossbowCombat = 0x80000041u;
public const uint TwoHandedSwordCombat = 0x80000046u; public const uint SlingCombat = 0x80000043u;
public const uint SlingCombat = 0x80000047u; 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 Magic = 0x80000049u;
public const uint AtlatlCombat = 0x8000013Bu; public const uint AtlatlCombat = 0x8000013Bu;
public const uint ThrownShieldCombat = 0x8000013Cu; public const uint ThrownShieldCombat = 0x8000013Cu;
@ -247,32 +265,44 @@ internal static class CombatAnimationMotionCommands
public const uint TripleThrustMed = 0x10000129u; public const uint TripleThrustMed = 0x10000129u;
public const uint TripleThrustHigh = 0x1000012Au; public const uint TripleThrustHigh = 0x1000012Au;
public const uint OffhandSlashHigh = 0x10000173u; public const uint OffhandSlashHigh = 0x10000170u;
public const uint OffhandSlashMed = 0x10000174u; public const uint OffhandSlashMed = 0x10000171u;
public const uint OffhandSlashLow = 0x10000175u; public const uint OffhandSlashLow = 0x10000172u;
public const uint OffhandThrustHigh = 0x10000176u; public const uint OffhandThrustHigh = 0x10000173u;
public const uint OffhandThrustMed = 0x10000177u; public const uint OffhandThrustMed = 0x10000174u;
public const uint OffhandThrustLow = 0x10000178u; public const uint OffhandThrustLow = 0x10000175u;
public const uint OffhandDoubleSlashLow = 0x10000179u; public const uint OffhandDoubleSlashLow = 0x10000176u;
public const uint OffhandDoubleSlashMed = 0x1000017Au; public const uint OffhandDoubleSlashMed = 0x10000177u;
public const uint OffhandDoubleSlashHigh = 0x1000017Bu; public const uint OffhandDoubleSlashHigh = 0x10000178u;
public const uint OffhandTripleSlashLow = 0x1000017Cu; public const uint OffhandTripleSlashLow = 0x10000179u;
public const uint OffhandTripleSlashMed = 0x1000017Du; public const uint OffhandTripleSlashMed = 0x1000017Au;
public const uint OffhandTripleSlashHigh = 0x1000017Eu; public const uint OffhandTripleSlashHigh = 0x1000017Bu;
public const uint OffhandDoubleThrustLow = 0x1000017Fu; public const uint OffhandDoubleThrustLow = 0x1000017Cu;
public const uint OffhandDoubleThrustMed = 0x10000180u; public const uint OffhandDoubleThrustMed = 0x1000017Du;
public const uint OffhandDoubleThrustHigh = 0x10000181u; public const uint OffhandDoubleThrustHigh = 0x1000017Eu;
public const uint OffhandTripleThrustLow = 0x10000182u; public const uint OffhandTripleThrustLow = 0x1000017Fu;
public const uint OffhandTripleThrustMed = 0x10000183u; public const uint OffhandTripleThrustMed = 0x10000180u;
public const uint OffhandTripleThrustHigh = 0x10000184u; public const uint OffhandTripleThrustHigh = 0x10000181u;
public const uint OffhandKick = 0x10000185u; public const uint OffhandKick = 0x10000182u;
public const uint AttackHigh4 = 0x10000186u; public const uint AttackHigh4 = 0x10000183u;
public const uint AttackMed4 = 0x10000187u; public const uint AttackMed4 = 0x10000184u;
public const uint AttackLow4 = 0x10000188u; public const uint AttackLow4 = 0x10000185u;
public const uint AttackHigh5 = 0x10000189u; public const uint AttackHigh5 = 0x10000186u;
public const uint AttackMed5 = 0x1000018Au; public const uint AttackMed5 = 0x10000187u;
public const uint AttackLow5 = 0x1000018Bu; public const uint AttackLow5 = 0x10000188u;
public const uint AttackHigh6 = 0x1000018Cu; public const uint AttackHigh6 = 0x10000189u;
public const uint AttackMed6 = 0x1000018Du; public const uint AttackMed6 = 0x1000018Au;
public const uint AttackLow6 = 0x1000018Eu; 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;
} }

View file

@ -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;
/// <summary>
/// Selects combat swing motions from the retail <c>CombatTable</c> DBObj.
///
/// Retail evidence:
/// - <c>CombatManeuverTable::Get</c> (0x0056AB60) loads DB type
/// <c>0x1000000D</c> for a 0x30xxxxxx combat table id.
/// - ACE <c>CombatManeuverTable.GetMotion</c> indexes maneuvers by
/// stance, attack height, and attack type, returning all matching motions.
/// - ACE <c>Player_Melee.GetSwingAnimation</c> then chooses
/// <c>motions[1]</c> when more than one motion exists and power is below
/// the subdivision threshold; otherwise it uses <c>motions[0]</c>.
/// </summary>
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<DatMotionCommand> FindMotions(
CombatTable table,
DatMotionStance stance,
DatAttackHeight attackHeight,
DatAttackType attackType)
{
var result = new List<DatMotionCommand>();
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<DatMotionCommand> Candidates,
DatAttackType EffectiveAttackType,
float Subdivision)
{
public static CombatManeuverSelection None { get; } = new(
Found: false,
Motion: DatMotionCommand.Invalid,
Candidates: Array.Empty<DatMotionCommand>(),
EffectiveAttackType: DatAttackType.Undef,
Subdivision: 0f);
}

View file

@ -84,6 +84,24 @@ public static class MotionCommandResolver
result[lo] = full; result[lo] = full;
} }
} }
ApplyNamedRetailOverrides(result);
return result; return result;
} }
private static void ApplyNamedRetailOverrides(Dictionary<ushort, uint> 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;
}
} }

View file

@ -9,17 +9,20 @@ public sealed class CombatAnimationPlannerTests
[Theory] [Theory]
[InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed [InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed
[InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh [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(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot
[InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload [InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload
[InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1 [InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1
[InlineData(0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6 [InlineData(0x1000018Bu, CombatAnimationKind.CreatureAttack)] // AttackLow6
[InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell [InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell
[InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff [InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff
[InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1 [InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1
[InlineData(0x10000055u, CombatAnimationKind.HitReaction)] // StaggerBackward [InlineData(0x10000055u, CombatAnimationKind.HitReaction)] // StaggerBackward
[InlineData(0x40000011u, CombatAnimationKind.Death)] // Dead [InlineData(0x40000011u, CombatAnimationKind.Death)] // Dead
[InlineData(0x8000003Eu, CombatAnimationKind.CombatStance)] // SwordCombat [InlineData(0x8000003Eu, CombatAnimationKind.CombatStance)] // SwordCombat
[InlineData(0x80000043u, CombatAnimationKind.CombatStance)] // SlingCombat
[InlineData(0x80000044u, CombatAnimationKind.CombatStance)] // 2HandedSwordCombat
public void ClassifyMotionCommand_RecognisesRetailCombatCommands( public void ClassifyMotionCommand_RecognisesRetailCombatCommands(
uint command, uint command,
CombatAnimationKind expected) CombatAnimationKind expected)
@ -27,6 +30,18 @@ public sealed class CombatAnimationPlannerTests
Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command)); 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] [Fact]
public void PlanFromWireCommand_Swing_IsActionOverlay() public void PlanFromWireCommand_Swing_IsActionOverlay()
{ {

View file

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