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>
This commit is contained in:
parent
b70b9832ff
commit
d5b0765ea8
3 changed files with 373 additions and 0 deletions
130
tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs
Normal file
130
tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only faithful mirror of ACE's server-side wire writer, so golden-byte
|
||||
/// conformance tests generate their fixtures the same way the authoritative
|
||||
/// server generates them. Hand-rolled hex would only prove that our parser
|
||||
/// agrees with whoever typed the hex; generating from the oracle's own
|
||||
/// algorithm proves it agrees with the server.
|
||||
///
|
||||
/// <para>
|
||||
/// Every primitive below is a line-for-line port of
|
||||
/// <c>ACE/Source/ACE.Server/Network/Extensions.cs</c> (read 2026-07-29 from
|
||||
/// the ACE checkout). Cited line numbers are that file's:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CalculatePadMultiple</c> — Extensions.cs:10</item>
|
||||
/// <item><c>WriteString16L</c> — Extensions.cs:12-21</item>
|
||||
/// <item><c>WritePackedDword</c> — Extensions.cs:23-34</item>
|
||||
/// <item><c>Pad</c> — Extensions.cs:51</item>
|
||||
/// <item><c>Align</c> — Extensions.cs:55-58</item>
|
||||
/// <item><c>WriteGuid</c> — Extensions.cs:121 (writes <c>guid.Full</c>, a u32)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// ACE's underlying <see cref="System.IO.BinaryWriter"/> is little-endian for
|
||||
/// the fixed-width overloads, which is what <c>Write(uint)</c>,
|
||||
/// <c>Write(ushort)</c> and <c>Write(float)</c> reproduce here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class AceWireWriter
|
||||
{
|
||||
private readonly List<byte> _buffer = new();
|
||||
|
||||
public int Length => _buffer.Count;
|
||||
|
||||
/// <summary>ACE <c>Extensions.cs:10</c>.</summary>
|
||||
private static uint CalculatePadMultiple(uint length, uint multiple)
|
||||
=> multiple * ((length + multiple - 1u) / multiple) - length;
|
||||
|
||||
/// <summary>BinaryWriter.Write(uint) — little-endian.</summary>
|
||||
public AceWireWriter Write(uint value)
|
||||
{
|
||||
_buffer.Add((byte)(value & 0xFF));
|
||||
_buffer.Add((byte)((value >> 8) & 0xFF));
|
||||
_buffer.Add((byte)((value >> 16) & 0xFF));
|
||||
_buffer.Add((byte)((value >> 24) & 0xFF));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>BinaryWriter.Write(ushort) — little-endian.</summary>
|
||||
public AceWireWriter Write(ushort value)
|
||||
{
|
||||
_buffer.Add((byte)(value & 0xFF));
|
||||
_buffer.Add((byte)((value >> 8) & 0xFF));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>BinaryWriter.Write(float) — little-endian IEEE-754.</summary>
|
||||
public AceWireWriter Write(float value)
|
||||
=> Write((uint)BitConverter.SingleToInt32Bits(value));
|
||||
|
||||
/// <summary>
|
||||
/// ACE <c>Extensions.cs:121</c> — <c>WriteGuid</c> writes the full 32-bit
|
||||
/// guid with no packing.
|
||||
/// </summary>
|
||||
public AceWireWriter WriteGuid(uint guid) => Write(guid);
|
||||
|
||||
/// <summary>
|
||||
/// ACE <c>Extensions.cs:12-21</c>. Writes a u16 length, the CP1252 bytes,
|
||||
/// then pads so that (2 + length) is a multiple of 4 — the comment in ACE
|
||||
/// reads "client expects string length to be a multiple of 4 including the
|
||||
/// 2 bytes for length".
|
||||
/// </summary>
|
||||
public AceWireWriter WriteString16L(string? data)
|
||||
{
|
||||
data ??= "";
|
||||
byte[] bytes = Encoding.GetEncoding(1252).GetBytes(data);
|
||||
Write((ushort)data.Length);
|
||||
_buffer.AddRange(bytes);
|
||||
return Pad(CalculatePadMultiple(sizeof(ushort) + (uint)data.Length, 4u));
|
||||
}
|
||||
|
||||
/// <summary>ACE <c>Extensions.cs:23-34</c>.</summary>
|
||||
public AceWireWriter WritePackedDword(uint value)
|
||||
{
|
||||
if (value <= 32767)
|
||||
return Write((ushort)value);
|
||||
|
||||
uint packed = (value << 16) | ((value >> 16) | 0x8000);
|
||||
return Write(packed);
|
||||
}
|
||||
|
||||
/// <summary>ACE <c>Extensions.cs:51</c>.</summary>
|
||||
public AceWireWriter Pad(uint pad)
|
||||
{
|
||||
for (uint i = 0; i < pad; i++)
|
||||
_buffer.Add(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>ACE <c>Extensions.cs:55-58</c> — pad the stream to a 4-byte boundary.</summary>
|
||||
public AceWireWriter Align() => Pad(CalculatePadMultiple((uint)_buffer.Count, 4u));
|
||||
|
||||
public byte[] ToArray() => _buffer.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Start a top-level GameMessage body: the 4-byte opcode, exactly as
|
||||
/// <c>ACE.Server.Network.GameMessages.GameMessage</c>'s constructor writes
|
||||
/// it before the per-message payload.
|
||||
/// </summary>
|
||||
public static AceWireWriter GameMessage(uint opcode)
|
||||
=> new AceWireWriter().Write(opcode);
|
||||
|
||||
/// <summary>
|
||||
/// Start a GameEvent body (top-level opcode <c>0xF7B0</c>), mirroring
|
||||
/// <c>ACE/Source/ACE.Server/Network/GameEvent/GameEventMessage.cs:21-25</c>:
|
||||
/// <c>WriteGuid(guid)</c>, <c>Write(session.GameEventSequence++)</c>,
|
||||
/// <c>Write((uint)EventType)</c>.
|
||||
/// </summary>
|
||||
public static AceWireWriter GameEvent(uint guid, uint eventSequence, uint eventType)
|
||||
=> new AceWireWriter()
|
||||
.Write(0xF7B0u)
|
||||
.WriteGuid(guid)
|
||||
.Write(eventSequence)
|
||||
.Write(eventType);
|
||||
}
|
||||
142
tests/AcDream.Core.Net.Tests/Messages/PlayScriptGoldenTests.cs
Normal file
142
tests/AcDream.Core.Net.Tests/Messages/PlayScriptGoldenTests.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-byte conformance for the two inbound script-playback GameMessages:
|
||||
/// <c>PlayScriptId (0xF754)</c> and <c>PlayEffect (0xF755)</c>.
|
||||
///
|
||||
/// <para><b>Oracle derivation.</b> Bytes are generated by
|
||||
/// <see cref="AceWireWriter"/> (a faithful mirror of ACE's
|
||||
/// <c>Extensions.cs</c> writers).</para>
|
||||
///
|
||||
/// <para>For <c>0xF755</c>, ACE's
|
||||
/// <c>GameMessages/Messages/GameMessageScript.cs</c> writes:</para>
|
||||
/// <code>
|
||||
/// Writer.WriteGuid(guid); // u32
|
||||
/// Writer.Write((uint)scriptId); // u32
|
||||
/// Writer.Write(speed); // f32, default 1.0f
|
||||
/// </code>
|
||||
/// <para>declaring a 16-byte message (4 opcode + 12 payload), which matches
|
||||
/// <see cref="PlayPhysicsScriptType.WireSize"/>.</para>
|
||||
///
|
||||
/// <para>For <c>0xF754</c>, ACE names the opcode <c>PlayScriptId</c>
|
||||
/// (<c>GameMessageOpcode.cs:63</c>) and the retail handler is
|
||||
/// <c>SmartBox::HandlePlayScriptID</c> (0x00452020) — guid + script DID, no
|
||||
/// intensity, giving the 12-byte body <see cref="PlayPhysicsScript.WireSize"/>
|
||||
/// asserts.</para>
|
||||
/// </summary>
|
||||
public class PlayScriptGoldenTests
|
||||
{
|
||||
// ---- 0xF754 PlayScriptId -------------------------------------------------
|
||||
|
||||
public static TheoryData<uint, uint> ScriptIdCases() => new()
|
||||
{
|
||||
{ 0x50000001u, 0x0D000001u }, // player, a portal-space script DID
|
||||
{ 0x7C95B01Au, 0x00000000u }, // zero DID must survive as zero, not null
|
||||
{ 0xFFFFFFFFu, 0xFFFFFFFFu }, // full-width guid + DID
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ScriptIdCases))]
|
||||
public void PlayScriptId_AceGoldenBytes_DecodesGuidAndDid(uint guid, uint scriptDid)
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(PlayPhysicsScript.Opcode)
|
||||
.WriteGuid(guid)
|
||||
.Write(scriptDid)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(PlayPhysicsScript.WireSize, body.Length);
|
||||
|
||||
PlayPhysicsScript? parsed = PlayPhysicsScript.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(guid, parsed!.Value.Guid);
|
||||
Assert.Equal(scriptDid, parsed.Value.ScriptDid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayScriptId_WrongOpcode_ReturnsNull()
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(0xF755u)
|
||||
.WriteGuid(1u).Write(2u).ToArray();
|
||||
|
||||
Assert.Null(PlayPhysicsScript.TryParse(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayScriptId_TrailingByte_ReturnsNull()
|
||||
{
|
||||
// The parser demands an exact length; a 13-byte body is not a
|
||||
// truncated-but-usable 0xF754.
|
||||
byte[] body = AceWireWriter.GameMessage(PlayPhysicsScript.Opcode)
|
||||
.WriteGuid(1u).Write(2u).Pad(1).ToArray();
|
||||
|
||||
Assert.Null(PlayPhysicsScript.TryParse(body));
|
||||
}
|
||||
|
||||
// ---- 0xF755 PlayEffect ---------------------------------------------------
|
||||
|
||||
public static TheoryData<uint, uint, float> ScriptTypeCases() => new()
|
||||
{
|
||||
// ACE's default speed argument is 1.0f.
|
||||
{ 0x50000001u, 0x00000021u, 1.0f },
|
||||
// Intensity is a free float on the wire; fractional values must survive.
|
||||
{ 0x7C95B01Au, 0x00000083u, 0.25f },
|
||||
// Zero intensity is meaningful (script suppressed), not "absent".
|
||||
{ 0x800114C0u, 0x00000001u, 0f },
|
||||
// Unknown type values are retained losslessly for the resolver to reject.
|
||||
{ 0xA9B40001u, 0xDEADBEEFu, -3.5f },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ScriptTypeCases))]
|
||||
public void PlayEffect_AceGoldenBytes_DecodesGuidTypeAndIntensity(
|
||||
uint guid, uint rawScriptType, float intensity)
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(PlayPhysicsScriptType.Opcode)
|
||||
.WriteGuid(guid)
|
||||
.Write(rawScriptType)
|
||||
.Write(intensity)
|
||||
.ToArray();
|
||||
|
||||
// ACE declares GameMessageScript's length as 16.
|
||||
Assert.Equal(PlayPhysicsScriptType.WireSize, body.Length);
|
||||
Assert.Equal(16, body.Length);
|
||||
|
||||
PlayPhysicsScriptType? parsed = PlayPhysicsScriptType.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(guid, parsed!.Value.Guid);
|
||||
Assert.Equal(rawScriptType, parsed.Value.RawScriptType);
|
||||
Assert.Equal(intensity, parsed.Value.Intensity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayEffect_NonFiniteIntensity_IsRetainedLosslessly()
|
||||
{
|
||||
// The parser documents that non-finite floats are kept for the
|
||||
// resolver to reject rather than being coerced at parse time.
|
||||
byte[] body = AceWireWriter.GameMessage(PlayPhysicsScriptType.Opcode)
|
||||
.WriteGuid(0x50000001u)
|
||||
.Write(0x00000021u)
|
||||
.Write(float.NaN)
|
||||
.ToArray();
|
||||
|
||||
PlayPhysicsScriptType? parsed = PlayPhysicsScriptType.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.True(float.IsNaN(parsed!.Value.Intensity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayEffect_WrongOpcode_ReturnsNull()
|
||||
{
|
||||
byte[] body = AceWireWriter.GameMessage(0xF754u)
|
||||
.WriteGuid(1u).Write(2u).Write(1.0f).ToArray();
|
||||
|
||||
Assert.Null(PlayPhysicsScriptType.TryParse(body));
|
||||
}
|
||||
}
|
||||
101
tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs
Normal file
101
tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue