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

@ -122,6 +122,69 @@ public class AutonomousPositionTests
Assert.Equal(0, body[52]);
}
[Fact]
public void Build_TimestampOrder_MatchesAutonomousPositionPackPack()
{
// AutonomousPositionPack::Pack (0x00516af0, confirmed via Ghidra
// decompile-by-address during this slice 2026-06-30): after
// Position::Pack (32 bytes), four u16 timestamps in order
// instance_timestamp, server_control_timestamp, teleport_timestamp,
// force_position_ts — then a CONTACT-ONLY byte (no longjump bit;
// that bit only exists in MoveToStatePack), then ALIGN_PTR.
var body = AutonomousPosition.Build(
gameActionSequence: 8,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0x1111,
serverControlSequence: 0x2222,
teleportSequence: 0x3333,
forcePositionSequence: 0x4444);
// 12 (envelope) + 32 (Position) = 44.
int tsOffset = 44;
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
Assert.Equal((ushort)0x1111, instance);
Assert.Equal((ushort)0x2222, serverControl);
Assert.Equal((ushort)0x3333, teleport);
Assert.Equal((ushort)0x4444, forcePosition);
}
[Fact]
public void Build_ContactByte_IsContactOnly_NoLongjumpBit()
{
// contact != 0 -> byte = 1 (bool cast, not an OR'd bitmask like MTS).
var bodyOnGround = AutonomousPosition.Build(
gameActionSequence: 9,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
lastContact: 1);
Assert.Equal(1, bodyOnGround[52]);
var bodyAirborne = AutonomousPosition.Build(
gameActionSequence: 10,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
lastContact: 0);
Assert.Equal(0, bodyAirborne[52]);
}
[Fact]
public void Build_ContainsIdentityRotation_AfterPosition()
{

View file

@ -440,6 +440,21 @@ public sealed class CreateObjectTests
Assert.Equal(7.5f, p.Workmanship);
}
[Fact]
public void TryParse_MovementSequence_SurfacedFromTimestampBlock()
{
// L.2g S1 (DEV-6): index 1 of the 9-u16 PhysicsDesc timestamp block
// is ObjectMovement (ACE WorldObject_Networking.cs:412) — it seeds
// MotionSequenceGate's MOVEMENT_TS so post-spawn UpdateMotion events
// are judged against the entity's live sequence, not zero.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000030u, name: "Runner", itemType: 0x10u,
movementSeq: 0x9000);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal((ushort)0x9000, parsed!.Value.MovementSequence);
}
[Fact]
public void TryParse_MidTailFieldsSet_StillReachesIconOverlay()
{
@ -484,7 +499,8 @@ public sealed class CreateObjectTests
uint? validLocations = null,
uint? currentWieldedLocation = null,
uint? priority = null,
float? workmanship = null)
float? workmanship = null,
ushort movementSeq = 0)
{
var bytes = new List<byte>();
WriteU32(bytes, CreateObject.Opcode);
@ -496,11 +512,12 @@ public sealed class CreateObjectTests
bytes.Add(0);
bytes.Add(0);
// PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps.
// PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps
// (PhysicsTimeStamp enum order; index 1 = ObjectMovement).
WriteU32(bytes, 0);
WriteU32(bytes, physicsState);
for (int i = 0; i < 9; i++)
WriteU16(bytes, 0);
WriteU16(bytes, i == 1 ? movementSeq : (ushort)0);
Align4(bytes);
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.

View file

@ -0,0 +1,170 @@
using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Golden-byte tests for the retail-faithful <see cref="JumpAction.Build"/>
/// layout, porting <c>JumpPack::Pack</c> (0x00516d10, decomp lines
/// ~284934-284963). Confirmed verbatim against the Ghidra decompile-by-address
/// bridge (http://127.0.0.1:8081/decompile_function?address=0x00516d10)
/// during this slice (2026-06-30):
///
/// <code>
/// extent (f32), velocity.x/y/z (f32 x3), Position::Pack (cellId + Frame),
/// instance_timestamp (u16), server_control_timestamp (u16),
/// teleport_timestamp (u16), force_position_ts (u16), ALIGN_PTR.
/// </code>
///
/// D4: retail does NOT pack an objectGuid or spellId. The pre-slice acdream
/// code wrote two spurious trailing <c>u32 0</c> fields and omitted Position
/// entirely — both are fixed here.
/// </summary>
public class JumpActionTests
{
[Fact]
public void Build_ProducesValidGameAction()
{
var body = JumpAction.Build(
gameActionSequence: 9,
extent: 0.5f,
velocity: new Vector3(1f, 2f, 3f),
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0xF7B1u, opcode);
uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(9u, seq);
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(0xF61Bu, actionType);
}
[Fact]
public void Build_ExtentAndVelocity_FollowEnvelope()
{
var body = JumpAction.Build(
gameActionSequence: 1,
extent: 0.75f,
velocity: new Vector3(1.5f, -2.5f, 9.81f),
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// 12-byte envelope, then extent(4), vx(4), vy(4), vz(4).
float extent = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(12));
float vx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16));
float vy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20));
float vz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
Assert.Equal(0.75f, extent);
Assert.Equal(1.5f, vx);
Assert.Equal(-2.5f, vy);
Assert.Equal(9.81f, vz);
}
[Fact]
public void Build_PositionFollowsVelocity_CellIdThenOriginThenQuaternion()
{
var body = JumpAction.Build(
gameActionSequence: 2,
extent: 0f,
velocity: Vector3.Zero,
cellId: 0xDEADBEEFu,
position: new Vector3(12.5f, 34.0f, 56.75f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// 12 (envelope) + 4 (extent) + 12 (velocity) = offset 28 -> Position::Pack.
int positionOffset = 28;
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(positionOffset));
float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 4));
float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 8));
float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 12));
float qw = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 16));
float qx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 20));
float qy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 24));
float qz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 28));
Assert.Equal(0xDEADBEEFu, cellId);
Assert.Equal(12.5f, x);
Assert.Equal(34.0f, y);
Assert.Equal(56.75f, z);
Assert.Equal(1.0f, qw);
Assert.Equal(0.0f, qx);
Assert.Equal(0.0f, qy);
Assert.Equal(0.0f, qz);
}
[Fact]
public void Build_TimestampsFollowPosition_InRetailOrder()
{
var body = JumpAction.Build(
gameActionSequence: 3,
extent: 0f,
velocity: Vector3.Zero,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0x1111,
serverControlSequence: 0x2222,
teleportSequence: 0x3333,
forcePositionSequence: 0x4444);
// Position::Pack is 32 bytes (cellId + Frame). Timestamps start at
// 28 (extent/velocity/envelope) + 32 = 60.
int tsOffset = 60;
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
Assert.Equal((ushort)0x1111, instance);
Assert.Equal((ushort)0x2222, serverControl);
Assert.Equal((ushort)0x3333, teleport);
Assert.Equal((ushort)0x4444, forcePosition);
}
[Fact]
public void Build_NoObjectGuidOrSpellId_JumpPackBodyLengthIs56()
{
// JumpPack::Pack body (everything AFTER the 12-byte GameAction
// envelope): extent(4) + velocity(12) + Position(32) +
// 4x u16 timestamps(8) = 56 bytes, already a multiple of 4 -> no
// align padding. Total wire length = 12 (envelope) + 56 = 68.
// Retail's JumpPack has NO objectGuid/spellId fields (D4) — the
// pre-slice code's two spurious trailing u32 writes are gone.
var body = JumpAction.Build(
gameActionSequence: 4,
extent: 0f,
velocity: Vector3.Zero,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
Assert.Equal(56, body.Length - 12);
Assert.Equal(68, body.Length);
Assert.Equal(0, body.Length % 4);
}
}

