acdream/src/AcDream.Core.Net/Messages/CreateObject.cs
Erik 7bd4388b6b
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
refactor(net): Slice 5.0 — extract the PublicWeenieDesc body parser for shared use (behavior-preserving)
The WeenieHeader fixed prefix plus the ~300-line conditional
optional-tail cascade moves verbatim from CreateObject.TryParse into
PublicWeenieDescParser.Parse (PublicWeenieDescBody.cs), so Slice 5.1's
vendor-item parser can share it instead of duplicating it — each shop
item on the wire is a full CreateObject-style PublicWeenieDesc
(research doc §A.2). Same field order, same nested try/catch swallow
shape, same truncation messages; CreateObject composes its Parsed from
the returned record. Zero test files changed; Core.Net tests 764/0/0
unchanged; clean-room complete solution 11,271 passed / 4 skipped / 0
failed.

Per the Slice 5 contract (decision 2): extraction FIRST, as its own
bisectable commit, before any vendor code exists to call it.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 14:41:09 +02:00

1207 lines
58 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Buffers.Binary;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Items;
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>
/// The parser preserves the complete PhysicsDesc needed to construct a retail
/// physics object, while retaining legacy convenience fields during the
/// LiveEntityRuntime migration:
/// </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>
/// Every PhysicsDesc field is captured, including presence-preserving zero
/// values and all nine timestamps. The PublicWeenieDesc parser continues to
/// retain its supported presentation/gameplay subset.
/// </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,
// L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1)
// seeds PhysicsTimestampGate's MOVEMENT_TS at spawn.
ushort MovementSequence = 0,
// Parent/placement bootstrap for equipped child objects. These are
// the CreateObject equivalents of ParentEvent 0xF749.
ushort PositionSequence = 0,
uint? ParentGuid = null,
uint? ParentLocation = null,
uint? PlacementId = null,
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,
// D.5.1 (2026-06-16): icon dat id (0x06xxxxxx) from the WeenieHeader
// fixed prefix. Previously discarded at cs:516; surfaced so the action
// bar / equipment UI can display the correct icon sprite without a
// separate dat lookup. Zero means "not sent" (packed zero sentinel in
// ReadPackedDwordOfKnownType preserves 0 as-is).
uint IconId = 0,
// 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 &amp; 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,
uint? TargetType = null,
// D.5.1 (2026-06-17): icon overlay/underlay dat ids from the
// WeenieHeader optional tail. IconOverlayId is gated by
// WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags;
// IconUnderlayId is gated by WeenieHeaderFlag2.IconUnderlay (0x01)
// in weenieFlags2 (present when objDescFlags bit 0x04000000 is set).
// Sourced from ACE WorldObject_Networking.cs:202-206. Zero when
// the server did not send the field (most entities have neither).
// IconComposer.GetIcon already composites these layers in the correct
// retail order (underlay / base / overlay+tint / effect).
uint IconOverlayId = 0,
uint IconUnderlayId = 0,
// D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's
// effect recolor (Magical=0x1 … Nether=0x1000). The ONLY wire path for the effect
// state (PropertyInt.UiEffects=18 has no [AssessmentProperty] → not in appraise).
// Previously read + discarded at the UiEffects skip. 0 = no effect.
uint UiEffects = 0,
// D.5.4 (2026-06-18): full item field set from the WeenieHeader tail —
// previously walked-past. Wire bits per r06 §4 / PublicWeenieDesc.
// Quantity fields are int? to match ClientObject storage (ACE PropertyInt
// convention; the wire ushort/byte values widen losslessly); id/mask
// fields are uint?. null = the gated flag was absent (don't clobber on
// merge). WeenieClassId is the fixed-prefix class id (was discarded at
// cs:538); it is non-nullable — 0 means the prefix was absent/zero.
uint WeenieClassId = 0,
int? Value = null,
int? StackSize = null,
int? StackSizeMax = null,
int? Burden = null,
int? ItemsCapacity = null,
int? ContainersCapacity = null,
// PublicWeenieDesc hook identity. ACCWeenieObject::IsHook
// @ 0x0058C660 requires both values to be non-zero.
// The wire stores HookItemTypes as u32 and HookType as u16.
uint? HookItemTypes = null,
uint? HookType = null,
uint? ContainerId = null,
uint? WielderId = null,
uint? ValidLocations = null,
uint? CurrentWieldedLocation = null,
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null,
// PublicWeenieDesc optional-tail bytes (_blipColor/_radar_enum at
// acclient.h:37191-37192; retail UnPack 0x005AD470 reads both as u8).
// Nullable preserves the wire distinction between an absent flag and
// an explicitly transmitted zero (the enum's undefined/default value).
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
byte? CombatUse = null,
// PublicWeenieDesc._plural_name, gated by WeenieHeader flag 0x1.
// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) selects this for stacks.
string? PluralName = null,
// PublicWeenieDesc._pet_owner, gated by second-header flag 0x8.
uint? PetOwnerId = null,
// PublicWeenieDesc._ammoType, gated by WeenieHeader flag 0x100.
// AMMO_NONE is the explicit wire value zero; null means absent.
ushort? AmmoType = null,
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
// wire field is u16 and widens into retail's u32 runtime member.
uint? SpellId = null,
// PublicWeenieDesc second-header cooldown metadata. The group id is
// compared against the player's cooldown enchantment list; the
// duration scales the shared UIItem radial countdown.
uint? CooldownId = null,
double? CooldownDuration = null,
// Complete immutable PhysicsDesc projection. Kept alongside the
// legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2.
PhysicsSpawnData? Physics = null,
// PublicWeenieDesc._material_type, gated by PWD_Packed_MaterialType.
// ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) uses this live
// spawn field to prefix the authored material name.
uint? MaterialType = null,
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): house-restriction
// PWD tail fields (WeenieHeaderFlag.Owner 0x02000000, .HouseRestrictions
// 0x04000000, .Monarch 0x40). Feeds ObjectInfo.CheckEntryRestrictions'
// CanMoveInto port.
uint? HouseOwnerId = null,
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = 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 &lt;&lt; 16) | (command &amp; 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>
/// <param name="MoveToRunRate">
/// R4-V3 deliverable D — the trailing <c>f32 runRate</c> on MoveToObject
/// (6) / MoveToPosition (7) payloads. Retail's <c>unpack_movement</c>
/// writes this straight onto <c>CMotionInterp::my_run_rate</c>
/// (r4-moveto-decomp.md §2f: <c>this->motion_interpreter->my_run_rate =
/// read_float()</c>, both @300603 case 6 and @300660 case 7 — SAME
/// write for both types, immediately after <c>UnPackNet</c>). Today this
/// field only seeds <c>PlanMoveToStart</c>'s local heuristic (plan M13);
/// the interp's actual <see cref="AcDream.Core.Physics.MotionInterpreter.MyRunRate"/>
/// field is a SEPARATE consumer write the V4/V5 MoveToManager cutover
/// performs at the GameWindow mt 6-9 routing site (r4-port-plan.md §4,
/// step 2: <c>Motion.MyRunRate = MoveToRunRate</c>) — this record is
/// wire-primitive only; it does not write MotionInterpreter state
/// itself.
/// </param>
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,
// R4-V3 (closes M7): movement types 8 (TurnToObject) and 9
// (TurnToHeading) — previously dropped end-to-end (UpdateMotion.cs
// only branched on `movementType is 6 or 7`). Carries the DECODED
// wire payload (guid + standalone wire_heading for type 8, plus the
// shared 3-dword UnPackNet triple for both types) per V0-pins.md P6.
TurnToPathData? TurnToPath = null,
// R4-V3 (closes M14-wire-note): the 0xF74C motionFlags sticky-guid
// trailer, mt=0 (Invalid) only — ACE MovementInvalid.Write gates the
// trailing guid on MotionFlags.StickToObject (0x1); the decomp's
// `unpack_movement` case 0 reads it right after
// InterpretedMotionState::UnPack (r4-moveto-decomp.md §2f
// @0052455d: `if (header & 0x100) sticky_object_guid = read_dword()`
// — bit 0x100 of the combined header word is motionFlags byte1&0x1).
// R5-V4 consumes it: the GameWindow mt-0 tail routes it into
// CPhysicsObj::stick_to_object's port (target PartArray radii →
// PositionManager.StickTo — decomp 0x005127e0, call @00524589).
uint? StickyObjectGuid = null,
// R5-V4 (closes the "documented but NOT consumed" note in
// UpdateMotion.cs): motionFlags & 0x2 — retail `unpack_movement`
// case 0 writes it onto `motion_interpreter->standing_longjump`
// UNCONDITIONALLY (@0052458e: absent flag CLEARS it). Consumed at
// the GameWindow mt-0 tails (remote + player).
bool StandingLongJump = false)
{
/// <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;
/// <summary>
/// R4-V3: movement types 8 (TurnToObject) and 9 (TurnToHeading) —
/// the turn-only sibling of <see cref="IsServerControlledMoveTo"/>.
/// Neither carries an InterpretedMotionState.ForwardCommand either.
/// </summary>
public bool IsServerControlledTurnTo => MovementType is 8 or 9;
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>
/// MovementParameters bit 4 (mask 0x10) — set when the mover should
/// charge (run) rather than walk. ACE's
/// <c>Creature.SetWalkRunThreshold</c> sets this when the player-to-
/// target distance is at least <c>WalkRunThreshold / 2</c> (7.5 m
/// for the 15 m default), and clears it for shorter chases — so this
/// bit IS the wire-side walk-vs-run decision.
/// <para>
/// Retail's <c>MovementParameters::get_command</c>
/// (<c>0x0052aa00</c>) gates the run path on this bit: cleared →
/// fall through to the inner walk_run_threshold check (which ACE's
/// 15 m default + 0.6 m use-radius makes practically always walk for
/// any &lt; 15.6 m chase); set → unconditional <c>HoldKey_Run</c>.
/// </para>
/// <para>
/// Cross-ref: ACE <c>MovementParams.CanCharge = 0x10</c>
/// (<c>ACE.Entity/Enum/MovementParams.cs:12</c>).
/// </para>
/// </summary>
public bool CanCharge => MoveToParameters.HasValue
&& (MoveToParameters.Value & 0x10u) != 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,
uint Bitfield = 0); // R4-V4: the raw UnPackNet flags dword, feeds MovementParameters.FromWire
/// <summary>
/// R4-V3 (closes M7) — path-control payload of a server-controlled
/// TurnTo packet (movementType 8 TurnToObject or 9 TurnToHeading).
/// Sibling of <see cref="MoveToPathData"/>: kept as a SEPARATE record
/// rather than widening <c>MoveToPathData</c> in place, because the two
/// wire forms genuinely diverge (7-dword <c>UnPackNet</c> with an
/// Origin+optional-guid head for move types vs. the 3-dword
/// <c>UnPackNet</c> with a guid+standalone-heading head for turn types —
/// V0-pins.md P6) and a single record would need every move-only field
/// nullable for turn payloads (and vice versa) for no reader benefit —
/// no code path ever needs "either a move or a turn path" polymorphically,
/// every consumer already switches on <see cref="ServerMotionState.MovementType"/>
/// first.
///
/// <list type="bullet">
/// <item>type 8 (TurnToObject) only: u32 <c>TargetGuid</c>, f32
/// <c>WireHeading</c> — the STANDALONE heading field (ACE
/// <c>TurnToObject.DesiredHeading</c>, distinct from
/// <see cref="DesiredHeading"/> below despite ACE always populating
/// both from the same source; V0-pins.md P6's fixture caveat: never
/// distinguish the two fields by value in a test, only by
/// OFFSET). Consumed ONLY in retail's unresolvable-object fallback
/// (decomp §2f case 8: <c>if (GetObjectA(object_id) == 0) {
/// params.desired_heading = wire_heading; goto TurnToHeading; }</c>)
/// — the resolved-object path never reads it.</item>
/// <item>TurnToParameters (0xc bytes, exact retail order —
/// <c>MovementParameters::UnPackNet</c> 3-dword TurnTo form, decomp
/// §2g): u32 <c>Bitfield</c>, f32 <c>Speed</c>, f32
/// <c>DesiredHeading</c>. Present for BOTH type 8 and type 9 (type 9
/// has no guid/WireHeading head — <see cref="TargetGuid"/> and
/// <see cref="WireHeading"/> are null).</item>
/// </list>
///
/// Feeds <see cref="AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo"/>
/// at the (future) App-layer consumer — this record stays wire-primitive
/// (no domain-object construction in the Net-layer parser).
/// </summary>
public readonly record struct TurnToPathData(
uint? TargetGuid,
float? WireHeading,
uint Bitfield,
float Speed,
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 after consuming the supported PhysicsDesc and public
/// WeenieHeader fields.
/// </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;
PhysicsMovementData? movement = null;
uint? soundTableId = null;
uint? physicsScriptTableId = null;
ReadOnlyMemory<PhysicsAttachment>? children = null;
float? translucency = null;
Vector3? velocity = null;
Vector3? acceleration = null;
Vector3? angularVelocity = null;
uint? defaultScriptType = null;
float? defaultScriptIntensity = null;
PhysicsSpawnData? physics = 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;
// ObjectDescriptionFlags (retail PWD._bitfield) is captured inside
// PublicWeenieDescParser.Parse below (Slice 5.0 extraction) — no
// longer a top-level accumulator here.
// 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;
uint? placementId = null;
uint? parentGuid = null;
uint? parentLocation = null;
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;
ReadOnlySpan<byte> movementBytes = body.Slice(movementStart, (int)movementLen);
motionState = TryParseMovementData(movementBytes);
pos = movementStart + (int)movementLen;
if (body.Length - pos < 4) return null;
bool isAutonomous = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)) != 0;
pos += 4;
movement = new PhysicsMovementData(movementBytes.ToArray(), motionState, isAutonomous);
}
else
movement = new PhysicsMovementData(ReadOnlyMemory<byte>.Empty, null, null);
}
else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
{
if (body.Length - pos < 4) return null;
placementId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
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;
soundTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.PeTable) != 0)
{
if (body.Length - pos < 4) return null;
physicsScriptTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
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;
parentGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
parentLocation = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 4));
pos += 8;
}
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 null;
if (body.Length - pos < childCount * 8) return null;
var parsedChildren = new PhysicsAttachment[childCount];
for (int i = 0; i < parsedChildren.Length; i++)
{
parsedChildren[i] = new PhysicsAttachment(
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)),
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 4)));
pos += 8;
}
children = parsedChildren;
}
if ((physicsFlags & PhysicsDescriptionFlag.ObjScale) != 0)
{
if (body.Length - pos < 4) return null;
objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0)
{
if (body.Length - pos < 4) return null;
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 null;
elasticity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0)
{
if (body.Length - pos < 4) return null;
translucency = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0)
{
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
velocity = value;
}
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0)
{
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
acceleration = value;
}
if ((physicsFlags & PhysicsDescriptionFlag.Omega) != 0)
{
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
angularVelocity = value;
}
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScript) != 0)
{
if (body.Length - pos < 4) return null;
defaultScriptType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0)
{
if (body.Length - pos < 4) return null;
defaultScriptIntensity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// 9 sequence timestamps, always present at end of PhysicsData.
// PhysicsTimeStamp enum order (acclient.h:6084; ACE
// WorldObject_Networking.cs:411-420): 0=position, 1=movement,
// 4=teleport, 5=serverControl, 6=forcePosition, 8=instance.
if (body.Length - pos < 9 * 2) return null;
var seqSpan = body.Slice(pos, 9 * 2);
ushort positionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(0 * 2));
ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 2));
ushort stateSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(2 * 2));
ushort vectorSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(3 * 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));
ushort objDescSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(7 * 2));
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
pos += 9 * 2;
AlignTo4(ref pos);
if (pos > body.Length) return null;
var timestamps = new PhysicsTimestamps(
positionSeq, movementSeq, stateSeq, vectorSeq, teleportSeq,
serverControlSeq, forcePositionSeq, objDescSeq, instanceSeq);
physics = new PhysicsSpawnData(
RawState: physicsState.Value,
Position: position,
Movement: movement,
AnimationFrame: placementId,
SetupTableId: setupTableId,
MotionTableId: motionTableId,
SoundTableId: soundTableId,
PhysicsScriptTableId: physicsScriptTableId,
Parent: parentGuid.HasValue && parentLocation.HasValue
? new PhysicsAttachment(parentGuid.Value, parentLocation.Value)
: null,
Children: children,
Scale: objScale,
Friction: friction,
Elasticity: elasticity,
Translucency: translucency,
Velocity: velocity,
Acceleration: acceleration,
AngularVelocity: angularVelocity,
DefaultScriptType: defaultScriptType,
DefaultScriptIntensity: defaultScriptIntensity,
Timestamps: timestamps);
// --- WeenieHeader / PublicWeenieDesc body ---
// Extracted (Slice 5.0, world-interaction-completion plan
// decision 2) into PublicWeenieDescParser.Parse
// (PublicWeenieDescBody.cs) so the Slice 5.1 vendor item-list
// parser (ApproachVendor, GameEvent 0x0062) can share this exact
// conditional field cascade instead of duplicating it.
// Extraction only — same fields, same order, same truncation
// handling as the original inline code (both inner try/catches
// there swallowed and returned a partial result; this call does
// the same).
var desc = PublicWeenieDescParser.Parse(body, ref pos);
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, desc.Name, desc.ItemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
movementSeq,
PositionSequence: positionSeq,
ParentGuid: parentGuid,
ParentLocation: parentLocation,
PlacementId: placementId,
PhysicsState: physicsState,
ObjectDescriptionFlags: desc.ObjectDescriptionFlags,
Friction: friction,
Elasticity: elasticity,
IconId: desc.IconId,
Useability: desc.Useability, UseRadius: desc.UseRadius, TargetType: desc.TargetType,
IconOverlayId: desc.IconOverlayId, IconUnderlayId: desc.IconUnderlayId,
UiEffects: desc.UiEffects,
WeenieClassId: desc.WeenieClassId,
Value: desc.Value, StackSize: desc.StackSize, StackSizeMax: desc.StackSizeMax,
Burden: desc.Burden, ItemsCapacity: desc.ItemsCapacity, ContainersCapacity: desc.ContainersCapacity,
HookItemTypes: desc.HookItemTypes, HookType: desc.HookType,
ContainerId: desc.ContainerId, WielderId: desc.WielderId,
ValidLocations: desc.ValidLocations, CurrentWieldedLocation: desc.CurrentWieldedLocation,
Priority: desc.Priority, Structure: desc.Structure, MaxStructure: desc.MaxStructure,
Workmanship: desc.Workmanship,
RadarBlipColor: desc.RadarBlipColor, RadarBehavior: desc.RadarBehavior,
CombatUse: desc.CombatUse,
PluralName: desc.PluralName,
PetOwnerId: desc.PetOwnerId,
AmmoType: desc.AmmoType,
SpellId: desc.SpellId,
CooldownId: desc.CooldownId,
CooldownDuration: desc.CooldownDuration,
Physics: physics,
MaterialType: desc.MaterialType,
HouseOwnerId: desc.HouseOwnerId,
MonarchId: desc.MonarchId,
Restrictions: desc.Restrictions);
}
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 &gt; 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);
}
// Widened from `private` to `internal` (Slice 5.0, extraction only) so
// PublicWeenieDescParser.Parse (PublicWeenieDescBody.cs) — the shared
// desc-body parser used by both CreateObject and the upcoming vendor
// item-list parser — can reuse these primitive readers instead of
// duplicating them. No behavior change.
internal 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;
}
internal 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) &lt;&lt; 16) | highHalfFromNextU16</c>.</item>
/// </list>
/// Ported from ACE's Extensions.WritePackedDword: for values ≤ 32767, emitted as
/// u16; for larger, emitted as <c>(value &lt;&lt; 16) | ((value &gt;&gt; 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>
internal 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>
internal static uint ReadPackedDwordOfKnownType(ReadOnlySpan<byte> source, ref int pos, uint knownType)
{
uint packed = ReadPackedDword(source, ref pos);
return packed == 0 ? 0 : (packed | knownType);
}
internal 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;
}
private static bool TryReadVector3(ReadOnlySpan<byte> body, ref int pos, out Vector3 value)
{
if (body.Length - pos < 12)
{
value = default;
return false;
}
value = new Vector3(
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)),
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)),
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 8)));
pos += 12;
return true;
}
}