Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-07-09 23:18:52 +02:00
commit 217a4bad69
329 changed files with 81439 additions and 8499 deletions

View file

@ -119,6 +119,9 @@ public static class CreateObject
ushort TeleportSequence = 0,
ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0,
// L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1)
// seeds MotionSequenceGate's MOVEMENT_TS at spawn.
ushort MovementSequence = 0,
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object friction + elasticity from the
@ -215,6 +218,21 @@ public static class CreateObject
/// 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,
@ -228,7 +246,30 @@ public static class CreateObject
uint? MoveToParameters = null,
float? MoveToSpeed = null,
float? MoveToRunRate = null,
MoveToPathData? MoveToPath = 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
@ -238,6 +279,13 @@ public static class CreateObject
/// </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;
@ -297,6 +345,51 @@ public static class CreateObject
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>
@ -532,11 +625,13 @@ public static class CreateObject
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.
// 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 PartialResult();
var seqSpan = body.Slice(pos, 9 * 2);
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 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));
@ -849,6 +944,7 @@ public static class CreateObject
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
movementSeq,
physicsState, objectDescriptionFlags,
friction, elasticity,
IconId: iconId,

View file

@ -9,18 +9,27 @@ namespace AcDream.Core.Net.Messages;
/// the extent + velocity to validate the jump and replicate it to nearby
/// clients.
///
/// Wire layout (from holtburger JumpActionData::pack):
/// u32 0xF7B1 (GameAction envelope)
/// u32 sequence
/// u32 0xF61B (Jump sub-opcode)
/// f32 extent (0.01.0 charge power)
/// f32 velocity.x, f32 velocity.y, f32 velocity.z
/// u16 instanceSequence
/// u16 serverControlSequence
/// u16 teleportSequence
/// u16 forcePositionSequence
/// u32 objectGuid (0 for normal jump)
/// u32 spellId (0 for normal jump)
/// <para>
/// Wire layout — ports retail's <c>JumpPack::Pack</c> verbatim
/// (<c>0x00516d10</c>, decomp lines ~284934-284963). Confirmed against the
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
/// </para>
/// <list type="bullet">
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61B</item>
/// <item><b>f32 extent</b> (0.0-1.0 charge power)</item>
/// <item><b>f32 velocity.x, velocity.y, velocity.z</b></item>
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
/// u16 teleport, u16 forcePosition</item>
/// <item><b>Align to 4 bytes</b></item>
/// </list>
///
/// <para>
/// <b>D4 fix.</b> Retail's <c>JumpPack</c> does NOT pack an objectGuid or a
/// spellId, and DOES pack the full <c>Position</c> — the pre-slice code had
/// it backwards (two spurious trailing zero u32s, no Position at all).
/// </para>
/// </summary>
public static class JumpAction
{
@ -28,29 +37,45 @@ public static class JumpAction
public const uint JumpOpcode = 0xF61Bu;
public static byte[] Build(
uint gameActionSequence,
float extent,
Vector3 velocity,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence)
uint gameActionSequence,
float extent,
Vector3 velocity,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence)
{
var w = new PacketWriter(48);
var w = new PacketWriter(80);
w.WriteUInt32(GameActionOpcode);
w.WriteUInt32(gameActionSequence);
w.WriteUInt32(JumpOpcode);
w.WriteFloat(extent);
w.WriteFloat(velocity.X);
w.WriteFloat(velocity.Y);
w.WriteFloat(velocity.Z);
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
w.WriteUInt32(cellId);
w.WriteFloat(position.X);
w.WriteFloat(position.Y);
w.WriteFloat(position.Z);
// Quaternion wire order: W, X, Y, Z
w.WriteFloat(rotation.W);
w.WriteFloat(rotation.X);
w.WriteFloat(rotation.Y);
w.WriteFloat(rotation.Z);
w.WriteUInt16(instanceSequence);
w.WriteUInt16(serverControlSequence);
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
w.WriteUInt32(0); // objectGuid — 0 for normal jump
w.WriteUInt32(0); // spellId — 0 for normal jump
w.AlignTo4();
return w.ToArray();
}

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
namespace AcDream.Core.Net.Messages;
@ -11,61 +12,39 @@ namespace AcDream.Core.Net.Messages;
/// state and to drive interpolated position for other nearby clients.
///
/// <para>
/// Wire layout (ported from
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs</c>
/// <c>MoveToStateActionData::pack</c> and
/// <c>types.rs RawMotionState::pack</c>):
/// Wire layout — ports retail's <c>MoveToStatePack::Pack</c> verbatim
/// (<c>0x005168f0</c>, decomp lines ~284694-284722). Confirmed against the
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
/// </para>
/// <list type="bullet">
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61C</item>
/// <item><b>RawMotionState</b>: u32 packed_flags (bits 0-10 = flag bits,
/// bits 11-31 = command list length), then conditional u32/f32 fields
/// in flag-bit order (see <c>RawMotionFlags</c> in types.rs)</item>
/// <item><b>WorldPosition</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ</item>
/// <item><b>RawMotionState::Pack</b>: see <see cref="RawMotionStatePacker"/>
/// — default-difference flags dword + conditional fields + actions.</item>
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
/// u16 teleport, u16 forcePosition</item>
/// <item><b>Contact byte</b>: u8 (1 = on ground, 0 = airborne)</item>
/// <item><b>Trailing byte</b>: <c>(standingLongjump ? 0x02 : 0) |
/// (contact ? 0x01 : 0)</c> — <c>MoveToStatePack::Pack</c> trailing
/// byte expression <c>(longjump_mode == 0) - 1U &amp; 2 |
/// contact != 0</c>.</item>
/// <item><b>Align to 4 bytes</b></item>
/// </list>
///
/// <para>
/// The command list length is packed into bits 11-31 of the flags dword.
/// We always send 0 commands (no discrete motion events), so those bits stay 0.
/// </para>
/// </summary>
public static class MoveToState
{
public const uint GameActionOpcode = 0xF7B1u;
public const uint MoveToStateAction = 0xF61Cu;
// RawMotionFlags bit positions (from holtburger types.rs)
private const uint FlagCurrentHoldKey = 0x001u;
private const uint FlagCurrentStyle = 0x002u;
private const uint FlagForwardCommand = 0x004u;
private const uint FlagForwardHoldKey = 0x008u;
private const uint FlagForwardSpeed = 0x010u;
private const uint FlagSidestepCommand = 0x020u;
private const uint FlagSidestepHoldKey = 0x040u;
private const uint FlagSidestepSpeed = 0x080u;
private const uint FlagTurnCommand = 0x100u;
private const uint FlagTurnHoldKey = 0x200u;
private const uint FlagTurnSpeed = 0x400u;
/// <summary>
/// Build a MoveToState GameAction body.
/// </summary>
/// <param name="gameActionSequence">Monotonically increasing counter from
/// <see cref="WorldSession.NextGameActionSequence"/>.</param>
/// <param name="forwardCommand">Raw motion command (u32 in AC's command space),
/// e.g. 0x45000005 = WalkForward. Null = no forward command.</param>
/// <param name="forwardSpeed">Normalised speed scalar (0.0-1.0). Only written
/// when <paramref name="forwardCommand"/> is non-null.</param>
/// <param name="sidestepCommand">Sidestep command or null.</param>
/// <param name="sidestepSpeed">Sidestep speed or null.</param>
/// <param name="turnCommand">Turn command or null.</param>
/// <param name="turnSpeed">Turn speed or null.</param>
/// <param name="holdKey">Hold-key state (1=None, 2=Run). Null = omit.</param>
/// <param name="rawMotionState">Complete raw-motion snapshot, matching
/// retail's <c>CPhysicsObj::InqRawMotionState()</c>. Fields equal to
/// <see cref="RawMotionState.Default"/> are omitted from the wire
/// (see <see cref="RawMotionStatePacker"/>).</param>
/// <param name="cellId">Landblock cell ID (u32).</param>
/// <param name="position">World-space position relative to the landblock.</param>
/// <param name="rotation">Rotation quaternion. AC wire order is W, X, Y, Z.</param>
@ -73,27 +52,22 @@ public static class MoveToState
/// <param name="serverControlSequence">Server-control sequence number.</param>
/// <param name="teleportSequence">Teleport sequence number.</param>
/// <param name="forcePositionSequence">Force-position sequence number.</param>
/// <param name="contactLongJump">1 if the character is on the ground, 0 if airborne.</param>
/// <param name="contact">True if the character is on the ground.</param>
/// <param name="standingLongjump">True during a standing (charged,
/// stationary) longjump wind-up. Not yet implemented in acdream —
/// callers pass <c>false</c> honestly until that feature lands.</param>
public static byte[] Build(
uint gameActionSequence,
uint? forwardCommand,
float? forwardSpeed,
uint? sidestepCommand,
float? sidestepSpeed,
uint? turnCommand,
float? turnSpeed,
uint? holdKey,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
byte contactLongJump = 1,
uint? forwardHoldKey = null,
uint? sidestepHoldKey = null,
uint? turnHoldKey = null)
uint gameActionSequence,
RawMotionState rawMotionState,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
bool contact = true,
bool standingLongjump = false)
{
var w = new PacketWriter(128);
@ -102,43 +76,10 @@ public static class MoveToState
w.WriteUInt32(gameActionSequence);
w.WriteUInt32(MoveToStateAction);
// --- RawMotionState ---
// Build the flags word. Command list length (bits 11-31) is always 0.
// Field order matches holtburger's RawMotionState::pack — for any axis
// where we send a COMMAND + SPEED, retail expects the matching
// *_HOLD_KEY to accompany them (see holtburger's
// build_motion_state_raw_motion_state). Without the per-axis hold
// keys the server gets the flags but can't classify the input as a
// continuously-held key, so other players see the character sliding
// forward without an animation cycle.
uint flags = 0u;
if (holdKey.HasValue) flags |= FlagCurrentHoldKey;
if (forwardCommand.HasValue) flags |= FlagForwardCommand;
if (forwardHoldKey.HasValue) flags |= FlagForwardHoldKey;
if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
if (sidestepCommand.HasValue) flags |= FlagSidestepCommand;
if (sidestepHoldKey.HasValue) flags |= FlagSidestepHoldKey;
if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
if (turnCommand.HasValue) flags |= FlagTurnCommand;
if (turnHoldKey.HasValue) flags |= FlagTurnHoldKey;
if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
// --- RawMotionState::Pack (0x0051ed10) ---
RawMotionStatePacker.Pack(w, rawMotionState);
w.WriteUInt32(flags); // bits 0-10 = flags, bits 11-31 = 0 (no command list)
// Conditional fields in RawMotionFlags bit order:
if (holdKey.HasValue) w.WriteUInt32(holdKey.Value);
// FlagCurrentStyle (0x2): not sent — we don't track stance changes here
if (forwardCommand.HasValue) w.WriteUInt32(forwardCommand.Value);
if (forwardHoldKey.HasValue) w.WriteUInt32(forwardHoldKey.Value);
if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value);
if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value);
if (sidestepHoldKey.HasValue) w.WriteUInt32(sidestepHoldKey.Value);
if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value);
if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value);
if (turnHoldKey.HasValue) w.WriteUInt32(turnHoldKey.Value);
if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value);
// --- WorldPosition (32 bytes) ---
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
w.WriteUInt32(cellId);
w.WriteFloat(position.X);
w.WriteFloat(position.Y);
@ -155,8 +96,10 @@ public static class MoveToState
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
// --- Contact byte + 4-byte align ---
w.WriteByte(contactLongJump);
// --- Trailing byte (MoveToStatePack::Pack, 0x005168f0): ---
// ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
byte trailing = (byte)((standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0));
w.WriteByte(trailing);
w.AlignTo4();
return w.ToArray();

