acdream/docs/research/2026-05-04-l3-port/00-master-plan.md
Erik de129bc164 feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
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>
2026-05-05 14:56:42 +02:00

11 KiB
Raw Blame History

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_velocityCPhysicsObj::set_velocityCTransition::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)

  1. Port full update_object substepping with EPSILON / MinQuantum / MaxQuantum / HugeQuantum. Constants: 0.0002s / (1/30)s / 0.1s / 2.0s.
  2. 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).
  3. Drop RemoteMotion.ObservedOmega field.

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

  1. Port apply_interpreted_movement (acclient @ 0x00528600). Per-axis dispatch: STYLE → FORWARD/Falling → SIDESTEP/StopSideRight → TURN/StopTurnRight+Ready_to_queue.
  2. 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.
  3. Port GetObjectSequence (@ 0x00522860). Class-bit dispatch: 0x40000000 substate (replace), 0x20000000 modifier (additive blend + add to modifier_head), 0x10000000 action (overlay with substate restore).
  4. 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).
  5. 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)

  1. Plumb TeleportSequence (u16) from UpdatePosition parser through EntityPositionUpdate to OnLivePositionUpdated. Currently dropped at WorldSession.cs:110-114, 712-716.
  2. 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.
  3. 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.
  4. 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

  1. 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).
  2. Port set_on_walkable 0→1 / 1→0 edge detection. Drives LeaveGround/HitGround automatically without explicit Airborne flag.
  3. Drop RemoteMotion.Airborne bool. Replace with (state & Gravity) != 0 test.
  4. Replace K-fix15 landing heuristic (Z-velocity-based) with retail's contact_plane.N.z >= floor_z (cosine of 49°) gate.
  5. Port jumped_this_frame write in set_velocity. Currently missing in PhysicsBody.

Phase F: Sticky + Constraint (port from scratch)

  1. 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.
  2. Port ConstraintManager — leash to fixed Position. adjust_offset gated on Contact bit. Soft band (start..max) scales offset; past max zeros it.
  3. Wire both into PositionManager.adjust_offset chain in fixed order: Interp → Sticky → Constraint.

Phase G: NPC convergence

  1. Drop RemoteMotion.HasServerVelocity / ServerVelocity synth machinery. NPCs go through same per-tick pipeline as player remotes once Phase A lands.
  2. 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)

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

Phase I: Cleanup

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

Phase J: Tests + verification

  1. Update existing tests for new architecture. InterpolationManager tests already updated. Add per-axis dispatch tests for apply_interpreted_movement.
  2. Run full test suite, verify 0 new regressions.
  3. Code review subagent on full port.
  4. 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.