fix(anim): Phase L.1c clear MoveTo state + bulk-copy ForwardCommand on overlay UMs

User-observed regression on commit d247aef: creature reaches melee
range and "just runs" instead of stopping to attack. Two independent
research subagents converged on the same root cause.

When ACE broadcasts a melee swing, it sends an mt=0 UpdateMotion with
ForwardCommand=AttackHigh1 (Action class, 0x10000062), motion_flags
=StickToObject, and a trailing 4-byte sticky-target guid — there is
NO preceding cmd=Ready. The swing UM IS the stop signal.

Retail's CMotionInterp::move_to_interpreted_state
(acclient_2013_pseudo_c.txt:305936-305992) bulk-copies forward_command
from the wire into InterpretedState UNCONDITIONALLY, regardless of
motion class. With forward_command=AttackHigh1, get_state_velocity
(:305172-305180) returns velocity.Y=0 because its gate is
RunForward||WalkForward — body stops moving forward. The animation
overlay (the swing) is appended on top of whatever cyclic tail is
active.

Acdream's overlay branch in GameWindow.OnLiveMotionUpdated routed
Action-class commands through PlayAction (animation overlay only) and
SKIPPED:
  - ServerMoveToActive flag update — stale RunForward MoveTo state
    persisted, the per-tick driver kept steering toward the prior
    Origin and calling apply_current_movement.
  - InterpretedState.ForwardCommand bulk-copy — even if the flag had
    been cleared, the body's InterpretedState.ForwardCommand stayed
    at RunForward from the prior MoveTo cycle, so
    apply_current_movement kept producing forward velocity.
  - MoveToPath capture — staleness-timeout band-aid masked this.

Fix: lift the _remoteDeadReckon state-update block out of the
substate-only `else` branch so it runs for both overlay and substate
paths. For non-MoveTo packets, write fullMotion + speedMod directly to
InterpretedState.ForwardCommand/ForwardSpeed (bypassing
ApplyMotionToInterpretedState, which is a heuristic helper that
silently no-ops for Action class — see MotionInterpreter.cs:941-970).
This matches retail's copy_movement_from
(acclient_2013_pseudo_c.txt:293301-293311) bulk-copy semantics.

Also corrected RemoteMoveToDriver arrival predicate to retail-faithful:
chase = dist <= DistanceToObject; flee = dist >= MinDistance. The
prior max(MinDistance, DistanceToObject) defensive port happened to
compute the right value for ACE's wire defaults but had wrong
semantics (would have failed for any retail config with MinDistance >
DistanceToObject).

Tests: 1414 → 1416. New parser test for the AttackHigh1 wire layout;
new driver tests for retail-faithful chase/flee arrival.

