diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index ede9580..308da9e 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -240,6 +240,18 @@ public sealed class GameWindow : IDisposable
///
public uint CellId;
+ ///
+ /// K-fix9 (2026-04-26): true while the remote is airborne (jump
+ /// arc in flight). Set when a 0xF74E VectorUpdate arrives with
+ /// non-trivial +Z velocity; cleared when the next UpdatePosition
+ /// snaps to a new ground location. While true, the per-tick
+ /// remote update SKIPS the "force OnWalkable + apply_current_movement"
+ /// step that would otherwise stomp the body's Z velocity each
+ /// frame, AND enables gravity so the parabolic arc actually plays
+ /// out between server snaps.
+ ///
+ public bool Airborne;
+
public RemoteMotion()
{
Body = new AcDream.Core.Physics.PhysicsBody
@@ -1169,6 +1181,7 @@ public sealed class GameWindow : IDisposable
_liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated;
+ _liveSession.VectorUpdated += OnLiveVectorUpdated;
_liveSession.TeleportStarted += OnTeleportStarted;
// Phase 6c — PlayScript (0xF754) arrives from the server as
@@ -2337,6 +2350,45 @@ public sealed class GameWindow : IDisposable
/// snap the player entity + controller, and return to InWorld. Also sends
/// LoginComplete so the server knows the client has loaded the destination.
///
+ ///
+ /// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate — ACE broadcasts
+ /// this on remote-player JUMPS (Player.cs:954). The payload carries
+ /// the world-space launch velocity. Without handling it, remote
+ /// jumps render as a tiny lift-and-back because we never see the
+ /// +Z velocity that would integrate into a proper arc.
+ ///
+ private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
+ {
+ if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
+ if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return;
+
+ // World-space velocity. Apply directly to the body — the per-tick
+ // remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt².
+ rm.Body.Velocity = update.Velocity;
+
+ // Mark airborne when the launch has meaningful +Z. Threshold
+ // 0.5 m/s rejects noise / horizontal-only updates (server might
+ // also use VectorUpdate for non-jump events). The per-tick
+ // remote update reads .Airborne to skip the ground-clamp branch
+ // and apply gravity instead.
+ if (update.Velocity.Z > 0.5f)
+ {
+ rm.Airborne = true;
+ // Clear ground-contact bits + enable gravity so calc_acceleration
+ // returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then
+ // produces the parabolic arc.
+ rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact
+ | AcDream.Core.Physics.TransientStateFlags.OnWalkable);
+ rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
+ }
+
+ if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
+ {
+ Console.WriteLine(
+ $"VU guid=0x{update.Guid:X8} vel=({update.Velocity.X:F2},{update.Velocity.Y:F2},{update.Velocity.Z:F2}) airborne={rm.Airborne}");
+ }
+ }
+
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
// Phase A.1: track the most recently updated entity's landblock so the
@@ -2407,6 +2459,20 @@ 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;
+ }
// 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
@@ -4490,10 +4556,28 @@ public sealed class GameWindow : IDisposable
// Forces OnWalkable + Contact so the gate in apply_current_movement
// always succeeds (remotes are server-authoritative; we don't
// simulate airborne physics for them).
- rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
- | AcDream.Core.Physics.TransientStateFlags.OnWalkable
- | AcDream.Core.Physics.TransientStateFlags.Active;
- rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+ //
+ // K-fix9 (2026-04-26): SKIP this when the remote is airborne.
+ // Otherwise the force-OnWalkable + apply_current_movement
+ // path stomps the +Z velocity we set in OnLiveVectorUpdated,
+ // and gravity never gets to integrate the arc. The airborne
+ // body keeps the launch velocity from the VectorUpdate;
+ // UpdatePhysicsInternal below applies gravity each tick;
+ // the next UpdatePosition snaps to the new ground location
+ // and re-grounds.
+ if (!rm.Airborne)
+ {
+ rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
+ | AcDream.Core.Physics.TransientStateFlags.OnWalkable
+ | AcDream.Core.Physics.TransientStateFlags.Active;
+ rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
+ }
+ else
+ {
+ // Airborne — keep Active flag (so UpdatePhysicsInternal
+ // doesn't early-return) but DON'T set Contact / OnWalkable.
+ rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
+ }
// Step 2: integrate rotation manually per tick. We can't
// rely on PhysicsBody.update_object here — its MinQuantum
@@ -4573,7 +4657,12 @@ public sealed class GameWindow : IDisposable
sphereHeight: 1.2f,
stepUpHeight: 2.0f, // retail default for unknown remotes
stepDownHeight: 0.04f, // PhysicsGlobals.DefaultStepHeight
- isOnGround: true, // remotes are forced OnWalkable above
+ // K-fix9 (2026-04-26): mirror the K-fix7 gate —
+ // airborne remotes must NOT pre-seed the
+ // ContactPlane, otherwise AdjustOffset's snap-to-plane
+ // branch zeroes the +Z offset every step (same bug
+ // we hit on the local jump).
+ isOnGround: !rm.Airborne,
body: rm.Body); // persist ContactPlane across frames for slope tracking
rm.Body.Position = resolveResult.Position;
diff --git a/src/AcDream.Core.Net/Messages/VectorUpdate.cs b/src/AcDream.Core.Net/Messages/VectorUpdate.cs
new file mode 100644
index 0000000..f9dfcff
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/VectorUpdate.cs
@@ -0,0 +1,71 @@
+using System.Buffers.Binary;
+using System.Numerics;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Inbound VectorUpdate GameMessage (opcode 0xF74E). The
+/// server broadcasts this when a remote entity's velocity / omega changes
+/// without an accompanying full UpdatePosition — most importantly when a
+/// remote player JUMPS. Without handling this, remote jumps look like
+/// the player teleported through a tiny vertical hop and back: we never
+/// see the +Z velocity that would integrate into a proper arc.
+///
+///
+/// Wire layout (see
+/// references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageVectorUpdate.cs):
+///
+///
+/// - u32 opcode — 0xF74E
+/// - u32 objectGuid
+/// - 3xf32 velocity — world-space (already rotated by ACE's
+/// GameMessageVectorUpdate.cs:20 from PhysicsObj.Velocity).
+/// - 3xf32 omega — world-space angular velocity.
+/// - u16 instanceSequence — for stale-packet rejection.
+/// - u16 vectorSequence — for stale-packet rejection.
+///
+///
+/// Total body size after opcode: 32 bytes.
+///
+public static class VectorUpdate
+{
+ public const uint Opcode = 0xF74Eu;
+
+ public readonly record struct Parsed(
+ uint Guid,
+ Vector3 Velocity,
+ Vector3 Omega,
+ ushort InstanceSequence,
+ ushort VectorSequence);
+
+ ///
+ /// Parse a 0xF74E body. Returns null if the buffer is truncated or
+ /// malformed (sequence-number mismatch is not checked here — the
+ /// session-level handler decides what to do).
+ ///
+ public static Parsed? TryParse(ReadOnlySpan body)
+ {
+ if (body.Length < 32) return null;
+ try
+ {
+ uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[..4]);
+
+ float vx = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body.Slice(4, 4)));
+ float vy = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body.Slice(8, 4)));
+ float vz = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body.Slice(12, 4)));
+
+ float ox = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body.Slice(16, 4)));
+ float oy = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body.Slice(20, 4)));
+ float oz = BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32LittleEndian(body.Slice(24, 4)));
+
+ ushort instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(28, 2));
+ ushort vecSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(30, 2));
+
+ return new Parsed(guid, new Vector3(vx, vy, vz), new Vector3(ox, oy, oz), instSeq, vecSeq);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 3b752e9..3389fb7 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -92,6 +92,17 @@ public sealed class WorldSession : IDisposable
///
public event Action? PositionUpdated;
+ ///
+ /// Fires when the session parses a 0xF74E VectorUpdate game message.
+ /// ACE broadcasts this whenever a remote entity's velocity / omega
+ /// changes outside the normal UpdatePosition cadence — the canonical
+ /// case is a remote player JUMPING (Player.cs:954
+ /// EnqueueBroadcast(new GameMessageVectorUpdate(this));).
+ /// Subscribers update the remote's PhysicsBody velocity + airborne
+ /// state so the dead-reckoning produces a proper jump arc.
+ ///
+ public event Action? VectorUpdated;
+
///
/// Fires when the server sends a PlayerTeleport (0xF751) game message,
/// signalling that the player is entering portal space. The uint payload
@@ -667,6 +678,20 @@ public sealed class WorldSession : IDisposable
posUpdate.Value.Velocity));
}
}
+ else if (op == VectorUpdate.Opcode)
+ {
+ // K-fix9 (2026-04-26): server-broadcast remote jump
+ // velocity. ACE Player.cs:954 enqueues this on every
+ // jump in addition to the bracketing UpdateMotion. The
+ // payload's velocity field is the world-space launch
+ // velocity (post-rotation in
+ // GameMessageVectorUpdate.cs:20-24); subscribers feed
+ // it into the remote PhysicsBody so the dead-reckoning
+ // tick can integrate the arc.
+ var parsed = VectorUpdate.TryParse(body);
+ if (parsed is not null)
+ VectorUpdated?.Invoke(parsed.Value);
+ }
else if (op == HearSpeech.LocalOpcode || op == HearSpeech.RangedOpcode)
{
// Phase H.1: local/ranged chat. Standalone GameMessage