acdream/docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.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

19 KiB
Raw Blame History

L.3 port — PositionManager + CPartArray::Update + CSequence root motion

Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept-2013 EoR build, Binary Ninja decomp, PDB-named).

This note pins down where retail's per-tick "animation root motion" actually comes from, what PositionManager::adjust_offset adds on top of it, and exactly what each manager writes into the per-tick Frame.

It exists to settle one question: does retail's CPartArray::Update produce per-keyframe pos-frame deltas (a.k.a. baked root motion in the animation data), or does it integrate CSequence::velocity * dt (a constant-velocity model), or both? The answer is both, in a strict order, and acdream's current C# port only models the second half.


0. Top-level call site — CPhysicsObj::UpdatePositionInternal

@ 0x00512c30 (line 280817):

void __thiscall CPhysicsObj::UpdatePositionInternal(
    class CPhysicsObj* this, float arg2 /* dt */, class Frame* arg3 /* out */)
{
    Frame var_40;                               // 1. local Frame, identity
    Frame::cache(&var_40);                      //    var_40 = identity

    if ((state & 0x4000) == 0) {                // not animation-paused
        if (this->part_array != 0)
            CPartArray::Update(this->part_array, arg2, &var_40);   // (A)
        // ... var_c/var_8/var_4 scaled by m_scale (joint-frame stuff,
        // not the root) ...
    }

    if (this->position_manager != 0)
        PositionManager::adjust_offset(this->position_manager, &var_40, arg2);  // (B)

    Frame::combine(arg3, &this->m_position.frame, &var_40);  // (C)

    if ((state & 0x4000) == 0)
        CPhysicsObj::UpdatePhysicsInternal(this, arg2, arg3); // (D) — sweep/collision
    CPhysicsObj::process_hooks(this);
}

So the per-tick recipe is:

  1. var_40 = identity Frame
  2. (A) CPartArray::Update(dt, &var_40) writes the animation-driven delta into var_40 (origin + orientation).
  3. (B) PositionManager::adjust_offset(&var_40, dt) fans out to InterpolationManager::adjust_offset, StickyManager::adjust_offset, ConstraintManager::adjust_offset, each of which mutates var_40 in-place.
  4. (C) Result frame = m_position.frame ∘ var_40 (rotation composes, then translates).
  5. (D) Sweep/collision (the call we already port as ResolveWithTransition).

var_40 is both origin (m_fOrigin) and orientation (m_angles / Frame::rotate). It is a delta, not a position.


1. CPartArray::Update is a 1-line forwarder

@ 0x00517db0 (line 285883):

void __thiscall CPartArray::Update(class CPartArray* this, float arg2 /* dt */, class Frame* arg3)
{
    CSequence::update(&this->sequence, (double)arg2, arg3);
}

All the work is in CSequence::update.


2. CSequence::update — 1-line gatekeeper

@ 0x00525b80 (line 302402):

void __thiscall CSequence::update(class CSequence* this, double arg2 /* dt */, class Frame* arg3)
{
    if (this->anim_list.head_ != 0) {
        CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3);
        CSequence::apricot(this);   // remove finished non-cyclic anims from list
        return;
    }
    if (arg3 != 0)
        CSequence::apply_physics(this, arg3, arg2 /*dt*/, arg2 /*sign-dt*/);
}

If there are NO animations queued, apply_physics runs once with (dt, dt) and writes velocity·dt into the frame directly. Otherwise the inner loop drives both per-keyframe combine AND apply_physics.


3. CSequence::update_internal — the keyframe loop (THIS IS THE ROOT MOTION SOURCE)

@ 0x005255d0 (line 301839). I'll show the structurally important parts; the FCOMP/FLD ops are FPU translation noise from Binary Ninja and read like English once you ignore them:

Branching on the sign of arg2 (dt — positive = forward, negative = playing the cycle in reverse) the function picks one of two near- identical inner loops.

3a. Forward branch (arg2 ≥ 0) — else block at 0x00525646

// floor(frame_number) → ebx_2 = integer keyframe index
do {
    if (arg5 /*Frame*/ != 0) {
        AnimSequenceNode* node = *arg3;     // current animation node
        if (node->anim->pos_frames != 0) {
            // (A1) MULTIPLY-ACCUMULATE the dat-baked pos-frame for keyframe ebx_2
            //       into the running Frame:
            Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(node, ebx_2));
        }
        // If the animation has nonzero framerate (|fr| > F_EPSILON):
        //   (A2) integrate velocity·omega over the time spent on THIS keyframe
        //         dt_keyframe = 1.0 / framerate
        //         apply_physics(this, arg5, dt_keyframe, arg2_total_dt);
        if (|framerate| > F_EPSILON) {
            double dt_keyframe = 1.0 / framerate;
            CSequence::apply_physics(this, arg5, dt_keyframe, arg2);
        }
    }
    CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(node, ebx_2), 1 /*forward*/);
    ebx_2 += 1;
    // re-test loop: continue while frame_number > ebx_2 (we have more keyframes
    // worth of time-budget to consume this tick).
} while (frame_number > ebx_2);

