Merge feature/animation-system-complete — Phase L.1c animation MVP

21 commits porting retail's MoveToManager-equivalent client-side
behavior for server-controlled creature locomotion and combat
engagement. Shipped as MVP after live visual verification across
multiple iteration rounds with the user.

Highlights:
- 186a584 — initial Phase L.1c port: extracts Origin / target guid /
  MovementParameters block from MoveTo packets (movementType 6/7),
  adds RemoteMoveToDriver per-tick body-orientation steering with
  ±20° aux-turn-equivalent snap tolerance.
- d247aef — corrected arrival predicate semantics + 1.5 s
  stale-destination timeout for entities leaving the streaming view.
- f794832 — root-caused "creature won't stop to attack" via two
  research subagents converging on retail
  CMotionInterp::move_to_interpreted_state's unconditional
  forward_command bulk-copy. Lifted ServerMoveToActive flag clearing
  + InterpretedState bulk-copy out of substate-only branch so
  Action-class swing UMs (mt=0 ForwardCommand=AttackHigh1) clear
  stale MoveTo state and zero forward velocity.
- ff6d3d0 — RemoteMoveToDriver.ClampApproachVelocity caps horizontal
  velocity at the final-approach tick so body lands EXACTLY at
  DistanceToObject instead of overshooting through the player.
- 37de771 — bulk-copy ForwardCommand for MoveTo packets too (closed
  the regression where MoveTo creatures stayed at default
  ForwardCommand=Ready in InterpretedState and only translated via
  UpdatePosition snaps).
- 34d7f4d + e71ed73 — AnimationSequencer.HasCycle query +
  fallback chain (requested → WalkForward → Ready → no-op) at BOTH
  the OnLiveMotionUpdated path AND the spawn handler. Prevents
  ClearCyclicTail from wiping the body's cyclic tail when ACE
  CreateObject carries CurrentMotionState.ForwardCommand pointing
  to an Action-class motion (e.g. AttackHigh1 from a mid-swing
  creature) which has no cyclic-table entry — was the "torso on
  the ground" symptom for monsters seen in combat by a fresh
  observer.

Cross-references: docs/research/named-retail/acclient_2013_pseudo_c.txt
(MoveToManager 0x00529680 + 0x0052a240 + 0x00529d80,
CMotionInterp::move_to_interpreted_state 0x00528xxx,
MovementParameters::UnPackNet 0x0052ac50), references/ACE/Source/
ACE.Server/Physics/Animation/MoveToManager.cs (port aid),
references/holtburger/ (cross-check on snapshot-only client
behavior), docs/research/2026-04-28-remote-moveto-pseudocode.md
(the Phase L.1c pseudocode doc).

Tests: 1404 → 1422 (parser type-7 path retention, type-6 target
guid retention, driver arrival semantics, retail-faithful
chase/flee branches, approach-velocity clamp scenarios,
HasCycle present/missing, AttackHigh1 wire layout).

Pending follow-ups (filed for future): target-guid live resolution
for type 6 packets (residual chase lag), StickToObject sticky-target
guid trailing field, full MoveToManager state machine port
(CheckProgressMade stall detector, Sticky/StickTo, use_final_heading,
pending_actions queue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-29 10:50:59 +02:00
commit b93dfe95d8
44 changed files with 4580 additions and 301 deletions

View file

@ -241,28 +241,32 @@ public sealed class GameEventWiringTests
}
[Fact]
public void WireAll_KillerNotification_FiresKillLandedOnCombatState()
public void WireAll_KillerNotification_AppendsCombatLine()
{
// Issue #10 — orphan parser at GameEvents.ParseKillerNotification
// existed but was never registered for dispatch until 2026-04-25.
// Now wired: 0x01AD lands on CombatState.OnKillerNotification +
// fires the KillLanded event.
var (d, _, combat, _, _) = MakeAll();
string? gotVictimName = null;
uint gotVictimGuid = 0;
combat.KillLanded += (name, guid) => { gotVictimName = name; gotVictimGuid = guid; };
// Wire shape: string16L victimName + u32 victimGuid
byte[] nameBytes = MakeString16L("Drudge");
byte[] payload = new byte[nameBytes.Length + 4];
Array.Copy(nameBytes, payload, nameBytes.Length);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(nameBytes.Length), 0x80001234u);
var (d, _, _, _, chat) = MakeAll();
byte[] payload = MakeString16L("You killed the drudge!");
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
d.Dispatch(env!.Value);
Assert.Equal("Drudge", gotVictimName);
Assert.Equal(0x80001234u, gotVictimGuid);
Assert.Equal(1, chat.Count);
var entry = chat.Snapshot()[0];
Assert.Equal(ChatKind.Combat, entry.Kind);
Assert.Equal(CombatLineKind.Info, entry.CombatKind);
Assert.Equal("You killed the drudge!", entry.Text);
}
[Fact]
public void WireAll_CombatCommenceAttack_FiresCombatStateEvent()
{
var (d, _, combat, _, _) = MakeAll();
bool commenced = false;
combat.AttackCommenced += () => commenced = true;
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.CombatCommenceAttack, Array.Empty<byte>()));
d.Dispatch(env!.Value);
Assert.True(commenced);
}
[Fact]

