acdream/docs/research/2026-04-28-remote-moveto-pseudocode.md
Erik 186a584404 feat(anim): Phase L.1c port MoveTo path data + per-tick steer
Root-causing the user-reported "monsters disappearing some time +
laggy/jittery locomotion" via systematic-debugging Phase 1: our
UpdateMotion parser kept only speed/runRate/flags from a movementType
6/7 packet and discarded Origin (destination), targetGuid, and the
distance/walkRunThreshold/desiredHeading half of MovementParameters.
The integrator consequently held Body.Velocity at zero during MoveTo
("incomplete state" stabilizer 882a07c), so the body froze with legs
animating until UpdatePosition snap-teleported it — sometimes outside
the visible window (disappearing) — and constant-velocity drift along
the old heading between snaps produced jitter on every UP correction.

The 882a07c stabilizer was deliberately conservative because the state
WAS incomplete. Completing the data plumbing makes its restriction
moot: with the full MoveTo payload captured, the body solver has every
field retail's MoveToManager::HandleMoveToPosition (0x00529d80) reads.

Why: server re-emits MoveTo packets ~1 Hz with refreshed Origin while
chasing — verified in the live log (guid 0x800003B5 seq 0x01FE→0x0204
all show different cell/xyz floats). Those are heading updates we'd
been throwing away. With the full payload retained, the per-tick driver
steers body orientation toward Origin (±20° snap tolerance, π/2 rad/s
turn rate above tolerance) and lets apply_current_movement fill in
Velocity from the existing RunForward cycle — no new motion path,
just the right heading.

Scope is the minimum viable subset: target re-tracking, sticky/StickTo,
fail-distance progress detector, and sphere-cylinder distance are
server-side concerns we don't need (server's emit cadence handles all
of them). MoveToObject_Internal target-guid resolution is also skipped
— Origin is refreshed each packet, so the effective target tracks the
real entity even without a guid lookup.

Cross-references:
- docs/research/named-retail/acclient_2013_pseudo_c.txt — MoveToManager
  + MovementParameters::UnPackNet (0x0052ac50) + apply_run_to_command
  (0x00527be0). 18,366 named PDB symbols make this the primary oracle.
- references/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.cs
  — port aid; flagged divergences (WalkRunThreshold default, set_heading
  snap, inRange one-shot) called out in the new pseudocode doc.
- docs/research/2026-04-28-remote-moveto-pseudocode.md — pseudocode +
  ACE divergence flags + out-of-scope list per CLAUDE.md mandatory
  workflow (decompile → cross-reference → pseudocode → port).

Tests: 1404 → 1412 (parser type-7 path retention + type-6 target guid
retention; driver arrival, in-tolerance snap, beyond-tolerance step,
behind-target shortest-path turn, arrival preserves orientation,
Origin→world landblock-grid arithmetic).

Pending visual sign-off — handoff stabilizer 882a07c was the last
commit the user tested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:49:22 +02:00

13 KiB

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