acdream/src/AcDream.Core.Net/Messages/CreateObject.cs
Erik 58e155615d feat(B.8): retail useability gate + tall-scenery indicator scaling
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>
2026-05-15 20:07:32 +02:00

951 lines
44 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;
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 &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);
/// <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>
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 &amp; 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 &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);
}
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) &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>
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;
}
}