feat(net): handle 0xF74E VectorUpdate so remote players' jumps render

Remote-player jumps were silently dropped — we never parsed the
VectorUpdate broadcast that carries the jump launch velocity, so
the remote body's Z velocity stayed at 0 and the jump animation
showed without any vertical motion.

ACE Player.cs:954 enqueues GameMessageVectorUpdate (opcode 0xF74E)
on every jump in addition to the bracketing UpdateMotion. Wire
layout (GameMessageVectorUpdate.cs):

  u32 opcode (= 0xF74E)
  u32 objectGuid
  3xf32 velocity (world-space, post-rotation)
  3xf32 omega
  u16 instanceSequence
  u16 vectorSequence

This commit:

1. Adds VectorUpdate.TryParse + VectorUpdated session event.
2. WorldSession.ProcessDatagram dispatches 0xF74E.
3. GameWindow subscribes via OnLiveVectorUpdated:
   - Sets remote PhysicsBody.Velocity from the wire vector.
   - When velocity.Z > 0.5 m/s, marks the remote as Airborne,
     clears Contact + OnWalkable bits, and enables the Gravity
     state flag — so calc_acceleration returns (0, 0, -9.8) and
     UpdatePhysicsInternal produces a parabolic arc.
4. The per-tick remote update (TickAnimations remote-physics
   block) now SKIPS the "force OnWalkable + apply_current_movement"
   step when Airborne. Otherwise that path stomps the +Z velocity
   each frame — same shape as the bug the local jump hit before
   K-fix7.
5. ResolveWithTransition for remotes now passes
   isOnGround: !rm.Airborne. Mirrors K-fix7's local-player gate —
   airborne resolves must NOT pre-seed the ContactPlane,
   otherwise AdjustOffset's snap-to-plane branch zeroes the
   upward offset.
6. UpdatePosition handler clears the airborne flag and restores
   ground-contact bits, so the server's authoritative re-grounding
   ends the arc cleanly at the new ground location.

ACDREAM_DUMP_MOTION=1 logs each VectorUpdate as
"VU guid=0x... vel=(...) airborne=...".

Tests stay 1222 green. Live verification pending — watch a remote
character jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-26 17:38:32 +02:00
parent 1fce21034a
commit b609b5ea6e
3 changed files with 190 additions and 5 deletions

View file

@ -240,6 +240,18 @@ public sealed class GameWindow : IDisposable
/// </summary>
public uint CellId;
/// <summary>
/// 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.
/// </summary>
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.
/// </summary>
/// <summary>
/// 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.
/// </summary>
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;

View file

@ -0,0 +1,71 @@
using System.Buffers.Binary;
using System.Numerics;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>VectorUpdate</c> GameMessage (opcode <c>0xF74E</c>). 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.
///
/// <para>
/// Wire layout (see
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageVectorUpdate.cs</c>):
/// </para>
/// <list type="bullet">
/// <item><b>u32 opcode</b> — 0xF74E</item>
/// <item><b>u32 objectGuid</b></item>
/// <item><b>3xf32 velocity</b> — world-space (already rotated by ACE's
/// GameMessageVectorUpdate.cs:20 from PhysicsObj.Velocity).</item>
/// <item><b>3xf32 omega</b> — world-space angular velocity.</item>
/// <item><b>u16 instanceSequence</b> — for stale-packet rejection.</item>
/// <item><b>u16 vectorSequence</b> — for stale-packet rejection.</item>
/// </list>
///
/// Total body size after opcode: 32 bytes.
/// </summary>
public static class VectorUpdate
{
public const uint Opcode = 0xF74Eu;
public readonly record struct Parsed(
uint Guid,
Vector3 Velocity,
Vector3 Omega,
ushort InstanceSequence,
ushort VectorSequence);
/// <summary>
/// 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).
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> 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;
}
}
}

View file

@ -92,6 +92,17 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<EntityPositionUpdate>? PositionUpdated;
/// <summary>
/// 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
/// <c>EnqueueBroadcast(new GameMessageVectorUpdate(this));</c>).
/// Subscribers update the remote's PhysicsBody velocity + airborne
/// state so the dead-reckoning produces a proper jump arc.
/// </summary>
public event Action<VectorUpdate.Parsed>? VectorUpdated;
/// <summary>
/// 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