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>
491 lines
19 KiB
Markdown
491 lines
19 KiB
Markdown
# L.3 port — PositionManager + CPartArray::Update + CSequence root motion
|
||
|
||
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`
|
||
(Sept-2013 EoR build, Binary Ninja decomp, PDB-named).
|
||
|
||
This note pins down where retail's per-tick "animation root motion"
|
||
actually comes from, what `PositionManager::adjust_offset` adds on top
|
||
of it, and exactly what each manager writes into the per-tick `Frame`.
|
||
|
||
It exists to settle one question: **does retail's `CPartArray::Update`
|
||
produce per-keyframe pos-frame deltas (a.k.a. baked root motion in the
|
||
animation data), or does it integrate `CSequence::velocity * dt` (a
|
||
constant-velocity model), or both?** The answer is **both**, in a
|
||
strict order, and acdream's current C# port only models the second
|
||
half.
|
||
|
||
---
|
||
|
||
## 0. Top-level call site — `CPhysicsObj::UpdatePositionInternal`
|
||
|
||
`@ 0x00512c30` (line 280817):
|
||
|
||
```c
|
||
void __thiscall CPhysicsObj::UpdatePositionInternal(
|
||
class CPhysicsObj* this, float arg2 /* dt */, class Frame* arg3 /* out */)
|
||
{
|
||
Frame var_40; // 1. local Frame, identity
|
||
Frame::cache(&var_40); // var_40 = identity
|
||
|
||
if ((state & 0x4000) == 0) { // not animation-paused
|
||
if (this->part_array != 0)
|
||
CPartArray::Update(this->part_array, arg2, &var_40); // (A)
|
||
// ... var_c/var_8/var_4 scaled by m_scale (joint-frame stuff,
|
||
// not the root) ...
|
||
}
|
||
|
||
if (this->position_manager != 0)
|
||
PositionManager::adjust_offset(this->position_manager, &var_40, arg2); // (B)
|
||
|
||
Frame::combine(arg3, &this->m_position.frame, &var_40); // (C)
|
||
|
||
if ((state & 0x4000) == 0)
|
||
CPhysicsObj::UpdatePhysicsInternal(this, arg2, arg3); // (D) — sweep/collision
|
||
CPhysicsObj::process_hooks(this);
|
||
}
|
||
```
|
||
|
||
So the per-tick recipe is:
|
||
|
||
1. **var_40 = identity Frame**
|
||
2. **(A)** `CPartArray::Update(dt, &var_40)` writes the animation-driven
|
||
delta into var_40 (origin + orientation).
|
||
3. **(B)** `PositionManager::adjust_offset(&var_40, dt)` fans out to
|
||
`InterpolationManager::adjust_offset`, `StickyManager::adjust_offset`,
|
||
`ConstraintManager::adjust_offset`, each of which mutates var_40
|
||
in-place.
|
||
4. **(C)** Result frame = `m_position.frame ∘ var_40` (rotation
|
||
composes, then translates).
|
||
5. **(D)** Sweep/collision (the call we already port as
|
||
`ResolveWithTransition`).
|
||
|
||
`var_40` is *both* origin (`m_fOrigin`) and orientation (`m_angles` /
|
||
`Frame::rotate`). It is a delta, not a position.
|
||
|
||
---
|
||
|
||
## 1. `CPartArray::Update` is a 1-line forwarder
|
||
|
||
`@ 0x00517db0` (line 285883):
|
||
|
||
```c
|
||
void __thiscall CPartArray::Update(class CPartArray* this, float arg2 /* dt */, class Frame* arg3)
|
||
{
|
||
CSequence::update(&this->sequence, (double)arg2, arg3);
|
||
}
|
||
```
|
||
|
||
All the work is in `CSequence::update`.
|
||
|
||
---
|
||
|
||
## 2. `CSequence::update` — 1-line gatekeeper
|
||
|
||
`@ 0x00525b80` (line 302402):
|
||
|
||
```c
|
||
void __thiscall CSequence::update(class CSequence* this, double arg2 /* dt */, class Frame* arg3)
|
||
{
|
||
if (this->anim_list.head_ != 0) {
|
||
CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3);
|
||
CSequence::apricot(this); // remove finished non-cyclic anims from list
|
||
return;
|
||
}
|
||
if (arg3 != 0)
|
||
CSequence::apply_physics(this, arg3, arg2 /*dt*/, arg2 /*sign-dt*/);
|
||
}
|
||
```
|
||
|
||
If there are NO animations queued, `apply_physics` runs once with
|
||
`(dt, dt)` and writes velocity·dt into the frame directly. Otherwise
|
||
the inner loop drives both per-keyframe combine AND apply_physics.
|
||
|
||
---
|
||
|
||
## 3. `CSequence::update_internal` — the keyframe loop (THIS IS THE ROOT MOTION SOURCE)
|
||
|
||
`@ 0x005255d0` (line 301839). I'll show the structurally important
|
||
parts; the FCOMP/FLD ops are FPU translation noise from Binary Ninja
|
||
and read like English once you ignore them:
|
||
|
||
Branching on the sign of `arg2` (dt — positive = forward, negative =
|
||
playing the cycle in reverse) the function picks one of two near-
|
||
identical inner loops.
|
||
|
||
### 3a. Forward branch (arg2 ≥ 0) — `else` block at 0x00525646
|
||
|
||
```c
|
||
// floor(frame_number) → ebx_2 = integer keyframe index
|
||
do {
|
||
if (arg5 /*Frame*/ != 0) {
|
||
AnimSequenceNode* node = *arg3; // current animation node
|
||
if (node->anim->pos_frames != 0) {
|
||
// (A1) MULTIPLY-ACCUMULATE the dat-baked pos-frame for keyframe ebx_2
|
||
// into the running Frame:
|
||
Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(node, ebx_2));
|
||
}
|
||
// If the animation has nonzero framerate (|fr| > F_EPSILON):
|
||
// (A2) integrate velocity·omega over the time spent on THIS keyframe
|
||
// dt_keyframe = 1.0 / framerate
|
||
// apply_physics(this, arg5, dt_keyframe, arg2_total_dt);
|
||
if (|framerate| > F_EPSILON) {
|
||
double dt_keyframe = 1.0 / framerate;
|
||
CSequence::apply_physics(this, arg5, dt_keyframe, arg2);
|
||
}
|
||
}
|
||
CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(node, ebx_2), 1 /*forward*/);
|
||
ebx_2 += 1;
|
||
// re-test loop: continue while frame_number > ebx_2 (we have more keyframes
|
||
// worth of time-budget to consume this tick).
|
||
} while (frame_number > ebx_2);
|
||
```
|
||
|
||
### 3b. Backward branch (arg2 < 0) — `if` block at 0x00525646
|
||
|
||
Mirror image of the forward branch:
|
||
|
||
```c
|
||
do {
|
||
if (arg5 != 0) {
|
||
AnimSequenceNode* node = *arg3;
|
||
if (node->anim->pos_frames != 0) {
|
||
// (A1') SUBTRACT the dat-baked pos-frame for keyframe ebx_1
|
||
// (Frame::subtract1, not Frame::combine)
|
||
Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(node, ebx_1));
|
||
}
|
||
if (|framerate| > F_EPSILON) {
|
||
double dt_keyframe = 1.0 / framerate;
|
||
CSequence::apply_physics(this, arg5, dt_keyframe, arg2 /*negative*/);
|
||
}
|
||
}
|
||
CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(node, ebx_1), -1 /*backward*/);
|
||
ebx_1 -= 1;
|
||
} while (frame_number < ebx_1); // walk indices DOWN
|
||
```
|
||
|
||
When the inner loop completes the time budget, `frame_number` is
|
||
updated to the new fractional position and (if the cycle ended)
|
||
`advance_to_next_animation` rolls the queue forward.
|
||
|
||
### 3c. Special "no time elapsed" path
|
||
|
||
If `|arg2| < F_EPSILON` (dt ≈ 0) the function still calls
|
||
`apply_physics(this, arg5, dt /*≈0*/, arg2)` once and returns —
|
||
ensures velocity·0 = 0 and omega·0 = 0 are written even when no
|
||
keyframe boundary is crossed.
|
||
|
||
### Per-keyframe vs per-tick
|
||
|
||
The crucial structural fact: the loop runs **once per integer keyframe
|
||
that fits inside the tick's time budget**. If an animation runs at
|
||
30 fps and we tick at 60 Hz, most ticks consume ZERO keyframes
|
||
(the loop body never executes); the time accumulates in `frame_number`
|
||
until the next keyframe boundary. When a keyframe is crossed,
|
||
`Frame::combine(frame, frame, pos_frame)` is invoked AND
|
||
`apply_physics` is invoked with `dt = 1/framerate` (NOT the tick's
|
||
real dt). Across many ticks this averages to integrating velocity at
|
||
the cycle's framerate, but on a single tick the integration may be 0
|
||
or it may be 1/framerate or it may be N/framerate for fast cycles.
|
||
|
||
This is **important for our port**: when our C# code does
|
||
`bodyPos += seqVel * dt` per tick at fixed 60 Hz, we are smoothing the
|
||
retail behavior. That's fine for steady motion but explains why the
|
||
retail trace shows "stairsteps" of pos updates aligned to keyframe
|
||
boundaries — it really is per-keyframe.
|
||
|
||
---
|
||
|
||
## 4. `CSequence::apply_physics` — the velocity integrator
|
||
|
||
`@ 0x00524ab0` (line 300955):
|
||
|
||
```c
|
||
void __thiscall CSequence::apply_physics(
|
||
class CSequence const* this,
|
||
class Frame* arg2, // mutated
|
||
double arg3, // dt magnitude (always positive in the loop)
|
||
double arg4) // sign carrier (positive = forward, negative = backward)
|
||
{
|
||
long double scale = fabs((long double)arg3); // |dt|
|
||
if (arg4 < 0.0)
|
||
scale = -scale; // negate for backward play
|
||
|
||
arg2->m_fOrigin.x += (float)(scale * this->velocity.x);
|
||
arg2->m_fOrigin.y += (float)(scale * this->velocity.y);
|
||
arg2->m_fOrigin.z += (float)(scale * this->velocity.z);
|
||
|
||
Vector3 axisAngle = {
|
||
scale * this->omega.x,
|
||
scale * this->omega.y,
|
||
scale * this->omega.z
|
||
};
|
||
Frame::rotate(arg2, &axisAngle); // arg2->m_angles = axisAngle ∘ arg2->m_angles
|
||
}
|
||
```
|
||
|
||
So one call writes BOTH translation (origin += scale·velocity) and
|
||
rotation (`Frame::rotate` = quat-from-axis-angle ∘ existing). The
|
||
sign of `arg4` determines whether we play forward or backward; the
|
||
*magnitude* in arg3 is the dt being integrated (1/framerate per
|
||
keyframe inside `update_internal`).
|
||
|
||
`this->velocity` is `CSequence::velocity` (set by `add_motion` from
|
||
`MotionData::velocity * style_speed`). `this->omega` is
|
||
`CSequence::omega` (same source).
|
||
|
||
---
|
||
|
||
## 5. Where does `CSequence::velocity` come from?
|
||
|
||
`add_motion` `@ 0x005224b0` (line 298437):
|
||
|
||
```c
|
||
void add_motion(CSequence* arg1, MotionData* arg2 /*dat-loaded*/, float arg3 /*style_speed*/)
|
||
{
|
||
if (arg2 == 0) return;
|
||
|
||
Vector3 vel = {
|
||
arg3 * arg2->velocity.x,
|
||
arg3 * arg2->velocity.y,
|
||
arg3 * arg2->velocity.z
|
||
};
|
||
CSequence::set_velocity(arg1, &vel); // overwrites — not additive
|
||
|
||
Vector3 omg = {
|
||
arg3 * arg2->omega.x,
|
||
arg3 * arg2->omega.y,
|
||
arg3 * arg2->omega.z
|
||
};
|
||
CSequence::set_omega(arg1, &omg);
|
||
|
||
// append each anim segment (the actual cyclic / link / ack list)
|
||
for (int i = 0; i < arg2->num_anims; i++)
|
||
CSequence::append_animation(arg1,
|
||
operator*(&__return, arg3, &arg2->anims[i]));
|
||
}
|
||
```
|
||
|
||
So the answer to the brief's first set of critical questions:
|
||
|
||
> For locomotion cycles (Walk, Run), is the root motion baked into
|
||
> PosFrames in the animation data, OR computed from MotionData.Velocity?
|
||
|
||
**Both, simultaneously.** The retail data ships SOME motions with
|
||
nonzero `MotionData::velocity` (which becomes per-keyframe
|
||
`scale·velocity` translation through `apply_physics`) AND/OR with
|
||
nonzero `CAnimation::pos_frames[i]` (per-keyframe explicit deltas
|
||
combined into the frame via `Frame::combine`). For Humanoid run/walk,
|
||
ACE's port and our existing diagnostics agree the dat ships
|
||
`HasVelocity = 0`, meaning the dat-side `MotionData::velocity` is
|
||
zero. The actual per-keyframe pos_frames are also typically tiny
|
||
(stride wobble) — which is why retail clients ALSO drive
|
||
`CMotionInterp::get_state_velocity` (RunAnimSpeed × ForwardSpeed) into
|
||
`CSequence::velocity` via a separate path during locomotion. Our
|
||
synthesized `CurrentVelocity` in `AnimationSequencer.SetCycle`
|
||
(WalkAnimSpeed=3.12, RunAnimSpeed=4.0, etc.) mirrors this exactly.
|
||
|
||
> For idle cycles (Ready), is the root motion zero?
|
||
|
||
Yes — Ready's `MotionData::velocity` is zero, and our synthesizer
|
||
leaves `CurrentVelocity` at zero for non-locomotion cycles. ✓.
|
||
|
||
> For sign-flipped backward (cycle plays in reverse), is root motion negated?
|
||
|
||
Yes — `apply_physics`'s `arg4 < 0` branch negates `scale`, so origin
|
||
delta and rotation delta both flip. Our port handles WalkBackward by
|
||
going through the MotionInterpreter's `adjust_motion` remap to
|
||
WalkForward + speedMod×−0.65 (matches retail's actual encoding); the
|
||
backward keyframe-loop branch is reachable for cyclic anims that
|
||
genuinely play with negative framerate.
|
||
|
||
---
|
||
|
||
## 6. `PositionManager::adjust_offset` — fan-out
|
||
|
||
`@ 0x00555190` (line 352090):
|
||
|
||
```c
|
||
void __thiscall PositionManager::adjust_offset(
|
||
class PositionManager* this, class Frame* arg2 /*the var_40 from above*/, double arg3 /*dt*/)
|
||
{
|
||
if (this->interpolation_manager != 0)
|
||
InterpolationManager::adjust_offset(this->interpolation_manager, arg2, arg3);
|
||
if (this->sticky_manager != 0)
|
||
StickyManager::adjust_offset(this->sticky_manager, arg2, arg3);
|
||
if (this->constraint_manager != 0)
|
||
ConstraintManager::adjust_offset(this->constraint_manager, arg2, arg3);
|
||
}
|
||
```
|
||
|
||
ORDER MATTERS. Each manager mutates `arg2` in-place.
|
||
|
||
### 6a. `InterpolationManager::adjust_offset` (`@ 0x00555d30`, line 353071)
|
||
|
||
This is the head-of-queue catch-up logic the user already agonized
|
||
over. The behavior:
|
||
|
||
- If position_queue is empty → no-op.
|
||
- If transient_state lacks bit 1 → no-op.
|
||
- If queue head has special types 2 or 3 → no-op.
|
||
- If `Position::distance(physics_obj, head_target) < 0.05f` →
|
||
`NodeCompleted(true)` and **return** (arg2 untouched — animation
|
||
root motion stands).
|
||
- Otherwise:
|
||
- `max_speed = (fUseAdjustedSpeed_ ? get_adjusted_max_speed
|
||
: get_max_speed) * 2.0f`.
|
||
- Build a unit direction toward head, scaled by
|
||
`min(max_speed × dt, distance)`, **OVERWRITE arg2->m_fOrigin** with
|
||
that vector. Animation root motion for THIS tick is discarded.
|
||
|
||
So `InterpolationManager::adjust_offset` is **either** a pure pass-
|
||
through (close-enough) or a **REPLACE** (overwrite arg2->m_fOrigin).
|
||
It is NOT additive. Our `PositionManager.cs` correctly implements
|
||
this dichotomy in `ComputeOffset`.
|
||
|
||
### 6b. `StickyManager::adjust_offset` (`@ 0x00555430`, line 352351)
|
||
|
||
When sticky-target-id is set and initialized:
|
||
|
||
- Compute world-space offset to target (via `Position::get_offset`),
|
||
store in `arg2->m_fOrigin`.
|
||
- Convert to local-space (`Position::globaltolocalvec`),
|
||
zero the Z (stay-at-target-altitude only in XY).
|
||
- Distance = `cylinder_distance_no_z - 0.30f`.
|
||
- If the offset normalized fine: scale it by
|
||
`min(max_speed * dt, |distance|)` and write back. (Same
|
||
movement-budget logic as InterpolationManager but toward a
|
||
different target.)
|
||
- Then `Frame::set_heading(arg2, target_heading − current_heading)` —
|
||
i.e., **OVERWRITES** arg2's heading too.
|
||
|
||
`StickyManager::adjust_offset` runs AFTER `InterpolationManager` so it
|
||
can REPLACE the interpolation correction. This makes sense: sticky
|
||
follow-target is a higher-priority constraint than queued node-by-node
|
||
movement.
|
||
|
||
### 6c. `ConstraintManager::adjust_offset` (`@ 0x00556180`, line 353479)
|
||
|
||
When `is_constrained != 0` and `transient_state & 1`:
|
||
|
||
- If `constraint_pos_offset > constraint_distance_max`: zero out
|
||
arg2->m_fOrigin (clamp to constraint).
|
||
- If `constraint_pos_offset > constraint_distance_start`: scale
|
||
arg2->m_fOrigin by `(max - offset) / (max - start)` (linear ease-out
|
||
near the cap).
|
||
- Otherwise: leave arg2->m_fOrigin alone.
|
||
- Always: accumulate arg2->m_fOrigin.x into `this->constraint_pos_offset`
|
||
(advance the offset tracker).
|
||
|
||
So Constraint is the only manager that's **purely scalar**: it scales
|
||
or zeros `arg2->m_fOrigin` rather than overwriting it.
|
||
|
||
### Summary of the fan-out
|
||
|
||
| Manager | What it writes to `arg2` | Conditions |
|
||
|---|---|---|
|
||
| `InterpolationManager` | OVERWRITES origin with catch-up vector OR no-op | head-of-queue distance > 0.05 |
|
||
| `StickyManager` | OVERWRITES origin AND heading with chase-target vector | target_id != 0 AND initialized |
|
||
| `ConstraintManager` | SCALES (or zeros) origin, never writes new value | is_constrained AND transient bit |
|
||
|
||
If multiple are active at once they compose, but the natural retail
|
||
case is at most one of (Interp, Sticky) active per object — Sticky is
|
||
typically used for combat lock / charge-target follows; Interp is the
|
||
default for queued moveto.
|
||
|
||
---
|
||
|
||
## 7. Cross-check vs acdream's port
|
||
|
||
### `src/AcDream.Core/Physics/PositionManager.cs`
|
||
|
||
acdream's port collapses the entire chain to:
|
||
|
||
```csharp
|
||
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
|
||
if (correction.LengthSquared() > 0f)
|
||
return correction;
|
||
|
||
Vector3 rootMotionLocal = seqVel * (float)dt;
|
||
return Vector3.Transform(rootMotionLocal, ori);
|
||
```
|
||
|
||
Divergences from retail:
|
||
|
||
1. **No StickyManager / ConstraintManager.** Currently fine for L.3
|
||
(we don't ship sticky-follow yet); flag for L.5+ when combat
|
||
targeting lands.
|
||
|
||
2. **Single `seqVel * dt` per tick instead of per-keyframe.**
|
||
Retail's loop runs once per integer keyframe boundary inside the
|
||
tick, calling `apply_physics(dt = 1/framerate)` each time. Our
|
||
port runs once per tick at `dt = tick`. Net displacement per
|
||
second is identical for steady-state running, but the retail
|
||
trace will show "stairsteps" aligned to keyframe boundaries
|
||
while ours will show smooth integration. This is probably the
|
||
cause of the user-reported "staircase" pattern when remotes run
|
||
up/down slopes — every keyframe boundary, retail does a discrete
|
||
`Frame::combine(pos_frame_delta)` then a discrete velocity bump.
|
||
We integrate continuously and miss the per-keyframe pos_frame
|
||
delta entirely.
|
||
|
||
3. **`pos_frames` from the dat are completely ignored.** Retail's
|
||
`Frame::combine(arg5, arg5, get_pos_frame(node, kf))` per keyframe
|
||
is the dat-baked stride wobble / hand-position-during-cast / etc.
|
||
For Humanoid locomotion these are small but nonzero — likely
|
||
±0.02m wobble plus Z bob. Ignoring them makes our remote bodies
|
||
glide unnaturally smoothly.
|
||
|
||
4. **`Frame::rotate` from `omega·dt` is partially handled** — our
|
||
`CurrentOmega` synth covers turn cycles (TurnRight/TurnLeft) and
|
||
`RemoteEntity` integrates omega into its quaternion per tick. ✓.
|
||
|
||
### `src/AcDream.Core/Physics/AnimationSequencer.cs`
|
||
|
||
Lines 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_speed` MULTIPLY through
|
||
`add_motion`. Retail computes `CSequence::velocity = style_speed *
|
||
MotionData.velocity`; our synth uses `RunAnimSpeed * adjustedSpeed`
|
||
directly. For Humanoid this is correct because the dat's
|
||
`MotionData.velocity` is zero so the multiply is a no-op anyway —
|
||
but for creatures with nonzero `MotionData.velocity`, our synth
|
||
silently drops that contribution. Filed as future-port concern;
|
||
currently no observed impact.
|
||
|
||
- Per-keyframe `pos_frames` deltas (see #3 above). Our
|
||
`CurrentVelocity` carries only the *steady-state* component of the
|
||
cycle's intent; the per-frame stride wobble is gone. To capture
|
||
it we'd need to walk `CAnimation.PosFrames[i]` and add the keyframe
|
||
delta on each integer-keyframe-boundary tick — i.e., port the
|
||
inner loop of `update_internal` rather than collapsing it to a
|
||
velocity number.
|
||
|
||
---
|
||
|
||
## 8. Recommendations for L.3 follow-up
|
||
|
||
Likely root cause of the remote-run-on-slope staircase regression
|
||
(env-var path) and the steady-state position blips:
|
||
|
||
1. The env-var path bypasses `ResolveWithTransition` (already fixed
|
||
in commit 039149a, per memory). ✓.
|
||
2. The remote body integrates `seqVel * dt` per tick smoothly, while
|
||
broadcasts arrive at ~5 Hz with retail's per-keyframe-discretized
|
||
advance. Mismatch shows as small +/- Z bob between UPs.
|
||
3. `pos_frames` deltas ignored — Z stride wobble lost.
|
||
4. `omega` integration order vs `Frame::combine(pos_frame)` — retail
|
||
does `Frame::combine(pos_frame)` BEFORE `apply_physics`, so the
|
||
pos_frame's heading rotation applies first; we do them in either
|
||
order depending on caller wiring.
|
||
|
||
Before implementing more porting, brainstorm with `superpowers:
|
||
brainstorming` whether per-keyframe integration is worth porting now
|
||
(complexity: high; visible impact: stride wobble; user-visibility:
|
||
probably low) versus accepting the smoothed model and instead tuning
|
||
the InterpolationManager catch-up thresholds.
|