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 <noreply@anthropic.com>
This commit is contained in:
parent
c1bfd64834
commit
0997f96078
2 changed files with 114 additions and 4 deletions
|
|
@ -350,6 +350,33 @@ public sealed class GameWindow : IDisposable
|
|||
public AcDream.Core.Physics.PositionManager Position { get; } =
|
||||
new AcDream.Core.Physics.PositionManager();
|
||||
|
||||
/// <summary>
|
||||
/// Most recent server-broadcast Z coordinate from any UpdatePosition
|
||||
/// (including mid-arc airborne UPs). Used by the
|
||||
/// <c>ACDREAM_INTERP_MANAGER=1</c> per-tick path as a landing-fallback
|
||||
/// floor: if gravity drags the body's Z below this value while
|
||||
/// <see cref="Airborne"/> 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.
|
||||
/// </summary>
|
||||
public float LastServerZ = float.NaN;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
|
||||
/// previous UpdatePosition's world position + timestamp. The per-tick
|
||||
/// path computes <c>(serverPos - prevServerPos) / dt</c> and compares
|
||||
/// it to the sequencer's <c>CurrentVelocity</c>. 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue