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:
parent
d247aef2e4
commit
f794832ebc
4 changed files with 165 additions and 54 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -268,6 +268,41 @@ public class UpdateMotionTests
|
|||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -60,12 +60,30 @@ public class RemoteMoveToDriverTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Drive_RetailMinDistanceWins_WhenLargerThanDistanceToObject()
|
||||
public void Drive_FleeArrival_UsesMinDistance()
|
||||
{
|
||||
// Hypothetical retail packet: MinDistance=2.0 (set explicitly),
|
||||
// DistanceToObject=0.6 (default). Arrival should fire at 2 m
|
||||
// because retail's algorithm uses MinDistance and it's the larger
|
||||
// of the two.
|
||||
// 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);
|
||||
|
|
@ -76,7 +94,7 @@ public class RemoteMoveToDriverTests
|
|||
dt: 0.016f, moveTowards: true,
|
||||
out _);
|
||||
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result);
|
||||
Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue