feat(net): Campaign LA LA7a — CharacterDelete/CharacterRestore/CharacterError wire messages

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>
This commit is contained in:
Erik 2026-08-14 15:46:48 +02:00
parent 7a839cba71
commit 6a32f37589
6 changed files with 811 additions and 0 deletions

View file

@ -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([]));
}
}

View 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([]));
}
}

View file

@ -0,0 +1,123 @@
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_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);
}
}