View file

@ -0,0 +1,98 @@
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Ports retail's <c>RawMotionState::Pack</c> verbatim
/// (<c>0x0051ed10</c>, decomp lines ~293761-294013; bitfield layout
/// <c>acclient.h RawMotionState::PackBitfield</c>, line 46474). Confirmed
/// against the Ghidra decompile-by-address bridge during this slice
/// (2026-06-30).
///
/// <para>
/// <b>D1 fix.</b> Retail does NOT set a field's bit merely because the
/// caller supplied a value — it compares every field against its retail
/// DEFAULT (<see cref="RawMotionState.Default"/>) and sets the bit (and
/// emits the field) only when the live value DIFFERS. This packer mirrors
/// that default-difference comparison exactly, field by field, in bit
/// order.
/// </para>
///
/// <para>
/// Flags dword layout (bits 0-15 only; bits 16-31 are unused — retail's
/// <c>num_actions</c> is a 5-bit field occupying bits 11-15, not "the rest
/// of the dword"):
/// </para>
/// <list type="table">
/// <item><term>0x001</term><description>current_holdkey != HoldKey.None</description></item>
/// <item><term>0x002</term><description>current_style != 0x8000003D</description></item>
/// <item><term>0x004</term><description>forward_command != 0x41000003</description></item>
/// <item><term>0x008</term><description>forward_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x010</term><description>forward_speed != 1.0f</description></item>
/// <item><term>0x020</term><description>sidestep_command != 0</description></item>
/// <item><term>0x040</term><description>sidestep_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x080</term><description>sidestep_speed != 1.0f</description></item>
/// <item><term>0x100</term><description>turn_command != 0</description></item>
/// <item><term>0x200</term><description>turn_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x400</term><description>turn_speed != 1.0f</description></item>
/// <item><term>0xF800</term><description>num_actions (bits 11-15, count not values)</description></item>
/// </list>
/// </summary>
public static class RawMotionStatePacker
{
private const uint FlagCurrentHoldKey = 0x001u;
private const uint FlagCurrentStyle = 0x002u;
private const uint FlagForwardCommand = 0x004u;
private const uint FlagForwardHoldKey = 0x008u;
private const uint FlagForwardSpeed = 0x010u;
private const uint FlagSidestepCommand = 0x020u;
private const uint FlagSidestepHoldKey = 0x040u;
private const uint FlagSidestepSpeed = 0x080u;
private const uint FlagTurnCommand = 0x100u;
private const uint FlagTurnHoldKey = 0x200u;
private const uint FlagTurnSpeed = 0x400u;
private const int NumActionsShift = 11;
public static void Pack(PacketWriter w, RawMotionState state)
{
var defaults = RawMotionState.Default;
uint flags = 0u;
if (state.CurrentHoldKey != defaults.CurrentHoldKey) flags |= FlagCurrentHoldKey;
if (state.CurrentStyle != defaults.CurrentStyle) flags |= FlagCurrentStyle;
if (state.ForwardCommand != defaults.ForwardCommand) flags |= FlagForwardCommand;
if (state.ForwardHoldKey != defaults.ForwardHoldKey) flags |= FlagForwardHoldKey;
if (state.ForwardSpeed != defaults.ForwardSpeed) flags |= FlagForwardSpeed;
if (state.SidestepCommand != defaults.SidestepCommand) flags |= FlagSidestepCommand;
if (state.SidestepHoldKey != defaults.SidestepHoldKey) flags |= FlagSidestepHoldKey;
if (state.SidestepSpeed != defaults.SidestepSpeed) flags |= FlagSidestepSpeed;
if (state.TurnCommand != defaults.TurnCommand) flags |= FlagTurnCommand;
if (state.TurnHoldKey != defaults.TurnHoldKey) flags |= FlagTurnHoldKey;
if (state.TurnSpeed != defaults.TurnSpeed) flags |= FlagTurnSpeed;
int numActions = state.Actions.Count;
flags |= (uint)(numActions << NumActionsShift);
w.WriteUInt32(flags);
if ((flags & FlagCurrentHoldKey) != 0) w.WriteUInt32((uint)state.CurrentHoldKey);
if ((flags & FlagCurrentStyle) != 0) w.WriteUInt32(state.CurrentStyle);
if ((flags & FlagForwardCommand) != 0) w.WriteUInt32(state.ForwardCommand);
if ((flags & FlagForwardHoldKey) != 0) w.WriteUInt32((uint)state.ForwardHoldKey);
if ((flags & FlagForwardSpeed) != 0) w.WriteFloat(state.ForwardSpeed);
if ((flags & FlagSidestepCommand) != 0) w.WriteUInt32(state.SidestepCommand);
if ((flags & FlagSidestepHoldKey) != 0) w.WriteUInt32((uint)state.SidestepHoldKey);
if ((flags & FlagSidestepSpeed) != 0) w.WriteFloat(state.SidestepSpeed);
if ((flags & FlagTurnCommand) != 0) w.WriteUInt32(state.TurnCommand);
if ((flags & FlagTurnHoldKey) != 0) w.WriteUInt32((uint)state.TurnHoldKey);
if ((flags & FlagTurnSpeed) != 0) w.WriteFloat(state.TurnSpeed);
foreach (var action in state.Actions)
{
w.WriteUInt16(action.Command);
ushort stampWord = (ushort)((action.Stamp & 0x7FFF) | (action.Autonomous ? 0x8000 : 0));
w.WriteUInt16(stampWord);
}
}
}

