# Phase B.6 — Local-player auto-walk for server-initiated MoveToObject — design

**Date:** 2026-05-14.
**Status:** DESIGN — implementation deferred to a dedicated session.
**Closes:** [issue #63](../../ISSUES.md) (server-initiated `MoveToObject` auto-walk not honored).
**Predecessors:** [B.5](../plans/2026-05-14-phase-b5-pickup.md) (ground-item pickup, close-range path) + [B.4b](../plans/2026-05-13-phase-b4b-plan.md) (outbound Use chain).
**References:**
- ACE server-side: [`Player_Move.cs:37–179`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Move.cs) (`CreateMoveToChain`, `MoveToChain`, `MoveTo`).
- ACE pickup driver: [`Player_Inventory.cs:976–1106`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Inventory.cs).
- Reference port (Rust): [holtburger `simulation.rs:33–41` + `178–191`](../../../references/holtburger/crates/holtburger-core/src/client/simulation.rs).
- Existing acdream infra: [`RemoteMoveToDriver.cs`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs), [`ServerControlledLocomotion.cs`](../../../src/AcDream.Core/Physics/ServerControlledLocomotion.cs).

---

## Problem statement

When the local player triggers a `Use (0x0036)` or `PutItemInContainer
(0x0019)` on a target outside ACE's `WithinUseRadius` (default 0.6 m),
ACE's `CreateMoveToChain` initiates a **server-side auto-walk** toward
the target. ACE also broadcasts `Motion(MovementType=MoveToObject,
target=X)` via `EnqueueBroadcastMotion`.

Our client currently does NOT honor this server-initiated motion for
the local player. The visible symptom: the character drifts a short
distance toward the target, then "snaps back" to the original
position. ACE's `MoveToChain` polls `WithinUseRadius` every 0.1 s; if
the player never enters the radius before `defaultMoveToTimeout` fires,
the chain calls `callback(false)` which broadcasts
`InventoryServerSaveFailed (ActionCancelled)` and the pickup / use
never completes.

**User-facing impact:**
- Double-click on a ground item from any distance → walk + snap, no pickup.
- F-press on a ground item from > 0.6 m → walk + snap, no pickup.
- Use on an out-of-range NPC → same.

Close-range (≤ 0.6 m) Use and PickUp work correctly via ACE's
early-return branch at `Player_Move.cs:66` (the `WithinUseRadius`
shortcut that skips the chain entirely).

---

## Current state — wire data we already have

`UpdateMotion (0xF74D)` is already parsed end-to-end. The parser
populates `EntityMotionUpdate.MotionState` with:

| Field | Source | Notes |
|---|---|---|
| `MovementType` | wire byte | `MoveToObject = 6`, `MoveToPosition = 7`. |
| `IsServerControlledMoveTo` | derived | True when `MovementType ∈ {6, 7}`. |
| `MoveToPath` | wire (when set) | `(OriginCellId, OriginX/Y/Z, MinDistance, DistanceToObject)` — the auto-walk destination, plus arrival predicates. |
| `MoveToSpeed`, `MoveToRunRate`, `MoveToCanRun`, `MoveTowards` | wire | speed scalars + chase-vs-flee bit. |

The remote-creature path at
[`GameWindow.cs:3346–3425`](../../../src/AcDream.App/Rendering/GameWindow.cs)
already consumes all of this — `_remoteDeadReckon` per-entity state
captures `MoveToDestinationWorld`, `MoveToMinDistance`,
`MoveToDistanceToObject`, `MoveToMoveTowards`, and
`HasMoveToDestination`. The per-tick driver
[`RemoteMoveToDriver`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs)
computes heading + arrival and is exercised on every NPC chase.

**Gap:** none of this fires for `update.Guid == _playerServerGuid`. The
local-player branch is gated out at:

1. `GameWindow.cs:3289` — `if (update.Guid == _playerServerGuid)` skips
   `SetCycle` so `UpdatePlayerAnimation` stays authoritative.
2. `GameWindow.cs:3346` — `_remoteDeadReckon.TryGetValue(update.Guid,
   …)` returns false because the local player isn't in
   `_remoteDeadReckon`.

The wire is parsed, the destination is in `MotionState.MoveToPath`, and
nothing in our client reads it for the local player.

---

## Why the player visibly walks-then-snaps today

A.  ACE broadcasts `MotionUpdated(MoveToObject)` → our client ignores it
    for the local player (above).
B.  ACE's server-side `PhysicsObj.MoveToObject(…)` runs its own
    simulation. ACE updates the player's authoritative `Location` on its
    side every physics tick.
C.  ACE periodically broadcasts the player's updated position back to
    everyone — including the player themselves — via `UpdatePosition`.
    The local-player's `PositionUpdated` handler in `GameWindow` likely
    applies these snapshots, producing the visible forward motion.
D.  After `defaultMoveToTimeout` (server-side configurable, typically
    on the order of seconds), ACE gives up. `MoveToChain` calls
    `callback(false)`. Server-side, ACE may issue a position correction
    that returns the player to where the chain started.
E.  The local prediction (our `PlayerMovementController` running on
    user input — which is "no movement keys held") reconciles toward
    "stationary at original spot", producing the snap-back when ACE's
    own corrections stop arriving.

The exact source of the visible motion (step C vs. some other path) is
unverified — flagged as **investigation work** below.

---

## Solution candidates

### Option A — Run the existing remote-driver for the local player

**Approach.** When `OnLiveMotionUpdated` sees `IsServerControlledMoveTo`
for `_playerServerGuid`, install the same per-tick steering machinery
that drives remote creatures: heading toward
`MoveToDestinationWorld`, run/walk cycle from
`ServerControlledLocomotion.PlanMoveToStart`, arrival detected via
`min_distance` (chase) / `distance_to_object` (flee).

While the auto-walk is active, suppress user-input driven movement so
the two control paths don't fight. On arrival or on a non-MoveTo motion
arriving, release auto-walk and restore user input.

**Pros.** Retail-faithful. Reuses well-tested infrastructure. Aligns
the local player's behavior with how every remote creature already
handles `MoveToObject`.

**Cons.** Largest implementation surface. Needs careful state-machine
design to prevent input/auto-walk fighting and ensure clean release on
ESC, arrival, packet cancel, target despawn, login transitions.

### Option B — Visual settle to arrival pose

**Approach.** When MoveToObject arrives, compute the approximate
arrival position via the holtburger
`approximate_move_to_object_projection_target` formula
(`target_pos - normalize(target_pos - source) × (DistanceToObject +
UseRadius)`). Smoothly tween the local-player camera/mesh from current
position to that arrival pose over the expected walk duration. Don't
send any extra packets; rely on ACE's own physics to walk the
authoritative position in parallel.

**Pros.** Smaller scope (~50 LOC). No state machine. No reconciliation
fight — the tween is purely visual; the underlying
`PlayerMovementController` is paused for the duration of the tween.

**Cons.** Not retail-faithful. Doesn't actually run the player's
physics integrator forward, so any side effects of normal movement
(footstep emit hooks, collision resolution mid-walk, environment
triggers) don't fire. Likely fine for an item pickup (no environment to
interact with mid-walk), but is a band-aid that doesn't generalize.

