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