# 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):

```c
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):

```c
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):

```c
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

```c
// 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:

```c
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):

```c
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):

```c
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):

```c
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.05f` →
  `NodeCompleted(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:

```csharp
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 614–679 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.