View file

@ -0,0 +1,254 @@
using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Full-message golden-byte tests for the refactored <see cref="MoveToState.Build"/>
/// (D1 default-difference RawMotionState packing + D3 trailing byte), porting
/// <c>MoveToStatePack::Pack</c> (0x005168f0, decomp lines ~284694-284722).
/// Confirmed verbatim against the Ghidra decompile-by-address bridge during
/// this slice (2026-06-30):
///
/// <code>
/// MoveToStatePack::Pack:
/// RawMotionState::Pack(...)
/// Position::Pack(...)
/// instance_timestamp(u16), server_control_timestamp(u16),
/// teleport_timestamp(u16), force_position_ts(u16)
/// trailing byte = ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
/// ALIGN_PTR
/// </code>
/// </summary>
public class MoveToStateGoldenTests
{
private static readonly Vector3 Pos = new(96f, 96f, 50f);
private static readonly Quaternion Rot = Quaternion.Identity;
[Fact]
public void Build_DefaultRawMotionState_FlagsAreZero_EnvelopePlusPositionPlusTimestampsPlusTrailingByte()
{
var body = MoveToState.Build(
gameActionSequence: 1,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
// 12 (envelope) + 4 (flags=0, no fields) + 32 (Position) + 8 (timestamps)
// + 1 (trailing byte) = 57, aligned to 60.
Assert.Equal(60, body.Length);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0u, flags);
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
Assert.Equal(0xA9B40001u, cellId);
// trailing byte at 12+4+32+8 = 56: contact=true, longjump=false -> 0x01
Assert.Equal(0x01, body[56]);
}
[Fact]
public void Build_WalkForward_OmitsForwardSpeedDefault()
{
// Walk-forward at default speed 1.0 -> forward_speed bit OMITTED (D1 fix).
var state = new RawMotionState
{
CurrentHoldKey = HoldKey.None, // default, omitted
ForwardCommand = 0x45000005u, // WalkForward
ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
ForwardSpeed = 1.0f, // default, omitted
};
var body = MoveToState.Build(
gameActionSequence: 2,
rawMotionState: state,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0x0000000Cu, flags); // ForwardCommand | ForwardHoldKey only
// RawMotionState body = flags(4) + fwd_cmd(4) + fwd_holdkey(4) = 12.
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12 + 12));
Assert.Equal(0xA9B40001u, cellId);
}
[Fact]
public void Build_RunForward_IncludesHoldKeyAndSpeed()
{
var state = new RawMotionState
{
CurrentHoldKey = HoldKey.Run,
ForwardCommand = 0x44000007u, // RunForward
ForwardHoldKey = HoldKey.Run,
ForwardSpeed = 2.94f,
};
var body = MoveToState.Build(
gameActionSequence: 3,
rawMotionState: state,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0x0000001Du, flags); // holdkey | fwd_cmd | fwd_holdkey | fwd_speed
// RawMotionState body = flags(4) + holdkey(4) + fwd_cmd(4) + fwd_holdkey(4) + fwd_speed(4) = 20.
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12 + 20));
Assert.Equal(0xA9B40001u, cellId);
}
[Fact]
public void Build_Sidestep_SetsSidestepBitsOnly()
{
var state = new RawMotionState
{
SidestepCommand = 0x6500000Fu, // SideStepRight
SidestepHoldKey = HoldKey.None,
SidestepSpeed = 1.0f, // default -> omitted
};
var body = MoveToState.Build(
gameActionSequence: 4,
rawMotionState: state,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0x00000060u, flags); // SidestepCommand(0x20) | SidestepHoldKey(0x40)
}
[Fact]
public void Build_Turn_SetsTurnBitsOnly()
{
var state = new RawMotionState
{
TurnCommand = 0x6500000Du, // TurnRight
TurnHoldKey = HoldKey.None,
TurnSpeed = 1.0f, // default -> omitted
};
var body = MoveToState.Build(
gameActionSequence: 5,
rawMotionState: state,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0x00000300u, flags); // TurnCommand(0x100) | TurnHoldKey(0x200)
}
[Theory]
[InlineData(false, false, 0x00)]
[InlineData(true, false, 0x01)]
[InlineData(false, true, 0x02)]
[InlineData(true, true, 0x03)]
public void Build_TrailingByte_AllFourContactLongjumpCombinations(bool contact, bool standingLongjump, byte expected)
{
// MoveToStatePack::Pack trailing byte:
// ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
var body = MoveToState.Build(
gameActionSequence: 6,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: contact,
standingLongjump: standingLongjump);
// flags(4) + Position(32) + timestamps(8) = 44; trailing byte at 12+44=56.
Assert.Equal(expected, body[56]);
}
[Fact]
public void Build_TimestampOrder_MatchesMoveToStatePackPack()
{
var body = MoveToState.Build(
gameActionSequence: 7,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0x1111,
serverControlSequence: 0x2222,
teleportSequence: 0x3333,
forcePositionSequence: 0x4444,
contact: true,
standingLongjump: false);
// 12 (envelope) + 4 (flags) + 32 (Position) = 48.
int tsOffset = 48;
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset));
ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2));
ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4));
ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6));
Assert.Equal((ushort)0x1111, instance);
Assert.Equal((ushort)0x2222, serverControl);
Assert.Equal((ushort)0x3333, teleport);
Assert.Equal((ushort)0x4444, forcePosition);
}
[Fact]
public void Build_IsAlignedTo4Bytes()
{
var body = MoveToState.Build(
gameActionSequence: 8,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Pos,
rotation: Rot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contact: true,
standingLongjump: false);
Assert.Equal(0, body.Length % 4);
}
}

