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

142 lines
5 KiB
C#

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