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:
Erik 2026-04-11 20:37:32 +02:00
parent a71db90310
commit 333a7c197a
3 changed files with 371 additions and 0 deletions

View 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;
}
}
}

View file

@ -74,6 +74,23 @@ public sealed class WorldSession : IDisposable
/// </summary>
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>
public event Action<State>? 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));
}
}
}
}