fix(net): remote jumps were stuck at apex — let physics detect landing instead of UP-driven clear

Cause of "remote characters jump up and get stuck in the air":
K-fix9 cleared rm.Airborne on every UpdatePosition, but ACE
broadcasts UPs during the arc (peak / mid-fall / land) at
~5-10 Hz. The first UP after a jump:
  1. Snapped body position to server mid-arc Z (often the apex).
  2. Cleared rm.Airborne -- restored Contact + OnWalkable, removed
     the Gravity flag.
  3. Next per-tick: apply_current_movement reads
     InterpretedState (Ready) and stomps Body.Velocity to
     (X, Y, 0).
Body stuck at apex Z forever.

Fix: do not auto-clear Airborne on UP. The position snap stays
authoritative -- if ACE says the body is at Z=68 mid-arc we
render Z=68, but we keep integrating gravity from there.
Per-tick post-resolve now detects a real landing -- mirrors the
local-player landing path in PlayerMovementController: when the
resolver returns IsOnGround && Velocity.Z <= 0, clear Airborne,
restore Contact + OnWalkable, remove Gravity, zero residual
downward velocity, and call HitGround so the sequencer can swap
Falling to idle/locomotion.

ACDREAM_DUMP_MOTION=1 logs each landing as
"VU.land guid=0x... Z=...".

Tests stay 1222 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 21:42:26 +02:00
parent 8db7a9ec28
commit 68d521df4f

View file

@ -2506,20 +2506,26 @@ public sealed class GameWindow : IDisposable
rmState.Body.Orientation = rot;
}
rmState.Body.Position = worldPos;
// K-fix9 (2026-04-26): UpdatePosition is the server's
// authoritative re-grounding signal after a jump. Clear the
// airborne flag, restore Contact + OnWalkable, and disable
// gravity so the next per-tick remote update goes back to
// the regular ground-clamped path. The server typically
// sends a UP at the apex / mid-arc / land — our integration
// fills in between.
if (rmState.Airborne)
{
rmState.Airborne = false;
rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
}
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
// the FIRST UP after the jump, which:
// * restored Contact + OnWalkable,
// * removed the Gravity flag,
// * caused the next per-tick to stomp Velocity via
// apply_current_movement (reading InterpretedState =
// Ready, so Velocity.Z went to 0),
// …so the body got stuck at the server-broadcast apex Z,
// visibly hovering. The fix: leave Airborne true; the
// per-tick post-resolve logic detects an actual landing
// (resolveResult.IsOnGround && Velocity.Z <= 0) and clears
// it then. Mirrors how PlayerMovementController re-grounds
// the local player at the bottom of its arc.
//
// The position-snap above is still authoritative — if ACE
// says the body is at Z=68 mid-arc, we render Z=68. But we
// continue integrating gravity from there, so the body
// proceeds along the parabolic path between UPs.
// Adopt the server's cell ID as the transition starting cell.
// Retail authoritatively hard-snaps cell membership here too; our
// per-tick ResolveWithTransition sweep then advances CheckCellId
@ -4723,6 +4729,35 @@ public sealed class GameWindow : IDisposable
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
rm.CellId = resolveResult.CellId;
// K-fix15 (2026-04-26): post-resolve landing
// detection for airborne remotes. Mirrors
// PlayerMovementController's local-player landing
// path: when the resolver says we're on ground AND
// velocity is no longer pointing up, transition
// back to grounded — clear Airborne, restore
// Contact + OnWalkable, remove Gravity, zero any
// residual downward velocity, and trigger
// HitGround so the sequencer can swap from
// Falling → idle/locomotion. Without this, an
// airborne remote falls through the floor (gravity
// keeps building Velocity.Z negative until the
// sphere-sweep clamps each frame, but Airborne
// stays true forever).
if (rm.Airborne
&& resolveResult.IsOnGround
&& rm.Body.Velocity.Z <= 0f)
{
rm.Airborne = false;
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
rm.Body.Velocity = new System.Numerics.Vector3(
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
rm.Motion.HitGround();
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
}
}
ae.Entity.Position = rm.Body.Position;