merge: Campaign LA LA7a — character wire messages (review-closed)
CharacterDelete 0xF655 (account+slot), CharacterRestore 0xF7D9/0xF643 (guid-only adaptation, register AD-97), CharacterError 0xF659 (retail 26-member enum). Opus retail-lens review PASS, narrow re-review MERGE:6a32f375+4338b1c1+0c8643a7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
fa2de1c46e
7 changed files with 877 additions and 0 deletions
|
|
@ -0,0 +1,83 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class CharacterDeleteTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildRequestBody_Layout_OpcodeThenAccountThenSlot()
|
||||
{
|
||||
byte[] body = CharacterDelete.BuildRequestBody("testaccount", characterSlot: 3);
|
||||
|
||||
int pos = 0;
|
||||
Assert.Equal(CharacterDelete.Opcode,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos))); pos += 4;
|
||||
|
||||
// String16L("testaccount") = u16(11) + 11 ASCII bytes, padded to a
|
||||
// 4-byte boundary counted from the length prefix: 2 + 11 = 13 -> 16
|
||||
// (3 pad bytes).
|
||||
ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(pos));
|
||||
Assert.Equal(11, len); pos += 2;
|
||||
string name = System.Text.Encoding.ASCII.GetString(body.AsSpan(pos, 11));
|
||||
Assert.Equal("testaccount", name); pos += 11;
|
||||
Assert.Equal(0, body[pos++]);
|
||||
Assert.Equal(0, body[pos++]);
|
||||
Assert.Equal(0, body[pos++]);
|
||||
|
||||
uint slot = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos)); pos += 4;
|
||||
Assert.Equal(3u, slot);
|
||||
|
||||
Assert.Equal(4 + 16 + 4, body.Length); // opcode + padded string + slot
|
||||
Assert.Equal(pos, body.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRequestBody_ExactByteSequence_ShortAccount()
|
||||
{
|
||||
// "ab" -> String16L = u16(2) + 2 bytes = 4, already 4-byte aligned,
|
||||
// no padding.
|
||||
byte[] body = CharacterDelete.BuildRequestBody("ab", characterSlot: 0x11u);
|
||||
|
||||
byte[] expected =
|
||||
[
|
||||
0x55, 0xF6, 0x00, 0x00, // opcode 0xF655 LE
|
||||
0x02, 0x00, // String16L length = 2
|
||||
(byte)'a', (byte)'b', // string bytes
|
||||
0x11, 0x00, 0x00, 0x00, // characterSlot = 0x11 LE
|
||||
];
|
||||
|
||||
Assert.Equal(expected, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRequestBody_NullAccountName_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => CharacterDelete.BuildRequestBody(null!, characterSlot: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAcknowledgement_AcceptsOpcodeOnlyBody()
|
||||
{
|
||||
byte[] body = BitConverter.GetBytes(CharacterDelete.Opcode);
|
||||
|
||||
Assert.True(CharacterDelete.IsAcknowledgement(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAcknowledgement_RejectsRequestShapedBody()
|
||||
{
|
||||
byte[] request = CharacterDelete.BuildRequestBody("acct", characterSlot: 1);
|
||||
|
||||
Assert.False(CharacterDelete.IsAcknowledgement(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAcknowledgement_RejectsTruncatedOrDifferentOpcode()
|
||||
{
|
||||
Assert.False(CharacterDelete.IsAcknowledgement([0x55, 0xF6, 0x00]));
|
||||
Assert.False(CharacterDelete.IsAcknowledgement(BitConverter.GetBytes(0xF656u)));
|
||||
Assert.False(CharacterDelete.IsAcknowledgement([]));
|
||||
}
|
||||
}
|
||||
113
tests/AcDream.Core.Net.Tests/Messages/CharacterErrorTests.cs
Normal file
113
tests/AcDream.Core.Net.Tests/Messages/CharacterErrorTests.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class CharacterErrorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x00u, CharacterError.Code.Undefined)]
|
||||
[InlineData(0x01u, CharacterError.Code.Logon)]
|
||||
[InlineData(0x02u, CharacterError.Code.LoggedOn)]
|
||||
[InlineData(0x03u, CharacterError.Code.AccountLogon)]
|
||||
[InlineData(0x04u, CharacterError.Code.ServerCrash)]
|
||||
[InlineData(0x05u, CharacterError.Code.Logoff)]
|
||||
[InlineData(0x06u, CharacterError.Code.Delete)]
|
||||
[InlineData(0x07u, CharacterError.Code.NoPremade)]
|
||||
[InlineData(0x08u, CharacterError.Code.AccountInUse)]
|
||||
[InlineData(0x09u, CharacterError.Code.AccountInvalid)]
|
||||
[InlineData(0x0Au, CharacterError.Code.AccountDoesntExist)]
|
||||
[InlineData(0x0Bu, CharacterError.Code.EnterGameGeneric)]
|
||||
[InlineData(0x0Cu, CharacterError.Code.EnterGameStressAccount)]
|
||||
[InlineData(0x0Du, CharacterError.Code.EnterGameCharacterInWorld)]
|
||||
[InlineData(0x0Eu, CharacterError.Code.EnterGamePlayerAccountMissing)]
|
||||
[InlineData(0x0Fu, CharacterError.Code.EnterGameCharacterNotOwned)]
|
||||
[InlineData(0x10u, CharacterError.Code.EnterGameCharacterInWorldServer)]
|
||||
[InlineData(0x11u, CharacterError.Code.EnterGameOldCharacter)]
|
||||
[InlineData(0x12u, CharacterError.Code.EnterGameCorruptCharacter)]
|
||||
[InlineData(0x13u, CharacterError.Code.EnterGameStartServerDown)]
|
||||
[InlineData(0x14u, CharacterError.Code.EnterGameCouldntPlaceCharacter)]
|
||||
[InlineData(0x15u, CharacterError.Code.LogonServerFull)]
|
||||
[InlineData(0x16u, CharacterError.Code.CharacterIsBooted)]
|
||||
[InlineData(0x17u, CharacterError.Code.EnterGameCharacterLocked)]
|
||||
[InlineData(0x18u, CharacterError.Code.SubscriptionExpired)]
|
||||
[InlineData(0x19u, CharacterError.Code.NumErrors)]
|
||||
public void Parse_EveryRetailCode_RoundTripsRawAndNamedValue(uint raw, CharacterError.Code expected)
|
||||
{
|
||||
var w = AceWireWriter.GameMessage(CharacterError.Opcode).Write(raw);
|
||||
|
||||
CharacterError.Parsed parsed = CharacterError.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(raw, parsed.RawErrorCode);
|
||||
Assert.Equal(expected, parsed.AsCode);
|
||||
Assert.Equal((uint)expected, raw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnknownErrorCode_DoesNotThrow_PreservesRawValue()
|
||||
{
|
||||
// A value retail never defined (and well past CHAR_ERROR_NUM_ERRORS)
|
||||
// — a future server revision or a private server could still send
|
||||
// it. Must not throw; the raw wire value is the source of truth.
|
||||
var w = AceWireWriter.GameMessage(CharacterError.Opcode).Write(0xDEADBEEFu);
|
||||
|
||||
CharacterError.Parsed parsed = CharacterError.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(0xDEADBEEFu, parsed.RawErrorCode);
|
||||
Assert.Equal((CharacterError.Code)0xDEADBEEFu, parsed.AsCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MaxUintErrorCode_DoesNotThrow()
|
||||
{
|
||||
var w = AceWireWriter.GameMessage(CharacterError.Opcode).Write(uint.MaxValue);
|
||||
|
||||
CharacterError.Parsed parsed = CharacterError.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(uint.MaxValue, parsed.RawErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ExactByteSequence_MatchesAceSerializer()
|
||||
{
|
||||
// ACE's GameMessageCharacterError: opcode then Writer.Write((uint)error).
|
||||
byte[] body = AceWireWriter.GameMessage(CharacterError.Opcode)
|
||||
.Write((uint)CharacterError.Code.Delete)
|
||||
.ToArray();
|
||||
|
||||
byte[] expected =
|
||||
[
|
||||
0x59, 0xF6, 0x00, 0x00, // opcode 0xF659 LE
|
||||
0x06, 0x00, 0x00, 0x00, // CHAR_ERROR_DELETE = 6 LE
|
||||
];
|
||||
|
||||
Assert.Equal(expected, body);
|
||||
|
||||
CharacterError.Parsed parsed = CharacterError.Parse(body);
|
||||
Assert.Equal(CharacterError.Code.Delete, parsed.AsCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WrongOpcode_Throws()
|
||||
{
|
||||
byte[] bytes = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu);
|
||||
|
||||
Assert.Throws<FormatException>(() => CharacterError.Parse(bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Truncated_Throws()
|
||||
{
|
||||
byte[] bytes = new byte[4]; // just the opcode, missing the error code
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes, CharacterError.Opcode);
|
||||
|
||||
Assert.Throws<FormatException>(() => CharacterError.Parse(bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EmptyBody_Throws()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => CharacterError.Parse([]));
|
||||
}
|
||||
}
|
||||
162
tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs
Normal file
162
tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class CharacterRestoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildRequestBody_ExactByteSequence_OpcodeThenGuidOnly()
|
||||
{
|
||||
byte[] body = CharacterRestore.BuildRequestBody(0x50000001u);
|
||||
|
||||
byte[] expected =
|
||||
[
|
||||
0xD9, 0xF7, 0x00, 0x00, // opcode 0xF7D9 LE
|
||||
0x01, 0x00, 0x00, 0x50, // guid 0x50000001 LE
|
||||
];
|
||||
|
||||
Assert.Equal(expected, body);
|
||||
Assert.Equal(8, body.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SuccessResponse_PopulatesAllTrailingFields()
|
||||
{
|
||||
// Mirrors ACE's GameMessageCharacterRestore: opcode, flag=1 (Ok),
|
||||
// guid, String16L name, secondsGreyedOut.
|
||||
var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode)
|
||||
.Write(1u)
|
||||
.WriteGuid(0x50000002u)
|
||||
.WriteString16L("+Acdream")
|
||||
.Write(0u);
|
||||
|
||||
CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(1u, parsed.VerificationFlag);
|
||||
Assert.True(parsed.IsOk);
|
||||
Assert.Equal(0x50000002u, parsed.Guid);
|
||||
Assert.Equal("+Acdream", parsed.Name);
|
||||
Assert.Equal(0u, parsed.SecondsGreyedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SuccessResponse_NonzeroSecondsGreyedOutPreserved()
|
||||
{
|
||||
var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode)
|
||||
.Write(1u)
|
||||
.WriteGuid(0x50000003u)
|
||||
.WriteString16L("Restored")
|
||||
.Write(45u);
|
||||
|
||||
CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(45u, parsed.SecondsGreyedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_FailureShapedResponse_LeavesTrailingFieldsNull()
|
||||
{
|
||||
// Retail's colliding CharacterCreateResponse shape: a non-Ok flag
|
||||
// (here 3 = NameInUse) has NO trailing guid/name/seconds on the
|
||||
// wire at all — GameMessageCharacterCreateResponse.cs only writes
|
||||
// them "if (response == ... .Ok)". Parse must not try to read past
|
||||
// the flag in this case.
|
||||
var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode)
|
||||
.Write(3u); // CharacterGenerationVerificationResponse.NameInUse
|
||||
|
||||
CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(3u, parsed.VerificationFlag);
|
||||
Assert.False(parsed.IsOk);
|
||||
Assert.Null(parsed.Guid);
|
||||
Assert.Null(parsed.Name);
|
||||
Assert.Null(parsed.SecondsGreyedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UndefFlagZero_FlagOnlyBody_LeavesTrailingFieldsNull()
|
||||
{
|
||||
// LA7a review test-coverage nit: flag 0 (Undef) is a non-Ok value
|
||||
// distinct from the NameInUse case — the conditional must treat it
|
||||
// as flag-only too.
|
||||
var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode)
|
||||
.Write(0u); // CharacterGenerationVerificationResponse.Undef
|
||||
|
||||
CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(0u, parsed.VerificationFlag);
|
||||
Assert.False(parsed.IsOk);
|
||||
Assert.Null(parsed.Guid);
|
||||
Assert.Null(parsed.Name);
|
||||
Assert.Null(parsed.SecondsGreyedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NonOkBodyWithTrailingBytes_IgnoresRatherThanMisreads()
|
||||
{
|
||||
// LA7a review test-coverage nit: a non-Ok body that DOES carry
|
||||
// trailing bytes (unknown server variant / padding) must not be
|
||||
// misread as character fields — the conditional stops at the flag
|
||||
// and the extra bytes are ignored.
|
||||
var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode)
|
||||
.Write(3u) // NameInUse
|
||||
.Write(0xDEADBEEFu)
|
||||
.Write(0x12345678u);
|
||||
|
||||
CharacterRestore.Parsed parsed = CharacterRestore.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(3u, parsed.VerificationFlag);
|
||||
Assert.False(parsed.IsOk);
|
||||
Assert.Null(parsed.Guid);
|
||||
Assert.Null(parsed.Name);
|
||||
Assert.Null(parsed.SecondsGreyedOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WrongOpcode_Throws()
|
||||
{
|
||||
byte[] bytes = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEADBEEFu);
|
||||
|
||||
Assert.Throws<FormatException>(() => CharacterRestore.Parse(bytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TruncatedAfterFlag_Throws()
|
||||
{
|
||||
// Claims success (flag=1) but the body ends before the guid.
|
||||
var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode).Write(1u);
|
||||
|
||||
Assert.Throws<FormatException>(() => CharacterRestore.Parse(w.ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TruncatedBeforeFlag_Throws()
|
||||
{
|
||||
var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode);
|
||||
|
||||
Assert.Throws<FormatException>(() => CharacterRestore.Parse(w.ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestThenResponse_RoundTrips_GuidIdentity()
|
||||
{
|
||||
const uint guid = 0x50000009u;
|
||||
byte[] request = CharacterRestore.BuildRequestBody(guid);
|
||||
|
||||
// The request itself carries only the guid; re-derive it the same
|
||||
// way a caller would to confirm nothing was lost in the builder.
|
||||
uint requestedGuid = BinaryPrimitives.ReadUInt32LittleEndian(request.AsSpan(4));
|
||||
Assert.Equal(guid, requestedGuid);
|
||||
|
||||
var w = AceWireWriter.GameMessage(CharacterRestore.ResponseOpcode)
|
||||
.Write(1u)
|
||||
.WriteGuid(guid)
|
||||
.WriteString16L("RoundTrip")
|
||||
.Write(0u);
|
||||
CharacterRestore.Parsed response = CharacterRestore.Parse(w.ToArray());
|
||||
|
||||
Assert.Equal(requestedGuid, response.Guid);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue