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>
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=0while 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 belowdocs/research/named-retail/acclient.h— struct definitionsreferences/ACE/Source/ACE.Server/Physics/Animation/MoveToManager.csreferences/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_Internaltarget-tracking (HandleUpdateTarget) — server does it- Sticky /
PositionManager::StickTo CheckProgressMadestall detection — server cancels the movefail_distance/WeenieError.YouChargedTooFar— server-side concernWeenieObj::OnMoveCompletecallback- Pending-actions queue (only ever 1-2 nodes; we treat each MoveTo packet as a fresh single-step plan)
We DO need:
- Parser: extract the discarded fields into
ServerMotionState. - 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_movementto run — which setsBody.Velocityfrom the active RunForward cycle, oriented along the now-correct heading. - Arrival: when
dist <= distance_to_object, switch animation to Ready and clearServerMoveToActive. 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
-
Parser round-trip — type 7 (MoveToPosition)
- Synthesize a 68-byte body with known origin + 7 params + runRate.
- Assert all 9 new fields decode correctly.
-
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.
-
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).
-
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).
-
DriveOneTick — arrival
- body at (0,0,0), destination (0.4,0,0), MinDistance=0.6.
- assert HasMoveToDestination cleared and Velocity zeroed.
-
Bit-flag mapping (already partially tested via
MoveToCanRun)- assert flag 0x00200 (move_towards) is detected as
MoveTowards=true.
- assert flag 0x00200 (move_towards) is detected as
Out of scope (future Phase L.1d if needed)
- Sticky / StickTo for MoveToObject completion
use_final_heading(post-arrival turn-to-heading)fail_distanceearly-cancel (server already does this; we just don't flag it)CheckProgressMadestall detector- Strafe / move_away / move_towards-and-away combo (
towards_and_awayhelper) - Sphere-cylinder distance (
use_spheresbit) MoveToObjecttarget-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.