View file

@ -57,4 +57,13 @@ public sealed class CharacterActionsTests
Assert.Equal(2u, // Melee = 2
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
}
[Fact]
public void CombatMode_UsesRetailAceBitValues()
{
Assert.Equal(1u, (uint)CharacterActions.CombatMode.NonCombat);
Assert.Equal(2u, (uint)CharacterActions.CombatMode.Melee);
Assert.Equal(4u, (uint)CharacterActions.CombatMode.Missile);
Assert.Equal(8u, (uint)CharacterActions.CombatMode.Magic);
}
}

View file

@ -1,6 +1,5 @@
using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
using Xunit;
@ -8,105 +7,140 @@ namespace AcDream.Core.Net.Tests.Messages;
public sealed class CombatEventTests
{
private static byte[] MakeString16L(string s)
{
byte[] data = Encoding.ASCII.GetBytes(s);
int recordSize = 2 + data.Length;
int padding = (4 - (recordSize & 3)) & 3;
byte[] result = new byte[recordSize + padding];
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
Array.Copy(data, 0, result, 2, data.Length);
return result;
}
[Fact]
public void AttackTargetRequest_Build_EmitsCorrectWireBytes()
public void AttackTargetRequest_BuildMelee_EmitsRetailWireBytes()
{
byte[] body = AttackTargetRequest.Build(
byte[] body = AttackTargetRequest.BuildMelee(
gameActionSequence: 3,
targetGuid: 0x12345678u,
powerLevel: 0.75f,
accuracyLevel: 0.5f,
attackHeight: 2);
attackHeight: 2,
powerLevel: 0.75f);
Assert.Equal(28, body.Length);
Assert.Equal(24, body.Length);
Assert.Equal(AttackTargetRequest.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body));
Assert.Equal(3u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(AttackTargetRequest.SubOpcode,
Assert.Equal(AttackTargetRequest.TargetedMeleeAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x12345678u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0.75f,
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)), 4);
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
}
[Fact]
public void AttackTargetRequest_BuildMissile_EmitsRetailWireBytes()
{
byte[] body = AttackTargetRequest.BuildMissile(
gameActionSequence: 4,
targetGuid: 0x87654321u,
attackHeight: 1,
accuracyLevel: 0.5f);
Assert.Equal(24, body.Length);
Assert.Equal(AttackTargetRequest.TargetedMissileAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x87654321u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(0.5f,
BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)), 4);
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
}
[Fact]
public void ParseVictimNotification_RoundTrip()
public void AttackTargetRequest_BuildCancel_HasNoPayload()
{
byte[] name = MakeString16L("Attacker");
byte[] tail = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu); // guid
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 1u); // damageType
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 42u); // damage
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(12), 3u); // quadrant
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(16), 1u); // crit
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(20), 8u); // attackType
byte[] body = AttackTargetRequest.BuildCancel(gameActionSequence: 5);
byte[] payload = new byte[name.Length + tail.Length];
Buffer.BlockCopy(name, 0, payload, 0, name.Length);
Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
Assert.Equal(12, body.Length);
Assert.Equal(AttackTargetRequest.CancelAttackOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
}
[Fact]
public void ParseAttackDone_HoltburgerFixture()
{
var env = ParseFixture("B0F700000000000000000000A701000036000000");
Assert.Equal(GameEventType.AttackDone, env.EventType);
var parsed = GameEvents.ParseAttackDone(env.Payload.Span);
var parsed = GameEvents.ParseVictimNotification(payload);
Assert.NotNull(parsed);
Assert.Equal("Attacker", parsed!.Value.AttackerName);
Assert.Equal(0xAAu, parsed.Value.AttackerGuid);
Assert.Equal(42u, parsed.Value.Damage);
Assert.Equal(0u, parsed!.Value.AttackSequence);
Assert.Equal(0x36u, parsed.Value.WeenieError);
}
[Fact]
public void ParseAttackerNotification_HoltburgerFixture()
{
var env = ParseFixture("B0F700000000000001000000B10100000E0044727564676520526176656E657201000000000000000000D03F25000000010000000600000000000000");
var parsed = GameEvents.ParseAttackerNotification(env.Payload.Span);
Assert.NotNull(parsed);
Assert.Equal("Drudge Ravener", parsed!.Value.DefenderName);
Assert.Equal(1u, parsed.Value.DamageType);
Assert.Equal(0.25, parsed.Value.HealthPercent, 6);
Assert.Equal(37u, parsed.Value.Damage);
Assert.Equal(1u, parsed.Value.Critical);
Assert.Equal(6ul, parsed.Value.AttackConditions);
}
[Fact]
public void ParseAttackerNotification_RoundTrip()
public void ParseDefenderNotification_HoltburgerFixture()
{
byte[] name = MakeString16L("Drudge");
byte[] tail = new byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(tail, 1u); // damageType
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 30u); // damage
BinaryPrimitives.WriteSingleLittleEndian(tail.AsSpan(8), 0.15f); // percent
var env = ParseFixture("B0F700000000000002000000B20100000A0042616E6465726C696E6710000000000000000000C03F1200000001000000000000000800000000000000");
byte[] payload = new byte[name.Length + tail.Length];
Buffer.BlockCopy(name, 0, payload, 0, name.Length);
Buffer.BlockCopy(tail, 0, payload, name.Length, tail.Length);
var parsed = GameEvents.ParseDefenderNotification(env.Payload.Span);
var parsed = GameEvents.ParseAttackerNotification(payload);
Assert.NotNull(parsed);
Assert.Equal("Drudge", parsed!.Value.DefenderName);
Assert.Equal(30u, parsed.Value.Damage);
Assert.Equal(0.15f, parsed.Value.DamagePercent, 4);
Assert.Equal("Banderling", parsed!.Value.AttackerName);
Assert.Equal(0x10u, parsed.Value.DamageType);
Assert.Equal(0.125, parsed.Value.HealthPercent, 6);
Assert.Equal(18u, parsed.Value.Damage);
Assert.Equal(1u, parsed.Value.HitQuadrant);
Assert.Equal(0u, parsed.Value.Critical);
Assert.Equal(8ul, parsed.Value.AttackConditions);
}
[Fact]
public void ParseEvasionAttackerNotification_RoundTrip()
public void ParseEvasionNotifications_HoltburgerFixtures()
{
byte[] payload = MakeString16L("Thrower");
Assert.Equal("Thrower", GameEvents.ParseEvasionAttackerNotification(payload));
var attacker = ParseFixture("B0F700000000000003000000B301000008004D6F7373776172740000");
var defender = ParseFixture("B0F700000000000004000000B401000004004D6974650000");
Assert.Equal("Mosswart", GameEvents.ParseEvasionAttackerNotification(attacker.Payload.Span));
Assert.Equal("Mite", GameEvents.ParseEvasionDefenderNotification(defender.Payload.Span));
}
[Fact]
public void ParseAttackDone_RoundTrip()
public void ParseCombatCommenceAttack_HoltburgerFixture()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0u); // no error
var env = ParseFixture("B0F700000000000005000000B8010000");
var parsed = GameEvents.ParseAttackDone(payload);
Assert.NotNull(parsed);
Assert.Equal(42u, parsed!.Value.AttackSequence);
Assert.Equal(0u, parsed.Value.WeenieError);
Assert.Equal(GameEventType.CombatCommenceAttack, env.EventType);
Assert.True(GameEvents.ParseCombatCommenceAttack(env.Payload.Span));
}
[Fact]
public void ParseDeathNotifications_HoltburgerFixtures()
{
var victim = ParseFixture("B0F700000000000006000000AC0100000E00596F752068617665206469656421");
var killer = ParseFixture("B0F700000000000007000000AD0100001600596F75206B696C6C6564207468652064727564676521");
Assert.Equal("You have died!", GameEvents.ParseVictimNotification(victim.Payload.Span)?.DeathMessage);
Assert.Equal("You killed the drudge!", GameEvents.ParseKillerNotification(killer.Payload.Span)?.DeathMessage);
}
private static GameEventEnvelope ParseFixture(string hex)
{
byte[] body = Convert.FromHexString(hex);
var env = GameEventEnvelope.TryParse(body);
Assert.NotNull(env);
return env.Value;
}
}