View file

@ -34,7 +34,20 @@ namespace AcDream.Core.Net.Messages;
/// u32 flagsAndCommandCount, then each present field in flag order
/// (CurrentStyle u16, ForwardCommand u16, SidestepCommand u16,
/// TurnCommand u16, forward speed f32, sidestep speed f32,
/// turn speed f32), commands list, align.</item>
/// turn speed f32), commands list, align; THEN — only when
/// <c>motionFlags &amp; 0x1</c> (StickToObject) — one trailing u32
/// sticky object guid (ACE <c>MovementInvalid.Write</c>; R4-V3).</item>
/// <item>MoveToObject (6) / MoveToPosition (7): [u32 target guid,
/// MoveToObject only] Origin (u32 cell + 3×f32), MoveToParameters /
/// <c>UnPackNet</c> 7-dword form (u32 bitfield + 6×f32), f32
/// runRate. R4-V3: exposed via
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>.</item>
/// <item>TurnToObject (8) / TurnToHeading (9): [u32 target guid, f32
/// standalone wire heading, TurnToObject only] TurnToParameters /
/// <c>UnPackNet</c> 3-dword form (u32 bitfield, f32 speed, f32
/// desired heading). R4-V3 (closes M7): exposed via
/// <see cref="AcDream.Core.Net.Messages.CreateObject.TurnToPathData"/>
/// — previously dropped end-to-end.</item>
/// </list>
/// </item>
/// </list>
@ -57,10 +70,21 @@ public static class UpdateMotion
/// command is nullable because the <c>ForwardCommand</c> flag may be
/// unset in the InterpretedMotionState; the stance is always present
/// (even if 0, meaning "no specific stance").
///
/// <para>L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are
/// exposed for the retail gate (<see cref="AcDream.Core.Physics"/>
/// <c>MotionSequenceGate</c>) — retail compares them against
/// <c>update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS]</c>
/// and stores <c>last_move_was_autonomous</c>
/// (<c>CPhysics::SetObjectMovement</c> 0x00509690).</para>
/// </summary>
public readonly record struct Parsed(
uint Guid,
CreateObject.ServerMotionState MotionState);
CreateObject.ServerMotionState MotionState,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
/// <summary>
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
@ -82,8 +106,11 @@ public static class UpdateMotion
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
// ObjectInstance sequence (u16) — tracked but not used for pose.
// ObjectInstance sequence (u16) — retail's dispatch-level
// INSTANCE_TS staleness gate compares this against the object's
// known incarnation (DispatchSmartBoxEvent case 0xF74C).
if (body.Length - pos < 2) return null;
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
// MovementData header: u16 movementSequence, u16 serverControlSequence,
@ -102,13 +129,34 @@ public static class UpdateMotion
// Previous version mistakenly reserved 8 bytes here, which shifted
// every subsequent field by 2 and made every remote-char UpdateMotion
// decode as garbage (stance read from the packed-flags dword).
//
// L.2g S1 (DEV-6): the header fields feed retail's
// CPhysics::SetObjectMovement staleness gates + the
// last_move_was_autonomous store, so parse them instead of
// skipping.
if (body.Length - pos < 6) return null;
pos += 6;
ushort movementSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
ushort serverControlSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
bool isAutonomous = body[pos] != 0;
pos += 2; // u8 isAutonomous + Align(4) pad byte
// movementType u8, motionFlags u8, currentStyle u16
if (body.Length - pos < 4) return null;
byte movementType = body[pos]; pos += 1;
byte _motionFlags = body[pos]; pos += 1;
// MotionFlags (ACE ACE.Entity.Enum.MotionFlags, byte):
// 0x1 = StickToObject, 0x2 = StandingLongJump.
// R4-V3 (closes M14-wire-note): previously discarded as
// `_motionFlags`. StickToObject now drives the sticky-guid
// trailer parse below (mt=0 only, per ACE MovementInvalid.Write
// + decomp §2f case 0 @0052455d). StandingLongJump (0x2) is
// carried on ServerMotionState.StandingLongJump (R5-V4) and
// consumed at the GameWindow mt-0 tails — retail's
// `unpack_movement` case 0 writes it onto
// `motion_interpreter->standing_longjump` (§2f @0052458e,
// UNCONDITIONAL — an absent flag clears it).
byte motionFlags = body[pos]; pos += 1;
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
@ -118,7 +166,7 @@ public static class UpdateMotion
var hex = new System.Text.StringBuilder();
for (int i = 0; i < preHex; i++) hex.Append($"{body[i]:X2} ");
System.Console.WriteLine(
$" UM raw: mt=0x{movementType:X2} mf=0x{_motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
$" UM raw: mt=0x{movementType:X2} mf=0x{motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
}
ushort? forwardCommand = null;
@ -131,6 +179,8 @@ public static class UpdateMotion
float? moveToSpeed = null;
float? moveToRunRate = null;
CreateObject.MoveToPathData? moveToPath = null;
CreateObject.TurnToPathData? turnToPath = null;
uint? stickyObjectGuid = null;
List<CreateObject.MotionItem>? commands = null;
if (movementType == 0)
@ -139,7 +189,7 @@ public static class UpdateMotion
// MovementInvalid branch, just reached via the header'd path.
// Includes the Commands list (MotionItem[]) that carries
// Actions, emotes, and other one-shots not in ForwardCommand.
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
uint flags = packed & 0x7Fu;
@ -162,13 +212,13 @@ public static class UpdateMotion
if ((flags & 0x1u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((flags & 0x2u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
@ -223,6 +273,23 @@ public static class UpdateMotion
commands.Add(new CreateObject.MotionItem(cmd, seq, speed));
}
}
// R4-V3 (closes M14-wire-note): the sticky-guid trailer.
// ACE MovementInvalid.Write (Motion/MovementInvalid.cs:41-47)
// writes the InterpretedMotionState FIRST (everything above,
// including the Commands list), THEN — only when
// MotionFlags.StickToObject (0x1) is set — one trailing u32
// guid. Decomp confirms the same order (§2f case 0:
// InterpretedMotionState::UnPack, then
// `if (header & 0x100) sticky_object_guid = read_dword()`).
// Parsed here purely to keep the cursor honest past this
// field; R5's PositionManager::StickTo body is the eventual
// consumer (M14 — "seam now, body R5").
if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4)
{
stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
done:;
}
else if (movementType is 6 or 7)
@ -236,6 +303,14 @@ public static class UpdateMotion
out moveToRunRate,
out moveToPath);
}
else if (movementType is 8 or 9)
{
TryParseTurnToPayload(
body,
pos,
movementType,
out turnToPath);
}
return new Parsed(guid, new CreateObject.ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
@ -244,7 +319,14 @@ public static class UpdateMotion
moveToParameters,
moveToSpeed,
moveToRunRate,
moveToPath));
moveToPath,
turnToPath,
stickyObjectGuid,
// R5-V4: MotionFlags 0x2 — consumed at the GameWindow mt-0
// tails (retail unpack_movement @0052458e writes it onto
// minterp->standing_longjump unconditionally).
(motionFlags & 0x2) != 0),
instanceSequence, movementSequence, serverControlSequence, isAutonomous);
}
catch
{
@ -326,6 +408,73 @@ public static class UpdateMotion
minDistance,
failDistance,
walkRunThreshold,
desiredHeading,
movementParameters ?? 0u); // R4-V4: the raw flags dword -> FromWire
return true;
}
/// <summary>
/// R4-V3 (closes M7) — parses movementType 8 (TurnToObject) / 9
/// (TurnToHeading), byte-for-byte per V0-pins.md P6 (built + confirmed
/// against ACE's own writers: <c>TurnToObjectExtensions.Write</c>,
/// <c>TurnToParametersExtensions.Write</c>,
/// <c>TurnToHeadingExtensions.Write</c> —
/// <c>references/ACE/Source/ACE.Server/Network/Motion/</c>):
/// <list type="bullet">
/// <item>type 8 (TurnToObject) only: u32 <c>object guid</c>, f32
/// <c>wire_heading</c> (the STANDALONE heading field — ACE
/// <c>TurnToObject.DesiredHeading</c>, "used instead of the
/// DesiredHeading in the TurnToParameters" per its own field
/// comment).</item>
/// <item>TurnToParameters / <c>MovementParameters::UnPackNet</c>
/// 3-dword TurnTo form (0xc bytes, decomp §2g), present for BOTH
/// types: u32 <c>bitfield</c>, f32 <c>speed</c>, f32
/// <c>desired_heading</c>.</item>
/// </list>
/// Matches retail's read order exactly (decomp §2f case 8:
/// <c>object_id = read_dword(); wire_heading = read_dword();
/// UnPackNet(...)</c>). The unresolvable-object fallback (substitute
/// <c>wire_heading</c> into <c>params.desired_heading</c>, degrade to
/// TurnToHeading) is a CONSUMER decision (needs a live object-id
/// resolution the wire parser has no visibility into) — this method
/// only exposes both heading fields distinctly so that fallback can be
/// implemented at the call site (V4/V5, GameWindow routing per
/// r4-port-plan.md §4).
/// </summary>
private static bool TryParseTurnToPayload(
ReadOnlySpan<byte> body,
int pos,
byte movementType,
out CreateObject.TurnToPathData? path)
{
path = null;
uint? targetGuid = null;
float? wireHeading = null;
if (movementType == 8)
{
if (body.Length - pos < 8) return false;
targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
wireHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// TurnToParameters / UnPackNet 3-dword TurnTo form (0xc bytes):
// bitfield, speed, desired_heading.
if (body.Length - pos < 12) return false;
uint bitfield = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
path = new CreateObject.TurnToPathData(
targetGuid,
wireHeading,
bitfield,
speed,
desiredHeading);
return true;
}