fix(motion): airborne hard-snap + velocity-extrapolation (L.3.1)

Round 2 fix for two visual bugs that survived commit 5154a3e:

Bug 1 (chop at 1 Hz UP cadence): Round 1 zeroed body.Velocity each
tick on grounded remotes, leaving AdjustOffset as the sole motion
source. AdjustOffset catches up in ~150 ms then sits idle until the
next UP at 1 Hz, producing visible "updates every 1 second" stepping.
Root cause: retail achieves smoothness via animation root motion +
AdjustOffset *corrections*; we only ported corrections (root motion
is Phase L.3.2 / PositionManager). Workaround for L.3.1: seed
body.Velocity from update.Velocity on every grounded UP so
UpdatePhysicsInternal integrates position += vel*dt between UPs,
with the queue providing corrective patches via AdjustOffset.

Bug 2 (endless jump): Round 1 tried to detect landing via "UP arrives
during airborne with no velocity" but ACE keeps sending non-zero
velocity through the arc, so the detector never fired. Fix: stop
maintaining a local "predicted arc". Server is authoritative for
airborne position too -- hard-snap from each UP during airborne;
body.Velocity (set by OnLiveVectorUpdated) integrates between UPs
for smoothing. Landing detected via reported-Z-near-body-Z + falling/
settled velocity heuristic (more reliable than the velocity-zero
test).

Per-frame tick: removed the !rm.Airborne velocity clamp from Round 1.
OnLivePositionUpdated now owns velocity policy; per-tick just
integrates whatever is set.

Both deviations from retail decomp are documented in source comments
and slated for L.3.2 (PositionManager) cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-03 09:38:49 +02:00
parent 5154a3eae1
commit f199a6a075

View file