View file

@ -0,0 +1,99 @@
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class CreateObjectTests
{
[Fact]
public void TryParse_WeenieHeaderPrefix_ReturnsNameAndItemType()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000002u,
name: "Drudge",
itemType: (uint)ItemType.Creature);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x50000002u, parsed.Value.Guid);
Assert.Equal("Drudge", parsed.Value.Name);
Assert.Equal((uint)ItemType.Creature, parsed.Value.ItemType);
}
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid,
string name,
uint itemType)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
WriteU32(bytes, guid);
// ModelData header: marker, subpalette count, texture count, animpart count.
bytes.Add(0x11);
bytes.Add(0);
bytes.Add(0);
bytes.Add(0);
// PhysicsData: no flags, empty physics state, then 9 sequence stamps.
WriteU32(bytes, 0);
WriteU32(bytes, 0);
for (int i = 0; i < 9; i++)
WriteU16(bytes, 0);
Align4(bytes);
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
WriteU32(bytes, 0); // weenieFlags
WriteString16L(bytes, name);
WritePackedDword(bytes, 0x1234); // WeenieClassId
WritePackedDword(bytes, 0); // IconId via known-type writer
WriteU32(bytes, itemType);
WriteU32(bytes, 0); // ObjectDescriptionFlags
Align4(bytes);
return bytes.ToArray();
}
private static void WriteU32(List<byte> bytes, uint value)
{
Span<byte> tmp = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(tmp, value);
bytes.AddRange(tmp.ToArray());
}
private static void WriteU16(List<byte> bytes, ushort value)
{
Span<byte> tmp = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
bytes.AddRange(tmp.ToArray());
}
private static void WritePackedDword(List<byte> bytes, uint value)
{
if (value <= 0x7FFF)
{
WriteU16(bytes, (ushort)value);
return;
}
WriteU16(bytes, (ushort)(((value >> 16) & 0x7FFF) | 0x8000));
WriteU16(bytes, (ushort)(value & 0xFFFF));
}
private static void WriteString16L(List<byte> bytes, string value)
{
byte[] encoded = Encoding.GetEncoding(1252).GetBytes(value);
WriteU16(bytes, checked((ushort)encoded.Length));
bytes.AddRange(encoded);
Align4(bytes);
}
private static void Align4(List<byte> bytes)
{
while ((bytes.Count & 3) != 0)
bytes.Add(0);
}
}

