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; } =
|
public AcDream.Core.Physics.PositionManager Position { get; } =
|
||||||
new AcDream.Core.Physics.PositionManager();
|
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()
|
public RemoteMotion()
|
||||||
{
|
{
|
||||||
Body = new AcDream.Core.Physics.PhysicsBody
|
Body = new AcDream.Core.Physics.PhysicsBody
|
||||||
|
|
@ -3266,6 +3293,25 @@ public sealed class GameWindow : IDisposable
|
||||||
// position only; heading would otherwise lag the queue.
|
// position only; heading would otherwise lag the queue.
|
||||||
rmState.Body.Orientation = rot;
|
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 ────────────────────────────────────────────
|
// ── AIRBORNE NO-OP ────────────────────────────────────────────
|
||||||
// Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330):
|
// Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330):
|
||||||
// when has_contact==0, return false (don't touch body, don't queue).
|
// 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²).
|
// Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
|
||||||
rm.Body.UpdatePhysicsInternal(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.Position = rm.Body.Position;
|
||||||
ae.Entity.Rotation = rm.Body.Orientation;
|
ae.Entity.Rotation = rm.Body.Orientation;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -567,10 +567,15 @@ public sealed class AnimationSequencer
|
||||||
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
|
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
|
||||||
zomega = -(MathF.PI / 2f) * adjustedSpeed;
|
zomega = -(MathF.PI / 2f) * adjustedSpeed;
|
||||||
break;
|
break;
|
||||||
case 0x0E: // TurnLeft — counter-clockwise = +Z. adjust_motion
|
case 0x0E: // TurnLeft — counter-clockwise = +Z.
|
||||||
// may have remapped 0x0E → 0x0D with negated speed;
|
// adjust_motion above ALREADY remapped 0x0E → 0x0D
|
||||||
// in that case the negation preserves correct sign.
|
// with adjustedSpeed = -speedMod, so the same
|
||||||
zomega = (MathF.PI / 2f) * adjustedSpeed;
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
if (zomega != 0f)
|
if (zomega != 0f)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue