feat(motion): L.3 M3 — animation root motion fallback for idle queue

Restores PositionManager.ComputeOffset call in TickAnimations player-
remote branch. M2 was queue-only (body chases server but stops between
UPs after head reached); M3 adds the retail REPLACE behavior:

  - Queue active and not reached → catch-up vector (REPLACES anim).
  - Queue empty or head reached → anim root motion (seqVel × dt rotated
    by body.Orientation) drives translation between UPs.
  - Blip-to-tail still fires on fail_count > 3.

Mirrors retail UpdatePositionInternal @ 0x00512c30 per
docs/research/2026-05-04-l3-port/05-position-manager-and-partarray.md
§ 6: PositionManager::adjust_offset OVERWRITES local frame's origin
with catch-up when active; otherwise no-op (anim root motion stands).

User-verified 2026-05-05: "Best implementation we have had so far.
Running works, walking works, strafing works."

Closes #40 (env-var path regression — replaced wholesale).
Files #41 for residual sub-decimeter blips: velocity-synthesis magnitude
(RunAnimSpeed × adjustedSpeed) overshoots server pace slightly, queue
walks it back every UP. Within retail's DesiredDistance / MinDistance
tolerances; not a correctness bug. Fix path requires porting
add_motion @ 0x005224b0 and cdb-tracing retail's actual
CSequence::velocity magnitude.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-05 15:17:51 +02:00
parent 40d88b92ed
commit 2365c8cd6e
2 changed files with 151 additions and 21 deletions

View file

@ -6071,10 +6071,12 @@ public sealed class GameWindow : IDisposable
{
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
{
// ── L.3 M2 (2026-05-05): queue-only chase for grounded player remotes ──
// ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
//
// Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
// 04-interp-manager.md):
// 04-interp-manager.md +
// 05-position-manager-and-partarray.md):
//
// - For a grounded REMOTE player, m_velocityVector stays at 0.
// - apply_current_movement is NEVER called per tick on remotes
// (it's the local-player-only velocity feed).
@ -6082,15 +6084,23 @@ public sealed class GameWindow : IDisposable
// velocity² > 0, so it's a no-op when body.Velocity = 0.
// - ResolveWithTransition is NOT called — the server already
// collision-resolved the broadcast position.
// - Per-tick body translation comes ENTIRELY from
// InterpolationManager::adjust_offset's queue catch-up.
// When the queue is empty (head reached, between UPs), the
// body stays put. M3 will add animation root motion to fill
// the gap so legs match body pace; for M2 the body chases
// the server position without anim contribution.
// - Per-tick body translation per retail UpdatePositionInternal:
// 1. CPartArray::Update writes anim root motion (body-local
// seqVel × dt) into the local frame.
// 2. PositionManager::adjust_offset OVERWRITES the local
// frame's origin with the queue catch-up vector when
// the queue is active and the head is not yet reached
// — REPLACE, not additive.
// 3. Frame::combine composes the local frame with the
// body's world pose.
// Net: catch-up replaces anim during the chase phase, anim
// stands when the queue is empty / head reached. PositionManager.
// ComputeOffset implements this exact REPLACE dichotomy.
//
// Airborne player remotes (rm.Airborne) and NPCs fall through to
// the legacy path below — unchanged from main per the M2 plan.
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
?? System.Numerics.Vector3.Zero;
@ -6115,21 +6125,28 @@ public sealed class GameWindow : IDisposable
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// Step 2 (M2): queue-only translation. Direct call to
// InterpolationManager.AdjustOffset — no PositionManager
// mixing, no animation root motion. The InterpolationManager
// returns:
// - Vector3.Zero when the queue is empty OR the head is
// within DesiredDistance (0.05 m) — body stays still.
// - Direction × min(catchUpSpeed × dt, dist) — body chases
// the head waypoint at up to 2× motion-table max speed.
// - tail body when fail_count > 3 (stall blip; queue
// cleared as a side effect).
// Step 2 (M3): queue + anim translation via PositionManager.
// ComputeOffset returns:
// - Vector3.Zero when queue is empty AND seqVel is zero
// (idle remote between UPs after head reached) — body
// stays still.
// - Direction × min(catchUpSpeed × dt, dist) when the
// queue is active and head is not reached — body chases
// the head waypoint at up to 2× motion-table max speed
// (REPLACES anim for this frame).
// - Anim root motion (seqVel × dt rotated into world) when
// the queue is empty OR head is within DesiredDistance —
// body advances with the locomotion cycle's baked
// velocity, keeping legs and body pace synchronized.
// - Blip-to-tail (tail body) when fail_count > 3.
float maxSpeed = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3 offset = rm.Interp.AdjustOffset(
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
maxSpeedFromMinterp: maxSpeed);
seqVel: seqVel,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeed);
rm.Body.Position += offset;
// Step 2.5: angular velocity → body orientation. Prefer