View file

@ -0,0 +1,39 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class DeleteObjectTests
{
[Fact]
public void RejectsWrongOpcode()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
Assert.Null(DeleteObject.TryParse(body));
}
[Fact]
public void RejectsTruncated()
{
Assert.Null(DeleteObject.TryParse(ReadOnlySpan<byte>.Empty));
Assert.Null(DeleteObject.TryParse(new byte[9]));
}
[Fact]
public void ParsesGuidAndInstanceSequence()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000439u);
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x1234);
var parsed = DeleteObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x80000439u, parsed!.Value.Guid);
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
}
}

View file

@ -185,7 +185,8 @@ public class UpdateMotionTests
[Fact]
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
{
// movementType != 0 means one of the Move* variants we don't parse.
// movementType != 0 means one of the Move* variants; a truncated
// non-Invalid payload still returns the outer state.
// The parser must still return a valid Parsed with the outer stance
// and a null ForwardCommand rather than failing the whole message.
var body = new byte[4 + 4 + 2 + 6 + 4];
@ -194,7 +195,7 @@ public class UpdateMotionTests
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 1; // movementType = MoveToObject (non-Invalid)
body[p++] = 7; // movementType = MoveToPosition (non-Invalid)
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2;
@ -202,5 +203,152 @@ public class UpdateMotionTests
Assert.NotNull(result);
Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance);
Assert.Null(result.Value.MotionState.ForwardCommand);
Assert.Equal((byte)7, result.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
}
[Fact]
public void ParsesMoveToPositionSpeedAndRunRate()
{
// Layout after MovementData's movementType/motionFlags/currentStyle:
// Origin: cell + xyz (16 bytes)
// MoveToParameters: flags, distance, min, fail, speed,
// walk/run threshold, desired heading (28 bytes)
// runRate: f32
var body = new byte[4 + 4 + 2 + 6 + 4 + 16 + 28 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 7; // MoveToPosition
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 10f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 20f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 30f); p += 4;
const uint canWalkCanRunMoveTowards = 0x1u | 0x2u | 0x200u;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), canWalkCanRunMoveTowards); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 90.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4;
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)7, result!.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
Assert.Equal((ushort)0x003D, result.Value.MotionState.Stance);
Assert.Null(result.Value.MotionState.ForwardCommand);
Assert.Equal(canWalkCanRunMoveTowards, result.Value.MotionState.MoveToParameters);
Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed);
Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate);
Assert.True(result.Value.MotionState.MoveToCanRun);
Assert.True(result.Value.MotionState.MoveTowards);
// Phase L.1c (2026-04-28): full path payload retained.
Assert.NotNull(result.Value.MotionState.MoveToPath);
var path = result.Value.MotionState.MoveToPath!.Value;
Assert.Null(path.TargetGuid);
Assert.Equal(0xA8B4000Eu, path.OriginCellId);
Assert.Equal(10f, path.OriginX);
Assert.Equal(20f, path.OriginY);
Assert.Equal(30f, path.OriginZ);
Assert.Equal(0.6f, path.DistanceToObject);
Assert.Equal(0.0f, path.MinDistance);
Assert.Equal(float.MaxValue, path.FailDistance);
Assert.Equal(15.0f, path.WalkRunThreshold);
Assert.Equal(90.0f, path.DesiredHeading);
}
[Fact]
public void ParsesAttackHigh1_AsActionForwardCommand()
{
// Phase L.1c followup (2026-04-28): regression that verifies the
// wire-format ACE uses for melee swings — mt=0 with
// ForwardCommand=AttackHigh1 (0x0062 in low 16 bits) and
// ForwardSpeed (typically the animSpeed). The receiver in
// GameWindow.OnLiveMotionUpdated relies on this layout to bulk-copy
// ForwardCommand into the body's InterpretedState so that
// get_state_velocity returns 0 (gate is RunForward||WalkForward).
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x800003B5u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // header padding
body[p++] = 0; // mt = Invalid (interpreted)
body[p++] = 0; // motion_flags
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003C); p += 2; // stance: HandCombat
// InterpretedMotionState: flags = ForwardCommand (0x02) | ForwardSpeed (0x04)
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x06u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0062); p += 2; // AttackHigh1 low bits
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // animSpeed
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)0, result!.Value.MotionState.MovementType);
Assert.False(result.Value.MotionState.IsServerControlledMoveTo);
Assert.Equal((ushort)0x0062, result.Value.MotionState.ForwardCommand);
Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed);
}
[Fact]
public void ParsesMoveToObjectTargetGuidAndOrigin()
{
// Type 6 (MoveToObject) prepends a u32 target guid before the
// standard Origin + MovementParameters + runRate payload.
// Body size: 20 (header) + 4 (guid) + 16 (origin) + 28 (params) + 4 (runRate) = 72.
var body = new byte[20 + 4 + 16 + 28 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004321u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // MovementData header padding
body[p++] = 6; // MoveToObject
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; // target guid
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; // cell
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 5f); p += 4; // origin x
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 6f); p += 4; // origin y
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 7f); p += 4; // origin z
const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.57f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // runRate
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)6, result!.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
Assert.NotNull(result.Value.MotionState.MoveToPath);
var path = result.Value.MotionState.MoveToPath!.Value;
Assert.Equal(0x80001234u, path.TargetGuid);
Assert.Equal(0xA8B4000Eu, path.OriginCellId);
Assert.Equal(5f, path.OriginX);
Assert.Equal(6f, path.OriginY);
Assert.Equal(7f, path.OriginZ);
Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate);
}
}

