Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive, Count) so PositionManager + GameWindow callers continue to compile; internals are full retail spec. Bug fixes vs prior port (audit 04-interp-manager.md § 7): #1 progress_quantum accumulates dt (sum of frame deltas), not step magnitude. Retail line 353140; the prior port's `+= step` made the secondary stall ratio meaningless. #3 Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets _failCount = StallFailCountThreshold + 1 = 4, so the next AdjustOffset call's post-stall check fires an immediate blip-to- tail snap. Retail line 352944. Prior port silently drifted toward far targets at catch-up speed instead of teleporting. #4 Secondary stall test ports the retail formula verbatim: cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE. Audit notes the units are 1/sec (likely Turbine bug or x87 FPU misread by Binary Ninja) — mirrored byte-for-byte regardless. #5 Tail-prune is a tail-walking loop, not a single-tail compare. Multiple consecutive stale tail entries within DesiredDistance (0.05 m) of the new target collapse together. Retail line 352977. #6 Cap-eviction at the HEAD when count reaches 20 (already correct in the prior port; verified). New API: Enqueue gains an optional `currentBodyPosition` parameter so the far-branch detection can reference the body when the queue is empty. Backward-compatible (default null = pre-far-branch behavior). UseTime collapsed into AdjustOffset's tail (post-stall blip check) since acdream has no per-tick UseTime call separate from adjust_offset; identical semantic outcome. State fields renamed to retail names with sentinel values: _frameCounter, _progressQuantum, _originalDistance (init = 999999f sentinel per retail line 0x00555D30 ctor), _failCount. Tests: - 17/17 InterpolationManagerTests green. - New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset pins the bug #3 fix: enqueueing 150 m away triggers a same-tick blip (delta length ≈ 150 m), and the queue clears. Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/. 00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick, 03-up-routing, 04-interp-manager, 05-position-manager-and-partarray, 06-acdream-audit, 14-local-player-audit are the L.3 spec used by this commit and the M2 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
11 KiB
Markdown
150 lines
11 KiB
Markdown
# 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.
|