acdream/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs
Erik e77ebf100f CC2 review fix round: latch scope narrowed, AD-100, creationFailed reason key
F1 (MEDIUM): the correlation-latch docs claimed replies are never
misattributed; in truth an overlapping send OVERWRITES the latch and the
first reply routes to the newest request's event. Narrowed all three doc
sites to the exact contract (single outstanding request; overlap refusal
is CC3's Runtime verification gate, retail's DoFinish UNDEF-state rule)
and pinned the overwrite behavior with
OverlappingSend_OverwritesTheLatch_ReplyRoutesToNewestRequest.

F2 (LOW): filed register AD-100 for the drop-unless-armed deviation —
retail's Handle_CharGenVerificationResponse@0x0055E8B0 has no armed gate
and processes whatever arrives against its persistent verification state.

F3 (LOW): doc note in CharacterCreate.cs — ACE double-sends NameInUse
(IsCharacterNameAvailable runs twice; the first callback's return exits
only the lambda), so the second reply hitting the drop path during a
connected gate is EXPECTED, not a defect.

F4 (LOW): creationFailed's enum-member key renamed name -> reason and the
ATTEMPTED character name added as name, before any consumer shipped —
one status vocabulary must not give the same key two meanings
(characterCreated.name is a character name). Contract, writer, tailer,
and shape-pinning tests updated in lockstep.

F5 (LOW): the thread-id probe-note pointer now cites
ProbeNetLogOutbound's doc comment, where the note actually lives.

