acdream/tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.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

130 lines
4.8 KiB
C#

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);
}