acdream/tests/AcDream.Core.Net.Tests/Messages/CharacterRestoreTests.cs
Erik 4338b1c1f3 fix(net): Campaign LA LA7a review fixes — AD-97 register row, corrected restore justification
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>
2026-08-14 16:01:52 +02:00

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