acdream/docs/superpowers/specs/2026-05-14-phase-b6-design.md
Erik d82b0648b5 docs(B.6): record Slice 1 trace findings — ACE sends mtRun=0.00, no UP echo
Captured a live ACDREAM_PROBE_AUTOWALK trace double-clicking +Je from
~3.5m. Findings folded into the spec's State at design freeze section:

1. Wire parser is correct (matches ACE MoveToObject.Write +
   MoveToParameters.Write byte-for-byte).
2. ACE sends mtRun=0.00. Not a parser bug — that's the wire value.
   Retail's apply_run_to_command (0x00527BE0) fell back to the
   player's own rate; our Slice 2 needs the same fallback chain.
3. Player position never changed during the entire trace — current
   behavior is pure no-op on the inbound MoveToObject (literally
   ignored, as our code at OnLiveMotionUpdated:3289 suggests).
4. ACE does NOT broadcast UpdatePosition for the local player during
   auto-walk. Definitively kills Option C — nothing to blend with.
   Local body must drive itself.

The trace validates the spec's Option A path. Slice 2 implementation
can proceed without further wire-format guessing.
2026-05-14 18:45:17 +02:00

20 KiB
Raw Blame History

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 (server-initiated MoveToObject auto-walk not honored). Predecessors: B.5 (ground-item pickup, close-range path) + B.4b (outbound Use chain). References:


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:33463425 already consumes all of this — _remoteDeadReckon per-entity state captures MoveToDestinationWorld, MoveToMinDistance, MoveToDistanceToObject, MoveToMoveTowards, and HasMoveToDestination. The per-tick driver RemoteMoveToDriver 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:3289if (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 300628300648) 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 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 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:34013417.

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 25 m away. Character walks to the item, picks it up, despawn fires, inventory updates.
  • F-press on a selected item from 25 m away. Same behavior as double-click.
  • Use a Holtburg NPC from 25 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 300628300648) 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.