3b. Backward branch (arg2 < 0) — if block at 0x00525646

Mirror image of the forward branch:

do {
    if (arg5 != 0) {
        AnimSequenceNode* node = *arg3;
        if (node->anim->pos_frames != 0) {
            // (A1') SUBTRACT the dat-baked pos-frame for keyframe ebx_1
            //        (Frame::subtract1, not Frame::combine)
            Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(node, ebx_1));
        }
        if (|framerate| > F_EPSILON) {
            double dt_keyframe = 1.0 / framerate;
            CSequence::apply_physics(this, arg5, dt_keyframe, arg2 /*negative*/);
        }
    }
    CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(node, ebx_1), -1 /*backward*/);
    ebx_1 -= 1;
} while (frame_number < ebx_1);  // walk indices DOWN

When the inner loop completes the time budget, frame_number is updated to the new fractional position and (if the cycle ended) advance_to_next_animation rolls the queue forward.

3c. Special "no time elapsed" path

If |arg2| < F_EPSILON (dt ≈ 0) the function still calls apply_physics(this, arg5, dt /*≈0*/, arg2) once and returns — ensures velocity·0 = 0 and omega·0 = 0 are written even when no keyframe boundary is crossed.

Per-keyframe vs per-tick

The crucial structural fact: the loop runs once per integer keyframe that fits inside the tick's time budget. If an animation runs at 30 fps and we tick at 60 Hz, most ticks consume ZERO keyframes (the loop body never executes); the time accumulates in frame_number until the next keyframe boundary. When a keyframe is crossed, Frame::combine(frame, frame, pos_frame) is invoked AND apply_physics is invoked with dt = 1/framerate (NOT the tick's real dt). Across many ticks this averages to integrating velocity at the cycle's framerate, but on a single tick the integration may be 0 or it may be 1/framerate or it may be N/framerate for fast cycles.

This is important for our port: when our C# code does bodyPos += seqVel * dt per tick at fixed 60 Hz, we are smoothing the retail behavior. That's fine for steady motion but explains why the retail trace shows "stairsteps" of pos updates aligned to keyframe boundaries — it really is per-keyframe.


4. CSequence::apply_physics — the velocity integrator

@ 0x00524ab0 (line 300955):

void __thiscall CSequence::apply_physics(
    class CSequence const* this,
    class Frame* arg2,        // mutated
    double       arg3,        // dt magnitude (always positive in the loop)
    double       arg4)        // sign carrier (positive = forward, negative = backward)
{
    long double scale = fabs((long double)arg3);   // |dt|
    if (arg4 < 0.0)
        scale = -scale;                            // negate for backward play

    arg2->m_fOrigin.x += (float)(scale * this->velocity.x);
    arg2->m_fOrigin.y += (float)(scale * this->velocity.y);
    arg2->m_fOrigin.z += (float)(scale * this->velocity.z);

    Vector3 axisAngle = {
        scale * this->omega.x,
        scale * this->omega.y,
        scale * this->omega.z
    };
    Frame::rotate(arg2, &axisAngle);   // arg2->m_angles = axisAngle ∘ arg2->m_angles
}

So one call writes BOTH translation (origin += scale·velocity) and rotation (Frame::rotate = quat-from-axis-angle ∘ existing). The sign of arg4 determines whether we play forward or backward; the magnitude in arg3 is the dt being integrated (1/framerate per keyframe inside update_internal).

this->velocity is CSequence::velocity (set by add_motion from MotionData::velocity * style_speed). this->omega is CSequence::omega (same source).


5. Where does CSequence::velocity come from?

add_motion @ 0x005224b0 (line 298437):

void add_motion(CSequence* arg1, MotionData* arg2 /*dat-loaded*/, float arg3 /*style_speed*/)
{
    if (arg2 == 0) return;

    Vector3 vel = {
        arg3 * arg2->velocity.x,
        arg3 * arg2->velocity.y,
        arg3 * arg2->velocity.z
    };
    CSequence::set_velocity(arg1, &vel);     // overwrites — not additive

    Vector3 omg = {
        arg3 * arg2->omega.x,
        arg3 * arg2->omega.y,
        arg3 * arg2->omega.z
    };
    CSequence::set_omega(arg1, &omg);

    // append each anim segment (the actual cyclic / link / ack list)
    for (int i = 0; i < arg2->num_anims; i++)
        CSequence::append_animation(arg1,
            operator*(&__return, arg3, &arg2->anims[i]));
}

