feat(net): Phase 6.7 — parse UpdatePosition (0xF748) into PositionUpdated event
Companion to the Phase 6.6 UpdateMotion parser. Without this, every server-spawned entity stays frozen at its CreateObject origin forever — NPCs don't patrol, creatures don't hunt, other players don't walk past. UpdatePosition is the per-entity position delta the server sends on every movement tick. The wire format is straightforward but fiddly: u32 opcode | u32 guid | u32 flags | u32 cellId | 3xf32 pos (0..4) conditional f32 rotation components, present iff the corresponding OrientationHasNo* flag is CLEAR optional 3xf32 velocity iff HasVelocity optional u32 placementId iff HasPlacementID four u16 sequence numbers (consumed but not used) Layout ported from references/ACE/Source/ACE.Server/Network/Structure/ PositionPack.cs::Write and ACE.Entity/Enum/PositionFlags.cs. WorldSession dispatches PositionUpdated(guid, position, velocity) on a successful parse. GameWindow wiring (guid → WorldEntity lookup and transform swap) is deferred to the same follow-up commit that lands Phase 6.6 wiring, after the in-flight Phase 9.1 translucent-pass work merges so we don't step on GameWindow.cs edits. 96 Core.Net tests (was 89, +7 for UpdatePosition coverage). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a71db90310
commit
333a7c197a
3 changed files with 371 additions and 0 deletions
164
src/AcDream.Core.Net/Messages/UpdatePosition.cs
Normal file
164
src/AcDream.Core.Net/Messages/UpdatePosition.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inbound <c>UpdatePosition</c> GameMessage (opcode <c>0xF748</c>). 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 <see cref="UpdateMotion"/>: motion tells us
|
||||||
|
/// <i>what cycle to play</i>, position tells us <i>where in world space</i>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Wire layout (see
|
||||||
|
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdatePosition.cs</c>
|
||||||
|
/// and <c>references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs::Write</c>):
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>u32 opcode</b> — 0xF748</item>
|
||||||
|
/// <item><b>u32 objectGuid</b></item>
|
||||||
|
/// <item><b>u32 positionFlags</b> — see <see cref="PositionFlags"/></item>
|
||||||
|
/// <item><b>Origin</b> — u32 landblockCellId + 3xf32 local XYZ</item>
|
||||||
|
/// <item><b>Rotation components</b> — f32 W / X / Y / Z, but only the
|
||||||
|
/// ones whose <c>OrientationHasNo*</c> flag is <i>clear</i>. Absent
|
||||||
|
/// components default to 0.</item>
|
||||||
|
/// <item><b>Velocity</b> — 3xf32 if HasVelocity set</item>
|
||||||
|
/// <item><b>PlacementID</b> — u32 if HasPlacementID set</item>
|
||||||
|
/// <item><b>Four u16 sequence numbers</b> — instance, position, teleport,
|
||||||
|
/// forcePosition. We don't currently check these for freshness but
|
||||||
|
/// we must consume them to walk the buffer correctly.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static class UpdatePosition
|
||||||
|
{
|
||||||
|
public const uint Opcode = 0xF748u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bitflag layout mirroring <c>ACE.Entity.Enum.PositionFlags</c>.
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Flags]
|
||||||
|
public enum PositionFlags : uint
|
||||||
|
{
|
||||||
|
None = 0x00,
|
||||||
|
HasVelocity = 0x01,
|
||||||
|
HasPlacementID = 0x02,
|
||||||
|
IsGrounded = 0x04,
|
||||||
|
OrientationHasNoW = 0x08,
|
||||||
|
OrientationHasNoX = 0x10,
|
||||||
|
OrientationHasNoY = 0x20,
|
||||||
|
OrientationHasNoZ = 0x40,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct Parsed(
|
||||||
|
uint Guid,
|
||||||
|
CreateObject.ServerPosition Position,
|
||||||
|
System.Numerics.Vector3? Velocity,
|
||||||
|
uint? PlacementId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a reassembled UpdatePosition body. <paramref name="body"/>
|
||||||
|
/// must start with the 4-byte opcode. Returns null on truncation or
|
||||||
|
/// wrong opcode.
|
||||||
|
/// </summary>
|
||||||
|
public static Parsed? TryParse(ReadOnlySpan<byte> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,23 @@ public sealed class WorldSession : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<EntityMotionUpdate>? MotionUpdated;
|
public event Action<EntityMotionUpdate>? MotionUpdated;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payload for <see cref="PositionUpdated"/>: the server guid plus a
|
||||||
|
/// full <see cref="CreateObject.ServerPosition"/> describing the
|
||||||
|
/// entity's new world position and rotation. Subscribers translate
|
||||||
|
/// the landblock-local position into acdream world space and reseat
|
||||||
|
/// the corresponding <c>WorldEntity</c>.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct EntityPositionUpdate(
|
||||||
|
uint Guid,
|
||||||
|
CreateObject.ServerPosition Position,
|
||||||
|
System.Numerics.Vector3? Velocity);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires when the session parses a 0xF748 UpdatePosition game message.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<EntityPositionUpdate>? PositionUpdated;
|
||||||
|
|
||||||
/// <summary>Raised every time the state machine transitions.</summary>
|
/// <summary>Raised every time the state machine transitions.</summary>
|
||||||
public event Action<State>? StateChanged;
|
public event Action<State>? StateChanged;
|
||||||
|
|
||||||
|
|
@ -275,6 +292,22 @@ public sealed class WorldSession : IDisposable
|
||||||
motion.Value.MotionState));
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
174
tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs
Normal file
174
tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
using System;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers <see cref="UpdatePosition.TryParse"/> — 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.
|
||||||
|
/// </summary>
|
||||||
|
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<byte>()));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue