# 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.