acdream/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs
Erik 78e163a41e feat(L.2b): outbound movement wire parity — RawMotionState default-difference, JumpPack, contact byte
Ports the three retail outbound-movement packers verbatim (decomp-derived
golden bytes, confirmed via the Ghidra bridge, cross-checked vs holtburger):

- D1 — RawMotionState::Pack (0x0051ed10): new AcDream.Core.Physics.RawMotionState
  data type (11 fields + actions, retail defaults) + RawMotionStatePacker that
  sets a flag bit only when the field DIFFERS from its default. MoveToState.Build
  now takes a RawMotionState instead of presence-based nullable params, so the
  over-sent forwardSpeed=1.0 / currentHoldKey=None / default per-axis holdkeys are
  no longer emitted. num_actions packs into bits 11-15 (not "bits 11-31").
- D3 — MoveToStatePack::Pack (0x005168f0) trailing byte =
  (standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0); explicit contact/
  standingLongjump params (standingLongjump=false honestly until the feature lands).
- D4 — JumpAction rewritten to retail JumpPack::Pack (0x00516d10): extent,
  velocity, full Position, four u16 timestamps, align. Removed the spurious
  objectGuid/spellId u32s; Position is now packed (it was absent). Body 56 bytes.
- Position::Pack (0x005a9640) / Frame::Pack (0x00535130) verified already-correct
  (cellId, origin xyz, quaternion wxyz); locked with a golden test, no change.

GameWindow callers adapted minimally: build the RawMotionState from the existing
MovementResult values (behavior preserved except the intended D1 omissions) and
pass cellId/position/rotation to the Jump send. Pre-existing MotionInterpreter
placeholder struct RawMotionState renamed LegacyRawMotionState (D6/Phase-2 scope,
pure rename) to free the name for the retail-faithful type.

D5 audit: confirmed a real divergence — retail SendMovementEvent (0x006b4680)
stamps only last_sent_position_time after an MTS while SendPositionEvent
(0x006b4770) stamps all three; acdream's NotePositionSent stamps all three on
both paths. Left unchanged (comments added at both call sites), recorded as
register TS-33, deferred to a dedicated cadence-port slice.

Tests: RawMotionStatePackTests / MoveToStateGoldenTests / JumpActionTests /
PositionPackTests + updated MoveToStateTests / AutonomousPositionTests. Full
suite green (Core.Net.Tests 372, full solution 3228 passed / 4 pre-existing skips).

Register: TS-24/TS-25 refreshed (packer now supports actions/style; runtime
emission still deferred), TS-33 added. Roadmap L.2b shipped note added.
Spec: docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md (2-6)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:30:01 +02:00

211 lines
7.3 KiB
C#

using System;
using System.Buffers.Binary;
using System.Numerics;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public class AutonomousPositionTests
{
[Fact]
public void Build_ProducesValidGameAction()
{
var body = AutonomousPosition.Build(
gameActionSequence: 5,
cellId: 0xA9B40001u,
position: new Vector3(100f, 100f, 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(5u, seq);
uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8));
Assert.Equal(0xF753u, actionType);
}
[Fact]
public void Build_ContainsCellIdAfterHeader()
{
var body = AutonomousPosition.Build(
gameActionSequence: 1,
cellId: 0xDEADBEEFu,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// After the 12-byte GameAction header, the WorldPosition starts
// with u32 cell_id.
uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12));
Assert.Equal(0xDEADBEEFu, cellId);
}
[Fact]
public void Build_ContainsPosition_AfterCellId()
{
var body = AutonomousPosition.Build(
gameActionSequence: 2,
cellId: 0xA9B40001u,
position: new Vector3(12.5f, 34.0f, 56.75f),
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// WorldPosition: cellId (4) + x (4) + y (4) + z (4) at offsets 12-27
float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16));
float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20));
float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24));
Assert.Equal(12.5f, x);
Assert.Equal(34.0f, y);
Assert.Equal(56.75f, z);
}
[Fact]
public void Build_IsAlignedTo4Bytes()
{
var body = AutonomousPosition.Build(
gameActionSequence: 3,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
Assert.Equal(0, body.Length % 4);
}
[Fact]
public void Build_TotalLengthIsCorrect_NoCommandsNoExtraFields()
{
// 12 (envelope) + 32 (WorldPosition) + 8 (4x u16 sequences) + 1 (contact) + 3 (align) = 56
var body = AutonomousPosition.Build(
gameActionSequence: 4,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
Assert.Equal(56, body.Length);
}
[Fact]
public void Build_UsesExplicitAirborneContactByte()
{
var body = AutonomousPosition.Build(
gameActionSequence: 7,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0,
lastContact: 0);
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()
{
var body = AutonomousPosition.Build(
gameActionSequence: 6,
cellId: 0xA9B40001u,
position: Vector3.Zero,
rotation: Quaternion.Identity, // W=1, X=Y=Z=0
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
// Rotation starts at offset 28: rotW(4), rotX(4), rotY(4), rotZ(4)
float rotW = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(28));
float rotX = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(32));
float rotY = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(36));
float rotZ = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(40));
Assert.Equal(1.0f, rotW);
Assert.Equal(0.0f, rotX);
Assert.Equal(0.0f, rotY);
Assert.Equal(0.0f, rotZ);
}
}