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();
+ }
+}