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>
176 lines
7.3 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|