CombatAnimationPlanner.CombatAnimationMotionCommands hand-transcribed the combat MotionCommand constants from the Sept 2013 EoR decomp. The whole late-combat block (Offhand* / Attack4-6 / Punch*) is numbered +3 lower there than in ACE/DatReaderWriter — a contiguous low-word shift that begins around SnowAngelState (0x115). So against a live ACE server every one of those 40 commands misclassified: the resolver returned the correct ACE value (e.g. wire 0x0173 -> 0x10000173 OffhandSlashHigh) but the planner's constant held the 2013 value (0x10000170), so no switch case matched. Reload was worse — hardcoded 0x100000D4, a value absent from DRW entirely; the real ACE/DRW Reload is the 0x40000016 SubState. Instead of re-transcribing 40 hex constants (exactly how the drift crept in), derive each constant directly from DatReaderWriter.Enums.MotionCommand by name: `= (uint)Drw.Name`. The value IS the oracle by construction, can never drift from the wire again, and ~80 magic numbers are gone. Ground truth was taken by reflecting over the same DatReaderWriter 2.1.7 assembly the runtime binds (409 enum values). Blocks 1-2 (stances + single melee, 0x3C-0x12A) were already correct and are unchanged in value. Tests: new PlanFromWireCommand_LateCombatBlock_UsesAceDrwNumbering pins the full wire -> resolve -> classify pipeline for 14 late-block ACE wire values (both full value and kind), plus a Reload SubState parity fact. Fixed two rows in ClassifyMotionCommand_RecognisesRetailCombatCommands whose literal values change meaning under DRW numbering (0x1000018E is AttackLow6/CreatureAttack, not PunchFastLow; Reload moves to 0x40000016). Core suite 2499 green. Note: CombatAnimationPlanner is not yet wired into the runtime dispatch (the live path is AnimationCommandRouter -> MotionInterpreter/AnimationSequencer), so this is a latent-correctness fix — it does not by itself change the #170 Mite Scamp symptom (whose attacks 0x62-0x64 live in the already-correct block). Ref: docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
7.1 KiB
C#
138 lines
7.1 KiB
C#
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(0x1000017Du, CombatAnimationKind.MeleeSwing)] // OffhandTripleSlashMed (DRW)
|
|
[InlineData(0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6 (DRW) — was mislabelled PunchFastLow under 2013 numbering
|
|
[InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot
|
|
[InlineData(0x40000016u, CombatAnimationKind.MissileAttack)] // Reload (DRW SubState) — was the dead 2013 value 0x100000D4
|
|
[InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1
|
|
[InlineData(0x1000018Bu, CombatAnimationKind.CreatureAttack)] // AttackLow5 (DRW)
|
|
[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)
|
|
{
|
|
Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command));
|
|
}
|
|
|
|
// These pin the RESOLVER (wire u16 -> full 32-bit) against the DRW enum,
|
|
// independent of the planner. wire 0x0170 is IssueSlashCommand = 0x09000170
|
|
// (class 0x09, UI command) — there is no Action-class (0x10) entry at that
|
|
// wire; the real OffhandSlashHigh is 0x10000173 (the 2013 decomp numbers it
|
|
// 0x10000170, +3 low). See docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md.
|
|
[Theory]
|
|
[InlineData(0x0170, 0x09000170u)] // IssueSlashCommand (UI class), NOT OffhandSlashHigh
|
|
[InlineData(0x017D, 0x1000017Du)] // OffhandTripleSlashMed
|
|
[InlineData(0x018B, 0x1000018Bu)] // AttackLow5
|
|
[InlineData(0x018E, 0x1000018Eu)] // AttackLow6
|
|
public void MotionCommandResolver_UsesNamedRetailLateCombatCommands(
|
|
ushort wireCommand,
|
|
uint expectedFullCommand)
|
|
{
|
|
Assert.Equal(expectedFullCommand, MotionCommandResolver.ReconstructFullCommand(wireCommand));
|
|
}
|
|
|
|
// #159 parity: the full wire -> resolve -> classify pipeline for the
|
|
// late-combat block, which ACE/DRW numbers +3 above the 2013 decomp.
|
|
// CombatAnimationMotionCommands is now derived directly from
|
|
// DatReaderWriter.Enums.MotionCommand, so these ACE wire values must both
|
|
// reconstruct to their DRW full value AND classify into the right kind.
|
|
// (Expected full/kind pairs cross-checked against the DRW MotionCommand
|
|
// enum, Chorizite.DatReaderWriter 2.1.7.)
|
|
[Theory]
|
|
[InlineData((ushort)0x0173, 0x10000173u, CombatAnimationKind.MeleeSwing)] // OffhandSlashHigh
|
|
[InlineData((ushort)0x0175, 0x10000175u, CombatAnimationKind.MeleeSwing)] // OffhandSlashLow
|
|
[InlineData((ushort)0x0176, 0x10000176u, CombatAnimationKind.MeleeSwing)] // OffhandThrustHigh
|
|
[InlineData((ushort)0x017E, 0x1000017Eu, CombatAnimationKind.MeleeSwing)] // OffhandTripleSlashHigh
|
|
[InlineData((ushort)0x0182, 0x10000182u, CombatAnimationKind.MeleeSwing)] // OffhandTripleThrustLow
|
|
[InlineData((ushort)0x0185, 0x10000185u, CombatAnimationKind.MeleeSwing)] // OffhandKick
|
|
[InlineData((ushort)0x0186, 0x10000186u, CombatAnimationKind.CreatureAttack)] // AttackHigh4
|
|
[InlineData((ushort)0x0188, 0x10000188u, CombatAnimationKind.CreatureAttack)] // AttackLow4
|
|
[InlineData((ushort)0x018C, 0x1000018Cu, CombatAnimationKind.CreatureAttack)] // AttackHigh6
|
|
[InlineData((ushort)0x018E, 0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6
|
|
[InlineData((ushort)0x018F, 0x1000018Fu, CombatAnimationKind.MeleeSwing)] // PunchFastHigh
|
|
[InlineData((ushort)0x0191, 0x10000191u, CombatAnimationKind.MeleeSwing)] // PunchFastLow
|
|
[InlineData((ushort)0x0197, 0x10000197u, CombatAnimationKind.MeleeSwing)] // OffhandPunchFastLow
|
|
[InlineData((ushort)0x019A, 0x1000019Au, CombatAnimationKind.MeleeSwing)] // OffhandPunchSlowLow
|
|
public void PlanFromWireCommand_LateCombatBlock_UsesAceDrwNumbering(
|
|
ushort wireCommand,
|
|
uint expectedFullCommand,
|
|
CombatAnimationKind expectedKind)
|
|
{
|
|
var plan = CombatAnimationPlanner.PlanFromWireCommand(wireCommand);
|
|
Assert.Equal(expectedFullCommand, plan.MotionCommand);
|
|
Assert.Equal(expectedKind, plan.Kind);
|
|
}
|
|
|
|
// #159: Reload is the DRW SubState 0x40000016 (wire 0x0016), not the dead
|
|
// 2013 value 0x100000D4 the planner used to hold (absent from DRW entirely).
|
|
[Fact]
|
|
public void PlanFromWireCommand_Reload_UsesDrwSubStateValue()
|
|
{
|
|
var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0016);
|
|
|
|
Assert.Equal(0x40000016u, plan.MotionCommand);
|
|
Assert.Equal(CombatAnimationKind.MissileAttack, plan.Kind);
|
|
Assert.Equal(AnimationCommandRouteKind.SubState, plan.RouteKind);
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|