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>
This commit is contained in:
Erik 2026-04-28 21:49:22 +02:00
parent 882a07cfde
commit 186a584404
7 changed files with 917 additions and 19 deletions

View file

@ -0,0 +1,285 @@
# 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.