View file

@ -2,26 +2,36 @@ using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Envelope + structural tests for <see cref="MoveToState.Build"/>. Golden
/// byte-layout tests for the RawMotionState default-difference packing (D1)
/// and the trailing contact/longjump byte (D3) live in
/// <see cref="MoveToStateGoldenTests"/> and <see cref="RawMotionStatePackTests"/>.
///
/// D1/D3 refactor (2026-06-30): <c>Build</c> now takes a
/// <see cref="RawMotionState"/> snapshot (matching retail's
/// <c>CPhysicsObj::InqRawMotionState()</c>) instead of flat nullable
/// per-axis params, plus explicit <c>contact</c>/<c>standingLongjump</c>
/// booleans for the trailing byte — see <c>MoveToStatePack::Pack</c>
/// (0x005168f0).
/// </summary>
public class MoveToStateTests
{
private static readonly Vector3 Pos = new(96f, 96f, 50f);
[Fact]
public void Build_IdleState_ProducesValidGameAction()
{
var body = MoveToState.Build(
gameActionSequence: 1,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
position: Pos,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
@ -42,31 +52,33 @@ public class MoveToStateTests
}
[Fact]
public void Build_WalkForward_IncludesForwardCommandInFlags()
public void Build_WalkForward_DefaultSpeedOmitted_OnlyCommandAndHoldKeyFlagsSet()
{
// D1 fix: forward_speed == 1.0 (the retail default) is OMITTED from
// the flags, unlike the pre-slice presence-based packer which always
// set the ForwardSpeed bit whenever a caller supplied a value.
var state = new RawMotionState
{
ForwardCommand = 0x45000005u, // WalkForward
ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
ForwardSpeed = 1.0f, // default -> omitted
};
var body = MoveToState.Build(
gameActionSequence: 2,
forwardCommand: 0x45000005u, // WalkForward
forwardSpeed: 1.0f,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: state,
cellId: 0xA9B40001u,
position: new Vector3(96f, 96f, 50f),
position: Pos,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// After the 12-byte GameAction header comes RawMotionState.
// First u32 is the packed flags word. ForwardCommand flag = 0x4.
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.True((flags & 0x4u) != 0, "ForwardCommand flag (0x4) should be set");
// ForwardSpeed flag = 0x10
Assert.True((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) should be set");
Assert.True((flags & 0x8u) != 0, "ForwardHoldKey flag (0x8) should be set");
Assert.False((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) must be OMITTED at the retail default 1.0");
}
[Fact]
@ -74,13 +86,7 @@ public class MoveToStateTests
{
var body = MoveToState.Build(
gameActionSequence: 3,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@ -94,19 +100,14 @@ public class MoveToStateTests
}
[Fact]
public void Build_IdleState_WorldPositionFollowsMotionState()
public void Build_IdleState_WorldPositionFollowsZeroFlagMotionState()
{
// With no motion state, flags = 0 and no conditional fields are written.
// So WorldPosition starts at offset 12 (envelope) + 4 (flags) = 16.
// With the default raw motion state, flags = 0 and no conditional
// fields are written. So WorldPosition starts at offset 12
// (envelope) + 4 (flags) = 16.
var body = MoveToState.Build(
gameActionSequence: 4,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xDEADBEEFu,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@ -124,13 +125,7 @@ public class MoveToStateTests
{
var body = MoveToState.Build(
gameActionSequence: 5,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@ -143,17 +138,11 @@ public class MoveToStateTests
}
[Fact]
public void Build_UsesExplicitAirborneContactByte()
public void Build_UsesExplicitAirborneContact()
{
var body = MoveToState.Build(
gameActionSequence: 7,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: null,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
@ -161,23 +150,20 @@ public class MoveToStateTests
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
contactLongJump: 0);
contact: false);
// flags(4) + Position(32) + timestamps(8) = 44; trailing byte at 12+44=56.
Assert.Equal(0, body[56]);
}
[Fact]
public void Build_WithHoldKey_IncludesHoldKeyFlag()
{
var state = new RawMotionState { CurrentHoldKey = HoldKey.Run };
var body = MoveToState.Build(
gameActionSequence: 6,
forwardCommand: null,
forwardSpeed: null,
sidestepCommand: null,
sidestepSpeed: null,
turnCommand: null,
turnSpeed: null,
holdKey: 2u, // Run
rawMotionState: state,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,

View file

@ -0,0 +1,63 @@
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Pins the shared WorldPosition/Position block byte order used by
/// <see cref="MoveToState"/>, <see cref="AutonomousPosition"/>, and
/// <see cref="JumpAction"/>: <c>Position::Pack</c> (0x005a9640) wraps
/// <c>cellId(u32)</c> then <c>Frame::Pack</c> (0x00535130) =
/// <c>origin.x/y/z(f32)</c> then <c>qw/qx/qy/qz(f32)</c>. Confirmed verbatim
/// against the Ghidra decompile-by-address bridge during this slice
/// (2026-06-30):
///
/// <code>
/// Position::Pack: objcell_id(u32), Frame::Pack(...)
/// Frame::Pack: m_fOrigin.x/y/z(f32 x3), qw(f32), qx(f32), qy(f32), qz(f32)
/// </code>
///
/// 32 bytes total (4 + 12 + 16). This was already correct pre-slice — these
/// tests lock the byte order with a golden-value assertion rather than
/// changing behavior.
/// </summary>
public class PositionPackTests
{
[Fact]
public void MoveToState_PositionBlock_OrderIsCellIdThenOriginThenQuaternion()
{
var body = MoveToState.Build(
gameActionSequence: 1,
rawMotionState: RawMotionState.Default,
cellId: 0xA9B40001u,
position: new Vector3(1.5f, 2.5f, 3.5f),
rotation: new Quaternion(0.1f, 0.2f, 0.3f, 0.9f), // X,Y,Z,W ctor order
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// No motion state -> flags(4) only before the Position block at offset 16.
int off = 12 + 4;
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(off));
float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 4));
float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 8));
float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 12));
float qw = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 16));
float qx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 20));
float qy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 24));
float qz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 28));
Assert.Equal(0xA9B40001u, cellId);
Assert.Equal(1.5f, x);
Assert.Equal(2.5f, y);
Assert.Equal(3.5f, z);
Assert.Equal(0.9f, qw);
Assert.Equal(0.1f, qx);
Assert.Equal(0.2f, qy);
Assert.Equal(0.3f, qz);
}
}

