merge: Campaign LA LA7a — character wire messages (review-closed)

CharacterDelete 0xF655 (account+slot), CharacterRestore 0xF7D9/0xF643
(guid-only adaptation, register AD-97), CharacterError 0xF659 (retail
26-member enum). Opus retail-lens review PASS, narrow re-review MERGE:
6a32f375 + 4338b1c1 + 0c8643a7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 16:07:58 +02:00
commit fa2de1c46e
7 changed files with 877 additions and 0 deletions

View file

@ -0,0 +1,89 @@
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&lt;char&gt;::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>
/// Routing note for LA7b: retail transmits this request via
/// <c>Proto_UI::SendToLogon</c> (the restore request rides
/// <c>SendToControl</c>); ACE sends its acknowledgement and the follow-up
/// refreshed CharacterList on <c>GameMessageGroup.UIQueue</c>.
/// </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;
}

View file

@ -0,0 +1,279 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>CharacterError</c> GameMessage (opcode <c>0xF659</c>) — the
/// server's catch-all failure notice during the pre-world character-select
/// stage (logon conflicts, delete/restore failures, enter-world rejections,
/// subscription state). Today acdream cannot surface ANY character-stage
/// server error to the user; this is the first parser for the family.
///
/// <para>
/// Wire layout confirmed directly from retail's inbound dispatcher,
/// <c>UIQueueManager::ProcessNetBlobData</c> at <c>0x0055b000</c>, which
/// reads a u32 immediately after the opcode and passes it to
/// <c>CPlayerSystem::Handle_CharacterError</c> at <c>0x0055d5d0</c> typed
/// as <c>enum charError</c> (<c>enum charError eax_86 = *(uint32_t*)((char*)ecx + 4);</c>):
/// </para>
///
/// <code>
/// u32 opcode (0xF659)
/// u32 errorCode (enum charError)
/// </code>
///
/// <para>
/// ACE agrees: <c>GameMessageCharacterError</c>
/// (<c>ACE.Server/Network/GameMessages/Messages/GameMessageCharacterError.cs</c>)
/// writes exactly <c>opcode + (uint)error</c>, and every
/// <c>session.SendCharacterError(...)</c> call site in
/// <c>CharacterHandler.cs</c> (the two this slice's <see cref="CharacterDelete"/>
/// / <see cref="CharacterRestore"/> handlers can raise —
/// <c>CharacterError.Delete</c>, <c>CharacterError.LogonServerFull</c>,
/// <c>CharacterError.EnterGameCouldntPlaceCharacter</c>,
/// <c>CharacterError.EnterGameCharacterNotOwned</c> — plus every other
/// value the wider character-stage flow can raise) goes through this same
/// shape.
/// </para>
///
/// <para>
/// <see cref="Code"/> is a verbatim port of retail's <c>enum charError</c>
/// (<c>docs/research/named-retail/acclient.h:4038-4067</c>) — the header's
/// own numeric ground truth, not a subset filtered through ACE's C# port.
/// It is a strict superset of ACE's <c>ACE.Server.Network.Enum.CharacterError</c>
/// (<c>references/ACE/Source/ACE.Server/Network/Enum/CharacterError.cs</c>):
/// retail additionally names 0x2 (<c>LoggedOn</c>), 0x7 (<c>NoPremade</c>),
/// and 0x16 (<c>CharacterIsBooted</c>) — three values ACE omits entirely,
/// none of which ACE's server ever sends but all of which retail's client
/// can receive from a genuine retail server. At 0x8 the port additionally
/// CORRECTS an ACE misnaming: ACE defines 0x8 as <c>ServerCrash2</c> with a
/// doc comment duplicating 0x4's <c>ID_CHAR_ERROR_SERVER_CRASH</c> text,
/// but retail's header names 0x8 <c>CHAR_ERROR_ACCOUNT_IN_USE</c> — the
/// header wins. Per the project's property-enum-divergence lesson, we port
/// the complete oracle, not just what today's one server implementation
/// emits. ACE's per-value doc
/// comments (themselves sourced from the client's <c>ID_CHAR_ERROR_*</c>
/// string table) are folded in below where they exist. One retail member,
/// <c>FORCE_charError_32_BIT = 0x7FFFFFFF</c>, is a compiler
/// storage-width pragma (MSVC's "force this enum to 32-bit backing store"
/// idiom) and not a real wire value — it is deliberately NOT ported.
/// </para>
///
/// <para>
/// Unknown values are never rejected: <see cref="Parsed.RawErrorCode"/>
/// always carries the wire value verbatim, and casting it to
/// <see cref="Code"/> (see <see cref="Parsed.AsCode"/>) can never throw in
/// C# even for a value retail itself never defined — future server
/// revisions or private servers may add codes we haven't named yet.
/// </para>
/// </summary>
public static class CharacterError
{
public const uint Opcode = 0xF659u;
/// <summary>
/// Verbatim port of retail's <c>enum charError</c>
/// (<c>acclient.h:4038-4067</c>), excluding the 32-bit storage-width
/// sentinel <c>FORCE_charError_32_BIT</c>.
/// </summary>
public enum Code : uint
{
/// <summary>0x00 — CHAR_ERROR_UNDEF.</summary>
Undefined = 0x00,
/// <summary>
/// 0x01 — CHAR_ERROR_LOGON. ACE: "Cannot have two accounts logged
/// on at the same time."
/// </summary>
Logon = 0x01,
/// <summary>0x02 — CHAR_ERROR_LOGGED_ON. Retail-only; no ACE member.</summary>
LoggedOn = 0x02,
/// <summary>
/// 0x03 — CHAR_ERROR_ACCOUNT_LOGON. ACE: "Server could not access
/// your account information. Please try again in a few minutes."
/// </summary>
AccountLogon = 0x03,
/// <summary>
/// 0x04 — CHAR_ERROR_SERVER_CRASH. ACE: "The server has
/// disconnected. Please try again in a few minutes."
/// </summary>
ServerCrash = 0x04,
/// <summary>0x05 — CHAR_ERROR_LOGOFF. ACE: "Server could not log off your character."</summary>
Logoff = 0x05,
/// <summary>
/// 0x06 — CHAR_ERROR_DELETE. ACE: "Server could not delete your
/// character." Sent by <see cref="AcDream.Core.Net.Messages.CharacterDelete"/>'s
/// server-side handler on every rejection path.
/// </summary>
Delete = 0x06,
/// <summary>0x07 — CHAR_ERROR_NO_PREMADE. Retail-only; no ACE member.</summary>
NoPremade = 0x07,
/// <summary>
/// 0x08 — CHAR_ERROR_ACCOUNT_IN_USE. ACE misnames this value
/// <c>ServerCrash2</c> (its doc comment duplicates 0x04's text);
/// retail's header is the authority. See the class doc comment.
/// </summary>
AccountInUse = 0x08,
/// <summary>
/// 0x09 — CHAR_ERROR_ACCOUNT_INVALID. ACE: "The account name you
/// specified was not valid."
/// </summary>
AccountInvalid = 0x09,
/// <summary>
/// 0x0A — CHAR_ERROR_ACCOUNT_DOESNT_EXIST. ACE: "The account you
/// specified doesn't exist."
/// </summary>
AccountDoesntExist = 0x0A,
/// <summary>
/// 0x0B — CHAR_ERROR_ENTER_GAME_GENERIC. ACE: forces the player
/// back to character-select if in 3D mode; otherwise a no-op OK
/// popup.
/// </summary>
EnterGameGeneric = 0x0B,
/// <summary>
/// 0x0C — CHAR_ERROR_ENTER_GAME_STRESS_ACCOUNT. ACE: "You cannot
/// enter the game with a stress creating character."
/// </summary>
EnterGameStressAccount = 0x0C,
/// <summary>
/// 0x0D — CHAR_ERROR_ENTER_GAME_CHARACTER_IN_WORLD. ACE: "One of
/// your characters is still in the world. Please try again in a
/// few minutes."
/// </summary>
EnterGameCharacterInWorld = 0x0D,
/// <summary>
/// 0x0E — CHAR_ERROR_ENTER_GAME_PLAYER_ACCOUNT_MISSING. ACE:
/// "Server unable to find player account. Please try again
/// later."
/// </summary>
EnterGamePlayerAccountMissing = 0x0E,
/// <summary>
/// 0x0F — CHAR_ERROR_ENTER_GAME_CHARACTER_NOT_OWNED. ACE: "You do
/// not own this character." Sent by
/// <see cref="AcDream.Core.Net.Messages.CharacterRestore"/>'s
/// server-side handler when the delete grace window has expired.
/// </summary>
EnterGameCharacterNotOwned = 0x0F,
/// <summary>
/// 0x10 — CHAR_ERROR_ENTER_GAME_CHARACTER_IN_WORLD_SERVER. ACE:
/// "One of your characters is currently in the world. Please try
/// again later. This is likely an internal server error."
/// </summary>
EnterGameCharacterInWorldServer = 0x10,
/// <summary>
/// 0x11 — CHAR_ERROR_ENTER_GAME_OLD_CHARACTER. ACE: forces the
/// player back to character-select if in 3D mode; no-op
/// otherwise.
/// </summary>
EnterGameOldCharacter = 0x11,
/// <summary>
/// 0x12 — CHAR_ERROR_ENTER_GAME_CORRUPT_CHARACTER. ACE: "This
/// character's data has been corrupted. Please delete it and
/// create a new character."
/// </summary>
EnterGameCorruptCharacter = 0x12,
/// <summary>
/// 0x13 — CHAR_ERROR_ENTER_GAME_START_SERVER_DOWN. ACE: "This
/// character's starting server is experiencing difficulties.
/// Please try again in a few minutes."
/// </summary>
EnterGameStartServerDown = 0x13,
/// <summary>
/// 0x14 — CHAR_ERROR_ENTER_GAME_COULDNT_PLACE_CHARACTER. ACE:
/// "This character couldn't be placed in the world right now.
/// Please try again in a few minutes." Sent by
/// <see cref="AcDream.Core.Net.Messages.CharacterRestore"/>'s
/// server-side handler during a shutdown-in-progress race.
/// </summary>
EnterGameCouldntPlaceCharacter = 0x14,
/// <summary>
/// 0x15 — CHAR_ERROR_LOGON_SERVER_FULL. ACE: "Sorry, but the
/// Asheron's Call server is full currently. Please try again
/// later." Sent by both
/// <see cref="AcDream.Core.Net.Messages.CharacterDelete"/> and
/// <see cref="AcDream.Core.Net.Messages.CharacterRestore"/>'s
/// server-side handlers when the world is closed to non-advocates.
/// </summary>
LogonServerFull = 0x15,
/// <summary>0x16 — CHAR_ERROR_CHARACTER_IS_BOOTED. Retail-only; no ACE member.</summary>
CharacterIsBooted = 0x16,
/// <summary>
/// 0x17 — CHAR_ERROR_ENTER_GAME_CHARACTER_LOCKED. ACE: "A save of
/// this character is still in progress. Please try again later."
/// </summary>
EnterGameCharacterLocked = 0x17,
/// <summary>
/// 0x18 — CHAR_ERROR_SUBSCRIPTION_EXPIRED. ACE: "Your
/// subscription to this game has expired."
/// </summary>
SubscriptionExpired = 0x18,
/// <summary>
/// 0x19 — CHAR_ERROR_NUM_ERRORS. Retail's own count-of-errors
/// sentinel (the array-bound idiom, one past the last real code) —
/// never sent on the wire as an actual error. Kept for verbatim
/// completeness of the enum range; do not treat a received 0x19
/// as meaningful, and LA7b's error-to-string mapping must not
/// render it as a user-facing message.
/// </summary>
NumErrors = 0x19,
}
public readonly record struct Parsed(uint RawErrorCode)
{
/// <summary>
/// Best-effort named view of <see cref="RawErrorCode"/>. A plain
/// enum cast never throws in C#, so this is safe even for values
/// retail never defined — always trust <see cref="RawErrorCode"/>
/// as the source of truth.
/// </summary>
public Code AsCode => (Code)RawErrorCode;
}
/// <summary>
/// Parse a CharacterError body. <paramref name="body"/> must start
/// with the 4-byte opcode (0xF659).
/// </summary>
public static Parsed Parse(ReadOnlySpan<byte> body)
{
int pos = 0;
uint opcode = ReadU32(body, ref pos);
if (opcode != Opcode)
throw new FormatException($"expected CharacterError opcode 0x{Opcode:X4}, got 0x{opcode:X8}");
uint errorCode = ReadU32(body, ref pos);
return new Parsed(errorCode);
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
pos += 4;
return value;
}
}

