acdream/src/AcDream.Core.Net/Messages/UpdatePosition.cs
Erik 8a5d77f7f4 feat(net): port retail physics spawn and event timestamps
Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client.

Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-14 00:22:17 +02:00

173 lines
7.2 KiB
C#

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. Runtime freshness gates consume all four.</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,
bool IsGrounded,
ushort InstanceSequence = 0,
ushort PositionSequence = 0,
ushort TeleportSequence = 0,
ushort ForcePositionSequence = 0);
/// <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;
}
// Four u16 sequence numbers: instance, position, teleport, forcePosition.
if (body.Length - pos < 8) return null;
ushort instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
ushort posSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
ushort teleSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 4));
ushort forceSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 6));
pos += 8;
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,
IsGrounded: (flags & PositionFlags.IsGrounded) != 0,
instSeq, posSeq, teleSeq, forceSeq);
}
catch
{
return null;
}
}
}