Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196
# Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
217a4bad69
329 changed files with 81439 additions and 8499 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
170
tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs
Normal file
170
tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
254
tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs
Normal file
254
tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
63
tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs
Normal file
63
tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
212
tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs
Normal file
212
tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue