diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index cb9b34b..2a4896b 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -218,6 +218,31 @@ public sealed class PlayerMovementController
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
+ // ── B.6 slice 2 (2026-05-14): local-player server-initiated auto-walk ──
+ // When ACE sends a MoveToObject motion for the local player (out-of-range
+ // Use / PickUp triggers ACE's server-side CreateMoveToChain), the wire
+ // payload includes a destination, arrival predicates, and a run rate.
+ // Retail's MovementManager::PerformMovement (0x00524440 case 6) runs a
+ // LOCAL auto-walk in response: heading correction toward the target,
+ // run-forward velocity at the wire's runRate, arrival detection via
+ // MoveToManager::HandleMoveToPosition. Here we keep the active auto-walk
+ // state and inject it into Update() as a synthesized Forward+Run input
+ // so the existing motion-interpreter / body-velocity pipeline runs
+ // unchanged. Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md.
+ private bool _autoWalkActive;
+ private Vector3 _autoWalkDestination;
+ private float _autoWalkMinDistance;
+ private float _autoWalkDistanceToObject;
+ private bool _autoWalkMoveTowards;
+
+ ///
+ /// True while a server-initiated auto-walk (MoveToObject inbound) is
+ /// active on the local player. The next call
+ /// synthesizes Forward+Run input and steers toward
+ /// the destination until arrival or user-input cancellation.
+ ///
+ public bool IsServerAutoWalking => _autoWalkActive;
+
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
@@ -288,6 +313,154 @@ public sealed class PlayerMovementController
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
+ ///
+ /// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk
+ /// against this body. will synthesize
+ /// Forward+Run input and steer toward
+ /// until the body reaches the
+ /// arrival predicate (moveTowards: dist ≤ distanceToObject;
+ /// !moveTowards: dist ≥ minDistance) or the user presses any
+ /// movement key (which auto-cancels).
+ ///
+ ///
+ /// Retail reference: MovementManager::PerformMovement
+ /// (0x00524440) case 6 — unpacks the wire's target +
+ /// origin + run rate and calls CPhysicsObj::MoveToObject on
+ /// the local body. We do the equivalent at acdream's altitude:
+ /// hold the destination + thresholds + run rate locally, let the
+ /// existing per-tick motion machinery do the walking, and arrive
+ /// when the horizontal distance hits the threshold.
+ ///
+ ///
+ ///
+ /// The run-rate parameter is the EFFECTIVE rate after the
+ /// mtRun=0 fallback chain — the caller (GameWindow) is
+ /// responsible for substituting a non-zero rate when ACE sends 0.0
+ /// on the wire, per the trace finding in the design spec.
+ ///
+ ///
+ public void BeginServerAutoWalk(
+ Vector3 destinationWorld,
+ float minDistance,
+ float distanceToObject,
+ bool moveTowards)
+ {
+ _autoWalkActive = true;
+ _autoWalkDestination = destinationWorld;
+ _autoWalkMinDistance = minDistance;
+ _autoWalkDistanceToObject = distanceToObject;
+ _autoWalkMoveTowards = moveTowards;
+ }
+
+ ///
+ /// B.6 slice 2 (2026-05-14). Cancel any active server-initiated
+ /// auto-walk. Idempotent. is logged when
+ /// is on so
+ /// the trace shows why the auto-walk ended.
+ ///
+ public void EndServerAutoWalk(string reason)
+ {
+ if (!_autoWalkActive) return;
+ _autoWalkActive = false;
+ if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ Console.WriteLine($"[autowalk-end] reason={reason}");
+ }
+
+ ///
+ /// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
+ /// active, either cancel it (user pressed a movement key) or
+ /// synthesize a Forward+Run input with stepped
+ /// toward the destination. Returns the (possibly modified) input
+ /// for the rest of to consume.
+ ///
+ ///
+ /// Heading correction matches
+ /// — ±
+ /// snap-on-aligned, otherwise rotate at
+ /// . Arrival
+ /// predicate matches retail's
+ /// MoveToManager::HandleMoveToPosition: chase arrives at
+ /// distanceToObject; flee arrives at minDistance.
+ ///
+ ///
+ private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
+ {
+ if (!_autoWalkActive) return input;
+
+ // User-input cancellation. Any direct movement key takes over.
+ // Mouse-only turning (no movement key) doesn't cancel — the
+ // user might just be looking around mid-walk.
+ bool userOverride = input.Forward || input.Backward
+ || input.StrafeLeft || input.StrafeRight
+ || input.TurnLeft || input.TurnRight;
+ if (userOverride)
+ {
+ EndServerAutoWalk("user-input");
+ return input;
+ }
+
+ // Horizontal distance to target — server owns Z, our local body
+ // Z snaps to UpdatePosition broadcasts when ACE sends them.
+ var pos = _body.Position;
+ float dx = _autoWalkDestination.X - pos.X;
+ float dy = _autoWalkDestination.Y - pos.Y;
+ float dist = MathF.Sqrt(dx * dx + dy * dy);
+
+ // Arrival predicate (RemoteMoveToDriver convention; matches retail).
+ float arrivalThreshold = _autoWalkMoveTowards
+ ? _autoWalkDistanceToObject
+ : _autoWalkMinDistance;
+ bool arrived =
+ (_autoWalkMoveTowards
+ && dist <= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon)
+ || (!_autoWalkMoveTowards
+ && dist >= arrivalThreshold - RemoteMoveToDriver.ArrivalEpsilon);
+ if (arrived)
+ {
+ EndServerAutoWalk("arrived");
+ return input; // falls through to Ready (no Forward) → body stops
+ }
+
+ // Step Yaw toward target. Convention from Update line 364:
+ // _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2),
+ // so local-forward (+Y) maps to world (cos Yaw, sin Yaw, 0).
+ // Therefore Yaw that faces (dx,dy) is atan2(dy, dx).
+ if (dist > 1e-4f)
+ {
+ float desiredYaw = MathF.Atan2(dy, dx);
+ float delta = desiredYaw - Yaw;
+ while (delta > MathF.PI) delta -= 2f * MathF.PI;
+ while (delta < -MathF.PI) delta += 2f * MathF.PI;
+ if (MathF.Abs(delta) <= RemoteMoveToDriver.HeadingSnapToleranceRad)
+ {
+ Yaw = desiredYaw;
+ }
+ else
+ {
+ float maxStep = RemoteMoveToDriver.TurnRateRadPerSec * dt;
+ Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
+ }
+ while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
+ while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
+ }
+
+ // Synthesize "running forward" input. The rest of Update reads
+ // Yaw + input.Forward + input.Run to drive _motion + body
+ // velocity exactly as it does for user-driven W+Shift. We zero
+ // any mouse delta so a stale frame doesn't fight the steering.
+ return input with
+ {
+ Forward = true,
+ Run = true,
+ Backward = false,
+ StrafeLeft = false,
+ StrafeRight = false,
+ TurnLeft = false,
+ TurnRight = false,
+ MouseDeltaX = 0f,
+ };
+ }
+
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
// [cell-transit] probe fires from a single chokepoint. Both the
// server-snap path (SetPosition) and the per-frame resolver path
@@ -329,6 +502,15 @@ public sealed class PlayerMovementController
public MovementResult Update(float dt, MovementInput input)
{
+ // B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
+ // When _autoWalkActive, steer Yaw toward _autoWalkDestination and
+ // synthesize Forward+Run input so the rest of Update runs the
+ // body forward as if the user were holding W. User movement-key
+ // input cancels the auto-walk (retail UX). Arrival check fires
+ // before synthesizing, so a one-frame arrival doesn't waste a
+ // tick of velocity past the target.
+ input = ApplyAutoWalkOverlay(dt, input);
+
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
// can detect the frozen state (MotionStateChanged = false, no commands).
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index f25f58b..76b09d7 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -3313,6 +3313,46 @@ public sealed class GameWindow : IDisposable
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
}
+
+ // B.6 slice 2 (2026-05-14): drive the local player's body
+ // through a server-initiated auto-walk when ACE sends
+ // MoveToObject (movement type 6) — retail-faithful per
+ // MovementManager::PerformMovement 0x00524440 case 6. When
+ // the inbound motion is NOT a MoveTo, cancel any active
+ // auto-walk (server intent changed).
+ if (_playerController is not null)
+ {
+ if (update.MotionState.IsServerControlledMoveTo
+ && update.MotionState.MoveToPath is { } pathData)
+ {
+ // Translate landblock-local origin → world space.
+ var destWorld = AcDream.Core.Physics.RemoteMoveToDriver
+ .OriginToWorld(
+ pathData.OriginCellId,
+ pathData.OriginX,
+ pathData.OriginY,
+ pathData.OriginZ,
+ _liveCenterX,
+ _liveCenterY);
+ _playerController.BeginServerAutoWalk(
+ destWorld,
+ pathData.MinDistance,
+ pathData.DistanceToObject,
+ update.MotionState.MoveTowards);
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} towards={update.MotionState.MoveTowards}"));
+ }
+ }
+ else if (_playerController.IsServerAutoWalking)
+ {
+ // A non-MoveTo motion arrived (e.g., Ready, or a
+ // user-input echo) — server's auto-walk intent is
+ // gone; release the local driver.
+ _playerController.EndServerAutoWalk("motion-non-moveto");
+ }
+ }
}
else
{