diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7143d4b..669f599 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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;