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>
170 lines
6.6 KiB
C#
170 lines
6.6 KiB
C#
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);
|
|
}
|
|
}
|