using System.Buffers.Binary; namespace AcDream.Core.Net.Messages; /// /// Inbound CharacterError GameMessage (opcode 0xF659) — 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. /// /// /// Wire layout confirmed directly from retail's inbound dispatcher, /// UIQueueManager::ProcessNetBlobData at 0x0055b000, which /// reads a u32 immediately after the opcode and passes it to /// CPlayerSystem::Handle_CharacterError at 0x0055d5d0 typed /// as enum charError (enum charError eax_86 = *(uint32_t*)((char*)ecx + 4);): /// /// /// /// u32 opcode (0xF659) /// u32 errorCode (enum charError) /// /// /// /// ACE agrees: GameMessageCharacterError /// (ACE.Server/Network/GameMessages/Messages/GameMessageCharacterError.cs) /// writes exactly opcode + (uint)error, and every /// session.SendCharacterError(...) call site in /// CharacterHandler.cs (the two this slice's /// / handlers can raise — /// CharacterError.Delete, CharacterError.LogonServerFull, /// CharacterError.EnterGameCouldntPlaceCharacter, /// CharacterError.EnterGameCharacterNotOwned — plus every other /// value the wider character-stage flow can raise) goes through this same /// shape. /// /// /// /// is a verbatim port of retail's enum charError /// (docs/research/named-retail/acclient.h:4038-4067) — the header's /// own numeric ground truth, not a subset filtered through ACE's C# port. /// It is a strict superset of ACE's ACE.Server.Network.Enum.CharacterError /// (references/ACE/Source/ACE.Server/Network/Enum/CharacterError.cs): /// retail additionally names 0x2 (LoggedOn), 0x7 (NoPremade), /// and 0x16 (CharacterIsBooted) — 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 ServerCrash2 with a /// doc comment duplicating 0x4's ID_CHAR_ERROR_SERVER_CRASH text, /// but retail's header names 0x8 CHAR_ERROR_ACCOUNT_IN_USE — 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 ID_CHAR_ERROR_* /// string table) are folded in below where they exist. One retail member, /// FORCE_charError_32_BIT = 0x7FFFFFFF, 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. /// /// /// /// Unknown values are never rejected: /// always carries the wire value verbatim, and casting it to /// (see ) 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. /// /// public static class CharacterError { public const uint Opcode = 0xF659u; /// /// Verbatim port of retail's enum charError /// (acclient.h:4038-4067), excluding the 32-bit storage-width /// sentinel FORCE_charError_32_BIT. /// public enum Code : uint { /// 0x00 — CHAR_ERROR_UNDEF. Undefined = 0x00, /// /// 0x01 — CHAR_ERROR_LOGON. ACE: "Cannot have two accounts logged /// on at the same time." /// Logon = 0x01, /// 0x02 — CHAR_ERROR_LOGGED_ON. Retail-only; no ACE member. LoggedOn = 0x02, /// /// 0x03 — CHAR_ERROR_ACCOUNT_LOGON. ACE: "Server could not access /// your account information. Please try again in a few minutes." /// AccountLogon = 0x03, /// /// 0x04 — CHAR_ERROR_SERVER_CRASH. ACE: "The server has /// disconnected. Please try again in a few minutes." /// ServerCrash = 0x04, /// 0x05 — CHAR_ERROR_LOGOFF. ACE: "Server could not log off your character." Logoff = 0x05, /// /// 0x06 — CHAR_ERROR_DELETE. ACE: "Server could not delete your /// character." Sent by 's /// server-side handler on every rejection path. /// Delete = 0x06, /// 0x07 — CHAR_ERROR_NO_PREMADE. Retail-only; no ACE member. NoPremade = 0x07, /// /// 0x08 — CHAR_ERROR_ACCOUNT_IN_USE. ACE misnames this value /// ServerCrash2 (its doc comment duplicates 0x04's text); /// retail's header is the authority. See the class doc comment. /// AccountInUse = 0x08, /// /// 0x09 — CHAR_ERROR_ACCOUNT_INVALID. ACE: "The account name you /// specified was not valid." /// AccountInvalid = 0x09, /// /// 0x0A — CHAR_ERROR_ACCOUNT_DOESNT_EXIST. ACE: "The account you /// specified doesn't exist." /// AccountDoesntExist = 0x0A, /// /// 0x0B — CHAR_ERROR_ENTER_GAME_GENERIC. ACE: forces the player /// back to character-select if in 3D mode; otherwise a no-op OK /// popup. /// EnterGameGeneric = 0x0B, /// /// 0x0C — CHAR_ERROR_ENTER_GAME_STRESS_ACCOUNT. ACE: "You cannot /// enter the game with a stress creating character." /// EnterGameStressAccount = 0x0C, /// /// 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." /// EnterGameCharacterInWorld = 0x0D, /// /// 0x0E — CHAR_ERROR_ENTER_GAME_PLAYER_ACCOUNT_MISSING. ACE: /// "Server unable to find player account. Please try again /// later." /// EnterGamePlayerAccountMissing = 0x0E, /// /// 0x0F — CHAR_ERROR_ENTER_GAME_CHARACTER_NOT_OWNED. ACE: "You do /// not own this character." Sent by /// 's /// server-side handler when the delete grace window has expired. /// EnterGameCharacterNotOwned = 0x0F, /// /// 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." /// EnterGameCharacterInWorldServer = 0x10, /// /// 0x11 — CHAR_ERROR_ENTER_GAME_OLD_CHARACTER. ACE: forces the /// player back to character-select if in 3D mode; no-op /// otherwise. /// EnterGameOldCharacter = 0x11, /// /// 0x12 — CHAR_ERROR_ENTER_GAME_CORRUPT_CHARACTER. ACE: "This /// character's data has been corrupted. Please delete it and /// create a new character." /// EnterGameCorruptCharacter = 0x12, /// /// 0x13 — CHAR_ERROR_ENTER_GAME_START_SERVER_DOWN. ACE: "This /// character's starting server is experiencing difficulties. /// Please try again in a few minutes." /// EnterGameStartServerDown = 0x13, /// /// 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 /// 's /// server-side handler during a shutdown-in-progress race. /// EnterGameCouldntPlaceCharacter = 0x14, /// /// 0x15 — CHAR_ERROR_LOGON_SERVER_FULL. ACE: "Sorry, but the /// Asheron's Call server is full currently. Please try again /// later." Sent by both /// and /// 's /// server-side handlers when the world is closed to non-advocates. /// LogonServerFull = 0x15, /// 0x16 — CHAR_ERROR_CHARACTER_IS_BOOTED. Retail-only; no ACE member. CharacterIsBooted = 0x16, /// /// 0x17 — CHAR_ERROR_ENTER_GAME_CHARACTER_LOCKED. ACE: "A save of /// this character is still in progress. Please try again later." /// EnterGameCharacterLocked = 0x17, /// /// 0x18 — CHAR_ERROR_SUBSCRIPTION_EXPIRED. ACE: "Your /// subscription to this game has expired." /// SubscriptionExpired = 0x18, /// /// 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. /// NumErrors = 0x19, } public readonly record struct Parsed(uint RawErrorCode) { /// /// Best-effort named view of . A plain /// enum cast never throws in C#, so this is safe even for values /// retail never defined — always trust /// as the source of truth. /// public Code AsCode => (Code)RawErrorCode; } /// /// Parse a CharacterError body. must start /// with the 4-byte opcode (0xF659). /// public static Parsed Parse(ReadOnlySpan 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 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; } }