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
|
|
@ -0,0 +1,491 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue