From d5b0765ea81186f240ef65f0d1b2cac1f7f6644e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 01:49:15 +0200 Subject: [PATCH] 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 --- .../Messages/AceWireWriter.cs | 130 ++++++++++++++++ .../Messages/PlayScriptGoldenTests.cs | 142 ++++++++++++++++++ .../Messages/VectorUpdateGoldenTests.cs | 101 +++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/PlayScriptGoldenTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs diff --git a/tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs b/tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs new file mode 100644 index 00000000..0b08c637 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/AceWireWriter.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// 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. +/// +/// +/// Every primitive below is a line-for-line port of +/// ACE/Source/ACE.Server/Network/Extensions.cs (read 2026-07-29 from +/// the ACE checkout). Cited line numbers are that file's: +/// +/// +/// CalculatePadMultiple — Extensions.cs:10 +/// WriteString16L — Extensions.cs:12-21 +/// WritePackedDword — Extensions.cs:23-34 +/// Pad — Extensions.cs:51 +/// Align — Extensions.cs:55-58 +/// WriteGuid — Extensions.cs:121 (writes guid.Full, a u32) +/// +/// +/// +/// ACE's underlying is little-endian for +/// the fixed-width overloads, which is what Write(uint), +/// Write(ushort) and Write(float) reproduce here. +/// +/// +internal sealed class AceWireWriter +{ + private readonly List _buffer = new(); + + public int Length => _buffer.Count; + + /// ACE Extensions.cs:10. + private static uint CalculatePadMultiple(uint length, uint multiple) + => multiple * ((length + multiple - 1u) / multiple) - length; + + /// BinaryWriter.Write(uint) — little-endian. + 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; + } + + /// BinaryWriter.Write(ushort) — little-endian. + public AceWireWriter Write(ushort value) + { + _buffer.Add((byte)(value & 0xFF)); + _buffer.Add((byte)((value >> 8) & 0xFF)); + return this; + } + + /// BinaryWriter.Write(float) — little-endian IEEE-754. + public AceWireWriter Write(float value) + => Write((uint)BitConverter.SingleToInt32Bits(value)); + + /// + /// ACE Extensions.cs:121WriteGuid writes the full 32-bit + /// guid with no packing. + /// + public AceWireWriter WriteGuid(uint guid) => Write(guid); + + /// + /// ACE Extensions.cs:12-21. 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". + /// + 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)); + } + + /// ACE Extensions.cs:23-34. + public AceWireWriter WritePackedDword(uint value) + { + if (value <= 32767) + return Write((ushort)value); + + uint packed = (value << 16) | ((value >> 16) | 0x8000); + return Write(packed); + } + + /// ACE Extensions.cs:51. + public AceWireWriter Pad(uint pad) + { + for (uint i = 0; i < pad; i++) + _buffer.Add(0); + return this; + } + + /// ACE Extensions.cs:55-58 — pad the stream to a 4-byte boundary. + public AceWireWriter Align() => Pad(CalculatePadMultiple((uint)_buffer.Count, 4u)); + + public byte[] ToArray() => _buffer.ToArray(); + + /// + /// Start a top-level GameMessage body: the 4-byte opcode, exactly as + /// ACE.Server.Network.GameMessages.GameMessage's constructor writes + /// it before the per-message payload. + /// + public static AceWireWriter GameMessage(uint opcode) + => new AceWireWriter().Write(opcode); + + /// + /// Start a GameEvent body (top-level opcode 0xF7B0), mirroring + /// ACE/Source/ACE.Server/Network/GameEvent/GameEventMessage.cs:21-25: + /// WriteGuid(guid), Write(session.GameEventSequence++), + /// Write((uint)EventType). + /// + public static AceWireWriter GameEvent(uint guid, uint eventSequence, uint eventType) + => new AceWireWriter() + .Write(0xF7B0u) + .WriteGuid(guid) + .Write(eventSequence) + .Write(eventType); +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/PlayScriptGoldenTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PlayScriptGoldenTests.cs new file mode 100644 index 00000000..64a36ed3 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/PlayScriptGoldenTests.cs @@ -0,0 +1,142 @@ +using System; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Golden-byte conformance for the two inbound script-playback GameMessages: +/// PlayScriptId (0xF754) and PlayEffect (0xF755). +/// +/// Oracle derivation. Bytes are generated by +/// (a faithful mirror of ACE's +/// Extensions.cs writers). +/// +/// For 0xF755, ACE's +/// GameMessages/Messages/GameMessageScript.cs writes: +/// +/// Writer.WriteGuid(guid); // u32 +/// Writer.Write((uint)scriptId); // u32 +/// Writer.Write(speed); // f32, default 1.0f +/// +/// declaring a 16-byte message (4 opcode + 12 payload), which matches +/// . +/// +/// For 0xF754, ACE names the opcode PlayScriptId +/// (GameMessageOpcode.cs:63) and the retail handler is +/// SmartBox::HandlePlayScriptID (0x00452020) — guid + script DID, no +/// intensity, giving the 12-byte body +/// asserts. +/// +public class PlayScriptGoldenTests +{ + // ---- 0xF754 PlayScriptId ------------------------------------------------- + + public static TheoryData 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 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)); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs b/tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs new file mode 100644 index 00000000..6164690b --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/VectorUpdateGoldenTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Numerics; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Golden-byte conformance for inbound VectorUpdate (0xF74E). +/// +/// Oracle derivation. Bytes are generated by +/// , a faithful mirror of ACE's +/// Extensions.cs writers, driven in the exact order that +/// ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageVectorUpdate.cs +/// writes them: +/// +/// 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 +/// +/// ACE declares the message length as 36 bytes (4 opcode + 32 payload), +/// which matches 's 4 + 32 guard. +/// Cross-checked against holtburger's client-side reader for the same +/// opcode (crates/holtburger-protocol/src/messages/). +/// +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 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))); + } +}