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.
// Treating 0x10000051/52/53 as SetCycle commands made the
// 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 (!remoteIsAirborne)
@ -2499,47 +2575,17 @@ public sealed class GameWindow : IDisposable
// FUN_00528f70 DoInterpretedMotion
// FUN_00528960 get_state_velocity
// 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.
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 dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate. Retail (named decomp): dist ≤ min_distance.
// ACE port: dist ≤ DistanceToObject. ACE's wire put the melee
// threshold in DistanceToObject (default 0.6) and left
// MinDistance=0; retail server config presumably set MinDistance.
// Defensive port: take the larger so we honor whichever field is
// populated. Flee branch is unused here but we honor the
// moveTowards flag for symmetry.
float arrivalThreshold = MathF.Max(minDistance, distanceToObject);
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
// MoveToManager.cs:476:
//
// chase (MoveTowards): dist <= distance_to_object
// flee (MoveAway): dist >= min_distance
//
// (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)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
// Degenerate — already on target horizontally; preserve heading.
if (dist < 1e-4f)