### Option C — Server-position-authoritative blend

**Approach.** Detect `IsServerControlledMoveTo` for the local player.
While active, treat inbound `UpdatePosition` as authoritative without
reconciliation — i.e. trust the server's interpolated positions and
suppress the local prediction's snap-back. Don't try to drive
local-side; let ACE's broadcasts drag us along.

**Pros.** Very small surface (~20 LOC). No new state machine; just a
flag that gates reconciliation.

**Cons.** Depends on ACE actually broadcasting per-tick UpdatePosition
during the auto-walk. The visible "walks then snaps" pattern suggests
it does broadcast for a while and then stops; need to confirm. If
ACE's broadcast cadence is too sparse, the motion is choppy.

---

## Recommendation

**Option A is the retail-faithful path; Options B and C are non-retail
shortcuts. Implement Option A.**

### Retail evidence (settles A vs C)

`MovementManager::PerformMovement` at retail address `0x00524440`
(decomp `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines
300628–300648) is the inbound-motion dispatcher. The switch on movement
type has explicit cases for `MoveToObject (6)` and `MoveToPosition (7)`
that:

1. Call `MovementManager::MakeMoveToManager(this)` to ensure the
   per-physics-object manager exists.
2. Unpack the target guid + origin position + `MovementParameters` from
   the wire.
3. Set `this->motion_interpreter->my_run_rate` from the packet.
4. Call **`CPhysicsObj::MoveToObject(this->physics_obj, target_guid,
   &params)`** — which kicks off a **fully local** auto-walk on the
   player's own physics body (`0x00512860`, decomp line 280598).
5. Fall through to `MoveToManager::MoveToPosition` only if the target
   guid isn't found in the local physics world (rare; usually means the
   client hasn't streamed in the target yet).

This is the **same code path** that runs for every remote creature
chasing the player — retail did not have a separate "remote-only" vs
"local-only" auto-walk pipeline. The local client's MoveToManager
actively drove the local player's body forward when the server sent
MoveToObject. ACE's server-side `PhysicsObj.MoveToObject` simulation
runs in parallel for authoritative-position tracking + arrival
detection (`MoveToChain.WithinUseRadius`), but the visible movement on
the local client comes from the local MoveToManager — not from
inbound UpdatePosition packets.

Option C would diverge from retail by relying on server position
broadcasts instead of local physics integration. That risks combat
movement, environment-trigger interactions, and animation hooks all
diverging from retail because they'd be driven by sparse server-side
position snapshots rather than smooth local integration.

### Existing acdream infrastructure that's already retail-shaped

We have most of the building blocks already:

- [`RemoteMoveToDriver`](../../../src/AcDream.Core/Physics/RemoteMoveToDriver.cs)
  is the per-tick steering loop — heading correction, arrival via
  `min_distance` / `distance_to_object`, ±20° aux-turn tolerance —
  ported from retail's `MoveToManager::HandleMoveToPosition`
  (`0x00529d80`). It's already exercised on every NPC chase. The
  retail-faithful fix for the local player **reuses this driver**,
  installed against the local player's body instead of a remote's
  dead-reckoned body.
- [`ServerControlledLocomotion.PlanMoveToStart`](../../../src/AcDream.Core/Physics/ServerControlledLocomotion.cs)
  already does what retail's `MovementParameters::get_command`
  (`0x0052AA00`) does: seed `WalkForward` / `RunForward` depending on
  `CanRun` + `MoveToSpeed` + `MoveToRunRate`.
- `MotionState.MoveToPath` is fully parsed on the wire. Remote chase
  reads it at `GameWindow.cs:3401–3417`.

The B.6 work is essentially **"do for `_playerServerGuid` what we
already do for remotes,"** with one extra concern: the local player has
a user-input motion source (`PlayerMovementController`) that has to
yield to the auto-walk while it's active.

### Why not B

Tweens aren't retail-faithful and would diverge worse than C.
Eliminated.

---

## Required investigation before any code

The "walks then snaps back" symptom is observed but not yet
characterized in detail. We need a live trace of:

1. Outbound: timestamp + opcode + payload of the user's Use / PickUp
   packet that triggers the auto-walk.
2. Inbound during the auto-walk: every `UpdateMotion`, `UpdatePosition`,
   `VectorUpdate`, `WeenieError`, `InventoryServerSaveFailed` for the
   local player, timestamped.
3. Visible client position at each inbound event (so we can correlate
   "walks forward at frame N" with the inbound that caused it).

**Implementation step:** add a runtime-gated diagnostic
`ACDREAM_PROBE_AUTOWALK=1` that logs one line per relevant event during
local-player auto-walk attempts. Roughly 30 LOC; mirrors L.2a slice 1's
`ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL` pattern.

The trace is no longer needed to *decide between options* (retail
decomp settles that — Option A wins), but it remains valuable as a
**baseline measurement** for the Option A implementation: knowing what
ACE sends today lets us verify the local driver behaves equivalently
on the wire (no extra packets needed, position broadcasts arrive at
expected cadence, the auto-walk completes inside
`defaultMoveToTimeout` instead of timing out).

---

## File-level scope sketch (Option A — retail-faithful)

Mirror retail's `MovementManager::PerformMovement` case 6 against
acdream's existing `PlayerMovementController` + `RemoteMoveToDriver`.

**Slice 1 — diagnostic baseline (~30 LOC).**
- **Modify:** `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — add
  `ProbeAutoWalkEnabled` flag gated on `ACDREAM_PROBE_AUTOWALK=1`.
- **Modify:** `GameWindow.OnLiveMotionUpdated`, `OnLivePositionUpdated`,
  `OnVectorUpdated`, `SendUse`, `SendPickUp` — when probe is on and the
  guid is `_playerServerGuid`, log one line per event with timestamp +
  payload. Mirror the `[resolve]` / `[cell-transit]` line format from
  L.2a.

**Slice 2 — install auto-walk on inbound MoveToObject (~100 LOC).**
- **Modify:** `PlayerMovementController` — add `BeginServerAutoWalk(
  Vector3 destinationWorld, float minDistance, float distanceToObject,
  bool moveTowards, float moveToSpeed, float moveToRunRate, bool
  canRun)` + `EndServerAutoWalk(reason)` methods. The controller owns
  the "input vs auto-walk" state. While auto-walking, the per-tick
  update calls `RemoteMoveToDriver.Step(...)` against its own body, and
  the user-input motion path is suppressed.
- **Modify:** `GameWindow.OnLiveMotionUpdated` — when `update.Guid ==
  _playerServerGuid && IsServerControlledMoveTo && MoveToPath is
  not null`, translate the path's `OriginCellId + OriginXYZ` to world
  space (same `RemoteMoveToDriver.OriginToWorld` helper the remote
  path uses), call `_playerController.BeginServerAutoWalk(...)`.
  Otherwise (a non-MoveTo motion arrives for the player), call
  `EndServerAutoWalk(reason="motion-changed")`.
- **Modify:** `PlayerMovementController.Tick` — if auto-walking,
  consume input only for cancellation (W/A/S/D pressed → cancel
  auto-walk → restore input); skip the input-driven velocity solve;
  let `RemoteMoveToDriver.Step` set the body's velocity + heading;
  apply arrival check via `min_distance` / `distance_to_object`; on
  arrival, call `EndServerAutoWalk(reason="arrived")`.

**Slice 3 — animation cycle selection (~20 LOC).**
- **Modify:** `GameWindow` `UpdatePlayerAnimation` (the path that
  drives the local player's animation cycle from user input) — when
  the controller is in `IsServerAutoWalking` state, source the cycle
  from `ServerControlledLocomotion.PlanMoveToStart(...)` instead of the
  user-input MotionInterpreter. This is what makes the local player
  visibly walk + animate during the auto-walk.

**Slice 4 — local pickup animation (probably closes #64, ~10 LOC).**
- **Modify:** `OnLiveMotionUpdated` — for `_playerServerGuid`, allow
  `Motion(Pickup)` / `Motion(Pickup5/10/15/20)` to drive `SetCycle`
  bypassing the existing self-echo filter at `GameWindow.cs:3289`.
  This is the bend-down animation retail observers see when we pick up
  an item. Same one-shot mechanism retail used; `UpdatePlayerAnimation`
  doesn't predict it because it's server-initiated, so admitting the
  echo is correct.

Total: estimated ~160 LOC + unit tests for the controller state
machine. No new files; all changes land in existing physics + render
infrastructure.

---

## Acceptance criteria

- [ ] Double-click a ground item from 2–5 m away. Character walks to
  the item, picks it up, despawn fires, inventory updates.
- [ ] F-press on a selected item from 2–5 m away. Same behavior as
  double-click.
- [ ] Use a Holtburg NPC from 2–5 m away. Character walks to the NPC,
  chat dialogue appears (NPC interaction completes, like the
  close-range case from M1 target 3).
- [ ] No regression on close-range pickup (B.5 path remains 1-tap
  works).
- [ ] User can interrupt the auto-walk by pressing a movement key (W /
  A / S / D). Auto-walk releases; input takes over; ACE's
  `MoveToChain` reads "not arrived" + "user cancelled" and stops.
- [ ] Pressing ESC during auto-walk releases the auto-walk and returns
  to normal player mode.
- [ ] No "walks then snaps back" visual artifact under any of the
  scenarios above.

---

## Out of scope (deferred to later phases)

- `MoveToPosition` (movement type 7) for the local player — typically
  used by portal traversal and admin-teleport flows. M4 territory.
- `Stick` / sticky-target tracking — used by combat chase. M2/M3
  territory.
- Sphere-cylinder distance variant — relevant for vendor / corpse
  interactions where the target has non-trivial extent. Can be added
  in B.6's follow-up commit if needed.
- Local player's `MoveToObject` initiation via wire (we currently rely
  on ACE to start the auto-walk on the player's behalf — that's the
  retail behavior and we don't need a client-initiated path).

---

## Carry-overs from B.5

- **#64** — local-player pickup animation. Same self-echo filter at
  `OnLiveMotionUpdated:3289` that ignores MoveToObject also drops the
  inbound `Motion(Pickup)` retail observers render correctly. Slice 4
  above admits server-initiated one-shot motions through the filter
  for the local player, which should close #64. Verify in the same
  visual-test pass.

---

## State at design freeze

- **Main HEAD:** `281d125` (initial B.6 design spec committed).
- **No code changes in this spec commit** — design document only.
- **Spec update 2026-05-14 (this commit):** retail decomp at
  `MovementManager::PerformMovement` (`0x00524440` case 6, decomp lines
  300628–300648) decisively settles A vs C in favor of **Option A**.
  Retail's local client ran its own `MoveToManager` and called
  `CPhysicsObj::MoveToObject` on the local player's body. Option C
  (server-position-blend) is not retail-faithful and is no longer
  considered.
- **Slice 1 shipped 2026-05-14** (`eda8278` + `1b4f3ba`):
  `ACDREAM_PROBE_AUTOWALK` diagnostic + DebugPanel checkbox.

### Trace-captured findings (post-Slice-1)

A live trace captured at Holtburg with the probe enabled —
double-clicking `+Je` (a remote player at `(111.34, 5.96, 94.01)` in
cell `0xA9B40021`) from ~3.5 m away, 4 successive Use sends. Findings:

1. **Parser confirmed correct.** Each `[autowalk-mt]` line reads
   `mt=0x06 isMoveTo=True moveTowards=True
   path=cell=0xA9B40021,xyz=(111.34,5.96,94.01),minDist=0.00,objDist=0.50
   mtSpd=1.00 mtRun=0.00`. Matches ACE's
   `MoveToObject.Write` + `MoveToParameters.Write` byte-for-byte.
2. **ACE sends `mtRun=0.00`** — not a parser bug. ACE's
   `Player_Move.MoveTo` default for unspecified `runRate` is `0.0f`,
   and the call into MoveToObject uses that wire value directly. Retail
   decomp at `0x005245e9` copies the wire value into
   `motion_interpreter->my_run_rate`; if 0, the local MoveToManager
   falls back to the player's own run-rate via
   `CMotionInterp::apply_run_to_command` (`0x00527BE0`).
3. **Player position never changed** during the entire trace. All
   `[autowalk-up]` lines after the 4 Use sends report
   `pos=(112.32, 9.36, 94.00)` verbatim — current behavior is pure
   no-op on the inbound MoveToObject.
4. **ACE does NOT broadcast `UpdatePosition` for the local player
   during the auto-walk.** This kills Option C even more firmly than
   the retail decomp did. The local body has to drive itself.
5. **Slice 2 must handle `mtRun == 0.0`** — fall back to the player's
   own current run rate. The trace also captured `fwdSpd=2.86` for
   the user's normal running before the auto-walk attempt — that's
   the server-echoed `ForwardSpeed`, available as a recent-history
   source for the fallback. Default `1.0` if no echo yet.

### Next session entry point

Slice 2: add `PlayerMovementController.BeginServerAutoWalk` /
`EndServerAutoWalk` + per-tick steering via `RemoteMoveToDriver`-style
heading + arrival. Wire in `GameWindow.OnLiveMotionUpdated` with the
`mtRun=0` fallback chain. Suppress user-input motion while
auto-walking; cancel on movement-key press.
