Retail MovementManager::PerformMovement (0x00524440) reads MoveTo speed and runRate from the packet, MovementParameters::UnPackNet (0x0052AC50) defines the layout, and CMotionInterp::apply_run_to_command (0x00527BE0) multiplies RunForward by runRate. Parse those fields for UpdateMotion/CreateObject, seed server-controlled MoveTo locomotion with the retail speed multiplier, and avoid overriding active monster MoveTo with sparse UpdatePosition-derived velocity.
725 lines
33 KiB
C#
725 lines
33 KiB
C#
using System.Buffers.Binary;
|
||
using System.Collections.Generic;
|
||
|
||
namespace AcDream.Core.Net.Messages;
|
||
|
||
/// <summary>
|
||
/// Inbound <c>CreateObject</c> GameMessage (opcode <c>0xF745</c>). This is
|
||
/// the primary spawn-an-entity-into-my-world message — the server sends
|
||
/// one for every visible weenie (players, creatures, items, scenery
|
||
/// weenies like the Holtburg foundry statue) in the client's loaded area.
|
||
///
|
||
/// <para>
|
||
/// acdream's parser extracts only the fields needed to hand the spawn off
|
||
/// to <c>IGameState</c>:
|
||
/// </para>
|
||
/// <list type="bullet">
|
||
/// <item><b>GUID</b> — always at the start of the body (after the opcode).</item>
|
||
/// <item><b>Position</b> (landblock id + local XYZ + rotation quaternion) — present
|
||
/// when <see cref="PhysicsDescriptionFlag.Position"/> is set in the physics
|
||
/// description flags. We need this to place the entity in the world.</item>
|
||
/// <item><b>SetupTableId</b> — present when <see cref="PhysicsDescriptionFlag.CSetup"/>
|
||
/// is set. This is the dat-id for the visual model
|
||
/// (<c>Setup</c>/<c>GfxObj</c> chain) that acdream's existing
|
||
/// SetupMesh + GfxObjMesh pipeline already knows how to render.</item>
|
||
/// </list>
|
||
///
|
||
/// <para>
|
||
/// Most other fields (extended weenie header, object description, motion tables,
|
||
/// palettes, texture overrides, animation frames, velocity, ...) are
|
||
/// consumed-but-ignored so the parse position ends up wherever the
|
||
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
|
||
/// the end of the body to return useful output. We read through the fixed
|
||
/// WeenieHeader prefix for Name/ItemType, then stop before optional header
|
||
/// tails.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Ported by reading <c>ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs</c>
|
||
/// (SerializeCreateObject, SerializeModelData, SerializePhysicsData) plus
|
||
/// <c>ACE.Entity/Position.cs</c> and <c>PhysicsDescriptionFlag.cs</c>.
|
||
/// See NOTICE.md.
|
||
/// </para>
|
||
/// </summary>
|
||
public static class CreateObject
|
||
{
|
||
public const uint Opcode = 0xF745u;
|
||
|
||
/// <summary>AC dat id type prefix for GfxObj (visual model) ids.</summary>
|
||
public const uint GfxObjTypePrefix = 0x01000000u;
|
||
/// <summary>Palette dat id type prefix.</summary>
|
||
public const uint PaletteTypePrefix = 0x04000000u;
|
||
/// <summary>SurfaceTexture dat id type prefix.</summary>
|
||
public const uint SurfaceTextureTypePrefix = 0x05000000u;
|
||
/// <summary>Icon dat id type prefix.</summary>
|
||
public const uint IconTypePrefix = 0x06000000u;
|
||
|
||
[Flags]
|
||
public enum PhysicsDescriptionFlag : uint
|
||
{
|
||
None = 0x000000,
|
||
CSetup = 0x000001,
|
||
MTable = 0x000002,
|
||
Velocity = 0x000004,
|
||
Acceleration = 0x000008,
|
||
Omega = 0x000010,
|
||
Parent = 0x000020,
|
||
Children = 0x000040,
|
||
ObjScale = 0x000080,
|
||
Friction = 0x000100,
|
||
Elasticity = 0x000200,
|
||
Timestamps = 0x000400,
|
||
STable = 0x000800,
|
||
PeTable = 0x001000,
|
||
DefaultScript = 0x002000,
|
||
DefaultScriptIntensity = 0x004000,
|
||
Position = 0x008000,
|
||
Movement = 0x010000,
|
||
AnimationFrame = 0x020000,
|
||
Translucency = 0x040000,
|
||
}
|
||
|
||
/// <summary>
|
||
/// The spawn fields acdream currently cares about. Position and
|
||
/// SetupTableId are nullable because their corresponding
|
||
/// physics-description-flag bits may not be set on every CreateObject.
|
||
/// </summary>
|
||
public readonly record struct Parsed(
|
||
uint Guid,
|
||
ServerPosition? Position,
|
||
uint? SetupTableId,
|
||
IReadOnlyList<AnimPartChange> AnimPartChanges,
|
||
IReadOnlyList<TextureChange> TextureChanges,
|
||
IReadOnlyList<SubPaletteSwap> SubPalettes,
|
||
uint? BasePaletteId,
|
||
float? ObjScale,
|
||
string? Name,
|
||
uint? ItemType,
|
||
ServerMotionState? MotionState,
|
||
uint? MotionTableId,
|
||
ushort InstanceSequence = 0,
|
||
ushort TeleportSequence = 0,
|
||
ushort ServerControlSequence = 0,
|
||
ushort ForcePositionSequence = 0);
|
||
|
||
/// <summary>
|
||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||
/// <c>InterpretedMotionState</c>: the entity's current stance
|
||
/// (MotionStance, e.g. NonCombat / HandCombat / Crouch) and its
|
||
/// active <c>ForwardCommand</c> (MotionCommand, e.g. Ready / Crouch /
|
||
/// AttackHigh). These are what we need to compose a MotionTable
|
||
/// cycle key <c>(stance << 16) | (command & 0xFFFFFF)</c> and
|
||
/// resolve the right idle frame for entities like the Foundry's
|
||
/// Nullified Statue of a Drudge, which is rendered in the wrong pose
|
||
/// if you only consult the MotionTable's default style.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Full InterpretedMotionState from the server. Covers every field that
|
||
/// can appear in the wire — the earlier version only tracked
|
||
/// ForwardCommand/ForwardSpeed and silently discarded TurnCommand /
|
||
/// SideStepCommand / their speeds. That made it impossible to render
|
||
/// smooth circles or strafing for remote entities — the client literally
|
||
/// had no rotation-intent data between UpdatePositions.
|
||
///
|
||
/// <para>
|
||
/// Per ACE <c>InterpretedMotionState.Write</c> (line 127) the wire
|
||
/// order is: CurrentStyle, ForwardCommand, SideStepCommand,
|
||
/// TurnCommand (all ushort), then ForwardSpeed, SideStepSpeed, TurnSpeed
|
||
/// (all float). Flag bits (MovementStateFlag enum):
|
||
/// 0x01=CurrentStyle, 0x02=ForwardCommand, 0x04=ForwardSpeed,
|
||
/// 0x08=SideStepCommand, 0x10=SideStepSpeed, 0x20=TurnCommand,
|
||
/// 0x40=TurnSpeed.
|
||
/// </para>
|
||
/// </summary>
|
||
public readonly record struct ServerMotionState(
|
||
ushort Stance,
|
||
ushort? ForwardCommand,
|
||
float? ForwardSpeed = null,
|
||
IReadOnlyList<MotionItem>? Commands = null,
|
||
ushort? SideStepCommand = null,
|
||
float? SideStepSpeed = null,
|
||
ushort? TurnCommand = null,
|
||
float? TurnSpeed = null,
|
||
byte MovementType = 0,
|
||
uint? MoveToParameters = null,
|
||
float? MoveToSpeed = null,
|
||
float? MoveToRunRate = null)
|
||
{
|
||
/// <summary>
|
||
/// ACE/retail movement types 6 and 7 are server-controlled
|
||
/// MoveToObject/MoveToPosition packets. Their union body does not
|
||
/// carry an InterpretedMotionState.ForwardCommand, so command absence
|
||
/// is not a stop signal.
|
||
/// </summary>
|
||
public bool IsServerControlledMoveTo => MovementType is 6 or 7;
|
||
|
||
public bool MoveToCanRun => !MoveToParameters.HasValue
|
||
|| (MoveToParameters.Value & 0x2u) != 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
||
/// The server packs 0..many of these per broadcast: emotes, attacks,
|
||
/// and other one-shot motions arrive here, not in ForwardCommand.
|
||
///
|
||
/// Wire layout (see ACE Network/Motion/MotionItem.cs):
|
||
/// u16 command — low 16 bits of MotionCommand (Action class
|
||
/// typically 0x10xx; ChatEmote 0x13xx)
|
||
/// u16 packedSequence — bit 15 IsAutonomous, bits 0-14 sequence stamp
|
||
/// f32 speed — speedMod for the animation
|
||
/// </summary>
|
||
public readonly record struct MotionItem(
|
||
ushort Command,
|
||
ushort PackedSequence,
|
||
float Speed);
|
||
|
||
/// <summary>
|
||
/// Server instruction to replace the surface texture at
|
||
/// <paramref name="PartIndex"/> that currently uses
|
||
/// <paramref name="OldTexture"/> with <paramref name="NewTexture"/>.
|
||
/// Used to paint armor pieces the right color, make the statue
|
||
/// look stone instead of flesh, etc.
|
||
/// </summary>
|
||
public readonly record struct TextureChange(byte PartIndex, uint OldTexture, uint NewTexture);
|
||
|
||
/// <summary>
|
||
/// Palette-range swap: overlay <paramref name="SubPaletteId"/>'s colors
|
||
/// into the entity's base palette starting at index <paramref name="Offset"/>
|
||
/// for <paramref name="Length"/> colors. Used for skin/hair color
|
||
/// on characters and team-color variations. Both Offset and Length
|
||
/// are encoded as 8-bit values that the client historically multiplies
|
||
/// by 8 to get the final palette index.
|
||
/// </summary>
|
||
public readonly record struct SubPaletteSwap(uint SubPaletteId, byte Offset, byte Length);
|
||
|
||
/// <summary>A server-side position: landblock id + local XYZ + unit quaternion rotation.</summary>
|
||
public readonly record struct ServerPosition(
|
||
uint LandblockId,
|
||
float PositionX, float PositionY, float PositionZ,
|
||
float RotationW, float RotationX, float RotationY, float RotationZ);
|
||
|
||
/// <summary>
|
||
/// Server instruction to replace part index <paramref name="PartIndex"/>
|
||
/// in the base Setup's part list with the mesh at <paramref name="NewModelId"/>.
|
||
/// This is the primary mechanism ACE uses to dress characters (head →
|
||
/// helmet, torso → chestplate, ...) and also how many specialized
|
||
/// weenies like statues get their unique mesh overrides.
|
||
/// </summary>
|
||
public readonly record struct AnimPartChange(byte PartIndex, uint NewModelId);
|
||
|
||
/// <summary>
|
||
/// Parse a reassembled CreateObject body. <paramref name="body"/> must
|
||
/// start with the 4-byte opcode. Returns <c>null</c> if the body is
|
||
/// malformed (truncated field); returns a populated <see cref="Parsed"/>
|
||
/// on success. The parser stops at the end of PhysicsData; subsequent
|
||
/// weenie-header fields are deliberately not consumed.
|
||
/// </summary>
|
||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||
{
|
||
// Accumulators declared at the top so PartialResult (local function
|
||
// at the bottom) can reference them before they're conditionally
|
||
// populated — C# rejects forward references otherwise.
|
||
ServerPosition? position = null;
|
||
uint? setupTableId = null;
|
||
float? objScale = null;
|
||
ServerMotionState? motionState = null;
|
||
uint? motionTableId = null;
|
||
|
||
try
|
||
{
|
||
int pos = 0;
|
||
|
||
uint opcode = ReadU32(body, ref pos);
|
||
if (opcode != Opcode)
|
||
return null;
|
||
|
||
uint guid = ReadU32(body, ref pos);
|
||
|
||
// --- ModelData ---
|
||
// Header: byte 0x11 marker, byte subPalettes, byte textureChanges, byte animPartChanges
|
||
if (body.Length - pos < 4) return null;
|
||
byte _marker = body[pos]; pos += 1;
|
||
byte subPaletteCount = body[pos]; pos += 1;
|
||
byte textureChangeCount = body[pos]; pos += 1;
|
||
byte animPartChangeCount = body[pos]; pos += 1;
|
||
|
||
uint? basePaletteId = null;
|
||
if (subPaletteCount > 0)
|
||
basePaletteId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix);
|
||
|
||
var subPalettes = subPaletteCount == 0
|
||
? (IReadOnlyList<SubPaletteSwap>)Array.Empty<SubPaletteSwap>()
|
||
: new SubPaletteSwap[subPaletteCount];
|
||
for (int i = 0; i < subPaletteCount; i++)
|
||
{
|
||
uint subPalId = ReadPackedDwordOfKnownType(body, ref pos, PaletteTypePrefix);
|
||
if (body.Length - pos < 2) return null;
|
||
byte offset = body[pos]; pos += 1;
|
||
byte length = body[pos]; pos += 1;
|
||
((SubPaletteSwap[])subPalettes)[i] = new SubPaletteSwap(subPalId, offset, length);
|
||
}
|
||
|
||
var textureChanges = textureChangeCount == 0
|
||
? (IReadOnlyList<TextureChange>)Array.Empty<TextureChange>()
|
||
: new TextureChange[textureChangeCount];
|
||
for (int i = 0; i < textureChangeCount; i++)
|
||
{
|
||
if (body.Length - pos < 1) return null;
|
||
byte partIndex = body[pos]; pos += 1;
|
||
uint oldTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix);
|
||
uint newTex = ReadPackedDwordOfKnownType(body, ref pos, SurfaceTextureTypePrefix);
|
||
((TextureChange[])textureChanges)[i] = new TextureChange(partIndex, oldTex, newTex);
|
||
}
|
||
|
||
// Extract AnimPartChanges — the server uses these to replace
|
||
// base Setup parts with armored/statue/whatever-specific meshes.
|
||
// Without decoding these, characters render "naked" and custom
|
||
// weenies render as whatever their base Setup looks like.
|
||
//
|
||
// NOTE: ACE writes the NewModelId through WritePackedDwordOfKnownType
|
||
// with knownType=0x01000000 (GfxObj type prefix). That writer STRIPS
|
||
// the high-byte type if present before writing the PackedDword. We
|
||
// have to OR it back on read or our GfxObj dat lookup will fail
|
||
// (silently, producing no mesh refs — hence the Phase 4.7h regression).
|
||
var animParts = animPartChangeCount == 0
|
||
? (IReadOnlyList<AnimPartChange>)Array.Empty<AnimPartChange>()
|
||
: new AnimPartChange[animPartChangeCount];
|
||
for (int i = 0; i < animPartChangeCount; i++)
|
||
{
|
||
if (body.Length - pos < 1) return null;
|
||
byte partIndex = body[pos]; pos += 1;
|
||
uint newModelId = ReadPackedDwordOfKnownType(body, ref pos, GfxObjTypePrefix);
|
||
((AnimPartChange[])animParts)[i] = new AnimPartChange(partIndex, newModelId);
|
||
}
|
||
|
||
AlignTo4(ref pos);
|
||
|
||
// --- PhysicsData ---
|
||
if (body.Length - pos < 8) return null;
|
||
var physicsFlags = (PhysicsDescriptionFlag)BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
pos += 4; // PhysicsState (skip)
|
||
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Movement) != 0)
|
||
{
|
||
// u32 length, length bytes of serialized MovementData (no header
|
||
// — see ACE WorldObject_Networking.cs:326 writer.Write(movementData, false)),
|
||
// u32 isAutonomous (only present when the inner MovementData was non-empty).
|
||
if (body.Length - pos < 4) return null;
|
||
uint movementLen = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
if (movementLen > 0)
|
||
{
|
||
if (body.Length - pos < (int)movementLen) return null;
|
||
int movementStart = pos;
|
||
motionState = TryParseMovementData(body.Slice(movementStart, (int)movementLen));
|
||
pos = movementStart + (int)movementLen;
|
||
if (body.Length - pos < 4) return null;
|
||
pos += 4; // isAutonomous u32
|
||
}
|
||
}
|
||
else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
|
||
{
|
||
if (body.Length - pos < 4) return null;
|
||
pos += 4;
|
||
}
|
||
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Position) != 0)
|
||
{
|
||
if (body.Length - pos < 32) return null;
|
||
position = new ServerPosition(
|
||
LandblockId: BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 0)),
|
||
PositionX: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)),
|
||
PositionY: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 8)),
|
||
PositionZ: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 12)),
|
||
RotationW: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 16)),
|
||
RotationX: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 20)),
|
||
RotationY: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 24)),
|
||
RotationZ: BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 28)));
|
||
pos += 32;
|
||
}
|
||
|
||
if ((physicsFlags & PhysicsDescriptionFlag.MTable) != 0)
|
||
{
|
||
if (body.Length - pos < 4) return null;
|
||
motionTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
}
|
||
|
||
if ((physicsFlags & PhysicsDescriptionFlag.STable) != 0)
|
||
{
|
||
if (body.Length - pos < 4) return null;
|
||
pos += 4;
|
||
}
|
||
|
||
if ((physicsFlags & PhysicsDescriptionFlag.PeTable) != 0)
|
||
{
|
||
if (body.Length - pos < 4) return null;
|
||
pos += 4;
|
||
}
|
||
|
||
if ((physicsFlags & PhysicsDescriptionFlag.CSetup) != 0)
|
||
{
|
||
if (body.Length - pos < 4) return null;
|
||
setupTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
}
|
||
|
||
// Skip the remaining PhysicsData fields. Each is gated by a flag
|
||
// and must be consumed so we end up at the start of WeenieHeader.
|
||
// Order matches ACE's SerializePhysicsData exactly.
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Parent) != 0)
|
||
{
|
||
if (body.Length - pos < 8) return null;
|
||
pos += 8; // wielderId u32 + parentLocation u32
|
||
}
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Children) != 0)
|
||
{
|
||
if (body.Length - pos < 4) return null;
|
||
int childCount = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
if (childCount < 0 || childCount > 1024) return PartialResult();
|
||
if (body.Length - pos < childCount * 8) return null;
|
||
pos += childCount * 8; // each child = guid u32 + locationId u32
|
||
}
|
||
if ((physicsFlags & PhysicsDescriptionFlag.ObjScale) != 0)
|
||
{
|
||
if (body.Length - pos < 4) return PartialResult();
|
||
objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
}
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0) pos += 4;
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0) pos += 4;
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4;
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12;
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Omega) != 0) pos += 12;
|
||
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScript) != 0) pos += 4;
|
||
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4;
|
||
|
||
// 9 sequence timestamps, always present at end of PhysicsData.
|
||
// Indices per holtburger: 0=position, 4=teleport, 5=serverControl,
|
||
// 6=forcePosition, 8=instance.
|
||
if (body.Length - pos < 9 * 2) return PartialResult();
|
||
var seqSpan = body.Slice(pos, 9 * 2);
|
||
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
|
||
ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2));
|
||
ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2));
|
||
ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2));
|
||
pos += 9 * 2;
|
||
AlignTo4(ref pos);
|
||
|
||
// --- WeenieHeader: read the fixed prefix fields we need. ---
|
||
// ACE WorldObject_Networking.SerializeCreateObject writes:
|
||
// weenieFlags, Name, WeenieClassId(PackedDword),
|
||
// IconId(PackedDwordOfKnownType 0x06000000), ItemType,
|
||
// ObjectDescriptionFlags, align.
|
||
string? name = null;
|
||
uint? itemType = null;
|
||
if (body.Length - pos >= 4)
|
||
{
|
||
pos += 4; // skip weenieFlags u32
|
||
try
|
||
{
|
||
name = ReadString16L(body, ref pos);
|
||
_ = ReadPackedDword(body, ref pos); // WeenieClassId
|
||
_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix);
|
||
if (body.Length - pos >= 4)
|
||
itemType = ReadU32(body, ref pos);
|
||
if (body.Length - pos >= 4)
|
||
_ = ReadU32(body, ref pos); // ObjectDescriptionFlags
|
||
AlignTo4(ref pos);
|
||
}
|
||
catch { /* truncated name — partial result is still useful */ }
|
||
}
|
||
|
||
return new Parsed(guid, position, setupTableId, animParts,
|
||
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
|
||
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq);
|
||
|
||
// Local helper: if we ran out of fields past PhysicsData, still
|
||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||
Parsed PartialResult() => new(
|
||
guid, position, setupTableId, animParts,
|
||
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
|
||
{
|
||
if (source.Length - pos < 4) throw new FormatException("truncated u32");
|
||
uint v = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
|
||
pos += 4;
|
||
return v;
|
||
}
|
||
|
||
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)
|
||
{
|
||
if (source.Length - pos < 2) throw new FormatException("truncated String16L length");
|
||
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
||
pos += 2;
|
||
if (length > 1024) throw new FormatException($"String16L length {length} exceeds sanity limit");
|
||
if (source.Length - pos < length) throw new FormatException("truncated String16L body");
|
||
// Windows-1252 matches retail (and holtburger's encoding_rs::WINDOWS_1252).
|
||
string result = System.Text.Encoding.GetEncoding(1252).GetString(source.Slice(pos, length));
|
||
pos += length;
|
||
int recordSize = 2 + length;
|
||
int padding = (4 - (recordSize & 3)) & 3;
|
||
pos += padding;
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read a PackedDword from the stream. Format:
|
||
/// <list type="bullet">
|
||
/// <item>u16 first. If the top bit (0x8000) is clear, the u16 IS the value (0..0x7FFF).</item>
|
||
/// <item>Otherwise, read another u16 and combine: the full 32-bit value
|
||
/// is <c>((lowHalfTopBitStripped) << 16) | highHalfFromNextU16</c>.</item>
|
||
/// </list>
|
||
/// Ported from ACE's Extensions.WritePackedDword: for values ≤ 32767, emitted as
|
||
/// u16; for larger, emitted as <c>(value << 16) | ((value >> 16) | 0x8000)</c>
|
||
/// written as a little-endian u32. The reader is the inverse — sees the high-
|
||
/// bit marker in the first u16, then reads the second u16.
|
||
/// </summary>
|
||
private static uint ReadPackedDword(ReadOnlySpan<byte> source, ref int pos)
|
||
{
|
||
if (source.Length - pos < 2) throw new FormatException("truncated PackedDword");
|
||
ushort first = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
||
pos += 2;
|
||
if ((first & 0x8000) == 0)
|
||
return first;
|
||
|
||
// Extended form: first holds the HIGH 16 bits with top bit as marker,
|
||
// next u16 holds the LOW 16 bits. Strip the marker bit from the high half.
|
||
if (source.Length - pos < 2) throw new FormatException("truncated PackedDword ext");
|
||
ushort second = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
|
||
pos += 2;
|
||
uint high = (uint)(first & 0x7FFF);
|
||
return (high << 16) | second;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read a PackedDword that was written via <c>WritePackedDwordOfKnownType</c>.
|
||
/// That writer strips the <paramref name="knownType"/> prefix before
|
||
/// packing if the value had it set, so the reader must OR it back in to
|
||
/// recover the original dat id. The zero sentinel is preserved as-is
|
||
/// (a 0 means "no value" and must not be turned into <c>knownType</c>).
|
||
/// </summary>
|
||
private static uint ReadPackedDwordOfKnownType(ReadOnlySpan<byte> source, ref int pos, uint knownType)
|
||
{
|
||
uint packed = ReadPackedDword(source, ref pos);
|
||
return packed == 0 ? 0 : (packed | knownType);
|
||
}
|
||
|
||
private static void AlignTo4(ref int pos)
|
||
{
|
||
int padding = (4 - (pos & 3)) & 3;
|
||
pos += padding;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parse the inner <c>MovementData</c> bytes (no header form, as written
|
||
/// by ACE's CreateObject path with <c>writer.Write(movementData, false)</c>).
|
||
/// We extract the <c>CurrentStyle</c> stance and, when MovementType is
|
||
/// <c>Invalid</c> (the typical case for stationary entities like the
|
||
/// Foundry's drudge statue), the <c>InterpretedMotionState.ForwardCommand</c>
|
||
/// motion command. Both are used by the renderer to compose a MotionTable
|
||
/// cycle key and resolve the entity's actual idle pose.
|
||
/// <para>
|
||
/// Layout — see ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write
|
||
/// (header=false) and InterpretedMotionState.cs::Write:
|
||
/// </para>
|
||
/// <list type="bullet">
|
||
/// <item>u8 movementType</item>
|
||
/// <item>u8 motionFlags</item>
|
||
/// <item>u16 currentStyle (MotionStance)</item>
|
||
/// <item>For MovementType.Invalid (==0): InterpretedMotionState body</item>
|
||
/// </list>
|
||
/// Returns null on truncation; partial results are still returned with
|
||
/// whatever fields parsed successfully.
|
||
/// </summary>
|
||
private static ServerMotionState? TryParseMovementData(ReadOnlySpan<byte> mv)
|
||
{
|
||
try
|
||
{
|
||
int p = 0;
|
||
if (mv.Length < 4) return null;
|
||
byte movementType = mv[p]; p += 1;
|
||
byte _motionFlags = mv[p]; p += 1;
|
||
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||
p += 2;
|
||
|
||
ushort? forwardCommand = null;
|
||
float? forwardSpeed = null;
|
||
ushort? sidestepCommand = null;
|
||
float? sidestepSpeed = null;
|
||
ushort? turnCommand = null;
|
||
float? turnSpeed = null;
|
||
uint? moveToParameters = null;
|
||
float? moveToSpeed = null;
|
||
float? moveToRunRate = null;
|
||
List<MotionItem>? commands = null;
|
||
|
||
// 0 = Invalid is the only union variant we care about for static
|
||
// entities. Walking/turning entities use the other variants but
|
||
// their forward command lives in InterpretedMotionState too;
|
||
// those are typed differently though, so be conservative.
|
||
if (movementType == 0)
|
||
{
|
||
// InterpretedMotionState: u32 (flags | numCommands<<7), then
|
||
// each present field in flag order. Flag bits (low 7) are
|
||
// CurrentStyle/ForwardCommand/.../TurnSpeed; numCommands is
|
||
// the MotionItem list length that follows after the speed
|
||
// fields (see ACE InterpretedMotionState.cs::Write).
|
||
if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null);
|
||
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p));
|
||
p += 4;
|
||
uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits
|
||
uint numCommands = packed >> 7;
|
||
|
||
// Flag-bit + write order per ACE
|
||
// InterpretedMotionState.Write @ line 127
|
||
// (MovementStateFlag enum @ ACE.Entity.Enum):
|
||
// CurrentStyle = 0x01 (ushort)
|
||
// ForwardCommand = 0x02 (ushort)
|
||
// SideStepCommand = 0x08 (ushort)
|
||
// TurnCommand = 0x20 (ushort)
|
||
// ForwardSpeed = 0x04 (float)
|
||
// SideStepSpeed = 0x10 (float)
|
||
// TurnSpeed = 0x40 (float)
|
||
// Note the bit values are NOT in write order — commands
|
||
// come first in the wire stream regardless of bit value,
|
||
// then speeds. Earlier versions had this mapping wrong,
|
||
// which caused ForwardSpeed to silently never be read
|
||
// (appeared as HasValue=False on every remote broadcast).
|
||
|
||
if ((flags & 0x1u) != 0)
|
||
{
|
||
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
||
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||
p += 2;
|
||
}
|
||
if ((flags & 0x2u) != 0)
|
||
{
|
||
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
||
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||
p += 2;
|
||
}
|
||
// SideStepCommand (bit 0x8, ushort)
|
||
if ((flags & 0x8u) != 0)
|
||
{
|
||
if (mv.Length - p < 2) goto done;
|
||
sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||
p += 2;
|
||
}
|
||
// TurnCommand (bit 0x20, ushort)
|
||
if ((flags & 0x20u) != 0)
|
||
{
|
||
if (mv.Length - p < 2) goto done;
|
||
turnCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||
p += 2;
|
||
}
|
||
// ForwardSpeed (bit 0x4, float)
|
||
if ((flags & 0x4u) != 0)
|
||
{
|
||
if (mv.Length - p < 4) goto done;
|
||
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
|
||
p += 4;
|
||
}
|
||
// SideStepSpeed (bit 0x10, float)
|
||
if ((flags & 0x10u) != 0)
|
||
{
|
||
if (mv.Length - p < 4) goto done;
|
||
sidestepSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
|
||
p += 4;
|
||
}
|
||
// TurnSpeed (bit 0x40, float)
|
||
if ((flags & 0x40u) != 0)
|
||
{
|
||
if (mv.Length - p < 4) goto done;
|
||
turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
|
||
p += 4;
|
||
}
|
||
|
||
// Commands list: numCommands × 8-byte MotionItem (u16 cmd +
|
||
// u16 packedSeq + f32 speed). One-shot actions, emotes,
|
||
// attacks — everything that's NOT a looping cycle change
|
||
// arrives here. Cap read at the buffer boundary.
|
||
if (numCommands > 0 && numCommands < 1024)
|
||
{
|
||
commands = new List<MotionItem>((int)numCommands);
|
||
for (int i = 0; i < numCommands; i++)
|
||
{
|
||
if (mv.Length - p < 8) break;
|
||
ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||
ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p + 2));
|
||
float speed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p + 4));
|
||
p += 8;
|
||
commands.Add(new MotionItem(cmd, seq, speed));
|
||
}
|
||
}
|
||
done:;
|
||
}
|
||
else if (movementType is 6 or 7)
|
||
{
|
||
TryParseMoveToPayload(
|
||
mv,
|
||
p,
|
||
movementType,
|
||
out moveToParameters,
|
||
out moveToSpeed,
|
||
out moveToRunRate);
|
||
}
|
||
|
||
return new ServerMotionState(
|
||
currentStyle, forwardCommand, forwardSpeed, commands,
|
||
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed,
|
||
movementType,
|
||
moveToParameters,
|
||
moveToSpeed,
|
||
moveToRunRate);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private static bool TryParseMoveToPayload(
|
||
ReadOnlySpan<byte> body,
|
||
int pos,
|
||
byte movementType,
|
||
out uint? movementParameters,
|
||
out float? speed,
|
||
out float? runRate)
|
||
{
|
||
movementParameters = null;
|
||
speed = null;
|
||
runRate = null;
|
||
|
||
if (movementType == 6)
|
||
{
|
||
if (body.Length - pos < 4) return false;
|
||
pos += 4; // target guid
|
||
}
|
||
|
||
if (body.Length - pos < 16 + 28 + 4) return false;
|
||
pos += 16; // Origin
|
||
|
||
movementParameters = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
pos += 4; // distanceToObject
|
||
pos += 4; // minDistance
|
||
pos += 4; // failDistance
|
||
speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
pos += 4; // walkRunThreshold
|
||
pos += 4; // desiredHeading
|
||
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||
return true;
|
||
}
|
||
}
|