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>
25 KiB
09 — CPartArray::Update + CSequence::update / update_internal / apply_physics / add_motion
Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR
build, Binary Ninja pseudo-C with PDB names applied).
PDB symbol-name collisions you will see in the pseudo-C below. Several AnimSequenceNode getters were name-collapsed into mangled stubs. From the retail header
acclient.hthe struct is{ CAnimation* anim; float framerate; int low_frame; int high_frame; }. The Binary-Ninja pseudo-C shows them as:
Pseudo-C name Real symbol MD_Data_Fade::GetDuration(node)AnimSequenceNode::get_framerate(node)EffectInfoRegion::GetStat(node)AnimSequenceNode::get_high_frame(node)Attribute2ndInfoRegion::GetStat(node)AnimSequenceNode::get_low_frame(node)Verified by struct layout match (
framerateis the first float field,low_framethenhigh_frame) and by the call-site context (these are read exactly where the per-keyframe loop needs the framerate, end frame, start frame).
1. CPartArray::Update — 0x00517DB0
// 00517db0
void __thiscall CPartArray::Update(class CPartArray* this, float arg2, class Frame* arg3)
{
// 00517dc2
CSequence::update(&this->sequence, (double)arg2, arg3);
}
It is literally a one-line forwarder. All animation behavior lives in
CSequence. The caller is CPhysicsObj::UpdatePositionInternal at
0x00512c95, passing dt as arg2 and a Frame* to receive root-motion
displacement (pos_frames deltas + velocity*dt). The same forwarder is also
called from 0x00513e97 inside set_state_to_starting_frame for placement.
There is no per-part loop here — CPartArray keeps a single shared CSequence
that drives the root frame; per-part bone frames come from
CSequence::get_curr_animframe() later, applied in
Frame::combine(this->parts[esi]->pos.frame, parentFrame, anim_frame->frame[i])
inside CPartArray::DoLocalToParent (different code path).
2. CSequence::update — 0x00525B80
// 00525b80
void __thiscall CSequence::update(class CSequence* this, double arg2 /*dt*/, class Frame* arg3)
{
// 00525b88
if (this->anim_list.head_ != nullptr) {
// 00525ba3
CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3);
// 00525baa
CSequence::apricot(this); // garbage-collect drained link nodes
return;
}
// No animations on the sequence — degenerate path: still integrate the
// raw velocity * dt onto the frame. (Used by physics-only objects with a
// CSequence carrying only velocity/omega and no animation list.)
// 00525bb9
if (arg3 != nullptr) {
// 00525bca
CSequence::apply_physics(this, arg3, /*dt=*/arg2, /*sign=*/arg2);
}
}
Two key observations:
- The "no animations" branch (
anim_list.head_ == 0) is the only path that callsapply_physicswith the wholedt. With animations present, the per-keyframe path scales by1/framerate(see §4 below). apricot()walks the doubly-linked list, deletes any link node prior tocurr_animthat has been fully drained, and trims the list down to the cyclic head. This is how transition links (e.g. WalkForward → RunForward) "fall off" once consumed.
3. CSequence::update_internal — 0x005255D0 (the per-keyframe loop)
This is the heart of retail animation. It is the function whose literal-pseudocode reading is required for L.3 of the motion port. The disassembly is x87-FPU-heavy with synthesised compare flags; the algorithm below is the reconstructed control flow.
Signature
// 005255d0
void __thiscall CSequence::update_internal(
class CSequence const* this,
double arg2, // dt (seconds)
class AnimSequenceNode** arg3, // in/out: &this->curr_anim
double* arg4, // in/out: &this->frame_number (fractional)
class Frame* arg5 // out: accumulator for root-motion displacement
);
Algorithm (reconstructed)
while (true) { // 005255e8
// -- per-tick framerate scaling ----------------------------------------
framerate = AnimSequenceNode::get_framerate(*arg3); // 005255e8 (PDB-collision: MD_Data_Fade::GetDuration)
delta = framerate * arg2; // signed; negative = reverse play // 005255f1
int start_frame_int = (int)floor(*arg4); // 00525607
bool wrapped = false; // var_30_1 // 005255ff
int ebx = start_frame_int; // 0052561b
*arg4 = *arg4 + delta; // advance fractional frame_number // 00525635 (fadd / fstp)
if (delta >= 0.0) { // forward play branch // 00525646
// ----- FORWARD --------------------------------------------------
end_int = AnimSequenceNode::get_high_frame(*arg3); // 005257f5 (PDB-collision: EffectInfoRegion::GetStat)
if (*arg4 > (double)end_int) { // 00525806
// overflowed past the cycle's end — clamp + record overflow time
float left_in_cycle = (float)end_int - (float)start_frame_int; // 0052580f / 00525817
if (fabs(framerate) >= F_EPSILON) { // 00525841
overflow_time = left_in_cycle / framerate; // 00525843
// var_18_1
} else {
overflow_time = 0.0; // 0052584d
}
*arg4 = (double)AnimSequenceNode::get_high_frame(*arg3); // 00525866 / fild / fstp
wrapped = true; // 0052586e (var_30_1 = 1)
}
// -- per-keyframe loop: walk every integer frame just stepped on --
// ebx is the frame we are LEAVING; it is decremented in the loop
// body BEFORE the iteration cmp. (Retail counts down because a
// forward step crossing N integer frames must apply the deltas in
// reverse: subtract1(prev_frame's posFrame) to back out, then
// combine(curr_frame's posFrame) to step in. See loop body.)
while ((double)ebx > *arg4 - 1.0) { /* equivalent to: while floor(*arg4) > ebx */
if (arg5 != nullptr) {
node = *arg3;
if (node->anim->pos_frames != 0) { // 005258b1
Frame::subtract1(arg5, arg5,
AnimSequenceNode::get_pos_frame(node, ebx)); // 005258be
}
if (fabs(framerate) >= F_EPSILON) { // 005258d3
// apply_physics with dt = (1.0 / framerate) and same sign
// as outer dt — i.e. EXACTLY ONE keyframe duration of
// velocity*dt is integrated per crossed integer.
CSequence::apply_physics(this, arg5,
/*frame_dt =*/ 1.0 / framerate, // 005258d8 / 005258e1
/*sign =*/ arg2); // 005258f8
}
}
CSequence::execute_hooks(this,
AnimSequenceNode::get_part_frame(*arg3, ebx),
/*direction=*/ 0xFFFFFFFF); // 0052590c (-1 = forward)
ebx -= 1; // 00525916
}
} else { // reverse play branch // 00525646 else (negative delta)
// ----- REVERSE --------------------------------------------------
start_int = AnimSequenceNode::get_low_frame(*arg3); // 0052566a (PDB-collision: Attribute2ndInfoRegion::GetStat)
if (*arg4 <= (double)start_int - 1.0) { // 0052567b (fild + fsubr 1.0 + fcom 0.0)
float left_in_cycle = (float)start_int - (float)start_frame_int;
if (fabs(framerate) >= F_EPSILON) {
overflow_time = left_in_cycle / framerate; // 005256be
} else {
overflow_time = 0.0; // 005256c8
}
*arg4 = (double)AnimSequenceNode::get_low_frame(*arg3); // 005256e1
wrapped = true; // 005256e9
}
// -- per-keyframe loop: same idea, opposite direction ------------
int ebx2 = ebx; /* var_2c_1 = ebx (saved at 0052561d) */
while (/* floor(*arg4) < ebx2 */) { // 00525714
if (arg5 != nullptr) {
node = *arg3;
if (node->anim->pos_frames != 0) { // 00525731
Frame::combine(arg5, arg5,
AnimSequenceNode::get_pos_frame(node, ebx2)); // 0052573e
}
if (fabs(framerate) >= F_EPSILON) { // 00525753
CSequence::apply_physics(this, arg5,
/*frame_dt=*/ 1.0 / framerate, // 00525758 / 00525761
/*sign =*/ arg2); // 00525778
}
}
CSequence::execute_hooks(this,
AnimSequenceNode::get_part_frame(*arg3, ebx2),
/*direction=*/ 1); // 0052578c (+1 = backward)
ebx2 += 1; // 00525796
}
}
// -- end-of-tick: did we wrap? ----------------------------------------
if (!wrapped) return; // 00525943 / 005259ca
// We hit the cycle boundary. Notify hook-target if we just consumed a
// non-cyclic link node, then advance to the next animation in the list.
if (this->hook_obj != nullptr) { // 0052594e
anim_list_head = ((char*)this->anim_list.head_) - 4;
if (anim_list_head != this->first_cyclic) {
CPhysicsObj::add_anim_hook(this->hook_obj, &anim_done_hook); // 00525968
}
}
// arg2 (the outer dt) is rewritten here to "overflow_time" so the loop
// reschedules from the new node.
CSequence::advance_to_next_animation(this, arg2 /*now overflow_time*/,
arg3, arg4, arg5); // 0052597d
arg2 = 0; *(arg2 + 4) = 0; /* reset outer dt accumulator */ // 0052598a / 0052598d
// …then `while (true)` again and run another iteration of the chain.
}
What that means in plain English
Per call to update:
- Take the current dt and multiply by
framerate(signed, decompresses to the dat'sAnimData::framerate * speedModperoperator*at00525d00). - Add to
frame_number(the fractional cursor). - Walk every integer keyframe boundary the cursor just crossed:
- Forward crossing: subtract the just-left keyframe's
pos_frameout of the displacement accumulator (it's already been baked in the animation pose, so don't double-count it as root motion). Run hooks with direction-1. - Reverse crossing: combine (add) the just-left keyframe's
pos_frameback in. Run hooks with direction+1. - In both directions, integrate
velocity * (1 / framerate) * sign(dt)onto the displacement frame viaapply_physics. This is one keyframe's worth of velocity per crossed boundary — notvelocity * dt.
- Forward crossing: subtract the just-left keyframe's
- If the cursor went past the cycle's end (forward) or before its start (reverse), clamp to the boundary, compute the leftover dt, advance to the next AnimSequenceNode in the linked list, and loop again with the leftover.
4. CSequence::apply_physics — 0x00524AB0
// 00524ab0
void __thiscall CSequence::apply_physics(class CSequence const* this,
class Frame* arg2 /*frame*/,
double arg3 /*frame_dt = 1.0/framerate*/,
double arg4 /*sign carrier = outer dt*/)
{
// 00524ab7..00524ac1
long double dt = fabs((long double)arg3);
// 00524ac8..00524aca: if (sign(arg4) < 0) dt = -dt;
// I.e. the magnitude comes from arg3 (1/framerate, always positive),
// the sign comes from arg4 (outer dt). Reverse playback flips the sign.
if (arg4 < 0.0) dt = -dt;
// 00524af1..00524b05: integrate world-space velocity onto the frame.
arg2->m_fOrigin.x += (float)(dt * (long double)this->velocity.x);
arg2->m_fOrigin.y += (float)(dt * (long double)this->velocity.y);
arg2->m_fOrigin.z += (float)(dt * (long double)this->velocity.z);
// 00524b0f..00524b29: build (omega.x*dt, omega.y*dt, omega.z*dt) on stack.
Vector3 axis = { (float)(dt * (long double)this->omega.x),
(float)(dt * (long double)this->omega.y),
(float)(dt * (long double)this->omega.z) };
// 00524b2d
Frame::rotate(arg2, &axis);
}
So apply_physics integrates the CSequence's stored velocity/omega, which
were set by add_motion (§5 below). The crucial structural detail repeated:
arg3 is 1.0 / framerate, NOT dt. update_internal calls
apply_physics once per crossed integer keyframe; over a full cycle this
sums to exactly velocity * (cycle_duration_in_seconds).
5. add_motion — 0x005224B0 (the velocity producer)
add_motion is the writer that populates CSequence::velocity and
CSequence::omega. Called from the motion-table machinery
(get_seq_animations chain) when a new MotionData is enqueued.
// 005224b0
void add_motion(class CSequence* arg1, class MotionData* arg2, float arg3 /*speedMod*/)
{
if (arg2 == nullptr) return;
// Velocity = MotionData.velocity * speedMod (componentwise)
// 005224d1..005224f8
Vector3 v = { arg2->velocity.x * arg3,
arg2->velocity.y * arg3,
arg2->velocity.z * arg3 };
CSequence::set_velocity(arg1, &v);
// Omega = MotionData.omega * speedMod
// 0052250f..0052252f
Vector3 w = { arg2->omega.x * arg3,
arg2->omega.y * arg3,
arg2->omega.z * arg3 };
CSequence::set_omega(arg1, &w);
// For every AnimData in MotionData.anims, append it to the sequence
// with framerate scaled by speedMod (operator*(AnimData, float, AnimData)
// at 00525d00 builds the scaled copy: low/high frame copied verbatim,
// framerate multiplied, anim_id copied).
// 00522537..00522573
for (int i = 0; i < arg2->num_anims; ++i) {
AnimData scaled;
operator*(&scaled, arg3, &arg2->anims[i]);
CSequence::append_animation(arg1, &scaled);
SetPositionStruct::~SetPositionStruct(&scaled);
}
}
set_velocity (0x00524880) and set_omega (0x005248A0) are 3-float
overwrites — assignment, not accumulation. So whenever a new MotionData
is added, prior velocity/omega are clobbered.
(combine_motion at 0x00522580 / subtract_motion at 0x00522600 are the
accumulating variants — they call combine_physics / subtract_physics
instead — used elsewhere for additive blends.)
What this produces for Humanoid Walk/Run
This is the dispositive answer to the L.3 mystery:
"For Humanoid Walk/Run cycles where dat ships zero baked velocity, what does add_motion produce?"
Zero. MotionData.velocity for the Humanoid Walk and Run motion-table
entries is (0,0,0) (verified by acdream's own
AnimationSequencer.SetCycle() comment block at
src/AcDream.Core/Physics/AnimationSequencer.cs:579-613). add_motion
multiplies that zero by speedMod and writes zero into
CSequence::velocity. apply_physics therefore integrates zero translation
per keyframe step. The dat-baked pos_frames array on each animation
also has zero translation per frame for the Humanoid run cycle (cycles
in place — root motion is synthesised, not baked).
So update_internal's root-motion accumulator (arg5) ends the call
unchanged for a Humanoid run/walk cycle. Retail does NOT produce body
translation from CSequence::update. Body translation comes from a
SEPARATE source: CMotionInterp::get_state_velocity at 0x00528960,
which returns RunAnimSpeed × ForwardSpeed (or WalkAnimSpeed,
SidestepAnimSpeed) as a hard-coded constant looked up from
_DAT_007c96e0/e4/e8. That value is fed into CPhysicsObj::set_velocity
upstream, then CTransition::transitional_insert integrates it across the
swept-sphere collision pipeline.
For non-locomotion cycles (emotes, attacks, idle, jump): MotionData.velocity
may be non-zero (e.g. jump's vertical impulse) AND/OR the animation's
pos_frames array may contain baked deltas (e.g. attack lunges). Both
sources flow through the same update_internal loop above.
6. CSequence::velocity / omega / framerate accessors
There are no getter functions; the fields are read directly off the struct
(see acclient.h line 30751 / 30752 / 30754). For reference:
// 00524880 set_velocity — pure assignment of 3 floats
// 005248a0 set_omega — pure assignment of 3 floats
// 005248c0 combine_physics (additive: velocity += rhs, omega += rhs)
// 00524900 subtract_physics (additive: velocity -= rhs, omega -= rhs)
// 00524940 multiply_cyclic_animation_fr — for-each-cyclic-node node->framerate *= arg2
// 00525be0 AnimSequenceNode::multiply_framerate — node.framerate *= arg2 (single)
framerate lives on each AnimSequenceNode, not on CSequence. It is
already pre-multiplied by speedMod at the time add_motion runs (via
the operator*(AnimData, float) constructor at 0x00525D00 —
new_framerate = old_framerate * speedMod). Negative speedMod produces
negative framerate, which update_internal reads as the reverse-play
branch — there is no separate "play backward" flag, just the sign of
framerate.
get_starting_frame (0x00525C80) and get_ending_frame (0x00525CB0)
encode this: when framerate < 0, "start" returns high_frame + 1 and
"end" returns low_frame; when framerate >= 0, "start" returns
low_frame and "end" returns high_frame + 1. This is why the
forward-vs-reverse branches in update_internal use opposite boundaries.
7. Frame::combine / Frame::subtract1 — the per-keyframe pos_frame applicator
AnimSequenceNode::get_pos_frame(node, frame_index) at 0x00525C10:
class AFrame* get_pos_frame(int frame_index) {
CAnimation* anim = this->anim;
if (anim != nullptr && frame_index >= 0 && frame_index < anim->num_frames)
return ((AFrame*)((char*)anim->pos_frames + 0x1C * frame_index)); // sizeof(AFrame)=0x1C
return nullptr;
}
Frame::combine(Frame* result, Frame const* lhs, AFrame const* rhs)
at 0x00525180 (3-arg variant) — concatenates two transforms (result = lhs ∘ rhs). Frame::subtract1 at 0x00535520 is the inverse (result = lhs ∘ rhs⁻¹). Both are pure 4×4-equivalent rigid-body composition; nothing
animation-specific.
8. AnimSequenceNode::get_pos_frame — 0x00525C10
(See §7 — same function, used as a getter.)
9. Per-keyframe loop summary (the answer to the L.3 critical question)
"What does the per-keyframe loop look like exactly?"
Per crossed integer frame boundary (forward branch shown; reverse mirrors):
// 005258a5..00525917, per integer keyframe just crossed forward:
if (arg5 /*displacement frame*/) {
if (curr_node->anim->pos_frames) {
// Subtract the LEAVING keyframe's baked offset out of the running
// displacement accumulator. (We already advanced fractional frame
// past it; the pose for this frame is already where it should be in
// local space.)
Frame::subtract1(arg5, arg5,
AnimSequenceNode::get_pos_frame(curr_node, ebx));
}
if (fabs(framerate) >= F_EPSILON) {
// Integrate exactly ONE keyframe-duration of CSequence::velocity
// and CSequence::omega onto arg5.
CSequence::apply_physics(this, arg5, 1.0 / framerate, sign_of(outer_dt));
}
}
CSequence::execute_hooks(this,
AnimSequenceNode::get_part_frame(curr_node, ebx), -1);
ebx -= 1; // walk to next-older crossed frame
In plain English: for every integer frame boundary the fractional cursor
just crossed, apply (velocity × keyframe_period) of "free" displacement and
also stitch baked posframe deltas to keep the cycle's per-frame in-place
loop registered. Across a full cycle, the velocity contribution is
exactly velocity * cycle_duration. The framerate scaling is what keeps
the body moving the same world-space distance per real-time second
regardless of how the cycle is divided into frames.
10. Cross-reference: acdream's port
src/AcDream.Core/Physics/AnimationSequencer.cs (1455 lines):
- Per-keyframe loop is structurally correct (lines 766–846): it walks
lastFrameacross crossed integer boundaries, callsApplyPosFrameforward/reverse, fires hooks. Wrap-and-overflow logic mirrors retail'sadvance_to_next_animation. - Critical gap — apply_physics is missing. The retail per-keyframe loop
applies
CSequence::velocity * (1/framerate)in addition to the posFrame delta. acdream'sApplyPosFrame(line 1288) only applies theposFrames[frameIndex]and skips theapply_physics(velocity, 1/framerate)step. That's harmless for Humanoid run/walk because their dat velocity is zero, but it is wrong for any non-locomotion cycle that usesMotionData.velocity(jump impulse, knock-back, flying creatures). - acdream adds a synth path (lines 614–650) that overwrites
CSequence.CurrentVelocitywithRunAnimSpeed * speedModfor locomotion cycles. That value is consumed externally byCMotionInterp.get_state_velocityfor body translation — which is retail's actual locomotion path (see §5 closing note). So acdream's end-to-end behavior matches retail for Humanoid locomotion despite the missingapply_physicsinside the sequencer, because both paths bypass it. - Summary for L.3: the sequencer's
apply_physicsintegration is dead code for Humanoid locomotion (dat velocity = 0). Porting it faithfully is required for jump/emote/flying-creature root motion but does not affect the run/walk-cycle bug L.3 is investigating. The bug must lie upstream inCMotionInterp::get_state_velocityconsumption or in the per-tick path that re-feeds CSequence's velocity vs. theCTransition::transitional_insertbody sweep.
11. Files touched / line citations
All retail line numbers refer to
docs/research/named-retail/acclient_2013_pseudo_c.txt:
| Function | Pseudo-C line | Address |
|---|---|---|
CPartArray::Update |
285883 | 0x00517DB0 |
CSequence::update |
302402 | 0x00525B80 |
CSequence::update_internal |
301839 | 0x005255D0 |
CSequence::advance_to_next_animation |
301622 | 0x005252B0 |
CSequence::apply_physics |
300955 | 0x00524AB0 |
CSequence::set_velocity |
300798 | 0x00524880 |
CSequence::set_omega |
300808 | 0x005248A0 |
CSequence::combine_physics |
300818 | 0x005248C0 |
CSequence::subtract_physics |
300832 | 0x00524900 |
CSequence::execute_hooks |
300780 | 0x00524830 |
CSequence::apricot |
300978 | 0x00524B40 |
add_motion |
298437 | 0x005224B0 |
combine_motion |
298472 | 0x00522580 |
subtract_motion |
298492 | 0x00522600 |
AnimSequenceNode::get_pos_frame (int) |
302447 | 0x00525C10 |
AnimSequenceNode::get_part_frame |
302460 | 0x00525C40 |
AnimSequenceNode::get_starting_frame |
302483 | 0x00525C80 |
AnimSequenceNode::get_ending_frame |
302501 | 0x00525CB0 |
AnimSequenceNode::multiply_framerate |
302425 | 0x00525BE0 |
operator*(AnimData, float, AnimData) |
302531 | 0x00525D00 |
Header struct CSequence |
acclient.h:30747 | — |
Header struct AnimSequenceNode |
acclient.h:31063 | — |
Header struct CPartArray |
acclient.h:30762 | — |
acdream files cross-referenced:
src/AcDream.Core/Physics/AnimationSequencer.cs(lines 570–650 synth-velocity, 766–846 per-keyframe loop, 1236–1330 advance/posFrame application).