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>
526 lines
25 KiB
Markdown
526 lines
25 KiB
Markdown
# 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.h` the 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 (`framerate` is the first float field,
|
||
> `low_frame` then `high_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`
|
||
|
||
```cpp
|
||
// 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`
|
||
|
||
```cpp
|
||
// 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:
|
||
|
||
1. The "no animations" branch (`anim_list.head_ == 0`) is the only path that
|
||
calls `apply_physics` with the **whole `dt`**. With animations present, the
|
||
per-keyframe path scales by `1/framerate` (see §4 below).
|
||
2. `apricot()` walks the doubly-linked list, deletes any link node prior to
|
||
`curr_anim` that 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
|
||
|
||
```cpp
|
||
// 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)
|
||
|
||
```cpp
|
||
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`:
|
||
|
||
1. Take the current dt and multiply by `framerate` (signed, decompresses to
|
||
the dat's `AnimData::framerate * speedMod` per `operator*` at `00525d00`).
|
||
2. Add to `frame_number` (the fractional cursor).
|
||
3. Walk every integer keyframe boundary the cursor just crossed:
|
||
- **Forward** crossing: subtract the *just-left* keyframe's `pos_frame`
|
||
out 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_frame` back in. Run hooks with direction `+1`.
|
||
- In **both** directions, integrate `velocity * (1 / framerate) * sign(dt)`
|
||
onto the displacement frame via `apply_physics`. This is **one
|
||
keyframe's worth of velocity per crossed boundary** — not `velocity * dt`.
|
||
4. 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`
|
||
|
||
```cpp
|
||
// 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.
|
||
|
||
```cpp
|
||
// 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:
|
||
|
||
```cpp
|
||
// 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`:
|
||
|
||
```cpp
|
||
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):
|
||
|
||
```cpp
|
||
// 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
|
||
`lastFrame` across crossed integer boundaries, calls `ApplyPosFrame`
|
||
forward/reverse, fires hooks. Wrap-and-overflow logic mirrors retail's
|
||
`advance_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's `ApplyPosFrame` (line 1288) only applies the
|
||
`posFrames[frameIndex]` and **skips** the `apply_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 uses
|
||
`MotionData.velocity`** (jump impulse, knock-back, flying creatures).
|
||
- acdream adds a synth path (lines 614–650) that overwrites
|
||
`CSequence.CurrentVelocity` with `RunAnimSpeed * speedMod` for
|
||
locomotion cycles. That value is consumed externally by
|
||
`CMotionInterp.get_state_velocity` for 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
|
||
missing `apply_physics` inside the sequencer, because both paths
|
||
bypass it.
|
||
- **Summary for L.3:** the sequencer's `apply_physics` integration 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 in `CMotionInterp::get_state_velocity` consumption or in the
|
||
per-tick path that re-feeds CSequence's velocity vs. the
|
||
`CTransition::transitional_insert` body 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).
|