Defers: target-guid live resolution for type 6 packets (chase-lag
mitigation, symptom #3), StickToObject sticky-target guid trailing
field, full MoveToManager port (CheckProgressMade, pending_actions
queue, Sticky/StickTo, use_final_heading).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-29 10:02:53 +02:00
parent d247aef2e4
commit f794832ebc
4 changed files with 165 additions and 54 deletions

View file

@ -2424,6 +2424,82 @@ public sealed class GameWindow : IDisposable
// current cyclic tail and currState.Substate remains Ready. // current cyclic tail and currState.Substate remains Ready.
// Treating 0x10000051/52/53 as SetCycle commands made the // Treating 0x10000051/52/53 as SetCycle commands made the
// immediate follow-up Ready packet abort the swing. // immediate follow-up Ready packet abort the swing.
// Phase L.1c followup (2026-04-28): the next two state-update
// blocks are LIFTED out of the substate-only `else` branch so
// they run for BOTH overlay (Action/Modifier/ChatEmote) and
// substate (Walk/Run/Ready/etc) packets. Two separate research
// agents converged on the same root cause for the user-
// observed "creature just runs instead of attacking" symptom:
//
// 1. Attack swings arrive as mt=0 with
// ForwardCommand=AttackHigh1 (Action class). Retail's
// CMotionInterp::move_to_interpreted_state
// (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies
// forward_command from the wire into the body's
// InterpretedState UNCONDITIONALLY. With
// forward_command=AttackHigh1, get_state_velocity
// returns 0 because its gate is RunForward||WalkForward
// — body stops moving forward.
//
// 2. The acdream overlay branch was routing through
// PlayAction (animation overlay) but skipping ALL of:
// - ServerMoveToActive flag update
// - MoveToPath capture
// - InterpretedState.ForwardCommand assignment
// So during a swing UM, the body's InterpretedState
// stayed at RunForward from the prior MoveTo packet,
// ServerMoveToActive stayed true, and the per-tick
// remote driver kept steering + applying RunForward
// velocity through every frame.
//
// Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState
// here because the latter is a heuristic that ONLY handles
// WalkForward / RunForward / WalkBackward / SideStep / Turn
// / Ready (MotionInterpreter.cs:941-970). For an Action
// command (e.g. AttackHigh1 = 0x10000062) the switch falls
// through and InterpretedState is silently NOT updated —
// exactly the bug we are fixing. Direct field assignment
// matches retail's <c>copy_movement_from</c> bulk-copy
// (acclient_2013_pseudo_c.txt:293301-293311).
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
{
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } path)
{
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
.OriginToWorld(
path.OriginCellId,
path.OriginX,
path.OriginY,
path.OriginZ,
_liveCenterX,
_liveCenterY);
remoteMot.MoveToMinDistance = path.MinDistance;
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
remoteMot.HasMoveToDestination = true;
remoteMot.LastMoveToPacketTime =
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
}
else if (!update.MotionState.IsServerControlledMoveTo)
{
// Off MoveTo — clear stale destination so the per-tick
// driver doesn't keep steering.
remoteMot.HasMoveToDestination = false;
// Bulk-copy the wire's resolved ForwardCommand + speed
// into InterpretedState. For Action commands this
// makes apply_current_movement return zero velocity
// on the next tick (gate fails). For substate
// commands (Run/Walk/Ready), this is identical to
// what DoInterpretedMotion would have written.
remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion;
remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod <= 0f ? 1f : speedMod;
}
}
if (forwardIsOverlay) if (forwardIsOverlay)
{ {
if (!remoteIsAirborne) if (!remoteIsAirborne)
@ -2499,47 +2575,17 @@ public sealed class GameWindow : IDisposable
// FUN_00528f70 DoInterpretedMotion // FUN_00528f70 DoInterpretedMotion
// FUN_00528960 get_state_velocity // FUN_00528960 get_state_velocity
// FUN_00529210 apply_current_movement // FUN_00529210 apply_current_movement
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) // ServerMoveToActive flag, MoveToPath capture, and the
// InterpretedState.ForwardCommand bulk-copy are already
// handled by the LIFTED block above (so overlay-class swings
// also clear stale MoveTo state and update the body's
// forward command). This branch only handles sidestep /
// turn axes plus the ObservedOmega seed — none of which
// appear on overlay packets, so the existing logic is
// correct unchanged. (`remoteMot` is the same dictionary
// entry obtained at the top of the lifted block.)
if (remoteMot is not null)
{ {
remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo;
// Phase L.1c (2026-04-28): capture the full MoveTo path
// payload so the per-tick remote driver can steer the
// body toward Origin instead of holding velocity at zero
// between sparse UpdatePosition snaps. Retail
// MoveToManager::MoveToPosition stores the same fields
// (acclient_2013_pseudo_c.txt:307521-307593).
if (update.MotionState.IsServerControlledMoveTo
&& update.MotionState.MoveToPath is { } path)
{
remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver
.OriginToWorld(
path.OriginCellId,
path.OriginX,
path.OriginY,
path.OriginZ,
_liveCenterX,
_liveCenterY);
remoteMot.MoveToMinDistance = path.MinDistance;
remoteMot.MoveToDistanceToObject = path.DistanceToObject;
remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards;
remoteMot.HasMoveToDestination = true;
remoteMot.LastMoveToPacketTime =
(System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
}
else if (!update.MotionState.IsServerControlledMoveTo)
{
// Cycle changed off MoveTo — clear stale destination
// so the per-tick driver doesn't keep steering after
// the server has switched us back to interpreted
// motion.
remoteMot.HasMoveToDestination = false;
}
// Forward axis (Ready / WalkForward / RunForward / WalkBackward).
remoteMot.Motion.DoInterpretedMotion(
fullMotion, speedMod, modifyInterpretedState: true);
// Sidestep axis. // Sidestep axis.
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
{ {

View file

@ -147,19 +147,31 @@ public static class RemoteMoveToDriver
float dy = destinationWorld.Y - bodyPosition.Y; float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy); float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate. Retail (named decomp): dist ≤ min_distance. // Arrival predicate per retail MoveToManager::HandleMoveToPosition
// ACE port: dist ≤ DistanceToObject. ACE's wire put the melee // (acclient_2013_pseudo_c.txt:307289-307320) and ACE
// threshold in DistanceToObject (default 0.6) and left // MoveToManager.cs:476:
// MinDistance=0; retail server config presumably set MinDistance. //
// Defensive port: take the larger so we honor whichever field is // chase (MoveTowards): dist <= distance_to_object
// populated. Flee branch is unused here but we honor the // flee (MoveAway): dist >= min_distance
// moveTowards flag for symmetry. //
float arrivalThreshold = MathF.Max(minDistance, distanceToObject); // (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
// defensive guess; cross-checked with two independent research
// agents against the named retail decomp + ACE port + holtburger,
// the chase threshold is unambiguously DistanceToObject —
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
// at melee range, not run to zero.)
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon) if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
{ {
newOrientation = bodyOrientation; newOrientation = bodyOrientation;
return DriveResult.Arrived; return DriveResult.Arrived;
} }
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
// Degenerate — already on target horizontally; preserve heading. // Degenerate — already on target horizontally; preserve heading.
if (dist < 1e-4f) if (dist < 1e-4f)

View file

@ -268,6 +268,41 @@ public class UpdateMotionTests
Assert.Equal(90.0f, path.DesiredHeading); 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] [Fact]
public void ParsesMoveToObjectTargetGuidAndOrigin() public void ParsesMoveToObjectTargetGuidAndOrigin()
{ {

View file

@ -60,12 +60,30 @@ public class RemoteMoveToDriverTests
} }
[Fact] [Fact]
public void Drive_RetailMinDistanceWins_WhenLargerThanDistanceToObject() public void Drive_FleeArrival_UsesMinDistance()
{ {
// Hypothetical retail packet: MinDistance=2.0 (set explicitly), // Flee branch (moveTowards=false): arrival when dist >= MinDistance.
// DistanceToObject=0.6 (default). Arrival should fire at 2 m // Retail / ACE both use MinDistance for the flee-arrival threshold.
// because retail's algorithm uses MinDistance and it's the larger var bodyPos = new Vector3(0f, 0f, 0f);
// of the two. 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 bodyPos = new Vector3(0f, 0f, 0f);
var bodyRot = Quaternion.Identity; var bodyRot = Quaternion.Identity;
var dest = new Vector3(0f, 1.5f, 0f); var dest = new Vector3(0f, 1.5f, 0f);
@ -76,7 +94,7 @@ public class RemoteMoveToDriverTests
dt: 0.016f, moveTowards: true, dt: 0.016f, moveTowards: true,
out _); out _);
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
} }
[Fact] [Fact]