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:
Erik 2026-05-03 10:48:10 +02:00
parent c1bfd64834
commit 0997f96078
2 changed files with 114 additions and 4 deletions

View file

@ -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;
}

View file

@ -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)