acdream/src/AcDream.Core.Net/Messages/UpdatePosition.cs
Erik 5d717312cc feat(net): plumb IsGrounded through EntityPositionUpdate (L.3.2 Task 2)
PositionFlags.IsGrounded (0x04) was already parsed by UpdatePosition
but not exposed through the Parsed record or EntityPositionUpdate.
Adds the bool field to both records so OnLivePositionUpdated can
consume it for retail-faithful MoveOrTeleport routing
(acclient @ 0x00516330: has_contact=false → no-op during airborne arc).

Consumed in subsequent task (L.3.1+L.3.2 Task 3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 10:15:02 +02:00

176 lines
7.3 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. 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,
bool IsGrounded,
ushort InstanceSequence = 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.
ushort instSeq = 0, teleSeq = 0, forceSeq = 0;
if (body.Length - pos >= 8)
{
instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
// pos+2 = positionSequence (not tracked by movement)
teleSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 4));
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, teleSeq, forceSeq);
}
catch
{
return null;
}
}
}