From 0997f9607824a149a55c05136a0e28228d389bdc Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 3 May 2026 10:48:10 +0200 Subject: [PATCH] fix(motion): landing fallback + TurnLeft omega sign + vel diagnostic (L.3.2) Three Option-A patches addressing visual issues from the L.3.1+L.3.2 remote-entity motion path (gated by ACDREAM_INTERP_MANAGER=1): 1. Landing fallback. ACE doesn't always send IsGrounded=true on the landing frame, so airborne remotes kept falling under gravity and visually "disappeared into the ground" until the next non-stop UP forced a re-snap. Track the most recent server-broadcast Z on every UP (including mid-arc airborne ones) and, in TickAnimations, snap the body back up + clear airborne when its predicted Z drops more than 0.5 m below that floor. 2. TurnLeft omega sign. The synthesize-omega fallback in AnimationSequencer (used when MotionData ships without HasOmega) had case 0x0E using zomega = +(pi/2) * adjustedSpeed, but adjust_motion above already remapped 0x0E to 0x0D with adjustedSpeed = -speedMod. The double-negate produced -Z (clockwise = right) for both turn directions, matching the reported "turning left animates as turning right". Use the same -(pi/2) * adjustedSpeed formula as case 0x0D so the negation lands the result on +Z (CCW). 3. Velocity diagnostic. New env var ACDREAM_REMOTE_VEL_DIAG=1 prints one line per moving remote per ~2 seconds comparing the sequencer's CurrentVelocity to the server's effective broadcast pace ((LastServerPos - PrevServerPos) / dt). Lets us measure the speed-overshoot ratio that produces the residual 1-Hz blippiness before tuning a fix. Refs Phase L.3.1+L.3.2 spec at docs/superpowers/specs/2026-05-02-l3-remote-entity-motion-design.md. Co-Authored-By: Claude Opus 4.7 --- src/AcDream.App/Rendering/GameWindow.cs | 105 ++++++++++++++++++ .../Physics/AnimationSequencer.cs | 13 ++- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d87df04..6d0c297 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -350,6 +350,33 @@ public sealed class GameWindow : IDisposable public AcDream.Core.Physics.PositionManager Position { get; } = new AcDream.Core.Physics.PositionManager(); + /// + /// Most recent server-broadcast Z coordinate from any UpdatePosition + /// (including mid-arc airborne UPs). Used by the + /// ACDREAM_INTERP_MANAGER=1 per-tick path as a landing-fallback + /// floor: if gravity drags the body's Z below this value while + /// is still set, force-land locally because + /// the server has effectively told us where the ground is even if + /// it never sent an IsGrounded=true UP. Initialized to NaN so the + /// fallback is a no-op until the first UP arrives. + /// + public float LastServerZ = float.NaN; + + /// + /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the + /// previous UpdatePosition's world position + timestamp. The per-tick + /// path computes (serverPos - prevServerPos) / dt and compares + /// it to the sequencer's CurrentVelocity. The ratio tells us + /// whether the local-prediction speed (animation root motion) is + /// outrunning the server's actual broadcast pace, which would cause + /// the InterpolationManager queue to walk back the body each UP and + /// produce visible 1-Hz blips. Read in TickAnimations and throttled + /// to one log line per remote per ~2 seconds. + /// + public System.Numerics.Vector3 PrevServerPos; + public double PrevServerPosTime; + public double LastVelDiagLogTime; + public RemoteMotion() { Body = new AcDream.Core.Physics.PhysicsBody @@ -3266,6 +3293,25 @@ public sealed class GameWindow : IDisposable // position only; heading would otherwise lag the queue. rmState.Body.Orientation = rot; + // Track the most recent server-broadcast Z on EVERY UP — including + // mid-arc airborne ones. Read by the per-tick landing-fallback in + // TickAnimations: if gravity drags the body below this floor while + // still airborne, we force-land locally even when the server never + // sent an IsGrounded=true UP for the actual landing frame. + rmState.LastServerZ = worldPos.Z; + + // Diagnostic-only (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous + // server-pos snapshot forward so the per-tick comparison has a + // delta to work with. Cheap (struct copy + double write); not + // gated here because the read side gates the actual print. + { + double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + rmState.PrevServerPos = rmState.LastServerPos; + rmState.PrevServerPosTime = rmState.LastServerPosTime; + rmState.LastServerPos = worldPos; + rmState.LastServerPosTime = nowSecDiag; + } + // ── AIRBORNE NO-OP ──────────────────────────────────────────── // Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330): // when has_contact==0, return false (don't touch body, don't queue). @@ -5839,6 +5885,65 @@ public sealed class GameWindow : IDisposable // Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²). rm.Body.UpdatePhysicsInternal(dt); + // Step 5: landing fallback. The retail-faithful path leaves + // the landing transition to OnLivePositionUpdated when ACE + // sends IsGrounded=true. In practice ACE doesn't always + // broadcast that flag promptly — the body keeps falling + // under gravity and visibly disappears into the ground until + // the next non-stop UP arrives (e.g. when the player turns). + // The remote's most recent server-reported Z is an + // authoritative ground floor: if our predicted body has + // sunk below it by more than half a meter, snap up to it + // and clear airborne, mirroring the OnLivePositionUpdated + // landing-transition branch. Threshold matches retail's + // MIN_DISTANCE_TO_REACH_POSITION-style tolerance. + if (rm.Airborne + && !float.IsNaN(rm.LastServerZ) + && rm.Body.Position.Z < rm.LastServerZ - 0.5f) + { + rm.Airborne = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact + | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Interp.Clear(); + rm.Body.Position = new System.Numerics.Vector3( + rm.Body.Position.X, rm.Body.Position.Y, rm.LastServerZ); + } + + // Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1). + // Compare the sequencer's body-local CurrentVelocity (root motion + // we're applying per tick) against the server's effective + // broadcast pace ((LastServerPos - PrevServerPos) / Δt). If + // |seqVel| significantly exceeds |serverVel|, the body + // overshoots between UPs and the InterpolationManager has to + // walk it backward each waypoint — visible as 1-Hz blips. + // The ratio prints once per remote per ~2 seconds so a moving + // remote shows up without flooding the console. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && rm.PrevServerPosTime > 0.0 + && rm.LastServerPosTime > rm.PrevServerPosTime) + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - rm.LastVelDiagLogTime > 2.0) + { + double dtServer = rm.LastServerPosTime - rm.PrevServerPosTime; + var serverDelta = rm.LastServerPos - rm.PrevServerPos; + float serverSpeed = (float)(serverDelta.Length() / dtServer); + float seqSpeed = seqVel.Length(); + // Only log when the entity is actually moving — skip + // idle remotes where both speeds are ~0. + if (serverSpeed > 0.1f || seqSpeed > 0.1f) + { + System.Console.WriteLine( + $"[VEL_DIAG] guid={serverGuid:X8} seqSpeed={seqSpeed:F3} m/s " + + $"serverSpeed={serverSpeed:F3} m/s " + + $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}"); + rm.LastVelDiagLogTime = nowSec; + } + } + } + ae.Entity.Position = rm.Body.Position; ae.Entity.Rotation = rm.Body.Orientation; } diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index a6d57ba..d270f35 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -567,10 +567,15 @@ public sealed class AnimationSequencer case 0x0D: // TurnRight — clockwise from above = -Z in right-handed. zomega = -(MathF.PI / 2f) * adjustedSpeed; break; - case 0x0E: // TurnLeft — counter-clockwise = +Z. adjust_motion - // may have remapped 0x0E → 0x0D with negated speed; - // in that case the negation preserves correct sign. - zomega = (MathF.PI / 2f) * adjustedSpeed; + case 0x0E: // TurnLeft — counter-clockwise = +Z. + // adjust_motion above ALREADY remapped 0x0E → 0x0D + // with adjustedSpeed = -speedMod, so the same + // formula as 0x0D applied to the negated speed + // produces the correct +Z (CCW) result. Using a + // different sign here would double-negate and + // animate a left turn as a right turn — that was + // the bug observed before this fix (commit follows). + zomega = -(MathF.PI / 2f) * adjustedSpeed; break; } if (zomega != 0f)