diff --git a/src/AcDream.Core.Net/Messages/UpdatePosition.cs b/src/AcDream.Core.Net/Messages/UpdatePosition.cs new file mode 100644 index 0000000..77217c2 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/UpdatePosition.cs @@ -0,0 +1,164 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound UpdatePosition GameMessage (opcode 0xF748). The +/// server sends this whenever an entity moves in the world — NPCs walking +/// their patrol routes, creatures hunting, other players running past, +/// projectiles tracking. Without handling this, NPCs only ever render at +/// their CreateObject spawn point and never follow their server-side +/// position. Pairs with : motion tells us +/// what cycle to play, position tells us where in world space. +/// +/// +/// Wire layout (see +/// references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdatePosition.cs +/// and references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs::Write): +/// +/// +/// u32 opcode — 0xF748 +/// u32 objectGuid +/// u32 positionFlags — see +/// Origin — u32 landblockCellId + 3xf32 local XYZ +/// Rotation components — f32 W / X / Y / Z, but only the +/// ones whose OrientationHasNo* flag is clear. Absent +/// components default to 0. +/// Velocity — 3xf32 if HasVelocity set +/// PlacementID — u32 if HasPlacementID set +/// Four u16 sequence numbers — instance, position, teleport, +/// forcePosition. We don't currently check these for freshness but +/// we must consume them to walk the buffer correctly. +/// +/// +public static class UpdatePosition +{ + public const uint Opcode = 0xF748u; + + /// + /// Bitflag layout mirroring ACE.Entity.Enum.PositionFlags. + /// Exposed so callers can inspect whether velocity / placement were + /// present in the wire payload, though for the basic rendering use + /// case only the position/rotation matter. + /// + [Flags] + public enum PositionFlags : uint + { + None = 0x00, + HasVelocity = 0x01, + HasPlacementID = 0x02, + IsGrounded = 0x04, + OrientationHasNoW = 0x08, + OrientationHasNoX = 0x10, + OrientationHasNoY = 0x20, + OrientationHasNoZ = 0x40, + } + + /// + /// Extracted payload: the target guid plus its new world position and + /// rotation. Velocity and placement are captured too but are optional + /// information for clients that want to smooth motion between updates. + /// + public readonly record struct Parsed( + uint Guid, + CreateObject.ServerPosition Position, + System.Numerics.Vector3? Velocity, + uint? PlacementId); + + /// + /// Parse a reassembled UpdatePosition body. + /// must start with the 4-byte opcode. Returns null on truncation or + /// wrong opcode. + /// + public static Parsed? TryParse(ReadOnlySpan body) + { + try + { + int pos = 0; + if (body.Length - pos < 4) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + if (opcode != Opcode) return null; + + if (body.Length - pos < 4) return null; + uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + + if (body.Length - pos < 4) return null; + var flags = (PositionFlags)BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + + // Origin: u32 cellId + Vector3 position + if (body.Length - pos < 16) return null; + uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + float px = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)); + float py = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 8)); + float pz = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 12)); + pos += 16; + + // Rotation: each component is f32 *only if the corresponding + // OrientationHasNo* flag is CLEAR*. An unset flag means "this + // component is present in the payload". Default 0 for absent. + float rw = 0f, rx = 0f, ry = 0f, rz = 0f; + if ((flags & PositionFlags.OrientationHasNoW) == 0) + { + if (body.Length - pos < 4) return null; + rw = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } + if ((flags & PositionFlags.OrientationHasNoX) == 0) + { + if (body.Length - pos < 4) return null; + rx = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } + if ((flags & PositionFlags.OrientationHasNoY) == 0) + { + if (body.Length - pos < 4) return null; + ry = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } + if ((flags & PositionFlags.OrientationHasNoZ) == 0) + { + if (body.Length - pos < 4) return null; + rz = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); + pos += 4; + } + + System.Numerics.Vector3? velocity = null; + if ((flags & PositionFlags.HasVelocity) != 0) + { + if (body.Length - pos < 12) return null; + velocity = new System.Numerics.Vector3( + BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 0)), + BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)), + BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 8))); + pos += 12; + } + + uint? placementId = null; + if ((flags & PositionFlags.HasPlacementID) != 0) + { + if (body.Length - pos < 4) return null; + placementId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); + pos += 4; + } + + // We deliberately skip the four u16 sequence numbers that + // follow; subscribers don't need them for simple rendering, + // and if the message is a deliberate out-of-order teleport the + // server always follows up with a fresher update anyway. + + var serverPos = new CreateObject.ServerPosition( + LandblockId: cellId, + PositionX: px, PositionY: py, PositionZ: pz, + RotationW: rw, RotationX: rx, RotationY: ry, RotationZ: rz); + + return new Parsed(guid, serverPos, velocity, placementId); + } + catch + { + return null; + } + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index fe3c8f5..5b8482f 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -74,6 +74,23 @@ public sealed class WorldSession : IDisposable /// public event Action? MotionUpdated; + /// + /// Payload for : the server guid plus a + /// full describing the + /// entity's new world position and rotation. Subscribers translate + /// the landblock-local position into acdream world space and reseat + /// the corresponding WorldEntity. + /// + public readonly record struct EntityPositionUpdate( + uint Guid, + CreateObject.ServerPosition Position, + System.Numerics.Vector3? Velocity); + + /// + /// Fires when the session parses a 0xF748 UpdatePosition game message. + /// + public event Action? PositionUpdated; + /// Raised every time the state machine transitions. public event Action? StateChanged; @@ -275,6 +292,22 @@ public sealed class WorldSession : IDisposable motion.Value.MotionState)); } } + else if (op == UpdatePosition.Opcode) + { + // Phase 6.7: the server sends UpdatePosition (0xF748) every + // time an entity moves through the world — NPC patrols, + // creatures hunting, other players walking past, projectiles + // tracking. Without this, everything stays at its + // CreateObject spawn point forever. + var posUpdate = UpdatePosition.TryParse(body); + if (posUpdate is not null) + { + PositionUpdated?.Invoke(new EntityPositionUpdate( + posUpdate.Value.Guid, + posUpdate.Value.Position, + posUpdate.Value.Velocity)); + } + } } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs new file mode 100644 index 0000000..ff82a99 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs @@ -0,0 +1,174 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Covers — the 0xF748 GameMessage +/// the server sends whenever an entity's world position changes. The +/// layout relies on conditional field presence driven by PositionFlags, +/// so the tests exercise both the full-rotation and missing-component +/// paths plus the optional velocity/placement fields. +/// +public class UpdatePositionTests +{ + [Fact] + public void RejectsWrongOpcode() + { + var body = new byte[64]; + BinaryPrimitives.WriteUInt32LittleEndian(body, 0xCAFEBABE); + Assert.Null(UpdatePosition.TryParse(body)); + } + + [Fact] + public void RejectsTruncated() + { + Assert.Null(UpdatePosition.TryParse(Array.Empty())); + Assert.Null(UpdatePosition.TryParse(new byte[3])); + } + + [Fact] + public void ParsesAllFourRotationComponentsPresent() + { + // Full rotation, no velocity, no placement. + var body = BuildBody( + guid: 0x12345678u, + flags: 0, + cellId: 0xA9B4001Au, + px: 10f, py: 20f, pz: 30f, + rw: 1f, rx: 0f, ry: 0f, rz: 0f); + + var result = UpdatePosition.TryParse(body); + Assert.NotNull(result); + Assert.Equal(0x12345678u, result!.Value.Guid); + Assert.Equal(0xA9B4001Au, result.Value.Position.LandblockId); + Assert.Equal(10f, result.Value.Position.PositionX); + Assert.Equal(20f, result.Value.Position.PositionY); + Assert.Equal(30f, result.Value.Position.PositionZ); + Assert.Equal(1f, result.Value.Position.RotationW); + Assert.Null(result.Value.Velocity); + Assert.Null(result.Value.PlacementId); + } + + [Fact] + public void ParsesWithMissingRotationComponents() + { + // OrientationHasNoX | OrientationHasNoY | OrientationHasNoZ — only + // W is present. The missing components default to 0, which is the + // convention the server assumes. + uint flags = 0x10 | 0x20 | 0x40; + var body = BuildBodyPartial( + guid: 0xDEADBEEF, + flags: flags, + cellId: 0x01BB0001, + px: 1f, py: 2f, pz: 3f, + rotationComponents: new float[] { 0.707f }); + + var result = UpdatePosition.TryParse(body); + Assert.NotNull(result); + Assert.Equal(0.707f, result!.Value.Position.RotationW, precision: 4); + Assert.Equal(0f, result.Value.Position.RotationX); + Assert.Equal(0f, result.Value.Position.RotationY); + Assert.Equal(0f, result.Value.Position.RotationZ); + } + + [Fact] + public void ParsesHasVelocityFlag() + { + // HasVelocity(0x01) + all four rotation components present. + uint flags = 0x01; + using var ms = new System.IO.MemoryStream(); + using var bw = new System.IO.BinaryWriter(ms); + bw.Write(UpdatePosition.Opcode); + bw.Write(0xAABBCCDDu); + bw.Write(flags); + bw.Write(0x01020003u); + bw.Write(5f); bw.Write(6f); bw.Write(7f); + bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f); + bw.Write(100f); bw.Write(200f); bw.Write(300f); // velocity + + var result = UpdatePosition.TryParse(ms.ToArray()); + Assert.NotNull(result); + Assert.NotNull(result!.Value.Velocity); + Assert.Equal(100f, result.Value.Velocity!.Value.X); + Assert.Equal(200f, result.Value.Velocity.Value.Y); + Assert.Equal(300f, result.Value.Velocity.Value.Z); + } + + [Fact] + public void ParsesHasPlacementIdFlag() + { + // HasPlacementID(0x02) — placement id u32 follows rotation (no velocity). + uint flags = 0x02; + using var ms = new System.IO.MemoryStream(); + using var bw = new System.IO.BinaryWriter(ms); + bw.Write(UpdatePosition.Opcode); + bw.Write(0x11111111u); + bw.Write(flags); + bw.Write(0x00000001u); + bw.Write(0f); bw.Write(0f); bw.Write(0f); + bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f); + bw.Write((uint)0x65); // Placement.Resting + + var result = UpdatePosition.TryParse(ms.ToArray()); + Assert.NotNull(result); + Assert.Equal((uint)0x65, result!.Value.PlacementId); + } + + [Fact] + public void ParsesBothVelocityAndPlacement() + { + uint flags = 0x01 | 0x02; + using var ms = new System.IO.MemoryStream(); + using var bw = new System.IO.BinaryWriter(ms); + bw.Write(UpdatePosition.Opcode); + bw.Write(0x22222222u); + bw.Write(flags); + bw.Write(0x04050607u); + bw.Write(10f); bw.Write(20f); bw.Write(30f); + bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f); + bw.Write(1f); bw.Write(2f); bw.Write(3f); + bw.Write((uint)42u); + + var result = UpdatePosition.TryParse(ms.ToArray()); + Assert.NotNull(result); + Assert.NotNull(result!.Value.Velocity); + Assert.Equal(42u, result.Value.PlacementId); + } + + // ---- helpers ---- + + private static byte[] BuildBody( + uint guid, uint flags, uint cellId, + float px, float py, float pz, + float rw, float rx, float ry, float rz) + { + using var ms = new System.IO.MemoryStream(); + using var bw = new System.IO.BinaryWriter(ms); + bw.Write(UpdatePosition.Opcode); + bw.Write(guid); + bw.Write(flags); + bw.Write(cellId); + bw.Write(px); bw.Write(py); bw.Write(pz); + bw.Write(rw); bw.Write(rx); bw.Write(ry); bw.Write(rz); + return ms.ToArray(); + } + + private static byte[] BuildBodyPartial( + uint guid, uint flags, uint cellId, + float px, float py, float pz, + float[] rotationComponents) + { + using var ms = new System.IO.MemoryStream(); + using var bw = new System.IO.BinaryWriter(ms); + bw.Write(UpdatePosition.Opcode); + bw.Write(guid); + bw.Write(flags); + bw.Write(cellId); + bw.Write(px); bw.Write(py); bw.Write(pz); + foreach (var c in rotationComponents) bw.Write(c); + return ms.ToArray(); + } +}