Fidelity fold (reviewer's positive note): the latch is retail's OWN
discriminator one layer down — 0x0055E8B0 case 1 branches on
GetVerificationState()==PENDING (create) vs not (restore) — now cited in
both the latch doc and CharGenVerificationResponse.cs.

Core.Net 994, Runtime 1667, Launcher.Core 324, all green Release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:10:24 +02:00

152 lines
6.3 KiB
C#

using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Shared parser for opcode <c>0xF643</c> — retail's
/// <c>CharacterGenerationVerificationResponse</c> shape, which BOTH
/// <see cref="CharacterRestore"/> (opcode <c>0xF7D9</c> request) and
/// <see cref="CharacterCreate"/> (opcode <c>0xF656</c> request) receive on
/// the exact same wire opcode — a genuine retail opcode reuse, confirmed by
/// ACE's own <c>GameMessageOpcode.cs</c> declaring both
/// <c>CharacterCreateResponse = 0xF643</c> and
/// <c>CharacterRestoreResponse = 0xF643, // This is a duplicate...</c>.
///
/// <para>
/// <b>Campaign CC CC2:</b> this type is the promotion of the parse logic
/// that used to live only in <see cref="CharacterRestore.Parse"/> (Campaign
/// LA slice LA7a). Character creation now exists (<see cref="CharacterCreate"/>),
/// so the two message families that collide on this opcode are both real and
/// both need it — <see cref="CharacterRestore"/> keeps its own
/// <see cref="CharacterRestore.Parsed"/> shape for source compatibility and
/// delegates to this type internally; new code (the create response,
/// <c>WorldSession.CharacterCreateResponseReceived</c>) consumes
/// <see cref="Parsed"/> directly. A caller cannot tell "restore response"
/// from "create response" by opcode or shape alone — <c>WorldSession</c>
/// disambiguates by tracking which outbound request (restore vs. create) it
/// is awaiting a reply to (see <c>WorldSession</c>'s awaiting-request latch).
/// That latch is not merely a reasonable design — it is retail's OWN
/// mechanism: <c>Handle_CharGenVerificationResponse@0x0055E8B0</c> case 1
/// branches on the client's persistent chargen state,
/// <c>GetVerificationState() == PENDING</c> → new <c>CharacterIdentity</c>
/// + <c>AddIdentity</c> (a create it initiated), else → unpack into the
/// existing identity at <c>slot</c> (a restore). Same discriminator, one
/// layer down (CC2 review's fidelity note).
/// </para>
///
/// <para>
/// Wire layout, verbatim from ACE's <c>GameMessageCharacterCreateResponse.cs</c>
/// / <c>GameMessageCharacterRestore.cs</c> (both write the identical shape)
/// and cross-checked against holtburger's
/// <c>CharacterCreateResponseData::unpack</c>
/// (<c>holtburger-protocol/src/messages/character/types.rs:379-410</c>):
/// </para>
///
/// <code>
/// u32 opcode (0xF643)
/// u32 code (CharacterGenerationVerificationResponse)
/// -- only when code == Ok --
/// u32 guid
/// String16L name
/// u32 secondsGreyedOut
/// </code>
///
/// <para>
/// <see cref="Code"/> is a verbatim port of ACE's
/// <c>CharacterGenerationVerificationResponse</c> enum
/// (<c>ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs</c>),
/// which is itself retail's own dialog dispatch table
/// (<c>Handle_CharGenVerificationResponse@0x0055E8B0</c>): <c>NameInUse</c> →
/// <c>ID_Character_Err_NameReserved</c>, <c>NameBanned</c> →
/// <c>ID_Character_Err_NameBanned</c>, <c>Corrupt</c>/<c>DatabaseDown</c> →
/// <c>ID_Character_Err_NameDBDown</c>, <c>AdminPrivilegeDenied</c> →
/// <c>ID_Character_Err_NameAdminDenied</c>. <c>Pending</c>/<c>Undef</c>
/// retail treats as a silent state reset with no dialog — notably ACE sends
/// <c>Pending</c> for a disabled-Olthoi rejection
/// (<c>CharacterHandler.CharacterCreateEx</c>,
/// <c>olthoi_play_disabled</c> branch), so that specific rejection is
/// invisible to the retail-faithful client too; this is a retail quirk to
/// port as-is, not a bug to fix. Dialog presentation itself is CC5's job
/// (App layer), not this Core.Net type's.
/// </para>
/// </summary>
public static class CharGenVerificationResponse
{
public const uint ResponseOpcode = 0xF643u;
/// <summary>
/// Verbatim port of ACE's <c>CharacterGenerationVerificationResponse</c>
/// enum, which is retail's own <c>Handle_CharGenVerificationResponse</c>
/// dispatch table.
/// </summary>
public enum Code : uint
{
Undef = 0,
Ok = 1,
Pending = 2,
NameInUse = 3,
NameBanned = 4,
Corrupt = 5,
DatabaseDown = 6,
AdminPrivilegeDenied = 7,
}
/// <summary>
/// Parsed <c>0xF643</c> body. <see cref="Guid"/>, <see cref="Name"/>, and
/// <see cref="SecondsGreyedOut"/> are only populated when
/// <see cref="RawCode"/> equals <see cref="Code.Ok"/> — retail omits
/// them entirely on the wire otherwise (both
/// <c>GameMessageCharacterCreateResponse</c> and
/// <c>GameMessageCharacterRestore</c> gate the trailing fields on
/// <c>response == ... .Ok</c>).
/// </summary>
public readonly record struct Parsed(
uint RawCode,
uint? Guid,
string? Name,
uint? SecondsGreyedOut)
{
/// <summary>
/// Best-effort named view of <see cref="RawCode"/>. A plain enum
/// cast never throws in C#, so this is safe even for a value retail
/// never defined — always trust <see cref="RawCode"/> as the source
/// of truth.
/// </summary>
public Code AsCode => (Code)RawCode;
/// <summary>True when the trailing identity fields are present.</summary>
public bool IsOk => RawCode == (uint)Code.Ok;
}
/// <summary>
/// Parse a <c>0xF643</c> body. <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 CharacterGenerationVerificationResponse opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}");
uint rawCode = ReadU32(body, ref pos);
if (rawCode != (uint)Code.Ok)
return new Parsed(rawCode, 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(rawCode, 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;
}
}