From b7a9322b400a555ce5bd3221afd21df817a53646 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:29:56 +0200 Subject: [PATCH] feat(anim): dead-reckoning remote entity positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 176 +++++++++++++++++- .../Physics/AnimationSequencerTests.cs | 45 +++++ 2 files changed, 212 insertions(+), 9 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9dfe8ce..a5ba3f4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -150,6 +150,54 @@ public sealed class GameWindow : IDisposable private readonly Dictionary _remoteLastMove = new(); + /// + /// 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 CMotionInterp-surfaced + /// velocity forward each tick — see chunk_00520000.c + /// apply_current_movement L7132-L7189 and holtburger's + /// spatial/physics.rs::project_pose_by_velocity). + /// + /// + /// 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 CurrentVelocity (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). + /// + /// + private readonly Dictionary _remoteDeadReckon = new(); + + private sealed class RemoteDeadReckonState + { + /// Last server-authoritative world position. + public System.Numerics.Vector3 LastServerPos; + /// When that last server position arrived (UTC). + public System.DateTime LastServerPosTime; + /// Last server-authoritative world rotation. + public System.Numerics.Quaternion LastServerRot = System.Numerics.Quaternion.Identity; + /// + /// 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. + /// + public System.Numerics.Vector3 ObservedVelocity; + /// Server-supplied world velocity from UpdatePosition (HasVelocity flag). + public System.Numerics.Vector3? ServerVelocity; + } + + /// + /// 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. + /// + private const float DeadReckonMaxPredictSeconds = 1.0f; + // Phase F.1-H.1 — client-side state classes fed by GameEventWiring. // Exposed publicly so plugins + UI panels can bind directly. 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 // that report the same position keep the old Time, so the // 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) { + var now = System.DateTime.UtcNow; if (_remoteLastMove.TryGetValue(update.Guid, out var prev)) { float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos); 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 { - _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. @@ -3154,6 +3232,15 @@ public sealed class GameWindow : IDisposable { 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 // locomotion cycle and their position hasn't changed in >400ms, // the retail player stopped moving. Swap them to Ready. This @@ -3166,13 +3253,6 @@ public sealed class GameWindow : IDisposable || motionLo == 0x07 // RunForward || motionLo == 0x0F // SideStepRight || 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 && serverGuid != 0 && 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 ── IReadOnlyList? seqFrames = null; if (ae.Sequencer is not null) diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 1f9316d..717ec2e 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -1102,6 +1102,51 @@ public sealed class AnimationSequencerTests 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 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] public void SetCycle_SameMotionSameSpeed_StaysNoOp() {