The Opus retail-lens review decoded the PDB-paired binary at CPlayerSystem::RestoreCharacter@0x0055d760 and refuted the uninitialized-edx justification: the two extra arguments are real push imm32 of a constant PStringBase (BN mis-renders them, but they pack to >=4 bytes each), so retail 0xF7D9 is >=16 bytes where ours is 8. The guid-only CODE stands (ACE reads only the guid; holtburger consensus) but it is an adaptation, not a corrected decompile — filed as divergence register AD-97 and the doc comment now states the true mechanism. Also from the review: the 0xF643 conditional-parse doc now names BOTH ACE flag-only failure branches (NameInUse + Corrupt); CharacterError 0x08 doc corrected (ACE misnames it ServerCrash2 — the port corrects an ACE misnaming; ACE omits three values, not four); LA7b hazard notes added (ACE silent no-reply on unknown restore guid; retail SendToLogon vs SendToControl routing; NumErrors never rendered); two review-nit tests (flag=0 Undef flag-only, non-Ok body with trailing bytes ignored). Core.Net suite: 953 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
279 lines
11 KiB
C#
279 lines
11 KiB
C#
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;
|
|
}
|
|
}
|