@ -3284,13 +3284,30 @@ public sealed class GameWindow : IDisposable
// contact: clear Airborne, zero body.Velocity, restore contact flags.
// This is the signal ACE uses (VectorUpdate only fires on jump start;
// no corresponding "landed" packet — the next plain UpdatePosition is it).
// ── AIRBORNE ────────────────────────────────────────────────────────────
// Server is authoritative for the arc. Hard-snap position from every UP
// while airborne; body.Velocity (set by OnLiveVectorUpdated at jump start,
// or unchanged) continues to integrate via UpdatePhysicsInternal/gravity
// between UPs. Don't enqueue — the queue is for grounded motion only.
//
// Landing heuristic (L.3.1): ACE doesn't send an explicit "landed" packet.
// Instead we detect landing by two conditions simultaneously:
// 1. The server-reported Z is within 0.5m of the body's current Z
// (server has snapped to ground level — close to where we are).
// 2. Body's vertical velocity is falling or settled (vz <= 0.5 m/s).
// Both together mean the arc is complete. We do NOT use "velocity == 0"
// because ACE sends non-zero velocity through the entire arc (Bug 2 root
// cause in Round 1).
if (rmState.Airborne)
{
bool velocityIsNegligible = update.Velocity is null
|| update.Velocity.Value.LengthSquared() < 0.04f;
if (velocityIsNegligible)
bool reportedNearBodyZ =
MathF.Abs(worldPos.Z - rmState.Body.Position.Z) < 0.5f;
bool velocityFallingOrSettled =
rmState.Body.Velocity.Z <= 0.5f;
if (reportedNearBodyZ && velocityFallingOrSettled)
{
// Landed: snap to server position, re-ground the body.
// LANDED: snap to ground, re-ground the body.
rmState.Airborne = false;
rmState.Body.Velocity = System.Numerics.Vector3.Zero;
rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
@ -3298,11 +3315,18 @@ public sealed class GameWindow : IDisposable
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rmState.Body.Position = worldPos;
rmState.Interp.Clear();
}
// Still airborne: don't enqueue — let gravity arc continue.
return;
}
// Still airborne: hard-snap so server is authoritative for the arc.
// body.Velocity preserved from VectorUpdate; UpdatePhysicsInternal
// integrates gravity between UPs.
rmState.Body.Position = worldPos;
return;
}
// ── GROUNDED ────────────────────────────────────────────────────────────
// Routing mirrors CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330).
const float MaxPhysicsDistance = 96f;
System.Numerics.Vector3 localPlayerPos =
_playerController?.Position ?? System.Numerics.Vector3.Zero;
@ -3313,27 +3337,37 @@ public sealed class GameWindow : IDisposable
// Default-true: HasContact not on wire yet (CreateObject.ServerPosition gap).
// bool hasContact = true; (implicit — only the teleport and distance branches below)
if (teleportFlag)
if (teleportFlag || dist > MaxPhysicsDistance)
{
// SetPosition equivalent: hard-snap position, clear interp queue.
// Orientation already applied unconditionally above.
rmState.Body.Position = worldPos;
rmState.Interp.Clear();
}
else if (dist > MaxPhysicsDistance)
{
// SetPositionSimple equivalent: slide-snap (clear queue, then hard-snap).
// SetPosition / SetPositionSimple equivalent: hard-snap, clear queue.
// Orientation already applied unconditionally above.
// Zero velocity so UpdatePhysicsInternal doesn't extrapolate from
// a prior walk-direction after a teleport or distant slide-snap.
rmState.Interp.Clear();
rmState.Body.Position = worldPos;
rmState.Body.Velocity = System.Numerics.Vector3.Zero;
}
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.
// InterpolationManager.Enqueue equivalent: queue for AdjustOffset to walk to.
// NOTE: do NOT touch rmState.Body.Position here — AdjustOffset owns it.
// Orientation already applied unconditionally above.
//
// L.3.1 WORKAROUND — velocity-extrapolation between UPs:
// Retail achieves smooth 60 fps motion via animation root motion feeding
// PositionManager (Phase L.3.2 / PositionManager port). Until that lands,
// AdjustOffset alone catches up in ~150 ms after each 1-Hz UP then sits
// idle the remaining 850 ms — visible as "updates every 1 second" stepping.
// Workaround: seed body.Velocity from the UP's velocity field so
// UpdatePhysicsInternal integrates position += vel*dt between UPs;
// AdjustOffset provides corrective patches when drift accumulates.
// When update.Velocity is null the entity is stationary on this UP →
// zero velocity → only queue-walking applies. This deviates from the
// retail decomp finding that walking remotes have m_velocityVector == 0,
// but is the best approximation available without root motion.
float headingFromQuat = ExtractYawFromQuaternion(rot);
rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false);
rmState.Body.Velocity = update.Velocity ?? System.Numerics.Vector3.Zero;
}
// Skip the legacy hard-snap path below.
@ -5840,21 +5874,13 @@ public sealed class GameWindow : IDisposable
rm.Body.Position += delta;
}
// Bug 2a fix (L.3.1 visual verification): grounded remotes must keep
// body.Velocity == 0 so it doesn't fight the queue. In the legacy path
// apply_current_movement achieved this by recomputing velocity from
// InterpretedState each tick; the new path skips apply_current_movement,
// so we explicitly clamp. Airborne remotes keep their VectorUpdate-set
// velocity for gravity arc integration (UpdatePhysicsInternal below).
if (!rm.Airborne)
{
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
// 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.
// Velocity policy is owned by OnLivePositionUpdated (grounded) and
// OnLiveVectorUpdated (airborne jump start). Do NOT clamp body.Velocity
// here — doing so stomped the velocity-extrapolation workaround seeded
// on grounded UPs (Bug 1 regression from Round 1). UpdatePhysicsInternal
// integrates whatever velocity is set: zero for stationary remotes,
// update.Velocity for moving remotes (L.3.1 workaround), or the launch
// arc velocity for airborne remotes. Gravity is applied by the same call.
rm.Body.UpdatePhysicsInternal(dt);
ae.Entity.Position = rm.Body.Position;