# 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.
