acdream/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs
Erik 2de5a011b4 fix(combat): #159 — derive CombatAnimationMotionCommands from the DRW enum
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>
2026-07-04 09:08:11 +02:00

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));
}
}