# Full motion port — master plan

**Date:** 2026-05-04
**Scope:** Every motion-related code path. No bandaids, no workarounds.
**Source research:** all 14 docs in this directory.

---

## Critical reconciliation (research-vs-research)

Two prior research findings appeared to conflict:

- **Agent 02** (UM handling): "Retail does NOT velocity-dead-reckon walking remotes. m_velocityVector stays at zero."
- **Agent 09** (CPartArray): "Retail's actual locomotion comes from `CMotionInterp::get_state_velocity` → `CPhysicsObj::set_velocity` → `CTransition::transitional_insert`, NOT CSequence."
- **Agent 11** (UM dispatch deep): "`CPhysicsObj::DoInterpretedMotion` both pushes velocity (`set_local_velocity(get_state_velocity())`) AND drives the animation sequencer's per-axis cycle slot."

**Reconciliation:** Agent 02 was wrong. `m_velocityVector` IS non-zero for walking remotes — it gets written by `set_local_velocity` inside `DoInterpretedMotion`, which fires per-axis when an UM arrives AND on every per-tick `apply_current_movement` call. The original cdb trace agent 02 cited (`docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`) likely sampled at a moment when `DoInterpretedMotion` had not yet fired (e.g., immediately post-spawn).

**Correct retail model for walking remote per-tick:**

```
1. Frame local = identity                                      // var_40 cached
2. CPartArray::Update writes animation root motion into local
   - Humanoid locomotion (Walk/Run/Sidestep): dat ships zero, so local.origin stays 0
   - Non-locomotion cycles with baked PosFrames: local.origin = baked delta
3. PositionManager::adjust_offset:
   - InterpolationManager::adjust_offset: when distance(body, head) >= 0.05m,
     OVERWRITES local.origin with `direction × min(catchUpSpeed × dt, distance)`
     (catch-up REPLACES animation root motion this frame)
   - StickyManager::adjust_offset: when stuck, writes XY step + heading toward target
   - ConstraintManager::adjust_offset: when leashed and grounded, scales/zeros local.origin
4. Frame::combine(out, m_position.frame, local)
   - out.origin = m_position.origin + rotate(local.origin by m_position.l2gv)
   - out.rotation = m_position.rotation · local.rotation
5. UpdatePhysicsInternal: gated on velocity² > 0
   - out.origin += m_velocityVector × dt + 0.5 × accel × dt²
   - For grounded walking remote: m_velocityVector = walk_pace_world (set per-tick by apply_current_movement → set_local_velocity → get_state_velocity). NON-ZERO.
   - For idle (Ready cycle): get_state_velocity returns 0 → m_velocityVector = 0 → no-op.
6. transition() — sphere sweep from m_position to out
7. set_frame(resolved) — commit
```

**Implication for L.3 port already shipped (commit 91bf1e0):**

The L.3 commit ZEROS `body.Velocity` for player remotes and removes `apply_current_movement` from per-tick. This is **wrong direction**. It causes:
- Body advances only via queue catch-up (no velocity integration). Steady run lags.
- Orientation changes don't propagate (since velocity isn't recomputed per-tick to use latest orientation). Drift during turning.
- Stop case is fine (cycle goes Ready → seqVel=0 → no motion) but drift case is broken.

**Action:** revert the body.Velocity=0 change. Restore per-tick `apply_current_movement` for all remotes. Layer queue catch-up as ADDITIONAL local-frame contribution that REPLACES animation root motion (which is 0 for locomotion anyway). Sequence is exactly as agent 01 documented.

---

## What needs porting (consolidated)

### Phase A: Per-tick pipeline correction (Critical — fixes L.3 regression + drift)

1. **Restore per-tick `apply_current_movement` for all grounded remotes.** Remove the `IsPlayerGuid` zero-velocity branch added in commit 91bf1e0.
2. **Layer InterpolationManager catch-up before Frame::combine.** Local frame = identity, optionally overwritten by adjust_offset. Then composed with m_position. Then velocity integrates additively into the composed frame.
3. **Implement `Frame::combine` semantics correctly.** Out.origin = m_position.origin + rotate(local.origin by m_position.l2gv basis). Out.rotation = m_position.rotation · local.rotation.

