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
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>
1207 lines
58 KiB
C#
1207 lines
58 KiB
C#
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 & 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 << 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>
|
||
/// <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 < 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 > 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) << 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>
|
||
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;
|
||
}
|
||
}
|