View file

@ -0,0 +1,77 @@
using System.Net;
using AcDream.Core.Combat;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionCombatTests
{
private static WorldSession NewSession()
{
var ep = new IPEndPoint(IPAddress.Loopback, 65000);
return new WorldSession(ep);
}
[Fact]
public void SendChangeCombatMode_UsesSequenceAndRetailModeValue()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendChangeCombatMode(CombatMode.Magic);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildChangeCombatMode(
1,
CharacterActions.CombatMode.Magic), captured);
}
[Fact]
public void SendMeleeAttack_UsesRetailMeleeBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendMeleeAttack(0x50000002u, AttackHeight.High, 0.75f);
Assert.NotNull(captured);
Assert.Equal(AttackTargetRequest.BuildMelee(
1,
0x50000002u,
(uint)AttackHeight.High,
0.75f), captured);
}
[Fact]
public void SendMissileAttack_UsesRetailMissileBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendMissileAttack(0x50000003u, AttackHeight.Low, 0.5f);
Assert.NotNull(captured);
Assert.Equal(AttackTargetRequest.BuildMissile(
1,
0x50000003u,
(uint)AttackHeight.Low,
0.5f), captured);
}
[Fact]
public void SendCancelAttack_UsesRetailCancelBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendCancelAttack();
Assert.NotNull(captured);
Assert.Equal(AttackTargetRequest.BuildCancel(1), captured);
}
}

View file

@ -0,0 +1,89 @@
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));
}
[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()
{
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));
}
}

View file

@ -0,0 +1,43 @@
using AcDream.Core.Combat;
namespace AcDream.Core.Tests.Combat;
public sealed class CombatInputPlannerTests
{
[Fact]
public void ToggleMode_FromNonCombat_UsesDefaultCombatMode()
{
Assert.Equal(CombatMode.Melee, CombatInputPlanner.ToggleMode(CombatMode.NonCombat));
Assert.Equal(
CombatMode.Missile,
CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile));
}
[Fact]
public void ToggleMode_FromCombat_ReturnsNonCombat()
{
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee));
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic));
}
[Theory]
[InlineData(CombatAttackAction.Low, AttackHeight.Low)]
[InlineData(CombatAttackAction.Medium, AttackHeight.Medium)]
[InlineData(CombatAttackAction.High, AttackHeight.High)]
public void HeightFor_MapsRetailAttackKeys(CombatAttackAction action, AttackHeight expected)
{
Assert.Equal(expected, CombatInputPlanner.HeightFor(action));
}
[Theory]
[InlineData(CombatMode.Melee, true)]
[InlineData(CombatMode.Missile, true)]
[InlineData(CombatMode.NonCombat, false)]
[InlineData(CombatMode.Magic, false)]
public void SupportsTargetedAttack_MatchesRetailExecuteAttackModes(
CombatMode mode,
bool expected)
{
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
}
}

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

View file