View file

@ -0,0 +1,150 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Packets;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Retail character-restore request (opcode <c>0xF7D9</c>) and its response
/// (opcode <c>0xF643</c>).
///
/// <para>
/// <b>Request — guid-only, an ADAPTATION (register row AD-97).</b> Retail
/// really does send more than the guid. The PDB-paired binary at
/// <c>CPlayerSystem::RestoreCharacter@0x0055d760</c> is 26 bytes:
/// <c>push 0x008173B4; push 0x008173B4; push guid;
/// call Proto_UI::SendAdminRestoreCharacter@0x00546cf0</c> — two REAL
/// constant <c>PStringBase&lt;char&gt;*</c> arguments (Binary Ninja renders
/// them as an uninitialized <c>edx</c> local and <c>this</c>; that
/// rendering is the artifact, the two <c>push imm32</c> are not).
/// <c>SendAdminRestoreCharacter</c> packs both
/// (<c>PStringBase::Pack@0x004fc6f0</c> emits ≥4 bytes even for an empty
/// string), so retail's request is ≥16 bytes where ours is 8. We send
/// guid-only because ACE
/// (<c>CharacterHandler.CharacterRestore</c>,
/// <c>ACE.Server/Network/Handlers/CharacterHandler.cs:331-385</c>) reads
/// only <c>ReadUInt32()</c> and ignores any tail, and holtburger
/// (<c>holtburger-protocol/src/messages/character/types.rs::CharacterRestoreRequestData</c>,
/// sent from a real client command path) ships guid-only against ACE
/// successfully. The omitted tail is a recorded retail deviation —
/// divergence register AD-97.
/// </para>
///
/// <para>
/// <b>LA7b hazards.</b> (1) ACE's restore handler has a SILENT no-reply
/// path: an unknown guid hits
/// <c>Characters.SingleOrDefault(...) == null → return;</c> — no 0xF643,
/// no 0xF659. Selection state must never await a restore reply
/// unconditionally. (2) Routing: ACE sends the response on
/// <c>GameMessageGroup.UIQueue</c>; retail transmits the request via
/// <c>Proto_UI::SendToControl</c> (the delete request goes via
/// <c>SendToLogon</c>) — relevant when LA7b picks the outbound queue.
/// </para>
///
/// <code>
/// u32 opcode (0xF7D9)
/// u32 characterGuid
/// </code>
///
/// <para>
/// <b>Response — opcode collision with CharacterCreateResponse.</b> ACE's
/// own <c>GameMessageOpcode.cs</c> declares both
/// <c>CharacterCreateResponse = 0xF643</c> and
/// <c>CharacterRestoreResponse = 0xF643, // This is a duplicate...</c> — a
/// genuine retail opcode reuse, not an ACE bug. <c>GameMessageCharacterRestore</c>
/// (<c>ACE.Server/Network/GameMessages/Messages/GameMessageCharacterRestore.cs</c>)
/// unconditionally writes a success shape:
/// </para>
///
/// <code>
/// u32 opcode (0xF643)
/// u32 verificationFlag (1 = Ok, matching CharacterGenerationVerificationResponse.Ok)
/// u32 characterGuid
/// String16L characterName
/// u32 secondsGreyedOut
/// </code>
///
/// <para>
/// But ACE's <c>CharacterRestore</c> handler can ALSO reply on this same
/// opcode via the character-CREATE response path when restore itself fails
/// — TWO real branches: <c>NameInUse</c> (the freed name collided) and
/// <c>Corrupt</c> (<c>SaveCharacter</c> returned false). Both shapes are
/// flag-only, with NO trailing fields
/// (<c>GameMessageCharacterCreateResponse.cs</c>: the guid / name /
/// trailing u32 are only written <c>if (response == ... .Ok)</c>).
/// <see cref="Parse"/> mirrors that conditionality: the trailing three
/// fields are read only when <c>verificationFlag == 1</c>. Because the two
/// message families are wire-identical when they collide, a caller cannot
/// tell "restore response" from "create response" by opcode or shape
/// alone — it must track which outbound request (this file's
/// <see cref="BuildRequestBody"/> vs. a future CharacterCreate) it is
/// awaiting a reply to. Character creation is out of this campaign's scope
/// (design spec §7 non-goals); this type does not attempt to disambiguate
/// the two families itself.
/// </para>
/// </summary>
public static class CharacterRestore
{
public const uint RequestOpcode = 0xF7D9u;
public const uint ResponseOpcode = 0xF643u;
/// <summary>
/// Restore response body. <see cref="Guid"/>, <see cref="Name"/>, and
/// <see cref="SecondsGreyedOut"/> are only populated when
/// <see cref="VerificationFlag"/> equals 1 (Ok) — retail omits them
/// entirely on the wire otherwise (see the collision note above).
/// </summary>
public readonly record struct Parsed(
uint VerificationFlag,
uint? Guid,
string? Name,
uint? SecondsGreyedOut)
{
/// <summary>True when the trailing character fields are present.</summary>
public bool IsOk => VerificationFlag == 1u;
}
/// <summary>
/// Build the body bytes for an outbound <c>CharacterRestore</c> request.
/// Layout: opcode(4) + characterGuid(4). Guid-only — an adaptation of
/// retail's ≥16-byte shape; see the class doc comment and divergence
/// register AD-97.
/// </summary>
public static byte[] BuildRequestBody(uint characterGuid)
{
var w = new PacketWriter(8);
w.WriteUInt32(RequestOpcode);
w.WriteUInt32(characterGuid);
return w.ToArray();
}
/// <summary>
/// Parse a <c>CharacterRestore</c> response body (opcode <c>0xF643</c>).
/// <paramref name="body"/> must start with the 4-byte opcode.
/// </summary>
public static Parsed Parse(ReadOnlySpan<byte> body)
{
int pos = 0;
uint opcode = ReadU32(body, ref pos);
if (opcode != ResponseOpcode)
throw new FormatException($"expected CharacterRestore response opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}");
uint verificationFlag = ReadU32(body, ref pos);
if (verificationFlag != 1u)
return new Parsed(verificationFlag, null, null, null);
uint guid = ReadU32(body, ref pos);
string name = StringReader.ReadString16L(body, ref pos);
uint secondsGreyedOut = ReadU32(body, ref pos);
return new Parsed(verificationFlag, guid, name, secondsGreyedOut);
}
private static uint ReadU32(ReadOnlySpan<byte> source, ref int pos)
{
if (source.Length - pos < 4) throw new FormatException("truncated u32");
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
pos += 4;
return value;
}
}