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>
212 lines
8.1 KiB
C#
212 lines
8.1 KiB
C#
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);
|
|
}
|
|
}
|