# 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, ¶ms)`** — 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.