Splits MotionCommandResolver's single ACE-modern lookup into an IMotionCommandCatalog seam with two implementations: - AceModernCommandCatalog (runtime default): built cleanly from the DatReaderWriter MotionCommand enum (mirrors ACE + local DAT MotionTables) with a documented class-priority tiebreak. The old blind 0x016E-0x0197 per-range override is DELETED — verified the ACE matrix (LifestoneRecall 0x0153 to 0x10000153, MarketplaceRecall 0x0166, AllegianceHometownRecall 0x0171, OffhandSlashHigh 0x0173) resolves correctly straight from the enum with no override. Stale shift-start comment corrected to SnowAngelState (0x43000115 to 0x43000118), not AllegianceHometownRecall. - Retail2013CommandCatalog (conformance/reference): full verbatim extraction of command_ids[0x198] at 0x007c73e8 (acclient_2013_pseudo_c.txt:1017259-1017667), direct wire-low to full index lookup. 408 entries, anchors verified against source ([0x150]=0x10000150, [0x153]=0x09000153, [0x197]=0x10000197). MotionCommandResolver.ReconstructFullCommand stays a static facade delegating to an AceModern singleton — all ~10 runtime callers unchanged. Removing the override exposed a pre-existing bug: CombatAnimationPlanner's late-combat command block uses 2013 numbering, not ACE/DRW. Corrected the one catalog test assertion pinned to the override's output (wire 0x0170 to 0x09000170 IssueSlashCommand, its true DRW identity) and filed the planner bug as #159 rather than silently patching out-of-scope code. Tests: +catalog matrices (ACE/2013), class-priority collisions, boundary cases, real-DAT availability (gap-doc hit counts reproduced). Build + full suite green (Core.Tests 1718, no regressions). Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (1) Research: docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
5.4 KiB
C#
113 lines
5.4 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)] // OffhandDoubleThrustMed
|
|
[InlineData(0x1000018Eu, CombatAnimationKind.MeleeSwing)] // PunchFastLow
|
|
[InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot
|
|
[InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload
|
|
[InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1
|
|
[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)
|
|
{
|
|
Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command));
|
|
}
|
|
|
|
// L.1b correction (2026-06-30): these four expected values were pinned
|
|
// against MotionCommandResolver's old blind per-range override
|
|
// (0x016E-0x0197 force-mapped to class 0x10000000), not against named
|
|
// retail truth. The override is gone — MotionCommandResolver now
|
|
// delegates to AceModernCommandCatalog, built cleanly from
|
|
// DatReaderWriter.Enums.MotionCommand (Chorizite.DatReaderWriter
|
|
// 2.1.7). Per that enum, wire 0x0170 is IssueSlashCommand = 0x09000170
|
|
// (class 0x09, UI command) — there is no Action-class (0x10) entry at
|
|
// that wire at all, so the old "OffhandSlashHigh" expectation here was
|
|
// simply wrong (real OffhandSlashHigh = 0x10000173, see
|
|
// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md). Updated
|
|
// to the wire's real DRW-enum identity.
|
|
//
|
|
// NOTE: the surviving rows (0x017D/0x018B/0x018E) still pass, but only
|
|
// because they land on SOME Action-class value at that low word — their
|
|
// inline comments ("OffhandDoubleThrustMed"/"AttackLow6"/"PunchFastLow")
|
|
// were also wrong names (the real DRW names are
|
|
// OffhandTripleSlashMed/AttackLow5/AttackLow6 respectively). This
|
|
// reveals that CombatAnimationPlanner.CombatAnimationMotionCommands
|
|
// (src/AcDream.Core/Combat/CombatAnimationPlanner.cs:268-307) is itself
|
|
// built from 2013-decomp numbering, not ACE/DRW numbering — a
|
|
// pre-existing, separate bug outside this slice's scope (catalog only;
|
|
// CombatAnimationPlanner is explicitly out of bounds for L.1b). Flagged
|
|
// for follow-up rather than silently patched.
|
|
[Theory]
|
|
[InlineData(0x0170, 0x09000170u)] // IssueSlashCommand (UI class) — see note above
|
|
[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));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|