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:
parent
7a839cba71
commit
6a32f37589
6 changed files with 811 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([]));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue