Ports the three character-management wire messages LA7 (design spec §7, plan §11 item 4) identified as missing before the character-select screen (LA8) can be built: delete, restore, and the server error channel. Message types + tests only — no WorldSession/Runtime/UI wiring, that is LA7b. CharacterDelete (0xF655): outbound account+SLOT-INDEX request per Proto_UI::SendDeleteCharacter@0x00546b30 (retail packs the account as String16L then writes the trailing u32 directly after — NOT the character guid; CPlayerSystem::DeleteCharacter@0x0055f830 resolves that slot via CharacterSet::GetSlot before sending). The server's ack reuses the same opcode with an empty body (ACE GameMessageCharacterDelete.cs); a fresh CharacterList follows separately per CharacterHandler.cs:322 — that refresh flow is explicitly out of scope here (LA7b). CharacterRestore (0xF7D9 request / 0xF643 response): guid-only request, per ACE (CharacterHandler.cs:331-385, ReadUInt32 only) and holtburger (CharacterRestoreRequestData, guid-only) independent consensus. The decompiled call site (Proto_UI::SendAdminRestoreCharacter@0x00546cf0) appears to pack two extra strings, but its only caller (CPlayerSystem::RestoreCharacter@0x0055d760) passes an uninitialized local (`class PStringBase<char>* edx;`, never assigned) as the second argument and `this` (a CPlayerSystem*, not a string) as the third — textbook decompiler register-corruption, not real arguments. No divergence-register row: this follows the correct reading of a corrupted decompile, not a deviation from retail (spec §11 item 4). The response reuses opcode 0xF643, a genuine retail collision with CharacterCreateResponse (ACE's own comment: "This is a duplicate...", GameMessageOpcode.cs:42); GameMessageCharacterRestore.cs always writes a success shape (flag=1 + guid + name + secondsGreyedOut), but retail's CharacterRestore handler can also reply via the CharacterCreateResponse path on failure (e.g. NameInUse) with a flag-only body and no trailing fields — the parser mirrors that conditionality instead of assuming the four fields are always present. CharacterError (0xF659): u32 error code, confirmed directly from retail's inbound dispatcher UIQueueManager::ProcessNetBlobData@0x0055b000 -> CPlayerSystem::Handle_CharacterError@0x0055d5d0, which reads `enum charError` straight off the wire. The Code enum is a verbatim port of retail's own enum charError (docs/research/named-retail/acclient.h: 4038-4067, 26 members incl. CHAR_ERROR_NUM_ERRORS) rather than a subset filtered through ACE — retail's header names four members ACE's C# CharacterError enum omits (LoggedOn, NoPremade, AccountInUse, CharacterIsBooted) because ACE's server never sends them, though a genuine retail server could. The 32-bit storage-width compiler sentinel FORCE_charError_32_BIT is deliberately excluded (not a real value). Unknown codes never throw — RawErrorCode always preserves the wire value. Today acdream cannot surface any character-stage server error; this is the first parser for the family. 46 new tests (byte-exact builder assertions, ACE-serializer-shaped parser fixtures via the existing AceWireWriter test helper, all 26 retail error codes round-tripped, unknown/truncated/wrong-opcode handling). Full Core.Net.Tests suite: 951 passed, 0 failed, 0 skipped. Release build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
4.4 KiB
C#
113 lines
4.4 KiB
C#
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([]));
|
|
}
|
|
}
|