### Phase B: Substepping (eliminates ObservedOmega)

4. **Port full `update_object` substepping with EPSILON / MinQuantum / MaxQuantum / HugeQuantum.** Constants: 0.0002s / (1/30)s / 0.1s / 2.0s.
5. **Eliminate manual `ObservedOmega` integration.** Once substepping works, retail's `omega` field on Body is integrated correctly per substep — no need for the formula seed `(π/2 × turnSpeed)`.
6. **Drop `RemoteMotion.ObservedOmega` field.**

### Phase C: UM handling rewrite (collapses 600-line cycle picker)

7. **Port `apply_interpreted_movement` (acclient @ 0x00528600).** Per-axis dispatch: STYLE → FORWARD/Falling → SIDESTEP/StopSideRight → TURN/StopTurnRight+Ready_to_queue.
8. **Port `MotionTableManager`, `CMotionTable`, `MotionState`.** Each remote has its own (style, substate, substate_mod, modifier_head, action_head). Cycle decision per-axis through table lookups, not priority picker.
9. **Port `GetObjectSequence` (@ 0x00522860).** Class-bit dispatch: 0x40000000 substate (replace), 0x20000000 modifier (additive blend + add to modifier_head), 0x10000000 action (overlay with substate restore).
10. **Collapse `OnLiveMotionUpdated` to `move_to_interpreted_state(wireInterpretedState)`.** Drop the 600-line cycle picker (forward → sidestep → turn → Ready priority). Drop the RunForward → WalkForward → Ready fallback chain (acdream-original, doesn't exist in retail).
11. **Port `adjust_motion` (FUN_00528010).** WalkBackward → WalkForward + speed×-0.65; SideStepLeft → SideStepRight + speed×-1. This is sender-side normalization. Local player path needs it too (drops hand-rolled overrides at PlayerMovementController.cs:378-411).

### Phase D: UP routing — full Branch A (hard teleport)

12. **Plumb `TeleportSequence` (u16) from `UpdatePosition` parser through `EntityPositionUpdate` to `OnLivePositionUpdated`.** Currently dropped at WorldSession.cs:110-114, 712-716.
13. **Port `update_times[]` array on RemoteMotion.** 4 slots: instance(8), position(0), teleport(4), force_position(6). Each is `u16` with 16-bit-wrap forward-test via `newer_event` helper.
14. **Implement Branch A:** when `newer_event(TELEPORT_TS, wire.teleport_seq)` OR `cell == 0` → call `teleport_hook` (CancelMoveTo + UnStick + StopInterpolating + UnConstrain + ClearTarget + report_collision_end) + `SetPosition` with flags 0x1012.
15. **Implement Branch B (queue) and Branch C (slide-snap)** correctly — these are ALREADY in commit 91bf1e0 modulo using `player_distance` (already fixed via S1). Keep.

### Phase E: VectorUpdate (jump) corrections

16. **Drop `OnLiveVectorUpdated`'s extra writes** (Gravity flag toggle, transient_state Contact/OnWalkable clear). Retail's `DoVectorUpdate` only writes `set_velocity` + `set_omega`. The Gravity flag transition is driven by `set_on_walkable(false)` 1→0 edge inside the per-tick path (post-LeaveGround).
17. **Port `set_on_walkable` 0→1 / 1→0 edge detection.** Drives LeaveGround/HitGround automatically without explicit Airborne flag.
18. **Drop `RemoteMotion.Airborne` bool.** Replace with `(state & Gravity) != 0` test.
19. **Replace K-fix15 landing heuristic** (Z-velocity-based) with retail's `contact_plane.N.z >= floor_z` (cosine of 49°) gate.
20. **Port `jumped_this_frame` write in `set_velocity`.** Currently missing in PhysicsBody.

### Phase F: Sticky + Constraint (port from scratch)

21. **Port `StickyManager` (acclient ~0x???)** — sticks body to target object at fixed cylinder radius. Activates via MoveToManager when `__inner0` bit 0x80 set. adjust_offset writes XY step + heading rotation; Z always zero.
22. **Port `ConstraintManager`** — leash to fixed Position. adjust_offset gated on Contact bit. Soft band (start..max) scales offset; past max zeros it.
23. **Wire both into `PositionManager.adjust_offset` chain** in fixed order: Interp → Sticky → Constraint.

### Phase G: NPC convergence

24. **Drop `RemoteMotion.HasServerVelocity` / `ServerVelocity` synth machinery.** NPCs go through same per-tick pipeline as player remotes once Phase A lands.
25. **`RemoteMoveToDriver` audit:** add UseFinalHeading (bit 0x40 post-arrival rotation), Sticky-after-arrival (bit 0x80 latch-on), Contact-bit gating during mid-air. Per agent 07 research §"3 minor port gaps".

### Phase H: Local player cleanup (low-priority)

26. **Wire `PlayerDescription` (0xF7B0 / 0x0013) → `_weenie.SetSkills(...)`.** Drops `ACDREAM_RUN_SKILL` env var workaround (issue #7).
27. **Gate `AutonomousPosition` heartbeat on motion.** Don't send 1Hz heartbeat at rest (cdb confirmed retail doesn't).
28. **Apply `adjust_motion` to local player input.** Drops hand-rolled velocity overrides at `PlayerMovementController.cs:378-411` and `:466-501`.

### Phase I: Cleanup

29. **Delete env-var fork** (`ACDREAM_INTERP_MANAGER=1` path, ~430 lines of dead code).
30. **Remove `RemoteMotion` workaround fields:** `ServerVelocity`, `HasServerVelocity`, `LastServerZ`, `PrevServerPos*`, `Max*`, `TargetOrientation`, `ObservedOmega`. Down from 31 fields to ~10.
31. **Remove all `IsPlayerGuid` per-tick gates** (5 sites in audit). Same pipeline for all entities.
32. **Remove `ServerControlledLocomotion.PlanFromVelocity`** if `ApplyServerControlledVelocityCycle` is the only caller (already removed in commit 91bf1e0). Verify other consumers.

### Phase J: Tests + verification

33. **Update existing tests for new architecture.** InterpolationManager tests already updated. Add per-axis dispatch tests for apply_interpreted_movement.
34. **Run full test suite, verify 0 new regressions.**
35. **Code review subagent on full port.**
36. **Visual verify with user — full motion test matrix.**

---

## Implementation order (strict serial)

Bottom-up, each phase must build green before next:

1. **Phase A** (per-tick correction) — undoes the L.3 regression. Smallest, surgical. Highest priority.
2. **Phase D** (hard-teleport branch) — additive plumbing, no regression risk.
3. **Phase B** (substepping) — replaces ObservedOmega. Medium risk.
4. **Phase E** (VectorUpdate corrections) — drops acdream workarounds. Medium risk.
5. **Phase G** (NPC convergence) — depends on Phase A solid.
6. **Phase F** (Sticky + Constraint) — additive ports.
7. **Phase C** (UM handling rewrite + cycle picker) — biggest single change. Replaces 600 lines.
8. **Phase H** (local player cleanup) — independent of others.
9. **Phase I** (cleanup) — last, after everything works.
10. **Phase J** (tests + review + verify) — gating ship.

---

## Acceptance

- All 3 reported user issues fixed:
  1. Stop after running settles within ≤1 UP cycle (~200ms) — apply_current_movement reads Ready → velocity 0 → body stops.
  2. Backward walk plays backward animation (no flip to forward run) — verified by ApplyServerControlledVelocityCycle removal in 91bf1e0.
  3. Long co-run drift bounded by DesiredDistance (0.05m) — apply_current_movement per-tick keeps velocity rotated by current orientation.
- Build green.
- All existing tests pass (8 pre-existing failures unchanged; new tests added for per-axis dispatch).
- Code review passes.
- Visual verify by user.
