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>
11 KiB
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::DoInterpretedMotionboth 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)
- Restore per-tick
apply_current_movementfor all grounded remotes. Remove theIsPlayerGuidzero-velocity branch added in commit 91bf1e0. - 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.
- Implement
Frame::combinesemantics 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)
- Port full
update_objectsubstepping with EPSILON / MinQuantum / MaxQuantum / HugeQuantum. Constants: 0.0002s / (1/30)s / 0.1s / 2.0s. - Eliminate manual
ObservedOmegaintegration. Once substepping works, retail'somegafield on Body is integrated correctly per substep — no need for the formula seed(π/2 × turnSpeed). - Drop
RemoteMotion.ObservedOmegafield.
Phase C: UM handling rewrite (collapses 600-line cycle picker)
- Port
apply_interpreted_movement(acclient @ 0x00528600). Per-axis dispatch: STYLE → FORWARD/Falling → SIDESTEP/StopSideRight → TURN/StopTurnRight+Ready_to_queue. - 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. - Port
GetObjectSequence(@ 0x00522860). Class-bit dispatch: 0x40000000 substate (replace), 0x20000000 modifier (additive blend + add to modifier_head), 0x10000000 action (overlay with substate restore). - Collapse
OnLiveMotionUpdatedtomove_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). - 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)
- Plumb
TeleportSequence(u16) fromUpdatePositionparser throughEntityPositionUpdatetoOnLivePositionUpdated. Currently dropped at WorldSession.cs:110-114, 712-716. - Port
update_times[]array on RemoteMotion. 4 slots: instance(8), position(0), teleport(4), force_position(6). Each isu16with 16-bit-wrap forward-test vianewer_eventhelper. - Implement Branch A: when
newer_event(TELEPORT_TS, wire.teleport_seq)ORcell == 0→ callteleport_hook(CancelMoveTo + UnStick + StopInterpolating + UnConstrain + ClearTarget + report_collision_end) +SetPositionwith flags 0x1012. - 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
- Drop
OnLiveVectorUpdated's extra writes (Gravity flag toggle, transient_state Contact/OnWalkable clear). Retail'sDoVectorUpdateonly writesset_velocity+set_omega. The Gravity flag transition is driven byset_on_walkable(false)1→0 edge inside the per-tick path (post-LeaveGround). - Port
set_on_walkable0→1 / 1→0 edge detection. Drives LeaveGround/HitGround automatically without explicit Airborne flag. - Drop
RemoteMotion.Airbornebool. Replace with(state & Gravity) != 0test. - Replace K-fix15 landing heuristic (Z-velocity-based) with retail's
contact_plane.N.z >= floor_z(cosine of 49°) gate. - Port
jumped_this_framewrite inset_velocity. Currently missing in PhysicsBody.
Phase F: Sticky + Constraint (port from scratch)
- Port
StickyManager(acclient ~0x???) — sticks body to target object at fixed cylinder radius. Activates via MoveToManager when__inner0bit 0x80 set. adjust_offset writes XY step + heading rotation; Z always zero. - Port
ConstraintManager— leash to fixed Position. adjust_offset gated on Contact bit. Soft band (start..max) scales offset; past max zeros it. - Wire both into
PositionManager.adjust_offsetchain in fixed order: Interp → Sticky → Constraint.
Phase G: NPC convergence
- Drop
RemoteMotion.HasServerVelocity/ServerVelocitysynth machinery. NPCs go through same per-tick pipeline as player remotes once Phase A lands. RemoteMoveToDriveraudit: 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)
- Wire
PlayerDescription(0xF7B0 / 0x0013) →_weenie.SetSkills(...). DropsACDREAM_RUN_SKILLenv var workaround (issue #7). - Gate
AutonomousPositionheartbeat on motion. Don't send 1Hz heartbeat at rest (cdb confirmed retail doesn't). - Apply
adjust_motionto local player input. Drops hand-rolled velocity overrides atPlayerMovementController.cs:378-411and:466-501.
Phase I: Cleanup
- Delete env-var fork (
ACDREAM_INTERP_MANAGER=1path, ~430 lines of dead code). - Remove
RemoteMotionworkaround fields:ServerVelocity,HasServerVelocity,LastServerZ,PrevServerPos*,Max*,TargetOrientation,ObservedOmega. Down from 31 fields to ~10. - Remove all
IsPlayerGuidper-tick gates (5 sites in audit). Same pipeline for all entities. - Remove
ServerControlledLocomotion.PlanFromVelocityifApplyServerControlledVelocityCycleis the only caller (already removed in commit 91bf1e0). Verify other consumers.
Phase J: Tests + verification
- Update existing tests for new architecture. InterpolationManager tests already updated. Add per-axis dispatch tests for apply_interpreted_movement.
- Run full test suite, verify 0 new regressions.
- Code review subagent on full port.
- Visual verify with user — full motion test matrix.
Implementation order (strict serial)
Bottom-up, each phase must build green before next:
- Phase A (per-tick correction) — undoes the L.3 regression. Smallest, surgical. Highest priority.
- Phase D (hard-teleport branch) — additive plumbing, no regression risk.
- Phase B (substepping) — replaces ObservedOmega. Medium risk.
- Phase E (VectorUpdate corrections) — drops acdream workarounds. Medium risk.
- Phase G (NPC convergence) — depends on Phase A solid.
- Phase F (Sticky + Constraint) — additive ports.
- Phase C (UM handling rewrite + cycle picker) — biggest single change. Replaces 600 lines.
- Phase H (local player cleanup) — independent of others.
- Phase I (cleanup) — last, after everything works.
- Phase J (tests + review + verify) — gating ship.
Acceptance
- All 3 reported user issues fixed:
- Stop after running settles within ≤1 UP cycle (~200ms) — apply_current_movement reads Ready → velocity 0 → body stops.
- Backward walk plays backward animation (no flip to forward run) — verified by ApplyServerControlledVelocityCycle removal in 91bf1e0.
- 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.