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