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.
20 KiB
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:
- ACE server-side:
Player_Move.cs:37–179(CreateMoveToChain,MoveToChain,MoveTo). - ACE pickup driver:
Player_Inventory.cs:976–1106. - Reference port (Rust): holtburger
simulation.rs:33–41+178–191. - Existing acdream infra:
RemoteMoveToDriver.cs,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
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:
GameWindow.cs:3289—if (update.Guid == _playerServerGuid)skipsSetCyclesoUpdatePlayerAnimationstays authoritative.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:
- Call
MovementManager::MakeMoveToManager(this)to ensure the per-physics-object manager exists. - Unpack the target guid + origin position +
MovementParametersfrom the wire. - Set
this->motion_interpreter->my_run_ratefrom the packet. - 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). - Fall through to
MoveToManager::MoveToPositiononly 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:
RemoteMoveToDriveris the per-tick steering loop — heading correction, arrival viamin_distance/distance_to_object, ±20° aux-turn tolerance — ported from retail'sMoveToManager::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.PlanMoveToStartalready does what retail'sMovementParameters::get_command(0x0052AA00) does: seedWalkForward/RunForwarddepending onCanRun+MoveToSpeed+MoveToRunRate.MotionState.MoveToPathis fully parsed on the wire. Remote chase reads it atGameWindow.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:
- Outbound: timestamp + opcode + payload of the user's Use / PickUp packet that triggers the auto-walk.
- Inbound during the auto-walk: every
UpdateMotion,UpdatePosition,VectorUpdate,WeenieError,InventoryServerSaveFailedfor the local player, timestamped. - 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— addProbeAutoWalkEnabledflag gated onACDREAM_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— addBeginServerAutoWalk( 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 callsRemoteMoveToDriver.Step(...)against its own body, and the user-input motion path is suppressed. - Modify:
GameWindow.OnLiveMotionUpdated— whenupdate.Guid == _playerServerGuid && IsServerControlledMoveTo && MoveToPath is not null, translate the path'sOriginCellId + OriginXYZto world space (sameRemoteMoveToDriver.OriginToWorldhelper the remote path uses), call_playerController.BeginServerAutoWalk(...). Otherwise (a non-MoveTo motion arrives for the player), callEndServerAutoWalk(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; letRemoteMoveToDriver.Stepset the body's velocity + heading; apply arrival check viamin_distance/distance_to_object; on arrival, callEndServerAutoWalk(reason="arrived").
Slice 3 — animation cycle selection (~20 LOC).
- Modify:
GameWindowUpdatePlayerAnimation(the path that drives the local player's animation cycle from user input) — when the controller is inIsServerAutoWalkingstate, source the cycle fromServerControlledLocomotion.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, allowMotion(Pickup)/Motion(Pickup5/10/15/20)to driveSetCyclebypassing the existing self-echo filter atGameWindow.cs:3289. This is the bend-down animation retail observers see when we pick up an item. Same one-shot mechanism retail used;UpdatePlayerAnimationdoesn'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
MoveToChainreads "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
MoveToObjectinitiation 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:3289that ignores MoveToObject also drops the inboundMotion(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(0x00524440case 6, decomp lines 300628–300648) decisively settles A vs C in favor of Option A. Retail's local client ran its ownMoveToManagerand calledCPhysicsObj::MoveToObjecton 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_AUTOWALKdiagnostic + 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:
- Parser confirmed correct. Each
[autowalk-mt]line readsmt=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'sMoveToObject.Write+MoveToParameters.Writebyte-for-byte. - ACE sends
mtRun=0.00— not a parser bug. ACE'sPlayer_Move.MoveTodefault for unspecifiedrunRateis0.0f, and the call into MoveToObject uses that wire value directly. Retail decomp at0x005245e9copies the wire value intomotion_interpreter->my_run_rate; if 0, the local MoveToManager falls back to the player's own run-rate viaCMotionInterp::apply_run_to_command(0x00527BE0). - Player position never changed during the entire trace. All
[autowalk-up]lines after the 4 Use sends reportpos=(112.32, 9.36, 94.00)verbatim — current behavior is pure no-op on the inbound MoveToObject. - ACE does NOT broadcast
UpdatePositionfor 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. - Slice 2 must handle
mtRun == 0.0— fall back to the player's own current run rate. The trace also capturedfwdSpd=2.86for the user's normal running before the auto-walk attempt — that's the server-echoedForwardSpeed, available as a recent-history source for the fallback. Default1.0if 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.