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>
This commit is contained in:
parent
a3f53c2644
commit
de129bc164
18 changed files with 10721 additions and 190 deletions
526
docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md
Normal file
526
docs/research/2026-05-04-l3-port/09-cpart-array-cseq-update.md
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
# 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue