diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 300919e..1633f4a 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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 copy_movement_from 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)
{
diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
index 54152b0..0981666 100644
--- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
+++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs
@@ -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 max(MinDistance, DistanceToObject) 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)
diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
index da042f0..09f9eb9 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
@@ -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()
{
diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
index 3274702..ece3f9b 100644
--- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs
@@ -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]