@ -27,6 +27,51 @@ public sealed class CombatStateTests
Assert.Equal(1f, state.GetHealthPercent(0xDEAD));
}
[Fact]
public void CombatMode_UsesRetailAceBitValues()
{
Assert.Equal(1, (int)CombatMode.NonCombat);
Assert.Equal(2, (int)CombatMode.Melee);
Assert.Equal(4, (int)CombatMode.Missile);
Assert.Equal(8, (int)CombatMode.Magic);
}
[Fact]
public void AttackType_UsesNamedRetailBitValues()
{
Assert.Equal(0x0001u, (uint)AttackType.Punch);
Assert.Equal(0x0002u, (uint)AttackType.Thrust);
Assert.Equal(0x0004u, (uint)AttackType.Slash);
Assert.Equal(0x0008u, (uint)AttackType.Kick);
Assert.Equal(0x0010u, (uint)AttackType.OffhandPunch);
Assert.Equal(0x79E0u, (uint)AttackType.MultiStrike);
}
[Fact]
public void SetCombatMode_TracksCurrentMode_AndFiresEvent()
{
var state = new CombatState();
CombatMode? seen = null;
state.CombatModeChanged += mode => seen = mode;
state.SetCombatMode(CombatMode.Missile);
Assert.Equal(CombatMode.Missile, state.CurrentMode);
Assert.Equal(CombatMode.Missile, seen);
}
[Fact]
public void OnCombatCommenceAttack_FiresAttackCommenced()
{
var state = new CombatState();
bool seen = false;
state.AttackCommenced += () => seen = true;
state.OnCombatCommenceAttack();
Assert.True(seen);
}
[Fact]
public void OnVictimNotification_FiresDamageTaken()
{

View file

@ -0,0 +1,66 @@
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public sealed class AnimationCommandRouterTests
{
private const uint NonCombat = 0x8000003Du;
[Theory]
[InlineData(0x00000000u, AnimationCommandRouteKind.None)]
[InlineData(0x10000057u, AnimationCommandRouteKind.Action)] // Sanctuary
[InlineData(0x2500003Bu, AnimationCommandRouteKind.Modifier)] // Jump
[InlineData(0x13000087u, AnimationCommandRouteKind.ChatEmote)] // Wave
[InlineData(0x41000003u, AnimationCommandRouteKind.SubState)] // Ready
[InlineData(0x40000011u, AnimationCommandRouteKind.SubState)] // Dead
[InlineData(0x8000003Du, AnimationCommandRouteKind.Ignored)] // NonCombat style
public void Classify_ReturnsRetailRouteKind(uint command, AnimationCommandRouteKind expected)
{
Assert.Equal(expected, AnimationCommandRouter.Classify(command));
}
[Fact]
public void RouteWireCommand_SubState_UsesSetCycle()
{
var seq = MakeEmptySequencer();
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011);
Assert.Equal(AnimationCommandRouteKind.SubState, route);
Assert.Equal(NonCombat, seq.CurrentStyle);
Assert.Equal(MotionCommand.Dead, seq.CurrentMotion);
}
[Fact]
public void RouteWireCommand_Sanctuary_IsActionNotDeadCycle()
{
var seq = MakeEmptySequencer();
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0057);
Assert.Equal(AnimationCommandRouteKind.Action, route);
Assert.Equal(0u, seq.CurrentMotion);
}
[Fact]
public void RouteWireCommand_Wave_IsChatEmote()
{
var seq = MakeEmptySequencer();
var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0087);
Assert.Equal(AnimationCommandRouteKind.ChatEmote, route);
}
private static AnimationSequencer MakeEmptySequencer()
{
return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
}

View file

