# Phase L.1c — Remote MoveTo body-driver pseudocode **Date**: 2026-04-28 **Goal**: Port the minimum viable subset of retail `MoveToManager` so the body position of server-controlled chasing creatures (movementType 6/7) tracks the server-supplied destination smoothly, instead of freezing at zero velocity between sparse `UpdatePosition` snaps. ## Problem (root cause from systematic-debugging Phase 1) The 882a07c stabilizer holds `rm.Body.Velocity = 0` while `ServerMoveToActive` is true, on the principle "do not let `apply_current_movement` free-run with incomplete MoveTo state." The state IS incomplete: our parser at [`UpdateMotion.cs:280-290`](../../src/AcDream.Core.Net/Messages/UpdateMotion.cs) keeps only `speed`/`runRate`/flags from the 7-DWORD `MovementParameters` block and the `runRate` trailer, **discarding** `Origin (destination)`, `targetGuid` (type 6 only), `distance_to_object`, `min_distance`, `fail_distance`, `walk_run_threshhold`, and `desired_heading`. Symptoms (live log + user observation 2026-04-28): - **Disappearing**: body frozen at `Velocity=0` while RunForward animation plays; next UpdatePosition teleports body to actual server pose. If the teleport target is outside the visible window, observer sees disappear/reappear. - **Jitter**: when a stale UP-derived velocity exists, body extrapolates along the OLD heading; meanwhile the server is steering the creature on a curve. Each new UP snap-corrects → visible stutter. The fresh MoveTo packet stream (~1 Hz, seq 0x01FE→0x0204 in the live log) IS sending fresh target positions and headings each tick — we're throwing them away. ## Retail behavior (named decomp + ACE port) Sources: - `docs/research/named-retail/acclient_2013_pseudo_c.txt` — citations below - `docs/research/named-retail/acclient.h` — struct definitions - `references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs` - `references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs` ### Wire layout (`MovementParameters::UnPackNet` @ `0x0052ac50`, type 6/7) ``` [uint targetGuid] // type 6 only (MoveToObject) Origin: uint cellId // then 3 floats local x/y/z float x, y, z // destination position MovementParameters (28 bytes, exact retail order): uint flags // bitfield (see below) float distance_to_object // arrival far-bound (ACE default 0.6) float min_distance // arrival near-bound float fail_distance // abort when starting→current >= this float speed // base speed multiplier float walk_run_threshhold // (sic, two h's) — wire default 15.0 float desired_heading // final orientation (radians or degrees) float runRate // CMotionInterp::my_run_rate copy ``` ### MovementParameters bit-flags (declaration order, acclient.h:31423-31443) | Bit | Mask | Name | Meaning | |----:|---------|------|---------| | 0 | 0x00001 | can_walk | gait permission | | 1 | 0x00002 | can_run | gait permission (we already use this for `MoveToCanRun`) | | 2 | 0x00004 | can_sidestep | enables strafe path | | 3 | 0x00008 | can_walk_backwards | gait permission | | 4 | 0x00010 | can_charge | force HoldKey_Run | | 5 | 0x00020 | fail_walk | fail if only walk possible | | 6 | 0x00040 | use_final_heading | append final TurnToHeading after arrival | | 7 | 0x00080 | sticky | MoveToObject only — StickTo on completion | | 8 | 0x00100 | move_away | flee target | | 9 | 0x00200 | move_towards | chase target (chase creatures set this) | | 10 | 0x00400 | use_spheres | use cylinder distance vs straight-line | | 11 | 0x00800 | set_hold_key | apply HoldKeyToApply | | ... | ... | ... | (autonomous, modify_*_state, cancel_moveto, stop_completely, disable_jump) | ### MoveToManager::HandleMoveToPosition (per-tick, `0x00529d80` lines 307187-307440) ``` if physics.motions_pending: cancel any aux turn cmd (let the queued motion complete) else: targetWorld = currentTargetPosition // last server-supplied destination desiredHeading = atan2(targetWorld - body.position) + get_desired_heading(currentCmd) headingDelta = normalize(desiredHeading - body.heading) if |headingDelta| <= 20°: // retail tolerance // ACE adds set_heading(target, true) here (server-tic-rate fudge) cancel any aux turn cmd else: edi = (headingDelta < 180°) ? TurnLeft : TurnRight if edi != auxCommand: _DoMotion(edi) // -> CMotionInterp auxCommand = edi dist = GetCurrentDistance() if CheckProgressMade(dist): if !movingAway and dist <= min_distance: // arrived popHeadNode(); _StopMotion(currentCmd); _StopMotion(auxCommand); BeginNextNode() if movingAway and dist >= distance_to_object: popHeadNode(); ... if !movingAway and Position.distance(starting, current) >= fail_distance: CancelMoveTo(0x3d) // YouChargedTooFar ``` ### Key insight: MoveToManager does NOT touch the body directly Every motion start/stop is dispatched through `CMotionInterp::DoInterpretedMotion` (via `_DoMotion`/`_StopMotion`). The body's actual position evolves via the ordinary physics tick (`PhysicsBody::UpdatePhysicsInternal`). MoveToManager is purely a *planner* sitting above CMotionInterp, deciding *which command* (and which auxiliary turn) the body should be running at any given tick. ## Acdream port — minimum viable subset The server re-emits MoveTo packets ~1 Hz with fresh destinations, so we can skip: - `MoveToObject_Internal` target-tracking (`HandleUpdateTarget`) — server does it - Sticky / `PositionManager::StickTo` - `CheckProgressMade` stall detection — server cancels the move - `fail_distance` / `WeenieError.YouChargedTooFar` — server-side concern - `WeenieObj::OnMoveComplete` callback - Pending-actions queue (only ever 1-2 nodes; we treat each MoveTo packet as a fresh single-step plan) We DO need: 1. **Parser**: extract the discarded fields into `ServerMotionState`. 2. **Per-tick steer**: compute heading-to-destination, turn body orientation toward it (snap when within ±20° per ACE's tic-rate fudge), then *allow* `apply_current_movement` to run — which sets `Body.Velocity` from the active RunForward cycle, oriented along the now-correct heading. 3. **Arrival**: when `dist <= distance_to_object`, switch animation to Ready and clear `ServerMoveToActive`. Server's next MoveTo packet will resume. ## Pseudocode — acdream port ### Parser change (`UpdateMotion.TryParseMoveToPayload`) ``` TryParseMoveToPayload(body, pos, mt, out parsed): if mt == 6: if rem < 4: return false parsed.TargetGuid = ReadU32; pos += 4 if rem < 16: return false parsed.OriginCellId = ReadU32; pos += 4 parsed.OriginX = ReadF32; pos += 4 parsed.OriginY = ReadF32; pos += 4 parsed.OriginZ = ReadF32; pos += 4 if rem < 28: return false parsed.Flags = ReadU32; pos += 4 parsed.DistanceToObject = ReadF32; pos += 4 parsed.MinDistance = ReadF32; pos += 4 parsed.FailDistance = ReadF32; pos += 4 parsed.Speed = ReadF32; pos += 4 parsed.WalkRunThreshold = ReadF32; pos += 4 parsed.DesiredHeading = ReadF32; pos += 4 if rem < 4: return false parsed.RunRate = ReadF32 return true ``` ### Per-tick driver (new `RemoteMoveToDriver` in `AcDream.Core.Physics`) ``` DriveOneTick(rm, dt): if not rm.HasMoveToDestination: return ApplyDefault targetWorld = rm.MoveToDestinationWorld // pre-converted at packet time bodyPos = rm.Body.Position // Distance check first — arrival short-circuits before any heading work dist = horizontalDistance(targetWorld, bodyPos) if dist <= rm.MoveToMinDistance + 0.05 (epsilon for float wobble): rm.HasMoveToDestination = false // animation cycle moves to Ready via the existing // ApplyServerControlledVelocityCycle path on next zero-velocity sample rm.Body.Velocity = Vector3.Zero return Arrived // Heading compute (XY plane; Z untouched — server owns Z) deltaXY = (targetWorld.XY - bodyPos.XY).Normalized desiredHeading = atan2(deltaXY) // radians currentHeading = QuaternionToYaw(rm.Body.Orientation) headingDelta = wrapPi(desiredHeading - currentHeading) // Snap orientation toward target — match ACE's set_heading(target, true) // when within tolerance, otherwise rotate at retail-faithful turn rate. const float tolerance = 20° (in radians) if |headingDelta| <= tolerance: rm.Body.Orientation = QuaternionFromYaw(desiredHeading) else: // retail TurnSpeed default ≈ π/2 rad/s for monsters; clamp by dt float maxStep = TurnRateRadPerSec * dt float step = clamp(headingDelta, -maxStep, +maxStep) rm.Body.Orientation = QuaternionFromYaw(currentHeading + step) // Allow apply_current_movement to set Velocity from RunForward cycle. // The cycle was already seeded by PlanMoveToStart at packet receipt // and is being played by the AnimationSequencer. CMotionInterp's // apply_current_movement reads InterpretedState.ForwardCommand and // sets Body.Velocity = (forward axis of orientation) * RunAnimSpeed * speedMod. return DriveActive // caller now invokes apply_current_movement ``` ### Integration in `GameWindow.OnUpdateMotion` (movementType 6/7 branch) ``` on receipt of MoveTo packet: // existing code already seeds the animation cycle via PlanMoveToStart // NEW: store world-converted destination + thresholds on rmState lbX = (originCellId >> 24) & 0xFF lbY = (originCellId >> 16) & 0xFF origin = ((lbX - liveCenterX) * 192, (lbY - liveCenterY) * 192, 0) rmState.MoveToDestinationWorld = (originX, originY, originZ) + origin rmState.MoveToMinDistance = parsed.MinDistance rmState.MoveToDistanceToObject = parsed.DistanceToObject rmState.HasMoveToDestination = true // ServerMoveToActive remains set; existing ``` ### Integration in per-tick remote update (`GameWindow.cs` ~line 5045) ``` // Replace the current Velocity = Zero hold with: else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination) { var driveResult = RemoteMoveToDriver.DriveOneTick(rm, dt); if driveResult == Arrived: // signal cycle update to Ready via existing path ApplyServerControlledVelocityCycle(serverGuid, ae, rm, Vector3.Zero); else: rm.Body.TransientState |= Contact | OnWalkable | Active rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); } else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) { // No destination yet (very early frame, packet hasn't fully landed) rm.Body.Velocity = Vector3.Zero; } else { rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); } ``` ## Conformance test cases 1. **Parser round-trip — type 7 (MoveToPosition)** - Synthesize a 68-byte body with known origin + 7 params + runRate. - Assert all 9 new fields decode correctly. 2. **Parser round-trip — type 6 (MoveToObject)** - Synthesize a 72-byte body with target guid + origin + params + runRate. - Assert TargetGuid populated and shifts subsequent fields by 4 bytes. 3. **DriveOneTick — heading snap within tolerance** - body at (0,0,0) facing east, destination (10,0,0). - DesiredHeading=0; current=0; |delta|=0 ≤ 20° → snap. - assert orientation unchanged (already correct). 4. **DriveOneTick — heading turn beyond tolerance** - body at (0,0,0) facing east, destination (0,10,0). - desiredHeading=π/2; current=0; |delta|=π/2 > 20°. - dt=0.1s, TurnRate=π/2 → step = π/4 toward target. - assert orientation rotated by π/4 (not full snap). 5. **DriveOneTick — arrival** - body at (0,0,0), destination (0.4,0,0), MinDistance=0.6. - assert HasMoveToDestination cleared and Velocity zeroed. 6. **Bit-flag mapping** (already partially tested via `MoveToCanRun`) - assert flag 0x00200 (move_towards) is detected as `MoveTowards=true`. ## Out of scope (future Phase L.1d if needed) - Sticky / StickTo for MoveToObject completion - `use_final_heading` (post-arrival turn-to-heading) - `fail_distance` early-cancel (server already does this; we just don't flag it) - `CheckProgressMade` stall detector - Strafe / move_away / move_towards-and-away combo (`towards_and_away` helper) - Sphere-cylinder distance (`use_spheres` bit) - `MoveToObject` target-guid resolution — currently we only honor the Origin, which works because the server re-emits with refreshed Origin each tick. If the target is moving fast and the server's emit cadence falls behind, we'd see lag; a future enhancement is to look up the target entity by guid and use its current world position when fresher than Origin.