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
82
src/AcDream.Core.Net/Messages/CharacterDelete.cs
Normal file
82
src/AcDream.Core.Net/Messages/CharacterDelete.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Packets;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail character-delete request and server acknowledgement, both riding
|
||||
/// opcode <c>0xF655</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout ported from retail <c>Proto_UI::SendDeleteCharacter</c> at
|
||||
/// <c>0x00546b30</c>: the opcode, then <c>AC1Legacy::PStringBase<char>::Pack</c>
|
||||
/// of the account id as a String16L, then a trailing u32 written directly
|
||||
/// after the packed string (<c>*(uint32_t*)var_4 = arg2</c>):
|
||||
/// </para>
|
||||
///
|
||||
/// <code>
|
||||
/// u32 opcode (0xF655)
|
||||
/// String16L accountName
|
||||
/// u32 characterSlot (NOT the character guid)
|
||||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// The caller, <c>CPlayerSystem::DeleteCharacter</c> at <c>0x0055f830</c>,
|
||||
/// resolves that trailing u32 from the target character's guid via
|
||||
/// <c>CharacterSet::GetSlot(persistentData + 4, guid)</c> before sending —
|
||||
/// retail deletes by **account + SLOT INDEX**, never the character guid.
|
||||
/// This builder takes the already-resolved slot; resolving a selected
|
||||
/// character to its slot is Runtime selection-state work (Campaign LA
|
||||
/// slice LA7b), not this file's job.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The server's acknowledgement reuses the same opcode with no trailing
|
||||
/// payload — ACE's <c>GameMessageCharacterDelete</c> constructs a bare
|
||||
/// 4-byte body
|
||||
/// (<c>ACE.Server/Network/GameMessages/Messages/GameMessageCharacterDelete.cs</c>,
|
||||
/// base constructor called with <c>bodyLength: 4</c> and no further
|
||||
/// <c>Writer.Write</c> calls). holtburger's inbound dispatcher
|
||||
/// (<c>holtburger-protocol/src/messages/game_message/unpack.rs:50-58</c>)
|
||||
/// disambiguates request vs. ack the identical way we do here — a request
|
||||
/// has bytes remaining after the opcode, the ack does not.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// After the ack, ACE immediately follows with a fresh <see cref="CharacterList"/>
|
||||
/// so the roster reflects the character's new pending-delete state
|
||||
/// (<c>CharacterHandler.CharacterDelete</c>,
|
||||
/// <c>ACE.Server/Network/Handlers/CharacterHandler.cs:322</c>, inside the
|
||||
/// <c>SaveCharacter</c> success callback). Requesting and re-rendering that
|
||||
/// refreshed roster belongs to LA7b's Runtime selection state — this file
|
||||
/// only builds the request and recognizes the ack.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CharacterDelete
|
||||
{
|
||||
public const uint Opcode = 0xF655u;
|
||||
|
||||
/// <summary>
|
||||
/// Build the body bytes for an outbound <c>CharacterDelete</c> request.
|
||||
/// Layout: opcode(4) + String16L(accountName) + characterSlot(4).
|
||||
/// </summary>
|
||||
public static byte[] BuildRequestBody(string accountName, uint characterSlot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(accountName);
|
||||
var w = new PacketWriter(32);
|
||||
w.WriteUInt32(Opcode);
|
||||
w.WriteString16L(accountName);
|
||||
w.WriteUInt32(characterSlot);
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether a complete game-message body is the server's
|
||||
/// delete acknowledgement — the canonical four-byte opcode-only form
|
||||
/// ACE emits. A fresh <see cref="CharacterList"/> follows separately
|
||||
/// and is not this method's concern.
|
||||
/// </summary>
|
||||
public static bool IsAcknowledgement(ReadOnlySpan<byte> body) =>
|
||||
body.Length == sizeof(uint) &&
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue