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>
19 KiB
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:
- var_40 = identity Frame
- (A)
CPartArray::Update(dt, &var_40)writes the animation-driven delta into var_40 (origin + orientation). - (B)
PositionManager::adjust_offset(&var_40, dt)fans out toInterpolationManager::adjust_offset,StickyManager::adjust_offset,ConstraintManager::adjust_offset, each of which mutates var_40 in-place. - (C) Result frame =
m_position.frame ∘ var_40(rotation composes, then translates). - (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.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 inarg2->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:
-
No StickyManager / ConstraintManager. Currently fine for L.3 (we don't ship sticky-follow yet); flag for L.5+ when combat targeting lands.
-
Single
seqVel * dtper tick instead of per-keyframe. Retail's loop runs once per integer keyframe boundary inside the tick, callingapply_physics(dt = 1/framerate)each time. Our port runs once per tick atdt = 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 discreteFrame::combine(pos_frame_delta)then a discrete velocity bump. We integrate continuously and miss the per-keyframe pos_frame delta entirely. -
pos_framesfrom the dat are completely ignored. Retail'sFrame::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. -
Frame::rotatefromomega·dtis partially handled — ourCurrentOmegasynth covers turn cycles (TurnRight/TurnLeft) andRemoteEntityintegrates 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_speedMULTIPLY throughadd_motion. Retail computesCSequence::velocity = style_speed * MotionData.velocity; our synth usesRunAnimSpeed * adjustedSpeeddirectly. For Humanoid this is correct because the dat'sMotionData.velocityis zero so the multiply is a no-op anyway — but for creatures with nonzeroMotionData.velocity, our synth silently drops that contribution. Filed as future-port concern; currently no observed impact. -
Per-keyframe
pos_framesdeltas (see #3 above). OurCurrentVelocitycarries only the steady-state component of the cycle's intent; the per-frame stride wobble is gone. To capture it we'd need to walkCAnimation.PosFrames[i]and add the keyframe delta on each integer-keyframe-boundary tick — i.e., port the inner loop ofupdate_internalrather 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:
- The env-var path bypasses
ResolveWithTransition(already fixed in commit039149a, per memory). ✓. - The remote body integrates
seqVel * dtper tick smoothly, while broadcasts arrive at ~5 Hz with retail's per-keyframe-discretized advance. Mismatch shows as small +/- Z bob between UPs. pos_framesdeltas ignored — Z stride wobble lost.omegaintegration order vsFrame::combine(pos_frame)— retail doesFrame::combine(pos_frame)BEFOREapply_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.