feat(motion): retail-faithful per-frame remote tick (L.3.1+L.3.2 Task 3)

Combines PositionManager (Task 1, commit 08fbbef) + IsGrounded plumbing
(Task 2, commit 5d71731) into the per-frame remote motion path. Three
changes in GameWindow.cs, all gated behind ACDREAM_INTERP_MANAGER=1:

1. RemoteMotion gains Position field (PositionManager instance).

2. OnLivePositionUpdated env-var branch rewritten to mirror retail
   CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330):
   - orientation snap-on-receipt (PositionManager handles position only)
   - airborne (!IsGrounded) → no-op (server is authoritative for arc;
     body.Velocity from VectorUpdate integrates gravity locally)
   - landing transition (first IsGrounded=true after Airborne) →
     clear airborne flags, hard-snap to landing pos, clear queue
   - grounded routing: dist > 96m → slide-snap; dist ≤ 96m → enqueue

3. TickAnimations env-var branch rewritten to use PositionManager:
   body.Position += PositionManager.ComputeOffset(dt, pos, seqVel,
   ori, interp, maxSpeed); body.UpdatePhysicsInternal(dt) for gravity.

Replaces the L.3.1-only AdjustOffset-only path. Legacy (env-var off)
path unchanged. Cleanup commit (next sub-task) deletes the env-var
dual paths after visual verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-03 10:18:24 +02:00
parent 5d717312cc
commit e94e7913fb

View file

@ -342,6 +342,14 @@ public sealed class GameWindow : IDisposable
public AcDream.Core.Physics.InterpolationManager Interp { get; } =
new AcDream.Core.Physics.InterpolationManager();
/// <summary>
/// Per-frame combiner for animation root motion + InterpolationManager
/// correction (Phase L.3.2). Consumed in TickAnimations to compute the
/// per-frame body.Position delta.
/// </summary>
public AcDream.Core.Physics.PositionManager Position { get; } =
new AcDream.Core.Physics.PositionManager();
public RemoteMotion()
{
Body = new AcDream.Core.Physics.PhysicsBody
@ -3254,46 +3262,55 @@ public sealed class GameWindow : IDisposable
// identical to before this commit. Legacy hard-snap path remains below.
if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
{
// CPhysicsObj::MoveOrTeleport router (acclient @ 0x00516330):
// - stale instance/position seq → ignore (TODO: IsStaleSequence not yet plumbed)
// - teleport-seq newer or no-cell → SetPosition (hard-snap)
// - has_contact false → no-op (TODO: HasContact not on wire — default true for L.3.1)
// - has_contact && distance ≤ 96 → InterpolationManager.Enqueue (queue)
// - has_contact && distance > 96 → SetPositionSimple (slide-snap)
// Orientation always snaps on receipt — InterpolationManager walks
// position only; heading would otherwise lag the queue.
rmState.Body.Orientation = rot;
// ── AIRBORNE NO-OP ────────────────────────────────────────────
// Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330):
// when has_contact==0, return false (don't touch body, don't queue).
// body.Velocity (set once by OnLiveVectorUpdated at jump start) keeps
// integrating gravity via per-frame UpdatePhysicsInternal. Server is
// authoritative for the arc; we don't predict it locally.
if (!update.IsGrounded)
return;
// ── LANDING TRANSITION ────────────────────────────────────────
// First IsGrounded=true UP after rmState.Airborne signals landed.
// Clear airborne flags, hard-snap to authoritative landing position,
// clear interpolation queue (any pre-jump waypoints are stale).
if (rmState.Airborne)
{
rmState.Airborne = false;
rmState.Body.Velocity = System.Numerics.Vector3.Zero;
rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
return;
}
// ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ────────────
const float MaxPhysicsDistance = 96f;
System.Numerics.Vector3 localPlayerPos =
_playerController?.Position ?? System.Numerics.Vector3.Zero;
var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero;
float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos);
// Default-false: teleport flag not plumbed until sequence comparison lands (Task 5+).
bool teleportFlag = false;
// Default-true: HasContact not on wire yet (CreateObject.ServerPosition gap).
// bool hasContact = true; (implicit — only the teleport and distance branches below)
if (teleportFlag)
if (dist > MaxPhysicsDistance)
{
// SetPosition equivalent: hard-snap position + orientation, clear interp queue.
rmState.Body.Position = worldPos;
rmState.Body.Orientation = rot;
rmState.Interp.Clear();
}
else if (dist > MaxPhysicsDistance)
{
// SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap).
// Beyond view bubble: SetPositionSimple slide-snap. Clear queue.
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
rmState.Body.Orientation = rot;
}
else
{
// InterpolationManager.Enqueue equivalent: queue for adjust_offset to walk to.
// NOTE: do NOT touch rmState.Body.Position here — adjust_offset (Task 5) owns it.
// Within view bubble: enqueue waypoint for adjust_offset to walk to.
// PositionManager (called per-frame in TickAnimations) handles the
// actual body advancement — mix of animation root motion + queue
// correction.
float headingFromQuat = ExtractYawFromQuaternion(rot);
rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false);
}
// Skip the legacy hard-snap path below.
return;
}
@ -5771,36 +5788,24 @@ public sealed class GameWindow : IDisposable
{
if (System.Environment.GetEnvironmentVariable("ACDREAM_INTERP_MANAGER") == "1")
{
// ── NEW PATH: queued position-chase via InterpolationManager ──
// (L.3.1 Task 5 — ACDREAM_INTERP_MANAGER=1 gates this path)
// ── NEW PATH: PositionManager (animation root motion + InterpolationManager) ──
// (L.3.1+L.3.2 Task 3 — ACDREAM_INTERP_MANAGER=1 gates this path)
//
// Walking remotes have m_velocityVector == 0 in retail; all
// visible horizontal motion comes from
// InterpolationManager::adjust_offset (acclient @ 0x00555D30)
// walking the body toward the head of the waypoint queue at
// 2 × motion_max_speed × dt (clamped, 7.5 m/s fallback).
//
// Mirrors retail CPhysicsObj::UpdateObjectInternal
// (acclient @ 0x00513730) which calls adjust_offset every frame
// before UpdatePhysicsInternal integrates gravity.
//
// For airborne remotes, OnLiveVectorUpdated has set
// body.Velocity (launch arc); we still call
// UpdatePhysicsInternal below so gravity applies each frame and
// produces the parabolic arc. The IsActive gate prevents
// AdjustOffset from pulling against an in-flight arc when no
// waypoints are queued for a jumping remote.
if (rm.Interp.IsActive)
{
float maxSpeed = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3 delta = rm.Interp.AdjustOffset((double)dt, rm.Body.Position, maxSpeed);
rm.Body.Position += delta;
}
// Gravity integration: retail's UpdatePhysicsInternal still
// fires every frame regardless of the interpolation path.
// For grounded remotes body.Velocity == 0 so this is a no-op;
// for airborne remotes it applies gravity to the arc.
// Always-run-all-steps per retail CPhysicsObj::UpdateObjectInternal
// (acclient @ 0x00513730):
// 1+2. animation root motion + interpolation correction (combined)
// 3. physics integration (gravity for airborne; no-op for grounded)
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
float maxSpeed = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVel,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeed);
rm.Body.Position += offset;
rm.Body.UpdatePhysicsInternal(dt);
ae.Entity.Position = rm.Body.Position;