So the answer to the brief's first set of critical questions:

For locomotion cycles (Walk, Run), is the root motion baked into PosFrames in the animation data, OR computed from MotionData.Velocity?

Both, simultaneously. The retail data ships SOME motions with nonzero MotionData::velocity (which becomes per-keyframe scale·velocity translation through apply_physics) AND/OR with nonzero CAnimation::pos_frames[i] (per-keyframe explicit deltas combined into the frame via Frame::combine). For Humanoid run/walk, ACE's port and our existing diagnostics agree the dat ships HasVelocity = 0, meaning the dat-side MotionData::velocity is zero. The actual per-keyframe pos_frames are also typically tiny (stride wobble) — which is why retail clients ALSO drive CMotionInterp::get_state_velocity (RunAnimSpeed × ForwardSpeed) into CSequence::velocity via a separate path during locomotion. Our synthesized CurrentVelocity in AnimationSequencer.SetCycle (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, etc.) mirrors this exactly.

For idle cycles (Ready), is the root motion zero?

Yes — Ready's MotionData::velocity is zero, and our synthesizer leaves CurrentVelocity at zero for non-locomotion cycles. ✓.

For sign-flipped backward (cycle plays in reverse), is root motion negated?

Yes — apply_physics's arg4 < 0 branch negates scale, so origin delta and rotation delta both flip. Our port handles WalkBackward by going through the MotionInterpreter's adjust_motion remap to WalkForward + speedMod×0.65 (matches retail's actual encoding); the backward keyframe-loop branch is reachable for cyclic anims that genuinely play with negative framerate.


6. PositionManager::adjust_offset — fan-out

@ 0x00555190 (line 352090):

void __thiscall PositionManager::adjust_offset(
    class PositionManager* this, class Frame* arg2 /*the var_40 from above*/, double arg3 /*dt*/)
{
    if (this->interpolation_manager != 0)
        InterpolationManager::adjust_offset(this->interpolation_manager, arg2, arg3);
    if (this->sticky_manager != 0)
        StickyManager::adjust_offset(this->sticky_manager, arg2, arg3);
    if (this->constraint_manager != 0)
        ConstraintManager::adjust_offset(this->constraint_manager, arg2, arg3);
}

ORDER MATTERS. Each manager mutates arg2 in-place.

6a. InterpolationManager::adjust_offset (@ 0x00555d30, line 353071)

This is the head-of-queue catch-up logic the user already agonized over. The behavior:

  • If position_queue is empty → no-op.
  • If transient_state lacks bit 1 → no-op.
  • If queue head has special types 2 or 3 → no-op.
  • If Position::distance(physics_obj, head_target) < 0.05fNodeCompleted(true) and return (arg2 untouched — animation root motion stands).
  • Otherwise:
    • `max_speed = (fUseAdjustedSpeed_ ? get_adjusted_max_speed
      get_max_speed) * 2.0f`.
    • Build a unit direction toward head, scaled by min(max_speed × dt, distance), OVERWRITE arg2->m_fOrigin with that vector. Animation root motion for THIS tick is discarded.

So InterpolationManager::adjust_offset is either a pure pass- through (close-enough) or a REPLACE (overwrite arg2->m_fOrigin). It is NOT additive. Our PositionManager.cs correctly implements this dichotomy in ComputeOffset.

6b. StickyManager::adjust_offset (@ 0x00555430, line 352351)

When sticky-target-id is set and initialized:

  • Compute world-space offset to target (via Position::get_offset), store in arg2->m_fOrigin.
  • Convert to local-space (Position::globaltolocalvec), zero the Z (stay-at-target-altitude only in XY).
  • Distance = cylinder_distance_no_z - 0.30f.
  • If the offset normalized fine: scale it by min(max_speed * dt, |distance|) and write back. (Same movement-budget logic as InterpolationManager but toward a different target.)
  • Then Frame::set_heading(arg2, target_heading current_heading) — i.e., OVERWRITES arg2's heading too.

StickyManager::adjust_offset runs AFTER InterpolationManager so it can REPLACE the interpolation correction. This makes sense: sticky follow-target is a higher-priority constraint than queued node-by-node movement.

6c. ConstraintManager::adjust_offset (@ 0x00556180, line 353479)

When is_constrained != 0 and transient_state & 1:

  • If constraint_pos_offset > constraint_distance_max: zero out arg2->m_fOrigin (clamp to constraint).
  • If constraint_pos_offset > constraint_distance_start: scale arg2->m_fOrigin by (max - offset) / (max - start) (linear ease-out near the cap).
  • Otherwise: leave arg2->m_fOrigin alone.
  • Always: accumulate arg2->m_fOrigin.x into this->constraint_pos_offset (advance the offset tracker).

So Constraint is the only manager that's purely scalar: it scales or zeros arg2->m_fOrigin rather than overwriting it.

Summary of the fan-out

Manager What it writes to arg2 Conditions
InterpolationManager OVERWRITES origin with catch-up vector OR no-op head-of-queue distance > 0.05
StickyManager OVERWRITES origin AND heading with chase-target vector target_id != 0 AND initialized
ConstraintManager SCALES (or zeros) origin, never writes new value is_constrained AND transient bit

If multiple are active at once they compose, but the natural retail case is at most one of (Interp, Sticky) active per object — Sticky is typically used for combat lock / charge-target follows; Interp is the default for queued moveto.


7. Cross-check vs acdream's port

src/AcDream.Core/Physics/PositionManager.cs

acdream's port collapses the entire chain to:

Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
if (correction.LengthSquared() > 0f)
    return correction;

Vector3 rootMotionLocal = seqVel * (float)dt;
return Vector3.Transform(rootMotionLocal, ori);

Divergences from retail:

  1. No StickyManager / ConstraintManager. Currently fine for L.3 (we don't ship sticky-follow yet); flag for L.5+ when combat targeting lands.

  2. Single seqVel * dt per tick instead of per-keyframe. Retail's loop runs once per integer keyframe boundary inside the tick, calling apply_physics(dt = 1/framerate) each time. Our port runs once per tick at dt = tick. Net displacement per second is identical for steady-state running, but the retail trace will show "stairsteps" aligned to keyframe boundaries while ours will show smooth integration. This is probably the cause of the user-reported "staircase" pattern when remotes run up/down slopes — every keyframe boundary, retail does a discrete Frame::combine(pos_frame_delta) then a discrete velocity bump. We integrate continuously and miss the per-keyframe pos_frame delta entirely.

  3. pos_frames from the dat are completely ignored. Retail's Frame::combine(arg5, arg5, get_pos_frame(node, kf)) per keyframe is the dat-baked stride wobble / hand-position-during-cast / etc. For Humanoid locomotion these are small but nonzero — likely ±0.02m wobble plus Z bob. Ignoring them makes our remote bodies glide unnaturally smoothly.

  4. Frame::rotate from omega·dt is partially handled — our CurrentOmega synth covers turn cycles (TurnRight/TurnLeft) and RemoteEntity integrates omega into its quaternion per tick. ✓.

src/AcDream.Core/Physics/AnimationSequencer.cs

Lines 614679 synthesize CurrentVelocity and CurrentOmega for locomotion / turn cycles using the retail RunAnimSpeed=4.0, WalkAnimSpeed=3.12, SidestepAnimSpeed=1.25, omega ±π/2. These constants match _DAT_007c96e0/e4/e8 from the older Ghidra decomp and the named-retail symbols. ✓.

What we DON'T mirror:

  • The MotionData::velocity × style_speed MULTIPLY through add_motion. Retail computes CSequence::velocity = style_speed * MotionData.velocity; our synth uses RunAnimSpeed * adjustedSpeed directly. For Humanoid this is correct because the dat's MotionData.velocity is zero so the multiply is a no-op anyway — but for creatures with nonzero MotionData.velocity, our synth silently drops that contribution. Filed as future-port concern; currently no observed impact.

  • Per-keyframe pos_frames deltas (see #3 above). Our CurrentVelocity carries only the steady-state component of the cycle's intent; the per-frame stride wobble is gone. To capture it we'd need to walk CAnimation.PosFrames[i] and add the keyframe delta on each integer-keyframe-boundary tick — i.e., port the inner loop of update_internal rather than collapsing it to a velocity number.


8. Recommendations for L.3 follow-up

Likely root cause of the remote-run-on-slope staircase regression (env-var path) and the steady-state position blips:

  1. The env-var path bypasses ResolveWithTransition (already fixed in commit 039149a, per memory). ✓.
  2. The remote body integrates seqVel * dt per tick smoothly, while broadcasts arrive at ~5 Hz with retail's per-keyframe-discretized advance. Mismatch shows as small +/- Z bob between UPs.
  3. pos_frames deltas ignored — Z stride wobble lost.
  4. omega integration order vs Frame::combine(pos_frame) — retail does Frame::combine(pos_frame) BEFORE apply_physics, so the pos_frame's heading rotation applies first; we do them in either order depending on caller wiring.

Before implementing more porting, brainstorm with superpowers: brainstorming whether per-keyframe integration is worth porting now (complexity: high; visible impact: stride wobble; user-visibility: probably low) versus accepting the smoothed model and instead tuning the InterpolationManager catch-up thresholds.