The Opus retail-lens review decoded the PDB-paired binary at CPlayerSystem::RestoreCharacter@0x0055d760 and refuted the uninitialized-edx justification: the two extra arguments are real push imm32 of a constant PStringBase (BN mis-renders them, but they pack to >=4 bytes each), so retail 0xF7D9 is >=16 bytes where ours is 8. The guid-only CODE stands (ACE reads only the guid; holtburger consensus) but it is an adaptation, not a corrected decompile — filed as divergence register AD-97 and the doc comment now states the true mechanism. Also from the review: the 0xF643 conditional-parse doc now names BOTH ACE flag-only failure branches (NameInUse + Corrupt); CharacterError 0x08 doc corrected (ACE misnames it ServerCrash2 — the port corrects an ACE misnaming; ACE omits three values, not four); LA7b hazard notes added (ACE silent no-reply on unknown restore guid; retail SendToLogon vs SendToControl routing; NumErrors never rendered); two review-nit tests (flag=0 Undef flag-only, non-Ok body with trailing bytes ignored). Core.Net suite: 953 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
5.6 KiB
C#
162 lines
5.6 KiB
C#
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);
|
|
}
|
|
}
|