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

491 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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