@ -223,6 +223,46 @@ public sealed class AnimationSequencerTests
}
}
[Fact]
public void HasCycle_PresentInTable_ReturnsTrue()
{
// Phase L.1c followup (2026-04-28): regression guard for
// "torso on the ground" — caller (GameWindow MoveTo path) needs
// to query the table before SetCycle to avoid the
// ClearCyclicTail wipe on a missing cycle.
const uint Style = 0x003Cu; // HandCombat
const uint Motion = 0x0003u; // Ready
const uint AnimId = 0x03000001u;
var setup = Fixtures.MakeSetup(2);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
// Caller passes the SAME shape SetCycle expects: full style with
// class byte (0x80000000) and full motion (0x40000000 / 0x10000000).
Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u));
}
[Fact]
public void HasCycle_MissingFromTable_ReturnsFalse()
{
const uint Style = 0x003Cu;
const uint ReadyMotion = 0x0003u;
const uint AnimId = 0x03000001u;
var setup = Fixtures.MakeSetup(2);
var mt = Fixtures.MakeMtable(Style, ReadyMotion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
// RunForward (0x44000007) is NOT in the table — caller should
// see false and fall back to a known motion (WalkForward / Ready).
Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u));
}
[Fact]
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
{
@ -1313,6 +1353,45 @@ public sealed class AnimationSequencerTests
Assert.Equal(99f, fr[0].Origin.X, 1);
}
[Fact]
public void PlayAction_ActionSurvivesImmediateReadyCycleEcho()
{
// ACE broadcasts creature attacks as Action-class ForwardCommand
// values followed by Ready. Retail keeps currState.Substate at Ready
// while the action link drains, so the Ready echo must not abort the
// in-flight swing.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint AttackMotion = 0x10000052u;
const uint IdleAnimId = 0x03000503u;
const uint AttackAnimId = 0x03000504u;
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style };
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(AttackMotion);
seq.SetCycle(Style, IdleMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(12f, fr[0].Origin.X, 1);
Assert.Equal(IdleMotion, seq.CurrentMotion);
}
[Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict()
{

View file

@ -21,6 +21,10 @@ public class MotionCommandResolverTests
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
[InlineData(0x0015, 0x40000015u)] // Falling
[InlineData(0x0011, 0x40000011u)] // Dead
[InlineData(0x0012, 0x41000012u)] // Crouch
[InlineData(0x0013, 0x41000013u)] // Sitting
[InlineData(0x0014, 0x41000014u)] // Sleeping
// Action-class one-shots: melee attacks, death, portals
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
[InlineData(0x0058, 0x10000058u)] // ThrustMed

View file

@ -685,6 +685,33 @@ public sealed class MotionInterpreterTests
Assert.False(allowed);
}
[Fact]
public void ContactAllowsMove_DeadState_RejectsMove()
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = MotionCommand.Dead;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Theory]
[InlineData(MotionCommand.Crouch)]
[InlineData(MotionCommand.Sitting)]
[InlineData(MotionCommand.Sleeping)]
public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand)
{
var body = MakeGrounded();
var interp = MakeInterp(body);
interp.InterpretedState.ForwardCommand = postureCommand;
bool allowed = interp.contact_allows_move(MotionCommand.WalkForward);
Assert.False(allowed);
}
[Fact]
public void ContactAllowsMove_CrouchRange_RejectsMove()
{

View file

@ -0,0 +1,296 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Phase L.1c (2026-04-28). Covers <see cref="RemoteMoveToDriver"/> — the
/// per-tick steering port of retail
/// <c>MoveToManager::HandleMoveToPosition</c> for server-controlled remote
/// creatures.
/// </summary>
public class RemoteMoveToDriverTests
{
private const float Epsilon = 1e-3f;
private static float Yaw(Quaternion q)
{
var fwd = Vector3.Transform(new Vector3(0, 1, 0), q);
return MathF.Atan2(-fwd.X, fwd.Y);
}
[Fact]
public void Drive_AlreadyAtTarget_ReportsArrived()
{
var bodyPos = new Vector3(10f, 20f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(10f, 20.3f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0.5f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
Assert.Equal(bodyRot, newOrient); // orientation untouched
}
[Fact]
public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival()
{
// ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee).
// Body at 0.5m from target should ARRIVE — not keep oscillating
// around the target the way it did pre-fix when only MinDistance
// was the gate. This is the "monster keeps running in different
// directions when it should be attacking" regression fix.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 0.5f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
}
[Fact]
public void Drive_FleeArrival_UsesMinDistance()
{
// Flee branch (moveTowards=false): arrival when dist >= MinDistance.
// Retail / ACE both use MinDistance for the flee-arrival threshold.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 6f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 5.0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: false,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
}
[Fact]
public void Drive_ChaseDoesNotArriveAtMinDistanceFloor()
{
// Regression: my earlier max(MinDistance, DistanceToObject) port
// would have arrived here because dist (1.5) <= MinDistance (2.0).
// Retail uses DistanceToObject for chase arrival, so a chase at
// dist=1.5 with DistanceToObject=0.6 should still STEER, not arrive.
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 1.5f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 2.0f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
}
[Fact]
public void Drive_ChasingButNotInRange_ReportsSteering()
{
var bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity; // facing +Y
var dest = new Vector3(0f, 50f, 0f); // straight ahead
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
// Already facing target → snap branch keeps yaw at 0.
Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon);
}
[Fact]
public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance()
{
// Body facing +Y; target at (1, 10, 0) — that's a small angle
// (about 5.7°), well within the 20° snap tolerance.
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity;
var dest = new Vector3(1f, 10f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
// Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad.
float expectedYaw = MathF.Atan2(-1f, 10f);
Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon);
// Verify orientation actually transforms +Y onto the (1,10) line.
var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient);
Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f);
}
[Fact]
public void Drive_TargetBeyondTolerance_RotatesByLimitedStep()
{
// Body facing +Y; target at (-10, 0) — that's 90° to the left
// (well beyond the 20° snap tolerance), so we turn by at most
// TurnRateRadPerSec * dt this tick rather than snapping.
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity; // yaw = 0
var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left)
const float dt = 0.1f;
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: dt, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
// We should turn LEFT (positive yaw) toward the target.
Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon);
}
[Fact]
public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath()
{
// Body facing +Y; target directly behind at (0, -10, 0).
// |delta| = π, equally close either way; the implementation
// picks one (sign depends on float wobble) — just assert
// we made progress (yaw changed by exactly TurnRate * dt).
var bodyPos = Vector3.Zero;
var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, -10f, 0f);
const float dt = 0.1f;
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0f, distanceToObject: 0f,
dt: dt, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon);
}
[Fact]
public void Drive_PreservesOrientationAtArrival()
{
var bodyPos = new Vector3(5f, 5f, 0f);
var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f);
var dest = new Vector3(5.01f, 5.01f, 0f);
var result = RemoteMoveToDriver.Drive(
bodyPos, bodyRot, dest,
minDistance: 0.5f, distanceToObject: 0.6f,
dt: 0.016f, moveTowards: true,
out var newOrient);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
// Caller would zero velocity; orientation should be untouched
// so the body settles facing whatever direction it was already.
Assert.Equal(bodyRot, newOrient);
}
[Fact]
public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold()
{
// Body 1 m from destination, running at 4 m/s, dt = 0.1 s.
// Naive advance = 0.4 m → would end at 0.6 m from dest, exactly
// on the threshold. With threshold=0.6 and remaining=0.4, the
// clamp should let the full velocity through (advance == remaining).
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, 4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true);
// Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the
// remaining distance. The clamp may apply a 0.99999×-style
// tiny scale due to FP rounding — accept anything ≥ 99.9% of
// the input as "no meaningful overshoot prevention applied."
Assert.InRange(clamped.Y, 4f * 0.999f, 4f);
Assert.Equal(0f, clamped.X);
Assert.Equal(0f, clamped.Z);
}
[Fact]
public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding()
{
// Body 1 m from destination, running at 4 m/s, dt = 0.2 s.
// Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m.
// remaining = 0.4 m, advance = 0.8 m → scale = 0.5.
// Velocity should be halved → 2 m/s.
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, 4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true);
Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon);
Assert.Equal(0f, clamped.X);
}
[Fact]
public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal()
{
// Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0.
// Any horizontal velocity would overshoot; clamp must zero it.
var bodyPos = new Vector3(0f, 0f, 0f);
var dest = new Vector3(0f, 0.6f, 0f);
var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true);
Assert.Equal(0f, clamped.X);
Assert.Equal(0f, clamped.Y);
Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected
}
[Fact]
public void ClampApproachVelocity_FleeBranch_NoOp()
{
// moveTowards=false (flee): no overshoot risk, return velocity unchanged.
var bodyPos = Vector3.Zero;
var dest = new Vector3(0f, 1f, 0f);
var vel = new Vector3(0f, -4f, 0f);
var clamped = RemoteMoveToDriver.ClampApproachVelocity(
bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false);
Assert.Equal(vel, clamped);
}
[Fact]
public void OriginToWorld_AppliesLandblockGridShift()
{
// Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center
// at (0xA9, 0xB4), that's one landblock west and zero north,
// so origin (10, 20, 0) inside that landblock should map to
// (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space.
var w = RemoteMoveToDriver.OriginToWorld(
originCellId: 0xA8B4000Eu,
originX: 10f, originY: 20f, originZ: 0f,
liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4);
Assert.Equal(-182f, w.X);
Assert.Equal(20f, w.Y);
Assert.Equal(0f, w.Z);
}
}