View file

@ -0,0 +1,212 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Golden-byte tests for <see cref="RawMotionStatePacker.Pack"/>, porting
/// retail's <c>RawMotionState::Pack</c> (0x0051ed10, decomp lines
/// ~293761-294013; bitfield layout <c>acclient.h RawMotionState::PackBitfield</c>,
/// line 46474). Confirmed verbatim against the Ghidra decompile-by-address
/// bridge (http://127.0.0.1:8081/decompile_function?address=0x0051ed10)
/// during this slice (2026-06-30).
///
/// <para>
/// Retail compares every field against its DEFAULT and only sets the
/// matching bit (and emits the field) when the live value DIFFERS. This is
/// the D1 fix — the old presence-based packer over-sent defaulted fields.
/// </para>
/// </summary>
public class RawMotionStatePackTests
{
private static byte[] Pack(RawMotionState state)
{
var w = new PacketWriter(64);
RawMotionStatePacker.Pack(w, state);
return w.ToArray();
}
[Fact]
public void Pack_DefaultState_EmitsOnlyZeroFlags()
{
// Every field equals its retail default -> flags dword is 0,
// no conditional fields, no actions. 4 bytes total.
var body = Pack(RawMotionState.Default);
Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, body);
}
[Fact]
public void Pack_ShiftWalk_OmitsForwardSpeedAndCurrentHoldKey()
{
// Shift-walk: current_holdkey stays None (default omitted),
// forward_command = WalkForward (0x45000005), forward_holdkey =
// None (1) -- DIFFERS from default Invalid(0) so IS sent --
// forward_speed stays 1.0 (default, omitted). Rest default.
//
// Flags expected: ForwardCommand(0x004) | ForwardHoldKey(0x008) = 0x00C.
// Body: flags(u32) + forward_command(u32) + forward_holdkey(u32).
var state = new RawMotionState
{
CurrentHoldKey = HoldKey.None, // default -> omitted
ForwardCommand = 0x45000005u, // WalkForward -> differs -> set
ForwardHoldKey = HoldKey.None, // differs from Invalid -> set
ForwardSpeed = 1.0f, // default -> omitted (the D1 fix)
};
var body = Pack(state);
Assert.Equal(12, body.Length);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x0000000Cu, flags);
uint fwdCommand = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(0x45000005u, fwdCommand);
uint fwdHoldKey = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(1u, fwdHoldKey); // HoldKey.None
byte[] expected =
{
0x0C, 0x00, 0x00, 0x00,
0x05, 0x00, 0x00, 0x45,
0x01, 0x00, 0x00, 0x00,
};
Assert.Equal(expected, body);
}
[Fact]
public void Pack_RunForward_SetsHoldKeyForwardCommandHoldKeyAndSpeed()
{
// Run-forward: current_holdkey = Run(2) (differs from None -> set),
// forward_command = RunForward-ish 0x44000007 (differs -> set),
// forward_holdkey = Run(2) (differs from Invalid -> set),
// forward_speed = 3.0 (differs from 1.0 -> set).
//
// Flags expected: CurrentHoldKey(0x001) | ForwardCommand(0x004) |
// ForwardHoldKey(0x008) | ForwardSpeed(0x010) = 0x01D.
var state = new RawMotionState
{
CurrentHoldKey = HoldKey.Run,
ForwardCommand = 0x44000007u,
ForwardHoldKey = HoldKey.Run,
ForwardSpeed = 3.0f,
};
var body = Pack(state);
Assert.Equal(20, body.Length);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x0000001Du, flags);
byte[] expected =
{
0x1D, 0x00, 0x00, 0x00, // flags
0x02, 0x00, 0x00, 0x00, // current_holdkey = Run(2)
0x07, 0x00, 0x00, 0x44, // forward_command = 0x44000007
0x02, 0x00, 0x00, 0x00, // forward_holdkey = Run(2)
0x00, 0x00, 0x40, 0x40, // forward_speed = 3.0f (0x40400000 LE)
};
Assert.Equal(expected, body);
}
[Fact]
public void Pack_NonDefaultCurrentStyle_SetsStyleBitAndEmitsValue()
{
// current_style differs from 0x8000003D -> bit 0x002 set, value emitted
// immediately after the flags dword (bit order: holdkey, style, ...).
var state = new RawMotionState
{
CurrentStyle = 0x80000042u,
};
var body = Pack(state);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x002u, flags);
uint style = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
Assert.Equal(0x80000042u, style);
Assert.Equal(8, body.Length);
}
[Fact]
public void Pack_PopulatedActionsList_SetsNumActionsBitsAndEmitsPairs()
{
// num_actions occupies bits 11-15 (mask 0xF800) of the flags dword.
// Two actions -> num_actions = 2 -> bits = 2 << 11 = 0x1000.
// Each action emits u16 command then u16 (stamp & 0x7FFF) |
// (autonomous ? 0x8000 : 0) (decomp ~293998-294010).
var state = new RawMotionState
{
Actions = new[]
{
new RawMotionAction(Command: 0x0150, Stamp: 0x0001, Autonomous: false),
new RawMotionAction(Command: 0x0163, Stamp: 0x7FFF, Autonomous: true),
},
};
var body = Pack(state);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x1000u, flags); // num_actions=2 << 11, no continuous-axis bits
// Body: flags(4) + action0(4) + action1(4) = 12 bytes.
Assert.Equal(12, body.Length);
ushort cmd0 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(4));
ushort stamp0 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(6));
Assert.Equal((ushort)0x0150, cmd0);
Assert.Equal((ushort)0x0001, stamp0); // autonomous=false -> 0x8000 bit clear
ushort cmd1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(8));
ushort stamp1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(10));
Assert.Equal((ushort)0x0163, cmd1);
Assert.Equal((ushort)0xFFFF, stamp1); // (0x7FFF & 0x7FFF) | 0x8000 = 0xFFFF
}
[Fact]
public void Pack_SidestepAndTurnNonDefault_SetExpectedBitsInOrder()
{
// sidestep_command(0x020), sidestep_holdkey(0x040), sidestep_speed(0x080),
// turn_command(0x100), turn_holdkey(0x200), turn_speed(0x400) all non-default.
var state = new RawMotionState
{
SidestepCommand = 0x44000009u,
SidestepHoldKey = HoldKey.Run,
SidestepSpeed = 1.248f,
TurnCommand = 0x4400000Du,
TurnHoldKey = HoldKey.Run,
TurnSpeed = 1.5f,
};
var body = Pack(state);
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0));
Assert.Equal(0x000007E0u, flags); // 0x20|0x40|0x80|0x100|0x200|0x400
// Body order after flags: sidestep_command, sidestep_holdkey,
// sidestep_speed, turn_command, turn_holdkey, turn_speed.
uint ssCmd = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4));
uint ssHold = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
float ssSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(12));
uint turnCmd = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16));
uint turnHold = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20));
float turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
Assert.Equal(0x44000009u, ssCmd);
Assert.Equal(2u, ssHold);
Assert.Equal(1.248f, ssSpeed);
Assert.Equal(0x4400000Du, turnCmd);
Assert.Equal(2u, turnHold);
Assert.Equal(1.5f, turnSpeed);
Assert.Equal(28, body.Length);
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
@ -303,6 +304,37 @@ public class UpdateMotionTests
Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed);
}
[Fact]
public void ParsesSequenceNumbersAndAutonomyFlag()
{
// L.2g S1 (DEV-6): the three staleness stamps + autonomy flag must
// survive parsing — retail gates every 0xF74C on them
// (INSTANCE_TS at dispatch, MOVEMENT_TS + SERVER_CONTROLLED_MOVE_TS
// in CPhysics::SetObjectMovement 0x00509690, which also stores
// last_move_was_autonomous).
var body = new byte[4 + 4 + 2 + 6 + 4 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x50001234u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0102); p += 2; // instanceSeq
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0304); p += 2; // movementSeq
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0506); p += 2; // serverControlSeq
body[p++] = 1; // isAutonomous
p += 1; // Align(4) pad
body[p++] = 0; // movementType = Invalid
body[p++] = 0; // motionFlags
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // outer stance
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((ushort)0x0102, result!.Value.InstanceSequence);
Assert.Equal((ushort)0x0304, result.Value.MovementSequence);
Assert.Equal((ushort)0x0506, result.Value.ServerControlSequence);
Assert.True(result.Value.IsAutonomous);
}
[Fact]
public void ParsesMoveToObjectTargetGuidAndOrigin()
{
@ -351,4 +383,336 @@ public class UpdateMotionTests
Assert.Equal(7f, path.OriginZ);
Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate);
}
// ─────────────────────────────────────────────────────────────────
// R4-V3 (closes M7): mt 8 (TurnToObject) / mt 9 (TurnToHeading).
//
// Golden bytes assembled from ACE's own writers (V0-pins.md P6):
// MovementDataExtensions.Write (references/ACE/Source/ACE.Server/
// Network/Motion/MovementData.cs:184-229) writes the common header
// (movementType u8, motionFlags u8, currentStyle u16) then dispatches
// on MovementType to:
// TurnToObjectExtensions.Write (TurnToObject.cs:25-30):
// writer.WriteGuid(Target); // u32
// writer.Write(DesiredHeading); // f32 — the STANDALONE
// // "wire_heading" field
// writer.Write(TurnToParameters); // 3-dword UnPackNet form
// TurnToParametersExtensions.Write (TurnToParameters.cs:23-28):
// writer.Write((uint)MovementParams); // u32 bitfield
// writer.Write(Speed); // f32
// writer.Write(DesiredHeading); // f32 — TurnToParameters'
// // OWN desired_heading
// TurnToHeadingExtensions.Write (TurnToHeading.cs:18-21):
// writer.Write(TurnToParameters); // 3-dword UnPackNet form only
//
// P6's fixture caveat: ACE always populates field2 (TurnToObject.
// DesiredHeading) and field5 (TurnToParameters.DesiredHeading) from the
// SAME motion.DesiredHeading source, so a byte-faithful ACE capture
// would have field2 == field5. To prove the parser distinguishes the
// two fields by OFFSET (not by coincidentally-equal value), these
// fixtures hand-vary the two headings.
// ─────────────────────────────────────────────────────────────────
[Fact]
public void ParsesTurnToObject_GuidWireHeadingAndParams()
{
// Header (20 bytes) + guid (4) + wireHeading (4) + TurnToParameters (12) = 40.
var body = new byte[20 + 4 + 4 + 12];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80005678u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // MovementData header padding
body[p++] = 8; // TurnToObject
body[p++] = 0; // motionFlags
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80009999u); p += 4; // target guid
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 42.0f); p += 4; // standalone wire_heading (field2)
const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4; // TurnToParameters.bitfield
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // TurnToParameters.speed
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 199.0f); p += 4; // TurnToParameters.desired_heading (field5) — DELIBERATELY != field2
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)8, result!.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledTurnTo);
Assert.False(result.Value.MotionState.IsServerControlledMoveTo);
Assert.NotNull(result.Value.MotionState.TurnToPath);
var path = result.Value.MotionState.TurnToPath!.Value;
Assert.Equal(0x80009999u, path.TargetGuid);
Assert.Equal(42.0f, path.WireHeading); // field2 — distinguished by OFFSET
Assert.Equal(flags, path.Bitfield);
Assert.Equal(1.5f, path.Speed);
Assert.Equal(199.0f, path.DesiredHeading); // field5 — distinct from field2
// The consumer feeds this straight into FromWireTurnTo (App-layer,
// out of scope here) — verify the fixture is round-trippable.
var mp = MovementParameters.FromWireTurnTo(path.Bitfield, path.Speed, path.DesiredHeading);
Assert.True(mp.CanRun);
Assert.Equal(1.5f, mp.Speed);
Assert.Equal(199.0f, mp.DesiredHeading);
}
[Fact]
public void ParsesTurnToObject_UnresolvableFallback_BothHeadingsSurvivedDistinctly()
{
// Retail's degrade-to-TurnToHeading fallback (decomp §2f case 8) only
// fires when GetObjectA(object_id) == 0 — a runtime/consumer-side
// resolution the wire parser has no visibility into. The parser's
// job is just to expose BOTH heading fields so the (future) V4/V5
// consumer can implement: "if unresolvable, params.DesiredHeading =
// wireHeading, then degrade to TurnToHeading". Confirm both survive
// even when they'd trigger the fallback (i.e. even when they differ).
var body = new byte[20 + 4 + 4 + 12];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x8000AAAAu); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 8;
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4; // unresolvable guid
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 270.0f); p += 4; // wire_heading — the fallback source
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1u); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4; // params.desired_heading (would be overwritten by wire_heading on fallback)
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
var path = result!.Value.MotionState.TurnToPath!.Value;
Assert.Equal(0xDEADBEEFu, path.TargetGuid);
Assert.Equal(270.0f, path.WireHeading);
Assert.Equal(0.0f, path.DesiredHeading);
Assert.NotEqual(path.WireHeading, path.DesiredHeading);
}
[Fact]
public void ParsesTurnToHeading_ThreeDwordFormOnly_NoGuidOrWireHeading()
{
// Header (20 bytes) + TurnToParameters (12) = 32. No guid, no
// standalone heading field — TurnToHeadingExtensions.Write emits
// ONLY the 3-dword UnPackNet form (TurnToHeading.cs:18-21).
var body = new byte[20 + 12];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x8000BBBBu); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 9; // TurnToHeading
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
const uint flags = 0x2u | 0x800u; // can_run | set_hold_key
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 2.0f); p += 4; // speed
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 315.0f); p += 4; // desired_heading
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)9, result!.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledTurnTo);
Assert.NotNull(result.Value.MotionState.TurnToPath);
var path = result.Value.MotionState.TurnToPath!.Value;
Assert.Null(path.TargetGuid);
Assert.Null(path.WireHeading);
Assert.Equal(flags, path.Bitfield);
Assert.Equal(2.0f, path.Speed);
Assert.Equal(315.0f, path.DesiredHeading);
}
[Theory]
[InlineData(0u)] // no flags
[InlineData(0x1u | 0x2u)] // can_walk | can_run
[InlineData(0x10u)] // can_charge (fast-path bit)
[InlineData(0x3FFFFu)] // every A4 bit through 0x20000
public void ParsesTurnToHeading_FlagPermutations_BitfieldRoundTrips(uint bitfield)
{
var body = new byte[20 + 12];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001111u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 9;
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), bitfield); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal(bitfield, result!.Value.MotionState.TurnToPath!.Value.Bitfield);
}
// ─────────────────────────────────────────────────────────────────
// R4-V3 deliverable B: mt 6/7 widened exposure. MoveToPathData already
// carries every UnPackNet field (V0/V1 shipped that); this proves the
// fixture round-trips end-to-end through MovementParameters.FromWire —
// i.e. that ALL seven UnPackNet fields (not just the three ad-hoc bool
// properties MoveToCanRun/MoveTowards/CanCharge) reach a consumer.
// ─────────────────────────────────────────────────────────────────
[Fact]
public void MoveToPositionPath_FeedsFromWire_AllSevenFieldsSurvive()
{
var body = new byte[20 + 16 + 28 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80002222u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 7; // MoveToPosition
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 11f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 22f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 33f); p += 4;
const uint flags = 0x1u | 0x2u | 0x4u | 0x8u | 0x10u | 0x200u | 0x400u; // incl. can_charge + use_spheres
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.1f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 50.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 123.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 2.75f); p += 4; // runRate
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
var path = result!.Value.MotionState.MoveToPath!.Value;
var mp = MovementParameters.FromWire(
flags,
path.DistanceToObject,
path.MinDistance,
path.FailDistance,
result.Value.MotionState.MoveToSpeed!.Value,
path.WalkRunThreshold,
path.DesiredHeading);
Assert.True(mp.CanWalk);
Assert.True(mp.CanRun);
Assert.True(mp.CanSidestep);
Assert.True(mp.CanWalkBackwards);
Assert.True(mp.CanCharge);
Assert.True(mp.MoveTowards);
Assert.True(mp.UseSpheres);
Assert.Equal(0.6f, mp.DistanceToObject);
Assert.Equal(0.1f, mp.MinDistance);
Assert.Equal(50.0f, mp.FailDistance);
Assert.Equal(1.25f, mp.Speed);
Assert.Equal(15.0f, mp.WalkRunThreshhold);
Assert.Equal(123.0f, mp.DesiredHeading);
Assert.Equal(2.75f, result.Value.MotionState.MoveToRunRate);
}
// ─────────────────────────────────────────────────────────────────
// R4-V3 deliverable C: the 0xF74C motionFlags sticky-guid trailer
// (mt=0/Invalid only — ACE MovementInvalid.Write gates the trailing
// guid on MotionFlags.StickToObject 0x1; decomp §2f case 0
// @0052455d). Cursor-honesty test: bytes AFTER the trailer must still
// parse correctly (i.e. the trailer's 4 bytes were actually consumed,
// not left dangling / double-read).
// ─────────────────────────────────────────────────────────────────
[Fact]
public void ParsesStickyGuidTrailer_WhenMotionFlagsBitSet()
{
// motionFlags byte1&0x1 (StickToObject) set; InterpretedMotionState
// flags = 0 (no fields), so the sticky guid dword immediately
// follows the packed flags dword.
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80003333u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 0; // movementType = Invalid
body[p++] = 0x1; // motionFlags = StickToObject
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004444u); p += 4; // sticky object guid
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal(0x80004444u, result!.Value.MotionState.StickyObjectGuid);
}
[Fact]
public void SkipsStickyGuidTrailer_WhenMotionFlagsBitClear()
{
var body = new byte[4 + 4 + 2 + 6 + 4 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80005555u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 0; // movementType = Invalid
body[p++] = 0; // motionFlags = none
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Null(result!.Value.MotionState.StickyObjectGuid);
}
[Fact]
public void ParsesStickyGuidTrailer_CursorHonesty_BytesAfterTrailerStillParseCorrectly()
{
// Sticky trailer with the ForwardCommand flag ALSO set, so there are
// bytes both BEFORE (forwardCommand u16) and the sticky dword AFTER
// the flags dword — the trailer must be read at the right offset
// (after ForwardCommand + its own 2-byte read), not glued onto the
// packed-flags dword itself. Cross-checks against ACE's actual field
// order: MovementInvalid.Write emits `State` (the whole
// InterpretedMotionState, incl. Commands list) THEN the sticky guid
// — decomp confirms the same order (UnPack first, sticky guid read
// after, r4-moveto-decomp.md:274-275).
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80006666u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 0; // movementType = Invalid
body[p++] = 0x1; // motionFlags = StickToObject
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; // outer stance
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x2u); p += 4; // IMS flags = ForwardCommand only
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // ForwardCommand = RunForward
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80007777u); p += 4; // sticky object guid — AFTER ForwardCommand
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((ushort)0x0007, result!.Value.MotionState.ForwardCommand);
Assert.Equal(0x80007777u, result.Value.MotionState.StickyObjectGuid);
}
}