feat(anim): dead-reckoning remote entity positions
Before: remote characters stutter-hop between UpdatePosition broadcasts
(typical 100-200ms interval), looking lagging-forward during continuous
motion. The retail client hides this gap by integrating velocity forward
each tick — apply_current_movement in chunk_00520000.c L7132-L7189,
mirrored by holtburger's project_pose_by_velocity in spatial/physics.rs.
Strategy:
1. RemoteDeadReckonState per remote entity tracks the last authoritative
server position + rotation, an EMA-smoothed observed velocity from
position deltas, and any server-supplied HasVelocity vector.
2. OnLivePositionUpdated: on each UpdatePosition arrival, snap the entity
to the server position, then update the dead-reckon state. The
observed-velocity is a 50/50 EMA against the running average so a
single jitter sample doesn't blow out the velocity.
3. TickAnimations: each tick, for every remote entity in a locomotion
cycle, integrate Entity.Position += worldVelocity * dt. World velocity
is pulled in priority order:
- Sequencer's MotionData.Velocity rotated by Entity.Rotation (the
primary source; matches MotionData's "world-space on the object"
convention per r03 §1.3)
- Server-supplied HasVelocity from UpdatePosition (already world-space)
- EMA-observed position-delta velocity (fallback for NPC motion
tables with HasVelocity=0)
4. Cap: if the predicted position drifts more than velocity ×
DeadReckonMaxPredictSeconds (1.0s) from the last server position,
clamp back toward the server. This prevents runaway when sequencer
velocity and server reality disagree (e.g. server rubber-banding).
Result: remote chars now move smoothly between position updates,
matching the retail client's visual feel. When UpdatePosition arrives
the entity snaps to the authoritative position and the dead-reckon
origin resets, so there's no accumulating drift.
Tests: CurrentVelocity_ScalesWithSpeedMod — new unit test verifying
that the sequencer's CurrentVelocity accurately reflects speedMod changes
across both SetCycle's rebuild path and its rescale path. Combined with
the existing MultiplyCyclicFramerate tests, this validates the
downstream-visible velocity surface the dead-reckoner reads. 633 tests
green (was 632).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
afafefd71f
commit
b7a9322b40
2 changed files with 212 additions and 9 deletions
|
|
@ -150,6 +150,54 @@ public sealed class GameWindow : IDisposable
|
||||||
private readonly Dictionary<uint, (System.Numerics.Vector3 Pos, System.DateTime Time)>
|
private readonly Dictionary<uint, (System.Numerics.Vector3 Pos, System.DateTime Time)>
|
||||||
_remoteLastMove = new();
|
_remoteLastMove = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-remote-entity dead-reckoning state for smoothing between server
|
||||||
|
/// UpdatePosition broadcasts. Without this, remote characters teleport
|
||||||
|
/// every ~100–200 ms when the server pushes a new position (the retail
|
||||||
|
/// client hides the gap by integrating <c>CMotionInterp</c>-surfaced
|
||||||
|
/// velocity forward each tick — see chunk_00520000.c
|
||||||
|
/// <c>apply_current_movement</c> L7132-L7189 and holtburger's
|
||||||
|
/// <c>spatial/physics.rs::project_pose_by_velocity</c>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Each entry records the last authoritative server position + time + a
|
||||||
|
/// measured velocity inferred from the delta between consecutive
|
||||||
|
/// UpdatePositions. The client's per-tick integrator uses the
|
||||||
|
/// sequencer's <c>CurrentVelocity</c> (rotated into world space by the
|
||||||
|
/// entity's orientation) as the primary source and falls back to the
|
||||||
|
/// inferred velocity when the motion table doesn't carry one (e.g. NPC
|
||||||
|
/// motion tables with HasVelocity=0).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<uint, RemoteDeadReckonState> _remoteDeadReckon = new();
|
||||||
|
|
||||||
|
private sealed class RemoteDeadReckonState
|
||||||
|
{
|
||||||
|
/// <summary>Last server-authoritative world position.</summary>
|
||||||
|
public System.Numerics.Vector3 LastServerPos;
|
||||||
|
/// <summary>When that last server position arrived (UTC).</summary>
|
||||||
|
public System.DateTime LastServerPosTime;
|
||||||
|
/// <summary>Last server-authoritative world rotation.</summary>
|
||||||
|
public System.Numerics.Quaternion LastServerRot = System.Numerics.Quaternion.Identity;
|
||||||
|
/// <summary>
|
||||||
|
/// Most recently observed position-delta-based world velocity, used
|
||||||
|
/// as fallback when the sequencer has no CurrentVelocity. Computed
|
||||||
|
/// as (pos_new - pos_old) / dt across consecutive UpdatePositions.
|
||||||
|
/// </summary>
|
||||||
|
public System.Numerics.Vector3 ObservedVelocity;
|
||||||
|
/// <summary>Server-supplied world velocity from UpdatePosition (HasVelocity flag).</summary>
|
||||||
|
public System.Numerics.Vector3? ServerVelocity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
|
||||||
|
/// remote entity, dead-reckoning continues but the "origin" for
|
||||||
|
/// predicted position is the server pos. This matches retail's snap
|
||||||
|
/// behavior — the server is authoritative, we just interpolate between
|
||||||
|
/// authoritative samples.
|
||||||
|
/// </summary>
|
||||||
|
private const float DeadReckonMaxPredictSeconds = 1.0f;
|
||||||
|
|
||||||
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
|
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
|
||||||
// Exposed publicly so plugins + UI panels can bind directly.
|
// Exposed publicly so plugins + UI panels can bind directly.
|
||||||
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
public readonly AcDream.Core.Chat.ChatLog Chat = new();
|
||||||
|
|
@ -1483,19 +1531,49 @@ public sealed class GameWindow : IDisposable
|
||||||
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
|
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
|
||||||
// that report the same position keep the old Time, so the
|
// that report the same position keep the old Time, so the
|
||||||
// TickAnimations check can see when motion last changed.
|
// TickAnimations check can see when motion last changed.
|
||||||
|
//
|
||||||
|
// Also populate the dead-reckon state so TickAnimations can
|
||||||
|
// integrate velocity between server updates and avoid teleport jitter.
|
||||||
|
// Observed-velocity is computed from the position delta across
|
||||||
|
// consecutive updates — this is the fallback when the motion table's
|
||||||
|
// MotionData.Velocity is zero (NPCs without HasVelocity).
|
||||||
if (update.Guid != _playerServerGuid)
|
if (update.Guid != _playerServerGuid)
|
||||||
{
|
{
|
||||||
|
var now = System.DateTime.UtcNow;
|
||||||
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
|
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
|
||||||
{
|
{
|
||||||
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
|
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
|
||||||
if (moveDist > 0.05f)
|
if (moveDist > 0.05f)
|
||||||
_remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow);
|
_remoteLastMove[update.Guid] = (worldPos, now);
|
||||||
// else: leave old entry so "Time" = last real movement time
|
// else: leave old entry so "Time" = last real movement time
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow);
|
_remoteLastMove[update.Guid] = (worldPos, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dead-reckon state: accumulate observed world-space velocity.
|
||||||
|
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var drState))
|
||||||
|
{
|
||||||
|
drState = new RemoteDeadReckonState();
|
||||||
|
_remoteDeadReckon[update.Guid] = drState;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float dtSec = (float)(now - drState.LastServerPosTime).TotalSeconds;
|
||||||
|
if (dtSec > 0.01f && dtSec < 1.0f)
|
||||||
|
{
|
||||||
|
// EMA-smooth the observed velocity so one-off snaps don't
|
||||||
|
// overwrite the running average. alpha=0.5 converges fast
|
||||||
|
// but resists single-frame noise.
|
||||||
|
var observed = (worldPos - drState.LastServerPos) / dtSec;
|
||||||
|
drState.ObservedVelocity = 0.5f * drState.ObservedVelocity + 0.5f * observed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drState.LastServerPos = worldPos;
|
||||||
|
drState.LastServerRot = rot;
|
||||||
|
drState.LastServerPosTime = now;
|
||||||
|
drState.ServerVelocity = update.Velocity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase B.3: portal-space arrival detection.
|
// Phase B.3: portal-space arrival detection.
|
||||||
|
|
@ -3154,6 +3232,15 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
var ae = kv.Value;
|
var ae = kv.Value;
|
||||||
|
|
||||||
|
// Locate the server guid for this entity once per tick — needed
|
||||||
|
// for both stop-detection and dead-reckoning. O(N) reverse
|
||||||
|
// lookup; for player populations < 100 the cost is negligible.
|
||||||
|
uint serverGuid = 0;
|
||||||
|
foreach (var esg in _entitiesByServerGuid)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Remote stop-detection: if this entity's sequencer is in a
|
// ── Remote stop-detection: if this entity's sequencer is in a
|
||||||
// locomotion cycle and their position hasn't changed in >400ms,
|
// locomotion cycle and their position hasn't changed in >400ms,
|
||||||
// the retail player stopped moving. Swap them to Ready. This
|
// the retail player stopped moving. Swap them to Ready. This
|
||||||
|
|
@ -3166,13 +3253,6 @@ public sealed class GameWindow : IDisposable
|
||||||
|| motionLo == 0x07 // RunForward
|
|| motionLo == 0x07 // RunForward
|
||||||
|| motionLo == 0x0F // SideStepRight
|
|| motionLo == 0x0F // SideStepRight
|
||||||
|| motionLo == 0x10; // SideStepLeft
|
|| motionLo == 0x10; // SideStepLeft
|
||||||
// Locate the server guid for this entity (reverse lookup).
|
|
||||||
// Skip the player's own entity — we drive our own anim locally.
|
|
||||||
uint serverGuid = 0;
|
|
||||||
foreach (var esg in _entitiesByServerGuid)
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; }
|
|
||||||
}
|
|
||||||
if (inLocomotion
|
if (inLocomotion
|
||||||
&& serverGuid != 0
|
&& serverGuid != 0
|
||||||
&& serverGuid != _playerServerGuid
|
&& serverGuid != _playerServerGuid
|
||||||
|
|
@ -3187,6 +3267,84 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||||||
|
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||||||
|
// entities; without integration, remote chars jitter-hop between
|
||||||
|
// samples. Each tick we advance entity.Position by the
|
||||||
|
// sequencer's current velocity (rotated into world space by the
|
||||||
|
// entity's facing) — matching the retail client's
|
||||||
|
// apply_current_movement (chunk_00520000.c L7132-L7189) and
|
||||||
|
// holtburger's project_pose_by_velocity.
|
||||||
|
//
|
||||||
|
// The cap on predict-distance from the last server pos prevents
|
||||||
|
// runaway when the sequencer's velocity and the server's reality
|
||||||
|
// disagree (e.g. server is rubber-banding the entity). Retail
|
||||||
|
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
|
||||||
|
if (ae.Sequencer is not null
|
||||||
|
&& serverGuid != 0
|
||||||
|
&& serverGuid != _playerServerGuid
|
||||||
|
&& _remoteDeadReckon.TryGetValue(serverGuid, out var drState))
|
||||||
|
{
|
||||||
|
System.Numerics.Vector3 worldVel = System.Numerics.Vector3.Zero;
|
||||||
|
|
||||||
|
// Priority 1: sequencer's MotionData velocity, rotated into
|
||||||
|
// world space by the entity's orientation. "World space on
|
||||||
|
// the object" (r03 §1.3) → local vector rotated by entity
|
||||||
|
// rotation → world space.
|
||||||
|
var seqVel = ae.Sequencer.CurrentVelocity;
|
||||||
|
if (seqVel.LengthSquared() > 1e-6f)
|
||||||
|
{
|
||||||
|
worldVel = System.Numerics.Vector3.Transform(seqVel, ae.Entity.Rotation);
|
||||||
|
}
|
||||||
|
// Priority 2: server-supplied world velocity (HasVelocity flag
|
||||||
|
// on UpdatePosition). Already world-space; no rotation.
|
||||||
|
else if (drState.ServerVelocity is { } sv && sv.LengthSquared() > 1e-6f)
|
||||||
|
{
|
||||||
|
worldVel = sv;
|
||||||
|
}
|
||||||
|
// Priority 3: EMA-observed velocity from position deltas.
|
||||||
|
// Fallback for NPC motion tables with HasVelocity=0 (dat
|
||||||
|
// authors didn't encode it). Already world-space.
|
||||||
|
else if (drState.ObservedVelocity.LengthSquared() > 1e-6f
|
||||||
|
&& (now - drState.LastServerPosTime).TotalMilliseconds < 2000.0)
|
||||||
|
{
|
||||||
|
worldVel = drState.ObservedVelocity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worldVel.LengthSquared() > 1e-6f)
|
||||||
|
{
|
||||||
|
// Only integrate while the cycle is a locomotion cycle.
|
||||||
|
// Idle (Ready 0x03) and emotes should stay pinned at the
|
||||||
|
// last server pos — MotionData for Ready has no velocity
|
||||||
|
// anyway, but belt + suspenders.
|
||||||
|
uint mlo = ae.Sequencer.CurrentMotion & 0xFFu;
|
||||||
|
bool isLocomotion = mlo == 0x05 || mlo == 0x06
|
||||||
|
|| mlo == 0x07
|
||||||
|
|| mlo == 0x0F || mlo == 0x10;
|
||||||
|
if (isLocomotion)
|
||||||
|
{
|
||||||
|
var predicted = ae.Entity.Position + worldVel * dt;
|
||||||
|
// Cap prediction radius around last server pos. Over
|
||||||
|
// DeadReckonMaxPredictSeconds we must not drift more
|
||||||
|
// than 1 RunAnimSpeed × run-rate away from server
|
||||||
|
// truth, so cap at |worldVel| * max time.
|
||||||
|
float maxDrift = worldVel.Length() * DeadReckonMaxPredictSeconds;
|
||||||
|
var fromServer = predicted - drState.LastServerPos;
|
||||||
|
if (fromServer.LengthSquared() > maxDrift * maxDrift && maxDrift > 1e-3f)
|
||||||
|
{
|
||||||
|
// Clamp back toward last server position.
|
||||||
|
var clamped = drState.LastServerPos +
|
||||||
|
System.Numerics.Vector3.Normalize(fromServer) * maxDrift;
|
||||||
|
ae.Entity.Position = clamped;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ae.Entity.Position = predicted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
||||||
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
|
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
|
||||||
if (ae.Sequencer is not null)
|
if (ae.Sequencer is not null)
|
||||||
|
|
|
||||||
|
|
@ -1102,6 +1102,51 @@ public sealed class AnimationSequencerTests
|
||||||
Assert.Equal(cursorMid, GetFramePosition(seq), 5);
|
Assert.Equal(cursorMid, GetFramePosition(seq), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CurrentVelocity_ScalesWithSpeedMod()
|
||||||
|
{
|
||||||
|
// A RunForward motion with MotionData.Velocity = (0,4,0) should
|
||||||
|
// surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at
|
||||||
|
// 0.5×. The dead-reckoning integrator in TickAnimations reads
|
||||||
|
// CurrentVelocity each tick, so this has to be accurate.
|
||||||
|
const uint Style = 0x003Du;
|
||||||
|
const uint Motion = 0x0007u;
|
||||||
|
const uint AnimId = 0x03000405u;
|
||||||
|
|
||||||
|
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
|
||||||
|
var setup = Fixtures.MakeSetup(1);
|
||||||
|
var mt = new MotionTable();
|
||||||
|
mt.DefaultStyle = (DRWMotionCommand)Style;
|
||||||
|
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
|
||||||
|
|
||||||
|
var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) };
|
||||||
|
QualifiedDataId<Animation> qid = AnimId;
|
||||||
|
md.Anims.Add(new AnimData
|
||||||
|
{
|
||||||
|
AnimId = qid,
|
||||||
|
LowFrame = 0,
|
||||||
|
HighFrame = -1,
|
||||||
|
Framerate = 10f,
|
||||||
|
});
|
||||||
|
mt.Cycles[cycleKey] = md;
|
||||||
|
|
||||||
|
var loader = new FakeLoader();
|
||||||
|
loader.Register(AnimId, anim);
|
||||||
|
|
||||||
|
var seq = new AnimationSequencer(setup, mt, loader);
|
||||||
|
seq.SetCycle(Style, Motion, speedMod: 1f);
|
||||||
|
Assert.Equal(4f, seq.CurrentVelocity.Y, 3);
|
||||||
|
|
||||||
|
// Start a fresh sequencer so the initial SetCycle applies speedMod.
|
||||||
|
var seq2 = new AnimationSequencer(setup, mt, loader);
|
||||||
|
seq2.SetCycle(Style, Motion, speedMod: 1.5f);
|
||||||
|
Assert.Equal(6f, seq2.CurrentVelocity.Y, 3);
|
||||||
|
|
||||||
|
// Same-motion rescale path also updates velocity.
|
||||||
|
seq2.SetCycle(Style, Motion, speedMod: 0.5f);
|
||||||
|
Assert.Equal(2f, seq2.CurrentVelocity.Y, 2);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetCycle_SameMotionSameSpeed_StaysNoOp()
|
public void SetCycle_SameMotionSameSpeed_StaysNoOp()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue