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() {