View file

@ -0,0 +1,88 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public sealed class ServerControlledLocomotionTests
{
[Fact]
public void PlanMoveToStart_SeedsImmediateRunCycle()
{
var plan = ServerControlledLocomotion.PlanMoveToStart();
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.RunForward, plan.Motion);
Assert.Equal(1.0f, plan.SpeedMod);
}
[Fact]
public void PlanMoveToStart_AppliesRetailRunRate()
{
var plan = ServerControlledLocomotion.PlanMoveToStart(
moveToSpeed: 1.25f,
runRate: 1.5f,
canRun: true);
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.RunForward, plan.Motion);
Assert.Equal(1.875f, plan.SpeedMod);
}
[Fact]
public void PlanMoveToStart_UsesWalkWhenRunDisallowed()
{
var plan = ServerControlledLocomotion.PlanMoveToStart(
moveToSpeed: 0.75f,
runRate: 2.0f,
canRun: false);
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.WalkForward, plan.Motion);
Assert.Equal(0.75f, plan.SpeedMod);
}
[Fact]
public void PlanFromVelocity_StopsBelowRetailNoiseThreshold()
{
var plan = ServerControlledLocomotion.PlanFromVelocity(
new Vector3(0.10f, 0.12f, 3.0f));
Assert.False(plan.IsMoving);
Assert.Equal(MotionCommand.Ready, plan.Motion);
Assert.Equal(1.0f, plan.SpeedMod);
}
[Fact]
public void PlanFromVelocity_WalksForSlowServerControlledMotion()
{
var plan = ServerControlledLocomotion.PlanFromVelocity(
new Vector3(0.0f, 0.80f, 0.0f));
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.WalkForward, plan.Motion);
Assert.InRange(plan.SpeedMod, 0.25f, 0.27f);
}
[Fact]
public void PlanFromVelocity_RunsAtRetailRunScale()
{
var plan = ServerControlledLocomotion.PlanFromVelocity(
new Vector3(0.0f, MotionInterpreter.RunAnimSpeed, 0.0f));
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.RunForward, plan.Motion);
Assert.Equal(1.0f, plan.SpeedMod, precision: 4);
}
[Fact]
public void PlanFromVelocity_ClampsVeryFastSnapshots()
{
var plan = ServerControlledLocomotion.PlanFromVelocity(
new Vector3(0.0f, 30.0f, 0.0f));
Assert.True(plan.IsMoving);
Assert.Equal(MotionCommand.RunForward, plan.Motion);
Assert.Equal(ServerControlledLocomotion.MaxSpeedMod, plan.SpeedMod);
}
}

View file

@ -0,0 +1,9 @@
using Xunit;
namespace AcDream.Core.Tests.World;
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class DerethDateTimeCollection
{
public const string Name = "DerethDateTime global offset";
}

View file

@ -3,6 +3,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
[Collection(DerethDateTimeCollection.Name)]
public sealed class DerethDateTimeTests
{
// ACE calendar anchor: tick 0 = Morningthaw 1, 10 P.Y. at Morntide-and-Half

View file

@ -4,6 +4,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
[Collection(DerethDateTimeCollection.Name)]
public sealed class SkyStateTests
{
[Fact]

View file

@ -4,6 +4,7 @@ using Xunit;
namespace AcDream.Core.Tests.World;
[Collection(DerethDateTimeCollection.Name)]
public sealed class WorldTimeDebugTests
{
[Fact]