acdream/tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs
Erik d5b0765ea8 test(net): generate wire fixtures from ACE's own writer, not hand-typed hex
The golden-byte tests we had proved that a parser agreed with whoever typed
the hex literal. That is a weaker claim than it looks: if the author misread
the oracle, the test cements the misreading. This adds AceWireWriter, a
line-for-line mirror of ACE's Extensions.cs writers, so a fixture is produced
by the same algorithm the authoritative server uses. Each primitive cites the
ACE line it ports, including the string16L padding rule whose comment in ACE
reads "client expects string length to be a multiple of 4 including the 2
bytes for length".

On top of that harness, two inbound families get field-exact coverage they
had none of. VectorUpdate (0xF74E) is driven in GameMessageVectorUpdate.cs's
write order and pinned at ACE's declared 36-byte length, with cases for the
remote-jump +Z velocity, planar velocity plus yaw omega, rest, and
all-negative components so a sign or field-order slip cannot pass. The two
script-playback messages follow GameMessageScript.cs: PlayScriptId (0xF754)
as guid plus script DID, and PlayEffect (0xF755) as guid, type, and a free
intensity float. The NaN case documents the parser's deliberate choice to
retain non-finite intensities for the resolver to reject rather than coercing
them at parse time, which is behavior worth locking down.

Core.Net tests go 600 to 617, all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:49:15 +02:00

101 lines
3.8 KiB
C#

using System;
using System.Numerics;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Golden-byte conformance for inbound <c>VectorUpdate (0xF74E)</c>.
///
/// <para><b>Oracle derivation.</b> Bytes are generated by
/// <see cref="AceWireWriter"/>, a faithful mirror of ACE's
/// <c>Extensions.cs</c> writers, driven in the exact order that
/// <c>ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageVectorUpdate.cs</c>
/// writes them:</para>
/// <code>
/// Writer.WriteGuid(worldObject.Guid); // u32
/// Writer.Write(velocity); // 3 x f32
/// Writer.Write(omega); // 3 x f32
/// Writer.Write(... ObjectInstance); // u16
/// Writer.Write(... ObjectVector); // u16
/// </code>
/// <para>ACE declares the message length as 36 bytes (4 opcode + 32 payload),
/// which matches <see cref="VectorUpdate"/>'s <c>4 + 32</c> guard.</para>
/// <para>Cross-checked against holtburger's client-side reader for the same
/// opcode (<c>crates/holtburger-protocol/src/messages/</c>).</para>
/// </summary>
public class VectorUpdateGoldenTests
{
private static byte[] Golden(
uint guid,
Vector3 velocity,
Vector3 omega,
ushort instanceSequence,
ushort vectorSequence)
=> AceWireWriter.GameMessage(VectorUpdate.Opcode)
.WriteGuid(guid)
.Write(velocity.X).Write(velocity.Y).Write(velocity.Z)
.Write(omega.X).Write(omega.Y).Write(omega.Z)
.Write(instanceSequence)
.Write(vectorSequence)
.ToArray();
public static TheoryData<string, uint, Vector3, Vector3, ushort, ushort> Cases() => new()
{
// A remote player jumping: +Z velocity, no spin.
{ "jump", 0x50000001u, new Vector3(0f, 0f, 6.1f), Vector3.Zero, 355, 42 },
// Running with a heading change: planar velocity plus yaw omega.
{ "run+turn", 0x7C95B01Au, new Vector3(2.94f, -1.25f, 0f), new Vector3(0f, 0f, 1.5f), 1, 2 },
// Rest state — every field zero except the sequences.
{ "at-rest", 0x800114C0u, Vector3.Zero, Vector3.Zero, 0, 0 },
// Negative components on every axis, to pin sign handling.
{ "negatives", 0xA9B40001u, new Vector3(-1f, -2f, -3f), new Vector3(-4f, -5f, -6f), 65535, 65534 },
};
[Theory]
[MemberData(nameof(Cases))]
public void TryParse_AceGoldenBytes_DecodesEveryFieldExactly(
string label,
uint guid,
Vector3 velocity,
Vector3 omega,
ushort instanceSequence,
ushort vectorSequence)
{
byte[] body = Golden(guid, velocity, omega, instanceSequence, vectorSequence);
// ACE's GameMessageVectorUpdate declares length 36 (opcode + payload).
Assert.Equal(36, body.Length);
VectorUpdate.Parsed? parsed = VectorUpdate.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(guid, parsed!.Value.Guid);
Assert.Equal(velocity, parsed.Value.Velocity);
Assert.Equal(omega, parsed.Value.Omega);
Assert.Equal(instanceSequence, parsed.Value.InstanceSequence);
Assert.Equal(vectorSequence, parsed.Value.VectorSequence);
Assert.False(string.IsNullOrEmpty(label));
}
[Fact]
public void TryParse_WrongOpcode_ReturnsNull()
{
byte[] body = AceWireWriter.GameMessage(0xF74Cu)
.WriteGuid(1u)
.Write(0f).Write(0f).Write(0f)
.Write(0f).Write(0f).Write(0f)
.Write((ushort)0).Write((ushort)0)
.ToArray();
Assert.Null(VectorUpdate.TryParse(body));
}
[Fact]
public void TryParse_TruncatedByOneByte_ReturnsNull()
{
byte[] body = Golden(1u, Vector3.One, Vector3.One, 1, 1);
Assert.Null(VectorUpdate.TryParse(body.AsSpan(0, body.Length - 1)));
}
}