Two retail divergences fixed end-to-end: 1. R-key Use on non-useable entities (signs, banners, decorative scenery) was silently sending Use/PickUp to ACE, triggering auto-walk + NPC-style chat fallback. Retail's client checks ITEM_USEABLE (acclient.h:6478) and silently ignores Use when the USEABLE_REMOTE (0x20) bit isn't set. Now ports that gate. 2. Holtburg town sign indicator + click sphere only covered the base of the pole because the "everything else" default in EntityHeightFor was 1.5 m and the picker's vertical offset for default class was 0.2 m. A 3 m sign on a pole was almost entirely outside both shapes. Wire change: - CreateObject parser now walks the WeenieHeader optional tail (per ACE WorldObject_Networking.cs:87-114) up through Useability + UseRadius. Captures weenieFlags upfront, then conditionally skips PluralName, ItemCapacity, ContainerCapacity, AmmoType, Value before reading Useability (u32) and UseRadius (f32). - CreateObject.Parsed + WorldSession.EntitySpawn record append two new optional fields (Useability uint?, UseRadius float?), both defaulting to null. Existing call sites unchanged. - 3 new tests cover: no weenieFlags → null, weenieFlags=0x10 alone → useability read, weenieFlags=0x8|0x10|0x20 → walker skips Value then reads Useability + UseRadius in correct order. Behaviour change: - GameWindow.IsUseableTarget(guid) — authoritative path uses spawn .Useability when present (REMOTE bit gate); fallback when null permits Use on creatures + BF_DOOR/LIFESTONE/PORTAL/CORPSE for M1 flow continuity. - UseCurrentSelection (R-key dispatcher) and SendUse + SendPickUp (double-click + F-key direct paths) gate on IsUseableTarget, silent early-return matching retail. isRetryAfterArrival skips the gate (re-fires only previously-gated actions). - TargetIndicatorPanel.EntityHeightFor default branch 1.5 m → 3 m for non-creature non-flat non-small-item entities (sign-class). Scale > 1 still grows proportionally. - WorldPicker callbacks: new IsTallSceneryGuid branch lifts sphere centre to 1.5 m with 1.6 m radius for sign-class entities, mirroring the indicator's 3 m default so click sphere matches the visible box. Tests: 293/293 pass in AcDream.Core.Net.Tests (+3 new walker tests). dotnet build clean. Retail anchors: - acclient.h:6478 — ITEM_USEABLE enum (USEABLE_REMOTE = 0x20) - acclient.h:6431-6463 — PWD bitfield (BF_DOOR etc.) - ACE WorldObject_Networking.cs:87-114 — wire field order - ACE WeenieHeaderFlag — Usable = 0x10, UseRadius = 0x20 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
951 lines
44 KiB
C#
951 lines
44 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>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <see cref="PhysicsState"/> (<c>acclient.h:2815</c>) carries flag
|
||
/// bits like <c>ETHEREAL_PS=0x4</c>, <c>IGNORE_COLLISIONS_PS=0x10</c>,
|
||
/// <c>HAS_PHYSICS_BSP_PS=0x10000</c> — the bits retail's
|
||
/// <c>FindObjCollisions</c> reads to short-circuit ethereal /
|
||
/// no-collision entities. Pre-2026-04-29 (Commit A of the live-entity
|
||
/// collision port) the parser silently dropped this field.
|
||
/// </para>
|
||
/// <para>
|
||
/// <see cref="ObjectDescriptionFlags"/> is the <c>PWD._bitfield</c>
|
||
/// trailer (<c>acclient.h:6431-6463</c>) — bits include <c>BF_PLAYER (0x8)</c>,
|
||
/// <c>BF_PLAYER_KILLER (0x20)</c>, <c>BF_FREE_PKSTATUS (0x200000)</c>,
|
||
/// <c>BF_PKLITE_PKSTATUS (0x2000000)</c>. Decoded into
|
||
/// <c>EntityCollisionFlags</c> at registration time for the PvP
|
||
/// exemption gate.
|
||
/// </para>
|
||
/// </remarks>
|
||
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,
|
||
uint? PhysicsState = null,
|
||
uint? ObjectDescriptionFlags = null,
|
||
// L.3b (2026-04-30): per-object friction + elasticity from the
|
||
// wire. Default to null when their PhysicsDescriptionFlag bits
|
||
// weren't set; subscribers fall back to PhysicsBody constructor
|
||
// defaults (0.05f elasticity, 0.5f friction).
|
||
float? Friction = null,
|
||
float? Elasticity = null,
|
||
// 2026-05-15: optional WeenieHeader tail. The retail
|
||
// `ITEM_USEABLE _useability` (acclient.h:6478) — gates whether the
|
||
// R-key Use action does anything. <c>(Useability & USEABLE_REMOTE
|
||
// (0x20)) != 0</c> means the entity is useable from the world via
|
||
// mouse Use. Signs / banners / decorative scenery have
|
||
// <c>USEABLE_UNDEF (0x0)</c> here — selecting them via left-click is
|
||
// fine, but R-key Use should be a no-op (retail-faithful: the
|
||
// character does not walk toward; nothing happens).
|
||
// <c>UseRadius</c> is the use-action's reach in meters; doubles as
|
||
// a sizing hint for selection indicators on entities that
|
||
// publish it.
|
||
uint? Useability = null,
|
||
float? UseRadius = null);
|
||
|
||
/// <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,
|
||
MoveToPathData? MoveToPath = 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>
|
||
/// MovementParameters bit 9 (mask 0x200) — set when the creature is
|
||
/// chasing its target. Cross-checked against acclient.h:31423-31443
|
||
/// (named retail) + ACE <c>MovementParamFlags.MoveTowards</c>.
|
||
/// </summary>
|
||
public bool MoveTowards => MoveToParameters.HasValue
|
||
&& (MoveToParameters.Value & 0x200u) != 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Path-control payload of a server-controlled MoveTo packet (movementType 6 or 7).
|
||
/// Wire layout per <c>MovementParameters::UnPackNet</c> @ <c>0x0052ac50</c>
|
||
/// + the leading <c>Origin</c> + optional target guid for type 6:
|
||
/// <list type="bullet">
|
||
/// <item>type 6 (MoveToObject) only: u32 <c>TargetGuid</c></item>
|
||
/// <item>Origin: u32 <c>cellId</c>, then 3 floats (local x/y/z within the landblock)</item>
|
||
/// <item>MovementParameters (28 bytes, exact retail order):
|
||
/// u32 flags, f32 <c>distance_to_object</c>, f32 <c>min_distance</c>,
|
||
/// f32 <c>fail_distance</c>, f32 <c>speed</c>, f32 <c>walk_run_threshhold</c>,
|
||
/// f32 <c>desired_heading</c></item>
|
||
/// </list>
|
||
/// (The trailing <c>runRate</c> float is captured separately on
|
||
/// <see cref="ServerMotionState.MoveToRunRate"/>.)
|
||
/// </summary>
|
||
public readonly record struct MoveToPathData(
|
||
uint? TargetGuid,
|
||
uint OriginCellId,
|
||
float OriginX,
|
||
float OriginY,
|
||
float OriginZ,
|
||
float DistanceToObject,
|
||
float MinDistance,
|
||
float FailDistance,
|
||
float WalkRunThreshold,
|
||
float DesiredHeading);
|
||
|
||
/// <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>
|
||
/// The ModelData block — palette/texture/animpart changes — that lives
|
||
/// inside both CreateObject (initial spawn) and ObjDescEvent (0xF625
|
||
/// appearance update). Factored out so both sites parse the same wire
|
||
/// shape with one implementation.
|
||
/// </summary>
|
||
public readonly record struct ModelData(
|
||
uint? BasePaletteId,
|
||
IReadOnlyList<SubPaletteSwap> SubPalettes,
|
||
IReadOnlyList<TextureChange> TextureChanges,
|
||
IReadOnlyList<AnimPartChange> AnimPartChanges);
|
||
|
||
/// <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;
|
||
// Commit A 2026-04-29 — live-entity collision plumbing. PhysicsState
|
||
// (acclient.h:2815) was previously skipped at line ~337; the PWD
|
||
// _bitfield (acclient.h:6431-6463) was previously discarded as
|
||
// "ObjectDescriptionFlags" at the WeenieHeader trailer.
|
||
uint? physicsState = null;
|
||
uint? objectDescriptionFlags = null;
|
||
// L.3b (2026-04-30): per-object friction + elasticity. Wire-encoded
|
||
// when their PhysicsDescriptionFlag bits are set. Default values
|
||
// come from PhysicsBody constructors; these overrides drive the
|
||
// velocity-reflection bounce magnitude per object (e.g., bouncier
|
||
// platforms vs. inert walls).
|
||
float? friction = null;
|
||
float? elasticity = null;
|
||
|
||
try
|
||
{
|
||
int pos = 0;
|
||
|
||
uint opcode = ReadU32(body, ref pos);
|
||
if (opcode != Opcode)
|
||
return null;
|
||
|
||
uint guid = ReadU32(body, ref pos);
|
||
|
||
var modelData = ReadModelData(body, ref pos);
|
||
uint? basePaletteId = modelData.BasePaletteId;
|
||
var subPalettes = modelData.SubPalettes;
|
||
var textureChanges = modelData.TextureChanges;
|
||
var animParts = modelData.AnimPartChanges;
|
||
|
||
// --- PhysicsData ---
|
||
if (body.Length - pos < 8) return null;
|
||
var physicsFlags = (PhysicsDescriptionFlag)BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
// PhysicsState (acclient.h:2815). Previously skipped, now
|
||
// surfaced for live-entity collision (Commit A 2026-04-29).
|
||
physicsState = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
|
||
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)
|
||
{
|
||
if (body.Length - pos < 4) return PartialResult();
|
||
friction = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
}
|
||
if ((physicsFlags & PhysicsDescriptionFlag.Elasticity) != 0)
|
||
{
|
||
// L.3b (2026-04-30): capture instead of skipping. The wire
|
||
// float is the per-object elasticity used by the velocity-
|
||
// reflection bounce (CPhysicsObj::set_elasticity at
|
||
// acclient_2013_pseudo_c.txt:277817, clamped to [0, 0.1]).
|
||
// Was previously dropped — every object got the default
|
||
// 0.05f, so server-set bouncier surfaces felt identical to
|
||
// walls.
|
||
if (body.Length - pos < 4) return PartialResult();
|
||
elasticity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||
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;
|
||
uint weenieFlags = 0;
|
||
if (body.Length - pos >= 4)
|
||
{
|
||
weenieFlags = ReadU32(body, ref pos);
|
||
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)
|
||
{
|
||
// ObjectDescriptionFlags = retail PWD._bitfield
|
||
// (acclient.h:6431-6463). Carries BF_PLAYER (0x8),
|
||
// BF_PLAYER_KILLER (0x20), BF_FREE_PKSTATUS (0x200000),
|
||
// BF_PKLITE_PKSTATUS (0x2000000) — the bits that
|
||
// acclient_2013_pseudo_c.txt:406898-406918 read for
|
||
// IsPK() / IsPKLite() / IsImpenetrable(). Previously
|
||
// discarded; now surfaced for the PvP collision rule.
|
||
objectDescriptionFlags = ReadU32(body, ref pos);
|
||
}
|
||
AlignTo4(ref pos);
|
||
}
|
||
catch { /* truncated name — partial result is still useful */ }
|
||
}
|
||
|
||
// --- WeenieHeader optional tail (2026-05-15): walk the
|
||
// conditional fields up through Useability + UseRadius.
|
||
//
|
||
// Wire order is fixed by ACE WorldObject_Networking.cs:87-114
|
||
// and matches retail PWD::Pack order. We MUST skip every
|
||
// preceding optional field (even those we don't care about)
|
||
// because each one moves the parse cursor.
|
||
//
|
||
// Field bit width decoded?
|
||
// ------- ------ -------- --------
|
||
// weenieFlags2 conditional on objDescFlags & 0x80000000 (BF_INCLUDES_SECOND_HEADER)
|
||
// u32 skipped
|
||
// PluralName 0x1 String16L (variable, padded to 4) skipped
|
||
// ItemCapacity 0x2 1 byte skipped
|
||
// ContainerCap 0x4 1 byte skipped
|
||
// AmmoType 0x100 u16 skipped
|
||
// Value 0x8 u32 skipped
|
||
// Useability 0x10 u32 KEPT
|
||
// UseRadius 0x20 f32 KEPT
|
||
//
|
||
// Wrapped in try/catch — if a malformed entity truncates the
|
||
// tail we still return the prefix fields. Most spawned entities
|
||
// either have all of these or none of them.
|
||
uint? useability = null;
|
||
float? useRadius = null;
|
||
try
|
||
{
|
||
bool hasSecondHeader = objectDescriptionFlags.HasValue
|
||
&& (objectDescriptionFlags.Value & 0x80000000u) != 0;
|
||
if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2
|
||
|
||
if ((weenieFlags & 0x00000001u) != 0) // PluralName
|
||
_ = ReadString16L(body, ref pos);
|
||
|
||
if ((weenieFlags & 0x00000002u) != 0) // ItemCapacity
|
||
{
|
||
if (body.Length - pos < 1) throw new FormatException("trunc ItemCap");
|
||
pos += 1;
|
||
}
|
||
if ((weenieFlags & 0x00000004u) != 0) // ContainerCapacity
|
||
{
|
||
if (body.Length - pos < 1) throw new FormatException("trunc ContCap");
|
||
pos += 1;
|
||
}
|
||
if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16
|
||
{
|
||
if (body.Length - pos < 2) throw new FormatException("trunc AmmoType");
|
||
pos += 2;
|
||
}
|
||
if ((weenieFlags & 0x00000008u) != 0) // Value u32
|
||
{
|
||
if (body.Length - pos < 4) throw new FormatException("trunc Value");
|
||
pos += 4;
|
||
}
|
||
if ((weenieFlags & 0x00000010u) != 0) // Useability u32 ← KEEP
|
||
{
|
||
if (body.Length - pos < 4) throw new FormatException("trunc Useability");
|
||
useability = ReadU32(body, ref pos);
|
||
}
|
||
if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32 ← KEEP
|
||
{
|
||
if (body.Length - pos < 4) throw new FormatException("trunc UseRadius");
|
||
useRadius = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||
pos += 4;
|
||
}
|
||
}
|
||
catch { /* truncated weenie tail — keep whatever we got. */ }
|
||
|
||
return new Parsed(guid, position, setupTableId, animParts,
|
||
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
|
||
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
|
||
physicsState, objectDescriptionFlags,
|
||
friction, elasticity,
|
||
useability, useRadius);
|
||
|
||
// 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,
|
||
PhysicsState: physicsState,
|
||
ObjectDescriptionFlags: objectDescriptionFlags,
|
||
Friction: friction,
|
||
Elasticity: elasticity);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read the ModelData block — palette swaps + texture overrides +
|
||
/// animation-part replacements — that lives inside both CreateObject
|
||
/// (initial spawn) and ObjDescEvent (0xF625 appearance update).
|
||
///
|
||
/// <para>Layout: byte marker (0x11), byte subPaletteCount, byte
|
||
/// textureChangeCount, byte animPartChangeCount. Then:</para>
|
||
/// <list type="bullet">
|
||
/// <item>BasePaletteId (PackedDword of palette type), only present when subPaletteCount > 0</item>
|
||
/// <item>SubPalettes[subPaletteCount]: PackedDword id + byte offset + byte length</item>
|
||
/// <item>TextureChanges[textureChangeCount]: byte partIndex + PackedDword oldTex + PackedDword newTex</item>
|
||
/// <item>AnimPartChanges[animPartChangeCount]: byte partIndex + PackedDword newModelId</item>
|
||
/// <item>4-byte alignment pad</item>
|
||
/// </list>
|
||
///
|
||
/// <para>Throws <see cref="FormatException"/> on truncated input —
|
||
/// callers wrap in try/catch and convert to a null result. Advances
|
||
/// <paramref name="pos"/> past the alignment pad so the caller can
|
||
/// continue reading the next field.</para>
|
||
/// </summary>
|
||
public static ModelData ReadModelData(ReadOnlySpan<byte> body, ref int pos)
|
||
{
|
||
if (body.Length - pos < 4) throw new FormatException("truncated ModelData header");
|
||
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) throw new FormatException("truncated SubPaletteSwap");
|
||
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) throw new FormatException("truncated TextureChange");
|
||
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);
|
||
}
|
||
|
||
// ACE writes NewModelId via WritePackedDwordOfKnownType(0x01000000)
|
||
// which strips the high-byte type if present before packing.
|
||
// ReadPackedDwordOfKnownType ORs it back on read.
|
||
var animParts = animPartChangeCount == 0
|
||
? (IReadOnlyList<AnimPartChange>)Array.Empty<AnimPartChange>()
|
||
: new AnimPartChange[animPartChangeCount];
|
||
for (int i = 0; i < animPartChangeCount; i++)
|
||
{
|
||
if (body.Length - pos < 1) throw new FormatException("truncated AnimPartChange");
|
||
byte partIndex = body[pos]; pos += 1;
|
||
uint newModelId = ReadPackedDwordOfKnownType(body, ref pos, GfxObjTypePrefix);
|
||
((AnimPartChange[])animParts)[i] = new AnimPartChange(partIndex, newModelId);
|
||
}
|
||
|
||
AlignTo4(ref pos);
|
||
return new ModelData(basePaletteId, subPalettes, textureChanges, animParts);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|